1- import { mkdtempSync } from 'node:fs'
1+ import { mkdirSync , mkdtempSync , writeFileSync } from 'node:fs'
22import type { Server } from 'node:http'
33import { createServer } from 'node:http'
44import { tmpdir } from 'node:os'
5- import { join } from 'node:path'
5+ import { dirname , join } from 'node:path'
66import { fileURLToPath } from 'node:url'
77import type { ElectronApplication } from '@playwright/test'
88import { _electron as electron , expect , test } from '@playwright/test'
9+ import { getErrorMessage } from '@sim/utils/errors'
910
1011const DESKTOP_DIR = fileURLToPath ( new URL ( '..' , import . meta. url ) )
1112
@@ -66,6 +67,7 @@ async function launchApp(origin: string): Promise<ElectronApplication> {
6667}
6768
6869test . describe ( 'desktop shell smoke' , ( ) => {
70+ test . describe . configure ( { retries : 0 } )
6971 let server : Server
7072 let origin : string
7173 let app : ElectronApplication
@@ -89,6 +91,197 @@ test.describe('desktop shell smoke', () => {
8991 expect ( window . url ( ) ) . toBe ( `${ origin } /home` )
9092 } )
9193
94+ for ( let iteration = 1 ; iteration <= 10 ; iteration ++ ) {
95+ test ( `Folder Access opens from the focused main window and server picker (${ iteration } )` , async ( ) => {
96+ const checks : {
97+ name : string
98+ status : 'passed' | 'failed'
99+ durationMs : number
100+ shown ?: boolean
101+ closed ?: boolean
102+ callbackFinished ?: boolean
103+ windowBefore ?: unknown
104+ windowAfter ?: unknown
105+ events ?: unknown
106+ parentUrl ?: string | null
107+ error ?: string
108+ } [ ] = [ ]
109+ const reportPath =
110+ process . env . DESKTOP_FOLDER_ACCESS_MENU_REPORT_PATH ??
111+ test . info ( ) . outputPath ( 'folder-access-menu.json' )
112+ for ( const surface of [ 'main window' , 'server picker' ] ) {
113+ const started = Date . now ( )
114+ const check : ( typeof checks ) [ number ] = {
115+ name : `Folder Access opens in the ${ surface } ` ,
116+ status : 'failed' ,
117+ durationMs : 0 ,
118+ }
119+ checks . push ( check )
120+ try {
121+ if ( surface === 'main window' ) {
122+ app = await launchApp ( origin )
123+ const window = await app . firstWindow ( )
124+ await expect ( window . locator ( '#app' ) ) . toHaveText ( 'fixture-app' )
125+ }
126+ const expectedUrl =
127+ surface === 'main window' ? `${ origin } /home` : 'sim-shell://pages/server.html'
128+ if ( surface === 'server picker' ) {
129+ const opened = app . waitForEvent ( 'window' )
130+ await app . evaluate ( ( { Menu } ) => {
131+ const item = Menu . getApplicationMenu ( )
132+ ?. items . flatMap ( ( entry ) => entry . submenu ?. items ?? [ ] )
133+ . find ( ( entry ) => entry . label === 'Server…' )
134+ if ( ! item ) throw new Error ( 'Server menu item missing' )
135+ item . click ( )
136+ } )
137+ const picker = await opened
138+ await expect ( picker ) . toHaveURL ( expectedUrl )
139+ await expect ( picker . getByLabel ( 'Server URL' ) ) . toBeFocused ( )
140+ }
141+ await expect
142+ . poll (
143+ ( ) =>
144+ app . evaluate (
145+ ( { BrowserWindow } , url ) =>
146+ BrowserWindow . getAllWindows ( )
147+ . find ( ( candidate ) => candidate . webContents . getURL ( ) === url )
148+ ?. isVisible ( ) ,
149+ expectedUrl
150+ ) ,
151+ { message : `The ${ surface } must be natively visible before menu interaction` }
152+ )
153+ . toBe ( true )
154+ await app . evaluate ( ( { app, BrowserWindow } , url ) => {
155+ const focused = BrowserWindow . getAllWindows ( ) . find (
156+ ( candidate ) => candidate . webContents . getURL ( ) === url
157+ )
158+ if ( ! focused ) throw new Error ( 'Expected menu owner is missing' )
159+ app . focus ( { steal : true } )
160+ focused . focus ( )
161+ } , expectedUrl )
162+ await expect
163+ . poll ( ( ) =>
164+ app . evaluate ( ( { BrowserWindow } ) =>
165+ BrowserWindow . getFocusedWindow ( ) ?. webContents . getURL ( )
166+ )
167+ )
168+ . toBe ( expectedUrl )
169+ const observation = await app . evaluateHandle ( ( { BrowserWindow, Menu } ) => {
170+ const focused = BrowserWindow . getFocusedWindow ( )
171+ const item = Menu . getApplicationMenu ( )
172+ ?. items . flatMap ( ( entry ) => entry . submenu ?. items ?? [ ] )
173+ . find ( ( entry ) => entry . label === 'Folder Access…' )
174+ if ( ! focused || ! item ) throw new Error ( 'Folder Access menu or focused window missing' )
175+ const state : {
176+ shown : boolean
177+ parentUrl : string | null
178+ closed : boolean
179+ menu : Electron . Menu | null
180+ owner : Electron . BrowserWindow
181+ callbackFinished : boolean
182+ events : { name : string ; at : number } [ ]
183+ windowBefore : unknown
184+ } = {
185+ shown : false ,
186+ parentUrl : null ,
187+ closed : false ,
188+ menu : null ,
189+ owner : focused ,
190+ callbackFinished : false ,
191+ events : [ { name : 'menu-invoked' , at : Date . now ( ) } ] ,
192+ windowBefore : {
193+ visible : focused . isVisible ( ) ,
194+ focused : focused . isFocused ( ) ,
195+ modal : focused . isModal ( ) ,
196+ bounds : focused . getBounds ( ) ,
197+ parentId : focused . getParentWindow ( ) ?. id ?? null ,
198+ url : focused . webContents . getURL ( ) ,
199+ } ,
200+ }
201+ const originalPopup = Menu . prototype . popup
202+ Menu . prototype . popup = function ( options ) {
203+ state . menu = this
204+ const parent = options ?. window
205+ if ( parent instanceof BrowserWindow ) state . parentUrl = parent . webContents . getURL ( )
206+ this . once ( 'menu-will-show' , ( ) => {
207+ state . shown = true
208+ state . events . push ( { name : 'menu-will-show' , at : Date . now ( ) } )
209+ } )
210+ this . once ( 'menu-will-close' , ( ) => {
211+ state . closed = true
212+ state . events . push ( { name : 'menu-will-close' , at : Date . now ( ) } )
213+ } )
214+ originalPopup . call ( this , {
215+ ...options ,
216+ callback : ( ) => {
217+ state . callbackFinished = true
218+ state . events . push ( { name : 'popup-callback' , at : Date . now ( ) } )
219+ options ?. callback ?.( )
220+ } ,
221+ } )
222+ }
223+ try {
224+ item . click ( undefined , focused , focused . webContents )
225+ } finally {
226+ Menu . prototype . popup = originalPopup
227+ }
228+ return state
229+ } )
230+ try {
231+ await expect
232+ . poll ( ( ) => observation . evaluate ( ( { shown, parentUrl } ) => ( { shown, parentUrl } ) ) , {
233+ message : `Folder Access should open in the ${ surface } ` ,
234+ } )
235+ . toEqual ( { shown : true , parentUrl : expectedUrl } )
236+ await observation . evaluate ( ( { menu } ) => menu ?. closePopup ( ) )
237+ await expect
238+ . poll ( ( ) => observation . evaluate ( ( { closed } ) => closed ) , {
239+ message : `Folder Access should close in the ${ surface } ` ,
240+ } )
241+ . toBe ( true )
242+ } finally {
243+ Object . assign (
244+ check ,
245+ await observation . evaluate (
246+ ( { shown, parentUrl, closed, callbackFinished, owner, windowBefore, events } ) => ( {
247+ shown,
248+ parentUrl,
249+ closed,
250+ callbackFinished,
251+ windowBefore,
252+ events,
253+ windowAfter : owner . isDestroyed ( )
254+ ? { destroyed : true }
255+ : {
256+ visible : owner . isVisible ( ) ,
257+ focused : owner . isFocused ( ) ,
258+ modal : owner . isModal ( ) ,
259+ bounds : owner . getBounds ( ) ,
260+ parentId : owner . getParentWindow ( ) ?. id ?? null ,
261+ url : owner . webContents . getURL ( ) ,
262+ } ,
263+ } )
264+ )
265+ )
266+ await observation . evaluate ( ( { menu } ) => menu ?. closePopup ( ) )
267+ await observation . dispose ( )
268+ }
269+ check . status = 'passed'
270+ } catch ( error ) {
271+ check . error = getErrorMessage ( error )
272+ throw error
273+ } finally {
274+ check . durationMs = Date . now ( ) - started
275+ mkdirSync ( dirname ( reportPath ) , { recursive : true } )
276+ writeFileSync ( reportPath , JSON . stringify ( { iteration, checks } , null , 2 ) )
277+ process . stdout . write (
278+ `FOLDER_ACCESS_MENU_REPORT ${ JSON . stringify ( { iteration, checks } ) } \n`
279+ )
280+ }
281+ }
282+ } )
283+ }
284+
92285 test ( 'presents one stock Chrome user agent on every request from the first load' , async ( ) => {
93286 requestUserAgents . length = 0
94287 app = await launchApp ( origin )
0 commit comments