fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createBrowserBridge } from "./bridge.ts";
|
||||
import { createBrowserSessionManager, type BrowserSession, type BrowserSessionManager } from "./browser-session.ts";
|
||||
import { runSteps } from "./controller.ts";
|
||||
import type { BrowserBridge, Step } from "./contracts.ts";
|
||||
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = join(ROOT, "fixtures", "development-test.html");
|
||||
const CHROMIUM = "/usr/bin/chromium";
|
||||
const PINNED_PYTHON = join(process.cwd(), ".pi", "fast-browser-demo", "venv", "bin", "python");
|
||||
const STARTUP_TIMEOUT_MS = 20_000;
|
||||
const CLOSE_TIMEOUT_MS = 4_000;
|
||||
const READY_TIMEOUT_MS = 8_000;
|
||||
const RUN_TIMEOUT_MS = 15_000;
|
||||
|
||||
type CaseStatus = "passed" | "failed" | "skipped";
|
||||
|
||||
export interface ValidationCase {
|
||||
name: string;
|
||||
status: CaseStatus;
|
||||
detail: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ValidationVersions {
|
||||
chromium: string;
|
||||
python: string;
|
||||
browserHarness: string;
|
||||
node: string;
|
||||
}
|
||||
|
||||
export interface LiveValidationReport {
|
||||
ok: boolean;
|
||||
versions: ValidationVersions;
|
||||
cases: ValidationCase[];
|
||||
cleanup: { status: CaseStatus; detail: string };
|
||||
}
|
||||
|
||||
interface FixtureServer {
|
||||
origin: string;
|
||||
waitForReady(name: string, timeoutMs?: number): Promise<void>;
|
||||
automationEvidence(): { cookieHeaderPresent: boolean; cookieReceived: boolean; retained: boolean; outcome?: string } | undefined;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ReadyWaiter {
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
liveCookie: string;
|
||||
liveToken: string;
|
||||
isolationA: string;
|
||||
isolationAToken: string;
|
||||
isolationB: string;
|
||||
isolationBToken: string;
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function boundedError(error: unknown): string {
|
||||
return errorText(error).replace(/\s+/g, " ").slice(0, 300);
|
||||
}
|
||||
|
||||
function requiredExecutable(path: string): void {
|
||||
if (!existsSync(path)) throw new Error(`required executable is missing: ${path}`);
|
||||
const stats = statSync(path);
|
||||
if (!stats.isFile() || (stats.mode & 0o111) === 0) throw new Error(`required executable is not executable: ${path}`);
|
||||
}
|
||||
|
||||
function scrubbedEnvironment(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
const names = [
|
||||
"PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TMP", "TEMP",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP",
|
||||
"SYSTEMROOT", "COMSPEC", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH",
|
||||
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND",
|
||||
];
|
||||
for (const name of names) {
|
||||
if (process.env[name] !== undefined) env[name] = process.env[name];
|
||||
}
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (name.startsWith("LC_") && value !== undefined) env[name] = value;
|
||||
}
|
||||
env.PYTHONNOUSERSITE = "1";
|
||||
env.PYTHONUTF8 = "1";
|
||||
return env;
|
||||
}
|
||||
|
||||
function commandVersion(command: string, args: string[]): string {
|
||||
try {
|
||||
return execFileSync(command, args, { env: scrubbedEnvironment(), encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] }).trim().split(/\r?\n/, 1)[0] ?? "unknown";
|
||||
} catch (error) {
|
||||
throw new Error(`could not read runtime version for ${command}: ${boundedError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeVersions(python: string): ValidationVersions {
|
||||
requiredExecutable(CHROMIUM);
|
||||
const chromium = commandVersion(CHROMIUM, ["--version"]);
|
||||
const pythonVersion = commandVersion(python, ["--version"]);
|
||||
let browserHarness: string;
|
||||
try {
|
||||
browserHarness = execFileSync(python, ["-c", "import importlib.metadata; print(importlib.metadata.version('browser-harness'))"], {
|
||||
env: scrubbedEnvironment(),
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error(`pinned Python does not provide browser-harness==0.1.13: ${boundedError(error)}`);
|
||||
}
|
||||
if (browserHarness !== "0.1.13") {
|
||||
throw new Error(`browser-harness==0.1.13 is required, found ${browserHarness || "unknown"}`);
|
||||
}
|
||||
return { chromium, python: pythonVersion, browserHarness, node: process.version };
|
||||
}
|
||||
|
||||
function pythonExecutable(): string {
|
||||
if (existsSync(PINNED_PYTHON)) {
|
||||
requiredExecutable(PINNED_PYTHON);
|
||||
return PINNED_PYTHON;
|
||||
}
|
||||
throw new Error(`pinned Python is missing: ${PINNED_PYTHON}`);
|
||||
}
|
||||
|
||||
function cookieValue(header: string | undefined, name: string): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function htmlEscape(value: string): string {
|
||||
return value.replace(/[&<>\"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[character]!));
|
||||
}
|
||||
|
||||
function writeHtml(response: ServerResponse, fixture: string, options: {
|
||||
mode: string;
|
||||
retained: boolean;
|
||||
initialStatus: string;
|
||||
buttonLabel: string;
|
||||
readyCase?: string;
|
||||
setCookie?: string;
|
||||
cookieCheck?: string;
|
||||
cookieForbid?: string;
|
||||
}): void {
|
||||
const content = fixture
|
||||
.replaceAll("__MODE__", htmlEscape(options.mode))
|
||||
.replaceAll("__RETAINED__", String(options.retained))
|
||||
.replaceAll("__INITIAL_STATUS__", htmlEscape(options.initialStatus))
|
||||
.replaceAll("__BUTTON_LABEL__", htmlEscape(options.buttonLabel))
|
||||
.replaceAll("__COOKIE_CHECK__", htmlEscape(options.cookieCheck ?? ""))
|
||||
.replaceAll("__COOKIE_FORBID__", htmlEscape(options.cookieForbid ?? ""))
|
||||
.replace("__READY_CASE__", JSON.stringify(options.readyCase ?? null));
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
...(options.setCookie ? { "set-cookie": options.setCookie } : {}),
|
||||
});
|
||||
response.end(content);
|
||||
}
|
||||
|
||||
async function createFixtureServer(options: FixtureOptions): Promise<FixtureServer> {
|
||||
const fixture = readFileSync(FIXTURE, "utf8");
|
||||
const ready = new Set<string>();
|
||||
const waiters = new Map<string, ReadyWaiter>();
|
||||
let automationState: { cookieHeaderPresent: boolean; cookieReceived: boolean; retained: boolean; outcome?: string } | undefined;
|
||||
const noteReady = (name: string): void => {
|
||||
ready.add(name);
|
||||
const waiter = waiters.get(name);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timer);
|
||||
waiters.delete(name);
|
||||
waiter.resolve();
|
||||
};
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
const parsed = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const cookie = request.headers.cookie;
|
||||
const isLive = cookieValue(cookie, options.liveCookie) === options.liveToken;
|
||||
const isA = cookieValue(cookie, options.isolationA) === options.isolationAToken;
|
||||
const isB = cookieValue(cookie, options.isolationB) === options.isolationBToken;
|
||||
if (parsed.pathname === "/setup") {
|
||||
writeHtml(response, fixture, {
|
||||
mode: "setup",
|
||||
retained: false,
|
||||
initialStatus: "Setup fixture loaded; awaiting synthetic cookie",
|
||||
buttonLabel: "Setup fixture",
|
||||
readyCase: "setup",
|
||||
// This validator explicitly tests named-profile persistence. A session
|
||||
// cookie is intentionally insufficient: Chromium is allowed to drop it
|
||||
// during a graceful browser close, leaving no durable Cookies row.
|
||||
setCookie: `${options.liveCookie}=${options.liveToken}; Max-Age=3600; Path=/; SameSite=Lax`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/automation") {
|
||||
automationState = { cookieHeaderPresent: Boolean(cookie), cookieReceived: isLive, retained: isLive };
|
||||
writeHtml(response, fixture, {
|
||||
mode: "retained",
|
||||
retained: isLive,
|
||||
initialStatus: isLive ? "Retained seed cookie present" : "Retained seed cookie missing",
|
||||
buttonLabel: "Verify retained session",
|
||||
readyCase: "automation",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/automation-outcome") {
|
||||
if (automationState) automationState.outcome = (parsed.searchParams.get("status") ?? "").slice(0, 120);
|
||||
response.writeHead(204, { "cache-control": "no-store" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/headless") {
|
||||
writeHtml(response, fixture, {
|
||||
mode: "headless",
|
||||
retained: false,
|
||||
initialStatus: "Headless fixture ready",
|
||||
buttonLabel: "Continue",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/isolation-a") {
|
||||
writeHtml(response, fixture, {
|
||||
mode: "isolation-a",
|
||||
retained: isA && !isB,
|
||||
initialStatus: isA && !isB ? "Profile A seed is isolated" : "Profile A seed is missing or crossed",
|
||||
buttonLabel: "Verify Profile A isolation",
|
||||
readyCase: "isolation-a",
|
||||
cookieCheck: options.isolationA,
|
||||
cookieForbid: options.isolationB,
|
||||
setCookie: `${options.isolationA}=${options.isolationAToken}; Path=/; SameSite=Lax`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/isolation-b") {
|
||||
writeHtml(response, fixture, {
|
||||
mode: "isolation-b",
|
||||
retained: isB && !isA,
|
||||
initialStatus: isB && !isA ? "Profile B seed is isolated" : "Profile B seed is missing or crossed",
|
||||
buttonLabel: "Verify Profile B isolation",
|
||||
readyCase: "isolation-b",
|
||||
cookieCheck: options.isolationB,
|
||||
cookieForbid: options.isolationA,
|
||||
setCookie: `${options.isolationB}=${options.isolationBToken}; Path=/; SameSite=Lax`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (parsed.pathname === "/ready") {
|
||||
const name = parsed.searchParams.get("case");
|
||||
const valid = name === "automation" ? true : name === "setup" ? isLive : name === "isolation-a" ? isA && !isB : name === "isolation-b" ? isB && !isA : false;
|
||||
if (!name || !valid) {
|
||||
response.writeHead(409, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
||||
response.end("not ready");
|
||||
return;
|
||||
}
|
||||
noteReady(name);
|
||||
response.writeHead(204, { "cache-control": "no-store" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("fixture server did not expose a TCP port");
|
||||
return {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
waitForReady(name, timeoutMs = READY_TIMEOUT_MS): Promise<void> {
|
||||
if (ready.has(name)) return Promise.resolve();
|
||||
if (waiters.has(name)) throw new Error(`fixture readiness already awaited: ${name}`);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
waiters.delete(name);
|
||||
reject(new Error(`fixture ready request timed out for ${name}`));
|
||||
}, timeoutMs);
|
||||
waiters.set(name, { resolve, reject, timer });
|
||||
});
|
||||
},
|
||||
automationEvidence(): { cookieHeaderPresent: boolean; cookieReceived: boolean; retained: boolean; outcome?: string } | undefined {
|
||||
return automationState ? { ...automationState } : undefined;
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
for (const waiter of waiters.values()) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error("fixture server closed before readiness"));
|
||||
}
|
||||
waiters.clear();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
server.closeAllConnections?.();
|
||||
resolve();
|
||||
}, 1_000);
|
||||
server.close((error) => {
|
||||
clearTimeout(timer);
|
||||
if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function processLinesContaining(value: string): string[] {
|
||||
const result = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5_000 });
|
||||
if (result.status !== 0) return [];
|
||||
return String(result.stdout).split(/\r?\n/).filter((line) => line.includes(value));
|
||||
}
|
||||
|
||||
function processPid(line: string): number | undefined {
|
||||
const match = line.trim().match(/^(\\d+)\\s/);
|
||||
if (!match) return undefined;
|
||||
const pid = Number(match[1]);
|
||||
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
||||
}
|
||||
|
||||
function profileProcessLine(profile: string): string {
|
||||
const lines = processLinesContaining(`--user-data-dir=${profile}`);
|
||||
if (!lines.length) throw new Error(`no Chromium process was found for owned profile ${profile}`);
|
||||
return lines.join(" | ");
|
||||
}
|
||||
|
||||
function profileShutdownEvidence(profile: string): string {
|
||||
const lines = processLinesContaining(`--user-data-dir=${profile}`);
|
||||
const descendantPids = lines.map(processPid).filter((pid): pid is number => pid !== undefined);
|
||||
const gpuDescendantPids = lines
|
||||
.filter((line) => line.includes("--type=gpu-process"))
|
||||
.map(processPid)
|
||||
.filter((pid): pid is number => pid !== undefined);
|
||||
const singletonMarker = existsSync(join(profile, "SingletonLock"));
|
||||
// manager.close() returning means its tracked root child exited; these are
|
||||
// only still-running processes carrying this run's private profile path.
|
||||
return `trackedRootExited=true; descendantPids=${JSON.stringify(descendantPids)}; gpuDescendantPids=${JSON.stringify(gpuDescendantPids)}; singletonMarker=${singletonMarker}`;
|
||||
}
|
||||
|
||||
function assertLaunchFlags(profile: string, expected: "setup" | "visible"): void {
|
||||
const line = profileProcessLine(profile);
|
||||
if (expected === "setup") {
|
||||
assert.doesNotMatch(line, /remote-debugging|--headless/);
|
||||
} else {
|
||||
assert.match(line, /remote-debugging-port=0/);
|
||||
assert.doesNotMatch(line, /--headless/);
|
||||
}
|
||||
}
|
||||
|
||||
function assertHeadlessOwnedProcess(root: string): void {
|
||||
const line = processLinesContaining(root).find((candidate) => candidate.includes("--user-data-dir=") && candidate.includes("--headless=new"));
|
||||
assert.ok(line, "no owned Chromium process was found for the disposable headless profile");
|
||||
assert.match(line, /remote-debugging-port=0/);
|
||||
}
|
||||
|
||||
function launchConfig(origin: string, path: string, profile: string | undefined, headless: boolean): Parameters<BrowserSessionManager["open"]>[0] {
|
||||
return {
|
||||
allowedOrigins: [origin],
|
||||
browser: {
|
||||
mode: "launch",
|
||||
product: "chromium",
|
||||
executablePath: CHROMIUM,
|
||||
headless,
|
||||
startUrl: `${origin}${path}`,
|
||||
...(profile ? { profile } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupConfig(origin: string, path: string, profile: string): Parameters<BrowserSessionManager["openSetup"]>[0] {
|
||||
return {
|
||||
allowedOrigins: [origin],
|
||||
browser: {
|
||||
mode: "launch",
|
||||
product: "chromium",
|
||||
executablePath: CHROMIUM,
|
||||
headless: false,
|
||||
startUrl: `${origin}${path}`,
|
||||
profile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function exactRun(bridge: BrowserBridge, steps: Step[], signal: AbortSignal): Promise<void> {
|
||||
let semanticDecisions = 0;
|
||||
const result = await runSteps(steps, {
|
||||
browser: bridge,
|
||||
decide: async () => {
|
||||
semanticDecisions += 1;
|
||||
throw new Error("unexpected semantic browser decision in exact-control validation");
|
||||
},
|
||||
}, {
|
||||
signal,
|
||||
maxSteps: steps.length,
|
||||
timeoutMs: RUN_TIMEOUT_MS,
|
||||
minConfidence: 1,
|
||||
});
|
||||
assert.equal(semanticDecisions, 0, "exact fixture controls must not invoke JEV or a provider");
|
||||
assert.equal(result.status, "completed", result.reason);
|
||||
assert.equal(result.actionMayHaveExecuted, false);
|
||||
assert.equal(result.steps.every((step) => step.status === "verified" && step.mutationState === "verified"), true);
|
||||
}
|
||||
|
||||
async function openBridge(session: BrowserSession, python: string, origin: string): Promise<BrowserBridge> {
|
||||
return createBrowserBridge({
|
||||
cdpUrl: session.config.cdpUrl,
|
||||
allowedOrigins: [origin],
|
||||
python,
|
||||
requestTimeoutMs: 10_000,
|
||||
}, session.signal);
|
||||
}
|
||||
|
||||
async function runCase(cases: ValidationCase[], name: string, operation: () => Promise<void>): Promise<void> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
await operation();
|
||||
cases.push({ name, status: "passed", detail: "verified", elapsedMs: Date.now() - started });
|
||||
} catch (error) {
|
||||
cases.push({ name, status: "failed", detail: boundedError(error), elapsedMs: Date.now() - started });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLiveValidation(): Promise<LiveValidationReport> {
|
||||
const python = pythonExecutable();
|
||||
const versions = runtimeVersions(python);
|
||||
const root = mkdtempSync(join(tmpdir(), "fast-browser-live-validation-"));
|
||||
const profileRoot = join(root, "profiles");
|
||||
const leaseDirectory = join(root, "leases");
|
||||
const disposableRoot = join(root, "disposable");
|
||||
const runId = randomBytes(5).toString("hex");
|
||||
const liveProfile = `live-${runId}`;
|
||||
const profileA = `alpha-${runId}`;
|
||||
const profileB = `beta-${runId}`;
|
||||
const options: FixtureOptions = {
|
||||
liveCookie: `fb_live_${runId}`,
|
||||
liveToken: randomBytes(12).toString("hex"),
|
||||
isolationA: `fb_a_${runId}`,
|
||||
isolationAToken: randomBytes(12).toString("hex"),
|
||||
isolationB: `fb_b_${runId}`,
|
||||
isolationBToken: randomBytes(12).toString("hex"),
|
||||
};
|
||||
let server: FixtureServer | undefined;
|
||||
let manager: BrowserSessionManager | undefined;
|
||||
const sessions: string[] = [];
|
||||
const bridges: BrowserBridge[] = [];
|
||||
const cases: ValidationCase[] = [];
|
||||
let cleanupDetail = "not run";
|
||||
let cleanupStatus: CaseStatus = "failed";
|
||||
try {
|
||||
server = await createFixtureServer(options);
|
||||
manager = createBrowserSessionManager({
|
||||
profileRoot,
|
||||
leaseDirectory,
|
||||
profileDirectory: disposableRoot,
|
||||
startupTimeoutMs: STARTUP_TIMEOUT_MS,
|
||||
closeTimeoutMs: CLOSE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let setupPersistenceReady = false;
|
||||
await runCase(cases, "visible manual setup seeds persistent cookie and closes fully", async () => {
|
||||
const setup = await manager!.openSetup(setupConfig(server!.origin, "/setup", liveProfile), new AbortController().signal);
|
||||
sessions.push(setup.id);
|
||||
const profile = join(profileRoot, liveProfile);
|
||||
assertLaunchFlags(profile, "setup");
|
||||
await server!.waitForReady("setup");
|
||||
await manager!.close(setup.id);
|
||||
// Closed sessions remain in the bounded recent-exit tombstone so callers
|
||||
// receive the intentional terminal reason instead of an indistinguishable
|
||||
// unknown-session error.
|
||||
assert.throws(() => manager!.get(setup.id), /session is no longer available: session closed/);
|
||||
assert.equal(existsSync(profile), true, "named profile must survive setup close");
|
||||
const ownedProcesses = processLinesContaining(`--user-data-dir=${profile}`);
|
||||
assert.equal(ownedProcesses.length, 0, `manual setup close evidence: ${profileShutdownEvidence(profile)}`);
|
||||
sessions.splice(sessions.indexOf(setup.id), 1);
|
||||
setupPersistenceReady = true;
|
||||
});
|
||||
|
||||
if (setupPersistenceReady) {
|
||||
await runCase(cases, "visible automation reopens retained session with bounded fill/click", async () => {
|
||||
const session = await manager!.open(launchConfig(server!.origin, "/automation", liveProfile, false), new AbortController().signal);
|
||||
sessions.push(session.id);
|
||||
const profile = join(profileRoot, liveProfile);
|
||||
assertLaunchFlags(profile, "visible");
|
||||
const bridge = await openBridge(session, python, server!.origin);
|
||||
bridges.push(bridge);
|
||||
await server!.waitForReady("automation");
|
||||
try {
|
||||
await exactRun(bridge, [
|
||||
{ instruction: "Display name", action: "fill", text: "Milestone" },
|
||||
{ instruction: "Verify retained session", action: "click", postcondition: { kind: "text", value: "Retained session verified" } },
|
||||
], session.signal);
|
||||
} catch (error) {
|
||||
throw new Error(`${boundedError(error)}; expectedPostcondition=Retained session verified; automationEvidence=${JSON.stringify(server!.automationEvidence())}`);
|
||||
} finally {
|
||||
await bridge.close();
|
||||
bridges.splice(bridges.indexOf(bridge), 1);
|
||||
}
|
||||
await manager!.close(session.id);
|
||||
sessions.splice(sessions.indexOf(session.id), 1);
|
||||
assert.equal(processLinesContaining(`--user-data-dir=${profile}`).length, 0, "automation browser must be fully closed");
|
||||
});
|
||||
} else {
|
||||
cases.push({
|
||||
name: "visible automation reopens retained session with bounded fill/click",
|
||||
status: "skipped",
|
||||
detail: "blocked by visible manual setup cleanup failure; production lifecycle fix required",
|
||||
elapsedMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await runCase(cases, "headless launch verifies bounded fill/click", async () => {
|
||||
const session = await manager!.open(launchConfig(server!.origin, "/headless", undefined, true), new AbortController().signal);
|
||||
sessions.push(session.id);
|
||||
// The manager intentionally keeps the disposable directory private and
|
||||
// opaque. Its owned temporary root still lets this harness verify flags.
|
||||
assertHeadlessOwnedProcess(root);
|
||||
const bridge = await openBridge(session, python, server!.origin);
|
||||
bridges.push(bridge);
|
||||
try {
|
||||
await exactRun(bridge, [
|
||||
{ instruction: "Display name", action: "fill", text: "Ada" },
|
||||
{ instruction: "Continue", action: "click", postcondition: { kind: "text", value: "Headless bounded action verified" } },
|
||||
], session.signal);
|
||||
} finally {
|
||||
await bridge.close();
|
||||
bridges.splice(bridges.indexOf(bridge), 1);
|
||||
}
|
||||
await manager!.close(session.id);
|
||||
sessions.splice(sessions.indexOf(session.id), 1);
|
||||
});
|
||||
|
||||
let alpha: BrowserSession | undefined;
|
||||
let beta: BrowserSession | undefined;
|
||||
let alphaBridge: BrowserBridge | undefined;
|
||||
let betaBridge: BrowserBridge | undefined;
|
||||
await runCase(cases, "two named profiles isolate state, contention rejects, and one survives the other close", async () => {
|
||||
[alpha, beta] = await Promise.all([
|
||||
manager!.open(launchConfig(server!.origin, "/isolation-a", profileA, true), new AbortController().signal),
|
||||
manager!.open(launchConfig(server!.origin, "/isolation-b", profileB, true), new AbortController().signal),
|
||||
]);
|
||||
sessions.push(alpha.id, beta.id);
|
||||
await Promise.all([server!.waitForReady("isolation-a"), server!.waitForReady("isolation-b")]);
|
||||
await assert.rejects(
|
||||
manager!.open(launchConfig(server!.origin, "/isolation-a", profileA, true), new AbortController().signal),
|
||||
/active or stale lease/i,
|
||||
);
|
||||
alphaBridge = await openBridge(alpha, python, server!.origin);
|
||||
betaBridge = await openBridge(beta, python, server!.origin);
|
||||
bridges.push(alphaBridge, betaBridge);
|
||||
await Promise.all([
|
||||
exactRun(alphaBridge, [{ instruction: "Verify Profile A isolation", action: "click", postcondition: { kind: "text", value: "Profile A isolated" } }], alpha.signal),
|
||||
exactRun(betaBridge, [{ instruction: "Verify Profile B isolation", action: "click", postcondition: { kind: "text", value: "Profile B isolated" } }], beta.signal),
|
||||
]);
|
||||
await alphaBridge.close();
|
||||
bridges.splice(bridges.indexOf(alphaBridge), 1);
|
||||
alphaBridge = undefined;
|
||||
await manager!.close(alpha.id);
|
||||
sessions.splice(sessions.indexOf(alpha.id), 1);
|
||||
assert.doesNotThrow(() => manager!.get(beta!.id));
|
||||
const remaining = await betaBridge!.observe(beta.signal);
|
||||
assert.equal(remaining.url, `${server!.origin}/isolation-b`);
|
||||
assert(remaining.candidates.some((candidate) => candidate.name === "Verify Profile B isolation"));
|
||||
assert(processLinesContaining(`--user-data-dir=${join(profileRoot, profileB)}`).length > 0, "profile B browser must remain alive");
|
||||
});
|
||||
if (alphaBridge) await alphaBridge.close().catch(() => {});
|
||||
if (betaBridge) {
|
||||
await betaBridge.close().catch(() => {});
|
||||
const index = bridges.indexOf(betaBridge);
|
||||
if (index >= 0) bridges.splice(index, 1);
|
||||
}
|
||||
if (alpha) {
|
||||
await manager.close(alpha.id).catch(() => {});
|
||||
const index = sessions.indexOf(alpha.id);
|
||||
if (index >= 0) sessions.splice(index, 1);
|
||||
}
|
||||
if (beta) {
|
||||
await manager.close(beta.id).catch(() => {});
|
||||
const index = sessions.indexOf(beta.id);
|
||||
if (index >= 0) sessions.splice(index, 1);
|
||||
}
|
||||
} finally {
|
||||
for (const bridge of [...bridges]) await bridge.close().catch(() => {});
|
||||
if (manager) {
|
||||
for (const sessionId of [...sessions]) await manager.close(sessionId).catch(() => {});
|
||||
await manager.closeAll().catch(() => {});
|
||||
}
|
||||
const leftovers = processLinesContaining(root);
|
||||
if (server) await server.close().catch(() => {});
|
||||
// Preserve every failed-case root for postmortem inspection even when all
|
||||
// owned browser processes were safely reaped. Never remove a root that may
|
||||
// still contain an owned process or a failed-case profile/evidence trail.
|
||||
const preserveFailureRoot = cases.some((item) => item.status === "failed") || cases.length === 0;
|
||||
if (leftovers.length === 0 && !preserveFailureRoot) {
|
||||
try { rmSync(root, { recursive: true, force: true }); } catch { /* reported below */ }
|
||||
}
|
||||
const stillExists = existsSync(root);
|
||||
if (leftovers.length === 0) {
|
||||
cleanupStatus = "passed";
|
||||
cleanupDetail = preserveFailureRoot
|
||||
? `owned browsers, workers, leases, and profiles cleaned; failed-case root preserved: ${root}`
|
||||
: "owned browsers, workers, leases, profiles, and temporary root cleaned";
|
||||
} else {
|
||||
cleanupStatus = "failed";
|
||||
cleanupDetail = `leftovers after owned cleanup: processes=${leftovers.length}, rootExists=${stillExists}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: cases.every((item) => item.status === "passed") && cleanupStatus === "passed",
|
||||
versions,
|
||||
cases,
|
||||
cleanup: { status: cleanupStatus, detail: cleanupDetail },
|
||||
};
|
||||
}
|
||||
|
||||
function printReport(report: LiveValidationReport): void {
|
||||
console.log("Fast-browser live validation");
|
||||
console.log(`Chromium: ${report.versions.chromium}`);
|
||||
console.log(`Python: ${report.versions.python}`);
|
||||
console.log(`browser-harness: ${report.versions.browserHarness}`);
|
||||
console.log(`Node: ${report.versions.node}`);
|
||||
for (const item of report.cases) {
|
||||
console.log(`${item.status.toUpperCase()} ${item.name} (${item.elapsedMs}ms): ${item.detail}`);
|
||||
}
|
||||
console.log(`${report.cleanup.status.toUpperCase()} cleanup: ${report.cleanup.detail}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
const report = await runLiveValidation();
|
||||
printReport(report);
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
} catch (error) {
|
||||
console.error(`BLOCKED preflight/live validation: ${boundedError(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user