| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { RpcError } from "../rpc.js"; |
| 4 | import { ActionExecutor } from "./actions.js"; |
| 5 | import { DocumentRegistry } from "./documents.js"; |
| 6 | import { DownloadTracker } from "./downloads.js"; |
| 7 | import { BROWSER_ERR_NO_GRANT, BROWSER_ERR_STALE_REFERENCE, BROWSER_ERR_TAKEN_OVER } from "./errors.js"; |
| 8 | import { FakeViewFactory, silentLog } from "./fakeGuestViews.js"; |
| 9 | import { GrantRegistry } from "./grants.js"; |
| 10 | import { buildBrowserHostCalls, type HostBrowserTab, type BrowserHostDeps } from "./hostCalls.js"; |
| 11 | import { BrowserSurfaceManager } from "./surfaceManager.js"; |
| 12 | |
| 13 | const code = (value: number) => (error: unknown) => error instanceof RpcError && error.code === value; |
| 14 | |
| 15 | async function setup(overrides: Partial<BrowserHostDeps> = {}) { |
| 16 | const factory = new FakeViewFactory(); |
| 17 | const surfaces = new BrowserSurfaceManager({ views: factory, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog, openWaitMs: 5 }); |
| 18 | const grants = new GrantRegistry({ generation: () => "gen-1" }); |
| 19 | let tokens = 0; |
| 20 | const documents = new DocumentRegistry(() => `tok-${++tokens}`); |
| 21 | const actions = new ActionExecutor({ surfaces, documents }); |
| 22 | const directories = new Map<string, string>(); |
| 23 | const downloads = new DownloadTracker({ |
| 24 | tabForWebContents: () => undefined, |
| 25 | defaultDirectory: (taskId) => `/dl/${taskId}`, |
| 26 | onUpdate() {}, |
| 27 | log: silentLog, |
| 28 | }); |
| 29 | const table = buildBrowserHostCalls({ |
| 30 | surfaces, |
| 31 | grants, |
| 32 | documents, |
| 33 | actions, |
| 34 | downloads, |
| 35 | snapshot: async (tab, selector) => ({ tabId: tab.id, selector }) as never, |
| 36 | screenshot: async (tab, request) => ({ tabId: tab.id, ...request }) as never, |
| 37 | ...overrides, |
| 38 | }); |
| 39 | const call = async (method: keyof typeof table, params: Record<string, unknown> = {}): Promise<unknown> => table[method](params); |
| 40 | const setDirs = downloads.setTaskDirectory.bind(downloads); |
| 41 | downloads.setTaskDirectory = (taskId, directory) => { |
| 42 | directories.set(taskId, directory); |
| 43 | setDirs(taskId, directory); |
| 44 | }; |
| 45 | return { surfaces, grants, documents, downloads, directories, table, call }; |
| 46 | } |
| 47 | |
| 48 | test("grant, list and open are scoped to the grant's task", async () => { |
| 49 | const s = await setup(); |
| 50 | await assert.rejects(s.call("host/browser.tabs.list", { grantId: "g" }), code(BROWSER_ERR_NO_GRANT)); |
| 51 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "s1" }); |
| 52 | await s.call("host/browser.grant", { grantId: "h", tabId: "task-2", sessionId: "s2" }); |
| 53 | |
| 54 | const opened = (await s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test" })) as HostBrowserTab; |
| 55 | assert.equal(opened.url, "https://a.test/"); |
| 56 | assert.equal(s.surfaces.require(opened.id).taskId, "task-1", "the tab belongs to the grant's task"); |
| 57 | await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false }); |
| 58 | |
| 59 | const listed = (await s.call("host/browser.tabs.list", { grantId: "g" })) as { tabs: HostBrowserTab[] }; |
| 60 | assert.deepEqual(listed.tabs.map((tab) => tab.id), [opened.id], "another task's tabs are invisible"); |
| 61 | }); |
| 62 | |
| 63 | test("revoking a grant reclaims a pending open without closing a user-taken-over page", async () => { |
| 64 | for (const takeover of [false, true]) { |
| 65 | const s = await setup(); |
| 66 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "session" }); |
| 67 | const originalOpen = s.surfaces.open.bind(s.surfaces); |
| 68 | s.surfaces.open = async (...args) => { |
| 69 | const promise = originalOpen(...args); |
| 70 | const tab = s.surfaces.all()[0]; |
| 71 | if (takeover) s.surfaces.takeover(tab.id, "user click"); |
| 72 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 73 | return promise; |
| 74 | }; |
| 75 | await assert.rejects(s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test", requestId: "pending" }), |
| 76 | /outcome is unknown/); |
| 77 | assert.equal(s.surfaces.all().length, takeover ? 1 : 0); |
| 78 | s.surfaces.destroyAll(); |
| 79 | } |
| 80 | }); |
| 81 | |
| 82 | test("an open that finishes just before grant revocation cannot leave an unclaimed agent page", async () => { |
| 83 | const s = await setup(); |
| 84 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "session" }); |
| 85 | const originalOpen = s.surfaces.open.bind(s.surfaces); |
| 86 | s.surfaces.open = async (...args) => { |
| 87 | const tab = await originalOpen(...args); |
| 88 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 89 | return tab; |
| 90 | }; |
| 91 | await assert.rejects(s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test" }), |
| 92 | /outcome is unknown/); |
| 93 | assert.equal(s.surfaces.all().length, 0); |
| 94 | }); |
| 95 | |
| 96 | test("cancel and takeover release a capture lease before a late capture reply", async () => { |
| 97 | for (const reason of ["cancel", "takeover", "revoke", "resize", "close"]) { |
| 98 | let respond!: (value: never) => void; |
| 99 | let entered!: () => void; |
| 100 | const started = new Promise<void>(resolve => { entered = resolve; }); |
| 101 | const s = await setup({ screenshot: async () => { entered(); return new Promise(resolve => { respond = resolve; }); } }); |
| 102 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" }); |
| 103 | const tab = await s.surfaces.open("https://test.example", { taskId: "task", sessionId: "s", temporary: false }); |
| 104 | let released = 0; |
| 105 | tab.view.prepareCapture = async () => () => { released++; }; |
| 106 | const request = s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, requestId: "r1" }); |
| 107 | await started; |
| 108 | if (reason === "cancel") await s.call("host/browser.cancel", { grantId: "g", requestId: "r1" }); |
| 109 | if (reason === "takeover") s.surfaces.takeover(tab.id, "user"); |
| 110 | if (reason === "revoke") s.grants.revoke("g"); |
| 111 | if (reason === "resize") s.surfaces.setLayout({ x: 0, y: 0, width: 700, height: 500 }); |
| 112 | if (reason === "close") s.surfaces.close(tab.id); |
| 113 | await assert.rejects(request); |
| 114 | assert.equal(released, 1, reason); |
| 115 | respond({ path: "late.png" } as never); |
| 116 | await Promise.resolve(); |
| 117 | assert.equal(released, 1, "late completion cannot release another request's lease"); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | test("operation trace records failure phase and timing without page contents or credentials", async () => { |
| 122 | const trace: unknown[] = []; |
| 123 | const s = await setup({ trace: event => trace.push(event), snapshot: async () => { throw new RpcError(-32013, "password=DO_NOT_LOG https://example.test/?token=DO_NOT_LOG", { kind: "script_runtime_error" }); } }); |
| 124 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" }); |
| 125 | const tab = await s.surfaces.open("https://example.test/?token=DO_NOT_LOG", { taskId: "task", sessionId: "s", temporary: false }); |
| 126 | await assert.rejects(s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id, requestId: "trace" })); |
| 127 | const final = trace.at(-1) as { phase: string; elapsedMs: number; errorKind: string }; |
| 128 | assert.equal(final.phase, "failed"); assert.equal(final.errorKind, "script_runtime_error"); assert.ok(final.elapsedMs >= 0); |
| 129 | assert.equal(JSON.stringify(trace).includes("DO_NOT_LOG"), false); |
| 130 | assert.equal(JSON.stringify(trace).includes("example.test"), false); |
| 131 | }); |
| 132 | |
| 133 | test("panel resize preserves fixed CSS observations but invalidates natural viewport observations", async () => { |
| 134 | for (const fixed of [true, false]) { |
| 135 | let entered!: () => void, finish!: (value: never) => void; |
| 136 | const started = new Promise<void>(resolve => { entered = resolve; }); |
| 137 | const s = await setup({ snapshot: async () => { entered(); return new Promise(resolve => { finish = resolve; }); } }); |
| 138 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" }); |
| 139 | const tab = await s.surfaces.open("https://example.test", { taskId: "task", sessionId: "s", temporary: true }); |
| 140 | if (!fixed) tab.viewport = null; |
| 141 | const before = tab.viewportRevision; |
| 142 | const request = s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id }); |
| 143 | const outcome = request.then(value => ({ value }), error => ({ error })); |
| 144 | await started; |
| 145 | s.surfaces.setLayout({ x: 0, y: 0, width: 700, height: 500 }); |
| 146 | finish({ tree: "fixture" } as never); |
| 147 | const result = await outcome; |
| 148 | if (fixed) { assert.deepEqual(result, { value: { tree: "fixture" } }); assert.equal(tab.viewportRevision, before); } |
| 149 | else { assert.ok("error" in result); assert.ok(tab.viewportRevision > before); } |
| 150 | s.surfaces.destroyAll(); |
| 151 | } |
| 152 | }); |
| 153 | |
| 154 | test("cancel releases a navigation waiter without replaying the dispatched navigation", async () => { |
| 155 | const s = await setup(); |
| 156 | await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" }); |
| 157 | const tab = await s.surfaces.open("https://test.example", { taskId: "task", sessionId: "s", temporary: false }); |
| 158 | let finish!: () => void; |
| 159 | const page = tab.view.page as import("./fakeGuestViews.js").FakePage; |
| 160 | page.loadResult = new Promise(resolve => { finish = resolve; }); |
| 161 | const request = s.call("host/browser.tabs.navigate", { grantId: "g", tabId: tab.id, requestId: "nav", url: "https://next.example" }); |
| 162 | await s.call("host/browser.cancel", { grantId: "g", requestId: "nav" }); |
| 163 | await assert.rejects(request, /outcome is unknown/); |
| 164 | assert.equal(page.getURL(), "https://next.example/"); |
| 165 | finish(); |
| 166 | await Promise.resolve(); |
| 167 | assert.equal(s.surfaces.tabsForSession("task", "s").length, 1); |
| 168 | }); |
| 169 | |
| 170 | test("a new session grant cannot list or control the task's earlier session tabs", async () => { |
| 171 | const s = await setup(); |
| 172 | await s.call("host/browser.grant", { grantId: "old", tabId: "task-1", sessionId: "session-1" }); |
| 173 | const old = (await s.call("host/browser.tabs.open", { grantId: "old", url: "https://old.test" })) as HostBrowserTab; |
| 174 | await s.call("host/browser.grant", { grantId: "next", tabId: "task-1", sessionId: "session-2" }); |
| 175 | const listed = (await s.call("host/browser.tabs.list", { grantId: "next" })) as { tabs: HostBrowserTab[] }; |
| 176 | assert.deepEqual(listed.tabs, []); |
| 177 | await assert.rejects(s.call("host/browser.snapshot", { grantId: "next", tabId: old.id }), code(BROWSER_ERR_NO_GRANT)); |
| 178 | }); |
| 179 | |
| 180 | test("tab calls verify the grant and refuse tabs of another task", async () => { |
| 181 | const s = await setup(); |
| 182 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 183 | const other = await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false }); |
| 184 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: other.id, url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT)); |
| 185 | await assert.rejects(s.call("host/browser.tabs.close", { grantId: "g", tabId: other.id }), code(BROWSER_ERR_NO_GRANT)); |
| 186 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: "tab-99", url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT)); |
| 187 | }); |
| 188 | |
| 189 | test("reads and writes refuse a tab the user has taken over", async () => { |
| 190 | const s = await setup(); |
| 191 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 192 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 193 | assert.deepEqual(await s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id, selector: "" }), { tabId: tab.id, selector: "" }); |
| 194 | s.surfaces.takeover(tab.id, "user mousedown"); |
| 195 | await assert.rejects(s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id }), code(BROWSER_ERR_TAKEN_OVER)); |
| 196 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: tab.id, url: "https://c.test" }), code(BROWSER_ERR_TAKEN_OVER)); |
| 197 | const refreshed = (await s.call("host/browser.tabs.navigate", { |
| 198 | grantId: "g", tabId: tab.id, url: "https://refreshed.test", allowHuman: true, |
| 199 | })) as HostBrowserTab; |
| 200 | assert.equal(refreshed.url, "https://refreshed.test/"); |
| 201 | assert.equal(s.surfaces.require(tab.id).mode, "human", "an explicit user refresh preserves takeover"); |
| 202 | await assert.rejects(s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" }), code(BROWSER_ERR_TAKEN_OVER)); |
| 203 | const closed = await s.call("host/browser.tabs.close", { grantId: "g", tabId: tab.id }); |
| 204 | assert.deepEqual(closed, {}, "closing is still allowed so the task can clean up"); |
| 205 | assert.equal(s.surfaces.get(tab.id), undefined); |
| 206 | }); |
| 207 | |
| 208 | test("act re-verifies the grant and rejects a stale document token", async () => { |
| 209 | const s = await setup(); |
| 210 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 211 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 212 | await assert.rejects( |
| 213 | s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }), |
| 214 | code(BROWSER_ERR_STALE_REFERENCE), |
| 215 | ); |
| 216 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 217 | await assert.rejects( |
| 218 | s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }), |
| 219 | code(BROWSER_ERR_NO_GRANT), |
| 220 | ); |
| 221 | }); |
| 222 | |
| 223 | test("act and screenshot register the scratch directory for downloads", async () => { |
| 224 | const s = await setup(); |
| 225 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 226 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 227 | await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "/scratch/one" }); |
| 228 | assert.equal(s.directories.get("task-1"), "/scratch/one"); |
| 229 | await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" }); |
| 230 | assert.equal(s.directories.get("task-1"), "/scratch/one", "an empty directory keeps the previous mapping"); |
| 231 | }); |
| 232 | |
| 233 | test("downloads waits are served per tab and a zero wait answers at once", async () => { |
| 234 | const s = await setup(); |
| 235 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 236 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 237 | assert.deepEqual(await s.call("host/browser.downloads", { grantId: "g", tabId: tab.id, waitForMs: 0 }), { downloads: [] }); |
| 238 | }); |
| 239 | |
| 240 | test("revoke invalidates the task's document tokens", async () => { |
| 241 | const s = await setup(); |
| 242 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 243 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 244 | const token = s.documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snap", frames: [] }); |
| 245 | assert.ok(s.documents.lookup(token)); |
| 246 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 247 | assert.equal(s.documents.lookup(token), undefined); |
| 248 | }); |
| 249 |