Skip to content

Commit 1396496

Browse files
committed
fix(browser): preserve click targets and bound screenshot capture
1 parent 1e7c3dc commit 1396496

9 files changed

Lines changed: 484 additions & 119 deletions

File tree

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

Lines changed: 175 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body
2727
</div>
2828
</body></html>`
2929

30+
const CLICK_FIXTURE = `<!doctype html><title>Click fixture</title>
31+
<style>body{margin:0;height:2400px}button{position:absolute;left:100px;top:calc(100vh - 100px);width:200px;height:40px}</style>
32+
<button id="target" role="option" onclick="document.body.dataset.clicks = Number(document.body.dataset.clicks) + 1">Choose option</button>
33+
<script>
34+
document.body.dataset.clicks = '0'; document.body.dataset.scrolls = '0';
35+
const mode = new URLSearchParams(location.search).get('mode');
36+
if (mode === 'sticky') {
37+
document.getElementById('target').style.top = '1010px';
38+
document.body.insertAdjacentHTML('beforeend', '<div style="position:fixed;inset:0 0 auto;height:80px;background:white;z-index:2">Sticky header</div>');
39+
scrollTo(0,1000);
40+
}
41+
addEventListener('scroll', () => {
42+
document.body.dataset.scrolls = Number(document.body.dataset.scrolls) + 1;
43+
if (mode === 'menu' && document.body.dataset.armed === 'true') document.getElementById('target')?.remove();
44+
});
45+
</script>`
46+
3047
test.describe('browser tools', () => {
3148
const calls = new Map<
3249
string,
@@ -56,9 +73,11 @@ test.describe('browser tools', () => {
5673
}
5774
response.writeHead(200, { 'Content-Type': 'text/html' })
5875
response.end(
59-
path === '/form'
60-
? FORM
61-
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
76+
path === '/click'
77+
? CLICK_FIXTURE
78+
: path === '/form'
79+
? FORM
80+
: '<!doctype html><title>Sim fixture</title><h1>Browser tools fixture</h1>'
6281
)
6382
})
6483
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
@@ -78,15 +97,21 @@ test.describe('browser tools', () => {
7897
},
7998
})
8099
window = await app.firstWindow()
100+
await app.evaluate(({ BrowserWindow }) =>
101+
BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false)
102+
)
81103
await expect(window.getByRole('heading')).toHaveText('Browser tools fixture')
82104
await window.evaluate(async (scope) => {
83105
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
84106
await api.browserAgent.activateScope(scope)
85-
api.browserAgent.setPanelBounds(
86-
{ x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
87-
null,
88-
scope
89-
)
107+
const updateBounds = () =>
108+
api.browserAgent.setPanelBounds(
109+
{ x: 0, y: 80, width: innerWidth, height: innerHeight - 80 },
110+
null,
111+
scope
112+
)
113+
updateBounds()
114+
setInterval(updateBounds, 200)
90115
}, SCOPE)
91116
})
92117

@@ -143,6 +168,148 @@ test.describe('browser tools', () => {
143168
}, origin)
144169
}
145170

171+
for (const mode of ['menu', 'sticky']) {
172+
test(`clicks a ${mode} target without losing its identity`, async () => {
173+
const opened = await execute('browser_open_url', { url: `${origin}/click?mode=${mode}` })
174+
expect(opened.ok, opened.error).toBe(true)
175+
await app.evaluate(
176+
async ({ webContents }, { origin, mode }) => {
177+
const contents = webContents
178+
.getAllWebContents()
179+
.find((wc) => wc.getURL().startsWith(`${origin}/click`))
180+
if (!contents) throw new Error('Missing click fixture')
181+
await contents.executeJavaScript(`
182+
history.scrollRestoration = 'manual';
183+
document.getElementById('target').style.top = ${mode === 'sticky' ? '1010' : 'innerHeight - 100'} + 'px';
184+
scrollTo(0, ${mode === 'sticky' ? '1000' : '0'});
185+
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
186+
document.body.dataset.scrolls = '0'; document.body.dataset.armed = 'true'; resolve();
187+
})))
188+
`)
189+
},
190+
{ origin, mode }
191+
)
192+
const snapshot = await execute('browser_snapshot', {})
193+
expect(snapshot.ok, snapshot.error).toBe(true)
194+
const outline = (snapshot.result as { outline: string }).outline
195+
const line = outline.split('\n').find((line) => line.includes('"Choose option"'))
196+
const match = line?.match(/\[ref=(\d+)\]/)
197+
if (!match) throw new Error(`Missing target: ${outline}`)
198+
const result = await execute('browser_click', { elementId: Number(match[1]) })
199+
expect(result.ok, result.error).toBe(true)
200+
const state = await app.evaluate(async ({ webContents }, origin) => {
201+
const contents = webContents
202+
.getAllWebContents()
203+
.find((wc) => wc.getURL().startsWith(`${origin}/click`))
204+
if (!contents) throw new Error('Missing click fixture')
205+
return contents.executeJavaScript(
206+
'({clicks:document.body.dataset.clicks,scrolls:document.body.dataset.scrolls,scrollY})'
207+
)
208+
}, origin)
209+
expect(state.clicks).toBe('1')
210+
if (mode === 'menu') expect(state).toMatchObject({ scrolls: '0', scrollY: 0 })
211+
else expect(state.scrollY).toBeLessThan(1000)
212+
})
213+
}
214+
215+
for (const mode of ['visible', 'hidden', 'minimized']) {
216+
test(`captures a ${mode} window without changing its state`, async () => {
217+
test.skip(
218+
mode === 'minimized' && process.platform !== 'darwin',
219+
'Requires a window manager with minimize events'
220+
)
221+
await openForm()
222+
await app.evaluate(async ({ BrowserWindow }, mode) => {
223+
const win = BrowserWindow.getAllWindows()[0]
224+
win.blur()
225+
if (mode === 'hidden') win.hide()
226+
if (mode === 'minimized') {
227+
const minimized = new Promise<void>((resolve) => win.once('minimize', () => resolve()))
228+
win.minimize()
229+
await minimized
230+
}
231+
}, mode)
232+
const state = () =>
233+
app.evaluate(async ({ BrowserWindow, webContents }, origin) => {
234+
const win = BrowserWindow.getAllWindows()[0]
235+
const contents = webContents
236+
.getAllWebContents()
237+
.find((wc) => wc.getURL() === `${origin}/form`)
238+
if (!contents) throw new Error('Missing screenshot fixture')
239+
return {
240+
visible: win.isVisible(),
241+
minimized: win.isMinimized(),
242+
bounds: win.getBounds(),
243+
focused: BrowserWindow.getFocusedWindow()?.id ?? null,
244+
page: await contents.executeJavaScript(
245+
'({width:innerWidth,height:innerHeight,scrollX,scrollY,html:document.body.innerHTML,focus:document.activeElement?.id})'
246+
),
247+
}
248+
}, origin)
249+
const before = await state()
250+
for (let i = 0; i < 3; i++) {
251+
const response = await execute('browser_screenshot', {})
252+
expect(response.ok, response.error).toBe(true)
253+
const shot = response.result as {
254+
dataUrl: string
255+
scale: number
256+
viewport: { width: number; height: number }
257+
}
258+
expect(shot.dataUrl.length).toBeGreaterThan(1000)
259+
expect(shot.viewport.width).toBeGreaterThan(0)
260+
expect(shot.viewport.height).toBeGreaterThan(0)
261+
const image = await app.evaluate(({ nativeImage }, dataUrl) => {
262+
const image = nativeImage.createFromDataURL(dataUrl)
263+
return { empty: image.isEmpty(), ...image.getSize() }
264+
}, shot.dataUrl)
265+
expect(image).toEqual({
266+
empty: false,
267+
width: Math.round(shot.viewport.width * shot.scale),
268+
height: Math.round(shot.viewport.height * shot.scale),
269+
})
270+
expect(await state()).toEqual(before)
271+
}
272+
})
273+
}
274+
275+
test('captures fresh pixels after resizing and repainting the viewport', async () => {
276+
await openForm()
277+
const viewportWidth = () =>
278+
app.evaluate(async ({ webContents }, origin) => {
279+
const contents = webContents
280+
.getAllWebContents()
281+
.find((wc) => wc.getURL() === `${origin}/form`)
282+
return contents?.executeJavaScript('innerWidth')
283+
}, origin)
284+
const beforeWidth = await viewportWidth()
285+
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 900))
286+
await expect.poll(viewportWidth).not.toBe(beforeWidth)
287+
for (const color of ['red', 'blue']) {
288+
await app.evaluate(
289+
async ({ webContents }, { origin, color }) => {
290+
const contents = webContents
291+
.getAllWebContents()
292+
.find((wc) => wc.getURL() === `${origin}/form`)
293+
if (!contents) throw new Error('Missing screenshot fixture')
294+
await contents.executeJavaScript(
295+
`document.body.style.background = ${JSON.stringify(color)}; void 0`
296+
)
297+
},
298+
{ origin, color }
299+
)
300+
const response = await execute('browser_screenshot', {})
301+
expect(response.ok, response.error).toBe(true)
302+
const shot = response.result as { dataUrl: string }
303+
const pixel = await app.evaluate(({ nativeImage }, dataUrl) => {
304+
const image = nativeImage.createFromDataURL(dataUrl)
305+
return Array.from(image.toBitmap().subarray(0, 4))
306+
}, shot.dataUrl)
307+
const dominant = pixel[color === 'red' ? 2 : 0]
308+
const other = pixel[color === 'red' ? 0 : 2]
309+
expect(dominant - other, `${color}: ${pixel}`).toBeGreaterThan(150)
310+
}
311+
})
312+
146313
test('opens with references, fills in order, and scrolls a horizontal pane', async () => {
147314
const ref = await openForm()
148315
const fill = await execute('browser_fill_form', {

0 commit comments

Comments
 (0)