| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import { FakeFrame, FakePage } from "./fakeGuestViews.js"; |
| 4 | import { dispatchMouseInput } from "./mouseInput.js"; |
| 5 | import { withFrameOperationSignal } from "./frameRuntime.js"; |
| 6 | import { disposeDebugger } from "./debuggerLease.js"; |
| 7 | |
| 8 | function fixture(kind: "main" | "local" | "remote") { |
| 9 | const page = new FakePage(1); |
| 10 | const child = new FakeFrame(2, "https://child.test", () => undefined); |
| 11 | child.parent = page.mainFrame; page.mainFrame.children.push(child); |
| 12 | page.run = source => source.includes("reasonix:pageInputReady") ? true : kind === "main" ? null : { index: 0, x: 40, y: 20 }; |
| 13 | page.debugger.respond = (method, raw) => { |
| 14 | const params = raw as Record<string, unknown>; |
| 15 | if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } }; |
| 16 | if (method === "Page.createIsolatedWorld") return { executionContextId: params.frameId === "root" ? 1 : 2 }; |
| 17 | if (method === "Runtime.evaluate") return params.returnByValue ? { result: { value: String(params.expression).includes("reasonix:pageInputReady") ? true : null } } : { result: { objectId: "owner" } }; |
| 18 | if (method === "DOM.describeNode") return { node: { frameId: "child", backendNodeId: 10 } }; |
| 19 | if (method === "Target.getTargets") return { targetInfos: kind === "remote" ? [{ targetId: "child", type: "iframe" }] : [] }; |
| 20 | if (method === "Target.attachToTarget") return { sessionId: "child-session" }; |
| 21 | return {}; |
| 22 | }; |
| 23 | const routed: Array<{ sessionId?: string; params: unknown }> = []; |
| 24 | const send = page.debugger.sendCommand.bind(page.debugger); |
| 25 | page.debugger.sendCommand = async (method, params, sessionId?: string) => { |
| 26 | if (method === "Input.dispatchMouseEvent") { |
| 27 | assert.equal(sessionId, "child-session", "root CDP hit-test routing must never choose the target renderer"); |
| 28 | routed.push({ sessionId, params }); |
| 29 | } |
| 30 | return send(method, params); |
| 31 | }; |
| 32 | return { page, child, routed }; |
| 33 | } |
| 34 | |
| 35 | test("mouse input selects its renderer and applies native zoom and display scale once", async () => { |
| 36 | for (const kind of ["main", "local", "remote"] as const) for (const [zoom, scale] of [[1.25, 1.25], [1, 0.5], [1, 0.75]]) { |
| 37 | const f = fixture(kind); f.page.zoom = zoom; |
| 38 | try { |
| 39 | for (const type of ["mouseMove", "mouseDown", "mouseUp"] as const) await dispatchMouseInput(f.page, { type, x: 100, y: 120, ...(type === "mouseMove" ? {} : { button: "left" as const, clickCount: 1 }) }, () => scale); |
| 40 | if (kind === "remote") { |
| 41 | assert.equal(f.page.inputs.length, 0); |
| 42 | assert.deepEqual(f.routed.map(row => row.params), [ |
| 43 | { type: "mouseMoved", x: 40, y: 20, button: "none", buttons: 0, clickCount: 0 }, |
| 44 | { type: "mousePressed", x: 40, y: 20, button: "left", buttons: 1, clickCount: 1 }, |
| 45 | { type: "mouseReleased", x: 40, y: 20, button: "left", buttons: 0, clickCount: 1 }, |
| 46 | ]); |
| 47 | assert.equal(f.page.debugger.commands.filter(row => row.method === "Target.attachToTarget").length, 1, "move, press and release share the owning target session"); |
| 48 | assert.equal(f.page.debugger.commands.filter(row => row.method === "Target.detachFromTarget").length, 0); |
| 49 | } else { |
| 50 | assert.equal(f.routed.length, 0); |
| 51 | assert.deepEqual(f.page.inputs.map(event => "x" in event ? [event.x, event.y] : []), [[100, 120], [100, 120], [100, 120]]); |
| 52 | } |
| 53 | } finally { disposeDebugger(f.page.debugger); } |
| 54 | } |
| 55 | }); |
| 56 | |
| 57 | test("cancelled render preparation consumes late results without dispatching", async () => { |
| 58 | const f = fixture("remote"), controller = new AbortController(); |
| 59 | let entered!: () => void, finish!: (value: boolean) => void; |
| 60 | const started = new Promise<void>(resolve => { entered = resolve; }); |
| 61 | f.page.run = () => { entered(); return new Promise<boolean>(resolve => { finish = resolve; }); }; |
| 62 | const running = withFrameOperationSignal(controller.signal, () => dispatchMouseInput(f.page, { type: "mouseMove", x: 30, y: 40 }, () => 1)); |
| 63 | const rejected = assert.rejects(running, /cancelled/); |
| 64 | await started; controller.abort(); await rejected; |
| 65 | finish(true); await new Promise<void>(resolve => setImmediate(resolve)); |
| 66 | assert.equal(f.page.inputs.length, 0); assert.equal(f.routed.length, 0); |
| 67 | assert.equal(f.page.debugger.commands.length, 0); |
| 68 | }); |
| 69 | |
| 70 | test("same-process descendants of an OOPIF use their renderer owner's coordinates", async () => { |
| 71 | const f = fixture("remote"), respond = f.page.debugger.respond; |
| 72 | const inner = new FakeFrame(3, f.child.url, () => undefined); |
| 73 | inner.parent = f.child; f.child.children.push(inner); |
| 74 | f.page.debugger.respond = (method, raw) => { |
| 75 | const params = raw as Record<string, unknown>; |
| 76 | if (method === "Page.createIsolatedWorld") return { executionContextId: params.frameId === "root" ? 1 : params.frameId === "child" ? 2 : 3 }; |
| 77 | if (method === "Runtime.evaluate") { |
| 78 | if (!params.returnByValue) return { result: { objectId: `owner-${params.contextId}` } }; |
| 79 | return { result: { value: params.contextId === 2 ? { index: 0, x: 4, y: 5 } : null } }; |
| 80 | } |
| 81 | if (method === "DOM.describeNode") return { node: { frameId: params.objectId === "owner-1" ? "child" : "inner", backendNodeId: params.objectId === "owner-1" ? 10 : 11 } }; |
| 82 | return respond(method, raw); |
| 83 | }; |
| 84 | try { |
| 85 | await dispatchMouseInput(f.page, { type: "mouseDown", x: 100, y: 120, button: "left" }, () => 0.5); |
| 86 | assert.equal(f.page.inputs.length, 0); |
| 87 | assert.deepEqual(f.routed, [{ sessionId: "child-session", params: { type: "mousePressed", x: 40, y: 20, button: "left", buttons: 1, clickCount: 0 } }]); |
| 88 | } finally { disposeDebugger(f.page.debugger); } |
| 89 | }); |
| 90 | |
| 91 | test("frame replacement and viewport changes during routing refuse input without fallback", async () => { |
| 92 | for (const change of ["frame", "zoom", "scale", "grant"] as const) { |
| 93 | const f = fixture("remote"), respond = f.page.debugger.respond; |
| 94 | let scale = 1, valid = true; |
| 95 | f.page.debugger.respond = (method, raw) => { |
| 96 | if (method === "Runtime.evaluate" && (raw as { returnByValue?: boolean }).returnByValue) { |
| 97 | if (change === "frame") f.child.detached = true; |
| 98 | if (change === "zoom") f.page.zoom = 2; |
| 99 | if (change === "scale") scale = 0.5; |
| 100 | if (change === "grant") valid = false; |
| 101 | } |
| 102 | return respond(method, raw); |
| 103 | }; |
| 104 | try { |
| 105 | await assert.rejects(dispatchMouseInput(f.page, { type: "mouseDown", x: 30, y: 40, button: "left" }, () => scale, () => { if (!valid) throw new Error("grant revoked"); })); |
| 106 | assert.equal(f.page.inputs.length, 0); assert.equal(f.routed.length, 0); |
| 107 | assert.ok(f.page.debugger.commands.some(row => row.method === "Target.detachFromTarget")); |
| 108 | } finally { disposeDebugger(f.page.debugger); } |
| 109 | } |
| 110 | }); |
| 111 |