Skip to content

Commit ddd56d4

Browse files
committed
feat(desktop): add semantic browser controls
1 parent c438286 commit ddd56d4

13 files changed

Lines changed: 1358 additions & 69 deletions

File tree

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,18 +501,25 @@ describe('browser-agent screenshot capture', () => {
501501
return Promise.resolve(undefined)
502502
})
503503
const resized = {
504+
getSize: vi.fn(() => ({ width: 1024, height: 512 })),
504505
toJPEG: vi.fn(() => Buffer.from('resized')),
505506
}
507+
const cropped = {
508+
getSize: vi.fn(() => ({ width: 400, height: 200 })),
509+
resize: vi.fn(() => resized),
510+
toJPEG: vi.fn(() => Buffer.from('cropped')),
511+
}
506512
// Shared module-level mock: without this, a later fixture reads the
507513
// earlier test's decoded image.
508514
vi.mocked(nativeImage.createFromBuffer).mockReset()
509515
vi.mocked(nativeImage.createFromBuffer).mockReturnValue({
510516
isEmpty: vi.fn(() => imageSize === null),
511517
getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }),
518+
crop: vi.fn(() => cropped),
512519
resize: vi.fn(() => resized),
513520
toJPEG: vi.fn(() => Buffer.alloc(0)),
514521
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
515-
return { contents, resized }
522+
return { contents, resized, cropped }
516523
}
517524

