@@ -8,26 +8,7 @@ import stream from 'node:stream';
88
99import { Parser as TapParser } from 'tap-parser' ;
1010import tapFinished from '@tapjs/tap-finished' ;
11-
12- const MIME_TYPES = {
13- bin : 'application/octet-stream' ,
14- css : 'text/css; charset=utf-8' ,
15- gif : 'image/gif' ,
16- htm : 'text/html; charset=utf-8' ,
17- html : 'text/html; charset=utf-8' ,
18- jpe : 'image/jpeg' ,
19- jpeg : 'image/jpeg' ,
20- jpg : 'image/jpeg' ,
21- js : 'application/javascript; charset=utf-8' ,
22- json : 'application/json; charset=utf-8' ,
23- mjs : 'application/javascript; charset=utf-8' ,
24- png : 'image/png' ,
25- svg : 'image/svg+xml' ,
26- ttf : 'font/sfnt' ,
27- txt : 'text/plain; charset=utf-8' ,
28- woff2 : 'application/font-woff2' ,
29- woff : 'font/woff' ,
30- } ;
11+ import { MIME_TYPES , humanSeconds } from './util.js' ;
3112
3213const QTAP_DEBUG = process . env . QTAP_DEBUG === '1' ;
3314
@@ -51,11 +32,16 @@ class ControlServer {
5132 this . testFile = testFile ;
5233 this . browsers = new Map ( ) ;
5334 this . logger = logger . channel ( 'qtap_server_' + this . constructor . nextServerId ++ ) ;
54- // Optimization: Prefetch test file in parallel with http.Server#listen.
35+ // Optimization: Prefetch test file in parallel with server starting and browser launching.
36+ // Once browsers are launched and they make their first HTTP request,
37+ // we'll await this in handleRequest/getTestFile.
5538 this . testFilePromise = this . fetchTestFile ( this . testFile ) ;
5639
5740 const server = http . createServer ( ) ;
5841 this . proxyBase = null ;
42+
43+ // Optimization: Allow qtap.js to proceed and load browser functions.
44+ // We'll await this later in launchBrowser().
5945 this . proxyBasePromise = new Promise ( ( resolve ) => {
6046 server . on ( 'listening' , ( ) => {
6147 this . proxyBase = 'http://localhost:' + server . address ( ) . port ;
@@ -77,7 +63,7 @@ class ControlServer {
7763 this . handleTap ( req , url , resp ) ;
7864 break ;
7965 default :
80- this . handleStatic ( req , url , resp ) ;
66+ this . handleRequest ( req , url , resp ) ;
8167 }
8268 } catch ( e ) {
8369 this . logger . warning ( 'respond_uncaught' , e ) ;
@@ -113,58 +99,96 @@ class ControlServer {
11399 const controller = new AbortController ( ) ;
114100 const summary = { ok : true } ;
115101
102+ const CLIENT_IDLE_TIMEOUT = 5000 ;
103+ const CLIENT_IDLE_INTERVAL = 1000 ;
104+ let clientIdleTimer = null ;
105+
116106 let readableController ;
117107 const readable = new ReadableStream ( {
118108 start ( readableControllerParam ) {
119109 readableController = readableControllerParam ;
120110 }
121111 } ) ;
122112
123- this . browsers . set ( clientId , {
113+ const browser = {
124114 logger,
125115 readableController,
126- } ) ;
116+ clientIdleActive : performance . now ( ) ,
117+ } ;
118+ this . browsers . set ( clientId , browser ) ;
119+
120+ // NOTE: The below does not need to check browsers.get() before
121+ // calling browsers.delete() or controller.abort() to guard against
122+ // race conditions, because both of these are idempotent and ignore
123+ // all but the first call for a given client.
124+ //
125+ // Stop for these reasons (whichever is first):
126+ // * tap-finished.
127+ // * tap-parser 'bailout' event (client knows it crashed),
128+ // because tap-finished doesn't handle this.
129+ // * timeout after browser has not been idle for too long
130+ // (likely failed to start, lost connection, or crashed unknowingly)
131+
132+ const stopBrowser = async ( reason ) => {
133+ clearTimeout ( clientIdleTimer ) ;
134+ this . browsers . delete ( clientId ) ;
135+ controller . abort ( reason ) ;
136+ } ;
127137
128138 const tapFinishFinder = tapFinished ( { wait : 0 } , ( ) => {
129139 logger . debug ( 'browser_tap_finished' , 'Requesting browser stop' ) ;
130- // Check in case browser already gone (race condition)
131- if ( this . browsers . get ( clientId ) ) {
132- this . browsers . delete ( clientId ) ;
133- controller . abort ( 'QTap: browser_tap_finished' ) ;
134- }
140+
141+ stopBrowser ( 'QTap: browser_tap_finished' ) ;
135142 } ) ;
136143
137- // Also stop on bailout (tap-finished doesn't handle this)
138144 const tapParser = new TapParser ( ) ;
139145 tapParser . on ( 'bailout' , ( reason ) => {
140146 summary . ok = false ;
141147 logger . debug ( 'browser_tap_bailout' , reason ) ;
142148
143- // Check in case browser already gone (race condition)
144- if ( this . browsers . get ( clientId ) ) {
145- this . browsers . delete ( clientId ) ;
146- controller . abort ( 'QTap: browser_tap_bailout' ) ;
147- }
149+ stopBrowser ( 'QTap: browser_tap_bailout' ) ;
148150 } ) ;
149151 tapParser . once ( 'fail' , ( ) => {
150152 summary . ok = false ;
151153 logger . debug ( 'browser_tap_fail' , 'One or more tests failed' ) ;
152154 } ) ;
153-
154155 // Debugging
155156 // tapParser.on('assert', logger.debug.bind(logger, 'browser_tap_assert'));
156157 // tapParser.on('plan', logger.debug.bind(logger, 'browser_tap_plan'));
157158
159+ // TODO: Make timeout configurable, e.g. --timeout=3000s
160+ //
161+ // TODO: Consider handling timeout client-side by setting options.timeout to
162+ // qunit_config_testtimeout in getTestFile() and send a "Bail out!" tap
163+ // message which makes the server stop the browser.
164+ // Pro - timeouts are in sync. Con - we still need a "real" timeout
165+ // on this side.
166+ //
167+ // TODO: Dynamically increase this to whatever timeout the client's test framework
168+ // has set (QUnit, Mocha, Jasmine), with one second tolerance added for delay
169+ // (network/interprocess) between client and QTap.
170+ // We could probably read out smth like QUnit.config.testTimeout
171+ // and collect it via an endpoint like /.qtap/set_timeout?clientId=
172+ //
173+ // NOTE: We use performance.now() instead of natively clearTimeout+setTimeout
174+ // on each event, because that would add significant overhead from Node.js/V8
175+ // creating tons of timers when processing a large batch of test results back-to-back.
176+ clientIdleTimer = setTimeout ( function qtapClientTimeout ( ) {
177+ if ( ( performance . now ( ) - browser . clientIdleActive ) > CLIENT_IDLE_TIMEOUT ) {
178+ logger . debug ( 'browser_idle_timeout' , 'Requesting browser stop' ) ;
179+ // TODO:
180+ // Produce a tap line to report this test failure to CLI output/reporters.
181+ summary . ok = false ;
182+ stopBrowser ( 'QTap: browser_idle_timeout' ) ;
183+ } else {
184+ clientIdleTimer = setTimeout ( qtapClientTimeout , CLIENT_IDLE_INTERVAL ) ;
185+ }
186+ } , CLIENT_IDLE_INTERVAL ) ;
187+
158188 const [ readeableForParser , readableForFinished ] = readable . tee ( ) ;
159189 readeableForParser . pipeTo ( stream . Writable . toWeb ( tapParser ) ) ;
160190 readableForFinished . pipeTo ( stream . Writable . toWeb ( tapFinishFinder ) ) ;
161191
162- // TODO: Implement timeout if stream is quiet for too long
163- // --timeout=3000s
164- // use in getTestFile() as QTAP_TIMEOUT for qunit_config_testtimeout
165- // use in $something to send an error to reporters and force-quit
166- // the browser.
167-
168192 let signal = controller . signal ;
169193 if ( QTAP_DEBUG ) {
170194 // Replace with dummy signal that is never aborted
@@ -282,7 +306,7 @@ class ControlServer {
282306 return html ;
283307 }
284308
285- async handleStatic ( req , url , resp ) {
309+ async handleRequest ( req , url , resp ) {
286310 const filePath = path . join ( this . root , url . pathname ) ;
287311 const ext = path . extname ( url . pathname ) . slice ( 1 ) ;
288312 if ( ! filePath . startsWith ( this . root ) ) {
@@ -328,8 +352,14 @@ class ControlServer {
328352 const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
329353 const browser = this . browsers . get ( clientId ) ;
330354 if ( browser ) {
355+ const now = performance . now ( ) ;
331356 browser . readableController . enqueue ( body ) ;
332- browser . logger . debug ( 'browser_tap_received' , JSON . stringify ( body . slice ( 0 , 30 ) + '…' ) ) ;
357+ browser . logger . debug ( 'browser_tap_received' ,
358+ `+${ humanSeconds ( now - browser . clientIdleActive ) } s` ,
359+ JSON . stringify ( body . slice ( 0 , 30 ) + '…' )
360+ ) ;
361+
362+ browser . clientIdleActive = performance . now ( ) ;
333363 } else {
334364 this . logger . debug ( 'browser_tap_unhandled' , clientId , JSON . stringify ( body . slice ( 0 , 30 ) + '…' ) ) ;
335365 }
0 commit comments