| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { RpcError } from "../rpc.js"; |
| 4 | import { ActionExecutor, type ActRequest } from "./actions.js"; |
| 5 | import { DocumentRegistry } from "./documents.js"; |
| 6 | import { BROWSER_ERR_NO_GRANT, BROWSER_ERR_STALE_REFERENCE, BROWSER_ERR_TAKEN_OVER, noGrant } from "./errors.js"; |
| 7 | import { FakeGuestView, FakeViewFactory, silentLog } from "./fakeGuestViews.js"; |
| 8 | import type { ResolveOutput } from "./pageScripts.js"; |
| 9 | import { BrowserSurfaceManager } from "./surfaceManager.js"; |
| 10 | |
| 11 | const code = (value: number) => (error: unknown) => error instanceof RpcError && error.code === value; |
| 12 | |
| 13 | async function setup() { |
| 14 | const factory = new FakeViewFactory(); |
| 15 | const manager = new BrowserSurfaceManager({ views: factory, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog, openWaitMs: 5 }); |
| 16 | let tokens = 0; |
| 17 | const documents = new DocumentRegistry(() => `tok-${++tokens}`); |
| 18 | const tab = await manager.open("https://a.test", { taskId: "t", temporary: false }); |
| 19 | const view = tab.view as FakeGuestView; |
| 20 | const page = view.page; |
| 21 | const answers = { |
| 22 | resolve: { ok: true, x: 10, y: 20, width: 100, height: 40, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true } as ResolveOutput, |
| 23 | identity: true, |
| 24 | select: { ok: true, selected: ["x"] } as unknown, |
| 25 | locate: { ok: true, tag: "input", type: "file", path: "html > body:nth-child(2) > input:nth-child(1)" } as unknown, |
| 26 | beforeResolve: () => {}, |
| 27 | }; |
| 28 | const scripts: string[] = []; |
| 29 | page.run = (source) => { |
| 30 | scripts.push(source); |
| 31 | if (source.startsWith("(function pageResolve")) { |
| 32 | answers.beforeResolve(); |
| 33 | return answers.resolve; |
| 34 | } |
| 35 | if (source.startsWith("(function pageIdentity")) return answers.identity; |
| 36 | if (source.startsWith("(function pageSelect")) return answers.select; |
| 37 | if (source.startsWith("(function pageLocate")) return answers.locate; |
| 38 | if (source.startsWith("({ width")) return { width: 800, height: 600 }; |
| 39 | return undefined; |
| 40 | }; |
| 41 | const token = documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snap", frames: [{ prefix: "", frameTreeNodeId: page.mainFrame.frameTreeNodeId, docId: "doc-1" }] }); |
| 42 | const settled: Array<() => void> = []; |
| 43 | const actions = new ActionExecutor({ |
| 44 | surfaces: manager, |
| 45 | documents, |
| 46 | fileExists: (path) => path.endsWith(".txt"), |
| 47 | sleep: async () => { |
| 48 | for (const hook of settled.splice(0)) hook(); |
| 49 | }, |
| 50 | }); |
| 51 | const request = (extra: Partial<ActRequest>): ActRequest => ({ |
| 52 | operationId: "op", tabId: tab.id, documentToken: token, action: "click", ref: "e1", text: "", keys: "", options: [], files: [], submit: false, deltaX: 0, deltaY: 0, ...extra, |
| 53 | }); |
| 54 | let grantOk = true; |
| 55 | const verify = () => { |
| 56 | if (!grantOk) throw noGrant("revoked"); |
| 57 | }; |
| 58 | return { manager, documents, tab, view, page, answers, scripts, token, actions, request, verify, settled, revoke: () => (grantOk = false) }; |
| 59 | } |
| 60 | |
| 61 | test("acts are refused with the contract codes before any input is dispatched", async () => { |
| 62 | const s = await setup(); |
| 63 | await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: "nope" }), s.verify), code(BROWSER_ERR_STALE_REFERENCE)); |
| 64 | const other = await s.manager.open("https://b.test", { taskId: "t", temporary: false }); |
| 65 | await assert.rejects(s.actions.act(other, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "a token of another tab is stale"); |
| 66 | |
| 67 | s.manager.takeover(s.tab.id, "user click"); |
| 68 | await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER)); |
| 69 | s.manager.resume(s.tab.id); |
| 70 | await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "resume bumps the epoch, so the old snapshot is stale"); |
| 71 | |
| 72 | const fresh = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap2", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-1" }] }); |
| 73 | s.view.fire().onNavigate("https://a.test/other", false); |
| 74 | await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: fresh }), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "navigation bumps the epoch"); |
| 75 | |
| 76 | const again = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap3", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-1" }] }); |
| 77 | s.revoke(); |
| 78 | await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: again }), s.verify), code(BROWSER_ERR_NO_GRANT)); |
| 79 | assert.equal(s.page.inputs.length, 0, "nothing reached the page"); |
| 80 | }); |
| 81 | |
| 82 | test("a take-over that lands while the ref resolves cancels the act", async () => { |
| 83 | const s = await setup(); |
| 84 | s.answers.beforeResolve = () => s.manager.takeover(s.tab.id, "user typing"); |
| 85 | await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER)); |
| 86 | assert.equal(s.page.inputs.length, 0); |
| 87 | }); |
| 88 | |
| 89 | // Promoted from prototypes/electron-browser/scripts/verify-runtime.cjs: a |
| 90 | // renderer crash between approval and dispatch must cancel the pending act — |
| 91 | // the recovered page comes back in human mode and nothing is ever replayed. |
| 92 | test("a renderer crash while the ref resolves cancels the act without dispatching", async () => { |
| 93 | const s = await setup(); |
| 94 | s.answers.beforeResolve = () => s.view.fire().onRenderProcessGone("crashed"); |
| 95 | await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER)); |
| 96 | assert.equal(s.page.inputs.length, 0, "no input reached the crashed page"); |
| 97 | assert.equal(s.tab.mode, "human", "the recovered tab waits for a fresh grant"); |
| 98 | }); |
| 99 | |
| 100 | // Promoted from the same prototype suite: input already dispatched when the |
| 101 | // renderer dies is treated as applied (executed, no token rotation), so the |
| 102 | // Go-side ledger can never settle it as not-executed and replay it. |
| 103 | test("a renderer crash after dispatch completes the act without replay", async () => { |
| 104 | const s = await setup(); |
| 105 | s.settled.push(() => s.view.fire().onRenderProcessGone("crashed")); |
| 106 | assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: true }); |
| 107 | assert.equal(s.page.inputs.length, 3, "the click was physically dispatched before the crash"); |
| 108 | assert.equal(s.tab.mode, "human"); |
| 109 | assert.deepEqual(s.page.calls.at(-1), "load:https://a.test/", "the crashed view reloads its last URL"); |
| 110 | }); |
| 111 | |
| 112 | test("click dispatches trusted mouse events at the zoomed centre and rotates the token", async () => { |
| 113 | const s = await setup(); |
| 114 | s.page.zoom = 2; |
| 115 | const result = await s.actions.act(s.tab, s.request({}), s.verify); |
| 116 | assert.deepEqual(result, { executed: true, documentToken: "tok-2" }); |
| 117 | assert.deepEqual(s.page.inputs, [ |
| 118 | { type: "mouseMove", x: 120, y: 80 }, |
| 119 | { type: "mouseDown", x: 120, y: 80, button: "left", clickCount: 1 }, |
| 120 | { type: "mouseUp", x: 120, y: 80, button: "left", clickCount: 1 }, |
| 121 | ]); |
| 122 | assert.ok(s.tab.agentInputUntil > 0, "the agent's own input is marked so its echo is not a take-over"); |
| 123 | await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "the old token retired"); |
| 124 | const chained = await s.actions.act(s.tab, s.request({ documentToken: "tok-2" }), s.verify); |
| 125 | assert.deepEqual(chained, { executed: true, documentToken: "tok-3" }); |
| 126 | }); |
| 127 | |
| 128 | test("a navigation or document replacement during the act completes it without a token", async () => { |
| 129 | const s = await setup(); |
| 130 | s.settled.push(() => s.view.fire().onNavigate("https://a.test/after-click", false)); |
| 131 | assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: true }); |
| 132 | const token = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap2", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-2" }] }); |
| 133 | s.answers.identity = false; |
| 134 | assert.deepEqual(await s.actions.act(s.tab, s.request({ documentToken: token }), s.verify), { executed: true }); |
| 135 | }); |
| 136 | |
| 137 | test("non-interactable targets report executed:false with the same token", async () => { |
| 138 | const s = await setup(); |
| 139 | s.answers.resolve = { ok: false, reason: "element is covered by another element" }; |
| 140 | assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: false, reason: "element is covered by another element", documentToken: s.token }); |
| 141 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 5, height: 5, tag: "button", type: "", disabled: true, editable: false, frameOffsetKnown: true }; |
| 142 | assert.equal((await s.actions.act(s.tab, s.request({}), s.verify)).reason, "element is disabled"); |
| 143 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 5, height: 5, tag: "div", type: "", disabled: false, editable: false, frameOffsetKnown: false }; |
| 144 | assert.match((await s.actions.act(s.tab, s.request({}), s.verify)).reason ?? "", /cross-origin frame/); |
| 145 | assert.equal((await s.actions.act(s.tab, s.request({ action: "explode" }), s.verify)).executed, false); |
| 146 | assert.equal((await s.actions.act(s.tab, s.request({ ref: "" }), s.verify)).reason, "this action needs a ref"); |
| 147 | assert.equal(s.page.inputs.length, 0); |
| 148 | }); |
| 149 | |
| 150 | test("type clicks to focus, inserts text and submits with Enter", async () => { |
| 151 | const s = await setup(); |
| 152 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true }; |
| 153 | const result = await s.actions.act(s.tab, s.request({ action: "type", text: "hello", submit: true }), s.verify); |
| 154 | assert.deepEqual(result, { executed: true, documentToken: "tok-2" }); |
| 155 | assert.deepEqual(s.page.inserted, ["hello"]); |
| 156 | assert.deepEqual(s.page.inputs.map((event) => event.type), ["mouseMove", "mouseDown", "mouseUp", "keyDown", "char", "keyUp"]); |
| 157 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true }; |
| 158 | assert.equal((await s.actions.act(s.tab, s.request({ action: "type", text: "x", documentToken: "tok-2" }), s.verify)).reason, "element is not editable"); |
| 159 | }); |
| 160 | |
| 161 | test("press sends chords, scroll sends inverted wheel deltas, select runs in the page", async () => { |
| 162 | const s = await setup(); |
| 163 | assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "press", keys: "Control+a Escape", ref: "" }), s.verify), { executed: true, documentToken: "tok-2" }); |
| 164 | assert.deepEqual(s.page.inputs.map((event) => `${event.type}:${(event as { keyCode?: string }).keyCode ?? ""}`), ["keyDown:a", "keyUp:a", "keyDown:Escape", "keyUp:Escape"]); |
| 165 | assert.match((await s.actions.act(s.tab, s.request({ action: "press", keys: "Control+", ref: "", documentToken: "tok-2" }), s.verify)).reason ?? "", /malformed/); |
| 166 | |
| 167 | s.page.inputs.length = 0; |
| 168 | assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "scroll", ref: "", deltaY: 300, documentToken: "tok-2" }), s.verify), { executed: true, documentToken: "tok-3" }); |
| 169 | assert.deepEqual(s.page.inputs, [{ type: "mouseWheel", x: 400, y: 300, deltaX: -0, deltaY: -300, canScroll: true }]); |
| 170 | assert.equal((await s.actions.act(s.tab, s.request({ action: "scroll", ref: "", documentToken: "tok-3" }), s.verify)).reason, "scroll deltas are both zero"); |
| 171 | |
| 172 | assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "select", options: ["x"], documentToken: "tok-3" }), s.verify), { executed: true, documentToken: "tok-4" }); |
| 173 | assert.ok(s.scripts.some((source) => source.startsWith("(function pageSelect") && source.includes('"options":["x"]'))); |
| 174 | s.answers.select = { ok: false, reason: "stale" }; |
| 175 | await assert.rejects(s.actions.act(s.tab, s.request({ action: "select", documentToken: "tok-4" }), s.verify), code(BROWSER_ERR_STALE_REFERENCE)); |
| 176 | }); |
| 177 | |
| 178 | test("press focuses a non-editable ref before sending keys", async () => { |
| 179 | const s = await setup(); |
| 180 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true }; |
| 181 | s.page.run = (source) => source.startsWith("(function pageFocus") ? true : source.startsWith("(function pageIdentity") ? true : s.answers.resolve; |
| 182 | const result = await s.actions.act(s.tab, s.request({ action: "press", ref: "e1", keys: "Enter" }), s.verify); |
| 183 | assert.equal(result.executed, true); |
| 184 | assert.equal(s.page.inputs.filter((event) => event.type === "keyDown").length, 1); |
| 185 | }); |
| 186 | |
| 187 | test("upload validates files and sets them through the DevTools protocol", async () => { |
| 188 | const s = await setup(); |
| 189 | assert.equal((await s.actions.act(s.tab, s.request({ action: "upload" }), s.verify)).reason, "upload needs files"); |
| 190 | assert.match((await s.actions.act(s.tab, s.request({ action: "upload", files: ["relative.txt"] }), s.verify)).reason ?? "", /absolute/); |
| 191 | assert.match((await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/missing.bin"] }), s.verify)).reason ?? "", /not found/); |
| 192 | s.page.debugger.respond = (method) => { |
| 193 | if (method === "Runtime.enable") s.page.debugger.emit("Runtime.executionContextCreated", { context: { id: 9, auxData: { isDefault: false } } }); |
| 194 | return method === "Runtime.callFunctionOn" ? { result: { objectId: "obj-9" } } : {}; |
| 195 | }; |
| 196 | const result = await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"] }), s.verify); |
| 197 | assert.deepEqual(result, { executed: true, documentToken: "tok-2" }); |
| 198 | assert.deepEqual(s.page.debugger.commands.map((command) => command.method), ["Runtime.enable", "Runtime.callFunctionOn", "Runtime.disable", "DOM.setFileInputFiles", "Runtime.releaseObjectGroup"]); |
| 199 | assert.deepEqual(s.page.debugger.commands[3].params, { objectId: "obj-9", files: ["/tmp/a.txt"] }); |
| 200 | assert.equal(s.page.debugger.attached, false, "the debugger is detached afterwards"); |
| 201 | s.answers.locate = { ok: true, tag: "input", type: "text", path: "html" }; |
| 202 | assert.equal((await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"], documentToken: "tok-2" }), s.verify)).reason, "element is not a file input"); |
| 203 | }); |
| 204 | |
| 205 | test("takeover after a focus click preserves an unknown receipt and stops typing", async () => { |
| 206 | for (const action of ["type", "press"]) { |
| 207 | const s = await setup(); |
| 208 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true }; |
| 209 | s.settled.push(() => s.manager.takeover(s.tab.id, "user")); |
| 210 | const result = await s.actions.act(s.tab, s.request({ action, text: "hello", keys: "Enter" }), s.verify); |
| 211 | assert.equal(result.outcome, "unknown"); |
| 212 | assert.equal(result.executed, false); |
| 213 | assert.equal(s.page.inputs.length, 3); |
| 214 | assert.deepEqual(s.page.inserted, []); |
| 215 | } |
| 216 | }); |
| 217 | |
| 218 | test("takeover during insertText cancels the pending submit without a false no-effect receipt", async () => { |
| 219 | const s = await setup(); |
| 220 | s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true }; |
| 221 | s.page.insertText = async () => { s.manager.takeover(s.tab.id, "user"); }; |
| 222 | const result = await s.actions.act(s.tab, s.request({ action: "type", text: "hello", submit: true }), s.verify); |
| 223 | assert.equal(result.outcome, "unknown"); |
| 224 | assert.equal(s.page.inputs.filter((event) => event.type === "keyDown").length, 0); |
| 225 | }); |
| 226 | |
| 227 | test("upload verifies takeover and grant after CDP lookup, before attaching files", async () => { |
| 228 | for (const invalidate of ["takeover", "revoke", "navigation"]) { |
| 229 | const s = await setup(); |
| 230 | s.page.debugger.respond = (method) => { |
| 231 | if (method === "Runtime.enable") s.page.debugger.emit("Runtime.executionContextCreated", { context: { id: 9, auxData: { isDefault: false } } }); |
| 232 | if (method === "Runtime.callFunctionOn") { |
| 233 | if (invalidate === "takeover") s.manager.takeover(s.tab.id, "user"); |
| 234 | if (invalidate === "revoke") s.revoke(); |
| 235 | if (invalidate === "navigation") s.view.fire().onNavigate("https://other.test", false); |
| 236 | return { result: { objectId: "original-input" } }; |
| 237 | } |
| 238 | return {}; |
| 239 | }; |
| 240 | await assert.rejects(s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"] }), s.verify)); |
| 241 | assert.equal(s.page.debugger.commands.some((command) => command.method === "DOM.setFileInputFiles"), false); |
| 242 | } |
| 243 | }); |
| 244 | |
| 245 | test("select receipt survives a takeover after its DOM mutation, and lost script replies stay unknown", async () => { |
| 246 | const s = await setup(); |
| 247 | s.page.run = () => { s.manager.takeover(s.tab.id, "user"); return { ok: true, selected: ["x"] }; }; |
| 248 | assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "select", options: ["x"] }), s.verify), { executed: true }); |
| 249 | const other = await setup(); |
| 250 | other.page.run = () => { throw new Error("renderer gone after change"); }; |
| 251 | assert.equal((await other.actions.act(other.tab, other.request({ action: "select", options: ["x"] }), other.verify)).outcome, "unknown"); |
| 252 | }); |
| 253 |