Skip to content

Commit 009574a

Browse files
committed
fix(browser): preserve observation identity and control semantics
1 parent df9d2f6 commit 009574a

6 files changed

Lines changed: 301 additions & 65 deletions

File tree

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

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3363,29 +3363,49 @@ describe('credential protection', () => {
33633363
expect(contents.executeJavaScript).not.toHaveBeenCalled()
33643364
})
33653365

3366-
it('does not click a checkable control already in the requested state', async () => {
3366+
it.each([
3367+
{ kind: 'input:checkbox', checked: true, disabled: false, readOnly: false },
3368+
{ kind: 'input:radio', checked: false, disabled: false, readOnly: false },
3369+
{ kind: 'role:radio', checked: false, disabled: false, readOnly: false },
3370+
{ kind: 'role:menuitemradio', checked: false, disabled: false, readOnly: false },
3371+
{ kind: 'input:checkbox', checked: true, disabled: true, readOnly: false },
3372+
{ kind: 'input:checkbox', checked: true, disabled: false, readOnly: true },
3373+
])('does not click a control already in the requested state: %j', async (before) => {
33673374
const contents = await openPage()
33683375
respondWith(contents, {
3369-
readCheckableElementState: {
3370-
checked: true,
3371-
disabled: false,
3372-
readOnly: false,
3373-
kind: 'input:checkbox',
3374-
},
3376+
readCheckableElementState: before,
33753377
})
33763378

33773379
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
33783380
elementId: 0,
3379-
checked: true,
3381+
checked: before.checked,
33803382
})
33813383

33823384
expect(result).toMatchObject({
33833385
ok: true,
3384-
result: { checked: true, changed: false, dispatched: false },
3386+
result: { checked: before.checked, changed: false, dispatched: false },
33853387
})
33863388
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0)
33873389
})
33883390

