| 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 } 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() { |
| 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 | }); |
| 38 | const call = async (method: keyof typeof table, params: Record<string, unknown> = {}): Promise<unknown> => table[method](params); |
| 39 | const setDirs = downloads.setTaskDirectory.bind(downloads); |
| 40 | downloads.setTaskDirectory = (taskId, directory) => { |
| 41 | directories.set(taskId, directory); |
| 42 | setDirs(taskId, directory); |
| 43 | }; |
| 44 | return { surfaces, grants, documents, downloads, directories, table, call }; |
| 45 | } |
| 46 | |
| 47 | test("grant, list and open are scoped to the grant's task", async () => { |
| 48 | const s = await setup(); |
| 49 | await assert.rejects(s.call("host/browser.tabs.list", { grantId: "g" }), code(BROWSER_ERR_NO_GRANT)); |
| 50 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "s1" }); |
| 51 | await s.call("host/browser.grant", { grantId: "h", tabId: "task-2", sessionId: "s2" }); |
| 52 | |
| 53 | const opened = (await s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test" })) as HostBrowserTab; |
| 54 | assert.equal(opened.url, "https://a.test/"); |
| 55 | assert.equal(s.surfaces.require(opened.id).taskId, "task-1", "the tab belongs to the grant's task"); |
| 56 | await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false }); |
| 57 | |
| 58 | const listed = (await s.call("host/browser.tabs.list", { grantId: "g" })) as { tabs: HostBrowserTab[] }; |
| 59 | assert.deepEqual(listed.tabs.map((tab) => tab.id), [opened.id], "another task's tabs are invisible"); |
| 60 | }); |
| 61 | |
| 62 | test("tab calls verify the grant and refuse tabs of another task", async () => { |
| 63 | const s = await setup(); |
| 64 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 65 | const other = await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false }); |
| 66 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: other.id, url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT)); |
| 67 | await assert.rejects(s.call("host/browser.tabs.close", { grantId: "g", tabId: other.id }), code(BROWSER_ERR_NO_GRANT)); |
| 68 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: "tab-99", url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT)); |
| 69 | }); |
| 70 | |
| 71 | test("reads and writes refuse a tab the user has taken over", async () => { |
| 72 | const s = await setup(); |
| 73 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 74 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 75 | assert.deepEqual(await s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id, selector: "" }), { tabId: tab.id, selector: "" }); |
| 76 | s.surfaces.takeover(tab.id, "user mousedown"); |
| 77 | await assert.rejects(s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id }), code(BROWSER_ERR_TAKEN_OVER)); |
| 78 | await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: tab.id, url: "https://c.test" }), code(BROWSER_ERR_TAKEN_OVER)); |
| 79 | await assert.rejects(s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" }), code(BROWSER_ERR_TAKEN_OVER)); |
| 80 | const closed = await s.call("host/browser.tabs.close", { grantId: "g", tabId: tab.id }); |
| 81 | assert.deepEqual(closed, {}, "closing is still allowed so the task can clean up"); |
| 82 | assert.equal(s.surfaces.get(tab.id), undefined); |
| 83 | }); |
| 84 | |
| 85 | test("act re-verifies the grant and rejects a stale document token", async () => { |
| 86 | const s = await setup(); |
| 87 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 88 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 89 | await assert.rejects( |
| 90 | s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }), |
| 91 | code(BROWSER_ERR_STALE_REFERENCE), |
| 92 | ); |
| 93 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 94 | await assert.rejects( |
| 95 | s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }), |
| 96 | code(BROWSER_ERR_NO_GRANT), |
| 97 | ); |
| 98 | }); |
| 99 | |
| 100 | test("act and screenshot register the scratch directory for downloads", async () => { |
| 101 | const s = await setup(); |
| 102 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 103 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 104 | await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "/scratch/one" }); |
| 105 | assert.equal(s.directories.get("task-1"), "/scratch/one"); |
| 106 | await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" }); |
| 107 | assert.equal(s.directories.get("task-1"), "/scratch/one", "an empty directory keeps the previous mapping"); |
| 108 | }); |
| 109 | |
| 110 | test("downloads waits are served per tab and a zero wait answers at once", async () => { |
| 111 | const s = await setup(); |
| 112 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 113 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 114 | assert.deepEqual(await s.call("host/browser.downloads", { grantId: "g", tabId: tab.id, waitForMs: 0 }), { downloads: [] }); |
| 115 | }); |
| 116 | |
| 117 | test("revoke invalidates the task's document tokens", async () => { |
| 118 | const s = await setup(); |
| 119 | await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" }); |
| 120 | const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false }); |
| 121 | const token = s.documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snap", frames: [] }); |
| 122 | assert.ok(s.documents.lookup(token)); |
| 123 | await s.call("host/browser.revoke", { grantId: "g" }); |
| 124 | assert.equal(s.documents.lookup(token), undefined); |
| 125 | }); |
| 126 |