Skip to content

Commit ab2fe4a

Browse files
authored
v0.9.1: browser agent improvements
2 parents 349763c + e6badaf commit ab2fe4a

11 files changed

Lines changed: 965 additions & 61 deletions

File tree

‎apps/desktop/src/main/browser-agent/cdp.test.ts‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import {
1515
captureScreenshot,
1616
clickAt,
1717
consumeAgentContextMenu,
18+
dragPointer,
1819
ensureInstrumented,
1920
evaluateInIsolatedFrame,
2021
insertText,
22+
movePointer,
2123
PRIMARY_CLICK,
24+
pointerPathSteps,
2225
releaseFileInput,
2326
resolveFileInput,
2427
setColorScheme,
@@ -258,6 +261,122 @@ describe('browser-agent CDP instrumentation', () => {
258261
])
259262
})
260263

264+
it('stops a pointer route as soon as it is aborted', async () => {
265+
const contents = new WebContentsView().webContents
266+
const moves = () =>
267+
vi
268+
.mocked(contents.debugger.sendCommand)
269+
.mock.calls.filter(([method]) => String(method).startsWith('Input.dispatchMouseEvent'))
270+
const aborted = new AbortController()
271+
aborted.abort()
272+
await expect(
273+
movePointer(contents, { via: [], durationMs: null }, { x: 5, y: 5 }, aborted.signal)
274+
).rejects.toMatchObject({ name: 'AbortError' })
275+
expect(moves()).toHaveLength(0)
276+
277+
vi.useFakeTimers()
278+
try {
279+
const controller = new AbortController()
280+
const route = movePointer(
281+
contents,
282+
{ via: [{ x: 0, y: 0 }], durationMs: 5_000 },
283+
{ x: 500, y: 0 },
284+
controller.signal
285+
)
286+
const settled = expect(route).rejects.toMatchObject({ name: 'AbortError' })
287+
await vi.advanceTimersByTimeAsync(100)
288+
const sentBeforeAbort = moves().length
289+
controller.abort()
290+
await settled
291+
expect(moves()).toHaveLength(sentBeforeAbort)
292+
} finally {
293+
vi.useRealTimers()
294+
}
295+
})
296+
297+
it('sends nothing for an already-aborted drag and cancels one aborted while it settles', async () => {
298+
const contents = new WebContentsView().webContents
299+
const mouse = () =>
300+
vi
301+
.mocked(contents.debugger.sendCommand)
302+
.mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent')
303+
.map(([, params]) => toRecord(params).type)
304+
const aborted = new AbortController()
305+
aborted.abort()
306+
await expect(
307+
dragPointer(contents, { x: 0, y: 0 }, { x: 50, y: 0 }, undefined, aborted.signal)
308+
).rejects.toMatchObject({ name: 'AbortError' })
309+
expect(contents.debugger.sendCommand).not.toHaveBeenCalled()
310+
311+
vi.useFakeTimers()
312+
try {
313+
const controller = new AbortController()
314+
const drag = dragPointer(
315+
contents,
316+
{ x: 0, y: 0 },
317+
{ x: 50, y: 0 },
318+
undefined,
319+
controller.signal
320+
)
321+
const settled = expect(drag).rejects.toMatchObject({ name: 'AbortError' })
322+
// The default route takes 13 moves 20 ms apart, then a 120 ms settle hold.
323+
await vi.advanceTimersByTimeAsync(300)
324+
controller.abort()
325+
await settled
326+
expect(mouse().at(-1)).toBe('mouseReleased')
327+
} finally {
328+
vi.useRealTimers()
329+
}
330+
})
331+
332+
it('releases the button when a timed drag is aborted mid-route', async () => {
333+
const contents = new WebContentsView().webContents
334+
const types = () =>
335+
vi
336+
.mocked(contents.debugger.sendCommand)
337+
.mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent')
338+
.map(([, params]) => toRecord(params).type)
339+
vi.useFakeTimers()
340+
try {
341+
const controller = new AbortController()
342+
const drag = dragPointer(
343+
contents,
344+
{ x: 0, y: 0 },
345+
{ x: 500, y: 0 },
346+
{ via: [], durationMs: 5_000 },
347+
controller.signal
348+
)
349+
const settled = expect(drag).rejects.toMatchObject({ name: 'AbortError' })
350+
await vi.advanceTimersByTimeAsync(100)
351+
controller.abort()
352+
await settled
353+
expect(types().at(-1)).toBe('mouseReleased')
354+
expect(types().filter((type) => type === 'mouseMoved').length).toBeLessThan(20)
355+
} finally {
356+
vi.useRealTimers()
357+
}
358+
})
359+
360+
it('keeps the default drag pace and lands exactly on every via point', () => {
361+
const direct = pointerPathSteps({ x: 0, y: 0 }, { via: [], durationMs: null }, { x: 120, y: 0 })
362+
expect(direct.stepDelayMs).toBe(20)
363+
expect(direct.points).toHaveLength(12)
364+
expect(direct.points[0]).toEqual({ x: 10, y: 0 })
365+
expect(direct.points[11]).toEqual({ x: 120, y: 0 })
366+
367+
const routed = pointerPathSteps(
368+
{ x: 0, y: 0 },
369+
{ via: [{ x: 100, y: 0 }], durationMs: 800 },
370+
{ x: 100, y: 300 }
371+
)
372+
expect(routed.points).toContainEqual({ x: 100, y: 0 })
373+
expect(routed.points[routed.points.length - 1]).toEqual({ x: 100, y: 300 })
374+
expect(routed.points.length * routed.stepDelayMs).toBeCloseTo(800)
375+
// The longer second segment gets about three times the steps of the first.
376+
const corner = routed.points.findIndex((point) => point.x === 100 && point.y === 0)
377+
expect(routed.points.length - 1 - corner).toBeGreaterThan(corner * 2)
378+
})
379+
261380
it('holds the button down for holdMs before releasing it', async () => {
262381
const contents = new WebContentsView().webContents
263382
const types = () =>

‎apps/desktop/src/main/browser-agent/cdp.ts‎

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import type { BrowserTheme } from '@sim/browser-protocol'
1212
import { createLogger } from '@sim/logger'
1313
import { getErrorMessage } from '@sim/utils/errors'
14-
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
14+
import { interruptibleSleep } from '@sim/utils/helpers'
1515
import { isRecordLike } from '@sim/utils/object'
1616
import type { NativeImage, WebContents, WebFrameMain } from 'electron'
1717

@@ -948,6 +948,91 @@ export async function moveMouse(contents: WebContents, x: number, y: number): Pr
948948
})
949949
}
950950

