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 raceWithTimeout = (operation, timeoutMs, onTimeout = () => undefined) => { const controller = new AbortController(); return Promise.race([ operation, delay(timeoutMs, undefined, { signal: controller.signal }).then(onTimeout), ]).finally(() => controller.abort()); }; const storagePage = (kind, port) => { if (kind === "main") return `
`; if (kind === "frame") return ``; if (kind === "debugger") return ``; return ``; }; 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(); // Chrome can leave speculative TCP connections without an HTTP request; // server.close() alone waits indefinitely for those owned sockets. server.closeAllConnections(); 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(`]*>(.*?)`, "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 raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch( () => undefined, ); await raceWithTimeout(observer.close(), 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 raceWithTimeout(paused, 10_000, () => { 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 raceWithTimeout( pending.catch((pendingCause) => ({ ok: false, error: pendingCause })), 5_000, ); 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 raceWithTimeout(caller.send("Debugger.disable"), 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"); };