@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
44import { dirname , join } from 'node:path'
55import { fileURLToPath } from 'node:url'
66import { _electron as electron , expect , test } from '@playwright/test'
7+ import type { SimDesktopApi } from '@sim/desktop-bridge'
78import { getErrorMessage } from '@sim/utils/errors'
89import { build } from 'esbuild'
910import postcss from 'postcss'
@@ -38,19 +39,33 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
3839 }
3940 const userData = mkdtempSync ( join ( tmpdir ( ) , 'sim-chat-panel-e2e-' ) )
4041 let app : Awaited < ReturnType < typeof electron . launch > > | undefined
42+ const errors : string [ ] = [ ]
43+ let desktopExit : { code : number | null ; signal : string | null } | null = null
44+ let rendererCrashed = false
4145 let passed = false
4246 let javascript = ''
4347 let stylesheet = ''
4448 const server = createServer ( ( request , response ) => {
4549 const path = new URL ( request . url ?? '/' , 'http://localhost' ) . pathname
46- if ( path === '/fixture.js' || path === '/fixture.css' ) {
50+ if ( path === '/page' ) {
51+ response . setHeader ( 'Content-Type' , 'text/html' )
52+ response . end ( '<!doctype html><html><body>Native browser resize fixture</body></html>' )
53+ } else if ( path === '/fixture.js' || path === '/fixture.css' ) {
4754 response . setHeader ( 'Content-Type' , path . endsWith ( '.js' ) ? 'text/javascript' : 'text/css' )
4855 response . end ( path . endsWith ( '.js' ) ? javascript : stylesheet )
4956 } else if ( path . startsWith ( '/api/' ) ) {
5057 response . setHeader ( 'Content-Type' , 'application/json' )
51- response . end ( '{}' )
58+ response . end (
59+ path === '/api/auth/get-session'
60+ ? JSON . stringify ( { user : { id : 'fixture-user' } , session : { id : 'fixture-session' } } )
61+ : '{}'
62+ )
5263 } else {
5364 response . setHeader ( 'Content-Type' , 'text/html' )
65+ response . setHeader (
66+ 'Set-Cookie' ,
67+ 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/'
68+ )
5469 response . end (
5570 '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>'
5671 )
@@ -79,7 +94,7 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
7994 } )
8095 javascript = bundle . outputFiles . find ( ( file ) => file . path . endsWith ( '.js' ) ) ?. text ?? ''
8196 stylesheet = `${ css . css } \n${ bundle . outputFiles . find ( ( file ) => file . path . endsWith ( '.css' ) ) ?. text ?? '' } `
82- await new Promise < void > ( ( resolve ) => server . listen ( 0 , '127.0.0.1' , resolve ) )
97+ await new Promise < void > ( ( resolve ) => server . listen ( 0 , resolve ) )
8398 const address = server . address ( )
8499 if ( ! address || typeof address === 'string' ) throw new Error ( 'Missing fixture address' )
85100 app = await electron . launch ( {
@@ -93,37 +108,65 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
93108 } )
94109 } )
95110 if ( ! app ) throw new Error ( 'Electron did not launch' )
111+ app . process ( ) . once ( 'exit' , ( code , signal ) => {
112+ desktopExit = { code, signal }
113+ } )
96114 const shell = app
97115 const page = await shell . firstWindow ( )
98- const errors : string [ ] = [ ]
99116 page . on ( 'pageerror' , ( error ) => errors . push ( error . message ) )
117+ page . on ( 'crash' , ( ) => {
118+ rendererCrashed = true
119+ } )
100120 await shell . evaluate ( ( { app, BrowserWindow } ) => {
101121 const window = BrowserWindow . getAllWindows ( ) [ 0 ]
102122 // Keep the physical window inside the small displays used by macOS CI.
103123 window . setMinimumSize ( 0 , 0 )
104124 window . setContentSize ( 720 , 400 )
105- window . webContents . setZoomFactor ( 0.5 )
106125 window . webContents . setBackgroundThrottling ( false )
107126 app . focus ( { steal : true } )
108127 window . focus ( )
109128 } )
110129 await page . reload ( )
130+ await shell . evaluate ( ( { app, BrowserWindow } ) => {
131+ const window = BrowserWindow . getAllWindows ( ) [ 0 ]
132+ window . webContents . setZoomFactor ( 0.5 )
133+ app . focus ( { steal : true } )
134+ window . focus ( )
135+ } )
111136 expect ( errors ) . toEqual ( [ ] )
137+ await expect
138+ . poll ( ( ) =>
139+ shell . evaluate ( ( { BrowserWindow } ) => BrowserWindow . getAllWindows ( ) [ 0 ] . isFocused ( ) )
140+ )
141+ . toBe ( true )
112142 await expect . poll ( ( ) => page . evaluate ( ( ) => window . innerWidth ) ) . toBe ( 1440 )
113143 const panel = page . locator ( '[data-mothership-panel]' )
114144 const divider = page . getByRole ( 'separator' , { name : 'Resize resource view' } )
115145 const width = ( ) => panel . evaluate ( ( element ) => element . getBoundingClientRect ( ) . width )
116146 const expectWidth = async ( expected : number ) => {
117- await expect . poll ( async ( ) => Math . abs ( ( await width ( ) ) - expected ) ) . toBeLessThan ( 2 )
147+ await expect . poll ( width ) . toBeCloseTo ( expected , 0 )
118148 }
119- const dragTo = async ( target : number ) => {
149+ const beginDrag = async ( ) => {
150+ await shell . evaluate ( ( { app, BrowserWindow } ) => {
151+ app . focus ( { steal : true } )
152+ BrowserWindow . getAllWindows ( ) [ 0 ] . focus ( )
153+ } )
154+ await expect
155+ . poll ( ( ) =>
156+ shell . evaluate ( ( { BrowserWindow } ) => BrowserWindow . getAllWindows ( ) [ 0 ] . isFocused ( ) )
157+ )
158+ . toBe ( true )
120159 await divider . hover ( { position : { x : 4 , y : 100 } } )
121160 const rect = await panel . boundingBox ( )
122161 if ( ! rect ) throw new Error ( 'Missing panel bounds' )
123162 await page . mouse . down ( )
124163 await expect
125164 . poll ( ( ) => divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) )
126165 . toBe ( true )
166+ return rect
167+ }
168+ const dragTo = async ( target : number ) => {
169+ const rect = await beginDrag ( )
127170 await page . mouse . move ( rect . x + rect . width - target , rect . y + 100 , { steps : 12 } )
128171 await page . mouse . up ( )
129172 await expectWidth ( target )
@@ -195,30 +238,24 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
195238
196239 await check ( 'resizing writes storage only when the gesture ends' , async ( ) => {
197240 const before = await page . evaluate ( ( ) => JSON . stringify ( localStorage ) )
198- const rect = await panel . boundingBox ( )
199- if ( ! rect ) throw new Error ( 'Missing panel bounds' )
200- await page . mouse . move ( rect . x , rect . y + 100 )
201- await page . mouse . down ( )
202- await expect
203- . poll ( ( ) => divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) )
204- . toBe ( true )
241+ const rect = await beginDrag ( )
205242 await page . mouse . move ( rect . x + 100 , rect . y + 100 , { steps : 12 } )
206243 await expectWidth ( 698 )
207244 expect ( await page . evaluate ( ( ) => JSON . stringify ( localStorage ) ) ) . toBe ( before )
208245 await page . mouse . up ( )
209246 expect ( await page . evaluate ( ( ) => JSON . stringify ( localStorage ) ) ) . not . toBe ( before )
210247 } )
211248
212- for ( const interruption of [ 'pointercancel' , 'capture loss' , 'blur' , 'detach' ] as const ) {
249+ for ( const interruption of [
250+ 'pointercancel' ,
251+ 'capture loss' ,
252+ 'blur' ,
253+ 'detach' ,
254+ 'chat switch' ,
255+ ] as const ) {
213256 await check ( `${ interruption } keeps the previous saved width` , async ( ) => {
214257 const before = await page . evaluate ( ( ) => JSON . stringify ( localStorage ) )
215- await divider . hover ( { position : { x : 4 , y : 100 } } )
216- const rect = await panel . boundingBox ( )
217- if ( ! rect ) throw new Error ( 'Missing panel bounds' )
218- await page . mouse . down ( )
219- await expect
220- . poll ( ( ) => divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) )
221- . toBe ( true )
258+ const rect = await beginDrag ( )
222259 await page . mouse . move ( rect . x + 100 , rect . y + 100 , { steps : 12 } )
223260 await expectWidth ( 598 )
224261 if ( interruption === 'pointercancel' ) {
@@ -228,13 +265,21 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
228265 await page . mouse . move ( rect . x + 101 , rect . y + 100 )
229266 } else if ( interruption === 'blur' ) {
230267 await page . evaluate ( ( ) => window . dispatchEvent ( new Event ( 'blur' ) ) )
268+ } else if ( interruption === 'chat switch' ) {
269+ await page
270+ . getByRole ( 'button' , { name : 'organization-chat-a' , exact : true } )
271+ . evaluate ( ( element : HTMLButtonElement ) => element . click ( ) )
272+ await expectWidth ( 560 )
231273 } else {
232274 await page
233275 . getByRole ( 'button' , { name : 'Settings' , exact : true } )
234276 . evaluate ( ( element : HTMLButtonElement ) => element . click ( ) )
235277 await expect ( panel ) . toHaveCount ( 0 )
236278 }
237279 await page . mouse . up ( )
280+ if ( interruption === 'chat switch' ) {
281+ await page . getByRole ( 'button' , { name : 'workspace-chat-b' , exact : true } ) . click ( )
282+ }
238283 if ( interruption === 'detach' ) {
239284 await page . getByRole ( 'button' , { name : 'Back' , exact : true } ) . click ( )
240285 }
@@ -246,13 +291,7 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
246291 await check ( 'a viewport change during a drag clamps the committed display width' , async ( ) => {
247292 await page . getByRole ( 'button' , { name : 'Resize container' } ) . click ( )
248293 await expectWidth ( 520 )
249- await divider . hover ( { position : { x : 4 , y : 100 } } )
250- const rect = await panel . boundingBox ( )
251- if ( ! rect ) throw new Error ( 'Missing panel bounds' )
252- await page . mouse . down ( )
253- await expect
254- . poll ( ( ) => divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) )
255- . toBe ( true )
294+ const rect = await beginDrag ( )
256295 await page . mouse . move ( rect . x + 20 , rect . y + 100 , { steps : 12 } )
257296 await expectWidth ( 500 )
258297 await shell . evaluate ( ( { BrowserWindow } ) =>
@@ -275,12 +314,114 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c
275314 await dragTo ( 580 )
276315 await page . getByRole ( 'button' , { name : 'Switch account' } ) . click ( )
277316 await expectWidth ( 698 )
278- expect ( errors ) . toEqual ( [ ] )
279317 } )
318+ await check ( 'assigning a permanent chat ID lets an active drag finish' , async ( ) => {
319+ await page . getByRole ( 'button' , { name : 'pending:chat' , exact : true } ) . click ( )
320+ await dragTo ( 620 )
321+ const rect = await beginDrag ( )
322+ await page . mouse . move ( rect . x + 100 , rect . y + 100 , { steps : 12 } )
323+ await expectWidth ( 520 )
324+ await page
325+ . getByRole ( 'button' , { name : 'Assign chat ID' , exact : true } )
326+ . evaluate ( ( element : HTMLButtonElement ) => element . click ( ) )
327+ await expect ( page . getByRole ( 'button' , { name : 'Assign chat ID' , exact : true } ) ) . toBeDisabled ( )
328+ expect ( await divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) ) . toBe ( true )
329+ await page . mouse . up ( )
330+ await expectWidth ( 520 )
331+ await page . getByRole ( 'button' , { name : 'workspace-chat-b' , exact : true } ) . click ( )
332+ await expectWidth ( 698 )
333+ await page . getByRole ( 'button' , { name : 'assigned-chat' , exact : true } ) . click ( )
334+ await expectWidth ( 520 )
335+ await page . getByRole ( 'button' , { name : 'workspace-chat-b' , exact : true } ) . click ( )
336+ await expectWidth ( 698 )
337+ } )
338+
339+ await check (
340+ 'cancelling before the first animation frame restores native browser bounds' ,
341+ async ( ) => {
342+ await page . getByRole ( 'button' , { name : 'pending:native' , exact : true } ) . click ( )
343+ await dragTo ( 698 )
344+ await shell . evaluate ( ( { app, BrowserWindow } ) => {
345+ app . focus ( { steal : true } )
346+ BrowserWindow . getAllWindows ( ) [ 0 ] . focus ( )
347+ } )
348+ await expect
349+ . poll ( ( ) =>
350+ shell . evaluate ( ( { BrowserWindow } ) => BrowserWindow . getAllWindows ( ) [ 0 ] . isFocused ( ) )
351+ )
352+ . toBe ( true )
353+ await page . getByRole ( 'button' , { name : 'Start browser' , exact : true } ) . click ( )
354+ const nativeBounds = ( ) =>
355+ shell . evaluate ( ( { BrowserWindow, WebContentsView } ) => {
356+ const view = BrowserWindow . getAllWindows ( ) [ 0 ] . contentView . children . find (
357+ ( child ) =>
358+ child instanceof WebContentsView && child . webContents . getURL ( ) . endsWith ( '/page' )
359+ )
360+ return view ?. getVisible ( ) ? view . getBounds ( ) : null
361+ } )
362+ await expect . poll ( nativeBounds ) . not . toBeNull ( )
363+ const before = await nativeBounds ( )
364+ await beginDrag ( )
365+ await divider . evaluate ( ( element ) => {
366+ const rect = element . getBoundingClientRect ( )
367+ element . dispatchEvent (
368+ new PointerEvent ( 'pointermove' , { pointerId : 1 , clientX : rect . x + 104 , bubbles : true } )
369+ )
370+ element . dispatchEvent ( new PointerEvent ( 'pointercancel' , { pointerId : 1 , bubbles : true } ) )
371+ } )
372+ // The bridge round trip observes main-process geometry after the queued bounds messages.
373+ await page . evaluate ( ( ) =>
374+ (
375+ globalThis as typeof globalThis & { simDesktop : SimDesktopApi }
376+ ) . simDesktop . browserAgent . capturePanelSnapshot ( 'pending:native' )
377+ )
378+ await page . mouse . up ( )
379+ await expectWidth ( 698 )
380+ expect ( await nativeBounds ( ) ) . toEqual ( before )
381+
382+ await check ( 'native predictions follow a chat ID assigned during a drag' , async ( ) => {
383+ if ( ! before ) throw new Error ( 'Missing native browser bounds' )
384+ await beginDrag ( )
385+ await page
386+ . getByRole ( 'button' , { name : 'Assign chat ID' , exact : true } )
387+ . evaluate ( ( element : HTMLButtonElement ) => element . click ( ) )
388+ await expect (
389+ page . getByRole ( 'button' , { name : 'Assign chat ID' , exact : true } )
390+ ) . toBeDisabled ( )
391+ expect ( await divider . evaluate ( ( element ) => element . hasPointerCapture ( 1 ) ) ) . toBe ( true )
392+ await divider . evaluate ( ( element ) => {
393+ const rect = element . getBoundingClientRect ( )
394+ element . dispatchEvent (
395+ new PointerEvent ( 'pointermove' , {
396+ pointerId : 1 ,
397+ clientX : rect . x + 104 ,
398+ bubbles : true ,
399+ } )
400+ )
401+ } )
402+ await page . evaluate ( ( ) =>
403+ (
404+ globalThis as typeof globalThis & { simDesktop : SimDesktopApi }
405+ ) . simDesktop . browserAgent . capturePanelSnapshot ( 'assigned-chat' )
406+ )
407+ expect ( await nativeBounds ( ) ) . toEqual ( {
408+ ...before ,
409+ x : before . x + 50 ,
410+ width : before . width - 50 ,
411+ } )
412+ await page . mouse . up ( )
413+ await expectWidth ( 598 )
414+ } )
415+ }
416+ )
417+ expect ( errors ) . toEqual ( [ ] )
280418 passed = true
281419 } finally {
282420 mkdirSync ( dirname ( reportPath ) , { recursive : true } )
283- writeFileSync ( reportPath , JSON . stringify ( { passed, checks } , null , 2 ) )
421+ writeFileSync (
422+ reportPath ,
423+ JSON . stringify ( { passed, checks, errors, desktopExit, rendererCrashed } , null , 2 )
424+ )
284425 await app ?. close ( )
285426 await new Promise < void > ( ( resolve ) => server . close ( ( ) => resolve ( ) ) )
286427 rmSync ( userData , { recursive : true , force : true } )
0 commit comments