Skip to content

Commit 6074c04

Browse files
committed
improvement(desktop): warm every route the live E2E reaches, keep sends honest, and check dormancy layer by layer
- Warm-up runs the tests' own flows (read, import, a chat switch during a live turn, Stop, login) and waits until every request the app made has been answered - send() resends only when the UI did not take the message; a taken message whose turn never reaches Sim fails as a lost send - Stop and sign-out tests assert exact outcomes without holding the tool's report (the stopped tool reports nothing since #8732) - Dormancy checks registration, device records, binding, routes and the doorbell as separate soft assertions - CI: per-run E2E_APP tags, retries 0, and a path filter for pull requests
1 parent 9b4e37c commit 6074c04

3 files changed

Lines changed: 181 additions & 64 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -442,10 +442,44 @@ jobs:
442442
if-no-files-found: ignore
443443
retention-days: 7
444444

445+
# Pull requests run the live desktop suite only when they touch what it exercises.
446+
desktop-live-changes:
447+
name: Detect desktop tool changes
448+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
449+
timeout-minutes: 5
450+
outputs:
451+
changed: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop == 'true' }}
452+
steps:
453+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
454+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
455+
id: filter
456+
with:
457+
filters: |
458+
desktop:
459+
- '.github/workflows/test-build.yml'
460+
- '.github/scripts/stop-session.sh'
461+
- 'apps/desktop/**'
462+
- 'apps/realtime/**'
463+
- 'apps/sim/app/api/copilot/**'
464+
- 'apps/sim/app/api/desktop/**'
465+
- 'apps/sim/app/api/mothership/**'
466+
- 'apps/sim/app/workspace/*/home/**'
467+
- 'apps/sim/lib/auth/**'
468+
- 'apps/sim/lib/desktop/**'
469+
- 'apps/sim/lib/mothership/**'
470+
- 'apps/sim/stores/**'
471+
- 'packages/browser-protocol/**'
472+
- 'packages/db/**'
473+
- 'packages/desktop-bridge/**'
474+
- 'packages/terminal-protocol/**'
475+
- 'bun.lock'
476+
445477
# Desktop tools in the real Electron app against a local app, on its own runner: the
446478
# Electron app, the dev app and its realtime server together outgrow the http-e2e runner.
447479
desktop-live-e2e:
448480
name: Desktop tools against a local app
481+
needs: desktop-live-changes
482+
if: needs.desktop-live-changes.outputs.changed == 'true'
449483
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-latest' }}
450484
timeout-minutes: 30
451485
services:
@@ -518,16 +552,18 @@ jobs:
518552
(cd apps/desktop && bun run scripts/build.ts)
519553
# Each app runs in its own session under an E2E_APP tag, and stop-session.sh returns once
520554
# every process it started has exited.
555+
realtime_tag="desktop-realtime-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
556+
server_tag="desktop-tools-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
521557
(cd apps/realtime && PORT=3023 SIM_DB_ROLE=realtime ALLOWED_ORIGINS="$NEXT_PUBLIC_APP_URL" \
522-
E2E_APP=desktop-realtime exec setsid bun src/index.ts > "$report_dir/desktop-tools-realtime.log" 2>&1) &
558+
E2E_APP="$realtime_tag" exec setsid bun src/index.ts > "$report_dir/desktop-tools-realtime.log" 2>&1) &
523559
realtime_pid=$!
524-
(cd apps/sim && E2E_APP=desktop-tools exec setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
560+
(cd apps/sim && E2E_APP="$server_tag" exec setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
525561
--port 3021 > "$report_dir/desktop-tools-next.log" 2>&1) &
526562
server_pid=$!
527563
finish() {
528564
status=$?
529-
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" desktop-tools || status=1
530-
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$realtime_pid" desktop-realtime || status=1
565+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag" || status=1
566+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$realtime_pid" "$realtime_tag" || status=1
531567
wait "$server_pid" "$realtime_pid" 2>/dev/null || true
532568
exit "$status"
533569
}
@@ -548,7 +584,7 @@ jobs:
548584
SIM_DESKTOP_E2E_REDIS_URL="$REDIS_URL" \
549585
SIM_DESKTOP_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
550586
xvfb-run -a -s '-screen 0 1920x1200x24' bunx playwright test e2e/desktop-tools-live-sim.spec.ts \
551-
--output "$report_dir/desktop-tools-results"
587+
--output "$report_dir/desktop-tools-results" --retries=0
552588
553589
- name: Upload Electron E2E results and server logs
554590
if: failure()

‎apps/desktop/e2e/desktop-tools-live-sim.spec.ts‎

Lines changed: 119 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,12 @@ test.describe('desktop tools against a live Sim', () => {
108108
})
109109

110110
/**
111-
* A dev app compiles each route on its first request, which can outlast the timeouts the
112-
* tests depend on (Electron gives a tool authorization 8 s). One full pass over every route,
113-
* page and client chunk the tests use compiles them all before any timed step.
111+
* A dev app compiles each route on its first request, one at a time, so a route first reached
112+
* mid-test stalls every other request (Electron gives a tool authorization 8 s). The warm-up runs
113+
* the tests' own flows once: a read and an import, a chat switch during a live turn and back,
114+
* Stop, and the login page. It then waits until every request the app made has been answered,
115+
* so no route the tests reach is left to compile. It ends with a marker request
116+
* (`/api/health?e2e=warm-up-done`) so the dev server's log shows anything compiled after it.
114117
*/
115118
async function warmUp(): Promise<void> {
116119
scratch = mkdtempSync(join(tmpdir(), 'sim-desktop-tools-warm-'))
@@ -121,23 +124,31 @@ test.describe('desktop tools against a live Sim', () => {
121124
Cookie: `better-auth.session_token=${user.cookie}`,
122125
Origin: proxy.origin,
123126
}
124-
const compile = (path: string, method: 'GET' | 'POST' = 'GET') =>
127+
const compile = (path: string, method: 'GET' | 'POST' | 'PUT' = 'GET') =>
125128
fetch(new URL(path, sim.upstream), {
126129
method,
127130
headers,
128-
body: method === 'POST' ? '{}' : undefined,
131+
body: method === 'GET' ? undefined : '{}',
129132
redirect: 'manual',
130133
signal: AbortSignal.timeout(COMPILE_MS),
131134
}).then((response) => response.arrayBuffer())
135+
// Routes the tests reach that a warm-up turn alone would not: Stop's, registration, and the
136+
// ones a running app loads in the background.
132137
for (const path of [
133138
chatPath(user, 'Warm chat'),
134139
'/login',
135140
`/api/mothership/chats/${user.chats['Warm other chat']}`,
141+
`/api/mothership/chat/stream?chatId=${user.chats['Warm chat']}`,
136142
`/api/workspaces/${user.workspaceId}/files/folders`,
143+
'/api/copilot/chats',
144+
'/api/users/me/settings',
145+
'/api/auth/oauth/connections',
137146
])
138147
await compile(path)
139148
for (const path of [
140149
'/api/mothership/chat/stop',
150+
'/api/mothership/chat/abort',
151+
'/api/desktop/devices',
141152
'/api/desktop/tool/authorize',
142153
'/api/copilot/confirm',
143154
'/api/files/uploads',
@@ -146,21 +157,49 @@ test.describe('desktop tools against a live Sim', () => {
146157
`/api/workspaces/${user.workspaceId}/files/folders`,
147158
])
148159
await compile(path, 'POST')
160+
await compile('/api/v2/uploads/warm-up', 'PUT')
149161

150162
const file = writeFile(join(scratch, 'warm.txt'), 'warm')
163+
const folder = join(scratch, 'Warm folder')
164+
writeFile(join(folder, 'warm.txt'), 'warm')
165+
let proceed!: () => void
166+
const proceeding = new Promise<void>((resolve) => {
167+
proceed = resolve
168+
})
151169
agent.script(
152170
'[warm-up]',
153-
(turn) => {
171+
async (turn) => {
172+
turn.text('Warming up.')
173+
await proceeding
154174
turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
175+
turn.toolCall({
176+
toolName: 'import_local_files',
177+
args: { path: folder, targetWorkspaceId: user.workspaceId },
178+
})
155179
turn.pause()
156180
},
157181
(_resume, turn) => turn.complete('Warmed up.')
158182
)
183+
agent.script('[warm-stop]', async (turn) => {
184+
turn.text('Stopping soon.')
185+
await new Promise<void>(() => {})
186+
})
159187
const page = await openApp(user, 'Warm chat', COMPILE_MS)
160-
await send(page, '[warm-up] read it', COMPILE_MS)
161-
await expect(page.getByText('Warmed up.')).toBeVisible({ timeout: 2 * COMPILE_MS })
188+
await send(page, '[warm-up] read and import', COMPILE_MS)
189+
await expect(page.getByText('Warming up.')).toBeVisible({ timeout: COMPILE_MS })
190+
// Leaving and reopening a chat while its turn runs re-attaches to the turn's stream.
162191
await openChat(page, user, 'Warm other chat', COMPILE_MS)
163192
await openChat(page, user, 'Warm chat', COMPILE_MS)
193+
proceed()
194+
await expect(page.getByText('Warmed up.')).toBeVisible({ timeout: 2 * COMPILE_MS })
195+
await send(page, '[warm-stop] wait for Stop', COMPILE_MS)
196+
await expect(page.getByText('Stopping soon.')).toBeVisible({ timeout: COMPILE_MS })
197+
await click(page, page.getByRole('button', { name: 'Stop generation' }), COMPILE_MS)
198+
await expect(page.getByRole('button', { name: 'Stop generation' })).toBeHidden({
199+
timeout: COMPILE_MS,
200+
})
201+
await proxy.settled(COMPILE_MS)
202+
await compile('/api/health?e2e=warm-up-done')
164203
} finally {
165204
await app?.close().catch(() => {})
166205
app = undefined
@@ -233,23 +272,22 @@ test.describe('desktop tools against a live Sim', () => {
233272
}
234273

235274
/**
236-
* Sends `message` and waits until the turn reached Sim. A page still hydrating can drop the
237-
* typed text (the Send button only shows for a non-empty message) or the click, so the message
238-
* is typed and sent again only while no turn went out.
275+
* Sends `message` and waits for its turn to reach Sim. Before the page hydrates, typing or the
276+
* click can be lost: the Send button is missing or does nothing and the composer is not emptied,
277+
* and only then is the message typed and sent again. Once the UI takes the message (the
278+
* composer empties after the click), a turn that never reaches Sim is a lost send and fails.
239279
*/
240280
async function send(page: Page, message: string, timeout = 60_000): Promise<void> {
241281
const since = Date.now()
282+
const deadline = since + timeout
242283
const sent = () =>
243284
proxy
244285
.seen(since)
245286
.some((entry) => entry.method === 'POST' && entry.path === '/api/mothership/chat')
246-
const deadline = Date.now() + timeout
247287
while (!sent()) {
248288
if (Date.now() > deadline)
249289
throw new Error(
250-
`The message was never sent: ${message} (composer: ${JSON.stringify(
251-
await composer(page).inputValue()
252-
)}; errors: ${pageErrors.join(' | ')})`
290+
`The UI never took the message: ${message} (errors: ${pageErrors.join(' | ')})`
253291
)
254292
if ((await composer(page).inputValue()) !== message) await composer(page).fill(message)
255293
const clicked = await click(page, page.getByRole('button', { name: 'Send message' }), 5_000)
@@ -258,11 +296,21 @@ test.describe('desktop tools against a live Sim', () => {
258296
if (String(error).includes('Next.js error overlay')) throw error
259297
return false
260298
})
261-
if (clicked)
262-
await expect
263-
.poll(sent, { timeout: 10_000 })
264-
.toBe(true)
265-
.catch(() => {})
299+
if (!clicked) continue
300+
const taken = await expect
301+
.poll(() => composer(page).inputValue(), { timeout: 5_000 })
302+
.toBe('')
303+
.then(
304+
() => true,
305+
() => false
306+
)
307+
if (!taken) continue
308+
await expect
309+
.poll(sent, {
310+
timeout: Math.max(deadline - Date.now(), 30_000),
311+
message: `The UI took the message but its turn never reached Sim: ${message} (errors: ${pageErrors.join(' | ')})`,
312+
})
313+
.toBe(true)
266314
}
267315
}
268316

@@ -407,20 +455,18 @@ test.describe('desktop tools against a live Sim', () => {
407455
.toMatch(/^a\.txt /)
408456
await laterFolder.arrival(ARRIVAL_MS, 'The next folder')
409457

410-
// Hold the import's own report until Stop has settled the call, so the outcome is Stop's.
411-
const report = proxy.hold(isToolReport)
458+
const stoppedAt = Date.now()
412459
await click(page, page.getByRole('button', { name: 'Stop generation' }))
413-
// Stop cancels the import's request in flight, and the import ends and reports.
460+
// Stop cancels the import's request in flight, which ends the import, and records the call as
461+
// cancelled; the stopped import reports nothing that could contest that record.
414462
await expect.poll(() => laterFolder.isAbandoned, { timeout: 15_000 }).toBe(true)
415-
const reported = await report.arrival(ARRIVAL_MS, 'The import’s report')
416-
await expect.poll(async () => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/)
463+
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/)
417464
laterFolder.release()
418-
report.release()
419-
// The late report is answered without overwriting what Stop recorded.
420-
await expect.poll(() => reported.status, { timeout: 30_000 }).toBeDefined()
465+
expect(proxy.seen(stoppedAt).filter((entry) => isToolReport(entry.method, entry.path))).toEqual(
466+
[]
467+
)
421468
const [call] = await db.toolCalls(chatId)
422469
expect(call).toMatchObject({ toolName: 'import_local_files', status: 'cancelled' })
423-
// The import has ended, so nothing after the stopped folder can still arrive.
424470
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
425471
expect(await db.workspaceFileNames(user.workspaceId)).toEqual(['a.txt'])
426472
})
@@ -449,18 +495,21 @@ test.describe('desktop tools against a live Sim', () => {
449495
await signOut.arrival(ARRIVAL_MS, 'The sign-out')
450496
await expect.poll(() => firstUpload.isAbandoned, { timeout: 15_000 }).toBe(true)
451497
const abandonedAt = Date.now()
452-
// The import ended and reported its own failure while the session was still valid.
453-
await expect.poll(async () => callState(chatId), { timeout: 30_000 }).toMatch(/^failed/)
498+
signOut.release()
499+
// The reload into the login page replaces the document, so nothing of the import can run after.
500+
await expect(page).toHaveURL(/\/login/, { timeout: 30_000 })
501+
firstUpload.release()
502+
// Between sign-out and that reload the cancelled import sent nothing more, not even a report.
454503
const toolRequests = proxy
455504
.seen(abandonedAt)
456505
.filter(
457506
(entry) =>
458-
isUploadStart(entry.method, entry.path) || isFolderCreate(entry.method, entry.path)
507+
isUploadStart(entry.method, entry.path) ||
508+
isFolderCreate(entry.method, entry.path) ||
509+
isToolReport(entry.method, entry.path)
459510
)
460511
expect(toolRequests).toEqual([])
461-
signOut.release()
462-
await expect(page).toHaveURL(/\/login/, { timeout: 30_000 })
463-
firstUpload.release()
512+
expect(await callState(chatId)).toMatch(/^running/)
464513
expect(await db.workspaceFileNames(user.workspaceId)).toEqual([])
465514
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
466515
})
@@ -550,16 +599,12 @@ test.describe('desktop tools against a live Sim', () => {
550599
const chatId = user.chats['Stop chat']
551600
// Delivered to Sim on release even should Electron have given up on it meanwhile.
552601
const lateClaim = proxy.hold(isDesktopClaim, { deliverIfAbandoned: true })
553-
// Should Electron give up on the held claim first, the read reports its own failure; hold that
554-
// too, so the call's outcome is Stop's.
555-
const report = proxy.hold(isToolReport)
556602
await send(page, '[stopped-claim] read my secret')
557603
const claim = await lateClaim.arrival(ARRIVAL_MS, 'The read’s claim')
558604
await click(page, page.getByRole('button', { name: 'Stop generation' }))
559605
// Stop settles the call nobody has claimed yet as never started.
560606
await expect.poll(() => callState(chatId), { timeout: 30_000 }).toMatch(/^cancelled/)
561607
lateClaim.release()
562-
report.release()
563608
// The claim held across Stop reaches Sim after it and is refused, and so is a replay of it.
564609
await expect.poll(() => claim.status, { timeout: 15_000 }).toBe(410)
565610
const [call] = await db.toolCalls(chatId)
@@ -603,8 +648,13 @@ test.describe('desktop tools against a live Sim', () => {
603648
}),
604649
signal: AbortSignal.timeout(COMPILE_MS),
605650
})
651+
// Each layer of the dormant executor is checked on its own (soft), so a regression shows
652+
// every layer it reaches: the answer to registration, the device record, the turn's
653+
// binding, the routes the app calls, and the doorbell.
606654
expect(registration.status).toBe(200)
607-
expect(await registration.json()).toMatchObject({ enabled: false })
655+
expect
656+
.soft(await registration.json(), 'registration answer')
657+
.toMatchObject({ enabled: false })
608658
proxy.rewriteChatBody((body) => {
609659
const desktop = toRecord(body.desktopCapabilities)
610660
// The app offers its own install when it speaks the executor protocol; otherwise offer
@@ -630,33 +680,43 @@ test.describe('desktop tools against a live Sim', () => {
630680
const since = Date.now()
631681
const page = await openApp(user, 'Round trip')
632682
await send(page, '[round-trip] what does my plan say?')
633-
await expect(page.getByText('The plan says go.')).toBeVisible({ timeout: 60_000 })
683+
await expect
684+
.soft(page.getByText('The plan says go.'), 'foreground round trip')
685+
.toBeVisible({ timeout: 60_000 })
686+
expect.soft(agent.resultFor(callId)?.success, 'read result').toBe(true)
687+
expect(proxy.rewrittenChatBodies).toBeGreaterThan(0)
634688

635-
expect(agent.resultFor(callId)?.success).toBe(true)
636-
expect(await db.desktopDeviceCount(user.userId)).toBe(0)
637-
const runs = await db.runs(user.chats['Round trip'])
638-
expect(runs.length).toBeGreaterThan(0)
639-
for (const run of runs) expect(run.desktopDeviceId).toBeNull()
640-
const [call] = await db.toolCalls(user.chats['Round trip'])
641-
expect(call).toMatchObject({ toolName: 'read_local_file', status: 'completed' })
642-
expect(call.persistSeq).not.toBeNull()
643-
// From launch on, the app only registers and claims the foreground call: no inbox, doorbell,
644-
// executor claim, lease or completion. It registers once signed out (refused) and again on
645-
// sign-in, which Sim answers as not enabled and does not record.
689+
// The app registers once signed out (refused) and again on sign-in.
646690
const registeredSignedIn = () =>
647691
proxy
648692
.seen(since, '/api/desktop/devices')
649693
.some((entry) => entry.method === 'POST' && entry.status === 200)
650694
await expect.poll(registeredSignedIn, { timeout: 30_000 }).toBe(true)
651-
expect(await db.desktopDeviceCount(user.userId)).toBe(0)
652-
const desktopRequests = proxy.seen(since, '/api/desktop/')
653-
const desktopRoutes = new Set(desktopRequests.map((entry) => entry.path))
654-
expect(desktopRoutes).toContain('/api/desktop/tool/authorize')
695+
expect.soft(await db.desktopDeviceCount(user.userId), 'device records').toBe(0)
696+
697+
const runs = await db.runs(user.chats['Round trip'])
698+
expect(runs.length).toBeGreaterThan(0)
699+
expect
700+
.soft(
701+
runs.map((run) => run.desktopDeviceId),
702+
'turn binding'
703+
)
704+
.toEqual(runs.map(() => null))
705+
const [call] = await db.toolCalls(user.chats['Round trip'])
706+
expect
707+
.soft(call, 'foreground call')
708+
.toMatchObject({ toolName: 'read_local_file', status: 'completed' })
709+
expect.soft(call?.persistSeq, 'persist order').not.toBeNull()
710+
711+
// Only registration and the foreground claim: no inbox, doorbell stream, executor claim,
712+
// lease or completion.
713+
const desktopRoutes = new Set(proxy.seen(since, '/api/desktop/').map((entry) => entry.path))
655714
desktopRoutes.delete('/api/desktop/devices')
656-
expect(desktopRoutes).toEqual(new Set(['/api/desktop/tool/authorize']))
657-
expect(proxy.rewrittenChatBodies).toBeGreaterThan(0)
715+
expect
716+
.soft(desktopRoutes, 'desktop routes the app called')
717+
.toEqual(new Set(['/api/desktop/tool/authorize']))
658718
expect(monitor.lines.length).toBeGreaterThan(0)
659-
expect(monitor.publishesTo('desktop:inbox')).toEqual([])
719+
expect.soft(monitor.publishesTo('desktop:inbox'), 'doorbell').toEqual([])
660720
} finally {
661721
monitor.stop()
662722
}

0 commit comments

Comments
 (0)