@@ -92,6 +92,7 @@ class ControlServer {
9292 this . connectTimeout = options . connectTimeout ;
9393 this . debugMode = options . debugMode ;
9494
95+ this . launchingBrowsers = new Set ( ) ;
9596 this . browsers = new Map ( ) ;
9697 // Optimization: Prefetch test file in parallel with server creation and browser launching.
9798 //
@@ -263,15 +264,9 @@ class ControlServer {
263264 const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
264265 if ( clientId !== null ) {
265266 // Serve the testfile from any URL path, as chosen by launchBrowser()
266- const browser = this . browsers . get ( clientId ) ;
267- if ( browser ) {
268- browser . logger . debug ( 'browser_connected' , `${ browser . getDisplayName ( ) } connected! Serving test file.` ) ;
269- this . eventbus . emit ( 'clientonline' , {
270- clientId,
271- testFile : this . testFile + this . testFileQueryString ,
272- browserName : browser . browserName ,
273- displayName : browser . getDisplayName ( ) ,
274- } ) ;
267+ if ( this . launchingBrowsers . has ( clientId ) ) {
268+ this . launchingBrowsers . delete ( clientId ) ;
269+ this . eventbus . emit ( 'clientonline' , { clientId } ) ;
275270 } else if ( this . debugMode ) {
276271 // Allow users to reload the page when in --debug mode.
277272 // Note that the results of this reload will not be reported, because
@@ -296,11 +291,6 @@ class ControlServer {
296291 resp . writeHead ( 200 ) ;
297292 resp . write ( testFileResp . body ) ;
298293 resp . end ( ) ;
299-
300- // Count proxying the test file toward connectTimeout, not idleTimeout.
301- if ( browser ) {
302- browser . clientIdleActive = performance . now ( ) ;
303- }
304294 return ;
305295 }
306296
@@ -382,9 +372,95 @@ class ControlServer {
382372 }
383373
384374 async launchBrowser ( browserFn , browserName , globalSignal ) {
385- const clientId = 'client_S' + this . serverId + '_C' + this . nextClientId ++ ;
386- const logger = this . logger . channel ( `qtap_browser_${ clientId } _${ browserName } ` ) ;
375+ const maxTries = ( browserFn . allowRetries === false || this . debugMode ) ? 1 : 3 ;
376+ let i = 1 ;
377+ let browserPromise ;
378+ while ( true ) {
379+ const clientId = 'client_S' + this . serverId + '_C' + this . nextClientId ++ ;
380+ const logger = this . logger . channel ( `qtap_browser_${ clientId } _${ browserName } ` ) ;
381+ const controller = new AbortController ( ) ;
382+ const signals = {
383+ // NOTE: The browser signal tracks both "browser" and "global" controllers,
384+ // so that if qtap.run() bails out (e.g. uncaught error from a reporter, or
385+ // test server fails in fetchTestFile due to file not found), and if for
386+ // some reason the natural shutdown fails (i.e. we don't call
387+ // server.stopBrowsers or don't await browerPromise), then we have one
388+ // last chance during shared_cleanup to stop dangling browser processes.
389+ browser : AbortSignal . any ( [ controller . signal , globalSignal ] ) ,
390+ global : globalSignal
391+ } ;
392+ if ( this . debugMode ) {
393+ // Replace with a dummy signal that we never invoke
394+ //
395+ // TODO: Refactor reporting and process mgmt so that results are reported
396+ // on the CLI as normal. Right now, once the test finishes, the CLI doesn't
397+ // report it because it waits to report until the browser exits. We need
398+ // to create a way to keep report the results yet keep the process open.
399+ signals . browser = ( new AbortController ( ) ) . signal ;
400+ signals . browser . addEventListener ( 'abort' , ( ) => {
401+ browser . logger . warning ( 'browser_signal_debugging' , 'Keeping browser open for debugging' ) ;
402+ } ) ;
403+ }
404+
405+ // TODO: rename browser=>client
406+ const browser = {
407+ clientId,
408+ logger,
409+ browserName,
410+ getDisplayName ( ) {
411+ return browserFn . getDisplayName ( ) ;
412+ } ,
413+ /**
414+ * Reasons to stop a browser, whichever comes first:
415+ * 1. tap-finished (client has sent us the test results).
416+ * 2. tap-parser 'bailout' event (client knows it crashed).
417+ * 3. connect timeout
418+ * 4. idle timeout (client idle and presumed lost, or a silent crash).
419+ *
420+ * @param {any } reason
421+ */
422+ stop : async ( reason ) => {
423+ if ( ! this . browsers . has ( clientId ) ) {
424+ // Ignore any duplicate or late reasons to stop
425+ return ;
426+ }
427+
428+ this . browsers . delete ( clientId ) ;
429+ controller . abort ( reason ) ;
430+ }
431+ } ;
432+ try {
433+ browserPromise = await this . launchBrowserAttempt ( browserFn , browser , signals ) ;
434+ break ;
435+ } catch ( e ) {
436+ // Do not retry uncaught errors from browserFn or client-side bailout,
437+ // which are expected to be deterministic. Only retry BrowserConnectTimeout.
438+ if ( e instanceof util . BrowserConnectTimeout && i < maxTries ) {
439+ i ++ ;
440+ this . logger . debug ( 'browser_connect_retry' , `Retrying, attempt ${ i } of ${ maxTries } ` ) ;
441+ } else {
442+ if ( e instanceof util . QTapError ) {
443+ e . qtapClient = {
444+ browser : browserFn . displayName ,
445+ testFile : this . testFile
446+ } ;
447+ }
448+ throw e ;
449+ }
450+ }
451+ }
452+
453+ await this . getClientResult ( browserPromise ) ;
454+ }
387455
456+ /**
457+ * Launch a browser once and throw if it takes too long to connect.
458+ *
459+ * @param {Function } browserFn
460+ * @param {Object } browser
461+ * @param {Object<string,AbortSignal> } signals
462+ */
463+ async launchBrowserAttempt ( browserFn , browser , signals ) {
388464 const proxyBase = this . getProxyBase ( ) ;
389465 // Serve the a test file from URL that looks like the original path when possible.
390466 //
@@ -402,97 +478,63 @@ class ControlServer {
402478 // (e.g. www.mysite.test/mysite/). That test case depends on the real path.
403479 // https://github.com/WordPress/wordpress-develop/blob/6.7.1/tests/qunit/wp-admin/js/password-strength-meter.js#L100
404480 const tmpUrl = new URL ( this . testFile + this . testFileQueryString , proxyBase ) ;
405- tmpUrl . searchParams . set ( 'qtap_clientId' , clientId ) ;
481+ tmpUrl . searchParams . set ( 'qtap_clientId' , browser . clientId ) ;
406482 const url = proxyBase + tmpUrl . pathname + tmpUrl . search ;
407483
408- const maxTries = ( browserFn . allowRetries === false || this . debugMode ) ? 1 : 3 ;
409- let i = 1 ;
410- while ( true ) {
411- try {
412- const browserPromise = this . launchBrowserAttempt ( browserFn , browserName , globalSignal , clientId , url , logger ) ;
413- const result = await browserPromise ;
414- logger . debug ( 'event_clientresult' , result ) ;
415- this . eventbus . emit ( 'clientresult' , result ) ;
416- return ;
417- } catch ( e ) {
418- // Do not retry uncaught errors from browserFn or client-side bailout,
419- // which are expected to be deterministic. Only retry BrowserConnectTimeout.
420- if ( i >= maxTries || ! ( e instanceof util . BrowserConnectTimeout ) ) {
421- if ( e instanceof util . QTapError ) {
422- e . qtapClient = {
423- browser : browserFn . displayName ,
424- testFile : this . testFile
425- } ;
426- }
427- throw e ;
428- }
484+ const connectTimeoutTimer = setTimeout ( ( ) => {
485+ const reason = `Browser did not start within ${ this . connectTimeout } s` ;
486+ this . launchingBrowsers . delete ( browser . clientId ) ;
487+ browser . stop ( new util . BrowserConnectTimeout ( reason ) ) ;
488+ browser . logger . warning ( 'browser_connect_timeout' , reason ) ;
489+ } , this . connectTimeout * 1000 ) ;
490+
491+ this . on ( 'clientonline' , ( event ) => {
492+ if ( event . clientId === browser . clientId ) {
493+ browser . logger . debug ( 'browser_connected' , `${ browserFn . getDisplayName ( ) } connected! Serving test file.` ) ;
494+ this . launchingBrowsers . delete ( browser . clientId ) ;
495+ clearTimeout ( connectTimeoutTimer ) ;
496+ }
497+ } ) ;
498+
499+ this . launchingBrowsers . add ( browser . clientId ) ;
500+ browser . logger . debug ( 'browser_launch_call' ) ;
501+
502+ try {
503+ await browserFn ( url , signals , browser . logger , this . debugMode ) ;
429504
430- i ++ ;
431- logger . debug ( 'browser_connect_retry' , `Retrying, attempt ${ i } of ${ maxTries } ` ) ;
505+ browser . logger . debug ( 'browser_launch_exit' ) ;
506+ } catch ( /** @type {Error|Object|string } */ e ) {
507+ // Silence any errors from browserFn that happen after we called browser.stop().
508+ if ( signals . browser . aborted ) {
509+ browser . logger . debug ( 'browser_launch_stopped' , String ( e . cause || e ) ) ;
510+ } else {
511+ browser . logger . warning ( 'browser_launch_error' , e ) ;
512+ throw e ;
432513 }
433514 }
434- }
435515
436- async launchBrowserAttempt ( browserFn , browserName , globalSignal , clientId , url , logger ) {
437- const controller = new AbortController ( ) ;
438-
439- const signals = {
440- // NOTE: The browser signal tracks both "browser" and "global" controllers,
441- // so that if qtap.run() bails out (e.g. uncaught error from a reporter, or
442- // test server fails in fetchTestFile due to file not found), and if for
443- // some reason the natural shutdown fails (i.e. we don't call
444- // server.stopBrowsers or don't await browerPromise), then we have one
445- // last chance during shared_cleanup to stop dangling browser processes.
446- browser : AbortSignal . any ( [ controller . signal , globalSignal ] ) ,
447- global : globalSignal
448- } ;
449- if ( this . debugMode ) {
450- // Replace with a dummy signal that we never invoke
451- //
452- // TODO: Refactor reporting and process mgmt so that results are reported
453- // on the CLI as normal. Right now, once the test finishes, the CLI doesn't
454- // report it because it waits to report until the browser exits. We need
455- // to create a way to keep report the results yet keep the process open.
456- signals . browser = ( new AbortController ( ) ) . signal ;
457- signals . browser . addEventListener ( 'abort' , ( ) => {
458- logger . warning ( 'browser_signal_debugging' , 'Keeping browser open for debugging' ) ;
459- } ) ;
516+ if ( signals . browser . aborted ) {
517+ // Throw BrowserConnectTimeout for retry purposes.
518+ throw signals . browser . reason ;
460519 }
520+ }
461521
522+ /**
523+ * Await a browser process and consume its results.
524+ *
525+ * @param {Function } browserFn
526+ * @param {Object } browser
527+ * @param {Object<string,AbortSignal> } signals
528+ */
529+ async getClientResult ( browserPromise , browser , signals , clientId , url , logger ) {
462530 let clientIdleTimer ;
463531
464- const browser = {
465- clientId,
466- logger,
467- clientIdleActive : null ,
468- browserName,
469- getDisplayName ( ) {
470- return browserFn . getDisplayName ( ) ;
471- } ,
472- /**
473- * Reasons to stop a browser, whichever comes first:
474- * 1. tap-finished (client has sent us the test results).
475- * 2. tap-parser 'bailout' event (client knows it crashed).
476- * 3. timeout (client didn't connect, client idle and presumed lost, or a silent crash).
477- *
478- * @param {any } reason
479- */
480- stop : async ( reason ) => {
481- if ( ! this . browsers . has ( clientId ) ) {
482- // Ignore any duplicate or late reasons to stop
483- return ;
484- }
485-
486- clearTimeout ( clientIdleTimer ) ;
487- this . browsers . delete ( clientId ) ;
488- controller . abort ( reason ) ;
489- }
490- } ;
491-
492532 let result ;
493533 const tapParser = tapFinished ( { wait : 0 } , ( finalResult ) => {
534+ clearTimeout ( clientIdleTimer ) ;
535+
494536 result = {
495- clientId,
537+ clientId : browser . clientId ,
496538 ok : finalResult . ok ,
497539 total : finalResult . count ,
498540 // avoid precomputed `finalResult.todo` because that
@@ -557,16 +599,8 @@ class ControlServer {
557599 // Node.js/V8 natively allocating many timers when processing large batches of test results.
558600 // Instead, merely store performance.now() and check that periodically.
559601 const TIMEOUT_CHECK_MS = 100 ;
560- const browserStart = performance . now ( ) ;
561602 const qtapCheckTimeout = ( ) => {
562- if ( ! browser . clientIdleActive ) {
563- if ( ( performance . now ( ) - browserStart ) > ( this . connectTimeout * 1000 ) ) {
564- const reason = `Browser did not start within ${ this . connectTimeout } s` ;
565- logger . warning ( 'browser_connect_timeout' , reason ) ;
566- browser . stop ( new util . BrowserConnectTimeout ( reason ) ) ;
567- return ;
568- }
569- } else {
603+ if ( browser . connected ) {
570604 if ( ( performance . now ( ) - browser . clientIdleActive ) > ( this . idleTimeout * 1000 ) ) {
571605 const reason = `Browser idle for ${ this . idleTimeout } s` ;
572606 logger . warning ( 'browser_idle_timeout' , reason ) ;
@@ -579,12 +613,10 @@ class ControlServer {
579613 clientIdleTimer = setTimeout ( qtapCheckTimeout , TIMEOUT_CHECK_MS ) ;
580614
581615 try {
582- logger . debug ( 'browser_launch_call' ) ;
583-
584- await browserFn ( url , signals , logger , this . debugMode ) ;
616+ await browserPromise ;
585617
586618 // Usually browserFn() will return because we asked via browser.stop(), e.g. tests finished,
587- // bailed, or timed out. In case the browser ended by itself, we call browser.stop() here,
619+ // or timed out. In case the browser ended by itself, we call browser.stop() here,
588620 // so that if we didn't called it before, this will report an error.
589621 // Also, this ensures the signal can clean up any resources created by browserFn.
590622 logger . debug ( 'browser_launch_exit' ) ;
@@ -606,6 +638,8 @@ class ControlServer {
606638 }
607639
608640 return result ;
641+ logger . debug ( 'event_clientresult' , result ) ;
642+ this . eventbus . emit ( 'clientresult' , result ) ;
609643 }
610644
611645 /** @return {string } */
0 commit comments