Skip to content

Commit bfa86a5

Browse files
authored
fix(browser): recover stalled screenshots and preserve image coordinates (#7889)
* fix(browser): recover stalled screenshots and preserve image geometry * chore(browser): synchronize screenshot fixture repaint * fix(browser): align coordinate guidance with screenshot mapping * chore(browser): record shared formatter module graph The shared formatter adds one dependency-free module to the home and chat page graphs (3130 on staging, 3131 here). Regenerate those two baseline entries without changing audit tolerances or unrelated entries.
1 parent 42a413b commit bfa86a5

13 files changed

Lines changed: 773 additions & 118 deletions

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

Lines changed: 186 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ test.describe('browser tools', () => {
9696

9797
test.beforeEach(async () => {
9898
app = await electron.launch({
99-
args: ['.'],
99+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
100100
cwd: DESKTOP_DIR,
101101
env: {
102102
...process.env,
@@ -105,9 +105,15 @@ test.describe('browser tools', () => {
105105
},
106106
})
107107
window = await app.firstWindow()
108-
await app.evaluate(({ BrowserWindow }) =>
109-
BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false)
110-
)
108+
await app.evaluate(({ app, BrowserWindow }) => {
109+
const host = BrowserWindow.getAllWindows()[0]
110+
host.webContents.setBackgroundThrottling(false)
111+
app.focus({ steal: true })
112+
host.focus()
113+
})
114+
await expect
115+
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused()))
116+
.toBe(true)
111117
await expect(window.getByRole('heading')).toHaveText('Browser tools fixture')
112118
await window.evaluate(async (scope) => {
113119
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
@@ -303,6 +309,180 @@ test.describe('browser tools', () => {
303309
})
304310
}
305311

312+
test('recovers a permanently pending native capture without losing the page', async () => {
313+
await openForm()
314+
const before = await app.evaluate(async ({ webContents }, origin) => {
315+
const contents = webContents
316+
.getAllWebContents()
317+
.find((wc) => wc.getURL() === `${origin}/form`)
318+
if (!contents) throw new Error('Missing capture fixture')
319+
await contents.executeJavaScript(`
320+
document.getElementById('name').value = 'Unsaved work';
321+
document.getElementById('name').focus();
322+
`)
323+
contents.capturePage = () => new Promise(() => {})
324+
return { id: contents.id, url: contents.getURL() }
325+
}, origin)
326+
for (const [color, dominantChannel] of [
327+
['rgb(240, 20, 30)', 0],
328+
['rgb(30, 40, 230)', 2],
329+
['rgb(20, 220, 50)', 1],
330+
] as const) {
331+
await app.evaluate(
332+
async ({ webContents }, { id, color }) => {
333+
const contents = webContents.fromId(id)
334+
if (!contents) throw new Error('Capture fixture was replaced')
335+
await contents.executeJavaScript(`
336+
document.body.style.background = ${JSON.stringify(color)};
337+
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
338+
`)
339+
},
340+
{ id: before.id, color }
341+
)
342+
const response = await execute('browser_screenshot', {})
343+
expect(response.ok, response.error).toBe(true)
344+
const shot = response.result as { dataUrl: string }
345+
const pixel = await app.evaluate(({ nativeImage }, dataUrl) => {
346+
const bitmap = nativeImage.createFromDataURL(dataUrl).toBitmap()
347+
return [bitmap[2], bitmap[1], bitmap[0]]
348+
}, shot.dataUrl)
349+
expect(pixel[dominantChannel]).toBeGreaterThan(180)
350+
for (let channel = 0; channel < 3; channel++) {
351+
if (channel !== dominantChannel)
352+
expect(pixel[dominantChannel] - pixel[channel]).toBeGreaterThan(80)
353+
}
354+
}
355+
const after = await app.evaluate(async ({ webContents }, id) => {
356+
const contents = webContents.fromId(id)
357+
if (!contents) throw new Error('Capture fixture was replaced')
358+
return {
359+
id: contents.id,
360+
url: contents.getURL(),
361+
page: await contents.executeJavaScript(
362+
`({value:document.getElementById('name').value,focus:document.activeElement.id})`
363+
),
364+
}
365+
}, before.id)
366+
expect(after).toEqual({ ...before, page: { value: 'Unsaved work', focus: 'name' } })
367+
})
368+
369+
test('maps a fractional narrow crop back to its actual viewport position', async () => {
370+
await openForm()
371+
const target = await app.evaluate(async ({ webContents }, origin) => {
372+
const contents = webContents
373+
.getAllWebContents()
374+
.find((wc) => wc.getURL() === `${origin}/form`)
375+
if (!contents) throw new Error('Missing crop fixture')
376+
return contents.executeJavaScript(`
377+
const button = document.createElement('button');
378+
button.textContent = 'Narrow target';
379+
button.style.cssText = 'position:absolute;left:20.1px;top:60.1px;width:1.1px;height:100px;padding:0;border:0;overflow:hidden';
380+
button.onclick = () => { document.body.dataset.cropClicks = Number(document.body.dataset.cropClicks || 0) + 1 };
381+
document.body.append(button);
382+
const rect = button.getBoundingClientRect();
383+
({x:rect.x,y:rect.y,width:rect.width,height:rect.height,devicePixelRatio});
384+
`) as Promise<{
385+
x: number
386+
y: number
387+
width: number
388+
height: number
389+
devicePixelRatio: number
390+
}>
391+
}, origin)
392+
const snapshot = await execute('browser_snapshot', {})
393+
expect(snapshot.ok, snapshot.error).toBe(true)
394+
const line = (snapshot.result as { outline: string }).outline
395+
.split('\n')
396+
.find((line) => line.includes('"Narrow target"'))
397+
const match = line?.match(/\[ref=(\d+)\]/)
398+
if (!match) throw new Error('Missing narrow target reference')
399+
const response = await execute('browser_screenshot', { elementId: Number(match[1]) })
400+
expect(response.ok, response.error).toBe(true)
401+
const shot = response.result as {
402+
imageSize: { width: number; height: number }
403+
clip: { x: number; y: number; width: number; height: number }
404+
scale: number
405+
}
406+
expect(shot.clip.x).toBeLessThanOrEqual(target.x)
407+
expect(shot.clip.y).toBeLessThanOrEqual(target.y)
408+
expect(shot.clip.x + shot.clip.width).toBeGreaterThanOrEqual(target.x + target.width)
409+
expect(shot.clip.y + shot.clip.height).toBeGreaterThanOrEqual(target.y + target.height)
410+
expect(target.x - shot.clip.x).toBeLessThan(1 / target.devicePixelRatio)
411+
expect(target.y - shot.clip.y).toBeLessThan(1 / target.devicePixelRatio)
412+
expect(shot.scale).toBeCloseTo(shot.imageSize.width / shot.clip.width)
413+
const clicked = await execute('browser_click_at', {
414+
x: shot.clip.x + shot.clip.width / 2,
415+
y: shot.clip.y + shot.clip.height / 2,
416+
})
417+
expect(clicked.ok, clicked.error).toBe(true)
418+
const count = await app.evaluate(async ({ webContents }, origin) => {
419+
const contents = webContents
420+
.getAllWebContents()
421+
.find((wc) => wc.getURL() === `${origin}/form`)
422+
return contents?.executeJavaScript('document.body.dataset.cropClicks')
423+
}, origin)
424+
expect(count).toBe('1')
425+
})
426+
427+
for (const mode of ['hidden', 'minimized']) {
428+
test(`recovers a stalled capture after restoring a ${mode} window`, async () => {
429+
test.skip(mode === 'minimized' && process.platform !== 'darwin', 'Requires minimize events')
430+
await openForm()
431+
await app.evaluate(
432+
async ({ BrowserWindow, webContents }, { origin, mode }) => {
433+
const contents = webContents
434+
.getAllWebContents()
435+
.find((wc) => wc.getURL() === `${origin}/form`)
436+
if (!contents) throw new Error('Missing capture fixture')
437+
contents.capturePage = () => new Promise(() => {})
438+
await contents.executeJavaScript("document.getElementById('name').value = 'Unsaved work'")
439+
const win = BrowserWindow.getAllWindows()[0]
440+
win.blur()
441+
if (mode === 'hidden') win.hide()
442+
else {
443+
const minimized = new Promise<void>((resolve) => win.once('minimize', resolve))
444+
win.minimize()
445+
await minimized
446+
}
447+
},
448+
{ origin, mode }
449+
)
450+
const state = () =>
451+
app.evaluate(async ({ BrowserWindow, webContents }, origin) => {
452+
const win = BrowserWindow.getAllWindows()[0]
453+
const contents = webContents
454+
.getAllWebContents()
455+
.find((wc) => wc.getURL() === `${origin}/form`)
456+
if (!contents) throw new Error('Missing capture fixture')
457+
return {
458+
id: contents.id,
459+
visible: win.isVisible(),
460+
minimized: win.isMinimized(),
461+
focused: BrowserWindow.getFocusedWindow()?.id ?? null,
462+
bounds: win.getBounds(),
463+
value: await contents.executeJavaScript("document.getElementById('name').value"),
464+
}
465+
}, origin)
466+
const before = await state()
467+
const start = Date.now()
468+
const hiddenCapture = await execute('browser_screenshot', {})
469+
expect(Date.now() - start).toBeLessThan(12_000)
470+
if (!hiddenCapture.ok)
471+
expect(hiddenCapture.error).toContain('Screenshot frame capture timed out')
472+
expect(await state()).toEqual(before)
473+
await app.evaluate(({ BrowserWindow }, mode) => {
474+
const win = BrowserWindow.getAllWindows()[0]
475+
if (mode === 'minimized') win.restore()
476+
else win.showInactive()
477+
}, mode)
478+
for (let attempt = 0; attempt < 2; attempt++) {
479+
const response = await execute('browser_screenshot', {})
480+
expect(response.ok, response.error).toBe(true)
481+
}
482+
expect(await state()).toMatchObject({ id: before.id, value: 'Unsaved work' })
483+
})
484+
}
485+
306486
for (const mode of ['visible', 'hidden', 'minimized']) {
307487
test(`captures a ${mode} window without changing its state`, async () => {
308488
test.skip(
@@ -383,7 +563,8 @@ test.describe('browser tools', () => {
383563
.find((wc) => wc.getURL() === `${origin}/form`)
384564
if (!contents) throw new Error('Missing screenshot fixture')
385565
await contents.executeJavaScript(
386-
`document.body.style.background = ${JSON.stringify(color)}; void 0`
566+
`document.body.style.background = ${JSON.stringify(color)};
567+
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))`
387568
)
388569
},
389570
{ origin, color }

0 commit comments

Comments
 (0)