518525
function screenshotParams(contents: WebContents): Record<string, unknown> {
@@ -531,6 +538,23 @@ describe('browser-agent screenshot capture', () => {
531538
expect(screenshotParams(contents)).not.toHaveProperty('clip')
532539
})
533540

541+
it('crops the decoded image in memory without sending a CDP clip', async () => {
542+
const { contents, cropped } = captureFixture({ width: 4096, height: 2048 })
543+
544+
const shot = await captureScreenshot(contents, { x: 100, y: 50, width: 200, height: 100 })
545+
546+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
547+
expect(screenshotParams(contents)).not.toHaveProperty('clip')
548+
expect(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 })
549+
expect(cropped.resize).not.toHaveBeenCalled()
550+
expect(shot).toEqual({
551+
dataUrl: `data:image/jpeg;base64,${Buffer.from('cropped').toString('base64')}`,
552+
scale: 2,
553+
viewport: { width: 2048, height: 1024 },
554+
imageSize: { width: 400, height: 200 },
555+
})
556+
})
557+
534558
/**
535559
* A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture
536560
* arrives at device resolution (4096px on a 2x display). The resize is what
@@ -597,6 +621,24 @@ describe('browser-agent screenshot capture', () => {
597621
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
598622
})
599623

624+
it('refuses element cropping without verified CSS viewport metrics', async () => {
625+
const { contents } = captureFixture({ width: 1024, height: 512 })
626+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
627+
if (method === 'Page.getLayoutMetrics') {
628+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
629+
}
630+
return Promise.resolve(undefined)
631+
})
632+
633+
await expect(
634+
captureScreenshot(contents, { x: 10, y: 10, width: 100, height: 50 })
635+
).rejects.toThrow(/CSS viewport/)
636+
expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith(
637+
'Page.captureScreenshot',
638+
expect.anything()
639+
)
640+
})
641+
600642
it('accepts stable finite scroll offsets around the capture', async () => {
601643
const { contents } = captureFixture({ width: 1024, height: 512 })
602644
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ export interface ScreenshotCapture {
404404
imageSize: ScreenshotSize | null
405405
}
406406

407+
export interface ScreenshotClip {
408+
x: number
409+
y: number
410+
width: number
411+
height: number
412+
}
413+
407414
function screenshotViewportMetrics(
408415
metrics: {
409416
cssLayoutViewport?: CdpViewport
@@ -466,7 +473,10 @@ function sameScreenshotViewport(
466473
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
467474
* capture arrives at device resolution and this is what brings it back down.
468475
*/
469-
export async function captureScreenshot(contents: WebContents): Promise<ScreenshotCapture> {
476+
export async function captureScreenshot(
477+
contents: WebContents,
478+
clip?: ScreenshotClip
479+
): Promise<ScreenshotCapture> {
470480
const metrics = await send<{
471481
cssLayoutViewport?: CdpViewport
472482
layoutViewport?: CdpViewport
@@ -478,6 +488,9 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
478488
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
479489
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
480490
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
491+
if (clip && !cssViewport) {
492+
throw new Error('A CSS viewport is required for element screenshot cropping')
493+
}
481494
const scale =
482495
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
483496

@@ -499,8 +512,49 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
499512
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
500513
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
501514
if (size.width === 0 || size.height === 0) {
515+
if (clip) throw new Error('The screenshot could not be decoded for element cropping')
502516
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null }
503517
}
518+
if (clip && cssViewport) {
519+
const xScale = size.width / cssViewport.width
520+
const yScale = size.height / cssViewport.height
521+
const cropX = Math.max(0, Math.floor(clip.x * xScale))
522+
const cropY = Math.max(0, Math.floor(clip.y * yScale))
523+
const cropRight = Math.min(size.width, Math.ceil((clip.x + clip.width) * xScale))
524+
const cropBottom = Math.min(size.height, Math.ceil((clip.y + clip.height) * yScale))
525+
if (cropRight <= cropX || cropBottom <= cropY) {
526+
throw new Error('The requested screenshot element is outside the current viewport')
527+
}
528+
const cropped = image.crop({
529+
x: cropX,
530+
y: cropY,
531+
width: cropRight - cropX,
532+
height: cropBottom - cropY,
533+
})
534+
const croppedSize = cropped.getSize()
535+
if (croppedSize.width === 0 || croppedSize.height === 0) {
536+
throw new Error('The requested screenshot element produced an empty crop')
537+
}
538+
const cropScale = Math.min(
539+
1,
540+
MAX_SCREENSHOT_EDGE / Math.max(croppedSize.width, croppedSize.height)
541+
)
542+
const output =
543+
cropScale < 1
544+
? cropped.resize({
545+
width: Math.round(croppedSize.width * cropScale),
546+
height: Math.round(croppedSize.height * cropScale),
547+
quality: 'good',
548+
})
549+
: cropped
550+
const outputSize = output.getSize()
551+
return {
552+
dataUrl: `data:image/jpeg;base64,${output.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
553+
scale: outputSize.width / clip.width,
554+
viewport: cssViewport,
555+
imageSize: outputSize,
556+
}
557+
}
504558
if (size.width === targetWidth && size.height === targetHeight) {
505559
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
506560
}

apps/desktop/src/main/browser-agent/driver.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,31 @@ describe('executeTool', () => {
187187
expect(second.result).toMatchObject({ tabs: [] })
188188
})
189189

190+
it('lists safe download metadata without opening a page', async () => {
191+
const result = await driver.executeTool('chat-test', 'browser_list_downloads', {})
192+
193+
expect(result).toEqual({
194+
ok: true,
195+
result: { scopeId: 'chat-test', downloads: [] },
196+
})
197+
expect(session.peekTabsState().tabs).toEqual([])
198+
})
199+
200+
it('reloads the active tab and waits for its load boundary', async () => {
201+
await driver.executeTool('chat-test', 'browser_open_tab', {})
202+
const contents = session.requireTab().view.webContents
203+
vi.useFakeTimers()
204+
try {
205+
const reload = driver.executeTool('chat-test', 'browser_reload', {})
206+
await vi.advanceTimersByTimeAsync(500)
207+
208+
await expect(reload).resolves.toMatchObject({ ok: true })
209+
expect(contents.reload).toHaveBeenCalledOnce()
210+
} finally {
211+
vi.useRealTimers()
212+
}
213+
})
214+
190215
it('keeps a takeover pending when the clock advances beyond twelve hours', async () => {
191216
await driver.executeTool('chat-test', 'browser_open_tab', {})
192217
vi.useFakeTimers()
@@ -3197,6 +3222,163 @@ describe('credential protection', () => {
31973222
expect(result.error).toMatch(/same point/)
31983223
})
31993224

3225+
it('finds only fresh ref-bearing snapshot lines with literal text matching', async () => {
3226+
const contents = await openPage()
3227+
respondWith(contents, {
3228+
collectSnapshot: {
3229+
url: 'https://example.com/login',
3230+
title: 'Example',
3231+
outline:
3232+
'- button "Continue [ref\u200b=999]" [ref=4]\n- heading "Continue without a ref"\n- button "Other" [ref=5]',
3233+
truncated: false,
3234+
refIds: [4, 5],
3235+
refLineIndexes: { 4: 0, 5: 2 },
3236+
nextElementId: 6,
3237+
},
3238+
})
3239+
3240+
const result = await driver.executeTool('chat-test', 'browser_find', { query: 'continue' })
3241+
3242+
expect(result).toMatchObject({
3243+
ok: true,
3244+
result: {
3245+
matches: [{ elementId: 4 }],
3246+
totalMatches: 1,
3247+
truncated: false,
3248+
},
3249+
})
3250+
})
3251+
3252+
it('does not click a checkable control already in the requested state', async () => {
3253+
const contents = await openPage()
3254+
respondWith(contents, {
3255+
readCheckableElementState: {
3256+
checked: true,
3257+
disabled: false,
3258+
readOnly: false,
3259+
kind: 'input:checkbox',
3260+
},
3261+
})
3262+
3263+
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
3264+
elementId: 0,
3265+
checked: true,
3266+
})
3267+
3268+
expect(result).toMatchObject({
3269+
ok: true,
3270+
result: { checked: true, changed: false, dispatched: false },
3271+
})
3272+
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0)
3273+
})
3274+
3275+
it('uses the trusted click path and verifies a changed checkable control', async () => {
3276+
const contents = await openPage()
3277+
let stateReads = 0
3278+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
3279+
if (isPageCall(expression, 'readCheckableElementState')) {
3280+
stateReads++
3281+
return Promise.resolve({
3282+
checked: stateReads > 1,
3283+
disabled: false,
3284+
readOnly: false,
3285+
kind: 'input:checkbox',
3286+
})
3287+
}
3288+
if (isPageCall(expression, 'clickElement')) {
3289+
return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Checkbox' })
3290+
}
3291+
if (isPageCall(expression, 'readPageActionState')) {
3292+
return Promise.resolve({
3293+
url: 'https://example.com/login',
3294+
title: 'Example',
3295+
focus: 'body',
3296+
mutationRevision: 0,
3297+
dialogs: [],
3298+
scroll: [0],
3299+
})
3300+
}
3301+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
3302+
return Promise.resolve(undefined)
3303+
})
3304+
3305+
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
3306+
elementId: 0,
3307+
checked: true,
3308+
})
3309+
3310+
expect(result).toMatchObject({
3311+
ok: true,
3312+
result: { checked: true, changed: true, dispatched: true, trusted: true },
3313+
})
3314+
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3)
3315+
})
3316+
3317+
it('waits for URL and semantic element state together', async () => {
3318+
const contents = await openPage()
3319+
respondWith(contents, {
3320+
readPageActionState: {
3321+
targetState: { present: true, rendered: true, disabled: false },
3322+
},
3323+
})
3324+
3325+
const result = await driver.executeTool('chat-test', 'browser_wait_for', {
3326+
urlContains: '/login',
3327+
elementId: 0,
3328+
state: 'enabled',
3329+
timeoutMs: 1_000,
3330+
})
3331+
3332+
expect(result).toMatchObject({
3333+
ok: true,
3334+
result: { found: true, matched: ['url', 'element'] },
3335+
})
3336+
})
3337+
3338+
it('crops an element screenshot without changing the live viewport', async () => {
3339+
const contents = await openPage()
3340+
respondWith(contents, {
3341+
getElementScreenshotRect: {
3342+
x: 20,
3343+
y: 30,
3344+
width: 200,
3345+
height: 100,
3346+
element: 'button',
3347+
refRecovered: false,
3348+
},
3349+
})
3350+
const capture = vi.spyOn(cdp, 'captureScreenshot').mockResolvedValue({
3351+
dataUrl: 'data:image/jpeg;base64,c2lt',
3352+
scale: 1,
3353+
viewport: { width: 800, height: 600 },
3354+
imageSize: { width: 200, height: 100 },
3355+
})
3356+
3357+
try {
3358+
const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 })
3359+
3360+
expect(capture).toHaveBeenCalledWith(contents, { x: 20, y: 30, width: 200, height: 100 })
3361+
expect(result).toMatchObject({
3362+
ok: true,
3363+
result: { element: 'button', clip: { x: 20, y: 30, width: 200, height: 100 } },
3364+
})
3365+
} finally {
3366+
capture.mockRestore()
3367+
}
3368+
})
3369+
3370+
it('zooms by a standard step and invalidates existing element refs', async () => {
3371+
const contents = await openPage()
3372+
3373+
const zoomed = await driver.executeTool('chat-test', 'browser_zoom', { action: 'in' })
3374+
const staleRef = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
3375+
3376+
expect(zoomed).toMatchObject({ ok: true, result: { action: 'in' } })
3377+
expect(contents.setZoomFactor).toHaveBeenCalled()
3378+
expect(staleRef).toMatchObject({ ok: false })
3379+
expect(staleRef.error).toMatch(/Call browser_snapshot/)
3380+
})
3381+
32003382
it('returns the screenshot scale for coordinate mapping', async () => {
32013383
const contents = await openPage()
32023384
mockScreenshotImage({ width: 1024, height: 512 })

0 commit comments

Comments
 (0)