@@ -157,8 +157,85 @@ class ControlServer {
157157 async launchBrowser ( browserFn , browserName , globalSignal ) {
158158 const clientId = 'client_' + ControlServer . nextClientId ++ ;
159159 const logger = this . logger . channel ( `qtap_browser_${ clientId } _${ browserName } ` ) ;
160- let clientIdleTimer = null ;
161160
161+ // Serve the a test file from URL that looks like the original path when possible.
162+ //
163+ // - For static files, serve it from a URL that matches were it would be among the
164+ // other static files (even though it is treated special).
165+ // "foo/bar" => "/foo/bar"
166+ // "/tmp/foo/bar" => "/tmp/foo/bar"
167+ // - For external URLs, match the URL path, including query params, so that these
168+ // can be seen both server-side and client-side.
169+ //
170+ // NOTE: This is entirely cosmetic. For how the actual fetch, see fetchTestFile().
171+ // For how resources are requested client side, we use <base href> to ensure correctness.
172+ //
173+ // TODO: Add test case to validate the URL resemblance to test file path.
174+ //
175+ // Example: WordPress password-strength-meter.js inspects the hostname and path name
176+ // (e.g. www.mysite.test/mysite/). The test case for defaults observes this.
177+ // https://github.com/WordPress/wordpress-develop/blob/6.7.1/tests/qunit/wp-admin/js/password-strength-meter.js#L100
178+ let qtapUrlPath ;
179+ if ( this . isURL ( this . testFile ) ) {
180+ const tmpUrl = new URL ( this . testFile ) ;
181+ tmpUrl . searchParams . set ( 'qtap_clientId' , clientId ) ;
182+ qtapUrlPath = tmpUrl . pathname + tmpUrl . search ;
183+ } else {
184+ qtapUrlPath = '/' + this . testFile + '?qtap_clientId=' + clientId ;
185+ }
186+ const url = await this . getProxyBase ( ) + qtapUrlPath ;
187+
188+ const maxTries = browserFn . allowRetries === false ? 1 : 3 ;
189+ let i = 1 ;
190+ while ( true ) {
191+ try {
192+ // The 'client' event must be emitted:
193+ // * ... early, so that reporters can indicate the browser is starting.
194+ // * ... exactly once, regardless of retries.
195+ // * ... with the correct display name from browserFn.displayName, which may be set
196+ // dynamically by browserFn() before any async logic, as used by "detect"
197+ // (to expand to the selected browser), and by the BrowserStack plugin
198+ // (to expand chrome_latest).
199+ //
200+ // Separate launchBrowserAttempt() and its browserFn() call from the "await" statement,
201+ // so that we can emit the 'client' event right after calling browserFn.
202+ // For this to work, launchBrowserAttempt() must have no async logic before calling browserFn.
203+ // If we awaited here directly, the event would not be emitted until after the client has
204+ // finished, which defeats its purpose for reporters.
205+ const browserPromise = this . launchBrowserAttempt ( browserFn , browserName , globalSignal , clientId , url , logger ) ;
206+
207+ if ( i === 1 ) {
208+ this . eventbus . emit ( 'client' , {
209+ clientId,
210+ testFile : this . testFile ,
211+ browserName,
212+ displayName : browserFn . getDisplayName ( ) ,
213+ } ) ;
214+ }
215+
216+ const result = await browserPromise ;
217+ this . eventbus . emit ( 'result' , result ) ;
218+ return ;
219+ } catch ( e ) {
220+ // Do not retry for test-specific bail reasons, which are expected to be consistent,
221+ // and unrelated to the browser.
222+ // Only retry for uncaught errors from browserFn, and for BrowserConnectTimeout.
223+ if ( i >= maxTries || e instanceof util . BrowserStopSignal ) {
224+ if ( e instanceof util . BrowserStopSignal || e instanceof util . BrowserConnectTimeout ) {
225+ this . eventbus . emit ( 'bail' , { clientId, reason : e . message } ) ;
226+ return ;
227+ } else {
228+ throw e ;
229+ }
230+ }
231+
232+ i ++ ;
233+ logger . debug ( 'browser_connect_retry' , `Retrying, attempt ${ i } of ${ maxTries } ` ) ;
234+ }
235+ }
236+ }
237+
238+ async launchBrowserAttempt ( browserFn , browserName , globalSignal , clientId , url , logger ) {
162239 const controller = new AbortController ( ) ;
163240 let signal = controller . signal ;
164241 if ( this . debugMode ) {
@@ -168,58 +245,61 @@ class ControlServer {
168245 logger . warning ( 'browser_signal_debugging' , 'Keeping browser open for debugging' ) ;
169246 } ) ;
170247 }
248+ const signals = { browser : signal , global : globalSignal } ;
171249
172- /**
173- * Reasons to stop a browser, whichever comes first:
174- * 1. tap-finished.
175- * 2. tap-parser 'bailout' event (client knows it crashed).
176- * 3. timeout (client didn't connect, client idle and presumed lost, or a silent crashed).
177- *
178- * @param {string } messageCode
179- * @param {string } [reason]
180- * @param {Object } [finalResult]
181- */
182- const stopBrowser = async ( messageCode , reason = '' , finalResult = null ) => {
183- // Ignore any duplicate or late reasons to stop
184- if ( ! this . browsers . has ( clientId ) ) return ;
185-
186- clearTimeout ( clientIdleTimer ) ;
187- this . browsers . delete ( clientId ) ;
188- controller . abort ( `QTap: ${ messageCode } ` ) ;
189-
190- if ( finalResult ) {
191- this . eventbus . emit ( 'result' , {
192- clientId,
193- ok : finalResult . ok ,
194- total : finalResult . count ,
195- // avoid precomputed `finalResult.todo` because that would double-count passing todos
196- passed : finalResult . pass + finalResult . todos . length ,
197- // avoid precomputed `finalResult.fail` because that includes todos (expected failure)
198- failed : finalResult . failures . length ,
199- skips : finalResult . skips ,
200- todos : finalResult . todos ,
201- failures : finalResult . failures ,
202- } ) ;
203- } else {
204- this . eventbus . emit ( 'bail' , {
205- clientId,
206- reason,
207- } ) ;
250+ let clientIdleTimer ;
251+
252+ const browser = {
253+ logger,
254+ clientIdleActive : null ,
255+ getDisplayName ( ) {
256+ return browserFn . getDisplayName ( ) ;
257+ } ,
258+ /**
259+ * Reasons to stop a browser, whichever comes first:
260+ * 1. tap-finished (client has sent us the test results).
261+ * 2. tap-parser 'bailout' event (client knows it crashed).
262+ * 3. timeout (client didn't connect, client idle and presumed lost, or a silent crash).
263+ *
264+ * @param {any } reason
265+ */
266+ stop : async ( reason ) => {
267+ if ( ! this . browsers . has ( clientId ) ) {
268+ // Ignore any duplicate or late reasons to stop
269+ return ;
270+ }
271+
272+ clearTimeout ( clientIdleTimer ) ;
273+ this . browsers . delete ( clientId ) ;
274+ controller . abort ( reason ) ;
208275 }
209276 } ;
210277
278+ let result ;
211279 const tapParser = tapFinished ( { wait : 0 } , ( finalResult ) => {
212- logger . debug ( 'browser_tap_finished' , 'Test run finished, stopping browser' , {
280+ result = {
281+ clientId,
213282 ok : finalResult . ok ,
214283 total : finalResult . count ,
284+ // avoid precomputed `finalResult.todo` because that would double-count passing todos
285+ passed : finalResult . pass + finalResult . todos . length ,
286+ // avoid precomputed `finalResult.fail` because that includes todos (expected failure)
215287 failed : finalResult . failures . length ,
288+ skips : finalResult . skips ,
289+ todos : finalResult . todos ,
290+ failures : finalResult . failures ,
291+ } ;
292+ logger . debug ( 'browser_tap_finished' , 'Test run finished, stopping browser' , {
293+ ok : result . ok ,
294+ total : result . total ,
295+ failed : result . failed ,
216296 } ) ;
217- stopBrowser ( 'browser_tap_finished' , '' , finalResult ) ;
297+ browser . stop ( new util . BrowserStopSignal ( 'browser_tap_finished' ) ) ;
218298 } ) ;
219299
220300 tapParser . on ( 'bailout' , ( reason ) => {
221- logger . warning ( 'browser_tap_bailout' , ` Test run bailed, stopping browser. Reason: ${ reason } ` ) ;
222- stopBrowser ( 'browser_tap_bailout' , reason ) ;
301+ logger . warning ( 'browser_tap_bailout' , ' Test run bailed, stopping browser' ) ;
302+ browser . stop ( new util . BrowserStopSignal ( reason ) ) ;
223303 } ) ;
224304
225305 tapParser . on ( 'comment' , ( comment ) => {
@@ -252,14 +332,7 @@ class ControlServer {
252332 // tapParser.once('fail', () => logger.debug('browser_tap_fail', 'Found one or more failing tests'));
253333 // tapParser.on('plan', logger.debug.bind(logger, 'browser_tap_plan'));
254334
255- const browser = {
256- logger,
257- tapParser,
258- clientIdleActive : null ,
259- getDisplayName ( ) {
260- return ( browserFn . displayName || browserFn . name || browserName || 'Browser' ) . slice ( 0 , 50 ) ;
261- }
262- } ;
335+ browser . tapParser = tapParser ;
263336 this . browsers . set ( clientId , browser ) ;
264337
265338 // Optimization: The naive approach would be to clearTimeout+setTimeout on every tap line,
@@ -271,78 +344,49 @@ class ControlServer {
271344 const qtapCheckTimeout = ( ) => {
272345 if ( ! browser . clientIdleActive ) {
273346 if ( ( performance . now ( ) - browserStart ) > ( this . connectTimeout * 1000 ) ) {
274- logger . warning ( 'browser_connect_timeout' , `Browser did not start within ${ this . connectTimeout } s, stopping browser` ) ;
275- stopBrowser ( 'browser_connect_timeout' , `Browser did not start within ${ this . connectTimeout } s` ) ;
347+ const reason = `Browser did not start within ${ this . connectTimeout } s` ;
348+ logger . warning ( 'browser_connect_timeout' , reason ) ;
349+ browser . stop ( new util . BrowserConnectTimeout ( reason ) ) ;
276350 return ;
277351 }
278352 } else {
279353 if ( ( performance . now ( ) - browser . clientIdleActive ) > ( this . idleTimeout * 1000 ) ) {
280- logger . warning ( 'browser_idle_timeout' , `Browser idle for ${ this . idleTimeout } s, stopping browser` ) ;
281- stopBrowser ( 'browser_idle_timeout' , `Browser idle for ${ this . idleTimeout } s` ) ;
354+ const reason = `Browser idle for ${ this . idleTimeout } s` ;
355+ logger . warning ( 'browser_idle_timeout' , reason ) ;
356+ browser . stop ( new util . BrowserStopSignal ( reason ) ) ;
282357 return ;
283358 }
284359 }
285360 clientIdleTimer = setTimeout ( qtapCheckTimeout , TIMEOUT_CHECK_MS ) ;
286361 } ;
287362 clientIdleTimer = setTimeout ( qtapCheckTimeout , TIMEOUT_CHECK_MS ) ;
288363
289- // Serve the a test file from URL that looks like the original path when possible.
290- //
291- // - For static files, serve it from a URL that matches were it would be among the
292- // other static files (even though it is treated special).
293- // "foo/bar" => "/foo/bar"
294- // "/tmp/foo/bar" => "/tmp/foo/bar"
295- // - For external URLs, match the URL path, including query params, so that these
296- // can be seen both server-side and client-side.
297- //
298- // NOTE: This is entirely cosmetic. For how the actual fetch, see fetchTestFile().
299- // For how resources are requested client side, we use <base href> to ensure correctness.
300- //
301- // TODO: Add test case to validate the URL resemblance to test file path.
302- //
303- // Example: WordPress password-strength-meter.js inspects the hostname and path name
304- // (e.g. www.mysite.test/mysite/). The test case for defaults observes this.
305- // https://github.com/WordPress/wordpress-develop/blob/6.7.1/tests/qunit/wp-admin/js/password-strength-meter.js#L100
306- let qtapUrlPath ;
307- if ( this . isURL ( this . testFile ) ) {
308- const tmpUrl = new URL ( this . testFile ) ;
309- tmpUrl . searchParams . set ( 'qtap_clientId' , clientId ) ;
310- qtapUrlPath = tmpUrl . pathname + tmpUrl . search ;
311- } else {
312- qtapUrlPath = '/' + this . testFile + '?qtap_clientId=' + clientId ;
313- }
314-
315- const url = await this . getProxyBase ( ) + qtapUrlPath ;
316- const signals = { browser : signal , global : globalSignal } ;
317-
318364 try {
319365 logger . debug ( 'browser_launch_call' ) ;
320366
321- // Separate "browserFn()" call from the "await", so that we can emit an event
322- // right after calling it (which may set Browser.displayName). If we awaited here,
323- // then the event would be emitted after the client is done instead of when it starts.
324- const browerPromise = browserFn ( url , signals , logger , this . debugMode ) ;
325- this . eventbus . emit ( 'client' , {
326- clientId,
327- testFile : this . testFile ,
328- browserName,
329- displayName : browser . getDisplayName ( ) ,
330- } ) ;
331- await browerPromise ;
367+ await browserFn ( url , signals , logger , this . debugMode ) ;
332368
333- // This particular stopBrowser () is most likely a no-op ( e.g. we test results
334- // are complete , or there was an error, and we already asked the browser to stop).
335- // In case the browser ended by itself for some other reason, call it again here
336- // so that we can convey it as an error if we didn't ask it to stop .
369+ // Usually browserFn () will return because we asked via browser.stop(), e.g. tests finished,
370+ // bailed , or timed out. In case the browser ended by itself, we call browser. stop() here,
371+ // so that if we didn't called it before, this will report an error.
372+ // Also, this ensures the signal can clean up any resources created by browserFn .
337373 logger . debug ( 'browser_launch_exit' ) ;
338- stopBrowser ( 'browser_launch_exit' , 'Browser ended unexpectedly' ) ;
374+ browser . stop ( new util . BrowserStopSignal ( 'Browser ended unexpectedly' ) ) ;
339375 } catch ( e ) {
376+ // Silence any errors from browserFn that happen after we called browser.stop().
340377 if ( ! signal . aborted ) {
341378 logger . warning ( 'browser_launch_error' , e ) ;
342- stopBrowser ( 'browser_launch_error' , 'Browser ended unexpectedly' ) ;
379+ browser . stop ( new util . BrowserStopSignal ( 'Browser ended unexpectedly' ) ) ;
343380 throw e ;
344381 }
345382 }
383+
384+ if ( ! result && signal . reason ) {
385+ // Throw BrowserConnectTimeout for retry purposes.
386+ throw signal . reason ;
387+ }
388+
389+ return result ;
346390 }
347391
348392 async getTestFile ( clientId ) {
0 commit comments