Skip to content

Commit f5d760e

Browse files
committed
feat(interaction): add real mouse-wheel primitive
1 parent dd9fc9e commit f5d760e

19 files changed

Lines changed: 901 additions & 2 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { SessionManager } from "@/session-manager/manager";
3+
import type { CdpRunner } from "@/tools/shared";
4+
import { handleWheel } from "../wheel";
5+
6+
function fakeAgentWindow(ids: number[]) {
7+
let index = 0;
8+
return {
9+
create: vi.fn(async () => {
10+
const id = ids[index++];
11+
if (id === undefined) throw new Error("ran out of fake ids");
12+
return id;
13+
}),
14+
remove: vi.fn(async () => {}),
15+
ensureActiveTab: vi.fn(async () => {}),
16+
};
17+
}
18+
19+
function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown> = {}) {
20+
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
21+
const cdp: CdpRunner = {
22+
send: vi.fn(async (tabId: number, method: string, params?: object) => {
23+
sent.push({ tabId, method, params });
24+
const handler = handlers[method];
25+
if (handler) return handler(params);
26+
if (method === "Page.getLayoutMetrics") {
27+
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
28+
}
29+
if (method === "Input.dispatchMouseEvent") return {};
30+
throw new Error(`unexpected CDP call ${method}`);
31+
}) as unknown as CdpRunner["send"],
32+
trackSessionTab: vi.fn(),
33+
};
34+
const tabsApi = {
35+
get: vi.fn(
36+
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
37+
),
38+
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
39+
};
40+
return { cdp, tabsApi, sent };
41+
}
42+
43+
describe("handleWheel", () => {
44+
it("dispatches a native mouseWheel event at the viewport centre", async () => {
45+
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
46+
await manager.start("aa11");
47+
const fake = makeFakeCdp();
48+
const bypassOverlay = vi.fn(async () => {});
49+
50+
const result = await handleWheel(
51+
manager,
52+
{
53+
session_id: "aa11",
54+
delta_y: 600,
55+
delta_x: -20,
56+
modifiers: ["ctrl", "shift"],
57+
},
58+
{ cdp: fake.cdp, tabsApi: fake.tabsApi, bypassOverlay },
59+
);
60+
61+
if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`);
62+
expect(result).toMatchObject({
63+
tab_id: 4,
64+
x: 500,
65+
y: 400,
66+
delta_x: -20,
67+
delta_y: 600,
68+
});
69+
expect(fake.sent).toEqual([
70+
{ tabId: 4, method: "Page.getLayoutMetrics", params: {} },
71+
{
72+
tabId: 4,
73+
method: "Input.dispatchMouseEvent",
74+
params: { type: "mouseMoved", x: 500, y: 400, modifiers: 10 },
75+
},
76+
{
77+
tabId: 4,
78+
method: "Input.dispatchMouseEvent",
79+
params: {
80+
type: "mouseWheel",
81+
x: 500,
82+
y: 400,
83+
deltaX: -20,
84+
deltaY: 600,
85+
modifiers: 10,
86+
},
87+
},
88+
]);
89+
expect(bypassOverlay.mock.calls).toEqual([
90+
[4, true],
91+
[4, false],
92+
]);
93+
});
94+
95+
it("uses an element's visible action point for targeted wheel input", async () => {
96+
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
97+
const ctx = await manager.start("aa11");
98+
ctx.refStore.set("e3", 1234, { tabId: 4 });
99+
const fake = makeFakeCdp({
100+
"DOM.scrollIntoViewIfNeeded": () => ({}),
101+
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
102+
});
103+
104+
const result = await handleWheel(
105+
manager,
106+
{ session_id: "aa11", ref: "@e3", delta_y: 120 },
107+
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
108+
);
109+
110+
if ("code" in result) throw new Error(`unexpected error: ${JSON.stringify(result)}`);
111+
expect(result).toMatchObject({ used_ref: "e3", x: 60, y: 40, delta_y: 120 });
112+
expect(fake.sent.at(-1)).toMatchObject({
113+
method: "Input.dispatchMouseEvent",
114+
params: { type: "mouseWheel", x: 60, y: 40, deltaY: 120 },
115+
});
116+
});
117+
118+
it("rejects a zero-distance wheel without issuing CDP calls", async () => {
119+
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
120+
await manager.start("aa11");
121+
const fake = makeFakeCdp();
122+
123+
const result = await handleWheel(
124+
manager,
125+
{ session_id: "aa11", delta_x: 0, delta_y: 0 },
126+
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
127+
);
128+
129+
expect(result).toMatchObject({ code: "invalid_params" });
130+
expect(fake.cdp.send).not.toHaveBeenCalled();
131+
});
132+
133+
it("does not issue CDP calls after an early cancellation", async () => {
134+
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
135+
await manager.start("aa11");
136+
const fake = makeFakeCdp();
137+
const abort = new AbortController();
138+
abort.abort();
139+
140+
const result = await handleWheel(
141+
manager,
142+
{ session_id: "aa11", delta_y: 120 },
143+
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
144+
);
145+
146+
expect(result).toMatchObject({ code: "cancelled" });
147+
expect(fake.cdp.send).not.toHaveBeenCalled();
148+
});
149+
});

apps/extension/src/tools/dispatcher.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
SelectParams,
3030
SnapshotParams,
3131
WaitForNavigationParams,
32+
WheelParams,
3233
} from "@/transport/types";
3334
import { isRequestFrame } from "@/transport/types";
3435
import { handleConsole } from "./console";
@@ -75,6 +76,7 @@ import {
7576
type TabSelectParams,
7677
} from "./tabs";
7778
import { handleWaitForNavigation } from "./waits";
79+
import { handleWheel } from "./wheel";
7880
import { handleWindowResize, type WindowResizeParams } from "./window";
7981

8082
type DispatcherCdpRunner = CdpRunner &
@@ -471,6 +473,20 @@ export class ToolDispatcher {
471473
);
472474
return this.rememberHover((req.params as HoverParams).session_id, result);
473475
}
476+
case "tool.wheel":
477+
return this.withHoverReassert(
478+
req.params as WheelParams,
479+
() =>
480+
handleWheel(
481+
this.sessions,
482+
req.params as WheelParams,
483+
this.cdp
484+
? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, bypassOverlay }
485+
: undefined,
486+
),
487+
{ releaseAfter: true },
488+
signal,
489+
);
474490
case "tool.fill":
475491
return this.withHoverReleaseForRequest(
476492
req.params as FillParams,
@@ -718,6 +734,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
718734
case "tool.reload":
719735
case "tool.click":
720736
case "tool.hover":
737+
case "tool.wheel":
721738
case "tool.fill":
722739
case "tool.press":
723740
case "tool.select":

apps/extension/src/tools/interaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ async function wait(ms: number, signal?: AbortSignal): Promise<void> {
128128
* `RpcError` if the caller supplied neither (or both), or if neither
129129
* lookup matched.
130130
*/
131-
async function resolveBackendNode(
131+
export async function resolveBackendNode(
132132
cdp: CdpRunner,
133133
ctx: SessionContext,
134134
target: { tabId: number },

apps/extension/src/tools/wheel.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
2+
import type { SessionContext, SessionManager } from "@/session-manager/manager";
3+
import type { RpcError, WheelParams, WheelResult } from "@/transport/types";
4+
import { attachDialogs, markDialogCursor } from "./dialogs";
5+
import { resolveNodeGeometry } from "./frame-geometry";
6+
import { modifiersBitfield, resolveBackendNode } from "./interaction";
7+
import {
8+
type CdpRunner,
9+
type ChromeTabsApi,
10+
chromeTabsApi,
11+
enforceAgentWindow,
12+
isRpcError,
13+
lookupSession,
14+
resolveTargetTab,
15+
} from "./shared";
16+
17+
export interface WheelDeps {
18+
cdp: CdpRunner;
19+
tabsApi: ChromeTabsApi;
20+
signal?: AbortSignal;
21+
/** Temporarily disable the Agent Window overlay's input blocker. */
22+
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
23+
}
24+
25+
interface WheelPoint {
26+
x: number;
27+
y: number;
28+
usedRef?: string;
29+
usedSelector?: string;
30+
}
31+
32+
let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null;
33+
function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } {
34+
if (!defaultDeps) defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi };
35+
return defaultDeps;
36+
}
37+
38+
export async function handleWheel(
39+
manager: SessionManager,
40+
params: WheelParams,
41+
deps: WheelDeps = getDefaultDeps(),
42+
): Promise<WheelResult | RpcError> {
43+
const ctxOrErr = lookupSession(manager, params, "wheel");
44+
if (isRpcError(ctxOrErr)) return ctxOrErr;
45+
const ctx = ctxOrErr;
46+
const deltaX = params.delta_x ?? 0;
47+
const deltaY = params.delta_y;
48+
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) {
49+
return { code: "invalid_params", message: "wheel deltas must be finite numbers" };
50+
}
51+
if (deltaX === 0 && deltaY === 0) {
52+
return { code: "invalid_params", message: "at least one wheel delta must be non-zero" };
53+
}
54+
if (deps.signal?.aborted) return cancelled();
55+
56+
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
57+
if (isRpcError(target)) return target;
58+
const denied = enforceAgentWindow(ctx, target, "wheel");
59+
if (denied) return denied;
60+
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
61+
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
62+
63+
const point = await resolveWheelPoint(deps.cdp, ctx, target, params);
64+
if (isRpcError(point)) return point;
65+
if (deps.signal?.aborted) return cancelled();
66+
67+
let bypassEnabled = false;
68+
if (deps.bypassOverlay) {
69+
try {
70+
await deps.bypassOverlay(target.tabId, true);
71+
bypassEnabled = true;
72+
} catch (error) {
73+
console.debug("[bsk wheel] overlay bypass enable failed", error);
74+
}
75+
}
76+
77+
try {
78+
if (deps.signal?.aborted) return cancelled();
79+
const modifiers = modifiersBitfield(params.modifiers);
80+
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
81+
type: "mouseMoved",
82+
x: point.x,
83+
y: point.y,
84+
modifiers,
85+
});
86+
if (deps.signal?.aborted) return cancelled();
87+
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
88+
type: "mouseWheel",
89+
x: point.x,
90+
y: point.y,
91+
deltaX,
92+
deltaY,
93+
modifiers,
94+
});
95+
} catch (error) {
96+
return {
97+
code: "cdp_failed",
98+
message: error instanceof Error ? error.message : String(error),
99+
};
100+
} finally {
101+
if (bypassEnabled && deps.bypassOverlay) {
102+
try {
103+
await deps.bypassOverlay(target.tabId, false);
104+
} catch (error) {
105+
console.debug("[bsk wheel] overlay bypass disable failed", error);
106+
}
107+
}
108+
}
109+
110+
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
111+
tab_id: target.tabId,
112+
used_ref: point.usedRef,
113+
used_selector: point.usedSelector,
114+
x: point.x,
115+
y: point.y,
116+
delta_x: deltaX,
117+
delta_y: deltaY,
118+
});
119+
}
120+
121+
async function resolveWheelPoint(
122+
cdp: CdpRunner,
123+
ctx: SessionContext,
124+
target: { tabId: number },
125+
params: WheelParams,
126+
): Promise<WheelPoint | RpcError> {
127+
const hasRef = typeof params.ref === "string" && params.ref.length > 0;
128+
const hasSelector = typeof params.selector === "string" && params.selector.length > 0;
129+
if (hasRef || hasSelector) {
130+
const node = await resolveBackendNode(cdp, ctx, target, params, "wheel");
131+
if (isRpcError(node)) return node;
132+
const geometry = await resolveNodeGeometry(
133+
cdp,
134+
target.tabId,
135+
{
136+
target: node.cdpTarget,
137+
backendNodeId: node.backendNodeId,
138+
...(node.frameId ? { frameId: node.frameId } : {}),
139+
},
140+
{ scrollIntoView: true },
141+
);
142+
if (isRpcError(geometry)) return geometry;
143+
return {
144+
x: geometry.actionPoint.x,
145+
y: geometry.actionPoint.y,
146+
usedRef: node.usedRef,
147+
usedSelector: node.usedSelector,
148+
};
149+
}
150+
151+
try {
152+
const metrics = await cdp.send<{
153+
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
154+
layoutViewport?: { clientWidth?: number; clientHeight?: number };
155+
}>(target.tabId, "Page.getLayoutMetrics", {});
156+
const viewport = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
157+
const width = viewport.clientWidth;
158+
const height = viewport.clientHeight;
159+
if (
160+
typeof width !== "number" ||
161+
typeof height !== "number" ||
162+
!Number.isFinite(width) ||
163+
!Number.isFinite(height) ||
164+
width <= 0 ||
165+
height <= 0
166+
) {
167+
return { code: "cdp_failed", message: "Page.getLayoutMetrics returned no viewport size" };
168+
}
169+
return { x: width / 2, y: height / 2 };
170+
} catch (error) {
171+
return {
172+
code: "cdp_failed",
173+
message: error instanceof Error ? error.message : String(error),
174+
};
175+
}
176+
}
177+
178+
function cancelled(): RpcError {
179+
return { code: "cancelled", message: "wheel aborted" };
180+
}

0 commit comments

Comments
 (0)