Skip to content

Commit 3bfd224

Browse files
authored
fix(desktop-e2e): remove root causes of four retry-masked desktop flakes (#8940)
* fix(desktop-e2e): remove root causes of four retry-masked desktop flakes - packaged-smoke: remove the profile only after every process that can write it (the app's process group, Crashpad's handler) is gone; SIGKILLing main left helpers creating files during rmSync (ENOTEMPTY) - background-executor: answer shell dialogs by waiting for their window to close, since the dialog destroys itself before the click is acknowledged - background-executor A: replace the 1.5 s p95 wall-clock threshold with a gated check that every call is claimed while chat B's first command still holds its queue, within Sim's 15 s pickup window, and that held leases renew - driver: a click whose tab closes itself mid-dispatch (popup Allow -> window.close()) is a dispatched click, not a failed one, whichever of the close and the input acknowledgement lands first - page-dialogs / browser-focus / browser-tools: print the tool error and the shown dialog id when they fail, to root-cause the remaining flakes * fix(desktop-e2e): rescue only a click whose final release hit a tab that then closed - cdp.clickAt raises TabClosedOnReleaseError only when the final mouseReleased was sent to a live tab and the tab is destroyed when it rejects; the driver rescues only that error (no string match, no pre-input/press/hold cases), with a note that does not claim the page closed itself and doNotRetry - background-executor A: assert each call is claimed within Sim's 15 s pickup window measured from issue, and always release the gated commands * fix(desktop): report a click whose tab closed mid-release as unconfirmed The tab being destroyed proves teardown, not that Chromium processed the release, so the rescued result no longer claims effectObserved and its note says the outcome could not be confirmed. Adds clickAt tests for each TabClosedOnReleaseError guard and a driver test for the rescued shape.
1 parent 140f1ae commit 3bfd224

10 files changed

Lines changed: 318 additions & 64 deletions

File tree

‎apps/desktop/e2e/background-executor.spec.ts‎

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1515
import { sleep } from '@sim/utils/helpers'
1616
import { recordCheck } from './check-report'
1717
import {
18+
answerShellDialog,
1819
type FixtureCall,
1920
FixtureSim,
2021
launch,
@@ -37,6 +38,9 @@ import {
3738
* BACKGROUND_EXECUTOR_REPORT_PATH as it finishes.
3839
*/
3940

41+
/** Sim's window for a device to claim an offered call (DESKTOP_TOOL_PICKUP_GRACE_MS in Sim). */
42+
const SIM_PICKUP_GRACE_MS = 15_000
43+
4044
const CHAT_A = 'chat-browser-a'
4145
const CHAT_B = 'chat-terminal-b'
4246
const CHAT_C = 'chat-idle-c'
@@ -89,6 +93,7 @@ test.describe('background executor', () => {
8993
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
9094

9195
const marker = join(userData, 'terminal-marker.txt')
96+
const releaseCommands = join(userData, 'release-commands')
9297
const readable = join(userData, 'notes.txt')
9398
writeFileSync(readable, 'background read fixture')
9499

@@ -109,16 +114,49 @@ test.describe('background executor', () => {
109114
const terminalRuns = [1, 2, 3].map((n) =>
110115
sim.issue(deviceId, CHAT_B, 'terminal', {
111116
operation: 'run',
112-
args: { command: `sleep 1; echo B-${n} >> '${marker}'`, waitSeconds: 30 },
117+
args: {
118+
command: `until [ -e '${releaseCommands}' ]; do sleep 0.1; done; echo B-${n} >> '${marker}'`,
119+
waitSeconds: 30,
120+
},
113121
})
114122
)
115123
const readConsent = launched.app.waitForEvent('window')
116124
const localRead = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
117-
await (await readConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()
125+
try {
126+
await answerShellDialog(await readConsent, 'Allow folder')
127+
128+
await window.goto(`${sim.origin}/workspace/ws-other/home`)
129+
await window.reload()
130+
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
131+
132+
// Chat B's first command holds its queue until released, so a claim that waited for the
133+
// calls ahead of it to run could never happen in time.
134+
await check("A: every call is claimed while chat B's first command still runs", async () => {
135+
await expect
136+
.poll(
137+
() =>
138+
[...sim.calls.values()]
139+
.filter((call) => call.claimedAt === undefined)
140+
.map((call) => call.toolCallId),
141+
{ timeout: SIM_PICKUP_GRACE_MS }
142+
)
143+
.toEqual([])
144+
for (const call of sim.calls.values()) {
145+
expect(
146+
(call.claimedAt ?? Number.POSITIVE_INFINITY) - call.issuedAt,
147+
call.toolCallId
148+
).toBeLessThan(SIM_PICKUP_GRACE_MS)
149+
}
150+
})
118151

119-
await window.goto(`${sim.origin}/workspace/ws-other/home`)
120-
await window.reload()
121-
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
152+
await check('A: the lease is renewed while calls wait and run', async () => {
153+
await expect
154+
.poll(() => terminalRuns.filter((id) => sim.requireCall(id).renewals === 0))
155+
.toEqual([])
156+
})
157+
} finally {
158+
writeFileSync(releaseCommands, '')
159+
}
122160

123161
await check('A: every call completes exactly once with its own token', async () => {
124162
for (const id of [...clicks, ...terminalRuns, localRead]) {
@@ -153,18 +191,6 @@ test.describe('background executor', () => {
153191
sim.requests.filter((request) => request.includes('/api/desktop/tool/authorize'))
154192
).toEqual([])
155193
})
156-
157-
await check('A: calls are picked up within 1.5 s at p95', async () => {
158-
const latencies = [...sim.calls.values()]
159-
.map((call) => (call.claimedAt ?? Number.POSITIVE_INFINITY) - call.issuedAt)
160-
.sort((a, b) => a - b)
161-
const p95 = latencies[Math.ceil(latencies.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY
162-
expect(p95).toBeLessThan(1_500)
163-
})
164-
165-
await check('A: the lease is renewed while calls wait and run', async () => {
166-
expect(Math.max(...terminalRuns.map((id) => sim.requireCall(id).renewals))).toBeGreaterThan(0)
167-
})
168194
})
169195

170196
test('background file reads require consent and Stop cancels pending permission', async () => {
@@ -181,7 +207,7 @@ test.describe('background executor', () => {
181207
const prompt = await shown
182208
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
183209
expect(sim.requireCall(call).completions).toHaveLength(0)
184-
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
210+
await answerShellDialog(prompt, "Don't allow")
185211
const completion = await settled(sim, call)
186212
expect(completion.status).toBe('error')
187213
expect(JSON.stringify(completion)).not.toContain('background consent fixture')
@@ -202,7 +228,7 @@ test.describe('background executor', () => {
202228
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
203229
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
204230
const prompt = await shown
205-
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
231+
await answerShellDialog(prompt, 'Allow folder')
206232
expect((await settled(sim, call)).status).toBe('success')
207233
const next = sim.issue(deviceId, CHAT_A, 'read_local_file', { path: readable })
208234
expect(JSON.stringify((await settled(sim, next)).data)).toContain(
@@ -225,7 +251,7 @@ test.describe('background executor', () => {
225251
const prompt = await shown
226252
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
227253
sim.disconnect()
228-
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
254+
await answerShellDialog(prompt, 'Allow folder')
229255
await expect
230256
.poll(() =>
231257
launched.app.evaluate(({ safeStorage }, toolCallId) => {
@@ -245,7 +271,7 @@ test.describe('background executor', () => {
245271
expect(JSON.stringify(completion)).not.toContain('offline consent fixture')
246272
const nextPrompt = launched.app.waitForEvent('window')
247273
const next = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
248-
await (await nextPrompt).getByRole('button', { name: "Don't allow", exact: true }).click()
274+
await answerShellDialog(await nextPrompt, "Don't allow")
249275
expect((await settled(sim, next)).status).toBe('error')
250276
})
251277
})
@@ -270,7 +296,7 @@ test.describe('background executor', () => {
270296
BrowserWindow.getAllWindows().map((window) => window.getParentWindow() === null)
271297
)
272298
).toEqual([true])
273-
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
299+
await answerShellDialog(prompt, "Don't allow")
274300
expect((await settled(sim, call)).status).toBe('error')
275301
})
276302
})
@@ -284,7 +310,7 @@ test.describe('background executor', () => {
284310
writeFileSync(readable, 'cleanup consent fixture')
285311
const shown = launched.app.waitForEvent('window')
286312
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
287-
await (await shown).getByRole('button', { name: 'Allow folder', exact: true }).click()
313+
await answerShellDialog(await shown, 'Allow folder')
288314
expect((await settled(sim, call)).status).toBe('success')
289315
const grants = join(userData, 'local-filesystem-grants.json')
290316
expect(existsSync(grants)).toBe(true)
@@ -321,7 +347,7 @@ test.describe('background executor', () => {
321347
item.click()
322348
})
323349
const failure = await failedSignOut
324-
await failure.getByRole('button', { name: 'OK', exact: true }).click()
350+
await answerShellDialog(failure, 'OK')
325351
expect(existsSync(join(userData, 'account-data-teardown-required.json'))).toBe(true)
326352
await expect.poll(() => existsSync(grants)).toBe(false)
327353
}
@@ -419,7 +445,7 @@ test.describe('background executor', () => {
419445
folderId: 'folder-e2e',
420446
})
421447

422-
await (await importConsent).getByRole('button', { name: 'Allow folder', exact: true }).click()
448+
await answerShellDialog(await importConsent, 'Allow folder')
423449

424450
await check('D: the import completes with every entry it stored', async () => {
425451
const completion = await settled(sim, call, 60_000)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ test('browser focus and shortcuts stay with the surface the user is using', asyn
122122
(globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
123123
{ callId, tool, args, scope: SCOPE }
124124
)
125-
expect(result.ok).toBe(true)
125+
expect(result.ok, result.error).toBe(true)
126126
return result.ok ? result.result : undefined
127127
}
128128
const panelAction = (action: Record<string, unknown>) =>

‎apps/desktop/e2e/browser-page-dialogs.spec.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`,
212212
(globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
213213
{ callId, tool, args, scope: SCOPE }
214214
)
215-
expect(result.ok).toBe(true)
215+
expect(result.ok, result.error).toBe(true)
216216
return result.ok ? result.result : undefined
217217
}
218218
/** Browser-chrome actions are user gestures, so each follows a real click in Sim. */
@@ -439,8 +439,13 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`,
439439
caret: 'initial',
440440
})
441441
await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused()
442+
const shown = await pageDialog()
442443
await shell.keyboard.press('Escape')
443-
await expect.poll(pageDialog).toBeNull()
444+
await expect
445+
.poll(pageDialog, {
446+
message: `dialog ${shown?.requestId} was showing when Escape was pressed`,
447+
})
448+
.toBeNull()
444449
await expect
445450
.poll(() =>
446451
shellApp.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL())

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,8 @@ test.describe('browser tools', () => {
615615
/button "Allow" \[ref=(\d+)\]/
616616
)?.[1]
617617
expect(allow, JSON.stringify(popup.result)).toBeTruthy()
618-
expect((await execute('browser_click', { elementId: Number(allow) })).ok).toBe(true)
618+
const allowed = await execute('browser_click', { elementId: Number(allow) })
619+
expect(allowed.ok, allowed.error).toBe(true)
619620

620621
await expect.poll(dataset).toMatchObject({ connected: 'granted' })
621622
await expect

‎apps/desktop/e2e/executor-sim.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
expect,
1313
type Page,
1414
} from '@playwright/test'
15+
import { getErrorMessage } from '@sim/utils/errors'
1516

1617
/**
1718
* A fixture Sim that speaks the background executor's device protocol (register, inbox,
@@ -497,6 +498,25 @@ export async function settled(
497498
return sim.requireCall(toolCallId).completions[0] as Completion
498499
}
499500

501+
/**
502+
* Answers a shell dialog with one of its buttons. The dialog destroys its window as soon as the
503+
* answer reaches the main process, which can happen before Chromium acknowledges the click to
504+
* Playwright; the click then rejects with a closed target although the answer went through. The
505+
* window closing is the proof the answer landed, so that is what this waits for.
506+
*/
507+
export async function answerShellDialog(dialog: Page, button: string): Promise<void> {
508+
const closed = dialog.waitForEvent('close')
509+
await dialog
510+
.getByRole('button', { name: button, exact: true })
511+
.click()
512+
.catch((error: unknown) => {
513+
if (!getErrorMessage(error).includes('Target page, context or browser has been closed')) {
514+
throw error
515+
}
516+
})
517+
await closed
518+
}
519+
500520
export function refFor(outline: string, name: string): number {
501521
const match = outline
502522
.split('\n')

‎apps/desktop/e2e/packaged-smoke.spec.ts‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { spawn } from 'node:child_process'
1+
import { type ChildProcess, execFileSync, spawn } from 'node:child_process'
22
import { once } from 'node:events'
3-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
44
import { tmpdir } from 'node:os'
55
import { join } from 'node:path'
66
import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses'
@@ -27,6 +27,53 @@ test.skip(
2727
'Packaged smoke runs only after the desktop executable has been built'
2828
)
2929

30+
/**
31+
* The processes of a packaged run that can still write its profile. Chromium's helpers stay in the
32+
* app's process group (it is spawned detached, so the group is its own), while Crashpad's handler
33+
* starts its own session but names the profile's Crashpad database on its command line.
34+
*/
35+
function profileWriters(appGroup: number, profilePaths: readonly string[]): number[] {
36+
return execFileSync('ps', ['-axww', '-o', 'pid=,pgid=,command='], { encoding: 'utf8' })
37+
.split('\n')
38+
.flatMap((line) => {
39+
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
40+
if (!match) return []
41+
const [, pid, group, command] = match
42+
return Number(group) === appGroup || profilePaths.some((path) => command.includes(path))
43+
? [Number(pid)]
44+
: []
45+
})
46+
}
47+
48+
/**
49+
* Removes a profile only once nothing can write into it. Killing the main process leaves its
50+
* helpers and Crashpad's handler running for a moment, and a file one of them creates while the
51+
* directory is being removed fails the removal with ENOTEMPTY.
52+
*/
53+
async function stopAndRemoveProfile(child: ChildProcess, userDataPath: string): Promise<void> {
54+
if (child.exitCode === null && child.signalCode === null) {
55+
const exited = once(child, 'exit')
56+
child.kill('SIGKILL')
57+
await exited
58+
}
59+
const appGroup = child.pid
60+
if (appGroup !== undefined) {
61+
const profilePaths = [userDataPath, realpathSync(userDataPath)]
62+
await expect
63+
.poll(() => {
64+
const writers = profileWriters(appGroup, profilePaths)
65+
for (const pid of writers) {
66+
try {
67+
process.kill(pid, 'SIGKILL')
68+
} catch {}
69+
}
70+
return writers
71+
})
72+
.toEqual([])
73+
}
74+
rmSync(userDataPath, { recursive: true, force: true })
75+
}
76+
3077
test('packaged Electron binary has the production fuse policy', async () => {
3178
const executablePath = process.env.SIM_DESKTOP_EXECUTABLE
3279
if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required')
@@ -48,6 +95,7 @@ test('packaged main process starts and records launch telemetry', async () => {
4895
if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required')
4996
const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-'))
5097
const child = spawn(executablePath, [], {
98+
detached: true,
5199
env: {
52100
...process.env,
53101
SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1',
@@ -74,12 +122,7 @@ test('packaged main process starts and records launch telemetry', async () => {
74122
)
75123
.toBe(true)
76124
} finally {
77-
if (child.exitCode === null && child.signalCode === null) {
78-
const exited = once(child, 'exit')
79-
child.kill('SIGKILL')
80-
await exited
81-
}
82-
rmSync(userDataPath, { recursive: true, force: true })
125+
await stopAndRemoveProfile(child, userDataPath)
83126
}
84127
})
85128

@@ -97,6 +140,7 @@ test('packaged shell renders the bundled offline page', async () => {
97140
// run on a machine that already has the real app's item) would block on a
98141
// Keychain prompt on its main thread, and the debugging endpoint with it.
99142
const child = spawn(executablePath, ['--remote-debugging-port=0', '--use-mock-keychain'], {
143+
detached: true,
100144
env: {
101145
...process.env,
102146
SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1',
@@ -150,11 +194,6 @@ test('packaged shell renders the bundled offline page', async () => {
150194
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
151195
} finally {
152196
await browser?.close().catch(() => {})
153-
if (child.exitCode === null && child.signalCode === null) {
154-
const exited = once(child, 'exit')
155-
child.kill('SIGKILL')
156-
await exited
157-
}
158-
rmSync(userDataPath, { recursive: true, force: true })
197+
await stopAndRemoveProfile(child, userDataPath)
159198
}
160199
})

0 commit comments

Comments
 (0)