Files
reverse-engineering-anything/src/browser/PlaywrightScenarioSession.test.ts
T
0155b89953 fix(browser): name web-bundle budgets and recover web deadlines (#6036)
* fix(browser): name web-bundle budgets and recover web deadlines

Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): apply selected web deadlines to the owning phase

Keep HTTP and browser-socket discovery on the fixed discovery deadline so a
short command_timeout_ms starts at the handshake. Apply scenario
command_timeout_ms to launch startup and the primary connect session, and
keep service-worker restoration on the fixed 5000 ms grace. Omit
command_timeout_ms from recovery text when the operation does not accept it.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): retain scenario commands that finish after their deadline

A timed-out scenario command stays owned so cleanup can close a late
browser or context and restore a mutation that lands after the deadline.
Child storage replies use that deadline, scenario recovery names
command_timeout_ms in the capture JSON, and the session test connection
implements beginCleanupGrace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep child command timeouts as the public failure

A nested command such as Page.enable that is delivered but never answered
was stored as protocol_error, hiding the command name, selected deadline,
and recovery. Store the typed timeout and assert that public error.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep runtime cleanup on the fixed grace

Switch runtime restoration to the cleanup grace before the first send so a
short command_timeout_ms cannot fail an otherwise successful observation.
Stop advertising command_timeout_ms on list_browser_targets, which never
bounds a post-discovery command.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(browser): prove a short command deadline cannot fail runtime cleanup

Drive restoration through the runtime fixture. A cleanup command that
outlives command_timeout_ms still succeeds, and a stalled restoration
names the fixed 5000 ms grace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the effective source-map decode deadline

codecBudgetLimitation reported a fixed 20-second deadline after a
caller selected decode_timeout_ms. Result and Evidence limitations
now name that effective millisecond deadline.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): separate handshake and target-attach deadlines

A browser-level Target.attachToTarget no longer shares the WebSocket
handshake budget. Blocked service-worker attachment keeps discovery on
its fixed deadline and applies command_timeout_ms to the later handshake
and attach.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the web-bundle resource each runtime exhausts

Bun ignores the V8 old-space flag. A Windows Job reports the process
commit limit, and other Bun hosts no longer suggest raising max_heap_mb.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): cite the browser-socket discovery message budget

WebSocket discovery enforces the 64 MiB protocol budget. Timeout
recovery no longer advertises the 8 MiB HTTP document budget.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): bound the blocked service-worker check before discovery

The first registration check has its own fixed 10s deadline. Discovery,
handshake, and Target.attachToTarget stay on their separate deadlines.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the operand that stopped web-bundle admission

Host heap pressure no longer claims the fixed result budget is exhausted.
A Windows Bun allocation before the commit limit still names that limit.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 05:47:38 +08:00

1456 lines
46 KiB
TypeScript

import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.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();
}
});