@@ -158,8 +158,11 @@ test.describe('desktop shell smoke', () => {
158158 const window = await app . firstWindow ( )
159159 await window . waitForSelector ( '#retry' , { timeout : 30_000 } )
160160 expect ( window . url ( ) ) . toMatch ( / ^ s i m - s h e l l : \/ \/ p a g e s \/ o f f l i n e \. h t m l \? / )
161- await expect ( window . locator ( '.wordmark' ) ) . toBeVisible ( )
162- await expect ( window . locator ( '.wordmark' ) ) . toHaveAttribute ( 'aria-label' , 'Sim' )
161+ await expect ( window . getByRole ( 'img' , { name : 'Sim' , exact : true } ) ) . toBeVisible ( )
162+ await expect ( window . getByRole ( 'img' , { name : 'Sim' , exact : true } ) ) . toHaveAttribute (
163+ 'aria-label' ,
164+ 'Sim'
165+ )
163166 await expect ( window . locator ( '#title' ) ) . toHaveText ( 'Can’t connect to Sim' )
164167 // The recovery path for a self-hosted shell pointed at a server it cannot
165168 // reach. Exercised end to end here because it is the only coverage of the
@@ -173,22 +176,74 @@ test.describe('desktop shell smoke', () => {
173176 await expect
174177 . poll ( ( ) => window . evaluate ( ( ) => document . fonts . check ( '16px "Season Sans"' ) ) )
175178 . toBe ( true )
176- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'height' , '30px' )
177- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'border-radius' , '8px' )
178- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'padding-left' , '8px' )
179- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'font-size' , '14px' )
180- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'line-height' , '20px' )
181- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'text-align' , 'left' )
182- await window . locator ( '#retry' ) . focus ( )
183- await expect ( window . locator ( '#retry' ) ) . toHaveCSS ( 'outline-style' , 'solid' )
184179 await expect ( window . locator ( '#detail' ) ) . toHaveAttribute ( 'role' , 'status' )
185180 } )
186181
182+ test ( 'recovery messages use an isolated EMCN dialog with a safe keyboard default' , async ( ) => {
183+ app = await launchApp ( 'http://127.0.0.1:1' )
184+ const window = await app . firstWindow ( )
185+ await expect ( window . locator ( '#server' ) ) . toBeVisible ( )
186+ const dialogPromise = app . waitForEvent ( 'window' )
187+ await app . evaluate ( ( { BrowserWindow } ) => {
188+ BrowserWindow . getAllWindows ( ) [ 0 ] . webContents . emit ( 'unresponsive' )
189+ } )
190+ const prompt = await dialogPromise
191+ await expect ( prompt . getByRole ( 'dialog' , { name : 'Sim' , exact : true } ) ) . toBeVisible ( )
192+ await expect ( prompt . getByText ( 'Sim isn’t responding' ) ) . toBeVisible ( )
193+ await expect ( prompt . getByRole ( 'button' , { name : 'Wait' , exact : true } ) ) . toBeFocused ( )
194+ await expect
195+ . poll ( ( ) =>
196+ prompt
197+ . getByRole ( 'dialog' )
198+ . evaluate ( ( element ) => element . scrollHeight <= globalThis . innerHeight )
199+ )
200+ . toBe ( true )
201+ await expect
202+ . poll ( ( ) => prompt . evaluate ( ( ) => typeof ( globalThis as { simDesktop ?: unknown } ) . simDesktop ) )
203+ . toBe ( 'undefined' )
204+ await prompt . screenshot ( {
205+ path : test . info ( ) . outputPath ( 'recovery-dialog.png' ) ,
206+ animations : 'disabled' ,
207+ } )
208+ await app . evaluate ( ( { BrowserWindow } ) => {
209+ const win = BrowserWindow . getAllWindows ( ) . find (
210+ ( entry ) => entry . webContents . getURL ( ) === 'sim-shell://pages/dialog.html'
211+ )
212+ if ( ! win ) throw new Error ( 'Recovery dialog is missing' )
213+ win . webContents . ipc . removeHandler ( 'shell:configuration' )
214+ win . webContents . ipc . handle ( 'shell:configuration' , ( ) => ( {
215+ title : 'Long recovery message' ,
216+ message : 'Recovery details' ,
217+ detail : Array . from ( { length : 80 } , ( _ , index ) => `Diagnostic detail ${ index + 1 } ` ) . join (
218+ '\n'
219+ ) ,
220+ type : 'warning' ,
221+ buttons : [ 'Wait' , 'Reload' ] ,
222+ defaultId : 0 ,
223+ cancelId : 0 ,
224+ } ) )
225+ win . webContents . reload ( )
226+ } )
227+ await expect (
228+ prompt . getByRole ( 'dialog' , { name : 'Long recovery message' , exact : true } )
229+ ) . toBeVisible ( )
230+ await expect ( prompt . getByRole ( 'button' , { name : 'Reload' , exact : true } ) ) . toBeInViewport ( )
231+ await expect ( prompt . getByRole ( 'button' , { name : 'Wait' , exact : true } ) ) . toBeFocused ( )
232+ const closed = prompt . waitForEvent ( 'close' )
233+ await prompt
234+ . getByRole ( 'button' , { name : 'Wait' , exact : true } )
235+ . press ( 'Enter' )
236+ . catch ( ( ) => { } )
237+ await closed
238+ await expect ( window . locator ( '#server' ) ) . toBeVisible ( )
239+ } )
240+
187241 // The picker is the only way to repoint a shell whose server is unreachable.
188242 // Its page, the pre-filled value (which crosses the local-page IPC gate) and
189243 // Escape are asserted together because the packaged build once opened it as
190244 // a blank sheet with no way out.
191- test ( 'the offline page opens the server picker, pre-filled, and Escape closes it' , async ( ) => {
245+ test ( 'the offline server picker renders EMCN controls and handles validation and dismissal' , async ( ) => {
246+ const testInfo = test . info ( )
192247 app = await launchApp ( 'http://127.0.0.1:1' )
193248 const window = await app . firstWindow ( )
194249 await window . waitForSelector ( '#server' , { timeout : 30_000 } )
@@ -198,8 +253,42 @@ test.describe('desktop shell smoke', () => {
198253 const picker = await pickerPromise
199254
200255 expect ( picker . url ( ) ) . toBe ( 'sim-shell://pages/server.html' )
201- await expect ( picker . locator ( 'h1' ) ) . toHaveText ( 'Sim server' )
202- await expect ( picker . locator ( '#origin' ) ) . toHaveValue ( 'http://127.0.0.1:1' )
256+ await expect ( picker . getByRole ( 'dialog' , { name : 'Sim server' , exact : true } ) ) . toBeVisible ( )
257+ await expect ( picker . getByLabel ( 'Server URL' ) ) . toHaveValue ( 'http://127.0.0.1:1' )
258+ await expect ( picker . getByLabel ( 'Server URL' ) ) . toBeFocused ( )
259+ await expect
260+ . poll ( ( ) =>
261+ picker
262+ . getByRole ( 'dialog' )
263+ . evaluate ( ( element ) => element . scrollHeight <= globalThis . innerHeight )
264+ )
265+ . toBe ( true )
266+ await picker . getByLabel ( 'Server URL' ) . fill ( 'http://example.com' )
267+ await picker . getByLabel ( 'Server URL' ) . press ( 'Enter' )
268+ await expect ( picker . getByRole ( 'alert' ) ) . toBeVisible ( )
269+ await expect ( picker . getByLabel ( 'Server URL' ) ) . toHaveAttribute ( 'aria-invalid' , 'true' )
270+ await picker . getByLabel ( 'Server URL' ) . fill ( 'http://127.0.0.1:1' )
271+ await expect ( picker . getByRole ( 'alert' ) ) . toHaveCount ( 0 )
272+ await picker . getByRole ( 'button' , { name : 'Connect' , exact : true } ) . click ( )
273+ await expect ( picker . getByRole ( 'status' ) ) . toHaveText ( 'Already connected to this server.' )
274+ await expect
275+ . poll ( ( ) =>
276+ picker
277+ . locator ( '[data-chip-modal-body]' )
278+ . evaluate ( ( element ) => element . scrollHeight <= element . clientHeight )
279+ )
280+ . toBe ( true )
281+ await picker . emulateMedia ( { colorScheme : 'light' } )
282+ await picker . screenshot ( {
283+ path : testInfo . outputPath ( 'server-modal-light.png' ) ,
284+ animations : 'disabled' ,
285+ } )
286+ await picker . emulateMedia ( { colorScheme : 'dark' } )
287+ await expect ( picker . locator ( 'html' ) ) . toHaveClass ( 'dark' )
288+ await picker . screenshot ( {
289+ path : testInfo . outputPath ( 'server-modal-dark.png' ) ,
290+ animations : 'disabled' ,
291+ } )
203292
204293 const closed = picker . waitForEvent ( 'close' )
205294 // The main process destroys the window on the key-down, so the key-up half
0 commit comments