| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { BrowserDiagnosticExport } from "./diagnosticExport.js"; |
| 4 | import { DiagnosticBuffer } from "./diagnostics.js"; |
| 5 | import { FakeViewFactory, silentLog } from "./fakeGuestViews.js"; |
| 6 | import { GrantRegistry } from "./grants.js"; |
| 7 | import { BrowserSurfaceManager } from "./surfaceManager.js"; |
| 8 | import { buildBrowserHostCalls } from "./hostCalls.js"; |
| 9 | import { ActionExecutor } from "./actions.js"; |
| 10 | import { DocumentRegistry } from "./documents.js"; |
| 11 | import { DownloadTracker } from "./downloads.js"; |
| 12 | import { acquireDebugger, disposeDebugger } from "./debuggerLease.js"; |
| 13 | |
| 14 | const a = "a".repeat(64), b = "b".repeat(64); |
| 15 | function setup(snapshot?: (tab: import("./surfaceManager.js").BrowserTab) => Promise<never>) { |
| 16 | const factory = new FakeViewFactory(); |
| 17 | const surfaces = new BrowserSurfaceManager({ views: factory, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog }); |
| 18 | const grants = new GrantRegistry({ generation: () => "generation" }); |
| 19 | const diagnostics = new BrowserDiagnosticExport(surfaces, grants, { build: "test-build", version: "test", platform: "test" }); |
| 20 | const documents = new DocumentRegistry(); |
| 21 | const calls = buildBrowserHostCalls({ surfaces, grants, diagnosticExport: diagnostics, documents, actions: new ActionExecutor({ surfaces, documents }), downloads: new DownloadTracker({ tabForWebContents: () => undefined, defaultDirectory: () => "/tmp", onUpdate() {}, log: silentLog }), snapshot }); |
| 22 | return { factory, surfaces, grants, diagnostics, calls }; |
| 23 | } |
| 24 | |
| 25 | test("export retains scoped closed-tab events, sanitizes pages and releases listeners", async () => { |
| 26 | const s = setup(); |
| 27 | await s.calls["host/browser.grant"]({ grantId: "grant-a", tabId: "task-a", sessionId: "session", diagnosticScope: a }); |
| 28 | await s.calls["host/browser.grant"]({ grantId: "grant-b", tabId: "task-b", sessionId: "session", diagnosticScope: b }); |
| 29 | const tab = await s.surfaces.open("https://example.test/?token=SECRET", { taskId: "task-a", sessionId: "session", temporary: true }); |
| 30 | const other = await s.surfaces.open("https://other.test", { taskId: "task-b", sessionId: "session", temporary: false }); |
| 31 | const page = tab.view.diagnostics = new DiagnosticBuffer(); |
| 32 | const otherPage = other.view.diagnostics = new DiagnosticBuffer(); |
| 33 | s.diagnostics.read(a); // Attach newly materialized page buffers. |
| 34 | page.add({ kind: "console", message: "token=SECRET cookie: SECRET", url: "https://user:SECRET@example.test/path?token=SECRET#SECRET" }); |
| 35 | otherPage.add({ kind: "exception", message: "FOREIGN-PAGE" }); |
| 36 | s.surfaces.takeover(tab.id, "user"); |
| 37 | s.grants.revoke("grant-a"); |
| 38 | s.surfaces.close(tab.id); |
| 39 | page.add({ kind: "console", message: "LATE-CLOSED-PAGE" }); |
| 40 | const result = s.diagnostics.read(a); |
| 41 | const data = JSON.stringify(result); |
| 42 | for (const text of ["SECRET", "grant-a", "FOREIGN-PAGE", "LATE-CLOSED-PAGE"]) assert.equal(data.includes(text), false, text); |
| 43 | assert.ok(result.entries.some(row => row.event === "tab_closed")); |
| 44 | assert.ok(result.entries.some(row => row.event === "grant_revoked")); |
| 45 | assert.ok(result.entries.some(row => row.mode === "human")); |
| 46 | assert.ok(result.entries.some(row => row.kind === "page")); |
| 47 | assert.equal(result.tabs.length, 0); |
| 48 | s.diagnostics.dispose(); |
| 49 | const restarted = new BrowserDiagnosticExport(s.surfaces, s.grants, { build: "new-build", version: "test", platform: "test" }); |
| 50 | assert.equal(restarted.read(a).entries.length, 0, "unattributed history must not be guessed after restart"); |
| 51 | restarted.dispose(); |
| 52 | s.surfaces.destroyAll(); |
| 53 | }); |
| 54 | |
| 55 | test("queued requests retain CDP request and operation identity without arguments", async () => { |
| 56 | const releases: (() => void)[] = []; |
| 57 | let enteredSecond!: () => void; |
| 58 | const secondStarted = new Promise<void>(resolve => { enteredSecond = resolve; }); |
| 59 | const s = setup(async tab => { |
| 60 | const release = acquireDebugger(tab.view.page.debugger); |
| 61 | try { await release.send("Page.createIsolatedWorld", { expression: "PRIVATE-PAGE" }, "private-session"); throw new Error("script failure token=SECRET"); } |
| 62 | finally { release(); disposeDebugger(tab.view.page.debugger); } |
| 63 | }); |
| 64 | for (const [scope, task] of [[a, "a"], [b, "b"]]) { |
| 65 | await s.calls["host/browser.grant"]({ grantId: task, tabId: task, sessionId: "session", diagnosticScope: scope }); |
| 66 | const tab = await s.surfaces.open("https://example.test", { taskId: task, sessionId: "session", temporary: false }); |
| 67 | s.factory.views.at(-1)!.page.debugger.respond = () => new Promise(resolve => { releases.push(() => resolve({})); if (releases.length === 2) enteredSecond(); }); |
| 68 | // Start both requests before delivering either CDP response. |
| 69 | const pending = s.calls["host/browser.snapshot"]({ grantId: task, tabId: tab.id, requestId: `request-${task}`, operationId: `operation-${task}` }); |
| 70 | (tab as typeof tab & { pending: Promise<unknown> }).pending = Promise.resolve(pending).then(() => assert.fail("expected failure"), () => {}); |
| 71 | } |
| 72 | assert.equal(releases.length, 1, "shared capture surface must serialize requests"); |
| 73 | releases[0](); |
| 74 | await secondStarted; |
| 75 | releases[1](); |
| 76 | await Promise.all(s.surfaces.all().map(tab => (tab as typeof tab & { pending: Promise<unknown> }).pending)); |
| 77 | for (const [scope, task] of [[a, "a"], [b, "b"]]) { |
| 78 | const result = s.diagnostics.read(scope); |
| 79 | const commands = result.entries.filter(row => row.command); |
| 80 | assert.deepEqual(commands.map(row => row.phase), ["cdp_started", "cdp_completed"]); |
| 81 | assert.ok(commands.every(row => row.requestId === `request-${task}` && row.operationId === `operation-${task}` && row.target === "child")); |
| 82 | assert.ok(result.entries.some(row => row.phase === "failed" && row.requestId === `request-${task}`)); |
| 83 | assert.equal(JSON.stringify(result).includes("PRIVATE-PAGE"), false); |
| 84 | assert.equal(JSON.stringify(result).includes("SECRET"), false); |
| 85 | } |
| 86 | s.surfaces.destroyAll(); s.diagnostics.dispose(); |
| 87 | }); |
| 88 | |
| 89 | test("export has honest global eviction and no page credentials in bounded payload", () => { |
| 90 | const s = setup(); |
| 91 | for (let i = 0; i < 700; i++) s.diagnostics.trace(a, { requestId: `r-${i}`, method: "host/browser.snapshot", phase: "reading", elapsedMs: i }); |
| 92 | let result = s.diagnostics.read(a); |
| 93 | assert.equal(result.truncated, true); assert.equal(result.entries.length, 512); |
| 94 | assert.ok(Buffer.byteLength(JSON.stringify(result.entries)) <= 256 * 1024); |
| 95 | assert.equal(s.diagnostics.read(b).entries.length, 0); |
| 96 | s.diagnostics.dispose(); |
| 97 | }); |
| 98 | |
| 99 | test("live grant attribution survives more than 256 bindings and page reopen", async () => { |
| 100 | const s = setup(); |
| 101 | s.diagnostics.bind(s.grants.install({ grantId: "first", taskId: "first", sessionId: "s", diagnosticScope: a })); |
| 102 | const closed = await s.surfaces.open("https://example.test", { taskId: "first", sessionId: "s", temporary: false }); |
| 103 | s.surfaces.close(closed.id); |
| 104 | for (let i = 0; i < 256; i++) s.diagnostics.bind(s.grants.install({ grantId: `g-${i}`, taskId: `task-${i}`, sessionId: "s", diagnosticScope: b })); |
| 105 | assert.equal(s.grants.verify("first").diagnosticScope, a); |
| 106 | const tab = await s.surfaces.open("https://example.test", { taskId: "first", sessionId: "s", temporary: false }); |
| 107 | const buffer = tab.view.diagnostics = new DiagnosticBuffer(); |
| 108 | s.diagnostics.read(a); |
| 109 | buffer.add({ kind: "exception", message: "retained after 257 grants" }); |
| 110 | const result = s.diagnostics.read(a); |
| 111 | assert.equal(result.truncated, false); |
| 112 | assert.ok(result.entries.some(row => row.kind === "page" && row.message === "retained after 257 grants")); |
| 113 | assert.equal(result.tabs[0]?.pageDiagnosticsAvailable, true); |
| 114 | assert.equal(s.diagnostics.read(b).entries.some(row => row.tabId === tab.id), false); |
| 115 | s.grants.revoke("first"); |
| 116 | buffer.add({ kind: "console", message: "existing page remains attributable after revoke" }); |
| 117 | assert.ok(s.diagnostics.read(a).entries.some(row => row.message === "existing page remains attributable after revoke")); |
| 118 | s.surfaces.destroyAll(); s.diagnostics.dispose(); |
| 119 | }); |
| 120 | |
| 121 | test("host export serializes sanitized console URLs for every protocol casing", async () => { |
| 122 | const s = setup(); |
| 123 | await s.calls["host/browser.grant"]({ grantId: "a", tabId: "a", sessionId: "s", diagnosticScope: a }); |
| 124 | const tab = await s.surfaces.open("https://example.test", { taskId: "a", sessionId: "s", temporary: false }); |
| 125 | const buffer = tab.view.diagnostics = new DiagnosticBuffer(); |
| 126 | s.diagnostics.read(a); |
| 127 | for (const scheme of ["https", "HTTPS", "HtTpS", "HTTP"]) { |
| 128 | buffer.add({ kind: "console", message: `Request failed ${scheme}://review-user:REVIEW-PASSWORD@example.test/callback?code=REVIEW-OAUTH-CODE&signature=REVIEW-SIGNATURE#REVIEW-FRAGMENT`, url: `${scheme}://example.test/source?code=REVIEW-OAUTH-CODE` }); |
| 129 | } |
| 130 | const exported = await s.calls["host/browser.exportDiagnostics"]({ scope: a }); |
| 131 | const json = JSON.stringify(exported); |
| 132 | for (const secret of ["review-user", "REVIEW-PASSWORD", "REVIEW-OAUTH-CODE", "REVIEW-SIGNATURE", "REVIEW-FRAGMENT"]) assert.equal(json.includes(secret), false, secret); |
| 133 | const entries = (exported as ReturnType<BrowserDiagnosticExport["read"]>).entries; |
| 134 | assert.deepEqual(entries.filter(row => row.kind === "page").map(row => ({ message: row.message, url: row.url })), [ |
| 135 | ...Array.from({ length: 3 }, () => ({ message: "Request failed https://example.test/callback", url: "https://example.test/source" })), |
| 136 | { message: "Request failed http://example.test/callback", url: "http://example.test/source" }, |
| 137 | ]); |
| 138 | s.surfaces.destroyAll(); s.diagnostics.dispose(); |
| 139 | }); |
| 140 |