1- import { existsSync , mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs'
1+ import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { dirname , join } from 'node:path'
44import { fileURLToPath } from 'node:url'
55import {
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 {
3233const DESKTOP_DIR = fileURLToPath ( new URL ( '..' , import . meta. url ) )
3334const config = liveSimConfig ( )
3435const 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
3641type 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' && / ^ \/ a p i \/ w o r k s p a c e s \/ [ ^ / ] + \/ f i l e s \/ f o l d e r s $ / . 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 ( / \/ l o g i n / , { 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 ( / \/ l o g i n / , { 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 } )
0 commit comments