/** * Env-handling utilities shared by every persona-flow module. * * Kept tiny and dependency-free so each module can `require` only * what it needs. */ 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; if (!(value.startsWith('"') && value.endsWith('"'))) return value; const encoded = value.slice(1, -1); try { return JSON.parse(`"${encoded}"`).replace(/\$\$/g, "$"); } catch { return encoded.replace(/\$\$/g, "$"); } } function normalizeUrl(value) { return decodeDotenvQuoted(value || "").replace(/\/$/, ""); } function readEnv(name) { return decodeDotenvQuoted(process.env[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 `_SERVICE_ENABLED` flag is not declared * in the role's env registry) as "disabled" rather than a hard fail. * Roles MAY mark a service entry with `# nocheck: playwright-service-flag` * in `meta/services.yml`, in which case the env flag is not rendered * and the gate MUST skip cleanly. */ function safeSkipUnlessEnabled(name) { let enabled; try { enabled = isServiceEnabled(name); } catch { enabled = false; } if (!enabled) { test.skip(true, `${name.toUpperCase()}_SERVICE_ENABLED=false or unknown`); } } function safeIsEnabled(name) { try { return isServiceEnabled(name); } catch { return false; } } /** * Video-recording options for a hand-built browser context. * * Playwright applies the project's `use.video` setting only to the context the * `page` fixture provides, so a context from `browser.newContext()` records * nothing and its spec reaches the artefact with a trace but no video. Spread * the result into the `newContext` options to follow the project setting. * * Args: * testInfo: the spec's TestInfo; supplies the per-test output directory and * the resolved project config. * * Returns: * `{ recordVideo: { dir } }` when the project asks for video, else `{}`. */ function recordVideoOptions(testInfo) { const configured = testInfo?.project?.use?.video; const mode = configured && typeof configured === "object" ? configured.mode : configured; if (!mode || mode === "off") return {}; return { recordVideo: { dir: testInfo.outputDir } }; } module.exports = { decodeDotenvQuoted, normalizeUrl, readEnv, isOnionCanonical, gotoOnion, apiGetOnion, apiFetchOnion, safeSkipUnlessEnabled, safeIsEnabled, recordVideoOptions, };