mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
8e3bf56e11
An Electron scenario stopped at the first failed or cancelled action and returned no record for the remaining declared actions, so normalized_result.actions could not account for the whole scenario. Record every remaining action as cancelled with a not-executed error, the representation browser scenarios already use. Both runners now share one helper for that error, which also distinguishes a stop caused by request cancellation from one caused by an action failure. Fixes #5943 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1245 lines
40 KiB
TypeScript
1245 lines
40 KiB
TypeScript
import { failBrowserScenarioOperation } from "./PlaywrightScenarioBrowser.js";
|
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
// Fake-backed composition coverage; real browser verification lives in
|
|
// `npm run verify:browser`.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { BrowserScenarioSessionPort } from "./BrowserScenarioSessionPort.js";
|
|
import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js";
|
|
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
|
import { PlaywrightScenarioBrowserCleanupOwner } from "./PlaywrightScenarioBrowser.js";
|
|
import {
|
|
browserScenarioSchema,
|
|
type BrowserScenario,
|
|
} from "../domain/browserScenario.js";
|
|
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
|
|
import {
|
|
browserStepArtifactsSchema,
|
|
type BrowserScenarioEvent,
|
|
type BrowserScenarioEventFamily,
|
|
type BrowserStepArtifacts,
|
|
} from "../domain/browserScenarioCaptureValues.js";
|
|
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../domain/browserNetworkEvidence.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { compareBrowserScenarios } from "../domain/browserScenarioDiff.js";
|
|
import { compareBrowserScenariosInputSchema } from "../domain/browserScenarioDiffValues.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.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 ?? [],
|
|
},
|
|
});
|
|
|
|
const connectedPlatformEmulationLimitation = expect.stringContaining(
|
|
"Detaching a CDP page session may clear caller-owned navigator.platform emulation",
|
|
);
|
|
|
|
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
|
|
|
interface FakeSessionFailures {
|
|
readonly incomplete?: boolean;
|
|
readonly action?: Error;
|
|
readonly capture?: { readonly call: number; readonly error: Error };
|
|
readonly close?: Error;
|
|
}
|
|
|
|
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";
|
|
cleanupLease = new AnalysisCleanupLease(async () => {
|
|
await this.close();
|
|
}, ["browser_transport"]);
|
|
closeCalls = 0;
|
|
performCalls = 0;
|
|
captureCalls = 0;
|
|
onPerform: (() => void) | undefined;
|
|
onClose: (() => void) | undefined;
|
|
eventGaps: readonly string[] = [];
|
|
eventItems: readonly BrowserScenarioEvent[] = [];
|
|
|
|
constructor(
|
|
readonly mode: "launch" | "connect",
|
|
private readonly failures: FakeSessionFailures = {},
|
|
) {
|
|
this.processOwnership = mode === "launch" ? "provider-owned" : "external";
|
|
}
|
|
|
|
currentUrl() {
|
|
return "https://app.example.test/current";
|
|
}
|
|
|
|
sanitizeUrl(value: string) {
|
|
return sanitizeBrowserUrl(value);
|
|
}
|
|
|
|
setStep(_index: number) {}
|
|
|
|
nextEventSequence() {
|
|
return 1;
|
|
}
|
|
|
|
lastEventSequence() {
|
|
return 0;
|
|
}
|
|
|
|
events(): ReturnType<BrowserScenarioSessionPort["events"]> {
|
|
return {
|
|
retained: this.eventItems.length,
|
|
dropped: 0,
|
|
items: this.eventItems,
|
|
omissions: {
|
|
by_kind: [],
|
|
network_transactions: [],
|
|
network_transaction_range: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
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.failures.action !== undefined) throw this.failures.action;
|
|
}
|
|
|
|
capture(
|
|
requested: ReadonlySet<SnapshotKind>,
|
|
_signal?: AbortSignal,
|
|
): Promise<BrowserStepArtifacts> {
|
|
this.captureCalls += 1;
|
|
if (this.failures.capture?.call === this.captureCalls)
|
|
return Promise.reject(this.failures.capture.error);
|
|
return Promise.resolve(
|
|
browserStepArtifactsSchema.parse({
|
|
screenshot: artifactState(
|
|
"screenshot",
|
|
requested,
|
|
this.failures.incomplete === true,
|
|
),
|
|
dom: artifactState("dom", requested, this.failures.incomplete === true),
|
|
accessibility: artifactState(
|
|
"accessibility",
|
|
requested,
|
|
this.failures.incomplete === true,
|
|
),
|
|
url: artifactState("url", requested, this.failures.incomplete === true),
|
|
history: artifactState(
|
|
"history",
|
|
requested,
|
|
this.failures.incomplete === true,
|
|
),
|
|
storage: artifactState(
|
|
"storage",
|
|
requested,
|
|
this.failures.incomplete === true,
|
|
),
|
|
}),
|
|
);
|
|
}
|
|
|
|
close() {
|
|
this.closeCalls += 1;
|
|
this.onClose?.();
|
|
if (this.failures.close !== undefined)
|
|
return Promise.reject(this.failures.close);
|
|
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("reports response-body provenance uncertainty without marking event coverage missing", async () => {
|
|
const session = new FakeSession("launch");
|
|
const selected = scenario();
|
|
session.eventItems = [
|
|
{
|
|
sequence: 1,
|
|
step_index: 0,
|
|
kind: "response",
|
|
transaction_id: "request-1",
|
|
redirected_from_transaction_id: null,
|
|
method: "GET",
|
|
url: sanitizeBrowserUrl("https://app.example.test/main.js"),
|
|
resource_type: "script",
|
|
header_names: [],
|
|
status: 200,
|
|
failure: null,
|
|
},
|
|
{
|
|
sequence: 2,
|
|
step_index: 0,
|
|
kind: "network-content",
|
|
transaction_id: "request-1",
|
|
source_event_sequence: 1,
|
|
phase: "response",
|
|
headers: { state: "not_requested" },
|
|
body: {
|
|
state: "captured",
|
|
representation: "playwright-response-body-bytes",
|
|
encoding: "base64",
|
|
content: "",
|
|
bytes: 0,
|
|
sha256:
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
media_type: "text/javascript",
|
|
redacted: false,
|
|
},
|
|
},
|
|
];
|
|
const input = browserScenarioSchema.parse({
|
|
...selected,
|
|
capture: {
|
|
...selected.capture,
|
|
network: { ...selected.capture.network, response_body: true },
|
|
},
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
|
|
const result = await provider.captureScenario(input);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.limitations).toContain(
|
|
PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION,
|
|
);
|
|
expect(result.value.completeness.missing_sections).not.toContain("events");
|
|
expect(result.value.events.items[1]).toMatchObject({
|
|
kind: "network-content",
|
|
body: {
|
|
state: "captured",
|
|
representation: "playwright-response-body-bytes",
|
|
},
|
|
});
|
|
});
|
|
|
|
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(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(
|
|
scenario({ events: ["frames"] }),
|
|
);
|
|
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(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario({ actions: 129 }));
|
|
if (!result.ok) throw result.error;
|
|
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", { incomplete: true });
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(
|
|
scenario({ captures: ["screenshot", "dom"] }),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
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", {
|
|
action: new Error("fixture secret action failure"),
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario({ actions: 2 }));
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.steps.map(({ status }) => status)).toEqual([
|
|
"completed",
|
|
"failed",
|
|
"cancelled",
|
|
]);
|
|
expect(result.value.steps[1]?.error).toBe(
|
|
"fixture [REDACTED] action failure",
|
|
);
|
|
expect(result.value.steps[2]?.error).toBe(
|
|
"not executed after an earlier action failure",
|
|
);
|
|
expect(session.performCalls).toBe(1);
|
|
expect(session.closeCalls).toBe(1);
|
|
expect(result.value.completeness.equality_eligible).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("connected browser scenario captures", () => {
|
|
it("disconnects external CDP sessions and marks pre-attach events missing", async () => {
|
|
const session = new FakeSession("connect");
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(
|
|
scenario({ mode: "connect" }),
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
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(result.value.limitations).toEqual(
|
|
expect.arrayContaining([connectedPlatformEmulationLimitation]),
|
|
);
|
|
expect(session.closeCalls).toBe(1);
|
|
});
|
|
|
|
it("retains the attached platform-emulation limitation in partial observations", async () => {
|
|
const session = new FakeSession("connect", {
|
|
capture: { call: 1, error: new Error("fixture capture failure") },
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(
|
|
scenario({ mode: "connect" }),
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
details: {
|
|
partial_observation: {
|
|
capture: {
|
|
limitations: expect.arrayContaining([
|
|
connectedPlatformEmulationLimitation,
|
|
]),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider timing", () => {
|
|
it("keeps capture elapsed values valid across a backward wall-clock jump", async () => {
|
|
let wallClock = 10_000;
|
|
vi.spyOn(Date, "now").mockImplementation(() => wallClock);
|
|
const session = new FakeSession("launch");
|
|
session.onPerform = () => {
|
|
wallClock = 1;
|
|
};
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (!result.ok) throw result.error;
|
|
expect(wallClock).toBe(1);
|
|
expect(Number.isInteger(result.value.duration_ms)).toBe(true);
|
|
expect(result.value.duration_ms).toBeGreaterThanOrEqual(0);
|
|
expect(
|
|
result.value.steps.every(
|
|
({ elapsed_ms }) => Number.isInteger(elapsed_ms) && elapsed_ms >= 0,
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider comparison coverage", () => {
|
|
it("keeps quiet captures with different event families from comparing unchanged", async () => {
|
|
const capture = async (events: readonly BrowserScenarioEventFamily[]) => {
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(new FakeSession("launch")),
|
|
);
|
|
const result = await provider.captureScenario(scenario({ events }));
|
|
if (!result.ok) throw result.error;
|
|
return result.value;
|
|
};
|
|
const compare = async (
|
|
before: readonly BrowserScenarioEventFamily[],
|
|
after: readonly BrowserScenarioEventFamily[],
|
|
) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: await capture(before),
|
|
after_scenario: await capture(after),
|
|
}),
|
|
);
|
|
|
|
const recorded = await capture(["page-errors", "console", "console"]);
|
|
expect(recorded.completeness.equality_eligible).toBe(true);
|
|
expect(recorded.scenario.event_families).toEqual([
|
|
"console",
|
|
"page-errors",
|
|
]);
|
|
for (const [before, after] of [
|
|
[[], ["console"]],
|
|
[["console"], ["page-errors"]],
|
|
] as const) {
|
|
const compared = await compare(before, after);
|
|
expect(compared.overall_status).toBe("unknown");
|
|
expect(
|
|
compared.steps.flatMap((step) => step.artifact_diffs),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Event family selections differ"),
|
|
}),
|
|
);
|
|
}
|
|
expect(
|
|
(await compare(["console", "page-errors"], ["page-errors", "console"]))
|
|
.overall_status,
|
|
).toBe("unchanged");
|
|
});
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider cleanup", () => {
|
|
it("returns a cleanup failure with completed observations when close fails", async () => {
|
|
const session = new FakeSession("launch", {
|
|
action: new Error("fixture secret action failure"),
|
|
close: new Error("browser close failed"),
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario());
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
const projection = projectAnalysisError(result.error);
|
|
expect(projection).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
cleanup: "incomplete",
|
|
resources: ["browser_transport"],
|
|
partial_observation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: {
|
|
browser: { cleanup: "incomplete" },
|
|
steps: [
|
|
{ step_id: "scenario_start" },
|
|
{ step_id: "wait_0", status: "failed" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(session.closeCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider operation failures", () => {
|
|
it.each([{ cleanupFails: false }, { cleanupFails: true }])(
|
|
"retains typed partial data when operation fails and cleanupFails=$cleanupFails",
|
|
async ({ cleanupFails }) => {
|
|
const session = new FakeSession("launch", {
|
|
capture: {
|
|
call: 2,
|
|
error: new AnalysisOutputError(
|
|
"capture_browser_scenario",
|
|
"step capture failed",
|
|
{
|
|
capturedOutput: {
|
|
stdout: "partial stdout",
|
|
stderr: "",
|
|
truncated: false,
|
|
},
|
|
cleanup: {
|
|
reason: "capture cleanup is uncertain",
|
|
resources: ["capture_handle"],
|
|
},
|
|
},
|
|
),
|
|
},
|
|
...(cleanupFails ? { close: new Error("browser close failed") } : {}),
|
|
});
|
|
session.eventItems = [
|
|
{
|
|
sequence: 1,
|
|
step_index: 0,
|
|
kind: "console",
|
|
level: "log",
|
|
text: "navigation started",
|
|
url: sanitizeBrowserUrl("https://app.example.test/"),
|
|
},
|
|
];
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario());
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
const projection = projectAnalysisError(result.error);
|
|
expect(projection).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
...(cleanupFails
|
|
? {
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
operation: "capture_browser_scenario",
|
|
diagnostics: {
|
|
primary_error: {
|
|
details: { reason: "step capture failed" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
cleanup_error: { code: "cleanup_incomplete" },
|
|
},
|
|
}
|
|
: { reason: "step capture failed" }),
|
|
partial_observation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: {
|
|
browser: {
|
|
cleanup: cleanupFails
|
|
? "incomplete"
|
|
: "terminated-owned-process",
|
|
},
|
|
steps: [{ step_id: "scenario_start" }],
|
|
events: {
|
|
retained: 1,
|
|
dropped: 0,
|
|
items: [
|
|
expect.objectContaining({ text: "navigation started" }),
|
|
],
|
|
omissions: {
|
|
by_kind: [],
|
|
network_transactions: [],
|
|
network_transaction_range: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(result.error.capturedOutput).toEqual({
|
|
stdout: "partial stdout",
|
|
stderr: "",
|
|
truncated: false,
|
|
});
|
|
expect(result.error.cleanupResources).toEqual(
|
|
cleanupFails
|
|
? ["capture_handle", "browser_transport"]
|
|
: ["capture_handle"],
|
|
);
|
|
if (!cleanupFails)
|
|
expect(result.error.cleanup).toEqual({
|
|
reason: "capture cleanup is uncertain",
|
|
resources: ["capture_handle"],
|
|
});
|
|
expect(session.closeCalls).toBe(1);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider cancellation and timeout", () => {
|
|
it.each([
|
|
{
|
|
name: "cancellation",
|
|
error: new AnalysisCancelledError("capture_browser_scenario"),
|
|
code: "cancelled",
|
|
category: "cancelled",
|
|
details: { operation: "capture_browser_scenario", cleanup: "complete" },
|
|
},
|
|
{
|
|
name: "timeout",
|
|
error: new AnalysisTimeoutError("capture_browser_scenario", 250),
|
|
code: "provider_timeout",
|
|
category: "timeout",
|
|
details: { operation: "capture_browser_scenario", timeout_ms: 250 },
|
|
},
|
|
])(
|
|
"preserves $name category and details when cleanup succeeds",
|
|
async ({ error, code, category, details }) => {
|
|
const session = new FakeSession("launch", {
|
|
capture: { call: 1, error },
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario());
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
const projection = projectAnalysisError(result.error);
|
|
expect(projection).toMatchObject({
|
|
code,
|
|
category,
|
|
details: {
|
|
...details,
|
|
partial_observation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: { browser: { cleanup: "terminated-owned-process" } },
|
|
},
|
|
},
|
|
});
|
|
expect(session.closeCalls).toBe(1);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("PlaywrightBrowserScenarioProvider request cancellation", () => {
|
|
it("does not open a session for an already-cancelled request", async () => {
|
|
let opens = 0;
|
|
const provider = new PlaywrightBrowserScenarioProvider(() => {
|
|
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 cancellationReason = new Error("cancel active scenario");
|
|
const session = new FakeSession("launch");
|
|
session.onPerform = () => controller.abort(cancellationReason);
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
const result = await provider.captureScenario(scenario({ actions: 2 }), {
|
|
signal: controller.signal,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
partialObservation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: {
|
|
browser: { cleanup: "terminated-owned-process" },
|
|
steps: [
|
|
{ status: "completed" },
|
|
{ status: "cancelled" },
|
|
{
|
|
status: "cancelled",
|
|
error: "not executed after scenario request cancellation",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (result.ok) throw new Error("Expected active scenario cancellation");
|
|
expect(session.performCalls).toBe(1);
|
|
expect(result.error.cause).toBe(cancellationReason);
|
|
expect(session.closeCalls).toBe(1);
|
|
});
|
|
|
|
it("retains the completed capture when cancellation arrives during final close", async () => {
|
|
const controller = new AbortController();
|
|
const cancellationReason = new Error("cancel final close");
|
|
const session = new FakeSession("launch");
|
|
session.onClose = () => controller.abort(cancellationReason);
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
|
|
const result = await provider.captureScenario(scenario(), {
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(session.closeCalls).toBe(1);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
partialObservation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: {
|
|
browser: { cleanup: "terminated-owned-process" },
|
|
steps: [{ status: "completed" }, { status: "completed" }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (result.ok) throw new Error("Expected final-close cancellation");
|
|
expect(result.error.cause).toBe(cancellationReason);
|
|
});
|
|
|
|
it("retains final-close cancellation alongside failed cleanup", async () => {
|
|
const controller = new AbortController();
|
|
const session = new FakeSession("launch", {
|
|
close: new Error("fixture close failed"),
|
|
});
|
|
session.onClose = () => controller.abort();
|
|
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
|
Promise.resolve(session),
|
|
);
|
|
|
|
const result = await provider.captureScenario(scenario(), {
|
|
signal: controller.signal,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "ProviderAdapterError",
|
|
cleanupIncomplete: true,
|
|
partialObservation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: {
|
|
browser: { cleanup: "incomplete" },
|
|
steps: [{ status: "completed" }, { status: "completed" }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (result.ok)
|
|
throw new Error("Expected final-close cancellation with cleanup");
|
|
let primary = result.error.cleanupPrimary;
|
|
while (primary?.cleanupPrimary !== undefined)
|
|
primary = primary.cleanupPrimary;
|
|
expect(primary).toMatchObject({ _tag: "AnalysisCancelledError" });
|
|
expect(primary?.cause).toBe(controller.signal.reason);
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
operation: "capture_browser_scenario",
|
|
});
|
|
expect(session.closeCalls).toBe(2);
|
|
});
|
|
});
|
|
|
|
it("shares concurrent close and retries browser cleanup after failure", async () => {
|
|
const browserClose = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockRejectedValueOnce(new Error("browser close failed"))
|
|
.mockResolvedValue(undefined);
|
|
const removeProfile = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockResolvedValue(undefined);
|
|
const finishEvents = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockResolvedValue(undefined);
|
|
const cleanupSettlements: boolean[] = [];
|
|
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: (disconnectAtBoundary) =>
|
|
disconnectAtBoundary(() => browserClose()),
|
|
removeProfile,
|
|
onSettled: (browserClosed) => cleanupSettlements.push(browserClosed),
|
|
});
|
|
|
|
const first = cleanup.close(finishEvents);
|
|
const concurrent = cleanup.close(finishEvents);
|
|
await expect(first).rejects.toMatchObject({ reason: "cleanup_failed" });
|
|
await expect(concurrent).rejects.toMatchObject({ reason: "cleanup_failed" });
|
|
expect(browserClose).toHaveBeenCalledTimes(1);
|
|
expect(removeProfile).not.toHaveBeenCalled();
|
|
expect(finishEvents).toHaveBeenCalledTimes(1);
|
|
expect(cleanupSettlements).toEqual([false]);
|
|
|
|
await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
|
|
await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
|
|
expect(browserClose).toHaveBeenCalledTimes(2);
|
|
expect(removeProfile).toHaveBeenCalledTimes(1);
|
|
expect(finishEvents).toHaveBeenCalledTimes(1);
|
|
expect(cleanupSettlements).toEqual([false, true]);
|
|
});
|
|
|
|
it("keeps failed event finalization failed across cleanup retries", async () => {
|
|
const finishEvents = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockRejectedValue(new Error("event finalization failed"));
|
|
const closeBrowser = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockResolvedValue(undefined);
|
|
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: (disconnectAtBoundary) =>
|
|
disconnectAtBoundary(() => closeBrowser()),
|
|
removeProfile: undefined,
|
|
});
|
|
|
|
await expect(cleanup.close(finishEvents)).rejects.toMatchObject({
|
|
reason: "cleanup_failed",
|
|
});
|
|
await expect(cleanup.close(finishEvents)).rejects.toMatchObject({
|
|
reason: "cleanup_failed",
|
|
});
|
|
expect(finishEvents).toHaveBeenCalledTimes(1);
|
|
expect(closeBrowser).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["launch", "connect"] as const)(
|
|
"reports locale coverage in %s success and retained operation/cleanup failures",
|
|
async (mode) => {
|
|
const input = scenario({ mode });
|
|
input.environment.locale = "fr-FR";
|
|
for (const failures of [
|
|
{},
|
|
{
|
|
capture: {
|
|
call: 1,
|
|
error: new AnalysisCancelledError("capture_browser_scenario"),
|
|
},
|
|
},
|
|
{ close: new Error("fixture cleanup interrupted") },
|
|
]) {
|
|
const session = new FakeSession(mode, failures);
|
|
const provider = new PlaywrightBrowserScenarioProvider(
|
|
async () => session,
|
|
);
|
|
const result = await provider.captureScenario(input);
|
|
expect(result.ok).toBe(!("capture" in failures || "close" in failures));
|
|
const expected = {
|
|
limitations:
|
|
mode === "connect"
|
|
? expect.arrayContaining([
|
|
"Attached locale fr-FR applies to Intl formatting only; navigator.language(s) and Accept-Language remain caller-controlled. Use launch mode for matching page and request languages.",
|
|
])
|
|
: expect.not.arrayContaining([
|
|
expect.stringContaining("Attached locale"),
|
|
]),
|
|
};
|
|
if (result.ok) expect(result.value).toMatchObject(expected);
|
|
else
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
details: {
|
|
partial_observation: {
|
|
kind: "browser-scenario-observation",
|
|
capture: expected,
|
|
},
|
|
},
|
|
});
|
|
expect(session.closeCalls).toBe(1);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"follows the browser owner through provider recovery (operation failed: %s)",
|
|
async (operationFailed) => {
|
|
const reason = { caller: "authored capture cancellation" };
|
|
const primary = new AnalysisCancelledError("capture_browser_scenario", {
|
|
cause: reason,
|
|
});
|
|
const session = new FakeSession(
|
|
"launch",
|
|
operationFailed ? { capture: { call: 1, error: primary } } : {},
|
|
);
|
|
let denied = true;
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: (disconnectAtBoundary) =>
|
|
disconnectAtBoundary(async () => undefined),
|
|
removeProfile: async () => {
|
|
if (denied) throw new Error("profile removal denied");
|
|
},
|
|
});
|
|
session.cleanupLease = owner.cleanupLease;
|
|
session.close = async () => {
|
|
await owner.close();
|
|
return "terminated-owned-process";
|
|
};
|
|
const provider = new PlaywrightBrowserScenarioProvider(async () => session);
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (result.ok) throw new Error("Expected retained cleanup");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources).toEqual(["browser_profile"]);
|
|
const partial = result.error.partialObservation;
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.cleanupResources).toEqual([]);
|
|
expect(result.error.partialObservation).toBe(partial);
|
|
if (operationFailed) {
|
|
let restored = result.error.cleanupPrimary;
|
|
while (restored?.cleanupPrimary !== undefined)
|
|
restored = restored.cleanupPrimary;
|
|
expect(restored).toBe(primary);
|
|
expect(primary.cause).toBe(reason);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cancelled",
|
|
});
|
|
}
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("resolves the returned failure after retrying a plain session cleanup capability", async () => {
|
|
const session = new FakeSession("launch");
|
|
let denied = true;
|
|
session.close = async () => {
|
|
if (denied) throw new Error("authored session cleanup denial");
|
|
return "terminated-owned-process";
|
|
};
|
|
const provider = new PlaywrightBrowserScenarioProvider(async () => session);
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (result.ok) throw new Error("Expected cleanup failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
const failed: unknown = await provider
|
|
.close()
|
|
.catch((cause: unknown) => cause);
|
|
expect(failed).toMatchObject({ cleanupIncomplete: true });
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(failed).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("retries every retained opening cleanup and preserves the original failure through mixed recovery", async () => {
|
|
let denied = true;
|
|
let healthyCloses = 0;
|
|
const physical = new AnalysisOutputError(
|
|
"capture_browser_scenario",
|
|
"authored open failure",
|
|
);
|
|
const provider = new PlaywrightBrowserScenarioProvider(
|
|
async (_scenario, options) => {
|
|
options.retainCleanup?.(
|
|
new AnalysisCleanupLease(async () => {
|
|
if (denied) throw new Error("first cleanup denied");
|
|
}, ["first cleanup"]),
|
|
);
|
|
options.retainCleanup?.(
|
|
new AnalysisCleanupLease(async () => {
|
|
healthyCloses++;
|
|
}, ["healthy cleanup"]),
|
|
);
|
|
throw physical;
|
|
},
|
|
);
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (result.ok) throw new Error("Expected opening failure");
|
|
const failed: unknown = await provider
|
|
.close()
|
|
.catch((cause: unknown) => cause);
|
|
expect(failed).toMatchObject({ cleanupIncomplete: true });
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(healthyCloses).toBe(1);
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(failed).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(result.error.cleanupPrimary).toBe(physical);
|
|
expect(healthyCloses).toBe(1);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("keeps a cancelled opening incomplete until its retained late cleanup finishes", async () => {
|
|
let finish: () => void = () => {};
|
|
const late = new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
const reason = { caller: "cancelled opening before late handshake" };
|
|
const primary = new AnalysisCancelledError("capture_browser_scenario", {
|
|
cause: reason,
|
|
});
|
|
const provider = new PlaywrightBrowserScenarioProvider(
|
|
async (_scenario, options) => {
|
|
options.retainCleanup?.(
|
|
new AnalysisCleanupLease(() => late, ["late opening"]),
|
|
);
|
|
throw primary;
|
|
},
|
|
);
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (result.ok) throw new Error("Expected cancelled opening");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
const closing = provider.close();
|
|
await Promise.resolve();
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
finish();
|
|
await closing;
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(result.error.cleanupPrimary).toBe(primary);
|
|
expect(primary.cause).toBe(reason);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cancelled",
|
|
});
|
|
} finally {
|
|
finish();
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"follows late browser acknowledgement without another retry (cancelled during close: %s)",
|
|
async (cancelled) => {
|
|
vi.useFakeTimers();
|
|
let acknowledge: () => void = () => {};
|
|
const acknowledgement = new Promise<void>((resolve) => {
|
|
acknowledge = resolve;
|
|
});
|
|
const caller = new AbortController();
|
|
const reason = { caller: "during final browser close" };
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: (disconnectAtBoundary) =>
|
|
disconnectAtBoundary(() => acknowledgement),
|
|
removeProfile: undefined,
|
|
});
|
|
const session = new FakeSession("launch");
|
|
session.cleanupLease = owner.cleanupLease;
|
|
session.close = async () => {
|
|
if (cancelled) caller.abort(reason);
|
|
await owner.close();
|
|
return "terminated-owned-process";
|
|
};
|
|
const provider = new PlaywrightBrowserScenarioProvider(async () => session);
|
|
try {
|
|
const pending = provider.captureScenario(scenario(), {
|
|
signal: caller.signal,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected timed out cleanup response");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
const partial = result.error.partialObservation;
|
|
acknowledge();
|
|
await owner.cleanupLease.release();
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(result.error.partialObservation).toBe(partial);
|
|
if (cancelled) {
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cancelled",
|
|
});
|
|
let primary = result.error.cleanupPrimary;
|
|
while (primary?.cleanupPrimary !== undefined)
|
|
primary = primary.cleanupPrimary;
|
|
expect(primary?.cause).toBe(reason);
|
|
}
|
|
} finally {
|
|
acknowledge();
|
|
await provider.close();
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains a profile-only opening lease without adding transport labels or waiting for another retry", async () => {
|
|
let denied = true;
|
|
let releases = 0;
|
|
const primary = new AnalysisOutputError(
|
|
"capture_browser_scenario",
|
|
"authored opening failure",
|
|
);
|
|
const lease = new AnalysisCleanupLease(async () => {
|
|
releases++;
|
|
if (denied) throw new Error("profile removal denied");
|
|
}, ["browser_profile"]);
|
|
const provider = new PlaywrightBrowserScenarioProvider(
|
|
async (_scenario, options) => {
|
|
try {
|
|
return await failBrowserScenarioOperation(lease, primary);
|
|
} catch (cause: unknown) {
|
|
options.retainCleanup?.(lease);
|
|
throw cause;
|
|
}
|
|
},
|
|
);
|
|
try {
|
|
const result = await provider.captureScenario(scenario());
|
|
if (result.ok) throw new Error("Expected opening failure");
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
cleanupResources: ["browser_profile"],
|
|
});
|
|
denied = false;
|
|
await lease.release();
|
|
expect(result.error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "unreadable_output",
|
|
});
|
|
await provider.close();
|
|
expect(releases).toBe(2);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("keeps an earlier physical failure primary when final cleanup also observes cancellation", async () => {
|
|
const caller = new AbortController();
|
|
const physical = new Error("physical capture failure");
|
|
const primary = new AnalysisOutputError(
|
|
"capture_browser_scenario",
|
|
physical.message,
|
|
{ cause: physical },
|
|
);
|
|
const session = new FakeSession("launch", {
|
|
capture: { call: 1, error: primary },
|
|
});
|
|
let denied = true;
|
|
session.close = async () => {
|
|
caller.abort({ caller: "later during final cleanup" });
|
|
if (denied) throw new Error("cleanup denied");
|
|
return "terminated-owned-process";
|
|
};
|
|
const provider = new PlaywrightBrowserScenarioProvider(async () => session);
|
|
try {
|
|
const result = await provider.captureScenario(scenario(), {
|
|
signal: caller.signal,
|
|
});
|
|
if (result.ok) throw new Error("Expected physical failure");
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "unreadable_output",
|
|
});
|
|
let restored = result.error.cleanupPrimary;
|
|
while (restored?.cleanupPrimary !== undefined)
|
|
restored = restored.cleanupPrimary;
|
|
expect(restored).toBe(primary);
|
|
expect(primary.cause).toBe(physical);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|