@@ -34,7 +34,7 @@ class ControlServer {
3434 let root = options . cwd ;
3535 let testFileAbsolute ;
3636 let testFileQueryString = '' ;
37- if ( this . isURL ( testFile ) ) {
37+ if ( util . isURL ( testFile ) ) {
3838 testFileAbsolute = testFile ;
3939 } else {
4040 // For `qtap ../foobar/test/index.html`, default root to ../foobar.
@@ -153,25 +153,181 @@ class ControlServer {
153153
154154 /** @return {Promise<Object> } Headers and HTML document */
155155 async fetchTestFile ( file ) {
156+ let headers , body ;
157+
156158 // fetch() does not support file URLs (as of writing, in Node.js 22).
157- if ( this . isURL ( file ) ) {
158- this . logger . debug ( 'testfile_fetch' , `Requesting a copy of ${ file } ` ) ;
159+ if ( util . isURL ( file ) ) {
160+ this . logger . debug ( 'testfile_fetch' , `Requesting ${ file } ` ) ;
159161 const resp = await fetch ( file ) ;
160162 if ( ! resp . ok ) {
161163 throw new Error ( 'Remote URL responded with HTTP ' + resp . status ) ;
162164 }
163- return {
164- // TODO: Write a test to confirm that we preserve response headers
165- headers : resp . headers ,
166- body : await resp . text ( )
167- } ;
165+ // TODO: Write a test to confirm that we preserve response headers
166+ headers = resp . headers ;
167+ body = await resp . text ( ) ;
168168 } else {
169- this . logger . debug ( 'testfile_read' , `Reading file contents from ${ file } ` ) ;
170- return {
171- headers : new Headers ( ) ,
172- body : ( await fsPromises . readFile ( file ) ) . toString ( )
173- } ;
169+ this . logger . debug ( 'testfile_read' , `Reading file ${ file } ` ) ;
170+ headers = new Headers ( ) ;
171+ body = ( await fsPromises . readFile ( file ) ) . toString ( ) ;
172+ }
173+
174+ this . logger . debug ( 'testfile_ready' , `Finished fetching ${ file } ` ) ;
175+ return { headers, body } ;
176+ }
177+
178+ async getTestFile ( clientId ) {
179+ const proxyBase = await this . getProxyBase ( ) ;
180+ const qtapTapUrl = proxyBase + '/.qtap/tap/?qtap_clientId=' + clientId ;
181+
182+ let headInjectHtml = `<script>(${ util . fnToStr ( qtapClientHead , qtapTapUrl ) } )();</script>` ;
183+
184+ // Add <base> tag so that URL-based files can fetch their resources directly from the
185+ // original server. Prepend as early as possible. If the file has its own <base>, theirs
186+ // will be after ours and correctly "win" by applying last.
187+ if ( util . isURL ( this . testFile ) ) {
188+ headInjectHtml = `<base href="${ util . escapeHTML ( this . testFile ) } "/>` + headInjectHtml ;
189+ }
190+
191+ let resp ;
192+ let html ;
193+ try {
194+ resp = await this . testFilePromise ;
195+ html = resp . body ;
196+ } catch ( e ) {
197+ // @ts -ignore - TypeScript @types/node lacks `Error(,options)`
198+ throw new Error ( 'Could not open ' + this . testFile , { cause : e } ) ;
199+ }
200+
201+ // Head injection
202+ // * Use a callback, to avoid corruption if "$1" appears in the user input.
203+ // * The headInjectHtml string must be one line without any line breaks,
204+ // so that line numbers in stack traces presented in QTap output remain
205+ // transparent and correct.
206+ // * Ignore <heading> and <head-thing>.
207+ // * Support <head x=y...>, including with tabs or newlines before ">".
208+ html = util . replaceOnce ( html ,
209+ [
210+ / < h e a d (?: \s [ ^ > ] * ) ? > / i,
211+ / < h t m l (?: \s [ ^ > ] * ) ? > / i,
212+ / < ! d o c t y p e [ ^ > ] * > / i,
213+ / ^ /
214+ ] ,
215+ ( m ) => m + headInjectHtml
216+ ) ;
217+
218+ html = util . replaceOnce ( html ,
219+ [
220+ / < \/ b o d y > (? ! [ \s \S ] * < \/ b o d y > ) / i,
221+ / < \/ h t m l > (? ! [ \s \S ] * < \/ h t m l > ) / i,
222+ / $ /
223+ ] ,
224+ ( m ) => '<script>(' + util . fnToStr ( qtapClientBody , qtapTapUrl ) + ')();</script>' + m
225+ ) ;
226+
227+ return {
228+ headers : resp . headers ,
229+ body : html
230+ } ;
231+ }
232+
233+ async handleRequest ( req , url , resp ) {
234+ const filePath = path . join ( this . root , url . pathname ) ;
235+ const ext = path . extname ( url . pathname ) . slice ( 1 ) ;
236+ if ( ! filePath . startsWith ( this . root ) ) {
237+ // Disallow outside directory traversal
238+ this . logger . debug ( 'respond_static_deny' , url . pathname ) ;
239+ return this . serveError ( resp , 403 , 'HTTP 403: QTap respond_static_deny' ) ;
240+ }
241+
242+ const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
243+ if ( clientId !== null ) {
244+ // Serve the testfile from any URL path, as chosen by launchBrowser()
245+ const browser = this . browsers . get ( clientId ) ;
246+ if ( browser ) {
247+ browser . logger . debug ( 'browser_connected' , `${ browser . getDisplayName ( ) } connected! Serving test file.` ) ;
248+ this . eventbus . emit ( 'online' , { clientId } ) ;
249+ } else if ( this . debugMode ) {
250+ // Allow users to reload the page when in --debug mode.
251+ // Note that do not handle more TAP results after a given test run has finished.
252+ this . logger . debug ( 'browser_reload_debug' , clientId ) ;
253+ } else {
254+ this . logger . debug ( 'browser_unknown_clientId' , clientId ) ;
255+ return this . serveError ( resp , 403 , 'HTTP 403: QTap browser_unknown_clientId.\n\nThis clientId was likely already served and cannot be repeated. Run qtap with --debug to bypass this restriction.' ) ;
256+ }
257+
258+ const testFileResp = await this . getTestFile ( clientId ) ;
259+ for ( const [ name , value ] of testFileResp . headers ) {
260+ resp . setHeader ( name , value ) ;
261+ }
262+ if ( ! testFileResp . headers . get ( 'Content-Type' ) ) {
263+ resp . setHeader ( 'Content-Type' , util . MIME_TYPES . html ) ;
264+ }
265+ resp . writeHead ( 200 ) ;
266+ resp . write ( testFileResp . body ) ;
267+ resp . end ( ) ;
268+
269+ // Count proxying the test file toward connectTimeout, not idleTimeout.
270+ if ( browser ) {
271+ browser . clientIdleActive = performance . now ( ) ;
272+ }
273+ return ;
274+ }
275+
276+ if ( ! fs . existsSync ( filePath ) ) {
277+ this . logger . debug ( 'respond_static_notfound' , filePath ) ;
278+ return this . serveError ( resp , 404 , 'HTTP 404: QTap respond_static_notfound' ) ;
279+ }
280+
281+ this . logger . debug ( 'respond_static_pipe' , filePath ) ;
282+ resp . writeHead ( 200 , { 'Content-Type' : util . MIME_TYPES [ ext ] || util . MIME_TYPES . bin } ) ;
283+ fs . createReadStream ( filePath )
284+ . on ( 'error' , ( err ) => {
285+ this . logger . warning ( 'respond_static_pipe_error' , err ) ;
286+ resp . end ( ) ;
287+ } )
288+ . pipe ( resp ) ;
289+ }
290+
291+ handleTap ( req , url , resp ) {
292+ let body = '' ;
293+ req . on ( 'data' , ( data ) => {
294+ body += data ;
295+ } ) ;
296+ req . on ( 'end' , ( ) => {
297+ // Support QUnit 2.16 - 2.23: Strip escape sequences for tap-parser compatibility.
298+ // Fixed in QUnit 2.23.1 with https://github.com/qunitjs/qunit/pull/1801.
299+ body = util . stripAsciEscapes ( body ) ;
300+ const bodyExcerpt = body . slice ( 0 , 30 ) + '…' ;
301+ const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
302+ const browser = this . browsers . get ( clientId ) ;
303+ if ( browser ) {
304+ const now = performance . now ( ) ;
305+ browser . logger . debug ( 'browser_tap_received' ,
306+ `+${ util . humanSeconds ( now - browser . clientIdleActive ) } s` ,
307+ bodyExcerpt
308+ ) ;
309+ browser . tapParser . write ( body ) ;
310+ browser . clientIdleActive = now ;
311+ } else {
312+ this . logger . debug ( 'browser_tap_unhandled' , clientId , bodyExcerpt ) ;
313+ }
314+ } ) ;
315+ resp . writeHead ( 204 ) ;
316+ resp . end ( ) ;
317+ }
318+
319+ /**
320+ * @param {http.ServerResponse } resp
321+ * @param {number } statusCode
322+ * @param {string|Error } e
323+ */
324+ serveError ( resp , statusCode , e ) {
325+ if ( ! resp . headersSent ) {
326+ resp . writeHead ( statusCode , { 'Content-Type' : util . MIME_TYPES . txt } ) ;
327+ // @ts -ignore - TypeScript @types/node lacks Error.stack
328+ resp . write ( ( e . stack || String ( e ) ) + '\n' ) ;
174329 }
330+ resp . end ( ) ;
175331 }
176332
177333 async launchBrowser ( browserFn , browserName , globalSignal ) {
@@ -401,169 +557,9 @@ class ControlServer {
401557 return result ;
402558 }
403559
404- async getTestFile ( clientId ) {
405- const proxyBase = await this . getProxyBase ( ) ;
406- const qtapTapUrl = proxyBase + '/.qtap/tap/?qtap_clientId=' + clientId ;
407-
408- let headInjectHtml = `<script>(${ util . fnToStr ( qtapClientHead , qtapTapUrl ) } )();</script>` ;
409-
410- // Add <base> tag so that URL-based files can fetch their resources directly from the
411- // original server. Prepend as early as possible. If the file has its own <base>, theirs
412- // will be after ours and correctly "win" by applying last.
413- if ( this . isURL ( this . testFile ) ) {
414- headInjectHtml = `<base href="${ util . escapeHTML ( this . testFile ) } "/>` + headInjectHtml ;
415- }
416-
417- let resp ;
418- let html ;
419- try {
420- resp = await this . testFilePromise ;
421- html = resp . body ;
422- } catch ( e ) {
423- // @ts -ignore - TypeScript @types/node lacks `Error(,options)`
424- throw new Error ( 'Could not open ' + this . testFile , { cause : e } ) ;
425- }
426- this . logger . debug ( 'testfile_ready' , `Finished fetching ${ this . testFile } ` ) ;
427-
428- // Head injection
429- // * Use a callback, to avoid corruption if "$1" appears in the user input.
430- // * The headInjectHtml string must be one line without any line breaks,
431- // so that line numbers in stack traces presented in QTap output remain
432- // transparent and correct.
433- // * Ignore <heading> and <head-thing>.
434- // * Support <head x=y...>, including with tabs or newlines before ">".
435- html = util . replaceOnce ( html ,
436- [
437- / < h e a d (?: \s [ ^ > ] * ) ? > / i,
438- / < h t m l (?: \s [ ^ > ] * ) ? > / i,
439- / < ! d o c t y p e [ ^ > ] * > / i,
440- / ^ /
441- ] ,
442- ( m ) => m + headInjectHtml
443- ) ;
444-
445- html = util . replaceOnce ( html ,
446- [
447- / < \/ b o d y > (? ! [ \s \S ] * < \/ b o d y > ) / i,
448- / < \/ h t m l > (? ! [ \s \S ] * < \/ h t m l > ) / i,
449- / $ /
450- ] ,
451- ( m ) => '<script>(' + util . fnToStr ( qtapClientBody , qtapTapUrl ) + ')();</script>' + m
452- ) ;
453-
454- return {
455- headers : resp . headers ,
456- body : html
457- } ;
458- }
459-
460- async handleRequest ( req , url , resp ) {
461- const filePath = path . join ( this . root , url . pathname ) ;
462- const ext = path . extname ( url . pathname ) . slice ( 1 ) ;
463- if ( ! filePath . startsWith ( this . root ) ) {
464- // Disallow outside directory traversal
465- this . logger . debug ( 'respond_static_deny' , url . pathname ) ;
466- return this . serveError ( resp , 403 , 'HTTP 403: QTap respond_static_deny' ) ;
467- }
468-
469- const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
470- if ( clientId !== null ) {
471- // Serve the testfile from any URL path, as chosen by launchBrowser()
472- const browser = this . browsers . get ( clientId ) ;
473- if ( browser ) {
474- browser . logger . debug ( 'browser_connected' , `${ browser . getDisplayName ( ) } connected! Serving test file.` ) ;
475- this . eventbus . emit ( 'online' , { clientId } ) ;
476- } else if ( this . debugMode ) {
477- // Allow users to reload the page when in --debug mode.
478- // Note that do not handle more TAP results after a given test run has finished.
479- this . logger . debug ( 'browser_reload_debug' , clientId ) ;
480- } else {
481- this . logger . debug ( 'browser_unknown_clientId' , clientId ) ;
482- return this . serveError ( resp , 403 , 'HTTP 403: QTap browser_unknown_clientId.\n\nThis clientId was likely already served and cannot be repeated. Run qtap with --debug to bypass this restriction.' ) ;
483- }
484-
485- const testFileResp = await this . getTestFile ( clientId ) ;
486- for ( const [ name , value ] of testFileResp . headers ) {
487- resp . setHeader ( name , value ) ;
488- }
489- if ( ! testFileResp . headers . get ( 'Content-Type' ) ) {
490- resp . setHeader ( 'Content-Type' , util . MIME_TYPES . html ) ;
491- }
492- resp . writeHead ( 200 ) ;
493- resp . write ( testFileResp . body ) ;
494- resp . end ( ) ;
495-
496- // Count proxying the test file toward connectTimeout, not idleTimeout.
497- if ( browser ) {
498- browser . clientIdleActive = performance . now ( ) ;
499- }
500- return ;
501- }
502-
503- if ( ! fs . existsSync ( filePath ) ) {
504- this . logger . debug ( 'respond_static_notfound' , filePath ) ;
505- return this . serveError ( resp , 404 , 'HTTP 404: QTap respond_static_notfound' ) ;
506- }
507-
508- this . logger . debug ( 'respond_static_pipe' , filePath ) ;
509- resp . writeHead ( 200 , { 'Content-Type' : util . MIME_TYPES [ ext ] || util . MIME_TYPES . bin } ) ;
510- fs . createReadStream ( filePath )
511- . on ( 'error' , ( err ) => {
512- this . logger . warning ( 'respond_static_pipe_error' , err ) ;
513- resp . end ( ) ;
514- } )
515- . pipe ( resp ) ;
516- }
517-
518- handleTap ( req , url , resp ) {
519- let body = '' ;
520- req . on ( 'data' , ( data ) => {
521- body += data ;
522- } ) ;
523- req . on ( 'end' , ( ) => {
524- // Support QUnit 2.16 - 2.23: Strip escape sequences for tap-parser compatibility.
525- // Fixed in QUnit 2.23.1 with https://github.com/qunitjs/qunit/pull/1801.
526- body = util . stripAsciEscapes ( body ) ;
527- const bodyExcerpt = body . slice ( 0 , 30 ) + '…' ;
528- const clientId = url . searchParams . get ( 'qtap_clientId' ) ;
529- const browser = this . browsers . get ( clientId ) ;
530- if ( browser ) {
531- const now = performance . now ( ) ;
532- browser . logger . debug ( 'browser_tap_received' ,
533- `+${ util . humanSeconds ( now - browser . clientIdleActive ) } s` ,
534- bodyExcerpt
535- ) ;
536- browser . tapParser . write ( body ) ;
537- browser . clientIdleActive = now ;
538- } else {
539- this . logger . debug ( 'browser_tap_unhandled' , clientId , bodyExcerpt ) ;
540- }
541- } ) ;
542- resp . writeHead ( 204 ) ;
543- resp . end ( ) ;
544- }
545-
546- /**
547- * @param {http.ServerResponse } resp
548- * @param {number } statusCode
549- * @param {string|Error } e
550- */
551- serveError ( resp , statusCode , e ) {
552- if ( ! resp . headersSent ) {
553- resp . writeHead ( statusCode , { 'Content-Type' : util . MIME_TYPES . txt } ) ;
554- // @ts -ignore - TypeScript @types/node lacks Error.stack
555- resp . write ( ( e . stack || String ( e ) ) + '\n' ) ;
556- }
557- resp . end ( ) ;
558- }
559-
560560 async getProxyBase ( ) {
561561 return this . proxyBase || await this . proxyBasePromise ;
562562 }
563-
564- isURL ( file ) {
565- return file . startsWith ( 'http:' ) || file . startsWith ( 'https:' ) ;
566- }
567563}
568564
569565export { ControlServer } ;
0 commit comments