mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(browser): seed storage by browser partition
Co-authored-by: Lucien <[email protected]>
This commit is contained in:
@@ -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");
|
||||
};
|
||||
@@ -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 ?? []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user