651 lines
27 KiB
TypeScript
651 lines
27 KiB
TypeScript
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;
|
|
}
|
|
}
|