fix(browser): seed storage by browser partition

Co-authored-by: Lucien <[email protected]>
This commit is contained in:
morluto
2026-10-11 10:17:53 +08:00
co-authored by Lucien
parent f998fcb1cc
commit 8b4c3de3b3
9 changed files with 2123 additions and 69 deletions
+12
View File
@@ -156,6 +156,18 @@ Storage cookies are scoped to the page URL at each capture. Unsupported action
tags, unknown fields, duplicate step IDs, and undeclared secret references fail
validation.
Cookies are installed before the initial navigation. Local storage entries are
seeded once per browser storage partition; session storage entries are seeded
once per origin in each top-level browsing context. Seeding occurs before the
first document scripts for that area. An origin first reached by a later action
receives its seed on that first visit. Application updates, deletions, and
empty values then persist across later navigations in the same area; a new
scenario starts with its own seeds. Partitioned frames can receive separate
local storage seeds even when they share an origin. The pre-script mechanism
uses CDP debugger instrumentation. The observer resumes only pauses whose top
frame belongs to its private seed script; page breakpoints and `debugger`
statements remain under the attached debugger client's control.
The result is Evidence with an initial state followed by one record per
declared action. Each step reports action status, elapsed time, sanitized URLs,
event bounds, and independently typed capture states for screenshot, DOM,
@@ -0,0 +1,676 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import {
runScenarioCli,
scenarioProfiles,
} from "./browser-scenario-verifier.mjs";
const scenarioFor = (browser, origin) =>
browserScenarioSchema.parse({
browser,
start_url: { url: `${origin}/storage-main` },
actions: [
{
step_id: "frame-ready",
action: "wait_for",
locator: { kind: "css", selector: "#frame-report[data-ready='true']" },
state: "attached",
timeout_ms: 10_000,
},
{
step_id: "top-level-partition",
action: "goto",
destination: {
url: `${origin.replace("127.0.0.1", "localhost")}/storage-top`,
},
wait_until: "load",
},
{
step_id: "return-to-origin",
action: "goto",
destination: { url: `${origin}/storage-main` },
wait_until: "load",
},
],
storage: {
local_storage: [
{
origin,
entries: [
{
name: "rea_storage_probe",
value: { source: "literal", value: "main-initial" },
},
{
name: "rea_storage_remove",
value: { source: "literal", value: "main-remove-seed" },
},
],
},
{
origin: origin.replace("127.0.0.1", "localhost"),
entries: [
{
name: "rea_storage_probe",
value: { source: "literal", value: "frame-initial" },
},
{
name: "rea_storage_remove",
value: { source: "literal", value: "frame-remove-seed" },
},
],
},
],
session_storage: [
{
origin,
entries: [
{
name: "rea_storage_probe",
value: { source: "literal", value: "main-session-initial" },
},
{
name: "rea_storage_empty",
value: { source: "literal", value: "main-empty-seed" },
},
],
},
{
origin: origin.replace("127.0.0.1", "localhost"),
entries: [
{
name: "rea_storage_probe",
value: { source: "literal", value: "frame-session-initial" },
},
{
name: "rea_storage_empty",
value: { source: "literal", value: "frame-empty-seed" },
},
],
},
],
},
capture: { after_each_step: ["dom"], at_end: ["dom"] },
});
const storagePage = (kind, port) => {
if (kind === "main")
return `<!doctype html><html><body><pre id="main-report"></pre><pre id="frame-report"></pre><iframe src="http://localhost:${port}/storage-frame"></iframe><script>
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
document.querySelector("#main-report").textContent = JSON.stringify(state);
localStorage.setItem("rea_storage_probe", "main-updated");
sessionStorage.setItem("rea_storage_probe", "main-session-updated");
localStorage.removeItem("rea_storage_remove");
sessionStorage.setItem("rea_storage_empty", "");
addEventListener("message", (event) => {
if (event.data?.kind !== "rea-storage-frame") return;
const report = document.querySelector("#frame-report");
report.textContent = JSON.stringify(event.data.state);
report.dataset.ready = "true";
});
</script></body></html>`;
if (kind === "frame")
return `<!doctype html><html><body><pre id="frame-state"></pre><script>
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
document.querySelector("#frame-state").textContent = JSON.stringify(state);
localStorage.setItem("rea_storage_probe", "frame-updated");
sessionStorage.setItem("rea_storage_probe", "frame-session-updated");
localStorage.removeItem("rea_storage_remove");
sessionStorage.setItem("rea_storage_empty", "");
parent.postMessage({ kind: "rea-storage-frame", state }, "*");
</script></body></html>`;
if (kind === "debugger")
return `<!doctype html><html><body><pre id="debugger-state"></pre><script>
window.reaDebuggerBefore = true;
debugger;
window.reaDebuggerAfter = true;
document.querySelector("#debugger-state").textContent = String(window.reaDebuggerAfter);
</script></body></html>`;
return `<!doctype html><html><body><pre id="top-report"></pre><script>
const state = { local: localStorage.getItem("rea_storage_probe"), session: sessionStorage.getItem("rea_storage_probe"), removed: localStorage.getItem("rea_storage_remove"), empty: sessionStorage.getItem("rea_storage_empty") };
document.querySelector("#top-report").textContent = JSON.stringify(state);
localStorage.setItem("rea_storage_probe", "top-updated");
sessionStorage.setItem("rea_storage_probe", "top-session-updated");
</script></body></html>`;
};
const startStorageSite = async () => {
let port = 0;
const server = createServer((request, response) => {
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
const kind =
path === "/storage-main"
? "main"
: path === "/storage-frame"
? "frame"
: path === "/storage-debugger"
? "debugger"
: path === "/storage-top"
? "top"
: undefined;
if (kind === undefined) {
response.writeHead(404).end();
return;
}
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(storagePage(kind, port));
});
server.listen(0, "::");
await once(server, "listening");
port = server.address().port;
return {
origin: `http://127.0.0.1:${port}`,
close: async () => {
server.close();
await once(server, "close");
},
};
};
const domText = (step) => {
assert.equal(step.artifacts.dom.state, "captured");
return step.artifacts.dom.value.text;
};
const assertContainsState = (text, id, expected) => {
const match = new RegExp(`<pre id="${id}"[^>]*>(.*?)</pre>`, "su").exec(text);
assert.ok(match, `Captured DOM omitted #${id}: ${text}`);
assert.deepEqual(JSON.parse(match[1]), expected);
};
/** Verify one-time local/session storage initialization on a real Chromium. */
export async function verifyScenarioStorage({
executable,
endpoint,
targetId,
}) {
const site = await startStorageSite();
try {
await verifyLaunchStorage(executable, site.origin);
await verifyConnectedStorage(endpoint, targetId, site.origin);
return {
mocked: false,
launch: true,
connect: true,
oopifInitialization: true,
firstInlineScriptSeeded: true,
updatesAndDeletesPersistAcrossNavigation: true,
newScenarioGetsFreshSeed: true,
externalTargetSurvives: true,
cancellationCleanup: true,
};
} finally {
await site.close();
}
}
const verifyLaunchStorage = async (executable, origin) => {
const scenario = scenarioFor(
{ mode: "launch", executable_path: executable },
origin,
);
const profilesBefore = await scenarioProfiles();
const result = await runScenarioCli(scenario);
const capture = result.normalized_result;
assert.ok(capture, "Launch-mode storage scenario returned no result");
assertLaunchStorageStates(capture);
assert.equal(capture.browser.cleanup, "terminated-owned-process");
const profilesAfter = await scenarioProfiles();
assert.ok([...profilesAfter].every((profile) => profilesBefore.has(profile)));
const freshScenario = browserScenarioSchema.parse({
...scenario,
actions: [scenario.actions[0]],
});
const fresh = await createBrowserScenarioProvider(
process.env,
).captureScenario(freshScenario);
assert.equal(fresh.ok, true);
if (!fresh.ok) throw fresh.error;
assertContainsState(domText(fresh.value.steps[0]), "main-report", {
local: "main-initial",
session: "main-session-initial",
removed: "main-remove-seed",
empty: "main-empty-seed",
});
};
const assertLaunchStorageStates = (capture) => {
assertContainsState(domText(capture.steps[0]), "main-report", {
local: "main-initial",
session: "main-session-initial",
removed: "main-remove-seed",
empty: "main-empty-seed",
});
assertContainsState(domText(capture.steps[1]), "frame-report", {
local: "frame-initial",
session: "frame-session-initial",
removed: "frame-remove-seed",
empty: "frame-empty-seed",
});
assertContainsState(domText(capture.steps[2]), "top-report", {
local: "frame-updated",
session: "frame-session-updated",
removed: null,
empty: "",
});
assertContainsState(domText(capture.steps[3]), "main-report", {
local: "main-updated",
session: "main-session-updated",
removed: null,
empty: "",
});
assertContainsState(domText(capture.steps[3]), "frame-report", {
local: "top-updated",
session: "top-session-updated",
removed: null,
empty: "",
});
};
const verifyConnectedStorage = async (endpoint, targetId, origin) => {
const scenario = scenarioFor(
{ mode: "connect", cdp_endpoint: endpoint, target_id: targetId },
origin,
);
const targets = await (
await fetch(`${endpoint}/json/list`, { signal: AbortSignal.timeout(2_000) })
).json();
const target = targets.find(({ id }) => id === targetId);
assert.ok(target, "External browser target is missing");
const observer = await CdpConnection.connect(
target.webSocketDebuggerUrl,
"capture_browser_scenario",
);
try {
await assertStorageKeyPartition(observer, origin);
const result = await runScenarioCli(scenario);
const capture = result.normalized_result;
assert.ok(capture, "Connect-mode storage scenario returned no result");
assertContainsState(domText(capture.steps[0]), "main-report", {
local: "main-initial",
session: "main-session-initial",
removed: "main-remove-seed",
empty: "main-empty-seed",
});
assert.equal(capture.browser.cleanup, "disconnected-external");
await verifyCallerDebuggerPause(observer, origin, scenario);
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
} finally {
await Promise.race([
restoreTarget(observer, target.url),
delay(1_000),
]).catch(() => undefined);
await Promise.race([observer.close(), delay(1_000)]).catch(() => undefined);
}
};
const assertStorageKeyPartition = async (connection, origin) => {
const attachedSessions = new Set();
const unsubscribe = connection.onEvent((event) => {
if (
event.method === "Target.attachedToTarget" &&
typeof event.params === "object" &&
event.params !== null &&
"sessionId" in event.params &&
typeof event.params.sessionId === "string" &&
"targetInfo" in event.params &&
typeof event.params.targetInfo === "object" &&
event.params.targetInfo !== null &&
"type" in event.params.targetInfo &&
event.params.targetInfo.type === "iframe"
)
attachedSessions.add(event.params.sessionId);
});
await connection.send("Page.enable");
await connection.send("Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
});
try {
const start = await connection.send("Runtime.evaluate", {
expression: "performance.timeOrigin",
returnByValue: true,
});
await connection.send("Page.navigate", {
url: `${origin}/storage-main`,
});
await waitForPagePath(connection, "/storage-main", start.result.value);
let childSession;
let childFrameId;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline && childFrameId === undefined) {
for (const sessionId of attachedSessions) {
try {
const tree = await connection.send(
"Page.getFrameTree",
{},
sessionId,
);
const frame = tree.frameTree?.frame;
if (
typeof frame?.id === "string" &&
frame.url ===
`${origin.replace("127.0.0.1", "localhost")}/storage-frame`
) {
childSession = sessionId;
childFrameId = frame.id;
break;
}
} catch {
// The iframe may detach between target discovery and frame inspection.
}
}
if (childFrameId === undefined) await delay(25);
}
assert.ok(
childSession && childFrameId,
"Storage fixture OOPIF was not attached",
);
const child = await connection.send(
"Storage.getStorageKey",
{ frameId: childFrameId },
childSession,
);
const rootTree = await connection.send("Page.getFrameTree");
const beforeRoot = await connection.send("Storage.getStorageKey", {
frameId: rootTree.frameTree.frame.id,
});
await connection.send("Page.navigate", {
url: `${origin.replace("127.0.0.1", "localhost")}/storage-top`,
});
await waitForPagePath(connection, "/storage-top", start.result.value);
const afterTree = await connection.send("Page.getFrameTree");
const topLevel = await connection.send("Storage.getStorageKey", {
frameId: afterTree.frameTree.frame.id,
});
assert.notEqual(
child.storageKey,
topLevel.storageKey,
`Chrome did not expose distinct partition keys: child=${JSON.stringify(child)} top=${JSON.stringify(topLevel)} rootBefore=${JSON.stringify(beforeRoot)}`,
);
} finally {
unsubscribe();
await connection.send("Target.setAutoAttach", {
autoAttach: false,
waitForDebuggerOnStart: false,
flatten: true,
});
}
};
const waitForPagePath = async (connection, path, priorTimeOrigin) => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const result = await connection.send("Runtime.evaluate", {
expression: `JSON.stringify({ path: location.pathname, time: performance.timeOrigin })`,
returnByValue: true,
});
const current = JSON.parse(result.result.value);
if (current.path === path && current.time !== priorTimeOrigin) return;
await delay(25);
}
throw new Error(`External browser did not navigate to ${path}`);
};
const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
await caller.send("Runtime.enable");
await caller.send("Debugger.enable");
const controller = new AbortController();
const debuggerUrl = `${origin}/storage-debugger`;
const scripts = new Map();
const protocolTrace = [];
let pauseObserved = false;
let resolvePaused;
const paused = new Promise((resolve) => {
resolvePaused = (callFrameId) => {
pauseObserved = true;
resolve(callFrameId);
};
});
const unsubscribe = caller.onEvent((event) => {
if (event.method === "Debugger.scriptParsed") {
const params = event.params;
if (
typeof params === "object" &&
params !== null &&
"scriptId" in params &&
typeof params.scriptId === "string" &&
"url" in params &&
typeof params.url === "string"
)
scripts.set(params.scriptId, params.url);
if (
typeof params === "object" &&
params !== null &&
"url" in params &&
typeof params.url === "string" &&
params.url.includes("storage")
)
protocolTrace.push({ method: event.method, url: params.url });
} else if (event.method === "Debugger.paused") {
const params = event.params;
const callFrames =
typeof params === "object" && params !== null && "callFrames" in params
? params.callFrames
: undefined;
const frame = Array.isArray(callFrames) ? callFrames[0] : undefined;
const scriptId =
typeof frame === "object" && frame !== null && "location" in frame
? frame.location?.scriptId
: undefined;
protocolTrace.push({
method: event.method,
url: typeof scriptId === "string" ? scripts.get(scriptId) : undefined,
reason:
typeof params === "object" && params !== null && "reason" in params
? params.reason
: undefined,
});
if (
typeof frame === "object" &&
frame !== null &&
typeof frame.callFrameId === "string" &&
typeof scriptId === "string" &&
scripts.get(scriptId) === debuggerUrl
)
resolvePaused(frame.callFrameId);
}
});
const debuggerScenario = browserScenarioSchema.parse({
...scenario,
start_url: { url: debuggerUrl },
actions: [
{ step_id: "after-debugger", action: "wait_for_timeout", duration_ms: 1 },
],
});
let settled = false;
const pending = createBrowserScenarioProvider(process.env)
.captureScenario(debuggerScenario, { signal: controller.signal })
.finally(() => {
settled = true;
unsubscribe();
});
try {
const callFrameId = await Promise.race([
paused,
delay(10_000).then(() => {
throw new Error(
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
);
}),
]);
const pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
callFrameId,
expression: "window.reaDebuggerAfter === true",
returnByValue: true,
});
assert.equal(
pausedState.result.value,
false,
"Caller-owned debugger frame resumed before the caller inspected it",
);
await delay(100);
assert.equal(
settled,
false,
"Storage observer resumed a caller-owned debugger pause",
);
await caller.send("Debugger.resume");
pauseObserved = false;
const result = await pending;
if (!result.ok) throw result.error;
const debuggerState = await caller.send("Runtime.evaluate", {
expression: "window.reaDebuggerAfter === true",
returnByValue: true,
});
assert.equal(debuggerState.result.value, true);
} catch (cause) {
if (!pauseObserved) controller.abort(cause);
else await caller.send("Debugger.resume").catch(() => undefined);
const completion = await Promise.race([
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
delay(5_000).then(() => undefined),
]);
if (completion === undefined)
throw new Error("Capture remained pending after cancellation", { cause });
const completionError =
typeof completion === "object" &&
completion !== null &&
"error" in completion
? completion.error
: typeof completion === "object" &&
completion !== null &&
"ok" in completion &&
completion.ok === false
? completion.error
: undefined;
const completionDetail =
completionError === undefined
? "capture did not report an error"
: (() => {
const causes = [];
let current = completionError;
for (
let depth = 0;
depth < 4 && current !== undefined;
depth += 1
) {
causes.push({
name: current.name,
tag: current._tag,
reason: current.reason,
message: current.message,
});
current = current.cause;
}
return JSON.stringify(causes);
})();
throw new Error(`${String(cause)}; capture=${completionDetail}`, {
cause,
});
} finally {
unsubscribe();
await Promise.race([caller.send("Debugger.disable"), delay(1_000)]).catch(
() => undefined,
);
}
};
const verifyCancelledStorage = async (
observer,
endpoint,
targetId,
scenario,
) => {
const controller = new AbortController();
const cancelledScenario = browserScenarioSchema.parse({
...scenario,
actions: [
{
step_id: "cancelled-wait",
action: "wait_for",
locator: { kind: "css", selector: "#never-storage-ready" },
state: "visible",
timeout_ms: 30_000,
},
],
});
const priorDocument = await observer.send("Runtime.evaluate", {
expression: "performance.timeOrigin",
returnByValue: true,
});
const pending = createBrowserScenarioProvider(process.env).captureScenario(
cancelledScenario,
{ signal: controller.signal },
);
await waitForNewDocument(
observer,
"/storage-main",
priorDocument.result.value,
);
const state = await observer.send("Runtime.evaluate", {
expression:
"JSON.parse(document.querySelector('#main-report').textContent)",
returnByValue: true,
});
assert.deepEqual(state.result.value, {
local: "main-initial",
session: "main-session-initial",
removed: "main-remove-seed",
empty: "main-empty-seed",
});
controller.abort(new Error("cancel storage seed scenario"));
const result = await pending;
assert.ok(
!result.ok ||
result.value.steps.some(({ status }) => status === "cancelled"),
"Cancellation unexpectedly completed every scenario step",
);
const targets = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(
targets.some(({ id }) => id === targetId),
"Cancelled connect-mode storage cleanup closed the external target",
);
};
const waitForNewDocument = async (connection, path, priorTimeOrigin) => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const result = await connection.send("Runtime.evaluate", {
expression: `JSON.stringify({ path: location.pathname, time: performance.timeOrigin, state: document.querySelector('#main-report')?.textContent })`,
returnByValue: true,
});
const current = JSON.parse(result.result.value);
if (
current.path === path &&
current.time !== priorTimeOrigin &&
current.state !== undefined
)
return;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`External browser did not navigate to ${path}`);
};
const restoreTarget = async (connection, url) => {
await connection.send("Page.navigate", { url });
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const result = await connection.send("Runtime.evaluate", {
expression: "location.href",
returnByValue: true,
});
if (result.result.value === url) return;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("External browser did not return to its original URL");
};
+7
View File
@@ -45,6 +45,7 @@ import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e
import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs";
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs";
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
@@ -281,6 +282,11 @@ try {
target,
site.origin,
);
const scenarioStorage = await verifyScenarioStorage({
executable,
endpoint,
targetId: target,
});
const launchedScenario = await createBrowserScenarioProvider(
process.env,
).captureScenario(
@@ -378,6 +384,7 @@ try {
browserScenarioLaunchCleanup: "terminated-owned-process",
scenarioFailure,
scenarioEnvironment,
scenarioStorage,
verified: true,
};
} finally {
@@ -19,6 +19,7 @@ const OPERATION = "capture_browser_scenario" as const;
const browserScenarioLimitations = (
session: BrowserScenarioSessionPort,
scenario: BrowserScenario,
): string[] => [
"Event sequence records provider receipt order; simultaneous browser causality is not inferred.",
"Network content is retained only when selected; response bytes are browser-decoded, not compressed wire bytes.",
@@ -26,6 +27,12 @@ const browserScenarioLimitations = (
"Network content reads settle within 5 seconds each and never wait for unfinished responses or refetch them.",
"Playwright scenario events do not expose request initiator stacks; receipt order does not prove causality.",
"Storage values are hashed only after declared-secret redaction.",
...(scenario.storage.local_storage.length === 0 &&
scenario.storage.session_storage.length === 0
? []
: [
"Non-cookie storage seeds use CDP pre-script instrumentation; only pauses in the scenario's private seed script are resumed by this observer.",
]),
...(session.eventLimitations?.() ?? []),
...(session.mode === "connect"
? [
@@ -65,7 +72,7 @@ export const browserScenarioCaptureData = (input: {
steps: [...input.steps],
events: input.session.events(),
limitations: [
...browserScenarioLimitations(input.session),
...browserScenarioLimitations(input.session, input.scenario),
...(input.limitations ?? []),
],
});
+4
View File
@@ -49,6 +49,10 @@ export class PlaywrightScenarioBrowserCleanupOwner {
this.#profileRemoved = resources.removeProfile === undefined;
}
get browserClosed(): boolean {
return this.#browserClosed;
}
close(
finishEvents?: () => Promise<void>,
signal?: AbortSignal,
@@ -0,0 +1,171 @@
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(),
}));
vi.mock("./PlaywrightScenarioBrowser.js", () => ({
openPlaywrightScenarioBrowser: mocks.openBrowser,
failBrowserScenarioOperation: async (
cleanup: () => Promise<void>,
cause: unknown,
) => {
try {
await cleanup();
} 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() {
mocks.finishEvents();
}
result() {
return { events: [] };
}
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 fixture = () => {
const page = Object.assign(new EventEmitter(), {
url: () => "about:blank",
goto: vi.fn(async () => undefined),
context: () => context,
});
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>) => {
await finishEvents?.();
browserClosed = true;
});
const cleanup = {
close,
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 () => undefined),
};
return { cleanup, storage: mocks.storage };
};
beforeEach(() => {
vi.clearAllMocks();
});
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("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).toBeInstanceOf(AggregateError);
expect((failure 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(storage.close).toHaveBeenCalledTimes(2);
expect(cleanup.close).toHaveBeenCalledTimes(2);
});
+149 -68
View File
@@ -13,6 +13,7 @@ import {
import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js";
import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js";
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
import {
browserScenarioCaptureData,
@@ -21,21 +22,19 @@ import {
const OPERATION = "capture_browser_scenario" as const;
const storageSeeds = (
blocks: BrowserScenario["storage"]["local_storage"],
secrets: BrowserScenarioSecrets,
): Record<string, string[][]> => {
const seeds: Record<string, string[][]> = {};
for (const { origin, entries } of blocks)
(seeds[origin] ??= []).push(
...entries.map(({ name, value }) => [name, secrets.value(value)]),
);
return seeds;
const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => {
if (signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
};
const installStorageSeeds = async (
const throwCleanupFailures = (failures: readonly unknown[]): void => {
if (failures.length === 1) throw failures[0];
if (failures.length > 1)
throw new AggregateError(failures, "Browser scenario cleanup failed");
};
const installCookies = async (
context: BrowserContext,
page: Page,
scenario: BrowserScenario,
secrets: BrowserScenarioSecrets,
): Promise<void> => {
@@ -49,19 +48,6 @@ const installStorageSeeds = async (
sameSite: cookie.same_site,
})),
);
const storage = {
local: storageSeeds(scenario.storage.local_storage, secrets),
session: storageSeeds(scenario.storage.session_storage, secrets),
};
const payload = JSON.stringify(storage).replaceAll("<", "\\u003c");
await page.addInitScript(`(() => {
const seeds = ${payload};
const local = seeds.local[window.location.origin] ?? [];
const session = seeds.session[window.location.origin] ?? [];
for (const [name, value] of local) window.localStorage.setItem(name, value);
for (const [name, value] of session)
window.sessionStorage.setItem(name, value);
})()`);
};
const blockAttachedServiceWorkers = async (page: Page): Promise<void> => {
@@ -93,7 +79,7 @@ const initializePage = async (
context.setDefaultNavigationTimeout(0);
if (scenario.browser.mode === "connect")
await blockAttachedServiceWorkers(page);
await installStorageSeeds(context, page, scenario, secrets);
await installCookies(context, scenario, secrets);
};
export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
@@ -104,6 +90,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
readonly initialUrl: string;
private readonly secrets: BrowserScenarioSecrets;
private readonly eventCapture: PlaywrightScenarioEvents;
private readonly storage: PlaywrightScenarioStorage | undefined;
private readonly signal: AbortSignal | undefined;
#storageReleased = false;
private constructor(
private readonly opened: OpenedScenarioBrowser,
@@ -111,6 +100,8 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
readonly mode: BrowserScenario["browser"]["mode"];
readonly secrets: BrowserScenarioSecrets;
readonly eventCapture: PlaywrightScenarioEvents;
readonly storage: PlaywrightScenarioStorage | undefined;
readonly signal: AbortSignal | undefined;
},
) {
this.mode = options.mode;
@@ -118,6 +109,8 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
options.mode === "launch" ? "provider-owned" : "external";
this.secrets = options.secrets;
this.eventCapture = options.eventCapture;
this.storage = options.storage;
this.signal = options.signal;
this.version = opened.browser.version();
this.initialUrl = opened.page.url();
}
@@ -130,8 +123,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
},
): Promise<PlaywrightScenarioSession> {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
throwIfScenarioCancelled(options.signal);
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
if (secrets === undefined)
throw new BrowserObservationError(OPERATION, "secret_unavailable");
@@ -143,7 +135,6 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
: { retainCleanup: options.retainCleanup }),
});
let opened: OpenedScenarioBrowser;
let events: PlaywrightScenarioEvents | undefined;
try {
opened = await withPlaywrightExecutionBoundary(
() => opening,
@@ -165,42 +156,12 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
});
throw cause;
}
let session: PlaywrightScenarioSession;
try {
await withPlaywrightExecutionBoundary(
() => initializePage(opened.context, opened.page, scenario, secrets),
undefined,
options.signal,
);
events = new PlaywrightScenarioEvents({
page: opened.page,
context: opened.context,
ownsContext: scenario.browser.mode === "launch",
enabled: new Set(scenario.capture.events),
secrets,
network: scenario.capture.network,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
session = new PlaywrightScenarioSession(opened, {
mode: scenario.browser.mode,
secrets,
eventCapture: events,
});
} catch (cause: unknown) {
const cleanup = () =>
opened.cleanup.close(
events === undefined
? undefined
: () => events?.finish() ?? Promise.resolve(),
options.signal,
);
try {
return await failBrowserScenarioOperation(cleanup, cause);
} catch (failure: unknown) {
if (failure !== cause) options.retainCleanup?.(cleanup);
throw failure;
}
}
const session = await this.initializeSession(
opened,
scenario,
secrets,
options,
);
try {
await withPlaywrightExecutionBoundary(
() =>
@@ -211,6 +172,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
undefined,
options.signal,
);
await withPlaywrightExecutionBoundary(
() => session.storage?.settle() ?? Promise.resolve(),
undefined,
options.signal,
);
return session;
} catch (cause: unknown) {
let cleanup:
@@ -219,7 +185,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
| "incomplete";
let cleanupFailure: { readonly cause: unknown } | undefined;
try {
cleanup = await session.close();
cleanup = await session.close(true);
} catch (failure: unknown) {
cleanup = "incomplete";
cleanupFailure = { cause: failure };
@@ -245,6 +211,91 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
}
}
private static async initializeSession(
opened: OpenedScenarioBrowser,
scenario: BrowserScenario,
secrets: BrowserScenarioSecrets,
options: {
readonly signal?: AbortSignal;
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
},
): Promise<PlaywrightScenarioSession> {
let events: PlaywrightScenarioEvents | undefined;
let storage: PlaywrightScenarioStorage | undefined;
try {
await withPlaywrightExecutionBoundary(
() => initializePage(opened.context, opened.page, scenario, secrets),
undefined,
options.signal,
);
storage = await PlaywrightScenarioStorage.create(
opened,
scenario,
secrets,
{
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.retainCleanup === undefined
? {}
: { retainCleanup: options.retainCleanup }),
},
);
await storage?.initialize(opened.page, opened.context);
throwIfScenarioCancelled(options.signal);
events = new PlaywrightScenarioEvents({
page: opened.page,
context: opened.context,
ownsContext: scenario.browser.mode === "launch",
enabled: new Set(scenario.capture.events),
secrets,
network: scenario.capture.network,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return new PlaywrightScenarioSession(opened, {
mode: scenario.browser.mode,
secrets,
eventCapture: events,
storage,
signal: options.signal,
});
} catch (cause: unknown) {
let storageReleased = false;
const cleanup = async () => {
let storageFailure: { readonly cause: unknown } | undefined;
let browserFailure: { readonly cause: unknown } | undefined;
if (!storageReleased) {
try {
await storage?.close(options.signal?.aborted === true);
} catch (failure: unknown) {
storageFailure = { cause: failure };
}
}
try {
await opened.cleanup.close(
events === undefined
? undefined
: () => events?.finish() ?? Promise.resolve(),
options.signal,
);
} catch (failure: unknown) {
browserFailure = { cause: failure };
}
if (opened.cleanup.browserClosed) storageReleased = true;
throwCleanupFailures([
...(storageReleased || storageFailure === undefined
? []
: [storageFailure.cause]),
...(browserFailure === undefined ? [] : [browserFailure.cause]),
]);
};
try {
return await failBrowserScenarioOperation(cleanup, cause);
} catch (failure: unknown) {
if (failure !== cause) options.retainCleanup?.(cleanup);
throw failure;
}
}
}
currentUrl(): string {
return this.opened.page.url();
}
@@ -288,6 +339,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
"timeout_ms" in action ? action.timeout_ms : undefined,
signal,
);
await withPlaywrightExecutionBoundary(
() => this.storage?.settle() ?? Promise.resolve(),
undefined,
signal,
);
}
async capture(
@@ -296,6 +352,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
>,
signal?: AbortSignal,
) {
await withPlaywrightExecutionBoundary(
() => this.storage?.settle() ?? Promise.resolve(),
undefined,
signal,
);
return withPlaywrightExecutionBoundary(
() =>
capturePlaywrightStepArtifacts({
@@ -309,8 +370,28 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
);
}
async close() {
await this.opened.cleanup.close(() => this.eventCapture.finish());
async close(stopLoading = this.signal?.aborted === true) {
let storageFailure: { readonly cause: unknown } | undefined;
let browserFailure: { readonly cause: unknown } | undefined;
if (!this.#storageReleased) {
try {
await this.storage?.close(stopLoading);
} catch (failure: unknown) {
storageFailure = { cause: failure };
}
}
try {
await this.opened.cleanup.close(() => this.eventCapture.finish());
} catch (failure: unknown) {
browserFailure = { cause: failure };
}
if (this.opened.cleanup.browserClosed) this.#storageReleased = true;
throwCleanupFailures([
...(this.#storageReleased || storageFailure === undefined
? []
: [storageFailure.cause]),
...(browserFailure === undefined ? [] : [browserFailure.cause]),
]);
return this.mode === "launch"
? ("terminated-owned-process" as const)
: ("disconnected-external" as const);
@@ -0,0 +1,418 @@
import { EventEmitter } from "node:events";
import { expect, it } from "vitest";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
class FakeCdp extends EventEmitter {
readonly calls: { readonly method: string; readonly params?: unknown }[] = [];
storageKey = "https://storage.example.test";
failDetachCount = 0;
holdDetach = false;
resolveDetach: (() => void) | undefined;
holdMethod: string | undefined;
heldReply: (() => void) | undefined;
autoReplyNested = false;
readonly pendingNested: {
readonly sessionId: string;
readonly id: number;
}[] = [];
detachCalls = 0;
async send(method: string, params?: unknown): Promise<unknown> {
this.calls.push({ method, params });
if (method === this.holdMethod) {
const response =
method === "Page.addScriptToEvaluateOnNewDocument"
? { identifier: "late-script" }
: method === "Debugger.setBreakpointByUrl"
? { breakpointId: "late-breakpoint" }
: method === "Storage.getStorageKey"
? { storageKey: this.storageKey }
: {};
return new Promise((resolve) => {
this.heldReply = () => resolve(response);
});
}
if (method === "Target.getTargetInfo")
return { targetInfo: { targetId: "root-target" } };
if (method === "Target.sendMessageToTarget") {
const request = params as {
readonly sessionId: string;
readonly message: string;
};
const { id } = JSON.parse(request.message) as { readonly id: number };
this.pendingNested.push({ sessionId: request.sessionId, id });
if (this.autoReplyNested) this.replyNested(request.sessionId, id);
}
if (method === "Debugger.setBreakpointByUrl")
return { breakpointId: "breakpoint" };
if (method === "Page.addScriptToEvaluateOnNewDocument")
return { identifier: "script" };
if (method === "Storage.getStorageKey")
return { storageKey: this.storageKey };
if (method === "Debugger.evaluateOnCallFrame") return {};
return {};
}
async detach(): Promise<void> {
this.detachCalls += 1;
if (this.failDetachCount > 0) {
this.failDetachCount -= 1;
throw new Error("one-shot CDP detach failure");
}
if (this.holdDetach)
await new Promise<void>((resolve) => {
this.resolveDetach = resolve;
});
}
releaseHeldReply(): void {
this.heldReply?.();
this.heldReply = undefined;
this.holdMethod = undefined;
}
releaseDetach(): void {
this.holdDetach = false;
this.resolveDetach?.();
this.resolveDetach = undefined;
}
replyNested(sessionId: string, id: number): void {
queueMicrotask(() =>
this.emit("Target.receivedMessageFromTarget", {
sessionId,
message: JSON.stringify({ id, result: {} }),
}),
);
}
}
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: "local-seed",
value: { source: "literal", value: "local-value" },
},
],
},
],
session_storage: [
{
origin: "https://storage.example.test",
entries: [
{
name: "session-seed",
value: { source: "literal", value: "session-value" },
},
],
},
],
},
});
const fixture = (cdp: FakeCdp) => {
const page = new EventEmitter();
const context = new EventEmitter();
const browser = new EventEmitter();
return {
page,
context,
browser,
contextApi: Object.assign(context, {
browser: () => browser,
newCDPSession: async () => cdp,
}),
};
};
const waitForCall = async (cdp: FakeCdp, method: string): Promise<void> => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (cdp.calls.some((call) => call.method === method)) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`CDP method was not called: ${method}`);
};
const createInitializedStorage = async (
context: ReturnType<typeof fixture>["contextApi"],
page: ReturnType<typeof fixture>["page"],
input = scenario(),
options: { readonly signal?: AbortSignal } = {},
) => {
const owner = await PlaywrightScenarioStorage.create(
{ context: context as never, page: page as never },
input,
BrowserScenarioSecrets.resolve(input, {})!,
options,
);
await owner?.initialize(page as never, context as never);
return owner;
};
const emitSeedPause = (cdp: FakeCdp): void => {
const addScript = cdp.calls.find(
({ method }) => method === "Page.addScriptToEvaluateOnNewDocument",
);
if (addScript === undefined)
throw new Error("Storage seed script is missing");
const sourceUrl = (addScript.params as { readonly worldName: string })
.worldName;
cdp.emit("Runtime.executionContextCreated", {
context: {
id: 4,
origin: "https://storage.example.test",
name: sourceUrl,
auxData: { frameId: "root-frame" },
},
});
cdp.emit("Debugger.scriptParsed", {
scriptId: "seed-script",
executionContextId: 4,
url: sourceUrl,
});
cdp.emit("Debugger.paused", {
callFrames: [
{
callFrameId: "seed-frame",
location: { scriptId: "seed-script" },
},
],
});
};
it("seeds a browser storage area once before the document script resumes", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const owner = await createInitializedStorage(contextApi, page);
expect(owner).toBeDefined();
cdp.emit("Debugger.paused", {
callFrames: [
{
callFrameId: "caller-debugger-frame",
location: { scriptId: "caller-owned-script" },
},
],
});
await owner?.settle();
expect(
cdp.calls.some(({ method }) => method === "Debugger.evaluateOnCallFrame"),
).toBe(false);
expect(cdp.calls.some(({ method }) => method === "Debugger.resume")).toBe(
false,
);
emitSeedPause(cdp);
await owner?.settle();
emitSeedPause(cdp);
await owner?.settle();
const evaluations = cdp.calls.filter(
({ method }) => method === "Debugger.evaluateOnCallFrame",
);
expect(evaluations).toHaveLength(1);
expect(evaluations[0]?.params).toMatchObject({
callFrameId: "seed-frame",
expression: expect.stringContaining('"local-seed","local-value"'),
});
expect(evaluations[0]?.params).toMatchObject({
expression: expect.stringContaining('"session-seed","session-value"'),
});
expect(
cdp.calls.findIndex(
({ method }) => method === "Debugger.evaluateOnCallFrame",
),
).toBeLessThan(
cdp.calls.findIndex(({ method }) => method === "Debugger.resume"),
);
await owner?.close();
});
it("seeds session storage once per namespace and StorageKey", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const base = scenario();
const input = browserScenarioSchema.parse({
...base,
storage: {
local_storage: [],
session_storage: base.storage.session_storage,
},
});
const owner = await createInitializedStorage(contextApi, page, input);
emitSeedPause(cdp);
await owner?.settle();
emitSeedPause(cdp);
await owner?.settle();
cdp.storageKey = "https://storage.example.test/^0https://top-level.example";
emitSeedPause(cdp);
await owner?.settle();
expect(
cdp.calls.filter(({ method }) => method === "Debugger.evaluateOnCallFrame"),
).toHaveLength(2);
await owner?.close();
});
it("retries the owning CDP detach after a failed attempt", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const owner = await createInitializedStorage(contextApi, page);
cdp.failDetachCount = 1;
await expect(owner?.close()).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
});
expect(cdp.detachCalls).toBe(1);
expect(cdp.listenerCount("Debugger.paused")).toBe(1);
await owner?.close();
expect(cdp.detachCalls).toBe(2);
expect(cdp.listenerCount("Debugger.paused")).toBe(0);
});
it("does not create storage instrumentation when there are no storage seeds", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const empty = 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 }],
});
expect(
await PlaywrightScenarioStorage.create(
{ context: contextApi as never, page: page as never },
empty,
BrowserScenarioSecrets.resolve(empty, {})!,
),
).toBeUndefined();
expect(cdp.calls).toEqual([]);
});
it("detaches after setup cancellation even while script installation is pending", async () => {
const cdp = new FakeCdp();
cdp.holdMethod = "Page.addScriptToEvaluateOnNewDocument";
const { page, contextApi } = fixture(cdp);
const input = scenario();
const controller = new AbortController();
const owner = await PlaywrightScenarioStorage.create(
{ context: contextApi as never, page: page as never },
input,
BrowserScenarioSecrets.resolve(input, {})!,
{ signal: controller.signal },
);
expect(owner).toBeDefined();
const initialization = owner?.initialize(page as never, contextApi as never);
await waitForCall(cdp, "Page.addScriptToEvaluateOnNewDocument");
controller.abort();
await expect(initialization).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
await owner?.close();
expect(cdp.detachCalls).toBe(1);
expect(
cdp.calls.some(
({ method }) => method === "Page.removeScriptToEvaluateOnNewDocument",
),
).toBe(false);
cdp.releaseHeldReply();
});
it("cancels a pending storage-key reply and still releases its CDP owner", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const input = scenario();
const controller = new AbortController();
const owner = await createInitializedStorage(contextApi, page, input, {
signal: controller.signal,
});
expect(owner).toBeDefined();
cdp.holdMethod = "Storage.getStorageKey";
emitSeedPause(cdp);
const settling = owner?.settle();
await waitForCall(cdp, "Storage.getStorageKey");
controller.abort();
await expect(settling).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
cdp.releaseHeldReply();
await owner?.close();
expect(cdp.detachCalls).toBe(1);
});
it("releases the CDP owner when an attached target reply remains pending", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const input = scenario();
const owner = await createInitializedStorage(contextApi, page, input);
expect(owner).toBeDefined();
cdp.emit("Target.attachedToTarget", {
sessionId: "iframe-session",
targetInfo: { type: "iframe", targetId: "iframe-target" },
});
await waitForCall(cdp, "Target.sendMessageToTarget");
await owner?.close();
expect(cdp.detachCalls).toBe(1);
});
it("does not fail root storage capture when an OOPIF detaches during setup", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const owner = await createInitializedStorage(contextApi, page);
expect(owner).toBeDefined();
cdp.emit("Target.attachedToTarget", {
sessionId: "navigated-away-iframe",
targetInfo: { type: "iframe", targetId: "iframe-target" },
});
await waitForCall(cdp, "Target.sendMessageToTarget");
cdp.emit("Target.detachedFromTarget", {
sessionId: "navigated-away-iframe",
});
await owner?.settle();
cdp.autoReplyNested = true;
emitSeedPause(cdp);
await owner?.settle();
expect(
cdp.calls.some(({ method }) => method === "Debugger.evaluateOnCallFrame"),
).toBe(true);
await owner?.close();
expect(cdp.detachCalls).toBe(1);
});
it("retains the exact in-flight detach after the bounded wait expires", async () => {
const cdp = new FakeCdp();
const { page, contextApi } = fixture(cdp);
const owner = await createInitializedStorage(contextApi, page);
expect(owner).toBeDefined();
cdp.holdDetach = true;
await expect(owner?.close()).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
});
expect(cdp.detachCalls).toBe(1);
const retry = owner?.close();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(cdp.detachCalls).toBe(1);
cdp.releaseDetach();
await retry;
expect(cdp.detachCalls).toBe(1);
});
+678
View File
@@ -0,0 +1,678 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type { BrowserContext, CDPSession, Page } from "playwright-core";
import type { BrowserScenario } from "../domain/browserScenario.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
const breakpointResponse = z.object({ breakpointId: z.string() });
const scriptIdentifierResponse = z.object({ identifier: z.string() });
const storageKeyResponse = z.object({ storageKey: z.string() });
const targetInfoResponse = z.object({
targetInfo: z.object({ targetId: z.string() }),
});
type StorageEventMethod =
| "Runtime.executionContextCreated"
| "Runtime.executionContextDestroyed"
| "Runtime.executionContextsCleared"
| "Debugger.scriptParsed"
| "Debugger.paused"
| "Target.attachedToTarget"
| "Target.detachedFromTarget"
| "Target.receivedMessageFromTarget";
const STORAGE_EVENT_METHODS = new Set<string>([
"Runtime.executionContextCreated",
"Runtime.executionContextDestroyed",
"Runtime.executionContextsCleared",
"Debugger.scriptParsed",
"Debugger.paused",
"Target.attachedToTarget",
"Target.detachedFromTarget",
"Target.receivedMessageFromTarget",
]);
const attachedTargetEvent = z.object({
sessionId: z.string(),
targetInfo: z.object({ type: z.string(), targetId: z.string() }),
});
const targetSessionEvent = z.object({ sessionId: z.string() });
const nestedMessageEvent = z.object({
sessionId: z.string(),
message: z.string(),
});
const nestedProtocolMessage = z.object({
id: z.number().optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z.object({ message: z.string() }).optional(),
});
const executionContextEvent = z.object({
context: z.object({
id: z.number(),
origin: z.string(),
name: z.string(),
auxData: z.object({ frameId: z.string().optional() }).optional(),
}),
});
const scriptParsedEvent = z.object({
scriptId: z.string(),
executionContextId: z.number(),
url: z.string(),
});
const pausedEvent = z.object({
callFrames: z.array(
z.object({
callFrameId: z.string(),
location: z.object({ scriptId: z.string() }),
}),
),
});
const evaluationResponse = z.object({
exceptionDetails: z.unknown().optional(),
});
const STORAGE_CLEANUP_TIMEOUT_MS = 1_000;
type StorageSeed = {
local: [string, string][];
session: [string, string][];
};
interface StorageTarget {
readonly parentId: string | undefined;
readonly topLevelTargetId: string;
readonly send: (
method: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
readonly contexts: Map<
number,
z.infer<typeof executionContextEvent>["context"]
>;
readonly scripts: Map<string, number>;
ready: Promise<void>;
waitingForDebugger: boolean;
}
type PendingReply = {
readonly sessionId: string;
readonly resolve: (value: unknown) => void;
readonly reject: (error: Error) => void;
};
type StorageInstallationOptions = {
readonly signal?: AbortSignal;
readonly retainCleanup?: (close: () => Promise<unknown>) => void;
};
type StoragePage = { readonly context: BrowserContext; readonly page: Page };
/** Owns pre-script storage seeding and its CDP instrumentation for one scenario. */
export class PlaywrightScenarioStorage {
readonly #seeds = new Map<string, StorageSeed>();
readonly #initializedLocal = new Set<string>();
readonly #initializedSession = new Set<string>();
readonly #targets = new Map<string, StorageTarget>();
readonly #replies = new Map<number, PendingReply>();
readonly #sourceUrl = `rea-storage-${randomUUID()}`;
readonly #listeners: {
readonly method: StorageEventMethod;
readonly listener: (params: unknown) => void;
}[] = [];
#sequence = 0;
#generation = 0;
#closed = false;
#cleanupCompleted = false;
#failure: BrowserObservationError | undefined;
#closePromise: Promise<void> | undefined;
#detachPromise: Promise<void> | undefined;
#rootInitialization: Promise<void> | undefined;
#work: Promise<void> = Promise.resolve();
#removeListeners: () => void = () => undefined;
private constructor(
private readonly cdp: CDPSession,
private readonly signal: AbortSignal | undefined,
) {}
static async create(
storagePage: StoragePage,
scenario: BrowserScenario,
secrets: BrowserScenarioSecrets,
options: StorageInstallationOptions = {},
): Promise<PlaywrightScenarioStorage | undefined> {
const { signal, retainCleanup } = options;
const { context, page } = storagePage;
if (
scenario.storage.local_storage.length === 0 &&
scenario.storage.session_storage.length === 0
)
return undefined;
const connection = context.newCDPSession(page);
let cdp: CDPSession;
try {
cdp = await withPlaywrightExecutionBoundary(
() => connection,
undefined,
signal,
);
} catch (cause: unknown) {
if (signal?.aborted === true) {
let detached = false;
const lateCleanup = async (): Promise<void> => {
let session: CDPSession;
try {
session = await connection;
} catch {
return;
}
if (!detached) {
await session.detach();
detached = true;
}
};
retainCleanup?.(lateCleanup);
void withPlaywrightExecutionBoundary(
lateCleanup,
STORAGE_CLEANUP_TIMEOUT_MS,
).catch(() => undefined);
}
throw cause;
}
const owner = new PlaywrightScenarioStorage(cdp, signal);
owner.addSeeds(scenario.storage.local_storage, "local", secrets);
owner.addSeeds(scenario.storage.session_storage, "session", secrets);
return owner;
}
async initialize(page: Page, context: BrowserContext): Promise<void> {
const initialization = this.initializeRoot(page, context);
this.#rootInitialization = initialization;
await withPlaywrightExecutionBoundary(
() => initialization,
undefined,
this.signal,
);
}
private async initializeRoot(
page: Page,
context: BrowserContext,
): Promise<void> {
const rootInfo = targetInfoResponse.parse(
await this.cdp.send("Target.getTargetInfo"),
);
const root = this.createTarget(
undefined,
rootInfo.targetInfo.targetId,
(method, params) =>
this.cdp.send(method as Parameters<CDPSession["send"]>[0], params),
);
this.#targets.set("", root);
if (this.#closed) return;
this.listen(page, context);
const ready = this.initializeTarget(root);
root.ready = ready;
await ready;
}
private addSeeds(
blocks: BrowserScenario["storage"]["local_storage"],
kind: "local" | "session",
secrets: BrowserScenarioSecrets,
): void {
for (const { origin, entries } of blocks) {
const seed = this.#seeds.get(origin) ?? { local: [], session: [] };
seed[kind].push(
...entries.map(({ name, value }): [string, string] => [
name,
secrets.value(value),
]),
);
this.#seeds.set(origin, seed);
}
}
private listen(page: Page, context: BrowserContext): void {
const browser = context.browser();
const detached = (): void => this.targetDetached("");
page.on("close", detached);
context.on("close", detached);
browser?.on("disconnected", detached);
this.#removeListeners = () => {
page.off("close", detached);
context.off("close", detached);
browser?.off("disconnected", detached);
};
for (const method of [
"Runtime.executionContextCreated",
"Runtime.executionContextDestroyed",
"Runtime.executionContextsCleared",
"Debugger.scriptParsed",
"Debugger.paused",
"Target.attachedToTarget",
"Target.detachedFromTarget",
"Target.receivedMessageFromTarget",
] as const) {
const listener = (params: unknown): void =>
this.event("", method, params);
this.#listeners.push({ method, listener });
this.cdp.on(method, listener);
}
}
private createTarget(
parentId: string | undefined,
topLevelTargetId: string,
send: StorageTarget["send"],
): StorageTarget {
return {
parentId,
topLevelTargetId,
send,
contexts: new Map(),
scripts: new Map(),
ready: Promise.resolve(),
waitingForDebugger: false,
};
}
private async initializeTarget(target: StorageTarget): Promise<void> {
if (this.#closed) return;
await target.send("Page.enable");
if (this.#closed) return;
await target.send("Runtime.enable");
if (this.#closed) return;
await target.send("Debugger.enable");
if (this.#closed) return;
breakpointResponse.parse(
await target.send("Debugger.setBreakpointByUrl", {
url: this.#sourceUrl,
lineNumber: 1,
}),
);
if (this.#closed) return;
scriptIdentifierResponse.parse(
await target.send("Page.addScriptToEvaluateOnNewDocument", {
worldName: this.#sourceUrl,
source: `(() => {\n void 0;\n})()\n//# sourceURL=${this.#sourceUrl}`,
}),
);
if (this.#closed) return;
await target.send("Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: false,
});
}
private sendObserved(
target: StorageTarget,
method: string,
params?: Record<string, unknown>,
): Promise<unknown> {
return withPlaywrightExecutionBoundary(
() => target.send(method, params),
undefined,
this.signal,
);
}
private event(
sessionId: string,
method: StorageEventMethod,
params: unknown,
): void {
if (this.#closed) return;
const target = this.#targets.get(sessionId);
if (target === undefined) return;
try {
if (method === "Runtime.executionContextCreated") {
const { context } = executionContextEvent.parse(params);
if (context.name === this.#sourceUrl)
target.contexts.set(context.id, context);
} else if (method === "Runtime.executionContextDestroyed") {
const { executionContextId } = z
.object({ executionContextId: z.number() })
.parse(params);
target.contexts.delete(executionContextId);
} else if (method === "Runtime.executionContextsCleared") {
target.contexts.clear();
target.scripts.clear();
} else if (method === "Debugger.scriptParsed") {
const script = scriptParsedEvent.parse(params);
if (script.url === this.#sourceUrl)
target.scripts.set(script.scriptId, script.executionContextId);
} else if (method === "Debugger.paused") {
const pause = pausedEvent.parse(params);
const frame = pause.callFrames[0];
// CDP resume is target-wide, so identify our private seed frame before
// doing any work that will resume the paused target.
if (frame === undefined || !target.scripts.has(frame.location.scriptId))
return;
this.#generation += 1;
this.#work = this.#work
.then(() => this.seed(target, pause))
.catch((cause: unknown) => {
if (sessionId === "" || this.#targets.get(sessionId) === target)
this.fail(cause);
});
} else if (method === "Target.attachedToTarget") {
this.#generation += 1;
this.attach(sessionId, attachedTargetEvent.parse(params));
} else if (method === "Target.detachedFromTarget") {
this.#generation += 1;
this.targetDetached(targetSessionEvent.parse(params).sessionId);
} else if (method === "Target.receivedMessageFromTarget") {
this.nestedMessage(nestedMessageEvent.parse(params));
}
} catch (cause: unknown) {
this.fail(cause);
}
}
private nestedMessage(event: z.infer<typeof nestedMessageEvent>): void {
const reply = nestedProtocolMessage.parse(JSON.parse(event.message));
if (reply.id !== undefined) {
const waiter = this.#replies.get(reply.id);
if (waiter?.sessionId !== event.sessionId) return;
this.#replies.delete(reply.id);
if (reply.error !== undefined)
waiter.reject(new Error(reply.error.message));
else waiter.resolve(reply.result);
return;
}
if (reply.method !== undefined && STORAGE_EVENT_METHODS.has(reply.method))
this.event(
event.sessionId,
reply.method as StorageEventMethod,
reply.params,
);
}
private fail(cause: unknown): void {
this.#generation += 1;
this.#failure ??= new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ cause },
);
}
private async seedPausedFrame(
target: StorageTarget,
frame: z.infer<typeof pausedEvent>["callFrames"][number],
): Promise<void> {
const contextId = target.scripts.get(frame.location.scriptId);
const context =
contextId === undefined ? undefined : target.contexts.get(contextId);
const seed =
context === undefined ? undefined : this.#seeds.get(context.origin);
if (
this.#closed ||
seed === undefined ||
context?.auxData?.frameId === undefined
)
return;
const storageKey = storageKeyResponse.parse(
await this.sendObserved(target, "Storage.getStorageKey", {
frameId: context.auxData.frameId,
}),
).storageKey;
const sessionKey = JSON.stringify([target.topLevelTargetId, storageKey]);
const local =
this.#initializedLocal.has(storageKey) || seed.local.length === 0
? []
: seed.local;
const session =
this.#initializedSession.has(sessionKey) || seed.session.length === 0
? []
: seed.session;
if (local.length === 0 && session.length === 0) return;
const payload = JSON.stringify({ local, session }).replaceAll(
"<",
"\\u003c",
);
const response = evaluationResponse.parse(
await this.sendObserved(target, "Debugger.evaluateOnCallFrame", {
callFrameId: frame.callFrameId,
expression: `(() => { const seed = ${payload}; for (const [name, value] of seed.local) localStorage.setItem(name, value); for (const [name, value] of seed.session) sessionStorage.setItem(name, value); })()`,
returnByValue: true,
}),
);
if (response.exceptionDetails !== undefined)
throw new BrowserObservationError(
"capture_browser_scenario",
"protocol_error",
{ detail: "The browser denied initial storage initialization." },
);
if (local.length > 0) this.#initializedLocal.add(storageKey);
if (session.length > 0) this.#initializedSession.add(sessionKey);
}
private async seed(
target: StorageTarget,
pause: z.infer<typeof pausedEvent>,
): Promise<void> {
const frame = pause.callFrames[0];
if (frame === undefined) return;
let primaryFailure: unknown;
let hasPrimaryFailure = false;
try {
await this.seedPausedFrame(target, frame);
} catch (cause: unknown) {
primaryFailure = cause;
hasPrimaryFailure = true;
}
let resumeFailure: unknown;
try {
await withPlaywrightExecutionBoundary(
() => target.send("Debugger.resume"),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch (cause: unknown) {
resumeFailure = cause;
}
if (hasPrimaryFailure && resumeFailure !== undefined)
throw new AggregateError(
[primaryFailure, resumeFailure],
"Storage initialization and debugger resume both failed",
{ cause: primaryFailure },
);
if (this.signal?.aborted === true && resumeFailure === undefined) return;
if (hasPrimaryFailure) throw primaryFailure;
if (resumeFailure !== undefined) throw resumeFailure;
}
private attach(
parentId: string,
event: z.infer<typeof attachedTargetEvent>,
): void {
const parent = this.#targets.get(parentId);
if (parent === undefined) return;
const { sessionId, targetInfo } = event;
const target = this.createTarget(
parentId,
event.targetInfo.type === "iframe"
? parent.topLevelTargetId
: event.targetInfo.targetId,
async (method, params = {}) => {
const id = ++this.#sequence;
const reply = new Promise<unknown>((resolve, reject) =>
this.#replies.set(id, { sessionId, resolve, reject }),
);
try {
const sent = parent.send("Target.sendMessageToTarget", {
sessionId,
message: JSON.stringify({ id, method, params }),
});
const [, result] = await Promise.all([sent, reply]);
return result;
} finally {
this.#replies.delete(id);
}
},
);
target.waitingForDebugger = true;
this.#targets.set(sessionId, target);
target.ready = (async () => {
try {
if (
!this.#closed &&
(targetInfo.type === "iframe" || targetInfo.type === "page")
)
await this.initializeTarget(target);
} catch (cause: unknown) {
if (
this.signal?.aborted !== true &&
!this.#closed &&
this.#targets.get(sessionId) === target
)
this.fail(cause);
}
if (this.#targets.get(sessionId) !== target) return;
try {
await withPlaywrightExecutionBoundary(
() => target.send("Runtime.runIfWaitingForDebugger"),
STORAGE_CLEANUP_TIMEOUT_MS,
);
target.waitingForDebugger = false;
} catch (cause: unknown) {
if (
this.signal?.aborted !== true &&
!this.#closed &&
this.#targets.get(sessionId) === target
)
this.fail(cause);
}
})();
}
private targetDetached(sessionId: string): void {
for (const [id, target] of this.#targets)
if (target.parentId === sessionId) this.targetDetached(id);
this.#targets.delete(sessionId);
for (const [id, reply] of this.#replies)
if (reply.sessionId === sessionId) {
this.#replies.delete(id);
reply.reject(
new BrowserObservationError(
"capture_browser_scenario",
"disconnected",
),
);
}
this.#generation += 1;
}
async settle(): Promise<void> {
for (;;) {
const generation = this.#generation;
const work = this.#work;
const targets = [...this.#targets.entries()];
await withPlaywrightExecutionBoundary(
() => Promise.all(targets.map(([, target]) => target.ready)),
undefined,
this.signal,
);
await withPlaywrightExecutionBoundary(() => work, undefined, this.signal);
await Promise.resolve();
if (this.#failure !== undefined) throw this.#failure;
if (
generation === this.#generation &&
work === this.#work &&
targets.every(([id, target]) => this.#targets.get(id) === target)
)
return;
}
}
close(stopLoading = false): Promise<void> {
if (this.#cleanupCompleted) return Promise.resolve();
if (this.#closePromise !== undefined) return this.#closePromise;
const closing = this.closeResources(stopLoading).catch((cause: unknown) => {
throw new BrowserObservationError(
"capture_browser_scenario",
"cleanup_failed",
{
cause,
cleanup: {
reason: cause instanceof Error ? cause.message : String(cause),
resources: ["browser_transport", "browser_storage_bootstrap"],
},
},
);
});
this.#closePromise = closing;
void closing.catch(() => {
if (this.#closePromise === closing) this.#closePromise = undefined;
});
return closing;
}
private async closeResources(stopLoading: boolean): Promise<void> {
this.#closed = true;
let stopLoadingFailure: { readonly cause: unknown } | undefined;
if (stopLoading) {
try {
const root = this.#targets.get("");
if (root !== undefined)
await withPlaywrightExecutionBoundary(
() => root.send("Page.stopLoading"),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch (cause: unknown) {
stopLoadingFailure = { cause };
}
}
const work = this.#work;
const rootInitialization = this.#rootInitialization;
const targets = [...this.#targets.values()];
try {
await withPlaywrightExecutionBoundary(
() =>
Promise.all([
work.catch(() => undefined),
...(rootInitialization === undefined
? []
: [rootInitialization.catch(() => undefined)]),
...targets.map((target) => target.ready.catch(() => undefined)),
]),
STORAGE_CLEANUP_TIMEOUT_MS,
);
} catch {
// Detaching the owning CDP session below is the terminal release for
// scripts, breakpoints, debugger state, and attached child sessions.
}
let detach = this.#detachPromise;
if (detach === undefined) {
detach = Promise.resolve().then(() => this.cdp.detach());
this.#detachPromise = detach;
void detach.catch(() => {
if (this.#detachPromise === detach) this.#detachPromise = undefined;
});
}
await withPlaywrightExecutionBoundary(
() => detach,
STORAGE_CLEANUP_TIMEOUT_MS,
);
for (const { method, listener } of this.#listeners)
this.cdp.off(method, listener);
this.#removeListeners();
for (const reply of this.#replies.values())
reply.reject(
new BrowserObservationError("capture_browser_scenario", "disconnected"),
);
this.#targets.clear();
this.#replies.clear();
this.#cleanupCompleted = true;
if (stopLoadingFailure !== undefined) throw stopLoadingFailure.cause;
}
}