Files

176 lines
6.2 KiB
JavaScript

/**
* 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 `<NAME>_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,
};