Skip to content

Commit ba9238d

Browse files
committed
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
1 parent 5313021 commit ba9238d

7 files changed

Lines changed: 164 additions & 52 deletions

File tree

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

Lines changed: 39 additions & 22 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,17 +114,41 @@ 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+
await answerShellDialog(await readConsent, 'Allow folder')
118126

119127
await window.goto(`${sim.origin}/workspace/ws-other/home`)
120128
await window.reload()
121129
await window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
122130

131+
// Chat B's first command holds its queue until released, so a claim that waited for the
132+
// calls ahead of it to run could never happen in time.
133+
await check("A: every call is claimed while chat B's first command still runs", async () => {
134+
await expect
135+
.poll(
136+
() =>
137+
[...sim.calls.values()]
138+
.filter((call) => call.claimedAt === undefined)
139+
.map((call) => call.toolCallId),
140+
{ timeout: SIM_PICKUP_GRACE_MS }
141+
)
142+
.toEqual([])
143+
})
144+
145+
await check('A: the lease is renewed while calls wait and run', async () => {
146+
await expect
147+
.poll(() => terminalRuns.filter((id) => sim.requireCall(id).renewals === 0))
148+
.toEqual([])
149+
})
150+
writeFileSync(releaseCommands, '')
151+
123152
await check('A: every call completes exactly once with its own token', async () => {
124153
for (const id of [...clicks, ...terminalRuns, localRead]) {
125154
const completion = await settled(sim, id, 90_000)
@@ -153,18 +182,6 @@ test.describe('background executor', () => {
153182
sim.requests.filter((request) => request.includes('/api/desktop/tool/authorize'))
154183
).toEqual([])
155184
})
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-
})
168185
})
169186

170187
test('background file reads require consent and Stop cancels pending permission', async () => {
@@ -181,7 +198,7 @@ test.describe('background executor', () => {
181198
const prompt = await shown
182199
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
183200
expect(sim.requireCall(call).completions).toHaveLength(0)
184-
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
201+
await answerShellDialog(prompt, "Don't allow")
185202
const completion = await settled(sim, call)
186203
expect(completion.status).toBe('error')
187204
expect(JSON.stringify(completion)).not.toContain('background consent fixture')
@@ -202,7 +219,7 @@ test.describe('background executor', () => {
202219
const shown = launched.app.waitForEvent('window', { timeout: 10_000 })
203220
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
204221
const prompt = await shown
205-
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
222+
await answerShellDialog(prompt, 'Allow folder')
206223
expect((await settled(sim, call)).status).toBe('success')
207224
const next = sim.issue(deviceId, CHAT_A, 'read_local_file', { path: readable })
208225
expect(JSON.stringify((await settled(sim, next)).data)).toContain(
@@ -225,7 +242,7 @@ test.describe('background executor', () => {
225242
const prompt = await shown
226243
await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible()
227244
sim.disconnect()
228-
await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click()
245+
await answerShellDialog(prompt, 'Allow folder')
229246
await expect
230247
.poll(() =>
231248
launched.app.evaluate(({ safeStorage }, toolCallId) => {
@@ -245,7 +262,7 @@ test.describe('background executor', () => {
245262
expect(JSON.stringify(completion)).not.toContain('offline consent fixture')
246263
const nextPrompt = launched.app.waitForEvent('window')
247264
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()
265+
await answerShellDialog(await nextPrompt, "Don't allow")
249266
expect((await settled(sim, next)).status).toBe('error')
250267
})
251268
})
@@ -270,7 +287,7 @@ test.describe('background executor', () => {
270287
BrowserWindow.getAllWindows().map((window) => window.getParentWindow() === null)
271288
)
272289
).toEqual([true])
273-
await prompt.getByRole('button', { name: "Don't allow", exact: true }).click()
290+
await answerShellDialog(prompt, "Don't allow")
274291
expect((await settled(sim, call)).status).toBe('error')
275292
})
276293
})
@@ -284,7 +301,7 @@ test.describe('background executor', () => {
284301
writeFileSync(readable, 'cleanup consent fixture')
285302
const shown = launched.app.waitForEvent('window')
286303
const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable })
287-
await (await shown).getByRole('button', { name: 'Allow folder', exact: true }).click()
304+
await answerShellDialog(await shown, 'Allow folder')
288305
expect((await settled(sim, call)).status).toBe('success')
289306
const grants = join(userData, 'local-filesystem-grants.json')
290307
expect(existsSync(grants)).toBe(true)
@@ -321,7 +338,7 @@ test.describe('background executor', () => {
321338
item.click()
322339
})
323340
const failure = await failedSignOut
324-
await failure.getByRole('button', { name: 'OK', exact: true }).click()
341+
await answerShellDialog(failure, 'OK')
325342
expect(existsSync(join(userData, 'account-data-teardown-required.json'))).toBe(true)
326343
await expect.poll(() => existsSync(grants)).toBe(false)
327344
}
@@ -419,7 +436,7 @@ test.describe('background executor', () => {
419436
folderId: 'folder-e2e',
420437
})
421438

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

424441
await check('D: the import completes with every entry it stored', async () => {
425442
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)