Skip to content

Commit cc4f3b9

Browse files
committed
fix(desktop): harden semantic browser control edge cases
1 parent ddd56d4 commit cc4f3b9

11 files changed

Lines changed: 500 additions & 213 deletions

File tree

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,8 @@ function sameScreenshotViewport(
468468
* snapshot capture refuses to scale a visible surface for the same reason.
469469
*
470470
* Bounding resolution therefore happens here instead, on the returned image.
471-
* The output keeps the dimensions the clipped capture produced, so `scale`
472-
* still maps image pixels back to CSS pixels for the coordinate tools
473-
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
474-
* capture arrives at device resolution and this is what brings it back down.
471+
* Optional element crops also happen in memory. Convert output coordinates
472+
* with cssX = (clip?.x ?? 0) + imageX / scale, and the equivalent Y formula.
475473
*/
476474
export async function captureScreenshot(
477475
contents: WebContents,

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

Lines changed: 232 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,28 @@ describe('executeTool', () => {
212212
}
213213
})
214214

215+
it('uses the shared failed-page recovery path when reloading', async () => {
216+
await driver.executeTool('chat-test', 'browser_open_tab', {})
217+
const tab = session.requireTab()
218+
tab.pageIssue = {
219+
kind: 'load-error',
220+
url: 'https://example.com/failed',
221+
code: -102,
222+
description: 'ERR_CONNECTION_REFUSED',
223+
}
224+
vi.useFakeTimers()
225+
try {
226+
const result = driver.executeTool('chat-test', 'browser_reload', {})
227+
await vi.advanceTimersByTimeAsync(500)
228+
229+
await expect(result).resolves.toMatchObject({ ok: true })
230+
expect(tab.view.webContents.loadURL).toHaveBeenCalledWith('https://example.com/failed')
231+
expect(tab.view.webContents.reload).not.toHaveBeenCalled()
232+
} finally {
233+
vi.useRealTimers()
234+
}
235+
})
236+
215237
it('keeps a takeover pending when the clock advances beyond twelve hours', async () => {
216238
await driver.executeTool('chat-test', 'browser_open_tab', {})
217239
vi.useFakeTimers()
@@ -1623,62 +1645,48 @@ describe('executeTool', () => {
16231645
)
16241646
})
16251647

1626-
it('does not let a snapshot that resolves after timeout overwrite newer refs', async () => {
1627-
vi.useFakeTimers()
1628-
try {
1629-
await driver.executeTool('chat-test', 'browser_open_tab', {})
1630-
const contents = session.requireTab().view.webContents
1631-
vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
1632-
let resolveLate: ((value: unknown) => void) | undefined
1633-
let snapshotCalls = 0
1634-
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1635-
if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined)
1636-
snapshotCalls++
1637-
if (snapshotCalls === 1) {
1648+
it.each(['browser_snapshot', 'browser_find'] as const)(
1649+
'does not let a timed-out %s restore refs',
1650+
async (tool) => {
1651+
vi.useFakeTimers()
1652+
try {
1653+
await driver.executeTool('chat-test', 'browser_open_tab', {})
1654+
const contents = session.requireTab().view.webContents
1655+
vi.mocked(contents.getURL).mockReturnValue('https://example.com/')
1656+
let resolveLate: ((value: unknown) => void) | undefined
1657+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1658+
if (!isPageCall(expression, 'collectSnapshot')) return Promise.resolve(undefined)
16381659
return new Promise((resolve) => {
16391660
resolveLate = resolve
16401661
})
1641-
}
1642-
return Promise.resolve({
1662+
})
1663+
1664+
const late = driver.executeTool('chat-test', tool, { query: 'Late' })
1665+
await vi.advanceTimersByTimeAsync(20_000)
1666+
await expect(late).resolves.toMatchObject({ ok: false })
1667+
resolveLate?.({
16431668
url: 'https://example.com/',
1644-
title: 'Fresh',
1645-
outline: '- button "Fresh" [ref=10]',
1669+
title: 'Late',
1670+
outline: '- button "Late" [ref=0]',
16461671
truncated: false,
1647-
refIds: [10],
1648-
refLineIndexes: { 10: 0 },
1649-
nextElementId: 11,
1672+
refIds: [0],
1673+
refLineIndexes: { 0: 0 },
1674+
nextElementId: 1,
16501675
})
1651-
})
1652-
1653-
const late = driver.executeTool('chat-test', 'browser_snapshot', {})
1654-
await vi.advanceTimersByTimeAsync(20_000)
1655-
await expect(late).resolves.toMatchObject({ ok: false })
1656-
await expect(driver.executeTool('chat-test', 'browser_snapshot', {})).resolves.toMatchObject({
1657-
ok: true,
1658-
})
1659-
1660-
resolveLate?.({
1661-
url: 'https://example.com/',
1662-
title: 'Late',
1663-
outline: '- button "Late" [ref=0]',
1664-
truncated: false,
1665-
refIds: [0],
1666-
refLineIndexes: { 0: 0 },
1667-
nextElementId: 1,
1668-
})
1669-
await Promise.resolve()
1670-
await Promise.resolve()
1671-
1672-
await expect(
1673-
driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1674-
).resolves.toMatchObject({
1675-
ok: false,
1676-
error: expect.stringContaining('not present in the current snapshot'),
1677-
})
1678-
} finally {
1679-
vi.useRealTimers()
1676+
await Promise.resolve()
1677+
await Promise.resolve()
1678+
1679+
await expect(
1680+
driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1681+
).resolves.toMatchObject({
1682+
ok: false,
1683+
error: expect.stringContaining('Call browser_snapshot'),
1684+
})
1685+
} finally {
1686+
vi.useRealTimers()
1687+
}
16801688
}
1681-
})
1689+
)
16821690

