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