Files
rea/src/browser/PlaywrightScenarioSession.test.ts
T

1626 lines
52 KiB
TypeScript

import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { EventEmitter } from "node:events";
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
openBrowser: vi.fn(),
storage: undefined as
| {
initialize: ReturnType<typeof vi.fn>;
settle: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
| undefined,
finishEvents: vi.fn(),
policySend: vi.fn(),
policyTransportClose: vi.fn(),
policyBeginCleanupGrace: vi.fn(),
policyEvent: undefined as
| ((event: { method: string; params: unknown; sessionId?: string }) => void)
| undefined,
}));
vi.mock("./CdpTargetSession.js", () => ({
openCdpEndpoint: async () => ({
discovery: { targets: [{ id: "target" }] },
connection: {
send: mocks.policySend,
onEvent: (listener: typeof mocks.policyEvent) => {
mocks.policyEvent = listener;
return () => undefined;
},
close: mocks.policyTransportClose,
beginCleanupGrace: mocks.policyBeginCleanupGrace,
},
}),
attachCdpTargetSession: async (connection: unknown) => ({
connection,
sessionId: "policy-session",
}),
}));
vi.mock("./PlaywrightScenarioBrowser.js", () => ({
openPlaywrightScenarioBrowser: mocks.openBrowser,
failBrowserScenarioOperation: async (
cleanup: { release(): Promise<void> },
cause: unknown,
) => {
try {
await cleanup.release();
} catch (cleanupCause: unknown) {
throw new AggregateError([cause, cleanupCause]);
}
throw cause;
},
}));
vi.mock("./PlaywrightScenarioStorage.js", () => ({
PlaywrightScenarioStorage: {
create: vi.fn(async () => mocks.storage),
},
}));
vi.mock("./PlaywrightScenarioEvents.js", () => ({
PlaywrightScenarioEvents: class {
finish() {
return mocks.finishEvents();
}
result() {
return {
retained: 0,
dropped: 0,
items: [],
omissions: {
by_kind: [],
network_transactions: [],
network_transaction_range: null,
},
};
}
limitations() {
return [];
}
setStep() {}
nextSequence() {
return 1;
}
lastSequence() {
return 0;
}
},
}));
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { PlaywrightScenarioSession } from "./PlaywrightScenarioSession.js";
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "https://storage.example.test/" },
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
storage: {
local_storage: [
{
origin: "https://storage.example.test",
entries: [{ name: "seed", value: { source: "literal", value: "v" } }],
},
],
},
});
const deferred = () => {
let resolve: () => void = () => undefined;
const promise = new Promise<void>((complete) => {
resolve = complete;
});
return { promise, resolve };
};
const connectedScenario = (serviceWorkers: "block" | "allow") =>
browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: serviceWorkers },
});
const fixture = () => {
const page = Object.assign(new EventEmitter(), {
url: () => "about:blank",
goto: vi.fn(async () => undefined),
evaluate: vi.fn(async (_script: unknown): Promise<unknown> => null),
addInitScript: vi.fn(async (_script: unknown) => undefined),
context: () => context,
frames: () => [page],
mainFrame: () => page,
isDetached: () => false,
isClosed: (): boolean => false,
waitForLoadState: vi.fn(async () => undefined),
});
const context = Object.assign(new EventEmitter(), {
setDefaultTimeout: vi.fn(),
setDefaultNavigationTimeout: vi.fn(),
addCookies: vi.fn(async () => undefined),
browser: () => browser,
});
const browser = Object.assign(new EventEmitter(), {
version: () => "Chrome 154",
});
let browserClosed = false;
const close = vi.fn(
async (
finishEvents?: () => Promise<void>,
releaseGate?: { readonly revision: number; run(): Promise<void> },
) => {
await finishEvents?.();
if (releaseGate !== undefined) {
for (;;) {
const revision = releaseGate.revision;
await releaseGate.run();
if (releaseGate.revision === revision) break;
}
}
browserClosed = true;
},
);
const cleanup = {
close,
release: close,
cleanupLease: new AnalysisCleanupLease(
async () => {
await close();
},
{
get cleanupResources() {
return browserClosed ? [] : ["browser_transport"];
},
},
),
get cleanupResources() {
return browserClosed ? [] : ["browser_transport"];
},
get browserClosed() {
return browserClosed;
},
};
mocks.openBrowser.mockResolvedValue({
page,
context,
browser,
cleanup,
profilePath: "/tmp/profile",
});
mocks.storage = {
initialize: vi.fn(async () => undefined),
settle: vi.fn(async () => undefined),
close: vi.fn(async (stopLoading = false) => ({
loadingStopped: stopLoading,
})),
};
return { cleanup, page, storage: mocks.storage };
};
const policyResponse = (method: string): unknown =>
method === "Page.addScriptToEvaluateOnNewDocument"
? { identifier: "owned-policy-script" }
: method === "Runtime.evaluate"
? { result: { value: null } }
: {};
const prepareHeldPolicyRestoration = async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
const { promise: transportClosed, resolve: signalTransportClosed } =
deferred();
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
return {
cleanup,
page,
storage,
cancellation,
session,
finishRestoration,
started,
transportClosed,
};
};
const prepareLateStopRetry = async () => {
const {
cleanup,
page,
cancellation,
session,
finishRestoration,
started,
transportClosed,
} = await prepareHeldPolicyRestoration();
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
return { cleanup, page, session };
} catch (cause) {
vi.useRealTimers();
throw cause;
}
};
beforeEach(() => {
vi.clearAllMocks();
mocks.policySend.mockReset();
mocks.policyTransportClose.mockReset();
mocks.policyEvent = undefined;
mocks.policySend.mockImplementation(async (method: string) =>
policyResponse(method),
);
mocks.policyTransportClose.mockResolvedValue(undefined);
});
it("stops attached navigation without storage and retries rejected policy cleanup", async () => {
const { cleanup } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const failure = new Error("stopLoading rejected");
mocks.policySend.mockRejectedValueOnce(failure);
await expect(session.close(true)).rejects.toMatchObject({
cause: failure,
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close(true)).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(2);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("stops attached navigation when cancellation precedes the first close", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const cancellation = new AbortController();
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
cancellation.abort();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(1);
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(0);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("assigns attached navigation stopping to the storage owner once", async () => {
const { cleanup, storage } = fixture();
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
await expect(session.close(true)).resolves.toBe("disconnected-external");
expect(storage?.close).toHaveBeenCalledWith(true);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(0);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("transfers attached navigation stopping after storage cleanup fails", async () => {
const { cleanup, storage } = fixture();
const storageFailure = new Error("storage stop unconfirmed");
storage?.close.mockRejectedValueOnce(storageFailure);
const policyFailure = new Error("policy stop unconfirmed");
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
mocks.policySend.mockRejectedValueOnce(policyFailure);
await expect(session.close(true)).rejects.toMatchObject({
cause: { errors: [policyFailure, storageFailure] },
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(storage?.close).toHaveBeenCalledWith(true);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(2);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("transfers a late stop request after storage has already released", async () => {
const { cleanup, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{
signal: cancellation.signal,
},
);
const restorationFailure = new Error("registration restoration failed");
let failRestoration = true;
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate" && failRestoration) {
failRestoration = false;
throw restorationFailure;
}
return policyResponse(method);
});
await expect(session.close(false)).rejects.toMatchObject({
cause: restorationFailure,
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
cancellation.abort();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(storage?.close.mock.calls).toEqual([[false]]);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it.each(["pending", "settled"] as const)(
"honors a late stop after policy restoration is %s",
async (phase) => {
const {
cleanup,
page,
storage,
cancellation,
session,
finishRestoration,
started,
transportClosed,
} = await prepareHeldPolicyRestoration();
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({
ok: false,
cause: { cleanupIncomplete: true },
});
expect(cleanup.close).not.toHaveBeenCalled();
if (phase === "settled") {
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
}
cancellation.abort();
const retry = session.close();
if (phase === "pending") {
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
}
await expect(retry).resolves.toBe("disconnected-external");
} finally {
vi.useRealTimers();
}
expect(storage?.close.mock.calls).toEqual([[false]]);
const policyStops = mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
).length;
const frameStops = page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
).length;
expect(policyStops + frameStops).toBe(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1);
},
);
it("transfers cancellation to a no-stop cleanup already in progress", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
let browserWasOpenAtStop = false;
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()")
browserWasOpenAtStop = cleanup.browserClosed === false;
return null;
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
if (method === "Page.stopLoading")
browserWasOpenAtStop = cleanup.browserClosed === false;
return policyResponse(method);
});
const firstClose = session.close(false);
await started;
cancellation.abort();
const retry = session.close();
finishRestoration();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
const policyStops = mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
).length;
const frameStops = page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
).length;
expect(policyStops + frameStops).toBe(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(browserWasOpenAtStop).toBe(true);
});
it("does not repeat a stop confirmed by storage during a late retry", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: true });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
const firstClose = session.close(false);
await started;
cancellation.abort();
const retry = session.close();
finishRestoration();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(storage?.close).toHaveBeenCalledWith(false);
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(0);
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(0);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it.each(["block", "allow"] as const)(
"transfers cancellation during final event cleanup with service workers set to %s",
async (serviceWorkers) => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario(serviceWorkers);
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: heldEvents, resolve: finishEvents } = deferred();
const { promise: eventsStarted, resolve: finishStarted } = deferred();
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
let browserWasOpenAtStop = false;
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()")
browserWasOpenAtStop = cleanup.browserClosed === false;
return null;
});
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const retry = session.close();
finishEvents();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
if (serviceWorkers === "allow")
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(storage?.close).toHaveBeenCalledWith(false);
expect(browserWasOpenAtStop).toBe(true);
expect(cleanup.close).toHaveBeenCalledTimes(1);
},
);
it("stops a connected page without storage or a service-worker policy owner", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const cancellation = new AbortController();
const attached = connectedScenario("allow");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: heldEvents, resolve: finishEvents } = deferred();
const { promise: eventsStarted, resolve: finishStarted } = deferred();
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const retry = session.close();
finishEvents();
await expect(Promise.all([firstClose, retry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retries a failed connected-page stop when service workers are allowed", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("allow");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: heldEvents, resolve: finishEvents } = deferred();
const { promise: eventsStarted, resolve: finishStarted } = deferred();
mocks.finishEvents.mockImplementation(async () => {
finishStarted();
await heldEvents;
});
const failedFrame = {
isDetached: () => false,
evaluate: vi.fn(async () => {
throw new Error("main frame stop failed");
}),
} as unknown as ReturnType<typeof page.mainFrame>;
const freshFrame = {
isDetached: () => false,
evaluate: vi.fn(async () => undefined),
} as unknown as ReturnType<typeof page.mainFrame>;
vi.spyOn(page, "mainFrame")
.mockReturnValueOnce(failedFrame)
.mockReturnValue(freshFrame);
const firstClose = session.close(false);
await eventsStarted;
cancellation.abort();
const concurrentClose = session.close();
finishEvents();
await expect(
Promise.allSettled([firstClose, concurrentClose]),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]),
);
expect(cleanup.browserClosed).toBe(false);
await expect(session.close()).resolves.toBe("disconnected-external");
expect(failedFrame.evaluate).toHaveBeenCalledTimes(1);
expect(freshFrame.evaluate).toHaveBeenCalledTimes(1);
expect(storage?.close).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(2);
});
it("retains a late fallback stop failure for retry", async () => {
const { cleanup, page, session } = await prepareLateStopRetry();
try {
const stopFailure = new Error("late main-frame stop failed");
const detachedFrame = {
evaluate: vi.fn(async () => {
throw stopFailure;
}),
isDetached: () => true,
} as unknown as ReturnType<typeof page.mainFrame>;
const freshFrame = {
evaluate: vi.fn(async () => null),
isDetached: () => false,
} as unknown as ReturnType<typeof page.mainFrame>;
vi.spyOn(page, "mainFrame")
.mockReturnValueOnce(detachedFrame)
.mockReturnValue(freshFrame);
const failedRetry = session.close().then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await expect(failedRetry).resolves.toMatchObject({
ok: false,
cause: { cleanupIncomplete: true },
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(detachedFrame.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(freshFrame.evaluate).toHaveBeenCalledWith("globalThis.stop()");
} finally {
vi.useRealTimers();
}
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("coalesces concurrent late-stop close retries", async () => {
const { cleanup, page, session } = await prepareLateStopRetry();
try {
const { promise: heldStop, resolve: finishStop } = deferred();
const { promise: startedStop, resolve: stopStarted } = deferred();
page.evaluate.mockImplementation(async (script: unknown) => {
if (script === "globalThis.stop()") {
stopStarted();
await heldStop;
}
return null;
});
const firstRetry = session.close();
await startedStop;
const secondRetry = session.close();
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(1);
finishStop();
await expect(Promise.all([firstRetry, secondRetry])).resolves.toEqual([
"disconnected-external",
"disconnected-external",
]);
} finally {
vi.useRealTimers();
}
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retains transferred navigation stopping after setup cleanup fails", async () => {
const { cleanup, storage } = fixture();
const cancellation = new AbortController();
const setupFailure = new Error("storage initialization failed");
storage?.initialize.mockImplementationOnce(async () => {
cancellation.abort();
throw setupFailure;
});
storage?.close.mockRejectedValueOnce(new Error("storage stop unconfirmed"));
const policyFailure = new Error("policy stop unconfirmed");
let failPolicyStop = true;
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Page.stopLoading" && failPolicyStop) {
failPolicyStop = false;
throw policyFailure;
}
return policyResponse(method);
});
let retainedCleanup: AnalysisCleanupLease | undefined;
const attached = connectedScenario("block");
await expect(
PlaywrightScenarioSession.open(
attached,
{},
{
signal: cancellation.signal,
retainCleanup: (close) => {
retainedCleanup = close;
},
},
),
).rejects.toBeInstanceOf(AggregateError);
expect(cleanup.close).not.toHaveBeenCalled();
if (retainedCleanup === undefined)
throw new Error("failed cleanup was not retained");
await expect(retainedCleanup.release()).resolves.toBeUndefined();
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(2);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("allows the bounded policy cleanup chain to cross one second", async () => {
const { cleanup } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const { promise: stopped, resolve: releaseStop } = deferred();
const { promise: started, resolve: stopStarted } = deferred();
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Page.stopLoading") {
stopStarted();
await stopped;
}
return policyResponse(method);
});
vi.useFakeTimers();
try {
let settled = false;
const closing = session.close(true).then(
() => {
settled = true;
return { ok: true as const };
},
(cause: unknown) => {
settled = true;
return { ok: false as const, cause };
},
);
await started;
await vi.advanceTimersByTimeAsync(1_100);
expect(settled).toBe(false);
releaseStop();
await expect(closing).resolves.toEqual({ ok: true });
} finally {
vi.useRealTimers();
}
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("does not await policy initialization after its child target detaches", async () => {
const { cleanup } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const { promise: started, resolve: childStarted } = deferred();
const { promise: held, resolve: releaseChild } = deferred();
mocks.policySend.mockImplementation(
async (method: string, _params: unknown, target: string) => {
if (method === "Page.enable" && target === "child-session") {
childStarted();
await held;
}
return policyResponse(method);
},
);
mocks.policyEvent?.({
method: "Target.attachedToTarget",
params: {
sessionId: "child-session",
targetInfo: { type: "iframe" },
},
sessionId: "policy-session",
});
await started;
const closing = session.close();
mocks.policyEvent?.({
method: "Target.detachedFromTarget",
params: { sessionId: "child-session" },
});
try {
await expect(closing).resolves.toBe("disconnected-external");
} finally {
releaseChild();
}
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("skips policy restoration for a child without a committed document", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const pendingChild = {
url: () => "",
isDetached: () => false,
evaluate: vi.fn(() => new Promise(() => undefined)),
};
const session = await PlaywrightScenarioSession.open(
browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
}),
{},
{},
);
page.frames = () => [page, pendingChild as unknown as typeof page];
await expect(session.close()).resolves.toBe("disconnected-external");
expect(pendingChild.evaluate).not.toHaveBeenCalled();
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("rejects an init script accepted by CDP when the live realm is unprotected", async () => {
const { page, cleanup } = fixture();
page.evaluate
.mockResolvedValueOnce(null)
.mockResolvedValueOnce("policy_unprotected");
const attached = connectedScenario("block");
await expect(
PlaywrightScenarioSession.open(attached, {}, {}),
).rejects.toMatchObject({
reason: "target_not_allowed",
});
expect(
mocks.policySend.mock.calls.some(
([method]) => method === "Page.addScriptToEvaluateOnNewDocument",
),
).toBe(true);
expect(
mocks.policySend.mock.calls.some(
([method]) => method === "Page.removeScriptToEvaluateOnNewDocument",
),
).toBe(true);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("restores an owned policy script after later setup fails", async () => {
const { cleanup } = fixture();
const setupFailure = new Error("auto-attach rejected");
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Target.setAutoAttach") throw setupFailure;
return policyResponse(method);
});
const attached = connectedScenario("block");
await expect(PlaywrightScenarioSession.open(attached, {}, {})).rejects.toBe(
setupFailure,
);
expect(mocks.policySend).toHaveBeenCalledWith(
"Page.removeScriptToEvaluateOnNewDocument",
{ identifier: "owned-policy-script" },
"policy-session",
);
expect(
mocks.policySend.mock.calls.some(
([method]) => method === "Runtime.evaluate",
),
).toBe(true);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retains an owned script identifier after rejected removal", async () => {
const { cleanup } = fixture();
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const failure = new Error("removeScript rejected");
mocks.policySend.mockRejectedValueOnce(failure);
await expect(session.close()).rejects.toMatchObject({
cause: failure,
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
const removals = mocks.policySend.mock.calls.filter(
([method]) => method === "Page.removeScriptToEvaluateOnNewDocument",
);
expect(removals).toHaveLength(2);
expect(
removals.every(([, params]) => params.identifier === "owned-policy-script"),
).toBe(true);
});
it("retries transport close without detaching an already released policy session", async () => {
fixture();
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const failure = new Error("transport close rejected");
mocks.policyTransportClose.mockRejectedValueOnce(failure);
await expect(session.close()).rejects.toMatchObject({
cause: failure,
cleanupIncomplete: true,
});
await expect(session.close()).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Target.detachFromTarget",
),
).toHaveLength(1);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(2);
});
it.each(["closed page", "detached target"])(
"releases its policy transport after a %s",
async (terminal) => {
const { page } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
if (terminal === "closed page") page.isClosed = () => true;
else
mocks.policyEvent?.({
method: "Target.detachedFromTarget",
params: { sessionId: "policy-session" },
});
await expect(session.close(true)).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.some(
([method]) => method === "Target.detachFromTarget",
),
).toBe(false);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1);
if (terminal === "detached target") {
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(page.evaluate).toHaveBeenCalledWith(
expect.stringContaining("policy_owned"),
);
}
},
);
it("retains a detached policy connection until the main realm is restored", async () => {
const { cleanup, page } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
mocks.policyEvent?.({
method: "Target.detachedFromTarget",
params: { sessionId: "policy-session" },
});
page.evaluate
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce("policy_owned")
.mockResolvedValueOnce(null);
await expect(session.close(true)).rejects.toMatchObject({
cause: {
message: expect.stringContaining(
"service-worker policy restoration could not be confirmed",
),
},
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
expect(mocks.policyTransportClose).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(page.evaluate).toHaveBeenCalledWith("globalThis.stop()");
expect(
page.evaluate.mock.calls.filter(
([script]) => script === "globalThis.stop()",
),
).toHaveLength(1);
expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("uses browser disconnect as terminal storage release after detach failure", async () => {
const { cleanup, storage } = fixture();
const detachFailure = new Error("CDP detach rejected");
storage.close.mockRejectedValueOnce(detachFailure);
const session = await PlaywrightScenarioSession.open(scenario, {}, {});
await expect(session.close()).resolves.toBe("terminated-owned-process");
expect(storage.close).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(mocks.finishEvents).toHaveBeenCalledTimes(1);
await expect(session.close()).resolves.toBe("terminated-owned-process");
expect(storage.close).toHaveBeenCalledTimes(1);
});
it("leaves attached service-worker state alone when the policy allows it", async () => {
const { cleanup, page } = fixture();
const attached = connectedScenario("allow");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
expect(mocks.openBrowser).toHaveBeenCalledWith(
attached,
{},
expect.any(Object),
);
expect(page.evaluate).not.toHaveBeenCalled();
expect(page.addInitScript).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("blocks new attached service-worker registration without removing existing registrations", async () => {
const { cleanup, page } = fixture();
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(attached, {}, {});
expect(page.evaluate).toHaveBeenCalledTimes(3);
const injection = mocks.policySend.mock.calls.find(
([method]) => method === "Page.addScriptToEvaluateOnNewDocument",
)?.[1];
expect(injection.source).toContain(
"service workers are blocked by scenario policy",
);
expect(injection.source).not.toContain("getRegistrations");
expect(injection.source).not.toContain("unregister");
await expect(session.close()).resolves.toBe("disconnected-external");
expect(mocks.policySend).toHaveBeenCalledWith(
"Page.removeScriptToEvaluateOnNewDocument",
{ identifier: "owned-policy-script" },
"policy-session",
);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("retains the attached connection when registration restoration fails", async () => {
const { cleanup } = fixture();
mocks.storage = undefined;
const attached = browserScenarioSchema.parse({
...scenario,
browser: {
mode: "connect",
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "target",
},
environment: { service_workers: "block" },
storage: {},
});
const session = await PlaywrightScenarioSession.open(attached, {}, {});
const failure = new Error("registration restoration failed");
let failRestoration = true;
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate" && failRestoration) {
failRestoration = false;
throw failure;
}
return policyResponse(method);
});
await expect(session.close(true)).rejects.toMatchObject({
cause: failure,
cleanupIncomplete: true,
});
expect(cleanup.close).not.toHaveBeenCalled();
await expect(session.close()).resolves.toBe("disconnected-external");
expect(
mocks.policySend.mock.calls.filter(
([method]) => method === "Page.stopLoading",
),
).toHaveLength(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
await expect(session.close()).resolves.toBe("disconnected-external");
});
it("cancels a stalled policy preflight without allowing late setup", async () => {
const { cleanup, page } = fixture();
let release: () => void = () => undefined;
const { promise: started, resolve: entered } = deferred();
page.evaluate.mockImplementationOnce(async () => {
entered();
await new Promise<void>((resolve) => {
release = resolve;
});
return false;
});
const cancellation = new AbortController();
const opening = PlaywrightScenarioSession.open(
connectedScenario("block"),
{},
{ signal: cancellation.signal },
);
const outcome = expect(opening).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
await started;
cancellation.abort();
await outcome;
expect(cleanup.close).toHaveBeenCalledTimes(1);
expect(cleanup.close.mock.calls[0]).toHaveLength(1);
release();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mocks.policySend).not.toHaveBeenCalled();
});
it("waits for a pending registration installation before cancelled cleanup", async () => {
const { cleanup, page } = fixture();
const { promise: installed, resolve: releaseInstallation } = deferred();
const { promise: started, resolve: installationStarted } = deferred();
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Page.addScriptToEvaluateOnNewDocument") {
installationStarted();
await installed;
return { identifier: "owned-policy-script" };
}
return policyResponse(method);
});
const cancellation = new AbortController();
const opening = PlaywrightScenarioSession.open(
connectedScenario("block"),
{},
{ signal: cancellation.signal },
);
const outcome = expect(opening).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
await started;
cancellation.abort();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(cleanup.close).not.toHaveBeenCalled();
expect(page.evaluate).toHaveBeenCalledTimes(1);
releaseInstallation();
await outcome;
const restoration = mocks.policySend.mock.calls.find(
([method]) => method === "Runtime.evaluate",
)?.[1];
expect(restoration).toMatchObject({
expression: expect.stringContaining("policy_owned"),
});
expect(mocks.policySend).toHaveBeenCalledWith(
"Page.stopLoading",
{},
"policy-session",
);
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("rejects attached block when caller-owned registrations already exist", async () => {
const { cleanup, page } = fixture();
page.evaluate.mockResolvedValueOnce("service_worker_blocked");
const attached = connectedScenario("block");
await expect(
PlaywrightScenarioSession.open(attached, {}, {}),
).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "service_worker_blocked",
});
expect(page.addInitScript).not.toHaveBeenCalled();
expect(cleanup.close).toHaveBeenCalledTimes(1);
});
it("preserves both cleanup failures and retries retained owners", async () => {
const { cleanup, storage } = fixture();
const detachFailure = new Error("CDP detach rejected");
const transportFailure = new Error("Playwright transport close failed");
storage.close
.mockRejectedValueOnce(detachFailure)
.mockResolvedValueOnce(undefined);
cleanup.close
.mockRejectedValueOnce(transportFailure)
.mockImplementationOnce(async (finishEvents?: () => Promise<void>) => {
await finishEvents?.();
});
const session = await PlaywrightScenarioSession.open(scenario, {}, {});
let failure: unknown;
try {
await session.close();
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toMatchObject({ cleanupIncomplete: true });
const physical = (failure as Error).cause;
expect(physical).toBeInstanceOf(AggregateError);
expect((physical as AggregateError).errors).toEqual([
detachFailure,
transportFailure,
]);
expect(storage.close).toHaveBeenCalledTimes(1);
expect(cleanup.close).toHaveBeenCalledTimes(1);
await expect(session.close()).resolves.toBe("terminated-owned-process");
expect(failure).toMatchObject({
cleanupIncomplete: false,
cleanupResources: [],
});
expect(storage.close).toHaveBeenCalledTimes(2);
expect(cleanup.close).toHaveBeenCalledTimes(2);
});
it("keeps the session's exact cleanup lease pending after response timeout until browser acknowledgement", async () => {
vi.useFakeTimers();
const { cleanup } = fixture();
const session = await PlaywrightScenarioSession.open(scenario, {}, {});
let acknowledge: () => void = () => {};
const late = new Promise<void>((resolve) => {
acknowledge = resolve;
});
cleanup.release = vi.fn(async (finishEvents?: () => Promise<void>) => {
await late;
await cleanup.close(finishEvents);
});
try {
const pending = session.close().catch((cause: unknown) => cause);
await vi.advanceTimersByTimeAsync(30_000);
const failure = await pending;
expect(failure).toMatchObject({
cleanupIncomplete: true,
cleanupResources: ["browser_transport"],
});
expect(session.cleanupLease.released).toBe(false);
acknowledge();
await session.cleanupLease.release();
expect(failure).toMatchObject({
cleanupIncomplete: false,
cleanupResources: [],
});
expect(cleanup.close).toHaveBeenCalledTimes(1);
} finally {
acknowledge();
await session.cleanupLease.release();
vi.useRealTimers();
}
});
it("retains late opening failure cleanup after cancellation until the exact profile lease recovers", async () => {
let finishOpening: () => void = () => {};
const terminal = new Promise<void>((resolve) => {
finishOpening = resolve;
});
let profileStarted: () => void = () => {};
const profileAttempt = new Promise<void>((resolve) => {
profileStarted = resolve;
});
let denied = true;
const lease = new AnalysisCleanupLease(async () => {
profileStarted();
if (denied) throw new Error("authored profile removal denial");
}, ["browser_profile"]);
const failure = new ProviderCleanupError("fixture", ["browser_profile"], {
reason: "late opening profile cleanup",
});
new AnalysisCleanupLeaseGroup(failure, [lease]);
let launchStarted: () => void = () => {};
const started = new Promise<void>((resolve) => {
launchStarted = resolve;
});
mocks.openBrowser.mockImplementation(
async (
_scenario: unknown,
_env: unknown,
options: { retainCleanup?: (lease: AnalysisCleanupLease) => void },
) => {
launchStarted();
await terminal;
options.retainCleanup?.(lease);
throw failure;
},
);
const provider = new PlaywrightBrowserScenarioProvider((input, options) =>
PlaywrightScenarioSession.open(input, {}, options),
);
const caller = new AbortController();
const reason = { caller: "before late launch rejection" };
try {
const pending = provider.captureScenario(scenario, {
signal: caller.signal,
});
await started;
caller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("Expected cancelled opening");
expect(result.error.cleanupIncomplete).toBe(true);
finishOpening();
await profileAttempt;
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(result.error).toMatchObject({
cleanupIncomplete: true,
cleanupResources: ["browser_profile"],
});
denied = false;
await provider.close();
expect(result.error).toMatchObject({
cleanupIncomplete: false,
cleanupResources: [],
});
expect(failure.cleanupIncomplete).toBe(false);
} finally {
denied = false;
finishOpening();
await provider.close();
}
});
it.each(["launch", "connect"] as const)(
"bounds never-settling initial navigation and awaits %s cleanup",
async (mode) => {
vi.useFakeTimers();
const { cleanup, page, storage } = fixture();
const request = browserScenarioSchema.parse({
...(mode === "launch" ? scenario : connectedScenario("allow")),
command_timeout_ms: 250,
});
page.goto.mockImplementation(() => new Promise<never>(() => {}));
try {
const opening = PlaywrightScenarioSession.open(request, {}, {}).catch(
(cause: unknown) => cause,
);
await vi.advanceTimersByTimeAsync(250);
const failure = await opening;
expect(failure).toMatchObject({
_tag: "AnalysisTimeoutError",
operation: "capture_browser_scenario",
timeoutMs: 250,
});
expect(cleanup.close).toHaveBeenCalledOnce();
expect(storage?.close).toHaveBeenCalledWith(true);
expect(page.goto).toHaveBeenCalledOnce();
} finally {
await cleanup.cleanupLease.release();
vi.useRealTimers();
}
},
);
it.each([false, true])(
"checks attached worker policy after final artifacts, worker takeover=%s",
async (takeover) => {
const { page, cleanup } = fixture();
let controlled = false;
let captures = 0;
Object.assign(page, {
waitForTimeout: vi.fn(async () => undefined),
content: vi.fn(async () => {
captures += 1;
controlled = takeover && captures === 2;
return controlled ? "<worker-response/>" : "<uncontrolled-response/>";
}),
});
page.evaluate.mockImplementation(async (script: unknown) =>
controlled &&
typeof script === "string" &&
script.includes("serviceWorker.controller")
? "service_worker_blocked"
: null,
);
const input = browserScenarioSchema.parse({
...connectedScenario("block"),
capture: { after_each_step: ["dom"], at_end: ["dom"], events: [] },
});
const provider = new PlaywrightBrowserScenarioProvider(
(scenario, options) =>
PlaywrightScenarioSession.open(scenario, {}, options),
);
try {
const result = await provider.captureScenario(input, {});
expect(captures).toBe(2);
expect(cleanup.browserClosed).toBe(true);
if (takeover) {
if (result.ok)
throw new Error("Expected final worker policy rejection");
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: {
collected_artifacts: {
dom: { value: { text: "<worker-response/>" } },
},
},
},
});
expect(result.error).toMatchObject({
reason: "service_worker_blocked",
cleanupIncomplete: false,
diagnostics: {
collected_artifacts: {
dom: { state: "captured", value: { text: "<worker-response/>" } },
},
},
partialObservation: {
capture: {
browser: { cleanup: "disconnected-external" },
steps: [{ action: "goto_start" }],
},
},
});
} else {
if (!result.ok) throw result.error;
expect(result.value.steps).toHaveLength(2);
expect(result.value.steps[1]).toMatchObject({
status: "completed",
completeness: { status: "complete", equality_eligible: true },
});
}
} finally {
await provider.close();
}
},
);
it.each(["absent", "undefined", "inherited", "empty"] as const)(
"resolves declared environment secrets before browser acquisition: %s",
async (state) => {
const { page } = fixture();
Object.assign(page, { waitForTimeout: async () => undefined });
const environment: Record<string, string | undefined> = {
AVAILABLE_SECRET: "already-supplied-private-value",
};
if (state === "inherited")
Object.setPrototypeOf(environment, {
REQUIRED_SECRET: "inherited-value",
});
if (state === "undefined") environment.REQUIRED_SECRET = undefined;
if (state === "empty") environment.REQUIRED_SECRET = "";
const input = browserScenarioSchema.parse({
...scenario,
secrets: [
{ secret_id: "available", environment_variable: "AVAILABLE_SECRET" },
{ secret_id: "required", environment_variable: "REQUIRED_SECRET" },
],
});
const provider = new PlaywrightBrowserScenarioProvider(
(scenario, options) =>
PlaywrightScenarioSession.open(scenario, environment, options),
);
try {
const result = await provider.captureScenario(input, {});
if (state === "empty") {
if (!result.ok) throw result.error;
expect(mocks.openBrowser).toHaveBeenCalledOnce();
} else {
if (result.ok) throw new Error("Expected unresolved environment input");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "invalid_request",
category: "invalid_input",
details: {
issues: [
{
path: ["secrets", 1, "environment_variable"],
reason: "missing_argument",
expected: {
secret_id: "required",
environment_variable: "REQUIRED_SECRET",
},
},
],
},
});
expect(projected.message).toContain(
"Set environment variable REQUIRED_SECRET",
);
expect(JSON.stringify(projected)).not.toContain(
environment.AVAILABLE_SECRET,
);
expect(result.error.cleanupIncomplete).toBe(false);
expect(mocks.openBrowser).not.toHaveBeenCalled();
}
} finally {
await provider.close();
}
},
);