mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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);
|
||||
|
||||
Reference in new issue
Block a user