mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 19:35:56 +00:00
* fix(browser): capture selected events in popup pages Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> * fix(browser): capture popup network and report early event gaps * fix(browser): deduplicate owned popup discovery * test(browser): group popup selection coverage with the scenario event suite Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> * test(browser): verify popup evidence through CLI and MCP * fix(browser): expose shared-context event coverage limits --------- Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Co-authored-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
327 lines
9.9 KiB
TypeScript
327 lines
9.9 KiB
TypeScript
// 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<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;
|
|
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<SnapshotKind>,
|
|
_signal?: AbortSignal,
|
|
): 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("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);
|
|
});
|
|
});
|