Skip to content

Commit b98c1a8

Browse files
committed
fix(desktop-browser): tighten ref reuse, name growth, and covered typing per review
1 parent 385d4d6 commit b98c1a8

6 files changed

Lines changed: 103 additions & 17 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1365,7 +1365,7 @@ test.describe('browser tools', () => {
13651365
expect(await formState()).toMatchObject({ name: 'Observed value' })
13661366
const result = fill.result as { observation: { result: { matches: { elementId: number }[] } } }
13671367
const freshId = result.observation.result.matches[0].elementId
1368-
expect(freshId).not.toBe(ref('Name'))
1368+
expect(freshId).toBe(ref('Name'))
13691369
const clear = await execute('browser_type', { elementId: freshId, text: '', observe: {} })
13701370
expect(clear.ok, clear.error).toBe(true)
13711371
expect(clear.result).toMatchObject({ observation: { ok: true } })

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import { interruptibleSleep, sleep } from '@sim/utils/helpers'
3737
import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object'
3838
import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron'
3939
import { Menu } from 'electron'
40-
import { LRUCache } from 'lru-cache'
4140
import * as cdp from '@/main/browser-agent/cdp'
4241
import { steppedZoomFactor, zoomPercentOf } from '@/main/browser-agent/context-menu'
4342
import { ToolError } from '@/main/browser-agent/errors'
@@ -146,9 +145,6 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt
146145

147146
type PageExecutionTarget = WebContents | WebFrameMain
148147

149-
/** Matches the page's own memory of issued refs; an evicted ref simply gets a new id. */
150-
const ISSUED_REF_MEMORY = 5000
151-
152148
/** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */
153149
function samePageTarget(
154150
left: PageExecutionTarget | undefined,
@@ -343,7 +339,7 @@ interface DriverScopeState {
343339
/** Monotonic ref floor shared by every frame in this browser scope. */
344340
nextElementRefId: number
345341
/** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */
346-
issuedRefTargets: LRUCache<number, PageExecutionTarget>
342+
issuedRefTargets: Map<number, PageExecutionTarget>
347343
/** Invalidates captures that finish after a tab/navigation/timeout race. */
348344
snapshotCaptureEpoch: number
349345
/** Cancels a timed-out multi-step action before any later native dispatch. */
@@ -385,7 +381,7 @@ function createDriverScopeState(): DriverScopeState {
385381
snapshotTabId: null,
386382
snapshotTargets: new Map(),
387383
nextElementRefId: 0,
388-
issuedRefTargets: new LRUCache({ max: ISSUED_REF_MEMORY }),
384+
issuedRefTargets: new Map(),
389385
snapshotCaptureEpoch: 0,
390386
toolExecutionEpoch: 0,
391387
}
@@ -2608,6 +2604,11 @@ async function captureSnapshot(
26082604
(targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length
26092605
)
26102606
)
2607+
// Never forget a ref a live page may still hand back (the page itself
2608+
// decides when to reissue); only refs of closed tabs and frames go.
2609+
for (const [ref, target] of state.issuedRefTargets) {
2610+
if ('isDestroyed' in target && target.isDestroyed()) state.issuedRefTargets.delete(ref)
2611+
}
26112612
for (const [ref, target] of targets) state.issuedRefTargets.set(ref, target)
26122613
state.snapshotTabId = capturedTabId
26132614
return {

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,17 @@ describe('combobox typing surfaces', () => {
368368
expect(input.value).toBe('cooking pot')
369369
})
370370

371+
it('still refuses a top-page field a modal dialog covers', () => {
372+
document.body.innerHTML =
373+
'<input type="text" aria-label="Search" /><div role="dialog" aria-modal="true"><p>Sign in</p></div>'
374+
const input = visible(document.querySelector('input') as HTMLInputElement)
375+
const cover = visible(document.querySelector('p') as HTMLParagraphElement)
376+
register(input)
377+
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => cover })
378+
379+
expect(focusElementForTyping(0)).toMatchObject({ error: 'obstructed' })
380+
})
381+
371382
it('still refuses a covered field inside a frame', () => {
372383
const frame = document.createElement('iframe')
373384
document.body.append(frame)
@@ -705,7 +716,8 @@ describe('collectSnapshot', () => {
705716
})
706717

707718
it('keeps a connected ref whose name grew when its own submenu opened', () => {
708-
document.body.innerHTML = '<div role="button" tabindex="0">Products ▾<ul></ul></div>'
719+
document.body.innerHTML =
720+
'<div role="button" tabindex="0" aria-haspopup="menu">Products ▾<ul></ul></div>'
709721
const menu = visible(document.querySelector('[role="button"]') as HTMLDivElement)
710722
const ref = refFor(outlineOf(collectSnapshot()), 'Products ▾')
711723

@@ -732,6 +744,25 @@ describe('collectSnapshot', () => {
732744
expect(clickElement(ref)).toMatchObject({ dispatched: true })
733745
})
734746

747+
it('invalidates a recycled row whose new name merely extends the old one', () => {
748+
document.body.innerHTML = '<div role="listitem">Alice</div>'
749+
const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement)
750+
const ref = refFor(outlineOf(collectSnapshot()), 'Alice')
751+
752+
row.textContent = 'Alice Smith'
753+
754+
expect(clickElement(ref)).toMatchObject({ error: 'stale' })
755+
})
756+
757+
it('gives a row control a new ref when its row now shows a different record', () => {
758+
document.body.innerHTML = '<div role="row"><span>Order 1</span><button>Delete</button></div>'
759+
for (const el of Array.from(document.querySelectorAll('span, button'))) visible(el)
760+
const first = refFor(outlineOf(collectSnapshot()), 'Delete')
761+
;(document.querySelector('span') as HTMLSpanElement).textContent = 'Order 2'
762+
763+
expect(refFor(outlineOf(collectSnapshot()), 'Delete')).not.toBe(first)
764+
})
765+
735766
it('invalidates a connected ref whose name changed to a longer, different word', () => {
736767
document.body.innerHTML = '<div role="listitem">Item 1</div>'
737768
const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement)

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

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ interface SnapshotRefLocator {
3737
attributes: Record<string, string>
3838
ancestor: string
3939
context: string
40+
/** Hash of the element's DOM text, to tell revealed content from replaced content. */
41+
textHash: number
4042
}
4143

4244
declare global {
@@ -606,6 +608,14 @@ export function collectSnapshot(
606608
const refOfElement = window.__simAgentRefOfElement ?? new WeakMap()
607609
window.__simAgentRefOfElement = refOfElement
608610
const issuedRefCap = 5000
611+
const textHashOf = (el: Element): number => {
612+
const text = el.textContent || ''
613+
let hash = 0
614+
for (let index = 0; index < text.length; index++) {
615+
hash = (hash * 31 + text.charCodeAt(index)) | 0
616+
}
617+
return hash
618+
}
609619
const registerElement = (el: Element, role: string, name: string): number => {
610620
const locator = {
611621
url: pageUrlFor(el),
@@ -615,6 +625,7 @@ export function collectSnapshot(
615625
attributes: locatorAttributes(el),
616626
ancestor: ancestorSignature(el),
617627
context: contextSignature(el),
628+
textHash: textHashOf(el),
618629
}
619630
const priorId = refOfElement.get(el)
620631
const prior = priorId === undefined ? undefined : issuedRefs.get(priorId)
@@ -624,6 +635,8 @@ export function collectSnapshot(
624635
prior.locator.tag === locator.tag &&
625636
prior.locator.role === locator.role &&
626637
prior.locator.name === locator.name &&
638+
prior.locator.ancestor === locator.ancestor &&
639+
prior.locator.context === locator.context &&
627640
Object.keys({ ...prior.locator.attributes, ...locator.attributes }).every(
628641
(key) => prior.locator.attributes[key] === locator.attributes[key]
629642
)
@@ -889,7 +902,10 @@ export function collectSnapshot(
889902
* real stale ref, never permission to click something nearby.
890903
*/
891904
window.__simAgentResolveElement = (id: number, allowRecovery = true) => {
892-
if (scopedRoot && !scopedRoot.isConnected) {
905+
// A ref from an earlier snapshot resolves only to its own live node: no
906+
// lookalike recovery, and the latest snapshot's scope does not apply to it.
907+
const inLatestSnapshot = locators[id] !== undefined
908+
if (inLatestSnapshot && scopedRoot && !scopedRoot.isConnected) {
893909
window.__simAgentStaleReason = 'the scoped snapshot root left the DOM'
894910
return null
895911
}
@@ -937,12 +953,22 @@ export function collectSnapshot(
937953
) {
938954
return false
939955
}
940-
// A still-mounted control whose own menu or disclosure opened (often by
941-
// the hover or click being validated) appends its new content to its
942-
// name; a recycled row replaces the name instead.
956+
// A mounted control whose own menu opened (often by the hover or click
957+
// being validated) appends that menu to its name: either hidden content
958+
// was revealed (its DOM text is unchanged) or it declares a popup. A
959+
// recycled row changes its DOM text with neither.
943960
const name = nameFor(candidate)
961+
const opensPopup =
962+
textHashOf(candidate) === locator.textHash ||
963+
['aria-haspopup', 'aria-expanded', 'aria-controls'].some(
964+
(attribute) => (candidate.getAttribute(attribute) || 'false') !== 'false'
965+
) ||
966+
candidate.querySelector(
967+
'[role="menu"], [role="menubar"], [role="listbox"], [role="tooltip"], [role="dialog"], [role="tree"]'
968+
) !== null
944969
const nameGrewInPlace =
945970
connected &&
971+
opensPopup &&
946972
locator.name.length > 0 &&
947973
name.startsWith(locator.name) &&
948974
(locator.name.length >= 120 || /\s/.test(name.charAt(locator.name.length)))
@@ -1037,7 +1063,7 @@ export function collectSnapshot(
10371063
}
10381064
}
10391065

1040-
if (!allowRecovery) {
1066+
if (!allowRecovery || !inLatestSnapshot) {
10411067
window.__simAgentStaleReason = 'the original snapshot node is detached or hidden'
10421068
return null
10431069
}
@@ -1915,11 +1941,30 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
19151941
coveredByRelatedPopup = true
19161942
}
19171943
}
1918-
// Top-page typing goes through keyboard focus, so a non-modal overlay does not block it (modals
1919-
// fail the aria-hidden/inert checks above); framed fields keep the check for their mapped point.
1944+
// Top-page typing goes through keyboard focus, so a non-modal overlay does not block it; a modal
1945+
// or dialog cover still refuses, and framed fields keep the check for their mapped point.
19201946
const topPageField = view === window && window.top === window
1947+
const insideModal = (node: Element | null): boolean => {
1948+
for (let current = node; current; current = composedParent(current)) {
1949+
const role = current.getAttribute('role')
1950+
if (
1951+
current.getAttribute('aria-modal') === 'true' ||
1952+
role === 'dialog' ||
1953+
role === 'alertdialog' ||
1954+
tagFor(current) === 'DIALOG'
1955+
) {
1956+
return true
1957+
}
1958+
}
1959+
return false
1960+
}
19211961
let coveredBy: string | undefined
1922-
if (!chosenPoint && topPageField && blockedPoints.length > 0) {
1962+
if (
1963+
!chosenPoint &&
1964+
topPageField &&
1965+
blockedPoints.length > 0 &&
1966+
!blockedPoints.some((point) => insideModal(point.hit))
1967+
) {
19231968
chosenPoint = { x: blockedPoints[0].x, y: blockedPoints[0].y }
19241969
coveredBy = blockerLabel(firstBlocker)
19251970
}

‎apps/desktop/src/main/browser-agent/post-action-observation.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ describe('post-action observation', () => {
2222
})
2323
})
2424

25+
it('rejects an explicit null observe on a wait instead of treating it as omitted', async () => {
26+
const action = vi.fn(async () => ({ found: true }))
27+
await expect(
28+
withPostActionObservation('browser_wait_for', { observe: null }, action, vi.fn(), vi.fn())
29+
).rejects.toThrow('observe must be')
30+
expect(action).not.toHaveBeenCalled()
31+
})
32+
2533
it('observes after the action, preserving partial form results and replacing refs', async () => {
2634
const order: string[] = []
2735
const result = await withPostActionObservation(

‎apps/desktop/src/main/browser-agent/post-action-observation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ const OBSERVABLE_ACTIONS: ReadonlySet<BrowserToolName> = new Set([
2222

2323
/** The call's post-action observation request; a wait observes by default, to show what the page became. */
2424
export function observeRequest(tool: BrowserToolName, params: Record<string, unknown>): unknown {
25-
return params.observe ?? (tool === 'browser_wait_for' ? {} : undefined)
25+
if (params.observe !== undefined) return params.observe
26+
return tool === 'browser_wait_for' ? {} : undefined
2627
}
2728

2829
/** Preserves a dispatched action when its acknowledgement or observation is interrupted. */

0 commit comments

Comments
 (0)