@@ -4,10 +4,12 @@ import fs from 'node:fs';
4
4
import fsPromises from 'node:fs/promises' ;
5
5
import http from 'node:http' ;
6
6
import path from 'node:path' ;
7
+ import { pathToFileURL } from 'node:url' ;
7
8
8
9
import { fnToStr , qtapClientHead , qtapClientBody } from './client.js' ;
9
10
import * as util from './util.js' ;
10
11
import tapFinished from './tap-finished.js' ;
12
+ /** @import events from 'node:events' */
11
13
/** @import { Logger } from './qtap.js' */
12
14
13
15
const QTAP_DEBUG = process . env . QTAP_DEBUG === '1' ;
@@ -19,12 +21,13 @@ class ControlServer {
19
21
/**
20
22
* @param {string|undefined } cwd
21
23
* @param {string } testFile File path or URL
24
+ * @param {events.EventEmitter } eventbus
22
25
* @param {Logger } logger
23
26
* @param {Object } options
24
27
* @param {number|undefined } options.idleTimeout
25
28
* @param {number|undefined } options.connectTimeout
26
29
*/
27
- constructor ( cwd , testFile , logger , options ) {
30
+ constructor ( cwd , testFile , eventbus , logger , options ) {
28
31
this . logger = logger . channel ( 'qtap_server_' + ControlServer . nextServerId ++ ) ;
29
32
30
33
// For `qtap <url>`, default root to cwd (unused).
@@ -50,11 +53,24 @@ class ControlServer {
50
53
if ( ! testFile || testFile . startsWith ( '..' ) ) {
51
54
throw new Error ( `Cannot serve ${ testFile } from ${ root } ` ) ;
52
55
}
56
+ // Normalize \backslash to POSIX slash, but only on Windows
57
+ // * To use as-is in URLs (launchBrowser/qtapUrlPath).
58
+ // * Stable values in reporter output text.
59
+ // * Stable values in event data.
60
+ // * Only on Windows (pathToFileURL chooses automatically),
61
+ // because on POSIX, backslash is a valid character to use in
62
+ // in a file name, which we must not replace with forward slash.
63
+ const rootUrlPathname = pathToFileURL ( root ) . pathname ;
64
+ const fileUrlPathname = pathToFileURL ( testFileAbsolute ) . pathname ;
65
+ testFile = fileUrlPathname
66
+ . replace ( rootUrlPathname , '' )
67
+ . replace ( / ^ \/ + / , '' ) ;
53
68
this . logger . debug ( 'server_testfile_normalized' , testFile ) ;
54
69
}
55
70
56
71
this . root = root ;
57
72
this . testFile = testFile ;
73
+ this . eventbus = eventbus ;
58
74
this . idleTimeout = options . idleTimeout || 30 ;
59
75
this . connectTimeout = options . connectTimeout || 60 ;
60
76
@@ -116,17 +132,18 @@ class ControlServer {
116
132
/** @return {Promise<string> } HTML */
117
133
async fetchTestFile ( file ) {
118
134
// fetch() does not yet support file URLs (as of Node.js 21).
119
- return this . isURL ( file )
120
- ? ( await ( await fetch ( file ) ) . text ( ) )
121
- : ( await fsPromises . readFile ( file ) ) . toString ( ) ;
135
+ if ( this . isURL ( file ) ) {
136
+ this . logger . debug ( 'testfile_fetch' , `Requesting a copy of ${ file } ` ) ;
137
+ return await ( await fetch ( file ) ) . text ( ) ;
138
+ } else {
139
+ this . logger . debug ( 'testfile_read' , `Reading file contents from ${ file } ` ) ;
140
+ return ( await fsPromises . readFile ( file ) ) . toString ( ) ;
141
+ }
122
142
}
123
143
124
144
async launchBrowser ( browserFn , browserName , globalSignal ) {
125
145
const clientId = 'client_' + ControlServer . nextClientId ++ ;
126
146
const logger = this . logger . channel ( `qtap_browser_${ clientId } _${ browserName } ` ) ;
127
-
128
- // TODO: Remove `summary` in favour of `eventbus`
129
- const summary = { ok : true } ;
130
147
let clientIdleTimer = null ;
131
148
132
149
const controller = new AbortController ( ) ;
@@ -139,28 +156,49 @@ class ControlServer {
139
156
} ) ;
140
157
}
141
158
142
- // Reasons to stop a browser, whichever comes first:
143
- // 1. tap-finished.
144
- // 2. tap-parser 'bailout' event (client knows it crashed).
145
- // 3. timeout (client didn't start, lost connection, or unknowingly crashed).
146
- const stopBrowser = async ( messageCode ) => {
159
+ /**
160
+ * Reasons to stop a browser, whichever comes first:
161
+ * 1. tap-finished.
162
+ * 2. tap-parser 'bailout' event (client knows it crashed).
163
+ * 3. timeout (client didn't start, lost connection, or unknowingly crashed).
164
+ *
165
+ * @param {string } messageCode
166
+ * @param {string } [problem]
167
+ * @param {Object } [result]
168
+ */
169
+ const stopBrowser = async ( messageCode , problem = '' , result = null ) => {
147
170
// Ignore any duplicate or late reasons to stop
148
171
if ( ! this . browsers . has ( clientId ) ) return ;
149
172
150
173
clearTimeout ( clientIdleTimer ) ;
151
174
this . browsers . delete ( clientId ) ;
152
175
controller . abort ( `QTap: ${ messageCode } ` ) ;
176
+
177
+ if ( result ) {
178
+ this . eventbus . emit ( 'clientresult' , {
179
+ clientId,
180
+ result
181
+ } ) ;
182
+ } else {
183
+ this . eventbus . emit ( 'clientbail' , { clientId, reason : problem } ) ;
184
+ }
153
185
} ;
154
186
155
- const tapParser = tapFinished ( { wait : 0 } , ( ) => {
156
- logger . debug ( 'browser_tap_finished' , 'Test run finished, stopping browser' ) ;
157
- stopBrowser ( 'browser_tap_finished' ) ;
187
+ const tapParser = tapFinished ( { wait : 0 } , ( finalResult ) => {
188
+ const result = {
189
+ ok : finalResult . ok ,
190
+ total : finalResult . count ,
191
+ passed : finalResult . pass ,
192
+ failed : finalResult . failures . length
193
+ // Tests completed, X passed [, X failed ] [, X skipped ].
194
+ } ;
195
+ logger . debug ( 'browser_tap_finished' , 'Test run finished, stopping browser' , result ) ;
196
+ stopBrowser ( 'browser_tap_finished' , '' , result ) ;
158
197
} ) ;
159
198
160
199
tapParser . on ( 'bailout' , ( reason ) => {
161
200
logger . warning ( 'browser_tap_bailout' , `Test run bailed, stopping browser. Reason: ${ reason } ` ) ;
162
- summary . ok = false ;
163
- stopBrowser ( 'browser_tap_bailout' ) ;
201
+ stopBrowser ( 'browser_tap_bailout' , reason ) ;
164
202
} ) ;
165
203
// Debugging
166
204
// tapParser.on('line', logger.debug.bind(logger, 'browser_tap_line'));
@@ -183,21 +221,19 @@ class ControlServer {
183
221
// Node.js/V8 natively allocating many timers when processing large batches of test results.
184
222
// Instead, merely store performance.now() and check that periodically.
185
223
// TODO: Write test for --connect-timeout by using a no-op browser.
186
- const TIMEOUT_CHECK_MS = 1000 ;
224
+ const TIMEOUT_CHECK_MS = 100 ;
187
225
const browserStart = performance . now ( ) ;
188
226
const qtapCheckTimeout = ( ) => {
189
227
if ( ! browser . clientIdleActive ) {
190
228
if ( ( performance . now ( ) - browserStart ) > ( this . connectTimeout * 1000 ) ) {
191
229
logger . warning ( 'browser_connect_timeout' , `Browser did not start within ${ this . connectTimeout } s, stopping browser` ) ;
192
- summary . ok = false ;
193
- stopBrowser ( 'browser_connect_timeout' ) ;
230
+ stopBrowser ( 'browser_connect_timeout' , `Browser did not start within ${ this . connectTimeout } s` ) ;
194
231
return ;
195
232
}
196
233
} else {
197
234
if ( ( performance . now ( ) - browser . clientIdleActive ) > ( this . idleTimeout * 1000 ) ) {
198
235
logger . warning ( 'browser_idle_timeout' , `Browser idle for ${ this . idleTimeout } s, stopping browser` ) ;
199
- summary . ok = false ;
200
- stopBrowser ( 'browser_idle_timeout' ) ;
236
+ stopBrowser ( 'browser_idle_timeout' , `Browser idle for ${ this . idleTimeout } s` ) ;
201
237
return ;
202
238
}
203
239
}
@@ -228,34 +264,37 @@ class ControlServer {
228
264
tmpUrl . searchParams . set ( 'qtap_clientId' , clientId ) ;
229
265
qtapUrlPath = tmpUrl . pathname + tmpUrl . search ;
230
266
} else {
231
- qtapUrlPath = '/'
232
- + ( this . testFile
233
- . replace ( / ^ \/ + / , '' )
234
- // TODO: Add test case to confirm Windows-file to POSIX-URL normalization.
235
- . replace ( / \\ / g, '/' )
236
- )
237
- + '?qtap_clientId=' + clientId ;
267
+ qtapUrlPath = '/' + this . testFile + '?qtap_clientId=' + clientId ;
238
268
}
239
269
240
270
const url = await this . getProxyBase ( ) + qtapUrlPath ;
241
271
const signals = { browser : signal , global : globalSignal } ;
242
272
243
273
try {
244
274
logger . debug ( 'browser_launch_call' ) ;
245
- await browserFn ( url , signals , logger ) ;
275
+
276
+ // Separate calling browserFn() from awaiting so that we can emit 'createclient'
277
+ // right after calling it (which may set Browser.displayName). If we await here,
278
+ // the event would be emitted when the client is done instead of when it starts.
279
+ const browerPromise = browserFn ( url , signals , logger ) ;
280
+ this . eventbus . emit ( 'clientcreate' , {
281
+ testFile : this . testFile ,
282
+ clientId,
283
+ browserName,
284
+ displayName : browser . getDisplayName ( )
285
+ } ) ;
286
+ await browerPromise ;
246
287
247
288
// This stopBrowser() is most likely a no-op (e.g. if we received test results
248
289
// or some error, and we asked the browser to stop). Just in case the browser
249
290
// ended by itself, call it again here so that we can convey it as an error
250
291
// if it was still running from our POV.
251
292
logger . debug ( 'browser_launch_exit' ) ;
252
- stopBrowser ( 'browser_launch_exit' ) ;
293
+ stopBrowser ( 'browser_launch_exit' , 'Browser ended unexpectedly' ) ;
253
294
} catch ( e ) {
254
- // TODO: Report error to TAP. Eg. "No executable found"
255
- // TODO: logger.warning('browser_launch_exit', err); but catch in qtap.js?
256
295
if ( ! signal . aborted ) {
257
296
logger . warning ( 'browser_launch_error' , e ) ;
258
- stopBrowser ( 'browser_launch_error' ) ;
297
+ stopBrowser ( 'browser_launch_error' , 'Browser ended unexpectedly' ) ;
259
298
throw e ;
260
299
}
261
300
}
@@ -267,7 +306,6 @@ class ControlServer {
267
306
268
307
let headInjectHtml = `<script>(${ fnToStr ( qtapClientHead , qtapUrl ) } )();</script>` ;
269
308
270
- // Add <base> tag so that /test/index.html can refer to files relative to it,
271
309
// and URL-based files can fetch their resources directly from the original server.
272
310
// * Prepend as early as possible. If the file has its own <base>, theirs will
273
311
// come later and correctly "win" by applying last (after ours).
@@ -277,6 +315,7 @@ class ControlServer {
277
315
}
278
316
279
317
let html = await this . testFilePromise ;
318
+ this . logger . debug ( 'testfile_ready' , `Finished fetching ${ this . testFile } ` ) ;
280
319
281
320
// Head injection
282
321
// * Use a callback, to avoid accidental $1 substitutions via user input.
@@ -319,6 +358,7 @@ class ControlServer {
319
358
if ( browser ) {
320
359
browser . clientIdleActive = performance . now ( ) ;
321
360
browser . logger . debug ( 'browser_connected' , `${ browser . getDisplayName ( ) } connected! Serving test file.` ) ;
361
+ this . eventbus . emit ( 'clientonline' , { clientId } ) ;
322
362
} else {
323
363
this . logger . debug ( 'respond_static_testfile' , clientId ) ;
324
364
}
@@ -355,6 +395,7 @@ class ControlServer {
355
395
const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
356
396
const browser = this . browsers . get ( clientId ) ;
357
397
if ( browser ) {
398
+ this . eventbus . emit ( 'clientsample' , { clientId, line : body . trimEnd ( ) . split ( '\n' ) . pop ( ) } ) ;
358
399
const now = performance . now ( ) ;
359
400
browser . logger . debug ( 'browser_tap_received' ,
360
401
`+${ util . humanSeconds ( now - browser . clientIdleActive ) } s` ,
@@ -369,66 +410,6 @@ class ControlServer {
369
410
} ) ;
370
411
resp . writeHead ( 204 ) ;
371
412
resp . end ( ) ;
372
-
373
- // TODO: Pipe to one of two options, based on --reporter:
374
- // - [tap | default in piped and CI?]: tap-parser + some kind of renumbering or prefixing.
375
- // client_1> ok 40 foo > bar
376
- // out> ok 1 - qtap > Firefox (client_1) connected! Running test/index.html.
377
- // out> ok 42 - foo > bar
378
- // -->
379
- // client_1> ok 40 foo > bar
380
- // client_2> ok 40 foo > bar
381
- // out> ok 1 - qtap > Firefox (client_1) connected! Running test/index.html.
382
- // out> ok 2 - qtap > Chromium (client_2) connected! Running test/index.html.
383
- // out> ok 81 - foo > bar [Firefox client_1]
384
- // out> ok 82 - foo > bar [Chromium client_2]
385
- // - [minimal|default in interactive mode]
386
- // out> Testing /test/index.html
387
- // out>
388
- // out> Firefox : SPINNER [blue]
389
- // out> Running test 40.
390
- // out> [Chromium] : [grey] [star] [grey] Launching...
391
- // out> [Safari] : [grey] [star] [grey] Launching...
392
- // -->
393
- // out> Testing /test/index.html
394
- // out>
395
- // out> [Firefox client_1]: ✔ [green] Completed 123 tests in 42ms.
396
- // out> [Chromium client2]: [blue*spinner] Running test 40.
397
- // out> [Safari client_3] [grey] [star] [grey] Launching...
398
- // -->
399
- // out> Testing /test/index.html
400
- // out>
401
- // out> not ok 40 foo > bar # Chromium client_2
402
- // out> ---
403
- // out> message: failed
404
- // out> actual : false
405
- // out> expected: true
406
- // out> stack: |
407
- // out> @/example.js:46:12
408
- // out> ...
409
- // out>
410
- // out> [Firefox client_1]: ✔ [green] Completed 123 tests in 42ms.
411
- // out> [Chromium client_2]: ✘ [red] 2 failures.
412
- //
413
- // If minimal is selected explicilty in piped/non-interactive/CI mode,
414
- // then it will have no spinners, and also lines won't overwrite each other.
415
- // Test counting will be disabled along with the spinner so instead we'll print:
416
- // out> Firefox client_1: Launching...
417
- // out> Firefox client_1: Running tests... [= instead of spinner/counter]
418
- // out> Firefox client_1: Completed 123 tets in 42ms.
419
-
420
- // "▓", "▒", "░" // noise, 100
421
- // "㊂", "㊀", "㊁" // toggle10, 100
422
- // await new Promise(r=>setTimeout(r,100)); process.stdout.write('\r' + frames[i % frames.length] + ' ');
423
- // writable.isTTY
424
- // !process.env.CI
425
-
426
- // Default: TAP where each browser is 1 virtual test in case of success.
427
- // Verbose: TAP forwarded, test names prepended with [browsername].
428
- // Failures are shown either way, with prepended names.
429
- // TODO: On "runEnd", report runtime
430
- // Default: No-op, as overall TAP line as single test (above) can contain runtime
431
- // Verbose: Output comment indicatinh browser done, and test runtime.
432
413
}
433
414
434
415
/**
0 commit comments