951+
/** A point in CSS viewport pixels. */
952+
export interface ViewportPoint {
953+
x: number
954+
y: number
955+
}
956+
957+
/** The points a pointer passes through on its way, and how long the whole movement takes. */
958+
export interface PointerPath {
959+
via: ViewportPoint[]
960+
/** Total movement time; null keeps the default brisk pace. */
961+
durationMs: number | null
962+
}
963+
964+
export const DIRECT_PATH: PointerPath = { via: [], durationMs: null }
965+
966+
const DEFAULT_PATH_STEPS = 12
967+
const DEFAULT_PATH_STEP_MS = 20
968+
/** One display frame, so a timed movement looks continuous to animation-driven pages. */
969+
const TIMED_PATH_STEP_MS = 16
970+
971+
/**
972+
* The moves from `from` through `path.via` to `to`, and the pause after each. A direct path
973+
* keeps the default 12 moves 20 ms apart. A path with via points or a duration moves one frame
974+
* at a time, shares the steps across segments by length, and lands exactly on every via point.
975+
*/
976+
export function pointerPathSteps(
977+
from: ViewportPoint,
978+
path: PointerPath,
979+
to: ViewportPoint
980+
): { points: ViewportPoint[]; stepDelayMs: number } {
981+
const lerp = (a: ViewportPoint, b: ViewportPoint, t: number): ViewportPoint => ({
982+
x: a.x + (b.x - a.x) * t,
983+
y: a.y + (b.y - a.y) * t,
984+
})
985+
if (path.via.length === 0 && path.durationMs === null) {
986+
const points: ViewportPoint[] = []
987+
for (let step = 1; step <= DEFAULT_PATH_STEPS; step++) {
988+
points.push(lerp(from, to, step / DEFAULT_PATH_STEPS))
989+
}
990+
return { points, stepDelayMs: DEFAULT_PATH_STEP_MS }
991+
}
992+
const vertices = [from, ...path.via, to]
993+
const durationMs = path.durationMs ?? DEFAULT_PATH_STEPS * DEFAULT_PATH_STEP_MS
994+
const lengths = vertices
995+
.slice(1)
996+
.map((vertex, index) => Math.hypot(vertex.x - vertices[index].x, vertex.y - vertices[index].y))
997+
const totalLength = lengths.reduce((sum, length) => sum + length, 0)
998+
const totalSteps = Math.max(lengths.length, Math.round(durationMs / TIMED_PATH_STEP_MS))
999+
const points: ViewportPoint[] = []
1000+
lengths.forEach((length, index) => {
1001+
const share = totalLength > 0 ? length / totalLength : 1 / lengths.length
1002+
const segmentSteps = Math.max(1, Math.round(totalSteps * share))
1003+
for (let step = 1; step <= segmentSteps; step++) {
1004+
points.push(lerp(vertices[index], vertices[index + 1], step / segmentSteps))
1005+
}
1006+
})
1007+
return { points, stepDelayMs: durationMs / points.length }
1008+
}
1009+
1010+
/**
1011+
* Moves the pointer with no button pressed through `path.via` to `to`, starting from the first
1012+
* via point (or `to` itself for a direct move), for hover effects that follow the cursor.
1013+
*/
1014+
export async function movePointer(
1015+
contents: WebContents,
1016+
path: PointerPath,
1017+
to: ViewportPoint,
1018+
signal?: AbortSignal
1019+
): Promise<void> {
1020+
const [start, ...rest] = [...path.via, to]
1021+
signal?.throwIfAborted()
1022+
await moveMouse(contents, start.x, start.y)
1023+
if (rest.length === 0) return
1024+
const { points, stepDelayMs } = pointerPathSteps(
1025+
start,
1026+
{ via: rest.slice(0, -1), durationMs: path.durationMs },
1027+
to
1028+
)
1029+
for (const point of points) {
1030+
await interruptibleSleep(stepDelayMs, signal)
1031+
signal?.throwIfAborted()
1032+
await moveMouse(contents, point.x, point.y)
1033+
}
1034+
}
1035+
9511036
/** One trusted click gesture: which button, how many presses, and held modifiers. */
9521037
export interface PointerClick {
9531038
button: 'left' | 'right' | 'middle'
@@ -1075,11 +1160,13 @@ export async function clickAt(
10751160
*/
10761161
export async function dragPointer(
10771162
contents: WebContents,
1078-
from: { x: number; y: number },
1079-
to: { x: number; y: number },
1080-
steps = 12,
1081-
stepDelayMs = 20
1163+
from: ViewportPoint,
1164+
to: ViewportPoint,
1165+
path: PointerPath = DIRECT_PATH,
1166+
signal?: AbortSignal
10821167
): Promise<{ nativeDragIntercepted: boolean }> {
1168+
signal?.throwIfAborted()
1169+
const { points, stepDelayMs } = pointerPathSteps(from, path, to)
10831170
const interception: DragInterception = { intercepted: false, data: null }
10841171
dragInterceptionsByContents.set(contents, interception)
10851172
let interceptEnabled = false
@@ -1124,17 +1211,19 @@ export async function dragPointer(
11241211
})
11251212
// Small first nudge so libraries with a start threshold (commonly 3-8px)
11261213
// register the drag before the pointer sweeps across the page.
1127-
await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2)
1128-
await sleep(stepDelayMs)
1129-
const stepCount = Math.max(2, steps)
1130-
for (let step = 1; step <= stepCount; step++) {
1131-
const progress = step / stepCount
1132-
await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress)
1133-
await sleep(stepDelayMs)
1214+
const heading = points[0] ?? to
1215+
await dragMove(from.x + Math.sign(heading.x - from.x || 1) * 4, from.y + 2)
1216+
await interruptibleSleep(stepDelayMs, signal)
1217+
for (const point of points) {
1218+
signal?.throwIfAborted()
1219+
await dragMove(point.x, point.y)
1220+
await interruptibleSleep(stepDelayMs, signal)
11341221
}
1222+
signal?.throwIfAborted()
11351223
// Hold over the target so drop zones running enter/over animations settle
11361224
// before the release lands.
1137-
await sleep(120)
1225+
await interruptibleSleep(120, signal)
1226+
signal?.throwIfAborted()
11381227
if (interception.intercepted && interception.data) {
11391228
await sendInput(contents, 'Input.dispatchDragEvent', {
11401229
type: 'drop',

0 commit comments

Comments
 (0)