mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
1626 lines
52 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
);
|