Skip to content

Commit faef2e6

Browse files
committed
fix(browser): detect truncated popup and dialog observations
1 parent 99c92fd commit faef2e6

3 files changed

Lines changed: 134 additions & 55 deletions

File tree

apps/desktop/e2e/browser-tools.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,47 @@ test.describe('browser tools', () => {
189189
expect(await formState()).toMatchObject({ name: '', route: 'change route' })
190190
})
191191

192+
test('stops when a new popup exceeds the page summary limit', async () => {
193+
const ref = await openForm()
194+
await app.evaluate(async ({ webContents }, origin) => {
195+
const page = webContents
196+
.getAllWebContents()
197+
.find((contents) => contents.getURL().startsWith(`${origin}/form`))
198+
if (!page) throw new Error('Missing browser fixture')
199+
await page.executeJavaScript(`
200+
for (let index = 0; index < 10; index++) {
201+
const toolbar = document.createElement('div')
202+
toolbar.setAttribute('role', 'toolbar')
203+
toolbar.textContent = 'Toolbar ' + index
204+
document.body.append(toolbar)
205+
}
206+
document.getElementById('name').addEventListener('input', () => {
207+
const popup = document.createElement('div')
208+
popup.setAttribute('role', 'listbox')
209+
popup.textContent = 'Suggestions'
210+
document.body.append(popup)
211+
}, { once: true })
212+
`)
213+
}, origin)
214+
215+
const fill = await execute('browser_fill_form', {
216+
fields: [
217+
{ elementId: ref('Name'), kind: 'text', text: 'Example User' },
218+
{ elementId: ref('Plan'), kind: 'select', value: 'pro' },
219+
],
220+
})
221+
expect(fill.ok, fill.error).toBe(true)
222+
expect(fill.result, JSON.stringify(fill.result)).toMatchObject({
223+
completed: false,
224+
completedCount: 1,
225+
stoppedIndex: 0,
226+
results: [{ verified: true, valuePreview: 'Example User' }],
227+
doNotRetry: true,
228+
error: expect.stringContaining('could not be fully verified'),
229+
})
230+
expect(await formState()).toMatchObject({ name: 'Example User', plan: 'basic' })
231+
})
232+
192233
test('refuses credential fields and leaves subsequent fields untouched', async () => {
193234
const ref = await openForm()
194235
const fill = await execute('browser_fill_form', {

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,43 @@ describe('collectSnapshot', () => {
915915
expect(after.popups).not.toEqual(before.popups)
916916
})
917917

918+
it.each([
919+
{ role: 'dialog', field: 'dialogs' },
920+
{ role: 'toolbar', field: 'popups' },
921+
] as const)(
922+
'reports truncation when visible $field exceed the summary limit',
923+
({ role, field }) => {
924+
for (let index = 0; index < 10; index++) {
925+
const element = visible(document.createElement('div'))
926+
element.setAttribute('role', role)
927+
element.setAttribute('aria-label', `Existing ${index}`)
928+
document.body.append(element)
929+
}
930+
const before = readPageActionState() as {
931+
dialogs: string[]
932+
popups: string[]
933+
observationTruncated: boolean
934+
}
935+
expect(before[field]).toHaveLength(10)
936+
expect(before.observationTruncated).toBe(false)
937+
938+
const additional = visible(document.createElement('div'))
939+
additional.setAttribute('role', role === 'toolbar' ? 'listbox' : role)
940+
additional.setAttribute('aria-label', 'New overlay')
941+
document.body.append(additional)
942+
expect(readPageActionState()).toMatchObject({
943+
[field]: before[field],
944+
observationTruncated: true,
945+
})
946+
947+
additional.setAttribute('aria-hidden', 'true')
948+
expect(readPageActionState()).toMatchObject({
949+
[field]: before[field],
950+
observationTruncated: false,
951+
})
952+
}
953+
)
954+
918955
it('reports a targeted control semantic disappearance after its panel closes', () => {
919956
document.body.innerHTML = `
920957
<aside aria-label="Thread panel"><button data-testid="close-thread">Close thread</button></aside>

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

Lines changed: 56 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2208,6 +2208,7 @@ export function readPageActionState(
22082208
const roots: ParentNode[] = observationRoot ? [observationRoot] : []
22092209
const allElements: Element[] = []
22102210
const stateNodeCap = 12_000
2211+
const overlayLimit = 10
22112212
for (let index = 0; index < roots.length; index++) {
22122213
for (const element of Array.from(roots[index].querySelectorAll('*'))) {
22132214
if (allElements.length >= stateNodeCap) break
@@ -2296,56 +2297,54 @@ export function readPageActionState(
22962297
const dialogs = allElements.filter((element) =>
22972298
element.matches('dialog[open], [role="dialog"], [aria-modal="true"]')
22982299
)
2299-
const visibleDialogLabels = dialogs
2300-
.filter((element) => {
2301-
const rect = element.getBoundingClientRect()
2302-
const view = element.ownerDocument.defaultView
2303-
if (!view || rect.width <= 0 || rect.height <= 0) return false
2304-
for (let current: Element | null = element; current; ) {
2305-
const style = view.getComputedStyle(current)
2306-
if (
2307-
style.display === 'none' ||
2308-
style.visibility === 'hidden' ||
2309-
Number.parseFloat(style.opacity || '1') <= 0.01 ||
2310-
current.hasAttribute('hidden') ||
2311-
current.getAttribute('aria-hidden') === 'true'
2312-
) {
2313-
return false
2314-
}
2315-
if (current.parentElement) current = current.parentElement
2316-
else {
2317-
const root = current.getRootNode()
2318-
current = 'host' in root ? (root.host as Element) : null
2319-
}
2300+
const visibleDialogs = dialogs.filter((element) => {
2301+
const rect = element.getBoundingClientRect()
2302+
const view = element.ownerDocument.defaultView
2303+
if (!view || rect.width <= 0 || rect.height <= 0) return false
2304+
for (let current: Element | null = element; current; ) {
2305+
const style = view.getComputedStyle(current)
2306+
if (
2307+
style.display === 'none' ||
2308+
style.visibility === 'hidden' ||
2309+
Number.parseFloat(style.opacity || '1') <= 0.01 ||
2310+
current.hasAttribute('hidden') ||
2311+
current.getAttribute('aria-hidden') === 'true'
2312+
) {
2313+
return false
23202314
}
2321-
return (
2322-
rect.right > 0 &&
2323-
rect.bottom > 0 &&
2324-
rect.left < view.innerWidth &&
2325-
rect.top < view.innerHeight
2326-
)
2327-
})
2328-
.slice(0, 10)
2329-
.map((element) =>
2330-
(
2331-
element.getAttribute('aria-label') ||
2332-
(element as HTMLElement).innerText ||
2333-
element.textContent ||
2334-
''
2335-
)
2336-
.replace(/\s+/g, ' ')
2337-
.trim()
2338-
.slice(0, 120)
2339-
.replace(/[\uD800-\uDBFF]$/, '')
2315+
if (current.parentElement) current = current.parentElement
2316+
else {
2317+
const root = current.getRootNode()
2318+
current = 'host' in root ? (root.host as Element) : null
2319+
}
2320+
}
2321+
return (
2322+
rect.right > 0 &&
2323+
rect.bottom > 0 &&
2324+
rect.left < view.innerWidth &&
2325+
rect.top < view.innerHeight
23402326
)
2327+
})
2328+
const visibleDialogLabels = visibleDialogs.slice(0, overlayLimit).map((element) =>
2329+
(
2330+
element.getAttribute('aria-label') ||
2331+
(element as HTMLElement).innerText ||
2332+
element.textContent ||
2333+
''
2334+
)
2335+
.replace(/\s+/g, ' ')
2336+
.trim()
2337+
.slice(0, 120)
2338+
.replace(/[\uD800-\uDBFF]$/, '')
2339+
)
23412340

23422341
// Roles an app uses for something that APPEARS over the page. The first three
23432342
// were the whole list, which missed the most common hover affordance there
23442343
// is: a row's action bar (Slack's message shortcuts is role="toolbar"/"group"
23452344
// with an aria-label). A hover that mounted one produced no popup change, no
23462345
// target change, and so no observed effect at all — the agent concluded its
23472346
// hover had failed and escalated to clicking pixels.
2348-
const visiblePopupLabels = allElements
2347+
const visiblePopups = allElements
23492348
.filter((element) =>
23502349
element.matches(
23512350
'[role="tooltip"], [role="menu"], [role="listbox"], [role="toolbar"], [role="menubar"], [role="group"][aria-label], [popover]'
@@ -2367,20 +2366,19 @@ export function readPageActionState(
23672366
rect.top < view.innerHeight
23682367
)
23692368
})
2370-
.slice(0, 10)
2371-
.map((element) =>
2372-
(
2373-
element.getAttribute('aria-label') ||
2374-
(element as HTMLElement).innerText ||
2375-
element.textContent ||
2376-
element.getAttribute('role') ||
2377-
''
2378-
)
2379-
.replace(/\s+/g, ' ')
2380-
.trim()
2381-
.slice(0, 120)
2382-
.replace(/[\uD800-\uDBFF]$/, '')
2369+
const visiblePopupLabels = visiblePopups.slice(0, overlayLimit).map((element) =>
2370+
(
2371+
element.getAttribute('aria-label') ||
2372+
(element as HTMLElement).innerText ||
2373+
element.textContent ||
2374+
element.getAttribute('role') ||
2375+
''
23832376
)
2377+
.replace(/\s+/g, ' ')
2378+
.trim()
2379+
.slice(0, 120)
2380+
.replace(/[\uD800-\uDBFF]$/, '')
2381+
)
23842382

23852383
const scrolledRegions = allElements
23862384
.filter((element) => (element as HTMLElement).scrollTop !== 0)
@@ -2405,7 +2403,10 @@ export function readPageActionState(
24052403
popups: visiblePopupLabels,
24062404
scroll: [Math.round(observedWindow.scrollY), ...scrolledRegions],
24072405
...(targetState ? { targetState } : {}),
2408-
observationTruncated: allElements.length >= stateNodeCap,
2406+
observationTruncated:
2407+
allElements.length >= stateNodeCap ||
2408+
visibleDialogs.length > overlayLimit ||
2409+
visiblePopups.length > overlayLimit,
24092410
}
24102411
}
24112412

0 commit comments

Comments
 (0)