Files
reverse-engineering-anything/tests/playwrightBrowserScenarioProvider.test.ts
T

389 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { BrowserScenarioSessionPort } from "../src/browser/BrowserScenarioSessionPort.js";
import { PlaywrightBrowserScenarioProvider } from "../src/browser/PlaywrightBrowserScenarioProvider.js";
import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js";
import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js";
import {
browserScenarioSchema,
type BrowserScenario,
type BrowserScenarioAction,
} from "../src/domain/browserScenario.js";
import {
browserStepArtifactsSchema,
type BrowserScenarioEvent,
type BrowserStepArtifacts,
} from "../src/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({
schema_version: 1,
browser:
options.mode === "connect"
? {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
ownership: "external",
cleanup: "disconnect-only",
}
: {
mode: "launch",
executable_path: "/opt/chromium",
headless: true,
user_data: "temporary-owned",
cleanup: "close-and-delete-profile",
},
start_url: { url: "https://app.example.test/" },
allowed_origins: ["https://app.example.test"],
environment: {
viewport: { width: 1_280, height: 720 },
locale: "en-US",
timezone: "UTC",
color_scheme: "light",
reduced_motion: "reduce",
service_workers: "block",
},
actions: Array.from({ length: options.actions ?? 1 }, (_, index) => ({
step_id: `wait_${index}`,
action: "wait_for_timeout",
duration_ms: 1,
})),
storage: {},
request_replay: { mode: "disabled" },
secrets: [],
redaction: {
secret_values: "replace-with-secret-reference",
query_parameter_names: [],
},
capture: {
after_each_step: options.captures ?? ["url"],
at_end: [],
events: options.events ?? [],
},
limits: {
max_duration_ms: 10_000,
action_timeout_ms: 1_000,
navigation_timeout_ms: 1_000,
max_events: 100,
max_frames: 10,
max_workers: 10,
max_popups: 10,
max_websockets: 10,
max_dom_nodes: 100,
max_accessibility_nodes: 100,
max_screenshots: 2,
max_screenshot_bytes: 1_024,
max_storage_entries: 10,
max_total_metadata_bytes: 16_384,
},
approved: true,
});
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
const artifactState = (
kind: SnapshotKind,
requested: ReadonlySet<SnapshotKind>,
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;
eventTruncations: (
| "events"
| "frames"
| "workers"
| "popups"
| "websockets"
)[] = [];
onPerform: (() => void) | undefined;
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";
}
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: [] };
}
eventTruncationSections() {
return this.eventTruncations;
}
async perform(
_action: BrowserScenarioAction,
_timeout: number,
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<SnapshotKind>,
_budget: BrowserScenarioCaptureBudget,
): Promise<BrowserStepArtifacts> {
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("captures initial and action steps and closes its launched session", async () => {
const session = new FakeSession("launch");
const provider = new PlaywrightBrowserScenarioProvider({
open: () => Promise.resolve(session),
});
const result = await provider.captureScenario(scenario());
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.steps.map(({ step_index }) => step_index)).toEqual([
0, 1,
]);
expect(result.value.browser).toMatchObject({
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
});
expect(result.value.completeness).toMatchObject({
status: "complete",
equality_eligible: true,
});
expect(session.closeCalls).toBe(1);
});
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("makes event limit truncation ineligible for equality", async () => {
const session = new FakeSession("launch");
session.eventTruncations = ["events", "websockets"];
const provider = new PlaywrightBrowserScenarioProvider({
open: () => Promise.resolve(session),
});
const result = await provider.captureScenario(
scenario({ events: ["websockets"] }),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.completeness).toMatchObject({
status: "truncated",
equality_eligible: false,
truncated_sections: ["events", "websockets"],
});
});
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);
});
});
describe("BrowserScenarioCaptureBudget", () => {
it("enforces cumulative screenshot and metadata limits", () => {
const budget = new BrowserScenarioCaptureBudget(1, 10);
expect(budget.claimScreenshot()).toBe(true);
expect(budget.claimScreenshot()).toBe(false);
expect(budget.claimMetadata(6)).toBe(true);
expect(budget.claimMetadata(5)).toBe(false);
expect(budget.claimMetadata(4)).toBe(true);
});
});