16831691
it('merges cross-origin structure and routes its refs through production frame isolation', async () => {
16841692
const win = new BrowserWindow()
@@ -1777,7 +1785,7 @@ describe('executeTool', () => {
17771785
if (isPageCall(expression, 'collectSnapshot')) {
17781786
return Promise.resolve({
17791787
url: 'https://ogs.google.com/u/0/widget/app',
1780-
title: 'Google apps',
1788+
title: 'Google apps [ref=0]',
17811789
outline: '- link "Drive" [ref=1]\n- textbox "Search apps" [ref=2]',
17821790
truncated: false,
17831791
refIds: [1, 2],
@@ -1863,7 +1871,7 @@ describe('executeTool', () => {
18631871
expect(snapshot).toMatchObject({
18641872
ok: true,
18651873
result: {
1866-
outline: expect.stringContaining('cross-origin iframe "Google apps"'),
1874+
outline: expect.stringContaining('cross-origin iframe "Google apps [ref\u200b=0]"'),
18671875
capturedCrossOriginFrames: 1,
18681876
unreadableCrossOriginFrames: 1,
18691877
hiddenCrossOriginFrames: 1,
@@ -3249,6 +3257,65 @@ describe('credential protection', () => {
32493257
})
32503258
})
32513259

3260+
it.each([
3261+
[1, false, 1],
3262+
[100, false, 50],
3263+
[100, true, 50],
3264+
])(
3265+
'bounds find results for maxResults=%s and snapshot truncation=%s',
3266+
async (maxResults, truncated, expectedCount) => {
3267+
const contents = await openPage()
3268+
const ids = Array.from({ length: 60 }, (_, index) => index + 1)
3269+
respondWith(contents, {
3270+
collectSnapshot: {
3271+
url: 'https://example.com/login',
3272+
title: 'Example',
3273+
outline: ids.map((id) => `- button "Continue ${id}" [ref=${id}]`).join('\n'),
3274+
truncated,
3275+
refIds: ids,
3276+
refLineIndexes: Object.fromEntries(ids.map((id, index) => [id, index])),
3277+
nextElementId: 61,
3278+
},
3279+
})
3280+
const response = await driver.executeTool('chat-test', 'browser_find', {
3281+
query: 'Continue',
3282+
maxResults,
3283+
})
3284+
expect(response).toMatchObject({ ok: true, result: { totalMatches: 60, truncated: true } })
3285+
expect((response.result as { matches: unknown[] }).matches).toHaveLength(expectedCount)
3286+
}
3287+
)
3288+
3289+
it('reports incomplete search coverage even when no ref matches', async () => {
3290+
const contents = await openPage()
3291+
respondWith(contents, {
3292+
collectSnapshot: {
3293+
url: 'https://example.com/login',
3294+
title: 'Example',
3295+
outline: '- button "Other" [ref=1]',
3296+
truncated: true,
3297+
refIds: [1],
3298+
refLineIndexes: { 1: 0 },
3299+
nextElementId: 2,
3300+
},
3301+
})
3302+
const response = await driver.executeTool('chat-test', 'browser_find', { query: 'Missing' })
3303+
expect(response).toMatchObject({
3304+
ok: true,
3305+
result: { matches: [], totalMatches: 0, truncated: true },
3306+
})
3307+
})
3308+
3309+
it('rejects oversized search text before taking a snapshot', async () => {
3310+
const contents = await openPage()
3311+
vi.mocked(contents.executeJavaScript).mockClear()
3312+
const response = await driver.executeTool('chat-test', 'browser_find', {
3313+
query: 'x'.repeat(4097),
3314+
})
3315+
expect(response).toMatchObject({ ok: false })
3316+
expect(contents.executeJavaScript).not.toHaveBeenCalled()
3317+
})
3318+
32523319
it('does not click a checkable control already in the requested state', async () => {
32533320
const contents = await openPage()
32543321
respondWith(contents, {
@@ -3272,47 +3339,50 @@ describe('credential protection', () => {
32723339
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0)
32733340
})
32743341

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-
})
3342+
it.each([false, 'mixed'])(
3343+
'uses the trusted click path from %s and verifies a changed checkable control',
3344+
async (initialState) => {
3345+
const contents = await openPage()
3346+
let stateReads = 0
3347+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
3348+
if (isPageCall(expression, 'readCheckableElementState')) {
3349+
stateReads++
3350+
return Promise.resolve({
3351+
checked: stateReads > 1 ? true : initialState,
3352+
disabled: false,
3353+
readOnly: false,
3354+
kind: 'input:checkbox',
3355+
})
3356+
}
3357+
if (isPageCall(expression, 'clickElement')) {
3358+
return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Checkbox' })
3359+
}
3360+
if (isPageCall(expression, 'readPageActionState')) {
3361+
return Promise.resolve({
3362+
url: 'https://example.com/login',
3363+
title: 'Example',
3364+
focus: 'body',
3365+
mutationRevision: 0,
3366+
dialogs: [],
3367+
scroll: [0],
3368+
})
3369+
}
3370+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
3371+
return Promise.resolve(undefined)
3372+
})
33043373

3305-
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
3306-
elementId: 0,
3307-
checked: true,
3308-
})
3374+
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
3375+
elementId: 0,
3376+
checked: true,
3377+
})
33093378

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-
})
3379+
expect(result).toMatchObject({
3380+
ok: true,
3381+
result: { checked: true, changed: true, dispatched: true, trusted: true },
3382+
})
3383+
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3)
3384+
}
3385+
)
33163386

33173387
it('waits for URL and semantic element state together', async () => {
33183388
const contents = await openPage()
@@ -3335,6 +3405,59 @@ describe('credential protection', () => {
33353405
})
33363406
})
33373407

3408+
it.each(['hidden', 'detached'])(
3409+
'does not treat a failed probe as element state %s',
3410+
async (state) => {
3411+
const contents = await openPage()
3412+
vi.mocked(contents.executeJavaScript).mockRejectedValue(
3413+
new Error('Execution context destroyed')
3414+
)
3415+
const response = await driver.executeTool('chat-test', 'browser_wait_for', {
3416+
elementId: 0,
3417+
state,
3418+
timeoutMs: 1000,
3419+
})
3420+
expect(response).toMatchObject({ ok: false })
3421+
expect(response.error).toContain('Execution context destroyed')
3422+
}
3423+
)
3424+
3425+
it('rejects navigation while inspecting an element wait condition', async () => {
3426+
const contents = await openPage()
3427+
vi.mocked(contents.executeJavaScript).mockImplementation(async () => {
3428+
vi.mocked(contents.getURL).mockReturnValue('https://example.com/next')
3429+
return { targetState: { present: false, rendered: false } }
3430+
})
3431+
const response = await driver.executeTool('chat-test', 'browser_wait_for', {
3432+
elementId: 0,
3433+
state: 'detached',
3434+
timeoutMs: 1000,
3435+
})
3436+
expect(response).toMatchObject({ ok: false })
3437+
expect(response.error).toContain('page changed')
3438+
})
3439+
3440+
it.each([
3441+
['detached', { present: true, rendered: false }],
3442+
['unchecked', { present: true, rendered: true, checked: 'mixed' }],
3443+
['unchecked', { present: true, rendered: true }],
3444+
])('does not satisfy %s from an incompatible element state', async (state, targetState) => {
3445+
const contents = await openPage()
3446+
respondWith(contents, { readPageActionState: { targetState } })
3447+
vi.useFakeTimers()
3448+
try {
3449+
const response = driver.executeTool('chat-test', 'browser_wait_for', {
3450+
elementId: 0,
3451+
state,
3452+
timeoutMs: 100,
3453+
})
3454+
await vi.advanceTimersByTimeAsync(1000)
3455+
await expect(response).resolves.toMatchObject({ ok: true, result: { found: false } })
3456+
} finally {
3457+
vi.useRealTimers()
3458+
}
3459+
})
3460+
33383461
it('crops an element screenshot without changing the live viewport', async () => {
33393462
const contents = await openPage()
33403463
respondWith(contents, {
@@ -3367,6 +3490,25 @@ describe('credential protection', () => {
33673490
}
33683491
})
33693492

3493+
it('rejects navigation during an element screenshot measurement', async () => {
3494+
const contents = await openPage()
3495+
vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => {
3496+
if (isPageCall(expression, 'getElementScreenshotRect')) {
3497+
vi.mocked(contents.getURL).mockReturnValue('https://example.com/next')
3498+
return { x: 20, y: 30, width: 200, height: 100 }
3499+
}
3500+
})
3501+
const capture = vi.spyOn(cdp, 'captureScreenshot')
3502+
try {
3503+
const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 })
3504+
expect(result).toMatchObject({ ok: false })
3505+
expect(result.error).toMatch(/page changed/)
3506+
expect(capture).not.toHaveBeenCalled()
3507+
} finally {
3508+
capture.mockRestore()
3509+
}
3510+
})
3511+
33703512
it('zooms by a standard step and invalidates existing element refs', async () => {
33713513
const contents = await openPage()
33723514

0 commit comments

Comments
 (0)