3391+
it.each([
3392+
{ checked: true, kind: 'input:radio', error: 'cannot be unchecked' },
3393+
{ checked: true, kind: 'role:radio', error: 'cannot be unchecked' },
3394+
{ checked: true, kind: 'role:menuitemradio', error: 'cannot be unchecked' },
3395+
{ checked: false, kind: 'input:checkbox', disabled: true, error: 'disabled' },
3396+
{ checked: false, kind: 'input:checkbox', readOnly: true, error: 'read-only' },
3397+
])('rejects a prohibited state change: %j', async (before) => {
3398+
const contents = await openPage()
3399+
respondWith(contents, { readCheckableElementState: before })
3400+
const result = await driver.executeTool('chat-test', 'browser_set_checked', {
3401+
elementId: 0,
3402+
checked: !before.checked,
3403+
})
3404+
expect(result.ok).toBe(false)
3405+
expect(result.error).toContain(before.error)
3406+
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0)
3407+
})
3408+
33893409
it.each([false, 'mixed'])(
33903410
'uses the trusted click path from %s and verifies a changed checkable control',
33913411
async (initialState) => {
@@ -3527,6 +3547,8 @@ describe('credential protection', () => {
35273547

35283548
it.each([
35293549
['detached', { present: true, rendered: false }],
3550+
['collapsed', { present: true, rendered: true }],
3551+
['expanded', { present: true, rendered: true }],
35303552
['unchecked', { present: true, rendered: true, checked: 'mixed' }],
35313553
['unchecked', { present: true, rendered: true }],
35323554
])('does not satisfy %s from an incompatible element state', async (state, targetState) => {
@@ -3546,6 +3568,55 @@ describe('credential protection', () => {
35463568
}
35473569
})
35483570

3571+
it.each([
3572+
['expanded', { open: true }],
3573+
['collapsed', { open: false }],
3574+
['expanded', { ariaExpanded: 'true' }],
3575+
['collapsed', { ariaExpanded: 'false' }],
3576+
])('waits for %s on native and ARIA disclosure controls', async (state, semanticState) => {
3577+
const contents = await openPage()
3578+
respondWith(contents, {
3579+
readPageActionState: { targetState: { present: true, rendered: true, ...semanticState } },
3580+
})
3581+
await expect(
3582+
driver.executeTool('chat-test', 'browser_wait_for', {
3583+
elementId: 0,
3584+
state,
3585+
timeoutMs: 100,
3586+
})
3587+
).resolves.toMatchObject({ ok: true, result: { found: true } })
3588+
})
3589+
3590+
it.each(['x', 'y', 'width', 'height', 'detached'])(
3591+
'rejects an element screenshot when %s changes during capture',
3592+
async (change) => {
3593+
const contents = await openPage()
3594+
let captured = false
3595+
vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => {
3596+
if (!isPageCall(expression, 'getElementScreenshotRect')) return undefined
3597+
if (captured && change === 'detached') return { error: 'stale' }
3598+
return { x: 20, y: 30, width: 200, height: 100, ...(captured ? { [change]: 50 } : {}) }
3599+
})
3600+
const capture = vi.spyOn(cdp, 'captureScreenshot').mockImplementation(async () => {
3601+
captured = true
3602+
return {
3603+
dataUrl: 'data:image/jpeg;base64,c2lt',
3604+
scale: 1,
3605+
viewport: { width: 800, height: 600 },
3606+
imageSize: { width: 200, height: 100 },
3607+
}
3608+
})
3609+
try {
3610+
const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 })
3611+
expect(result.ok).toBe(false)
3612+
expect(result.error).toMatch(change === 'detached' ? /stale/ : /element moved/)
3613+
expect(capture).toHaveBeenCalledTimes(1)
3614+
} finally {
3615+
capture.mockRestore()
3616+
}
3617+
}
3618+
)
3619+
35493620
it('crops an element screenshot without changing the live viewport', async () => {
35503621
const contents = await openPage()
35513622
respondWith(contents, {

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,14 @@ function browserElementStateMatches(
929929
: targetState.ariaChecked === 'false' || targetState.ariaPressed === 'false'
930930
? false
931931
: undefined
932+
const expanded =
933+
typeof targetState.open === 'boolean'
934+
? targetState.open
935+
: targetState.ariaExpanded === 'true'
936+
? true
937+
: targetState.ariaExpanded === 'false'
938+
? false
939+
: undefined
932940
const selected =
933941
typeof targetState.selected === 'boolean'
934942
? targetState.selected
@@ -956,9 +964,9 @@ function browserElementStateMatches(
956964
case 'unchecked':
957965
return present && checked === false
958966
case 'expanded':
959-
return present && targetState.ariaExpanded === 'true'
967+
return present && expanded === true
960968
case 'collapsed':
961-
return present && targetState.ariaExpanded === 'false'
969+
return present && expanded === false
962970
case 'selected':
963971
return present && selected === true
964972
case 'unselected':
@@ -2560,6 +2568,25 @@ async function executeToolInner(
25602568
)
25612569
}
25622570
assertCaptureIsCurrent()
2571+
if (elementId !== undefined && elementClip) {
2572+
const currentClip = toRecord(
2573+
unwrapPageResult(
2574+
await execInPage(
2575+
contents,
2576+
getElementScreenshotRect,
2577+
[elementId],
2578+
false,
2579+
executionDeadline
2580+
)
2581+
)
2582+
)
2583+
assertCaptureIsCurrent()
2584+
if (['x', 'y', 'width', 'height'].some((key) => currentClip[key] !== elementClip[key])) {
2585+
throw new ToolError(
2586+
'The element moved while its screenshot was being captured. Retry browser_screenshot before using image coordinates.'
2587+
)
2588+
}
2589+
}
25632590
if (shot.dataUrl.length > 8_000_000) {
25642591
throw new ToolError(
25652592
'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.'
@@ -3646,6 +3673,15 @@ async function executeToolInner(
36463673
await execInPage(target, readCheckableElementState, [elementId], false, executionDeadline)
36473674
)
36483675
)
3676+
if (before.checked === checked) {
3677+
return {
3678+
checked,
3679+
changed: false,
3680+
dispatched: false,
3681+
element: before.kind,
3682+
refRecovered: before.refRecovered === true,
3683+
}
3684+
}
36493685
if (before.disabled === true) throw new ToolError('That control is disabled.')
36503686
if (before.readOnly === true) throw new ToolError('That control is read-only.')
36513687
if (
@@ -3656,15 +3692,6 @@ async function executeToolInner(
36563692
) {
36573693
throw new ToolError('Radio buttons cannot be unchecked directly. Select another option.')
36583694
}
3659-
if (before.checked === checked) {
3660-
return {
3661-
checked,
3662-
changed: false,
3663-
dispatched: false,
3664-
element: before.kind,
3665-
refRecovered: before.refRecovered === true,
3666-
}
3667-
}
36683695

36693696
const clickResult = toRecord(
36703697
await executeToolInner(

apps/desktop/src/main/browser-agent/page-functions.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,29 @@ describe('semantic control state', () => {
994994
expect(collectSnapshot(10, 0)).toEqual({ error: 'framed-snapshot' })
995995
})
996996

997+
it.each(['detached', 'hidden', 'renamed'])(
998+
'rejects a %s root before scoped capture without adopting a lookalike',
999+
(change) => {
1000+
document.body.innerHTML =
1001+
'<div id="card" tabindex="0" aria-label="Selected card"><button>Save card</button></div>'
1002+
for (const element of document.querySelectorAll('*')) visible(element)
1003+
const root = document.querySelector('#card')!
1004+
const rootRef = refFor(outlineOf(collectSnapshot()), 'Selected card')
1005+
const replacement = root.cloneNode(true) as HTMLElement
1006+
for (const element of [replacement, ...replacement.querySelectorAll('*')]) visible(element)
1007+
if (change === 'detached') root.replaceWith(replacement)
1008+
else {
1009+
root.after(replacement)
1010+
if (change === 'hidden') root.setAttribute('hidden', '')
1011+
else root.setAttribute('aria-label', 'Different card')
1012+
}
1013+
1014+
expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' })
1015+
expect(window.__simAgentElements?.[rootRef]).toBe(root)
1016+
expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' })
1017+
}
1018+
)
1019+
9971020
it('marks unreadable scoped frame content truncated', () => {
9981021
const root = visible(document.createElement('div'))
9991022
const frame = visible(document.createElement('iframe'))
@@ -1063,6 +1086,43 @@ describe('semantic control state', () => {
10631086
expect(readPageActionState(false, 1, 'registered')).toEqual({ error: 'stale' })
10641087
})
10651088

1089+
it.each([true, false])(
1090+
'reports native disclosure state as %s without inventing it on other elements',
1091+
(open) => {
1092+
document.body.innerHTML =
1093+
'<details><summary>Details</summary></details><dialog></dialog><button>Other</button>'
1094+
const details = document.querySelector('details')!
1095+
const dialog = document.querySelector('dialog')!
1096+
details.open = open
1097+
dialog.open = open
1098+
const elements = [
1099+
details,
1100+
document.querySelector('summary')!,
1101+
dialog,
1102+
document.querySelector('button')!,
1103+
]
1104+
register(...elements.map(visible))
1105+
for (let id = 0; id < elements.length; id++) {
1106+
expect(runSerialized(readPageActionState, [false, id, 'registered'])).toMatchObject({
1107+
targetState: { open: id === 3 ? undefined : open },
1108+
})
1109+
}
1110+
}
1111+
)
1112+
1113+
it.each(['checkbox', 'radio'])('reads native %s state with XHTML tag casing', (type) => {
1114+
const input = visible(document.createElement('input'))
1115+
input.type = type
1116+
input.checked = true
1117+
Object.defineProperty(input, 'tagName', { value: 'input' })
1118+
document.body.append(input)
1119+
register(input)
1120+
1121+
expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({
1122+
targetState: { checked: true },
1123+
})
1124+
})
1125+
10661126
it('preserves native and ARIA mixed states instead of reporting unchecked', () => {
10671127
document.body.innerHTML =
10681128
'<input type="checkbox" /><div role="checkbox" aria-checked="mixed"></div>'
@@ -1086,6 +1146,26 @@ describe('semantic control state', () => {
10861146
expect(readCheckableElementState(1)).toMatchObject({ disabled: true })
10871147
})
10881148

1149+
it.each([true, false])(
1150+
'reads ARIA-disabled=%s across shadow boundaries for waits and checked state',
1151+
(disabled) => {
1152+
const host = visible(document.createElement('div'))
1153+
host.setAttribute('aria-disabled', String(disabled))
1154+
const nestedHost = visible(document.createElement('div'))
1155+
host.attachShadow({ mode: 'open' }).append(nestedHost)
1156+
const checkbox = visible(document.createElement('input'))
1157+
checkbox.type = 'checkbox'
1158+
nestedHost.attachShadow({ mode: 'open' }).append(checkbox)
1159+
document.body.append(host)
1160+
register(checkbox)
1161+
1162+
expect(runSerialized(readCheckableElementState, [0])).toMatchObject({ disabled })
1163+
expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({
1164+
targetState: { disabled },
1165+
})
1166+
}
1167+
)
1168+
10891169
it('reads native and ARIA checkable controls without mutating them', () => {
10901170
document.body.innerHTML = `
10911171
<input type="checkbox" checked />
@@ -1135,6 +1215,19 @@ describe('semantic control state', () => {
11351215
expect(button.scrollIntoView).not.toHaveBeenCalled()
11361216
})
11371217

1218+
it('rejects a replacement screenshot target even when its geometry matches', () => {
1219+
const button = visible(document.createElement('button'))
1220+
button.id = 'save'
1221+
button.textContent = 'Save'
1222+
document.body.append(button)
1223+
const ref = refFor(outlineOf(collectSnapshot()), 'Save')
1224+
expect(getElementScreenshotRect(ref)).toMatchObject({ width: 100, height: 20 })
1225+
button.replaceWith(visible(button.cloneNode(true) as HTMLElement))
1226+
1227+
expect(runSerialized(getElementScreenshotRect, [ref])).toMatchObject({ error: 'stale' })
1228+
expect(window.__simAgentElements?.[ref]).toBe(button)
1229+
})
1230+
11381231
it('rejects same-origin frame crops rather than using frame-local coordinates', () => {
11391232
const frame = document.createElement('iframe')
11401233
document.body.append(frame)

0 commit comments

Comments
 (0)