文件
hyb-oa/scripts/qa/browser-environment-probe.mjs
T

227 行
13 KiB
JavaScript

#!/usr/bin/env node
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { atomicEnvironmentJson, namespaceFingerprint, verifyManagedEnvironment } from "./full-local-environment.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export function guestProbeFailures(observed) {
const failures = [];
if (!observed.freshContext || observed.initialSessionHint !== false) failures.push("context_not_fresh");
if (!observed.listenerBeforeNavigation || !observed.noRouteMocks) failures.push("observation_setup_invalid");
if (!observed.loginFormVisible) failures.push("login_form_not_observed");
if (observed.refreshRequestCount !== 0) failures.push("refresh_attempt_observed");
const samples = [observed.nativeOnlineSamples?.beforeNavigation, observed.nativeOnlineSamples?.documentStart, observed.nativeOnlineSamples?.afterForm];
if (samples.some(value => value !== true && value !== false)) failures.push("native_online_observation_missing");
if (samples.includes(false)) failures.push("native_online_observed_false");
if (observed.loginRequestCount !== 0) failures.push("unexpected_login_request");
return failures;
}
export async function observeGuestEntry(context, baseURL, timeout = 30000) {
const target = new URL("/login", baseURL);
if (target.protocol !== "http:" || target.hostname !== "127.0.0.1") throw new Error("environment_probe_requires_loopback");
const state = await context.storageState();
const observed = {
scope: "environment-probe", observationPoint: "guest-entry-black-box",
freshContext: state.cookies.length === 0 && state.origins.length === 0,
initialSessionHint: state.cookies.some(cookie => cookie.name === "has_session" && cookie.value === "1"),
listenerBeforeNavigation: false, noRouteMocks: true, loginFormVisible: false,
refreshRequestCount: 0, loginRequestCount: 0, refreshFailedCount: 0,
nativeOnlineSamples: { beforeNavigation: null, documentStart: null, afterForm: null },
documentPath: "/login", startedAt: new Date().toISOString(), valid: false,
};
const page = await context.newPage();
try {
page.on("request", request => {
const pathname = new URL(request.url()).pathname;
if (pathname === "/api/auth/refresh") observed.refreshRequestCount += 1;
if (pathname === "/api/auth/login") observed.loginRequestCount += 1;
});
page.on("requestfailed", request => { if (new URL(request.url()).pathname === "/api/auth/refresh") observed.refreshFailedCount += 1; });
page.on("console", message => {
if (observed.nativeOnlineSamples.documentStart !== null) return;
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:true") observed.nativeOnlineSamples.documentStart = true;
if (message.text() === "QA_ENV_DOCUMENT_ONLINE:false") observed.nativeOnlineSamples.documentStart = false;
});
await page.addInitScript(() => { if (window === window.top) console.info(`QA_ENV_DOCUMENT_ONLINE:${navigator.onLine}`); });
observed.listenerBeforeNavigation = true;
observed.nativeOnlineSamples.beforeNavigation = await page.evaluate(() => navigator.onLine);
if (!observed.freshContext || observed.nativeOnlineSamples.beforeNavigation !== true) throw new Error("environment_guest_precondition_invalid");
const response = await page.goto(target.href, { waitUntil: "load", timeout });
observed.documentStatus = response?.status() ?? null;
for (const testId of ["login-identifier", "login-password", "login-submit"]) await page.getByTestId(testId).waitFor({ state: "visible", timeout });
observed.loginFormVisible = true;
observed.nativeOnlineSamples.afterForm = await page.evaluate(() => navigator.onLine);
observed.stayedOnLogin = new URL(page.url()).pathname === "/login";
observed.failures = guestProbeFailures(observed);
if (observed.documentStatus !== 200 || !observed.stayedOnLogin) observed.failures.push("candidate_login_document_invalid");
observed.valid = observed.failures.length === 0;
} catch (error) {
observed.failures = guestProbeFailures(observed);
observed.errorKind = error.name === "TimeoutError" ? "probe_timeout" : "probe_precondition_or_navigation_failed";
} finally {
observed.endedAt = new Date().toISOString();
await page.close();
}
return observed;
}
// Browser errors may include executable paths, websocket endpoints and process
// environments. Persist only classifications, never the message or stack.
function nativeFailure(error, stage) {
const message = typeof error?.message === "string" ? error.message : "";
const failure = { stage, errorKind: "browser_operation_failed" };
if (message === "environment_browser_namespace_mismatch") failure.errorKind = message;
else if (error?.name === "TimeoutError" || /Timeout \d+ms exceeded/i.test(message)) failure.errorKind = "probe_timeout";
else if (/error while loading shared libraries|cannot open shared object file|host system is missing dependencies/i.test(message)) failure.errorKind = "browser_dependency_missing";
else if (/executable doesn.t exist|spawn .* ENOENT/i.test(message)) failure.errorKind = "browser_executable_missing";
else if (/permission denied|operation not permitted|EACCES|EPERM/i.test(message)) failure.errorKind = "browser_permission_denied";
else if (/Target .*closed|browser.*closed|process did exit/i.test(message)) failure.errorKind = "browser_process_closed";
if (["ENOENT", "EACCES", "EPERM", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].includes(error?.code)) failure.systemCode = error.code;
const exit = /<process did exit: exitCode=(null|-?\d+), signal=(null|SIG[A-Z0-9]+)>/.exec(message);
if (exit) {
failure.processExitCode = exit[1] === "null" ? null : Number(exit[1]);
// Only standard names; do not echo arbitrary text resembling a signal.
if (["null", "SIGABRT", "SIGSEGV", "SIGILL", "SIGBUS", "SIGKILL", "SIGTERM", "SIGTRAP"].includes(exit[2])) failure.processSignal = exit[2] === "null" ? null : exit[2];
}
return failure;
}
export async function observeNativeEngine(engine, name, owner, { fingerprint = namespaceFingerprint, tracePath, observeSibling } = {}) {
const result = { name, version: null, valid: false, online: null, browserNamespace: null, browserMatchesOwnedChild: false, noNavigatorOverride: true, failures: [] };
let stage = "launch";
let server;
let browser;
let context;
let tracing = false;
const fail = failure => {
result.valid = false;
result.failures.push(failure);
result.errorKind ??= failure.errorKind;
result.failureStage ??= failure.stage;
};
try {
server = await engine.launchServer({ headless: true, host: "127.0.0.1" });
stage = "namespace";
result.browserNamespace = fingerprint(server.process().pid);
result.browserMatchesOwnedChild = result.browserNamespace === owner.ownedNamespace;
if (!result.browserMatchesOwnedChild) throw new Error("environment_browser_namespace_mismatch");
stage = "connect";
browser = await engine.connect(server.wsEndpoint());
stage = "version";
result.version = browser.version();
stage = "context";
context = await browser.newContext({ serviceWorkers: "block" });
if (tracePath) {
stage = "trace_start";
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
tracing = true;
}
stage = "page";
const page = await context.newPage();
stage = "online";
const observedOnline = await page.evaluate(() => navigator.onLine);
result.online = typeof observedOnline === "boolean" ? observedOnline : null;
result.valid = result.online === true;
if (!result.valid) fail({ stage, errorKind: result.online === false ? "native_online_observed_false" : "native_online_observation_missing" });
// Retain the existing successful lifecycle: Chromium is still alive while
// WebKit is observed, so diagnostics do not silently change that condition.
stage = "sibling";
await observeSibling?.();
} catch (error) {
fail(nativeFailure(error, stage));
} finally {
for (const [cleanupStage, close] of [
["trace_stop", tracing ? () => context.tracing.stop({ path: tracePath }) : null],
["context_close", context ? () => context.close() : null],
["browser_close", browser ? () => browser.close() : null],
["server_close", server ? () => server.close() : null],
]) {
if (!close) continue;
try { await close(); }
catch (error) {
fail(nativeFailure(error, cleanupStage));
if (cleanupStage === "server_close") {
try { await server.kill(); }
catch (killError) { fail(nativeFailure(killError, "server_kill")); }
}
}
}
}
return result;
}
export async function observeNativeProbe({ chromium, webkit }, owner, { fingerprint = namespaceFingerprint } = {}) {
let webkitResult;
const observeWebkit = async () => {
webkitResult = await observeNativeEngine(webkit, "webkit", owner, { fingerprint, tracePath: path.join(owner.environmentDir, "native-webkit-trace.zip") });
};
const chromiumResult = await observeNativeEngine(chromium, "chromium", owner, {
fingerprint, tracePath: path.join(owner.environmentDir, "native-trace.zip"), observeSibling: observeWebkit,
});
if (!webkitResult) await observeWebkit();
const engines = [chromiumResult, webkitResult];
return {
valid: engines.every(engine => engine.valid), scope: "blank-page-native-probe",
online: chromiumResult.online, browserNamespace: chromiumResult.browserNamespace,
browserMatchesOwnedChild: chromiumResult.browserMatchesOwnedChild, noNavigatorOverride: true,
errorKind: engines.every(engine => engine.valid) ? null : "native_engine_probe_failed", engines,
};
}
export async function recordNativeProbe(engines, owner, { verify = verifyManagedEnvironment, write = atomicEnvironmentJson, fingerprint = namespaceFingerprint } = {}) {
const result = await observeNativeProbe(engines, owner, { fingerprint });
try { verify(); }
catch (error) {
result.valid = false;
result.errorKind ??= "environment_recheck_failed";
result.environmentFailure = nativeFailure(error, "environment_recheck");
}
write(path.join(owner.environmentDir, "native-online.json"), result);
return result;
}
async function main(mode, baseURL) {
if (!["native", "guest"].includes(mode)) throw new Error("environment_probe_mode_invalid");
const { context: owner } = verifyManagedEnvironment();
const require = createRequire(path.join(root, "client/package.json"));
const { chromium, webkit } = require("@playwright/test");
if (mode === "native") {
const result = await recordNativeProbe({ chromium, webkit }, owner);
console.log(`[environment] native probe ${result.valid ? "PASS" : "FAIL"}`);
if (!result.valid) process.exitCode = 1;
return;
}
const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
let context;
let tracing = false;
let result = { valid: false, scope: "environment-probe", unknown: true };
const output = path.join(owner.environmentDir, mode === "native" ? "native-online.json" : "guest-entry.json");
try {
const browserNamespace = namespaceFingerprint(server.process().pid);
const browserMatchesOwnedChild = browserNamespace === owner.ownedNamespace;
if (!browserMatchesOwnedChild) throw new Error("environment_browser_namespace_mismatch");
browser = await chromium.connect(server.wsEndpoint());
context = await browser.newContext({ serviceWorkers: "block" });
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
tracing = true;
result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
verifyManagedEnvironment();
} catch (error) {
result.valid = false;
result.errorKind = error.message.startsWith("environment_") ? error.message : "environment_probe_failed";
} finally {
if (tracing) { try { await context.tracing.stop({ path: path.join(owner.environmentDir, `${mode}-trace.zip`) }); } catch { result.valid = false; result.traceFailed = true; } }
await context?.close().catch(() => {});
await browser?.close().catch(() => {});
await server.close();
atomicEnvironmentJson(output, result);
}
console.log(`[environment] ${mode} probe ${result.valid ? "PASS" : "FAIL"}`);
if (!result.valid) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main(process.argv[2], process.argv[3]).catch(error => { console.error(`[environment] ${error.message.startsWith("environment_") ? error.message : "environment_probe_failed"}`); process.exitCode = 1; });
}