fix(playwright): retry a click-triggered onion handoff, and split the transport helpers out

gotoOnion hardens the navigations a spec issues: four attempts against a
transient-error pattern, backoff from PLAYWRIGHT_ONION_GOTO_RETRIES. A
navigation the page starts itself gets none of that. odoo's SSO button sends
the browser through /auth_oauth/signin to Keycloak's onion, and when that
crossing failed transiently the page parked on chrome-error://chromewebdata/:

    Expected substring: "http://auth.<onion>/realms/infinito.nexus"
    Received string:    "chrome-error://chromewebdata/"

A parked page never changes its URL again, so the existing 60s onion-scaled
poll could not recover it; it only made the failure slower. awaitOnionHandoff
re-issues the handoff under the same attempt budget and backoff, and only while
page.url() starts with chrome-error:// — any other URL re-throws, so a page
that loaded the wrong thing still fails on the first look.

The helper landed in utils/env.js next to gotoOnion, which is the right
neighbourhood and the wrong file: that module promises in its own header to be
"tiny and dependency-free", yet the onion group had grown it to 203 lines and
pulled in playwright, service-gating, timeouts and csp. The group moves to
utils/onion.js and env.js is back to 89 lines with two imports.

Moving it folded two duplications the split made visible: the onion-URL test
was written out twice, and the attempt-count expression three times. They are
now isOnionUrl() and onionAttempts().

Nothing imports utils/env directly, only the aggregator spreads it, so the
public surface is unchanged — verified by requiring the aggregator and
resolving every moved name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-03 01:19:53 +02:00
1 parent 118e2f50a4
commit b6c0f5e1a2
4 files changed
+140 -99

No files matched your search

