423 lines
26 KiB
TypeScript
423 lines
26 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { createServer } from "node:net";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { parseScenarioCliArgs, runScenarioCli, ScenarioCliError, waitForScenarioHttpReadiness } from "./scenario-cli.ts";
|
|
import { BrowserOperationError } from "./contracts.ts";
|
|
import type { BrowserRuntime, BrowserTabId, PrivateArtifactStore } from "./contracts.ts";
|
|
import type { BrowserSession, BrowserSessionManager } from "./browser-session.ts";
|
|
|
|
const scenario = JSON.stringify({
|
|
version: 1,
|
|
id: "local-form",
|
|
settings: {
|
|
allowedOrigins: ["http://fixture.test"],
|
|
browser: {
|
|
mode: "launch",
|
|
product: "chromium",
|
|
executablePath: "/definitely/missing/fixture-browser",
|
|
startUrl: "http://fixture.test/form.html"
|
|
}
|
|
},
|
|
operations: [
|
|
{ op: "step", step: { instruction: "Display name", action: "fill", text: "Ada" } },
|
|
{ op: "step", step: { instruction: "Continue", action: "click", postcondition: { kind: "text", value: "Fixture complete" } } },
|
|
],
|
|
});
|
|
|
|
async function temporary(fn: (directory: string) => Promise<void>): Promise<void> {
|
|
const directory = await mkdtemp(join(tmpdir(), "fast-browser-cli-test-"));
|
|
try { await fn(directory); } finally { await rm(directory, { recursive: true, force: true }); }
|
|
}
|
|
|
|
test("parses explicit scenario paths, bounded matrix options, and headless defaults", () => {
|
|
const options = parseScenarioCliArgs(["--scenario", "one.json", "two.json", "--parallel=2", "--json", "--capture-on-request", "--check"]);
|
|
assert.deepEqual(options.scenarioPaths, ["one.json", "two.json"]);
|
|
assert.equal(options.parallel, 2);
|
|
assert.equal(options.headless, true);
|
|
assert.equal(options.format, "json");
|
|
assert.equal(options.checkOnly, true);
|
|
assert.equal(options.captureOnRequest, true);
|
|
assert.throws(() => parseScenarioCliArgs(["--headless", "--visible", "one.json"]), ScenarioCliError);
|
|
assert.throws(() => parseScenarioCliArgs(["--parallel", "0", "one.json"]), ScenarioCliError);
|
|
assert.throws(() => parseScenarioCliArgs(["--unknown", "one.json"]), ScenarioCliError);
|
|
});
|
|
|
|
test("polls bounded HTTP fixture readiness without reading response bodies", async () => {
|
|
let attempts = 0;
|
|
await waitForScenarioHttpReadiness({ url: "http://fixture.test/ready", expectedStatus: 204, timeoutMs: 1_000 }, new AbortController().signal, Date.now() + 1_000, async () => {
|
|
attempts += 1;
|
|
return new Response(null, { status: attempts < 2 ? 503 : 204 });
|
|
});
|
|
assert.equal(attempts, 2);
|
|
});
|
|
|
|
test("loads and validates with --check without starting a browser or provider", async () => {
|
|
await temporary(async (directory) => {
|
|
await writeFile(join(directory, "scenario.json"), scenario);
|
|
const result = await runScenarioCli(["--check", "--format", "json", "--cwd", directory, "scenario.json"]);
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.report?.status, "validated");
|
|
assert.equal(result.loaded?.scenarios[0]?.scenario.id, "local-form");
|
|
});
|
|
});
|
|
|
|
test("rejects compose before hooks or browser startup when the CLI has no generation adapter", async () => {
|
|
await temporary(async (directory) => {
|
|
const marker = join(directory, "hook-ran");
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "compose-unsupported",
|
|
settings: { textModel: "fixture/model" },
|
|
hooks: { setup: { argv: ["node", "-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran')`] } },
|
|
operations: [{ op: "step", step: { instruction: "Greeting", action: "fill", compose: "compose a greeting" } }],
|
|
}));
|
|
let opened = false;
|
|
const manager: BrowserSessionManager = {
|
|
async open() { opened = true; throw new Error("browser must not start"); },
|
|
async openSetup() { throw new Error("setup is not used"); },
|
|
get() { throw new Error("browser must not start"); },
|
|
async close() {},
|
|
async closeAll() {},
|
|
};
|
|
const result = await runScenarioCli(["--cwd", directory, "scenario.json"], { sessionManager: manager });
|
|
assert.equal(result.exitCode, 2);
|
|
assert.match(result.report?.failure?.message ?? "", /no text-generation adapter/);
|
|
assert.equal(opened, false);
|
|
await assert.rejects(access(marker));
|
|
const checked = await runScenarioCli(["--check", "--cwd", directory, "scenario.json"], { sessionManager: manager });
|
|
assert.equal(checked.exitCode, 2);
|
|
assert.equal(opened, false);
|
|
await assert.rejects(access(marker));
|
|
});
|
|
});
|
|
|
|
test("fixture hook teardown terminates detached descendants through its owned process group", async (t) => {
|
|
if (process.platform === "win32") { t.skip("POSIX process-group supervision is not available"); return; }
|
|
await temporary(async (directory) => {
|
|
const pidPath = join(directory, "descendant.pid");
|
|
const hookScript = `const fs=require('node:fs'),cp=require('node:child_process');const child=cp.spawn(process.execPath,['-e','setTimeout(()=>{},100000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(pidPath)},String(child.pid));child.unref()`;
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "hook-group",
|
|
hooks: { setup: { argv: [process.execPath, "-e", hookScript], timeoutMs: 2_000 } },
|
|
settings: { allowedOrigins: ["http://fixture.test"], browser: { mode: "launch", product: "chromium", executablePath: "/definitely/missing/fixture-browser", startUrl: "http://fixture.test" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const result = await runScenarioCli(["--cwd", directory, "scenario.json"]);
|
|
assert.equal(result.exitCode, 1);
|
|
const pid = Number(await readFile(pidPath, "utf8"));
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
assert.throws(() => process.kill(pid, 0));
|
|
});
|
|
});
|
|
|
|
test("manages a published fixture server around setup, readiness, reset, browser, cleanup, and stop", async () => {
|
|
if (process.platform !== "linux") return;
|
|
await temporary(async (directory) => {
|
|
const probe = createServer();
|
|
await new Promise<void>((resolve, reject) => { probe.once("error", reject); probe.listen(0, "127.0.0.1", () => resolve()); });
|
|
const address = probe.address();
|
|
if (!address || typeof address === "string") throw new Error("could not allocate fixture test port");
|
|
const port = address.port;
|
|
await new Promise<void>((resolve, reject) => probe.close((error) => error ? reject(error) : resolve()));
|
|
const orderPath = join(directory, "order.log");
|
|
const append = (value: string): string => `require('node:fs').appendFileSync(${JSON.stringify(orderPath)},${JSON.stringify(`${value}\n`)})`;
|
|
const serverScript = `${append("server-start")};require('node:http').createServer((req,res)=>{res.writeHead(200);res.end('ready')}).listen(${port},'127.0.0.1')`;
|
|
const resetScript = `require('node:http').get('http://127.0.0.1:${port}/ready',r=>{r.resume();r.on('end',()=>${append("reset")})})`;
|
|
const cleanupScript = `require('node:http').get('http://127.0.0.1:${port}/ready',r=>{r.resume();r.on('end',()=>${append("cleanup")})})`;
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "managed-fixture",
|
|
fixture: {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
server: {
|
|
mode: "managed",
|
|
start: { command: process.execPath, args: ["-e", serverScript], cwd: directory },
|
|
readiness: { url: `http://127.0.0.1:${port}/ready`, expectedStatus: 200, timeoutMs: 2_000 },
|
|
shutdownTimeoutMs: 2_000,
|
|
},
|
|
},
|
|
hooks: {
|
|
setup: { argv: [process.execPath, "-e", append("setup")] },
|
|
reset: { argv: [process.execPath, "-e", resetScript] },
|
|
cleanup: { argv: [process.execPath, "-e", cleanupScript] },
|
|
},
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [`http://127.0.0.1:${port}`], browser: { mode: "attach" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const tab = { tabId: "tab-1" as BrowserTabId, kind: "page" as const, selected: true, owned: true as const, url: `http://127.0.0.1:${port}/ready`, origin: `http://127.0.0.1:${port}`, title: "Fixture" };
|
|
let runtimeClosed = false;
|
|
let sessionClosed = false;
|
|
let delayRead = false;
|
|
const runtime: BrowserRuntime = {
|
|
sessionId: "managed-session",
|
|
async observe() { return { observationId: "obs", url: tab.url, title: tab.title, candidates: [] }; },
|
|
async act() { return { verified: true, evidence: "ok" }; },
|
|
async read() { if (delayRead) await new Promise((resolve) => setTimeout(resolve, 1_000)); return { tab, documentId: "doc", url: tab.url, title: tab.title, visibleText: "ready", visibleTextTruncated: false }; },
|
|
async assert() { return { status: "satisfied", readOnly: true as const, tab, documentId: "doc" }; },
|
|
async navigate() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc" }; },
|
|
async reload() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc" }; },
|
|
async listTabs() { return [tab]; },
|
|
async selectTab() { return { selected: true as const, tab }; },
|
|
async waitPopup() { return { status: "timed_out", autoSwitched: false as const }; },
|
|
async closeTab() { return { closed: true as const, tabId: tab.tabId, mutationState: "verified" as const }; },
|
|
async captureEvidence() { return { status: "unavailable" as const, console: [], failedRequests: [] }; },
|
|
async narrowOriginPolicy() { return { allowedOrigins: [tab.origin] }; },
|
|
async close() { runtimeClosed = true; },
|
|
};
|
|
const session: BrowserSession = { id: "managed-session", config: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [tab.origin], browser: { mode: "attach" } }, owned: false, signal: new AbortController().signal };
|
|
const manager: BrowserSessionManager = {
|
|
async open() { return session; },
|
|
async openSetup() { throw new Error("setup is not used"); },
|
|
get() { return session; },
|
|
async close() { sessionClosed = true; },
|
|
async closeAll() {},
|
|
};
|
|
const result = await runScenarioCli(["--cwd", directory, "scenario.json"], { sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(runtimeClosed, true);
|
|
assert.equal(sessionClosed, true);
|
|
assert.deepEqual((await readFile(orderPath, "utf8")).trim().split("\n"), ["setup", "server-start", "reset", "cleanup"]);
|
|
await assert.rejects(fetch(`http://127.0.0.1:${port}/ready`));
|
|
|
|
const earlyExitScript = `${append("server-start")};const server=require('node:http').createServer((req,res)=>{res.writeHead(200);res.end('ready')});server.listen(${port},'127.0.0.1');setTimeout(()=>process.exit(0),300)`;
|
|
delayRead = true;
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "managed-early-exit",
|
|
fixture: {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
server: { mode: "managed", start: { command: process.execPath, args: ["-e", earlyExitScript], cwd: directory }, readiness: { url: `http://127.0.0.1:${port}/ready`, expectedStatus: 200, timeoutMs: 2_000 }, shutdownTimeoutMs: 2_000 },
|
|
},
|
|
hooks: { reset: { argv: [process.execPath, "-e", resetScript] }, cleanup: { argv: [process.execPath, "-e", append("cleanup")] } },
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [`http://127.0.0.1:${port}`], browser: { mode: "attach" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const earlyExit = await runScenarioCli(["--cwd", directory, "scenario.json"], { sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
assert.equal(earlyExit.exitCode, 1);
|
|
assert.equal(earlyExit.report?.scenarios[0]?.failure?.phase, "fixture");
|
|
|
|
delayRead = true;
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "managed-external-abort",
|
|
fixture: {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
server: { mode: "managed", start: { command: process.execPath, args: ["-e", serverScript], cwd: directory }, readiness: { url: `http://127.0.0.1:${port}/ready`, expectedStatus: 200, timeoutMs: 2_000 }, shutdownTimeoutMs: 2_000 },
|
|
},
|
|
hooks: { reset: { argv: [process.execPath, "-e", resetScript] }, cleanup: { argv: [process.execPath, "-e", cleanupScript] } },
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [`http://127.0.0.1:${port}`], browser: { mode: "attach" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const externalAbort = new AbortController();
|
|
const run = runScenarioCli(["--cwd", directory, "scenario.json"], { signal: externalAbort.signal, sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
setTimeout(() => externalAbort.abort(new Error("test cancellation")), 500);
|
|
const aborted = await run;
|
|
assert.equal(aborted.exitCode, 1);
|
|
assert.equal((await readFile(orderPath, "utf8")).match(/^cleanup$/gm)?.length, 3);
|
|
await assert.rejects(fetch(`http://127.0.0.1:${port}/ready`));
|
|
|
|
const notReadyScript = `${append("server-start")};require('node:http').createServer((req,res)=>{res.writeHead(503);res.end('not ready')}).listen(${port},'127.0.0.1')`;
|
|
await writeFile(join(directory, "scenario.json"), JSON.stringify({
|
|
version: 1,
|
|
id: "managed-readiness-abort",
|
|
fixture: {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
server: { mode: "managed", start: { command: process.execPath, args: ["-e", notReadyScript], cwd: directory }, readiness: { url: `http://127.0.0.1:${port}/ready`, expectedStatus: 200, timeoutMs: 2_000 }, shutdownTimeoutMs: 2_000 },
|
|
},
|
|
hooks: { cleanup: { argv: [process.execPath, "-e", cleanupScript] } },
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [`http://127.0.0.1:${port}`], browser: { mode: "attach" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const readinessAbort = new AbortController();
|
|
const readinessRun = runScenarioCli(["--cwd", directory, "scenario.json"], { signal: readinessAbort.signal, sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
setTimeout(() => readinessAbort.abort(new Error("readiness cancellation")), 300);
|
|
const readinessAborted = await readinessRun;
|
|
assert.equal(readinessAborted.exitCode, 1);
|
|
assert.equal((await readFile(orderPath, "utf8")).match(/^cleanup$/gm)?.length, 4);
|
|
await assert.rejects(fetch(`http://127.0.0.1:${port}/ready`));
|
|
delayRead = false;
|
|
});
|
|
});
|
|
|
|
test("serializes attached unavailable and partial failure evidence without duplicate capture or unsafe details", async () => {
|
|
await temporary(async (directory) => {
|
|
const path = join(directory, "scenario.json");
|
|
await writeFile(path, JSON.stringify({
|
|
version: 1,
|
|
id: "attached-evidence",
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"], browser: { mode: "attach" } },
|
|
operations: [{ op: "read", request: {} }],
|
|
}));
|
|
const tab = { tabId: "tab-evidence" as BrowserTabId, kind: "page" as const, selected: true, owned: true as const, url: "http://fixture.test/form", origin: "http://fixture.test", title: "Fixture" };
|
|
let mode: "unavailable" | "partial" = "unavailable";
|
|
let captureCalls = 0;
|
|
let closeError: Error | undefined;
|
|
const runtime: BrowserRuntime = {
|
|
sessionId: "session-evidence",
|
|
async observe() { return { observationId: "obs", url: tab.url, title: tab.title, candidates: [] }; },
|
|
async act() { return { verified: true, evidence: "ok" }; },
|
|
async read() {
|
|
const error = new BrowserOperationError({ code: "aborted", operation: "read", phase: "dispatch", message: "browser operation cancelled", mutationState: "not_attempted", retry: "never" });
|
|
const attached = mode === "unavailable"
|
|
? { status: "unavailable", reason: "opaque-token ws://user:password@example.test/private cookie=session-secret https://fixture.test/private?token=top-secret" }
|
|
: { status: "partial", reason: "partial capture", screenshot: { artifactId: "shot-1", kind: "screenshot", contentType: "image/png", path: "/tmp/private-shot.png", bytes: 4, owner: { sessionId: "session-evidence", runId: "run", operationId: "op" }, permissions: "0600", localOnly: true, modelForwarded: false } };
|
|
Object.defineProperty(error, "failureEvidence", { value: attached, enumerable: false });
|
|
throw error;
|
|
},
|
|
async assert() { return { status: "satisfied", readOnly: true as const, tab, documentId: "doc" }; },
|
|
async navigate() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc" }; },
|
|
async reload() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc" }; },
|
|
async listTabs() { return [tab]; },
|
|
async selectTab() { return { selected: true as const, tab }; },
|
|
async waitPopup() { return { status: "timed_out", autoSwitched: false as const }; },
|
|
async closeTab() { return { closed: true as const, tabId: tab.tabId, mutationState: "verified" as const }; },
|
|
async captureEvidence() { captureCalls += 1; return { status: "unavailable" as const, console: [], failedRequests: [] }; },
|
|
async narrowOriginPolicy() { return { allowedOrigins: [tab.origin] }; },
|
|
async close() {},
|
|
};
|
|
const session: BrowserSession = { id: "session-evidence", config: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: [tab.origin], browser: { mode: "attach" } }, owned: false, signal: new AbortController().signal };
|
|
const manager: BrowserSessionManager = { async open() { return session; }, async openSetup() { throw new Error("unused"); }, get() { return session; }, async close() { if (closeError) throw closeError; }, async closeAll() {} };
|
|
closeError = new Error("Browser session is no longer available: session closed");
|
|
const unavailable = await runScenarioCli(["--cwd", directory, "scenario.json"], { sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
assert.equal(unavailable.exitCode, 1);
|
|
assert.equal(unavailable.report?.scenarios[0]?.cleanup?.status, "passed");
|
|
assert.equal(captureCalls, 0);
|
|
const unavailableEvidence = unavailable.report?.evidence.find((entry) => entry.status === "failed");
|
|
assert.equal(unavailableEvidence?.error, "failure evidence unavailable");
|
|
assert.doesNotMatch(JSON.stringify(unavailable.report), /opaque-token|password@example|session-secret|top-secret|private\?/);
|
|
|
|
mode = "partial";
|
|
closeError = new Error("browser profile cleanup failed");
|
|
const partial = await runScenarioCli(["--cwd", directory, "scenario.json"], { sessionManager: manager, runtimeFactory: { create() { return runtime; } } });
|
|
assert.equal(partial.exitCode, 1);
|
|
assert.equal(partial.report?.scenarios[0]?.cleanup?.status, "failed");
|
|
assert.equal(captureCalls, 0);
|
|
assert.equal(partial.report?.evidence.find((entry) => entry.status === "captured")?.retainedPath, "/tmp/private-shot.png");
|
|
assert.doesNotMatch(JSON.stringify(partial.report), /base64|top-secret/);
|
|
});
|
|
});
|
|
|
|
test("executes exact-name operations through an injected BrowserRuntime without browser or provider calls", async () => {
|
|
await temporary(async (directory) => {
|
|
const path = join(directory, "scenario.json");
|
|
await writeFile(path, JSON.stringify({
|
|
version: 1,
|
|
id: "offline-runtime",
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"], browser: { mode: "attach" } },
|
|
operations: [
|
|
{ op: "step", step: { instruction: "Continue", action: "click", postcondition: { kind: "text", value: "done" } } },
|
|
{ op: "read", request: {} },
|
|
{ op: "listTabs", id: "tabs", request: { select: { kind: "origin", origin: "http://fixture.test" } } },
|
|
{ op: "selectTab", id: "selected", request: { tab: { source: "operation", operation: { id: "tabs" }, result: "listedTab", index: 0 } } },
|
|
{ op: "read", request: { tab: { source: "operation", operation: { id: "selected" }, result: "selectedTab" } } },
|
|
{ op: "listTabs", id: "all" },
|
|
{ op: "selectTab", id: "other", request: { tab: { source: "operation", operation: { id: "all" }, result: "listedTab", index: 1 } } },
|
|
{ op: "read", request: { tab: { source: "initial", target: "selected" } } },
|
|
],
|
|
evidence: { onFailure: true, onRequest: true },
|
|
}));
|
|
const tab = { tabId: "tab-1" as BrowserTabId, kind: "page" as const, selected: true, owned: true as const, url: "http://fixture.test/form.html", origin: "http://fixture.test", title: "Fixture" };
|
|
const otherTab = { tabId: "tab-2" as BrowserTabId, kind: "page" as const, selected: false, owned: true as const, url: "http://other.test/form.html", origin: "http://other.test", title: "Other" };
|
|
let currentSelected = tab.tabId;
|
|
let closed = false;
|
|
let delayAct = false;
|
|
let artifactStore: PrivateArtifactStore | undefined;
|
|
const runtime: BrowserRuntime = {
|
|
sessionId: "session-1",
|
|
async observe() { return { observationId: "obs-1", url: tab.url, title: tab.title, candidates: [{ id: "continue", role: "button", name: "Continue", actions: ["click"] }] }; },
|
|
async act(_request, signal) {
|
|
if (delayAct) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
signal.throwIfAborted();
|
|
return { verified: true, evidence: "visible-text postcondition verified" };
|
|
},
|
|
async read() { return { tab, documentId: "doc-1", url: tab.url, title: tab.title, visibleText: "Fixture", visibleTextTruncated: false }; },
|
|
async assert() { return { status: "satisfied", readOnly: true as const, tab, documentId: "doc-1" }; },
|
|
async navigate() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc-2" }; },
|
|
async reload() { return { status: "committed", mutationState: "verified" as const, tab, documentId: "doc-2" }; },
|
|
async listTabs() { return [{ ...tab, selected: currentSelected === tab.tabId }, { ...otherTab, selected: currentSelected === otherTab.tabId }]; },
|
|
async selectTab(request) { currentSelected = request.tabId; return { selected: true as const, tab: request.tabId === otherTab.tabId ? otherTab : tab }; },
|
|
async waitPopup() { return { status: "timed_out", autoSwitched: false as const }; },
|
|
async closeTab() { return { closed: true as const, tabId: tab.tabId, mutationState: "verified" as const }; },
|
|
async captureEvidence() {
|
|
const writer = await artifactStore!.open({ sessionId: "session-1", runId: "run-1", operationId: "op-1" }, "diagnostics");
|
|
await writer.write(new TextEncoder().encode("{}"));
|
|
const metadata = await writer.commit();
|
|
return { status: "complete" as const, diagnostics: metadata, console: [], failedRequests: [] };
|
|
},
|
|
async narrowOriginPolicy() { return { allowedOrigins: ["http://fixture.test"] }; },
|
|
async close() { closed = true; await artifactStore?.close(); },
|
|
};
|
|
const session: BrowserSession = { id: "session-1", config: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"], browser: { mode: "attach" } }, owned: false, signal: new AbortController().signal };
|
|
const manager: BrowserSessionManager = {
|
|
async open() { return session; },
|
|
async openSetup() { throw new Error("setup is not used"); },
|
|
get() { return session; },
|
|
async close() {},
|
|
async closeAll() {},
|
|
};
|
|
const reportPath = join(directory, "report.json");
|
|
const result = await runScenarioCli(["--cwd", directory, "--report", reportPath, "scenario.json"], {
|
|
sessionManager: manager,
|
|
runtimeFactory: { create(options) { artifactStore = options.artifacts; return runtime; } },
|
|
});
|
|
assert.equal(result.exitCode, 0);
|
|
assert.equal(result.report?.status, "passed");
|
|
assert.deepEqual(result.report?.scenarios[0]?.operations?.map((operation) => operation.status), ["passed", "passed", "passed", "passed", "passed", "passed", "passed", "passed"]);
|
|
assert.equal(closed, true);
|
|
const retainedPath = result.report?.evidence.find((item) => item.status === "captured")?.retainedPath;
|
|
assert.ok(retainedPath);
|
|
await access(retainedPath);
|
|
const saved = JSON.parse(await readFile(reportPath, "utf8")) as { evidence?: Array<{ status?: string; file?: string; retainedPath?: string }> };
|
|
const savedEvidence = saved.evidence?.find((item) => item.status === "captured");
|
|
const savedPath = savedEvidence?.retainedPath;
|
|
assert.equal(savedPath, retainedPath);
|
|
assert.equal(savedEvidence?.file, retainedPath);
|
|
await access(savedPath!);
|
|
assert.match(result.report?.scenarios[0]?.cleanup?.detail ?? "", /committed private evidence retained/);
|
|
|
|
delayAct = true;
|
|
await writeFile(path, JSON.stringify({
|
|
version: 1,
|
|
id: "timeout",
|
|
settings: { cdpUrl: "http://127.0.0.1:9222", allowedOrigins: ["http://fixture.test"], browser: { mode: "attach" } },
|
|
budgets: { timeoutMs: 20 },
|
|
operations: [
|
|
{ op: "step", step: { instruction: "Continue", action: "click", postcondition: { kind: "text", value: "done" } } },
|
|
{ op: "step", step: { instruction: "Continue", action: "click", postcondition: { kind: "text", value: "done" } } },
|
|
],
|
|
}));
|
|
const timedOut = await runScenarioCli(["--cwd", directory, "scenario.json"], {
|
|
sessionManager: manager,
|
|
runtimeFactory: { create(options) { artifactStore = options.artifacts; return runtime; } },
|
|
});
|
|
assert.equal(timedOut.exitCode, 1);
|
|
assert.equal(timedOut.report?.scenarios[0]?.operations?.length, 1);
|
|
assert.equal(timedOut.report?.scenarios[0]?.operations?.[0]?.status, "failed");
|
|
});
|
|
});
|
|
|
|
test("executes through the BrowserRuntime seam and reports startup failure safely", async () => {
|
|
await temporary(async (directory) => {
|
|
await writeFile(join(directory, "scenario.json"), scenario);
|
|
const reportPath = join(directory, "report.json");
|
|
const result = await runScenarioCli(["--cwd", directory, "--report", reportPath, "scenario.json"]);
|
|
assert.equal(result.exitCode, 1);
|
|
assert.equal(result.report?.status, "failed");
|
|
assert.match(result.report?.failure?.message ?? "", /Browser process failed to start/);
|
|
assert.equal(JSON.parse(await readFile(reportPath, "utf8")).version, 1);
|
|
});
|
|
});
|
|
|
|
test("reports malformed input without echoing file contents", async () => {
|
|
await temporary(async (directory) => {
|
|
await writeFile(join(directory, "bad.json"), "{\"password\":\"private-secret\"");
|
|
const result = await runScenarioCli(["--cwd", directory, "bad.json"]);
|
|
assert.equal(result.exitCode, 2);
|
|
assert.doesNotMatch(JSON.stringify(result.report), /private-secret/);
|
|
});
|
|
});
|