// Fake-backed composition coverage; real browser verification lives in // `npm run verify:browser`. import { describe, expect, it } from "vitest"; import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js"; import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { browserScenarioSchema, type BrowserScenario, type BrowserScenarioAction, } from "../domain/browserScenario.js"; import { browserStepArtifactsSchema, type BrowserScenarioEvent, type BrowserStepArtifacts, } from "../domain/browserScenarioCapture.js"; const scenario = ( options: { readonly mode?: "launch" | "connect"; readonly captures?: readonly ( | "screenshot" | "dom" | "accessibility" | "url" | "history" | "storage" )[]; readonly events?: readonly ( | "console" | "page-errors" | "network" | "websockets" | "frames" | "workers" | "popups" | "downloads" )[]; readonly actions?: number; } = {}, ): BrowserScenario => browserScenarioSchema.parse({ browser: options.mode === "connect" ? { mode: "connect", cdp_endpoint: "http://127.0.0.1:9222", target_id: "page-1", } : { mode: "launch", executable_path: "/opt/chromium", }, start_url: { url: "https://app.example.test/" }, actions: Array.from({ length: options.actions ?? 1 }, (_, index) => ({ step_id: `wait_${index}`, action: "wait_for_timeout", duration_ms: 1, })), storage: {}, secrets: [], capture: { after_each_step: options.captures ?? ["url"], at_end: [], events: options.events ?? [], }, }); type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; const artifactState = ( kind: SnapshotKind, requested: ReadonlySet, incomplete: boolean, ) => { if (!requested.has(kind)) return { state: "not_requested" as const }; if (kind === "url") return { state: "captured" as const, value: sanitizeBrowserUrl("https://app.example.test/current"), }; if (incomplete && kind === "screenshot") return { state: "truncated" as const, observed: 2_048, retained: 0, reason: "fixture limit", }; return { state: "missing" as const, reason: "fixture unavailable" }; }; class FakeSession implements BrowserScenarioSessionPort { readonly product = "Fake Chromium"; readonly version = "1"; readonly initialUrl = "about:blank"; readonly processOwnership: "provider-owned" | "external"; closeCalls = 0; performCalls = 0; captureCalls = 0; onPerform: (() => void) | undefined; eventGaps: readonly string[] = []; private step = 0; constructor( readonly mode: "launch" | "connect", private readonly incomplete = false, private readonly failAction = false, private readonly failCapture = false, ) { this.processOwnership = mode === "launch" ? "provider-owned" : "external"; } currentUrl() { return "https://app.example.test/current"; } sanitizeUrl(value: string) { return sanitizeBrowserUrl(value); } setStep(index: number) { this.step = index; } nextEventSequence() { return 1; } lastEventSequence() { return 0; } events(): { readonly retained: number; readonly dropped: number; readonly items: readonly BrowserScenarioEvent[]; } { return { retained: 0, dropped: 0, items: [] }; } eventLimitations(): readonly string[] { return this.eventGaps; } async perform(_action: BrowserScenarioAction, signal?: AbortSignal) { this.performCalls += 1; this.onPerform?.(); if (signal?.aborted === true) throw new Error("request cancelled"); if (this.failAction) throw new Error("fixture secret action failure"); } capture( requested: ReadonlySet, _signal?: AbortSignal, ): Promise { this.captureCalls += 1; if (this.failCapture) return Promise.reject(new Error("capture failed")); return Promise.resolve( browserStepArtifactsSchema.parse({ screenshot: artifactState("screenshot", requested, this.incomplete), dom: artifactState("dom", requested, this.incomplete), accessibility: artifactState( "accessibility", requested, this.incomplete, ), url: artifactState("url", requested, this.incomplete), history: artifactState("history", requested, this.incomplete), storage: artifactState("storage", requested, this.incomplete), }), ); } close() { this.closeCalls += 1; return Promise.resolve( this.mode === "launch" ? ("terminated-owned-process" as const) : ("disconnected-external" as const), ); } redactError(error: unknown) { return error instanceof Error ? error.message.replaceAll("secret", "[REDACTED]") : "fixture failure"; } } describe("PlaywrightBrowserScenarioProvider", () => { it("makes reported event gaps ineligible for equality even in launch mode", async () => { const session = new FakeSession("launch"); session.eventGaps = ["Popup frames before discovery are unavailable"]; const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario( scenario({ events: ["frames"] }), ); expect(result.ok).toBe(true); if (!result.ok) throw result.error; expect(result.value.completeness).toMatchObject({ status: "incomplete", equality_eligible: false, missing_sections: ["events"], }); expect(result.value.limitations).toContain(session.eventGaps[0]); }); it("returns an initial state and every step beyond the former action ceiling", async () => { const session = new FakeSession("launch"); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario(scenario({ actions: 129 })); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.scenario.action_count).toBe(129); expect(result.value.steps).toHaveLength(130); expect(result.value.steps.at(-1)?.step_id).toBe("wait_128"); }); it("makes missing and truncated captures ineligible for equality", async () => { const session = new FakeSession("launch", true); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario( scenario({ captures: ["screenshot", "dom"] }), ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.completeness).toMatchObject({ status: "truncated", equality_eligible: false, missing_sections: ["dom"], truncated_sections: ["screenshot"], }); }); it("records one failed step, cancels later actions, and still cleans up", async () => { const session = new FakeSession("launch", false, true); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario(scenario({ actions: 2 })); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.steps.map(({ status }) => status)).toEqual([ "completed", "failed", "cancelled", ]); expect(result.value.steps[1]?.error).toBe( "fixture [REDACTED] action failure", ); expect(session.performCalls).toBe(1); expect(session.closeCalls).toBe(1); expect(result.value.completeness.equality_eligible).toBe(false); }); it("disconnects external CDP sessions and marks pre-attach events missing", async () => { const session = new FakeSession("connect"); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario( scenario({ mode: "connect" }), ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.browser).toMatchObject({ process_ownership: "external", cleanup: "disconnected-external", }); expect(result.value.completeness).toMatchObject({ status: "incomplete", equality_eligible: false, missing_sections: ["events"], }); expect(session.closeCalls).toBe(1); }); it("closes the session when initial capture fails", async () => { const session = new FakeSession("launch", false, false, true); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario(scenario()); expect(result.ok).toBe(false); expect(session.closeCalls).toBe(1); }); it("does not open a session for an already-cancelled request", async () => { let opens = 0; const provider = new PlaywrightBrowserScenarioProvider({ open: () => { opens += 1; return Promise.resolve(new FakeSession("launch")); }, }); const controller = new AbortController(); controller.abort(); const result = await provider.captureScenario(scenario(), { signal: controller.signal, }); expect(result.ok).toBe(false); expect(opens).toBe(0); }); it("cancels an active action and still closes the session", async () => { const controller = new AbortController(); const session = new FakeSession("launch"); session.onPerform = () => controller.abort(); const provider = new PlaywrightBrowserScenarioProvider({ open: () => Promise.resolve(session), }); const result = await provider.captureScenario(scenario(), { signal: controller.signal, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.steps[1]?.status).toBe("cancelled"); expect(session.closeCalls).toBe(1); }); });