@@ -7,8 +7,6 @@
const { test } = require("@playwright/test");
const { isServiceEnabled } = require("../../service-gating");
const { resolveTimeout } = require("../../timeouts");
const { installCspHeaderRecorder } = require("./csp");
function decodeDotenvQuoted(value) {
if (typeof value !== "string" || value.length < 2) return value;
@@ -30,86 +28,6 @@ function readEnv(name) {
}
/** True when the role under test is served over a `.onion` (Tor) canonical domain. */
function isOnionCanonical() {
return /\.onion$/i.test(readEnv("CANONICAL_DOMAIN").trim());
}
/**
* Tor-resilient `page.goto`: retries only transient Tor-transport errors;
* real navigation failures re-throw on the first hit. Clearnet URLs get a
* single attempt; callers budget the test timeout for the onion retries.
*/
const _ONION_TRANSIENT_RE =
/ERR_TIMED_OUT|ERR_SOCKS|ERR_CONNECTION_(?:CLOSED|RESET|FAILED)|ERR_PROXY_CONNECTION_FAILED|ERR_EMPTY_RESPONSE|ERR_TUNNEL_CONNECTION_FAILED|NS_ERROR_NET_(?:TIMEOUT|RESET|INTERRUPT)|NS_ERROR_(?:CONNECTION_REFUSED|UNKNOWN_HOST|PROXY_CONNECTION_REFUSED|UNKNOWN_PROXY_HOST)|NS_BINDING_ABORTED|Load request cancelled|page\.goto: Timeout \d+ms exceeded/i;
async function gotoOnion(page, url, opts = {}) {
installCspHeaderRecorder(page);
const isRelative = /^\/(?!\/)/.test(url);
const isOnion =
/\.onion(?::\d+)?(?:\/|$|\?)/i.test(url) || (isRelative && isOnionCanonical());
const attempts = isOnion ? Number(process.env.PLAYWRIGHT_ONION_GOTO_RETRIES) || 4 : 1;
const gotoOpts = { ...opts };
if (isOnion && gotoOpts.timeout === undefined) {
gotoOpts.timeout = Number(process.env.PLAYWRIGHT_NAVIGATION_TIMEOUT) || 60_000;
}
// Heavy SPAs (Element) fetch 30+ chunked JS bundles; over Tor each request
// serialises circuit latency, so the `load` event (every lazy subresource)
// can exceed the navigation cap. `domcontentloaded` returns after the HTML
// parses; the caller's explicit selector waits (onion-scaled) cover app boot.
if (isOnion && gotoOpts.waitUntil === undefined) {
gotoOpts.waitUntil = "domcontentloaded";
}
let lastErr;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await page.goto(url, gotoOpts);
} catch (err) {
lastErr = err;
if (attempt >= attempts || !_ONION_TRANSIENT_RE.test(String(err && err.message))) {
throw err;
}
await page.waitForTimeout(resolveTimeout(2_000 * attempt));
}
}
throw lastErr;
}
/**
* Tor-resilient `request.fetch` for any APIRequestContext (the standalone
* fixture or `context.request`); `opts.method` picks the verb. Its SOCKS
* CONNECT goes through the bundled `socks` client whose 30s connect cap is not
* configurable, so a cold onion circuit fails the request no matter how large
* the request timeout is. Retries only transient proxy/socket errors; clearnet
* URLs get a single attempt and real HTTP failures re-throw. `apiGetOnion` is
* the GET shorthand.
*/
const _API_TRANSIENT_RE =
/Proxy connection timed out|Socks5 proxy rejected connection|Socket closed|socket hang up|ECONNRESET|ETIMEDOUT|ECONNREFUSED|ENOTFOUND/i;
function apiGetOnion(request, url, opts = {}) {
return apiFetchOnion(request, url, { ...opts, method: "GET" });
}
async function apiFetchOnion(request, url, opts = {}) {
const isRelative = /^\/(?!\/)/.test(url);
const isOnion =
/\.onion(?::\d+)?(?:\/|$|\?)/i.test(url) || (isRelative && isOnionCanonical());
const attempts = isOnion ? Number(process.env.PLAYWRIGHT_ONION_GOTO_RETRIES) || 4 : 1;
let lastErr;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await request.fetch(url, opts);
} catch (err) {
lastErr = err;
if (attempt >= attempts || !_API_TRANSIENT_RE.test(String(err && err.message))) {
throw err;
}
await new Promise((resolve) => setTimeout(resolve, resolveTimeout(2_000 * attempt)));
}
}
throw lastErr;
}
/**
* Tolerant variant of `skipUnlessServiceEnabled`: treats an unknown
* service (i.e. one whose `<NAME>_SERVICE_ENABLED` flag is not declared
@@ -165,10 +83,6 @@ module.exports = {
decodeDotenvQuoted,
normalizeUrl,
readEnv,
isOnionCanonical,
gotoOnion,
apiGetOnion,
apiFetchOnion,
safeSkipUnlessEnabled,
safeIsEnabled,
recordVideoOptions,
@@ -13,6 +13,7 @@
*/
const env = require("./env");
const onion = require("./onion");
const tls = require("./tls");
const keycloak = require("./keycloak");
const logout = require("./logout");
@@ -25,6 +26,7 @@ const seaweedfs = require("./seaweedfs");
module.exports = {
...env,
...onion,
...tls,
...keycloak,
...logout,
@@ -0,0 +1,129 @@
/**
* Tor-resilient navigation and request helpers.
*/
const { resolveTimeout } = require("../../timeouts");
const { installCspHeaderRecorder } = require("./csp");
const { readEnv } = require("./env");
function isOnionCanonical() {
return /\.onion$/i.test(readEnv("CANONICAL_DOMAIN").trim());
}
function isOnionUrl(url) {
const isRelative = /^\/(?!\/)/.test(url);
return (
/\.onion(?::\d+)?(?:\/|$|\?)/i.test(url) || (isRelative && isOnionCanonical())
);
}
function onionAttempts(isOnion) {
return isOnion ? Number(process.env.PLAYWRIGHT_ONION_GOTO_RETRIES) || 4 : 1;
}
/**
* Tor-resilient `page.goto`: retries only transient Tor-transport errors;
* real navigation failures re-throw on the first hit. Clearnet URLs get a
* single attempt; callers budget the test timeout for the onion retries.
*/
const _ONION_TRANSIENT_RE =
/ERR_TIMED_OUT|ERR_SOCKS|ERR_CONNECTION_(?:CLOSED|RESET|FAILED)|ERR_PROXY_CONNECTION_FAILED|ERR_EMPTY_RESPONSE|ERR_TUNNEL_CONNECTION_FAILED|NS_ERROR_NET_(?:TIMEOUT|RESET|INTERRUPT)|NS_ERROR_(?:CONNECTION_REFUSED|UNKNOWN_HOST|PROXY_CONNECTION_REFUSED|UNKNOWN_PROXY_HOST)|NS_BINDING_ABORTED|Load request cancelled|page\.goto: Timeout \d+ms exceeded/i;
async function gotoOnion(page, url, opts = {}) {
installCspHeaderRecorder(page);
const isOnion = isOnionUrl(url);
const attempts = onionAttempts(isOnion);
const gotoOpts = { ...opts };
if (isOnion && gotoOpts.timeout === undefined) {
gotoOpts.timeout = Number(process.env.PLAYWRIGHT_NAVIGATION_TIMEOUT) || 60_000;
}
// Heavy SPAs (Element) fetch 30+ chunked JS bundles; over Tor each request
// serialises circuit latency, so the `load` event (every lazy subresource)
// can exceed the navigation cap. `domcontentloaded` returns after the HTML
// parses; the caller's explicit selector waits (onion-scaled) cover app boot.
if (isOnion && gotoOpts.waitUntil === undefined) {
gotoOpts.waitUntil = "domcontentloaded";
}
let lastErr;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await page.goto(url, gotoOpts);
} catch (err) {
lastErr = err;
if (attempt >= attempts || !_ONION_TRANSIENT_RE.test(String(err && err.message))) {
throw err;
}
await page.waitForTimeout(resolveTimeout(2_000 * attempt));
}
}
throw lastErr;
}
/**
* Wait for a click-triggered navigation to land on *expected*, retrying the
* handoff while the browser is parked on a failed navigation.
*
* Args:
* page: the Playwright page.
* expected: substring the target URL must contain.
* reissue: async callback that triggers the navigation again.
* opts.timeout: per-attempt budget, already onion-scaled by the caller.
*/
async function awaitOnionHandoff(page, expected, reissue, opts = {}) {
const attempts = onionAttempts(true);
const timeout = opts.timeout || resolveTimeout(60_000);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await page.waitForURL((url) => String(url).includes(expected), { timeout });
return;
} catch (err) {
if (attempt >= attempts || !page.url().startsWith("chrome-error://")) {
throw err;
}
await page.waitForTimeout(resolveTimeout(2_000 * attempt));
await reissue();
}
}
}
/**
* Tor-resilient `request.fetch` for any APIRequestContext (the standalone
* fixture or `context.request`); `opts.method` picks the verb. Its SOCKS
* CONNECT goes through the bundled `socks` client whose 30s connect cap is not
* configurable, so a cold onion circuit fails the request no matter how large
* the request timeout is. Retries only transient proxy/socket errors; clearnet
* URLs get a single attempt and real HTTP failures re-throw. `apiGetOnion` is
* the GET shorthand.
*/
const _API_TRANSIENT_RE =
/Proxy connection timed out|Socks5 proxy rejected connection|Socket closed|socket hang up|ECONNRESET|ETIMEDOUT|ECONNREFUSED|ENOTFOUND/i;
function apiGetOnion(request, url, opts = {}) {
return apiFetchOnion(request, url, { ...opts, method: "GET" });
}
async function apiFetchOnion(request, url, opts = {}) {
const attempts = onionAttempts(isOnionUrl(url));
let lastErr;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await request.fetch(url, opts);
} catch (err) {
lastErr = err;
if (attempt >= attempts || !_API_TRANSIENT_RE.test(String(err && err.message))) {
throw err;
}
await new Promise((resolve) => setTimeout(resolve, resolveTimeout(2_000 * attempt)));
}
}
throw lastErr;
}
module.exports = {
isOnionCanonical,
isOnionUrl,
gotoOnion,
awaitOnionHandoff,
apiGetOnion,
apiFetchOnion,
};
@@ -1,7 +1,7 @@
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, isVisible, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, gotoOnion } = require("./personas");
const { awaitOnionHandoff, decodeDotenvQuotedValue, isVisible, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, gotoOnion } = require("./personas");
const { isServiceEnabled } = require("./service-gating");
require("./test-seaweedfs");
@@ -110,12 +110,10 @@ test("odoo: admin sso login, verify ui, logout", async ({ page }) => {
await clickOdooSsoButton(page);
// 3. After clicking SSO, the page navigates to Keycloak.
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate to Keycloak for authentication"
})
.toContain(oidcIssuerUrl.replace(/\/$/, ""));
await awaitOnionHandoff(page, oidcIssuerUrl.replace(/\/$/, ""), async () => {
await gotoOnion(page, odooLoginUrl);
await clickOdooSsoButton(page);
});
// 4. Perform OIDC login with admin credentials
await performKeycloakLoginForm(page, adminUsername, adminPassword);
@@ -172,12 +170,10 @@ test("odoo: biber sso login, verify ui, logout", async ({ page }) => {
await clickOdooSsoButton(page);
// 3. Wait for navigation to Keycloak
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: "Expected page to navigate to Keycloak for authentication"
})
.toContain(oidcIssuerUrl.replace(/\/$/, ""));
await awaitOnionHandoff(page, oidcIssuerUrl.replace(/\/$/, ""), async () => {
await gotoOnion(page, odooLoginUrl);
await clickOdooSsoButton(page);
});
// 4. Perform OIDC login with biber credentials
await performKeycloakLoginForm(page, biberUsername, biberPassword);