Skip to content

Commit 85ffaa7

Browse files
committed
fix(desktop): keep browser-agent input alive through live-SPA re-renders
1 parent 46aae11 commit 85ffaa7

4 files changed

Lines changed: 323 additions & 38 deletions

File tree

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1732,4 +1732,146 @@ describe('credential protection', () => {
17321732
'Element ids are not valid in this tab. Call browser_snapshot and use an id from that result.',
17331733
})
17341734
})
1735+
1736+
/** Fires every instrumentation listener registered for a WebContents event. */
1737+
function emitContentsEvent(
1738+
contents: Awaited<ReturnType<typeof openPage>>,
1739+
event: string,
1740+
...args: unknown[]
1741+
): void {
1742+
for (const [name, listener] of vi.mocked(contents.on).mock.calls) {
1743+
if (name === event) (listener as (...listenerArgs: unknown[]) => void)({}, ...args)
1744+
}
1745+
}
1746+
1747+
it('keeps element ids across a same-document (SPA) navigation', async () => {
1748+
const contents = await openPage()
1749+
respondWith(contents, {})
1750+
1751+
emitContentsEvent(contents, 'did-navigate-in-page')
1752+
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1753+
1754+
expect(result).toMatchObject({ ok: true, result: { dispatched: true } })
1755+
})
1756+
1757+
it('still invalidates element ids on a cross-document navigation', async () => {
1758+
const contents = await openPage()
1759+
respondWith(contents, {})
1760+
1761+
emitContentsEvent(contents, 'did-navigate')
1762+
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1763+
1764+
expect(result).toEqual({
1765+
ok: false,
1766+
error:
1767+
'Element ids are not valid in this tab. Call browser_snapshot and use an id from that result.',
1768+
})
1769+
})
1770+
1771+
it('tolerates same-document URL churn during a keypress', async () => {
1772+
const contents = await openPage()
1773+
let urlReads = 0
1774+
vi.mocked(contents.getURL).mockImplementation(() =>
1775+
++urlReads === 1 ? 'https://example.com/channel-a' : 'https://example.com/channel-b'
1776+
)
1777+
respondWith(contents, {
1778+
activeElementSecrecy: 'safe',
1779+
readActiveElementState: {},
1780+
readPageActionState: {
1781+
url: 'https://example.com/channel-a',
1782+
title: 'Example',
1783+
focus: 'body',
1784+
mutationRevision: 0,
1785+
dialogs: [],
1786+
scroll: [0],
1787+
},
1788+
})
1789+
1790+
const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Escape' })
1791+
1792+
expect(result.ok).toBe(true)
1793+
expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0)
1794+
})
1795+
1796+
it('aborts a keypress when a cross-document navigation lands mid-flight', async () => {
1797+
const contents = await openPage()
1798+
let navigated = false
1799+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1800+
if (isPageCall(expression, 'activeElementSecrecy')) return Promise.resolve('safe')
1801+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
1802+
if (isPageCall(expression, 'readPageActionState')) {
1803+
if (!navigated) {
1804+
navigated = true
1805+
emitContentsEvent(contents, 'did-navigate')
1806+
}
1807+
return Promise.resolve({
1808+
url: 'https://example.com/login',
1809+
title: 'Example',
1810+
focus: 'body',
1811+
mutationRevision: 0,
1812+
dialogs: [],
1813+
scroll: [0],
1814+
})
1815+
}
1816+
return Promise.resolve(undefined)
1817+
})
1818+
1819+
const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Escape' })
1820+
1821+
expect(result.ok).toBe(false)
1822+
expect(result.error).toMatch(/active tab or page changed/)
1823+
expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0)
1824+
})
1825+
1826+
it('waits for a late-mounting editor before typing', async () => {
1827+
const contents = await openPage()
1828+
let focusReads = 0
1829+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1830+
if (isPageCall(expression, 'focusElementForTyping')) {
1831+
focusReads++
1832+
return Promise.resolve(
1833+
focusReads === 1
1834+
? { error: 'not-editable' }
1835+
: { focused: true, kind: 'contenteditable', x: 24, y: 48 }
1836+
)
1837+
}
1838+
if (isPageCall(expression, 'activeElementSecrecy')) return Promise.resolve('safe')
1839+
if (isPageCall(expression, 'readActiveElementState')) {
1840+
return Promise.resolve({ activeElement: 'div', valueLength: 5 })
1841+
}
1842+
return Promise.resolve(undefined)
1843+
})
1844+
1845+
const result = await driver.executeTool('chat-test', 'browser_type', {
1846+
elementId: 0,
1847+
text: 'hello',
1848+
})
1849+
1850+
expect(result.ok).toBe(true)
1851+
expect(focusReads).toBeGreaterThan(1)
1852+
expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1)
1853+
})
1854+
1855+
it('reprobes a transiently stale click target before giving up', async () => {
1856+
const contents = await openPage()
1857+
let clickReads = 0
1858+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1859+
if (isPageCall(expression, 'clickElement')) {
1860+
clickReads++
1861+
return Promise.resolve(
1862+
clickReads === 1
1863+
? { error: 'stale' }
1864+
: { dispatched: false, x: 24, y: 48, element: 'Channel row' }
1865+
)
1866+
}
1867+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
1868+
if (isPageCall(expression, 'readPageActionState')) return Promise.resolve({})
1869+
return Promise.resolve(undefined)
1870+
})
1871+
1872+
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1873+
1874+
expect(result).toMatchObject({ ok: true, result: { dispatched: true } })
1875+
expect(clickReads).toBeGreaterThan(1)
1876+
})
17351877
})

0 commit comments

Comments
 (0)