Skip to content

Commit 7894dac

Browse files
committed
improvement(desktop): warm the live desktop E2E routes and wait on outcomes instead of time
- Compile every route, page and client chunk the tests use in a named warm-up step before any timed hold - Send retries only while no turn reached Sim; every held request and poll is bounded - Stop and sign-out tests wait for the import's own report and assert one exact terminal state - Clicks fail fast with the dev overlay's error instead of a blocked click
1 parent f4c04af commit 7894dac

2 files changed

Lines changed: 186 additions & 36 deletions

File tree

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

Lines changed: 170 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { dirname, join } from 'node:path'
44
import { fileURLToPath } from 'node:url'
55
import {
66
type ElectronApplication,
77
_electron as electron,
88
expect,
9+
type Locator,
910
type Page,
1011
test,
1112
} from '@playwright/test'
@@ -32,6 +33,10 @@ import {
3233
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
3334
const config = liveSimConfig()
3435
const PICKUP_GRACE_MS = 15_000
36+
/** How long a held request may take to arrive once the step that sends it ran. */
37+
const ARRIVAL_MS = 60_000
38+
/** First requests to a route compile it, which takes minutes on a cold dev app. */
39+
const COMPILE_MS = 300_000
3540

3641
type DesktopWindow = typeof globalThis & { simDesktop: SimDesktopApi }
3742

@@ -45,14 +50,18 @@ test.describe('desktop tools against a live Sim', () => {
4550
let db: SimDatabase
4651
let app: ElectronApplication | undefined
4752
let scratch: string
53+
/** Errors the current window reported, shown when a click is blocked. */
54+
let pageErrors: string[] = []
4855

4956
test.beforeAll(async () => {
57+
test.setTimeout(900_000)
5058
if (typeof config === 'string') throw new Error(config)
5159
sim = config
5260
proxy = new SimProxy(sim)
5361
agent = new ScriptedAgent(sim.agentPort)
5462
db = new SimDatabase(sim)
5563
await Promise.all([proxy.start(), agent.start()])
64+
await test.step('warm up the routes the tests use', warmUp)
5665
})
5766

5867
test.afterAll(async () => {
@@ -76,11 +85,72 @@ test.describe('desktop tools against a live Sim', () => {
7685
rmSync(scratch, { recursive: true, force: true })
7786
})
7887

88+
/**
89+
* A dev app compiles each route on its first request, which can outlast the timeouts the
90+
* tests depend on (Electron gives a tool authorization 8 s). One full pass over every route,
91+
* page and client chunk the tests use compiles them all before any timed step.
92+
*/
93+
async function warmUp(): Promise<void> {
94+
scratch = mkdtempSync(join(tmpdir(), 'sim-desktop-tools-warm-'))
95+
try {
96+
const user = await db.seedUser(['Warm chat', 'Warm other chat'])
97+
const headers = {
98+
'Content-Type': 'application/json',
99+
Cookie: `better-auth.session_token=${user.cookie}`,
100+
Origin: proxy.origin,
101+
}
102+
const compile = (path: string, method: 'GET' | 'POST' = 'GET') =>
103+
fetch(new URL(path, sim.upstream), {
104+
method,
105+
headers,
106+
body: method === 'POST' ? '{}' : undefined,
107+
redirect: 'manual',
108+
signal: AbortSignal.timeout(COMPILE_MS),
109+
}).then((response) => response.arrayBuffer())
110+
for (const path of [
111+
chatPath(user, 'Warm chat'),
112+
'/login',
113+
`/api/mothership/chats/${user.chats['Warm other chat']}`,
114+
`/api/workspaces/${user.workspaceId}/files/folders`,
115+
])
116+
await compile(path)
117+
for (const path of [
118+
'/api/mothership/chat/stop',
119+
'/api/desktop/tool/authorize',
120+
'/api/copilot/confirm',
121+
'/api/files/uploads',
122+
'/api/files/uploads/warm-up/parts',
123+
'/api/files/uploads/warm-up/complete',
124+
`/api/workspaces/${user.workspaceId}/files/folders`,
125+
])
126+
await compile(path, 'POST')
127+
128+
const file = writeFile(join(scratch, 'warm.txt'), 'warm')
129+
agent.script(
130+
'[warm-up]',
131+
(turn) => {
132+
turn.toolCall({ toolName: 'read_local_file', args: { path: file } })
133+
turn.pause()
134+
},
135+
(_resume, turn) => turn.complete('Warmed up.')
136+
)
137+
const page = await openApp(user, 'Warm chat', COMPILE_MS)
138+
await send(page, '[warm-up] read it', COMPILE_MS)
139+
await expect(page.getByText('Warmed up.')).toBeVisible({ timeout: COMPILE_MS })
140+
await openChat(page, user, 'Warm other chat', COMPILE_MS)
141+
await openChat(page, user, 'Warm chat', COMPILE_MS)
142+
} finally {
143+
await app?.close().catch(() => {})
144+
app = undefined
145+
rmSync(scratch, { recursive: true, force: true })
146+
}
147+
}
148+
79149
const chatPath = (user: SeededUser, title: string) =>
80150
`/workspace/${user.workspaceId}/chat/${user.chats[title]}`
81151

82152
/** Launches the app signed in as `user`, showing the chat titled `title`. */
83-
async function openApp(user: SeededUser, title: string): Promise<Page> {
153+
async function openApp(user: SeededUser, title: string, timeout = 120_000): Promise<Page> {
84154
app = await electron.launch({
85155
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
86156
cwd: DESKTOP_DIR,
@@ -91,25 +161,71 @@ test.describe('desktop tools against a live Sim', () => {
91161
},
92162
})
93163
const page = await app.firstWindow()
164+
pageErrors = []
165+
page.on('pageerror', (error) => pageErrors.push(error.message))
166+
page.on('console', (message) => {
167+
if (message.type() === 'error') pageErrors.push(message.text())
168+
})
94169
const signIn = new URL('/__e2e/sign-in', proxy.origin)
95170
signIn.searchParams.set('cookie', user.cookie)
96171
signIn.searchParams.set('to', chatPath(user, title))
97-
await page.goto(signIn.toString(), { waitUntil: 'commit', timeout: 180_000 })
98-
await expect(composer(page)).toBeVisible({ timeout: 180_000 })
172+
await page.goto(signIn.toString(), { waitUntil: 'commit', timeout })
173+
await expect(composer(page)).toBeVisible({ timeout })
99174
return page
100175
}
101176

102177
const composer = (page: Page) => page.getByRole('textbox').last()
103178

104-
async function send(page: Page, message: string): Promise<void> {
105-
await composer(page).fill(message)
106-
await page.getByRole('button', { name: 'Send message' }).click()
179+
/**
180+
* Clicks `target`, failing at once with the dev app's error instead of waiting out a click the
181+
* Next.js error overlay intercepts.
182+
*/
183+
async function click(page: Page, target: Locator, timeout = 15_000): Promise<void> {
184+
const overlay = page.locator('nextjs-portal [data-nextjs-dialog]')
185+
const blocked = async () =>
186+
(await overlay.count()) > 0
187+
? `Next.js error overlay: ${await overlay.first().innerText()}\n${pageErrors.join('\n')}`
188+
: undefined
189+
const before = await blocked()
190+
if (before) throw new Error(before)
191+
try {
192+
await target.click({ timeout })
193+
} catch (error) {
194+
throw new Error((await blocked()) ?? String(error))
195+
}
196+
}
197+
198+
/**
199+
* Sends `message` and waits until the turn reached Sim. A page still hydrating can drop the
200+
* typed text or the click, so the message is typed and sent again only while no turn went out.
201+
*/
202+
async function send(page: Page, message: string, timeout = 60_000): Promise<void> {
203+
const since = Date.now()
204+
const sent = () =>
205+
proxy
206+
.seen(since)
207+
.some((entry) => entry.method === 'POST' && entry.path === '/api/mothership/chat')
208+
const deadline = Date.now() + timeout
209+
while (!sent()) {
210+
if (Date.now() > deadline) throw new Error(`The message was never sent: ${message}`)
211+
if ((await composer(page).inputValue()) !== message) await composer(page).fill(message)
212+
await click(page, page.getByRole('button', { name: 'Send message' }))
213+
await expect
214+
.poll(sent, { timeout: 10_000 })
215+
.toBe(true)
216+
.catch(() => {})
217+
}
107218
}
108219

109220
/** Switches chats in-app, the way the sidebar does, without reloading the page. */
110-
async function openChat(page: Page, user: SeededUser, title: string): Promise<void> {
111-
await page.getByRole('link', { name: title }).first().click()
112-
await expect(page).toHaveURL(new RegExp(`${user.chats[title]}$`))
221+
async function openChat(
222+
page: Page,
223+
user: SeededUser,
224+
title: string,
225+
timeout = 30_000
226+
): Promise<void> {
227+
await click(page, page.getByRole('link', { name: title }).first())
228+
await expect(page).toHaveURL(new RegExp(`${user.chats[title]}$`), { timeout })
113229
}
114230

115231
function writeFile(path: string, contents: string): string {
@@ -133,6 +249,9 @@ test.describe('desktop tools against a live Sim', () => {
133249
method === 'POST' && /^\/api\/workspaces\/[^/]+\/files\/folders$/.test(path)
134250
const isDesktopClaim = (method: string, path: string) =>
135251
method === 'POST' && path === '/api/desktop/tool/authorize'
252+
/** A client tool's report of its own result. */
253+
const isToolReport = (method: string, path: string) =>
254+
method === 'POST' && path === '/api/copilot/confirm'
136255

137256
test('a browser call issued after the user switched chats fails as not started after the pickup grace', async () => {
138257
const user = await db.seedUser(['Browser chat', 'Other chat'])
@@ -178,8 +297,10 @@ test.describe('desktop tools against a live Sim', () => {
178297
const page = await openApp(user, 'Read chat')
179298
const claim = proxy.hold(isDesktopClaim)
180299
await send(page, '[leave-read] read my notes')
181-
await claim.reached
182-
await openChat(page, user, 'Other chat')
300+
await claim.arrival(ARRIVAL_MS, 'The read’s claim')
301+
// Electron gives the claim 8 s, so the view change it spans must be quick: the warm-up
302+
// compiled the chat route.
303+
await openChat(page, user, 'Other chat', 5_000)
183304
claim.release()
184305

185306
await agent.waitForResume(() => Boolean(callId && agent.resultFor(callId)), 60_000)
@@ -192,6 +313,7 @@ test.describe('desktop tools against a live Sim', () => {
192313

193314
test('an import keeps running across chat switches, and Stop from the reopened chat ends it', async () => {
194315
const user = await db.seedUser(['Import chat', 'Other chat'])
316+
const chatId = user.chats['Import chat']
195317
const source = importSource()
196318
agent.script('[stop-import]', (turn) => {
197319
turn.toolCall({
@@ -203,7 +325,7 @@ test.describe('desktop tools against a live Sim', () => {
203325
const page = await openApp(user, 'Import chat')
204326
const firstUpload = proxy.hold(isUploadStart)
205327
await send(page, '[stop-import] import my reports')
206-
await firstUpload.reached
328+
await firstUpload.arrival(ARRIVAL_MS, 'The first upload')
207329

208330
await openChat(page, user, 'Other chat')
209331
await openChat(page, user, 'Import chat')
@@ -213,23 +335,31 @@ test.describe('desktop tools against a live Sim', () => {
213335
await expect
214336
.poll(() => db.workspaceFileNames(user.workspaceId), { timeout: 30_000 })
215337
.toContain('a.txt')
216-
await laterFolder.reached
338+
await laterFolder.arrival(ARRIVAL_MS, 'The next folder')
217339

218-
await page.getByRole('button', { name: 'Stop generation' }).click()
219-
// Stop cancels the import's request in flight; nothing after it runs.
340+
// Hold the import's own report until Stop has settled the call, so the outcome is Stop's.
341+
const report = proxy.hold(isToolReport)
342+
await click(page, page.getByRole('button', { name: 'Stop generation' }))
343+
// Stop cancels the import's request in flight, and the import ends and reports.
220344
await expect.poll(() => laterFolder.isAbandoned, { timeout: 15_000 }).toBe(true)
345+
const reported = await report.arrival(ARRIVAL_MS, 'The import’s report')
346+
await expect
347+
.poll(async () => (await db.toolCalls(chatId))[0]?.status, { timeout: 30_000 })
348+
.toBe('cancelled')
221349
laterFolder.release()
222-
await page.waitForTimeout(3_000)
350+
report.release()
351+
// The late report is answered without overwriting what Stop recorded.
352+
await expect.poll(() => reported.status, { timeout: 30_000 }).toBeDefined()
353+
const [call] = await db.toolCalls(chatId)
354+
expect(call).toMatchObject({ toolName: 'import_local_files', status: 'cancelled' })
355+
// The import has ended, so nothing after the stopped folder can still arrive.
223356
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
224357
expect(await db.workspaceFileNames(user.workspaceId)).toEqual(['a.txt'])
225-
// Whichever lands first, the cancelled import's report or Stop's settlement, it never completes.
226-
const [call] = await db.toolCalls(user.chats['Import chat'])
227-
expect(call.toolName).toBe('import_local_files')
228-
expect(['cancelled', 'failed']).toContain(call.status)
229358
})
230359

231360
test('signing out ends a desktop tool still running', async () => {
232361
const user = await db.seedUser(['Import chat'])
362+
const chatId = user.chats['Import chat']
233363
const source = importSource()
234364
agent.script('[sign-out-import]', (turn) => {
235365
turn.toolCall({
@@ -241,27 +371,30 @@ test.describe('desktop tools against a live Sim', () => {
241371
const page = await openApp(user, 'Import chat')
242372
const firstUpload = proxy.hold(isUploadStart)
243373
await send(page, '[sign-out-import] import my reports')
244-
await firstUpload.reached
374+
await firstUpload.arrival(ARRIVAL_MS, 'The first upload')
245375

246376
// The desktop reloads into the login page once signing out completes, which would end any
247377
// tool; holding the sign-out shows the tool ends at sign-out itself, not at that reload.
248378
const signOut = proxy.hold((method, path) => method === 'POST' && path === '/api/auth/sign-out')
249-
await page.getByRole('button', { name: user.name }).click()
250-
await page.getByRole('menuitem', { name: 'Sign out' }).click()
251-
await signOut.reached
379+
await click(page, page.getByRole('button', { name: user.name }))
380+
await click(page, page.getByRole('menuitem', { name: 'Sign out' }))
381+
await signOut.arrival(ARRIVAL_MS, 'The sign-out')
252382
await expect.poll(() => firstUpload.isAbandoned, { timeout: 15_000 }).toBe(true)
253383
const abandonedAt = Date.now()
254-
signOut.release()
255-
await expect(page).toHaveURL(/\/login/, { timeout: 30_000 })
256-
firstUpload.release()
257-
await page.waitForTimeout(3_000)
384+
// The import ended and reported its own failure while the session was still valid.
385+
await expect
386+
.poll(async () => (await db.toolCalls(chatId))[0]?.status, { timeout: 30_000 })
387+
.toBe('failed')
258388
const toolRequests = proxy
259389
.seen(abandonedAt)
260390
.filter(
261391
(entry) =>
262392
isUploadStart(entry.method, entry.path) || isFolderCreate(entry.method, entry.path)
263393
)
264394
expect(toolRequests).toEqual([])
395+
signOut.release()
396+
await expect(page).toHaveURL(/\/login/, { timeout: 30_000 })
397+
firstUpload.release()
265398
expect(await db.workspaceFileNames(user.workspaceId)).toEqual([])
266399
expect(await db.workspaceFolderNames(user.workspaceId)).not.toContain('later')
267400
})
@@ -314,10 +447,9 @@ test.describe('desktop tools against a live Sim', () => {
314447
expect(after).toMatchObject({ status: 'pending', claimedBy: null })
315448
})
316449

317-
test("a terminal command awaiting the user's approval cannot be claimed or run", async () => {
450+
test("a terminal command awaiting the user's approval cannot be claimed", async () => {
318451
const user = await db.seedUser(['Approval chat'])
319-
const touched = join(scratch, 'terminal-ran')
320-
const command = `touch '${touched}'`
452+
const command = `touch '${join(scratch, 'terminal-ran')}'`
321453
agent.script('[unapproved-run]', (turn) => {
322454
turn.toolCall({ toolName: 'terminal', args: { operation: 'run', command } })
323455
turn.pause()
@@ -326,15 +458,16 @@ test.describe('desktop tools against a live Sim', () => {
326458
const chatId = user.chats['Approval chat']
327459
const run = await gatedCall(page, chatId, '[unapproved-run] run a command', 'terminal')
328460

461+
// The guard is the claim: Sim must not hand an unapproved command to the desktop, so the call
462+
// stays pending and unclaimed. Whether a handed-over command then runs depends on a terminal
463+
// being open for the chat, which this test does not set up, so it checks the claim only.
329464
const response = await page.evaluate(
330465
({ toolCallId, command }) =>
331466
(globalThis as DesktopWindow).simDesktop.terminal
332467
.executeTool(toolCallId, 'run', { command }, 'unapproved-e2e')
333468
.catch((error: unknown) => ({ ok: false, error: String(error) })),
334469
{ toolCallId: run.toolCallId, command }
335470
)
336-
await page.waitForTimeout(3_000)
337-
expect(existsSync(touched)).toBe(false)
338471
expect(response).toMatchObject({ ok: false })
339472
const [after] = await db.toolCalls(chatId)
340473
expect(after).toMatchObject({ status: 'pending', claimedBy: null })
@@ -351,8 +484,8 @@ test.describe('desktop tools against a live Sim', () => {
351484
const chatId = user.chats['Stop chat']
352485
const lateClaim = proxy.hold(isDesktopClaim)
353486
await send(page, '[stopped-claim] read my secret')
354-
const claim = await lateClaim.reached
355-
await page.getByRole('button', { name: 'Stop generation' }).click()
487+
const claim = await lateClaim.arrival(ARRIVAL_MS, 'The read’s claim')
488+
await click(page, page.getByRole('button', { name: 'Stop generation' }))
356489
// Stop settles the call nobody has claimed yet as never started.
357490
await expect
358491
.poll(async () => (await db.toolCalls(chatId))[0]?.status, { timeout: 15_000 })
@@ -387,6 +520,7 @@ test.describe('desktop tools against a live Sim', () => {
387520
platform: `${process.platform}-${process.arch}`,
388521
capabilities: { executor: 1, browser: true, terminal: true, localFiles: true },
389522
}),
523+
signal: AbortSignal.timeout(COMPILE_MS),
390524
})
391525
expect(registration.status).toBe(200)
392526
expect(await registration.json()).toMatchObject({ enabled: false })

‎apps/desktop/e2e/fixtures/live-sim.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ class HeldRequest {
129129
})
130130
}
131131

132+
/** The held request, once it arrives; fails if it has not within `timeoutMs`. */
133+
async arrival(timeoutMs: number, label: string): Promise<ProxiedRequest> {
134+
let timer: ReturnType<typeof setTimeout> | undefined
135+
const timeout = new Promise<never>((_, reject) => {
136+
timer = setTimeout(
137+
() => reject(new Error(`${label} did not arrive in ${timeoutMs} ms`)),
138+
timeoutMs
139+
)
140+
})
141+
try {
142+
return await Promise.race([this.reached, timeout])
143+
} finally {
144+
clearTimeout(timer)
145+
}
146+
}
147+
132148
release(): void {
133149
this.released = true
134150
this.releaseHeld?.()

0 commit comments

Comments
 (0)