@@ -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 \. t x t / )
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 ( / ^ c a n c e l l e d / )
463+ await expect . poll ( ( ) => callState ( chatId ) , { timeout : 30_000 } ) . toMatch ( / ^ c a n c e l l e d / )
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 ( / ^ f a i l e d / )
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 ( / \/ l o g i n / , { 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 ( / \/ l o g i n / , { timeout : 30_000 } )
463- firstUpload . release ( )
512+ expect ( await callState ( chatId ) ) . toMatch ( / ^ r u n n i n g / )
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 ( / ^ c a n c e l l e d / )
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