From 682ddb1e74e501fe82ecbe689b685325203cf2ea Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 24 May 2026 13:23:18 -0700 Subject: [PATCH] fix(daemon): activate tabs before mouse input --- .../src/__tests__/command-dispatch.test.ts | 131 ++++++++++++++++++ packages/daemon/src/command-dispatch.ts | 8 ++ 2 files changed, 139 insertions(+) create mode 100644 packages/daemon/src/__tests__/command-dispatch.test.ts diff --git a/packages/daemon/src/__tests__/command-dispatch.test.ts b/packages/daemon/src/__tests__/command-dispatch.test.ts new file mode 100644 index 0000000..bf2601f --- /dev/null +++ b/packages/daemon/src/__tests__/command-dispatch.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Request } from "@bb-browser/shared"; +import { dispatchRequest } from "../command-dispatch.js"; +import type { CdpConnection, CdpTargetInfo } from "../cdp-connection.js"; +import { TabStateManager } from "../tab-state.js"; + +type RecordedCommand = { + scope: "browser" | "session"; + targetId?: string; + method: string; + params: Record; +}; + +class FakeCdp { + readonly tabManager = new TabStateManager(); + readonly target: CdpTargetInfo = { + id: "target-background", + type: "page", + title: "Example", + url: "https://example.com", + }; + readonly commands: RecordedCommand[] = []; + currentTargetId: string | undefined; + + constructor() { + const tab = this.tabManager.addTab(this.target.id); + tab.refs["1"] = { + backendDOMNodeId: 42, + role: "button", + name: "Submit", + tagName: "button", + }; + } + + async ensurePageTarget(): Promise { + this.currentTargetId = this.target.id; + return this.target; + } + + async browserCommand(method: string, params: Record = {}): Promise { + this.commands.push({ scope: "browser", method, params }); + return {} as T; + } + + async sessionCommand( + targetId: string, + method: string, + params: Record = {}, + ): Promise { + this.commands.push({ scope: "session", targetId, method, params }); + + if (method === "DOM.resolveNode") { + return { object: { objectId: "object-1" } } as T; + } + if (method === "Runtime.callFunctionOn") { + return { result: { value: { x: 100, y: 200 } } } as T; + } + return {} as T; + } +} + +function fakeConnection(): CdpConnection { + return new FakeCdp() as unknown as CdpConnection; +} + +describe("dispatchRequest input activation", () => { + it("activates a background target before resolving click coordinates", async () => { + const cdp = fakeConnection(); + + const response = await dispatchRequest(cdp, { + id: "click-1", + method: "click", + ref: "1", + } satisfies Request); + + assert.equal(response.error, undefined); + assert.equal(response.result?.tab, "ound"); + assert.equal(typeof response.result?.seq, "number"); + + const commands = (cdp as unknown as FakeCdp).commands; + const activateIndex = commands.findIndex( + (cmd) => cmd.scope === "browser" && cmd.method === "Target.activateTarget", + ); + const resolveIndex = commands.findIndex((cmd) => cmd.method === "DOM.resolveNode"); + const pressIndex = commands.findIndex( + (cmd) => cmd.method === "Input.dispatchMouseEvent" && cmd.params.type === "mousePressed", + ); + + assert.notEqual(activateIndex, -1); + assert.notEqual(resolveIndex, -1); + assert.notEqual(pressIndex, -1); + assert.ok(activateIndex < resolveIndex); + assert.ok(activateIndex < pressIndex); + assert.deepEqual(commands[activateIndex].params, { targetId: "target-background" }); + }); + + it("activates a background target before dispatching wheel events", async () => { + const cdp = fakeConnection(); + + const response = await dispatchRequest(cdp, { + id: "scroll-1", + method: "scroll", + direction: "down", + pixels: 200, + } satisfies Request); + + assert.equal(response.error, undefined); + assert.equal(response.result?.tab, "ound"); + assert.equal(typeof response.result?.seq, "number"); + + const commands = (cdp as unknown as FakeCdp).commands; + const activateIndex = commands.findIndex( + (cmd) => cmd.scope === "browser" && cmd.method === "Target.activateTarget", + ); + const wheelIndex = commands.findIndex( + (cmd) => cmd.method === "Input.dispatchMouseEvent" && cmd.params.type === "mouseWheel", + ); + + assert.notEqual(activateIndex, -1); + assert.notEqual(wheelIndex, -1); + assert.ok(activateIndex < wheelIndex); + assert.deepEqual(commands[wheelIndex].params, { + type: "mouseWheel", + x: 0, + y: 0, + deltaX: 0, + deltaY: 200, + }); + }); +}); diff --git a/packages/daemon/src/command-dispatch.ts b/packages/daemon/src/command-dispatch.ts index 2b5751a..96c78fe 100644 --- a/packages/daemon/src/command-dispatch.ts +++ b/packages/daemon/src/command-dispatch.ts @@ -400,6 +400,10 @@ async function mouseClick(cdp: CdpConnection, targetId: string, x: number, y: nu }); } +async function activateTarget(cdp: CdpConnection, targetId: string): Promise { + await cdp.browserCommand("Target.activateTarget", { targetId }); +} + async function insertTextIntoNode( cdp: CdpConnection, targetId: string, @@ -763,6 +767,9 @@ export async function dispatchRequest( role: refInfo?.role, tag: refInfo?.tagName, }); + if (request.method === "click") { + await activateTarget(cdp, target.id); + } const backendNodeId = await parseRef(cdp, target.id, tab, request.ref); const point = await getInteractablePoint(cdp, target.id, backendNodeId); await cdp.sessionCommand(target.id, "Input.dispatchMouseEvent", { @@ -886,6 +893,7 @@ export async function dispatchRequest( case "left": deltaX = -pixels; break; case "right": deltaX = pixels; break; } + await activateTarget(cdp, target.id); await cdp.sessionCommand(target.id, "Input.dispatchMouseEvent", { type: "mouseWheel", x: 0, y: 0, deltaX, deltaY, });