qunitx-cli 0.17.8 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +526 -124
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -387,9 +387,9 @@ var init_color = __esm({
387
387
 
388
388
  // lib/utils/path-exists.ts
389
389
  import fs2 from "node:fs/promises";
390
- async function pathExists(path6) {
390
+ async function pathExists(path7) {
391
391
  try {
392
- await fs2.access(path6);
392
+ await fs2.access(path7);
393
393
  return true;
394
394
  } catch {
395
395
  return false;
@@ -488,12 +488,18 @@ function dumpValue(value, indent) {
488
488
  if (Array.isArray(value)) {
489
489
  if (value.length === 0) return "[]";
490
490
  const next2 = `${indent} `;
491
- return "\n" + value.map((item) => `${next2}- ${dumpValue(item, next2)}`).join("\n");
491
+ return "\n" + value.map((item) => {
492
+ const v = dumpValue(item, next2);
493
+ return v[0] === "\n" ? `${next2}-${v}` : `${next2}- ${v}`;
494
+ }).join("\n");
492
495
  }
493
496
  const entries = Object.entries(value);
494
497
  if (entries.length === 0) return "{}";
495
498
  const next = `${indent} `;
496
- return "\n" + entries.map(([entryKey, entryValue]) => `${next}${entryKey}: ${dumpValue(entryValue, next)}`).join("\n");
499
+ return "\n" + entries.map(([entryKey, entryValue]) => {
500
+ const v = dumpValue(entryValue, next);
501
+ return v[0] === "\n" ? `${next}${entryKey}:${v}` : `${next}${entryKey}: ${v}`;
502
+ }).join("\n");
497
503
  }
498
504
  function yamlLine(key, value) {
499
505
  const serialized = dumpValue(value, "");
@@ -510,12 +516,12 @@ function dumpYaml({
510
516
  at
511
517
  }) {
512
518
  return `name: ${dumpString(name, "")}
513
- ` + yamlLine("actual", actual) + yamlLine("expected", expected) + yamlLine("message", message) + yamlLine("stack", stack) + yamlLine("at", at);
519
+ ` + yamlLine("actual", actual) + yamlLine("expected", expected) + (message !== null ? yamlLine("message", message) : "") + (stack !== null ? yamlLine("stack", stack) : "") + (at !== null ? yamlLine("at", at) : "");
514
520
  }
515
521
  var NEEDS_QUOTING;
516
522
  var init_dump_yaml = __esm({
517
523
  "lib/tap/dump-yaml.ts"() {
518
- NEEDS_QUOTING = /^$|^(null|true|false|~|yes|no|on|off|y|n)$|^[{[!|>'"#%@`]|^[-?:](\s|$)|^---|^[-+]?(\d|\.\d)|^\d{4}-\d{2}-\d{2}|: |#/i;
524
+ NEEDS_QUOTING = /^$|^\s|^(null|true|false|~|yes|no|on|off|y|n)$|^[{[!|>'"#%@`]|^[-?:](\s|$)|^---|^[-+]?(\d|\.\d)|^\d{4}-\d{2}-\d{2}|: |#/i;
519
525
  }
520
526
  });
521
527
 
@@ -538,46 +544,56 @@ function TAPDisplayTestResult(COUNTER, details) {
538
544
  COUNTER.testCount++;
539
545
  if (details.status === "skipped") {
540
546
  COUNTER.skipCount++;
541
- console.log(`ok ${COUNTER.testCount}`, details.fullName.join(" | "), "# skip");
547
+ process.stdout.write(`ok ${COUNTER.testCount} ${details.fullName.join(" | ")} # skip
548
+ `);
542
549
  } else if (details.status === "todo") {
543
- console.log(`not ok ${COUNTER.testCount}`, details.fullName.join(" | "), "# skip");
550
+ COUNTER.todoCount = (COUNTER.todoCount ?? 0) + 1;
551
+ process.stdout.write(`not ok ${COUNTER.testCount} ${details.fullName.join(" | ")} # TODO
552
+ `);
544
553
  } else if (details.status === "failed") {
545
554
  COUNTER.failCount++;
546
- console.log(
547
- `not ok ${COUNTER.testCount}`,
548
- details.fullName.join(" | "),
549
- `# (${details.runtime.toFixed(0)} ms)`
555
+ process.stdout.write(
556
+ `not ok ${COUNTER.testCount} ${details.fullName.join(" | ")} # (${details.runtime.toFixed(0)} ms)
557
+ `
550
558
  );
551
559
  details.assertions.forEach((assertion, index) => {
552
560
  if (!assertion.passed && assertion.todo === false) {
553
561
  COUNTER.errorCount = (COUNTER.errorCount ?? 0) + 1;
554
- const stack = assertion.stack?.match(/\(.+\)/g);
555
- console.log(" ---");
556
- console.log(
562
+ process.stdout.write(" ---\n");
563
+ process.stdout.write(
557
564
  indentString(
558
565
  dumpYaml({
559
566
  name: `Assertion #${index + 1}`,
560
- actual: assertion.actual ? JSON.parse(JSON.stringify(assertion.actual, getCircularReplacer())) : assertion.actual,
561
- expected: assertion.expected ? JSON.parse(JSON.stringify(assertion.expected, getCircularReplacer())) : assertion.expected,
567
+ actual: assertion.actual !== null && typeof assertion.actual === "object" ? JSON.parse(JSON.stringify(assertion.actual, getCircularReplacer())) : assertion.actual,
568
+ expected: assertion.expected !== null && typeof assertion.expected === "object" ? JSON.parse(JSON.stringify(assertion.expected, getCircularReplacer())) : assertion.expected,
562
569
  message: assertion.message || null,
563
- stack: assertion.stack || null,
564
- at: stack ? stack[0].replace("(file://", "").replace(")", "") : null
570
+ // Trim leading/trailing whitespace: Chrome stacks start with " at ..."
571
+ // (4 spaces per frame) which would otherwise render as "stack: at ..." in YAML.
572
+ stack: assertion.stack?.trim() || null,
573
+ at: extractStackAt(assertion.stack)
565
574
  }),
566
575
  4
567
576
  )
568
577
  );
569
- console.log(" ...");
578
+ process.stdout.write(" ...\n");
570
579
  }
571
580
  });
572
581
  } else if (details.status === "passed") {
573
582
  COUNTER.passCount++;
574
- console.log(
575
- `ok ${COUNTER.testCount}`,
576
- details.fullName.join(" | "),
577
- `# (${details.runtime.toFixed(0)} ms)`
583
+ process.stdout.write(
584
+ `ok ${COUNTER.testCount} ${details.fullName.join(" | ")} # (${details.runtime.toFixed(0)} ms)
585
+ `
578
586
  );
579
587
  }
580
588
  }
589
+ function extractStackAt(stack) {
590
+ if (!stack) return null;
591
+ const chromeMatch = stack.match(/\(([^)\n]+:[0-9]+:[0-9]+)\)/);
592
+ if (chromeMatch) return chromeMatch[1].replace("file://", "");
593
+ const geckoMatch = stack.match(/@([^\s\n@]+:[0-9]+:[0-9]+)/);
594
+ if (geckoMatch) return geckoMatch[1];
595
+ return null;
596
+ }
581
597
  function getCircularReplacer() {
582
598
  const ancestors = [];
583
599
  return function(_key, value) {
@@ -728,8 +744,8 @@ var init_http = __esm({
728
744
  });
729
745
  }
730
746
  /** Registers a GET route handler. */
731
- get(path6, handler) {
732
- this.#registerRouteHandler("GET", path6, handler);
747
+ get(path7, handler) {
748
+ this.#registerRouteHandler("GET", path7, handler);
733
749
  }
734
750
  /**
735
751
  * Starts listening on the given port (0 = OS-assigned).
@@ -760,30 +776,30 @@ var init_http = __esm({
760
776
  });
761
777
  }
762
778
  /** Registers a POST route handler. */
763
- post(path6, handler) {
764
- this.#registerRouteHandler("POST", path6, handler);
779
+ post(path7, handler) {
780
+ this.#registerRouteHandler("POST", path7, handler);
765
781
  }
766
782
  /** Registers a DELETE route handler. */
767
- delete(path6, handler) {
768
- this.#registerRouteHandler("DELETE", path6, handler);
783
+ delete(path7, handler) {
784
+ this.#registerRouteHandler("DELETE", path7, handler);
769
785
  }
770
786
  /** Registers a PUT route handler. */
771
- put(path6, handler) {
772
- this.#registerRouteHandler("PUT", path6, handler);
787
+ put(path7, handler) {
788
+ this.#registerRouteHandler("PUT", path7, handler);
773
789
  }
774
790
  /** Adds a middleware function to the chain. */
775
791
  use(middleware) {
776
792
  this.middleware.push(middleware);
777
793
  }
778
- #registerRouteHandler(method, path6, handler) {
794
+ #registerRouteHandler(method, path7, handler) {
779
795
  if (!this.routes[method]) {
780
796
  this.routes[method] = {};
781
797
  }
782
- this.routes[method][path6] = {
783
- path: path6,
798
+ this.routes[method][path7] = {
799
+ path: path7,
784
800
  handler,
785
- paramNames: this.#extractParamNames(path6),
786
- isWildcard: path6 === "/*"
801
+ paramNames: this.#extractParamNames(path7),
802
+ isWildcard: path7 === "/*"
787
803
  };
788
804
  }
789
805
  #handleRequest(req, res) {
@@ -821,13 +837,13 @@ var init_http = __esm({
821
837
  return null;
822
838
  }
823
839
  return routes[url] || Object.values(routes).find((route) => {
824
- const { path: path6, isWildcard } = route;
825
- if (!isWildcard && !path6.includes(":")) {
840
+ const { path: path7, isWildcard } = route;
841
+ if (!isWildcard && !path7.includes(":")) {
826
842
  return false;
827
843
  }
828
- if (isWildcard || this.#matchPathSegments(path6, url)) {
844
+ if (isWildcard || this.#matchPathSegments(path7, url)) {
829
845
  if (route.paramNames.length > 0) {
830
- const regexPattern = this.#buildRegexPattern(path6, route.paramNames);
846
+ const regexPattern = this.#buildRegexPattern(path7, route.paramNames);
831
847
  const regex = new RegExp(`^${regexPattern}$`);
832
848
  const regexMatches = regex.exec(url);
833
849
  if (regexMatches) {
@@ -839,8 +855,8 @@ var init_http = __esm({
839
855
  return false;
840
856
  }) || routes["/*"] || null;
841
857
  }
842
- #matchPathSegments(path6, url) {
843
- const pathSegments = path6.split("/");
858
+ #matchPathSegments(path7, url) {
859
+ const pathSegments = path7.split("/");
844
860
  const urlSegments = url.split("/");
845
861
  if (pathSegments.length !== urlSegments.length) {
846
862
  return false;
@@ -857,14 +873,14 @@ var init_http = __esm({
857
873
  }
858
874
  return true;
859
875
  }
860
- #buildRegexPattern(path6, _paramNames) {
861
- let regexPattern = path6.replace(/:[^/]+/g, "([^/]+)");
876
+ #buildRegexPattern(path7, _paramNames) {
877
+ let regexPattern = path7.replace(/:[^/]+/g, "([^/]+)");
862
878
  regexPattern = regexPattern.replace(/\//g, "\\/");
863
879
  return regexPattern;
864
880
  }
865
- #extractParamNames(path6) {
881
+ #extractParamNames(path7) {
866
882
  const paramRegex = /:(\w+)/g;
867
- const paramMatches = path6.match(paramRegex);
883
+ const paramMatches = path7.match(paramRegex);
868
884
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
869
885
  }
870
886
  #extractParams(route, _url) {
@@ -898,7 +914,7 @@ function setupWebServer(config, cachedContent) {
898
914
  config._onWsOpen?.();
899
915
  } else if (event === "connection") {
900
916
  config._phase = "running";
901
- if (!config._groupMode) console.log("TAP version 13");
917
+ if (!config._groupMode) process.stdout.write("TAP version 13\n");
902
918
  if (config.debug && config._groupMode) {
903
919
  const allFiles = Object.keys(config.fsTree);
904
920
  const relFiles = allFiles.map(
@@ -907,7 +923,8 @@ function setupWebServer(config, cachedContent) {
907
923
  const shown = relFiles.slice(0, 2);
908
924
  const rest = relFiles.length - shown.length;
909
925
  const fileList = rest > 0 ? `${shown.join(" ")} +${rest} more` : shown.join(" ");
910
- console.log("#", blue(`\u2500\u2500 ${fileList} \u2500\u2500`));
926
+ process.stdout.write(`# ${blue(`\u2500\u2500 ${fileList} \u2500\u2500`)}
927
+ `);
911
928
  }
912
929
  config._resetTestTimeout?.();
913
930
  } else if (event === "testEnd" && !abort) {
@@ -915,8 +932,9 @@ function setupWebServer(config, cachedContent) {
915
932
  config.lastFailedTestFiles = config.lastRanTestFiles;
916
933
  }
917
934
  if (config.debug && details.runtime > config.timeout * 0.8) {
918
- console.log(
919
- `# SLOW (${details.runtime.toFixed(0)}ms / ${config.timeout}ms timeout): ${details.fullName.join(" | ")}`
935
+ process.stdout.write(
936
+ `# SLOW (${details.runtime.toFixed(0)}ms / ${config.timeout}ms timeout): ${details.fullName.join(" | ")}
937
+ `
920
938
  );
921
939
  }
922
940
  config._resetTestTimeout?.();
@@ -924,8 +942,9 @@ function setupWebServer(config, cachedContent) {
924
942
  } else if (event === "done") {
925
943
  config._phase = "done";
926
944
  if (config.debug && config._groupMode) {
927
- console.log(
928
- `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)`
945
+ process.stdout.write(
946
+ `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)
947
+ `
929
948
  );
930
949
  }
931
950
  if (typeof config._testRunDone === "function") {
@@ -937,8 +956,9 @@ function setupWebServer(config, cachedContent) {
937
956
  });
938
957
  server.get("/tests.js", (_req, res) => {
939
958
  const bytes = cachedContent.allTestCode?.length ?? null;
940
- console.log(
941
- `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}`
959
+ process.stdout.write(
960
+ `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}
961
+ `
942
962
  );
943
963
  if (bytes === null) {
944
964
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
@@ -953,8 +973,9 @@ function setupWebServer(config, cachedContent) {
953
973
  });
954
974
  server.get("/filtered-tests.js", (_req, res) => {
955
975
  const bytes = cachedContent.filteredTestCode?.length ?? null;
956
- console.log(
957
- `# [HTTPServer] GET /filtered-tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (filteredTestCode is null)"}`
976
+ process.stdout.write(
977
+ `# [HTTPServer] GET /filtered-tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (filteredTestCode is null)"}
978
+ `
958
979
  );
959
980
  if (bytes === null) {
960
981
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
@@ -968,6 +989,23 @@ function setupWebServer(config, cachedContent) {
968
989
  res.end(cachedContent.filteredTestCode);
969
990
  });
970
991
  server.get("/", async (_req, res) => {
992
+ if (cachedContent._buildError) {
993
+ const htmlContent2 = buildErrorHTML(cachedContent._buildError);
994
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
995
+ res.write(htmlContent2);
996
+ res.end();
997
+ return await fsPromise.writeFile(
998
+ `${config.projectRoot}/${config.output}/index.html`,
999
+ htmlContent2
1000
+ );
1001
+ }
1002
+ if (cachedContent._noTestsWarning) {
1003
+ const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
1004
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1005
+ res.write(htmlContent2);
1006
+ res.end();
1007
+ return;
1008
+ }
971
1009
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
972
1010
  const htmlContent = escapeAndInjectTestsToHTML(
973
1011
  mainHTMLWithReplacedAssets,
@@ -983,6 +1021,23 @@ function setupWebServer(config, cachedContent) {
983
1021
  );
984
1022
  });
985
1023
  server.get("/qunitx.html", async (_req, res) => {
1024
+ if (cachedContent._buildError) {
1025
+ const htmlContent2 = buildErrorHTML(cachedContent._buildError);
1026
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1027
+ res.write(htmlContent2);
1028
+ res.end();
1029
+ return await fsPromise.writeFile(
1030
+ `${config.projectRoot}/${config.output}/qunitx.html`,
1031
+ htmlContent2
1032
+ );
1033
+ }
1034
+ if (cachedContent._noTestsWarning) {
1035
+ const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
1036
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1037
+ res.write(htmlContent2);
1038
+ res.end();
1039
+ return;
1040
+ }
986
1041
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
987
1042
  const htmlContent = escapeAndInjectTestsToHTML(
988
1043
  mainHTMLWithReplacedAssets,
@@ -1026,7 +1081,10 @@ function setupWebServer(config, cachedContent) {
1026
1081
  } else {
1027
1082
  fs8.createReadStream(filePath).pipe(res);
1028
1083
  }
1029
- console.log(`# [HTTPServer] GET ${url} ${statusCode} - ${/* @__PURE__ */ new Date() - requestStartedAt}ms`);
1084
+ process.stdout.write(
1085
+ `# [HTTPServer] GET ${url} ${statusCode} - ${/* @__PURE__ */ new Date() - requestStartedAt}ms
1086
+ `
1087
+ );
1030
1088
  });
1031
1089
  return server;
1032
1090
  }
@@ -1140,7 +1198,15 @@ function testRuntimeToInject(port, config) {
1140
1198
 
1141
1199
  if (!window.QUnit) {
1142
1200
  console.log('QUnit not found after WebSocket connected');
1143
- window.testTimeout = ${config.timeout};
1201
+ if (window.IS_PLAYWRIGHT) {
1202
+ // Signal the Playwright runner that the run is complete with 0 tests rather than
1203
+ // waiting for the inactivity timeout. The runner treats totalTests === 0 as a
1204
+ // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1205
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1206
+ window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1207
+ } else {
1208
+ window.testTimeout = ${config.timeout};
1209
+ }
1144
1210
  return;
1145
1211
  }
1146
1212
 
@@ -1192,6 +1258,211 @@ function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
1192
1258
  return injectScript(html, `${testRuntimeCode}
1193
1259
  <script src="${testBundleUrl}" async></script>`);
1194
1260
  }
1261
+ function buildNoTestsHTML(files) {
1262
+ const escaped = files.map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")).join("\n");
1263
+ return `<!DOCTYPE html>
1264
+ <html lang="en">
1265
+ <head>
1266
+ <meta charset="utf-8">
1267
+ <meta name="viewport" content="width=device-width">
1268
+ <title>No Tests Registered \u2014 qunitx</title>
1269
+ <style>
1270
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1271
+ #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1272
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1273
+ }
1274
+ #qunit-header {
1275
+ padding: 0.5em 0 0.5em 1em;
1276
+ color: #C2CCD1;
1277
+ background-color: #0D3349;
1278
+ font-size: 1.5em;
1279
+ line-height: 1em;
1280
+ font-weight: 400;
1281
+ border-radius: 5px 5px 0 0;
1282
+ }
1283
+ #qunit-banner { height: 5px; background-color: #F0AD4E; }
1284
+ #qunit-userAgent {
1285
+ padding: 0.5em 1em;
1286
+ color: #fff;
1287
+ background-color: #EC971F;
1288
+ text-shadow: rgba(0,0,0,.3) 2px 2px 1px;
1289
+ font-size: small;
1290
+ }
1291
+ #qunit-tests { list-style: none; font-size: smaller; }
1292
+ #qunit-tests li.warn {
1293
+ display: list-item;
1294
+ padding: 0.4em 1em;
1295
+ border-bottom: 1px solid #fff;
1296
+ color: #000;
1297
+ background-color: #FCF8E3;
1298
+ border-left: 5px solid #F0AD4E;
1299
+ }
1300
+ #qunit-tests li.warn:last-child { border-radius: 0 0 5px 5px; }
1301
+ .qunit-assert-list { margin-top: 0.5em; padding: 0.5em; background-color: #fff; border-radius: 5px; list-style: none; }
1302
+ .qunit-assert-list > li {
1303
+ padding: 5px;
1304
+ background-color: #FFF8DC;
1305
+ border-left: 10px solid #F0AD4E;
1306
+ color: #8A6D3B;
1307
+ }
1308
+ .qunit-assert-list pre {
1309
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
1310
+ font-size: 12px;
1311
+ line-height: 1.6;
1312
+ white-space: pre-wrap;
1313
+ word-break: break-word;
1314
+ color: #8A6D3B;
1315
+ margin: 0;
1316
+ }
1317
+ #qunit-testresult {
1318
+ padding: 0.5em 1em;
1319
+ color: #366097;
1320
+ background-color: #E2F0F7;
1321
+ border-bottom: 1px solid #fff;
1322
+ font-size: small;
1323
+ }
1324
+ .dots span { display: inline-block; animation: pulse 1.4s ease-in-out infinite; }
1325
+ .dots span:nth-child(2) { animation-delay: .2s; }
1326
+ .dots span:nth-child(3) { animation-delay: .4s; }
1327
+ @keyframes pulse { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
1328
+ </style>
1329
+ </head>
1330
+ <body>
1331
+ <div id="qunit">
1332
+ <h1 id="qunit-header">qunitx</h1>
1333
+ <h2 id="qunit-banner"></h2>
1334
+ <div id="qunit-userAgent">Warning: No Tests Registered</div>
1335
+ <ol id="qunit-tests">
1336
+ <li class="warn">
1337
+ <strong>0 QUnit tests were registered in the bundled file(s)</strong>
1338
+ <ol class="qunit-assert-list">
1339
+ <li><pre>${escaped}</pre></li>
1340
+ </ol>
1341
+ </li>
1342
+ </ol>
1343
+ <div id="qunit-testresult">
1344
+ Watching for changes&nbsp;<span class="dots"><span>&#9679;</span><span>&#9679;</span><span>&#9679;</span></span>
1345
+ </div>
1346
+ </div>
1347
+ <script>
1348
+ if (location.port) {
1349
+ (function () {
1350
+ var retries = 0;
1351
+ function connect() {
1352
+ var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1353
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1354
+ ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1355
+ ws.addEventListener('error', function () { ws.close(); });
1356
+ }
1357
+ connect();
1358
+ })();
1359
+ }
1360
+ </script>
1361
+ </body>
1362
+ </html>`;
1363
+ }
1364
+ function buildErrorHTML(buildError) {
1365
+ const escaped = buildError.formatted.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1366
+ return `<!DOCTYPE html>
1367
+ <html lang="en">
1368
+ <head>
1369
+ <meta charset="utf-8">
1370
+ <meta name="viewport" content="width=device-width">
1371
+ <title>Build Error \u2014 qunitx</title>
1372
+ <style>
1373
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1374
+ #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1375
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1376
+ }
1377
+ #qunit-header {
1378
+ padding: 0.5em 0 0.5em 1em;
1379
+ color: #C2CCD1;
1380
+ background-color: #0D3349;
1381
+ font-size: 1.5em;
1382
+ line-height: 1em;
1383
+ font-weight: 400;
1384
+ border-radius: 5px 5px 0 0;
1385
+ }
1386
+ #qunit-banner { height: 5px; background-color: #EE5757; }
1387
+ #qunit-userAgent {
1388
+ padding: 0.5em 1em;
1389
+ color: #fff;
1390
+ background-color: #2B81AF;
1391
+ text-shadow: rgba(0,0,0,.5) 2px 2px 1px;
1392
+ font-size: small;
1393
+ }
1394
+ #qunit-tests { list-style: none; font-size: smaller; }
1395
+ #qunit-tests li.fail {
1396
+ display: list-item;
1397
+ padding: 0.4em 1em;
1398
+ border-bottom: 1px solid #fff;
1399
+ color: #000;
1400
+ background-color: #EE5757;
1401
+ }
1402
+ #qunit-tests li.fail:last-child { border-radius: 0 0 5px 5px; }
1403
+ .qunit-assert-list { margin-top: 0.5em; padding: 0.5em; background-color: #fff; border-radius: 5px; list-style: none; }
1404
+ .qunit-assert-list > li {
1405
+ padding: 5px;
1406
+ background-color: #fff;
1407
+ border-left: 10px solid #EE5757;
1408
+ color: #710909;
1409
+ }
1410
+ .qunit-assert-list pre {
1411
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
1412
+ font-size: 12px;
1413
+ line-height: 1.6;
1414
+ white-space: pre-wrap;
1415
+ word-break: break-word;
1416
+ color: #710909;
1417
+ margin: 0;
1418
+ }
1419
+ #qunit-testresult {
1420
+ padding: 0.5em 1em;
1421
+ color: #366097;
1422
+ background-color: #E2F0F7;
1423
+ border-bottom: 1px solid #fff;
1424
+ font-size: small;
1425
+ }
1426
+ .dots span { display: inline-block; animation: pulse 1.4s ease-in-out infinite; }
1427
+ .dots span:nth-child(2) { animation-delay: .2s; }
1428
+ .dots span:nth-child(3) { animation-delay: .4s; }
1429
+ @keyframes pulse { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
1430
+ </style>
1431
+ </head>
1432
+ <body>
1433
+ <div id="qunit">
1434
+ <h1 id="qunit-header">qunitx</h1>
1435
+ <h2 id="qunit-banner"></h2>
1436
+ <div id="qunit-userAgent">Build Error: ${buildError.type}</div>
1437
+ <ol id="qunit-tests">
1438
+ <li class="fail">
1439
+ <strong>esbuild failed to bundle test files</strong>
1440
+ <ol class="qunit-assert-list">
1441
+ <li><pre>${escaped}</pre></li>
1442
+ </ol>
1443
+ </li>
1444
+ </ol>
1445
+ <div id="qunit-testresult">
1446
+ Watching for changes&nbsp;<span class="dots"><span>&#9679;</span><span>&#9679;</span><span>&#9679;</span></span>
1447
+ </div>
1448
+ </div>
1449
+ <script>
1450
+ if (location.port) {
1451
+ (function () {
1452
+ var retries = 0;
1453
+ function connect() {
1454
+ var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1455
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1456
+ ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1457
+ ws.addEventListener('error', function () { ws.close(); });
1458
+ }
1459
+ connect();
1460
+ })();
1461
+ }
1462
+ </script>
1463
+ </body>
1464
+ </html>`;
1465
+ }
1195
1466
  var fsPromise;
1196
1467
  var init_web_server = __esm({
1197
1468
  "lib/setup/web-server.ts"() {
@@ -1259,7 +1530,9 @@ async function setupBrowser(config, cachedContent, existingBrowser = null) {
1259
1530
  perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1260
1531
  const browser = resolvedExistingBrowser || await launchBrowser(config);
1261
1532
  const pageStart = Date.now();
1262
- const [page] = await Promise.all([browser.newPage(), bindServerToPort(server, config)]);
1533
+ const isHeadedWatchMode = config.open === true && config.watch;
1534
+ const getPage = isHeadedWatchMode ? () => browser.contexts()[0]?.pages()[0] ?? browser.newPage() : () => browser.newPage();
1535
+ const [page] = await Promise.all([getPage(), bindServerToPort(server, config)]);
1263
1536
  perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1264
1537
  await page.addInitScript(() => {
1265
1538
  window.IS_PLAYWRIGHT = true;
@@ -1365,15 +1638,23 @@ var init_run_user_module = __esm({
1365
1638
  });
1366
1639
 
1367
1640
  // lib/tap/display-final-result.ts
1368
- function TAPDisplayFinalResult({ testCount, passCount, skipCount, failCount }, timeTaken) {
1369
- console.log("");
1370
- console.log(`1..${testCount}`);
1371
- console.log(`# tests ${testCount}`);
1372
- console.log(`# pass ${passCount}`);
1373
- console.log(`# skip ${skipCount}`);
1374
- console.log(`# fail ${failCount}`);
1375
- console.log(`# duration ${timeTaken}`);
1376
- console.log("");
1641
+ function TAPDisplayFinalResult({ testCount, passCount, skipCount, todoCount, failCount }, timeTaken) {
1642
+ process.stdout.write("\n");
1643
+ process.stdout.write(`1..${testCount}
1644
+ `);
1645
+ process.stdout.write(`# tests ${testCount}
1646
+ `);
1647
+ process.stdout.write(`# pass ${passCount}
1648
+ `);
1649
+ process.stdout.write(`# skip ${skipCount}
1650
+ `);
1651
+ process.stdout.write(`# todo ${todoCount}
1652
+ `);
1653
+ process.stdout.write(`# fail ${failCount}
1654
+ `);
1655
+ process.stdout.write(`# duration ${timeTaken}
1656
+ `);
1657
+ process.stdout.write("\n");
1377
1658
  }
1378
1659
  var init_display_final_result = __esm({
1379
1660
  "lib/tap/display-final-result.ts"() {
@@ -1382,7 +1663,36 @@ var init_display_final_result = __esm({
1382
1663
 
1383
1664
  // lib/commands/run/tests-in-browser.ts
1384
1665
  import fs9 from "node:fs/promises";
1666
+ import path5 from "node:path";
1385
1667
  import esbuild from "esbuild";
1668
+ function deriveBuildErrorType(error) {
1669
+ const msgs = error?.errors ?? [];
1670
+ const text = msgs[0]?.text ?? (error instanceof Error ? error.message : String(error));
1671
+ if (/could not resolve|cannot find module|no such file/i.test(text))
1672
+ return "Module Resolution Error";
1673
+ if (/unexpected token|expected .* but found|unterminated/i.test(text)) return "Syntax Error";
1674
+ if (/is not (defined|a function)|cannot read prop/i.test(text)) return "Reference Error";
1675
+ return "Build Error";
1676
+ }
1677
+ function formatBuildErrors(error) {
1678
+ const msgs = error?.errors ?? [];
1679
+ if (msgs.length > 0) {
1680
+ return msgs.map((msg, i) => {
1681
+ const loc = msg.location;
1682
+ const lineNum = loc ? String(loc.line) : "";
1683
+ const pad = loc ? " ".repeat(lineNum.length) : "";
1684
+ const locationLines = loc ? [
1685
+ ` ${loc.file}:${loc.line}:${loc.column}`,
1686
+ ` ${lineNum} \u2502 ${loc.lineText}`,
1687
+ ` ${pad} \u2502 ${" ".repeat(loc.column)}${"~".repeat(Math.max(1, loc.length))}`
1688
+ ] : [];
1689
+ const noteLines = msg.notes.filter((n) => n.text).map((n) => ` Note: ${n.text}`);
1690
+ return [`[${i + 1}] ${msg.text}`].concat(locationLines, noteLines).join("\n");
1691
+ }).join("\n\n");
1692
+ }
1693
+ const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1694
+ return raw.replace(/\x1b\[[0-9;]*[mGKH]/g, "").replace(/\r\n/g, "\n");
1695
+ }
1386
1696
  async function buildTestBundle(config, cachedContent) {
1387
1697
  const { projectRoot, output } = config;
1388
1698
  const allTestFilePaths = Object.keys(config.fsTree);
@@ -1399,8 +1709,12 @@ async function buildTestBundle(config, cachedContent) {
1399
1709
  contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
1400
1710
  resolveDir: process.cwd()
1401
1711
  },
1712
+ // Allow test files outside the project root (e.g. /tmp/my-test.ts) to import
1713
+ // packages from any node_modules on the ancestor chain of cwd — the same lookup
1714
+ // order Node itself uses when resolving require() from process.cwd().
1715
+ nodePaths: ancestorNodeModules(process.cwd()),
1402
1716
  bundle: true,
1403
- logLevel: "error",
1717
+ logLevel: "silent",
1404
1718
  outfile,
1405
1719
  keepNames: true,
1406
1720
  legalComments: "none",
@@ -1412,26 +1726,47 @@ async function buildTestBundle(config, cachedContent) {
1412
1726
  // all browsers and does not require changes to user test code.
1413
1727
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1414
1728
  };
1415
- const [allTestCode] = await Promise.all([
1416
- config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
1417
- Promise.all(
1418
- cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
1419
- const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
1420
- if (htmlPath !== "/") {
1421
- await fs9.rm(targetPath, { force: true, recursive: true });
1422
- await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
1423
- }
1424
- })
1425
- )
1426
- ]);
1427
- cachedContent.allTestCode = allTestCode;
1729
+ cachedContent._buildError = null;
1730
+ cachedContent._noTestsWarning = null;
1731
+ try {
1732
+ const [allTestCode] = await Promise.all([
1733
+ config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
1734
+ Promise.all(
1735
+ cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
1736
+ const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
1737
+ if (htmlPath !== "/") {
1738
+ await fs9.rm(targetPath, { force: true, recursive: true });
1739
+ await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
1740
+ }
1741
+ })
1742
+ )
1743
+ ]);
1744
+ cachedContent.allTestCode = allTestCode;
1745
+ } catch (error) {
1746
+ cachedContent._buildError = {
1747
+ type: deriveBuildErrorType(error),
1748
+ formatted: formatBuildErrors(error)
1749
+ };
1750
+ await fs9.writeFile(
1751
+ `${projectRoot}/${output}/index.html`,
1752
+ buildErrorHTML(cachedContent._buildError)
1753
+ );
1754
+ throw error;
1755
+ }
1428
1756
  }
1429
1757
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1430
1758
  const { projectRoot, output } = config;
1431
1759
  const allTestFilePaths = Object.keys(config.fsTree);
1432
1760
  const runHasFilter = !!targetTestFilesToFilter;
1433
1761
  if (!config._groupMode) {
1434
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
1762
+ config.COUNTER = {
1763
+ testCount: 0,
1764
+ failCount: 0,
1765
+ skipCount: 0,
1766
+ todoCount: 0,
1767
+ passCount: 0,
1768
+ errorCount: 0
1769
+ };
1435
1770
  }
1436
1771
  config.lastRanTestFiles = targetTestFilesToFilter || allTestFilePaths;
1437
1772
  try {
@@ -1463,6 +1798,20 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1463
1798
  }
1464
1799
  const TIME_TAKEN = TIME_COUNTER.stop();
1465
1800
  if (!config._groupMode) {
1801
+ if (config.COUNTER.testCount === 0 && !cachedContent._buildError) {
1802
+ const displayFiles = allTestFilePaths.map(
1803
+ (f) => f.startsWith(`${projectRoot}/`) ? f.slice(projectRoot.length + 1) : f
1804
+ );
1805
+ cachedContent._noTestsWarning = displayFiles;
1806
+ const fileWord = allTestFilePaths.length === 1 ? "file" : "files";
1807
+ console.log(
1808
+ `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
1809
+ );
1810
+ fs9.writeFile(`${projectRoot}/${output}/index.html`, buildNoTestsHTML(displayFiles)).catch(
1811
+ () => {
1812
+ }
1813
+ );
1814
+ }
1466
1815
  TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
1467
1816
  if (config.after) {
1468
1817
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
@@ -1478,8 +1827,18 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1478
1827
  }
1479
1828
  } catch (error) {
1480
1829
  config.lastFailedTestFiles = config.lastRanTestFiles;
1481
- console.log(error);
1482
1830
  const exception = new BundleError(error);
1831
+ if (!cachedContent._buildError && error.errors?.length) {
1832
+ cachedContent._buildError = {
1833
+ type: deriveBuildErrorType(error),
1834
+ formatted: formatBuildErrors(error)
1835
+ };
1836
+ fs9.writeFile(
1837
+ `${projectRoot}/${output}/qunitx.html`,
1838
+ buildErrorHTML(cachedContent._buildError)
1839
+ ).catch(() => {
1840
+ });
1841
+ }
1483
1842
  if (config.watch) {
1484
1843
  console.log(`# ${exception}`);
1485
1844
  } else {
@@ -1497,8 +1856,9 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1497
1856
  contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
1498
1857
  resolveDir: process.cwd()
1499
1858
  },
1859
+ nodePaths: ancestorNodeModules(process.cwd()),
1500
1860
  bundle: true,
1501
- logLevel: "error",
1861
+ logLevel: "silent",
1502
1862
  outfile: outputPath,
1503
1863
  legalComments: "none",
1504
1864
  target: esbuildTarget(config.browser),
@@ -1513,15 +1873,13 @@ async function runWithOverlayfsRetry(getContents, needsDisk) {
1513
1873
  const MAX_RETRIES = 3;
1514
1874
  const EMPTY_BUNDLE_THRESHOLD = 500;
1515
1875
  let { result, js } = await getContents();
1876
+ const initialSize = js.length;
1516
1877
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
1517
1878
  if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
1518
- console.log(
1519
- `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes (< ${EMPTY_BUNDLE_THRESHOLD}) on attempt ${retry}/${MAX_RETRIES} \u2014 overlayfs flush race, retrying in ${RETRY_DELAY_MS}ms`
1520
- );
1521
1879
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
1522
1880
  ({ result, js } = await getContents());
1523
1881
  }
1524
- if (js.length < EMPTY_BUNDLE_THRESHOLD) {
1882
+ if (js.length < EMPTY_BUNDLE_THRESHOLD && js.length !== initialSize) {
1525
1883
  console.log(
1526
1884
  `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1527
1885
  );
@@ -1612,13 +1970,15 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1612
1970
  config._resetTestTimeout = null;
1613
1971
  config._testRunDone = null;
1614
1972
  }
1615
- if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
1973
+ if (!QUNIT_RESULT) {
1616
1974
  if (targetError) console.log(targetError);
1617
1975
  const wsReason = !wsConnected ? "WebSocket connection never received \u2014 Chrome may be CPU-starved or the page failed to load" : "WebSocket connected but no tests ran \u2014 QUnit may have failed to start";
1618
1976
  console.log(`# TIMEOUT: ${wsReason}`);
1619
1977
  console.log("BROWSER: runtime error thrown during executing tests");
1620
1978
  console.error("BROWSER: runtime error thrown during executing tests");
1621
1979
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
1980
+ } else if (QUNIT_RESULT.totalTests === 0) {
1981
+ return;
1622
1982
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
1623
1983
  if (targetError) console.log(targetError);
1624
1984
  console.log(
@@ -1644,7 +2004,7 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
1644
2004
  process.exit(1);
1645
2005
  }
1646
2006
  }
1647
- var BundleError;
2007
+ var BundleError, ancestorNodeModules;
1648
2008
  var init_tests_in_browser = __esm({
1649
2009
  "lib/commands/run/tests-in-browser.ts"() {
1650
2010
  init_color();
@@ -1652,6 +2012,7 @@ var init_tests_in_browser = __esm({
1652
2012
  init_time_counter();
1653
2013
  init_run_user_module();
1654
2014
  init_display_final_result();
2015
+ init_web_server();
1655
2016
  BundleError = class extends Error {
1656
2017
  constructor(message) {
1657
2018
  super(message);
@@ -1659,13 +2020,16 @@ var init_tests_in_browser = __esm({
1659
2020
  this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
1660
2021
  }
1661
2022
  };
2023
+ ancestorNodeModules = (dir) => dir.split(path5.sep).map(
2024
+ (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2025
+ );
1662
2026
  }
1663
2027
  });
1664
2028
 
1665
2029
  // lib/setup/file-watcher.ts
1666
2030
  import fs10 from "node:fs";
1667
2031
  import { stat, lstat } from "node:fs/promises";
1668
- import path5 from "node:path";
2032
+ import path6 from "node:path";
1669
2033
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1670
2034
  const extensions = config.extensions || ["js", "ts"];
1671
2035
  const readyPromises = [];
@@ -1695,7 +2059,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1695
2059
  const lastChangeMs = {};
1696
2060
  const childWatcher = fs10.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1697
2061
  if (!ready || !filename) return;
1698
- const fullPath = path5.join(watchPath, filename);
2062
+ const fullPath = filename === path6.basename(watchPath) ? watchPath : path6.join(watchPath, filename);
1699
2063
  if (eventType === "change") {
1700
2064
  if (!config._building) {
1701
2065
  const now = Date.now();
@@ -1720,8 +2084,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1720
2084
  }
1721
2085
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1722
2086
  });
1723
- const parentDir = path5.dirname(watchPath);
1724
- const watchedBasename = path5.basename(watchPath);
2087
+ const parentDir = path6.dirname(watchPath);
2088
+ const watchedBasename = path6.basename(watchPath);
1725
2089
  let parentUnlinkFired = false;
1726
2090
  const parentWatcher = fs10.watch(parentDir, async (eventType, filename) => {
1727
2091
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
@@ -1796,7 +2160,8 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1796
2160
  "#",
1797
2161
  magenta().bold("==================================================================")
1798
2162
  );
1799
- console.log("#", colorEvent(event), filePath.split(config.projectRoot)[1]);
2163
+ const displayPath = filePath.startsWith(config.projectRoot) ? filePath.slice(config.projectRoot.length) : filePath;
2164
+ console.log("#", colorEvent(event), displayPath);
1800
2165
  console.log(
1801
2166
  "#",
1802
2167
  magenta().bold("==================================================================")
@@ -1823,13 +2188,13 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1823
2188
  }
1824
2189
  });
1825
2190
  }
1826
- function mutateFSTree(fsTree, event, path6) {
2191
+ function mutateFSTree(fsTree, event, path7) {
1827
2192
  if (event === "add") {
1828
- fsTree[path6] = null;
2193
+ fsTree[path7] = null;
1829
2194
  } else if (event === "unlink") {
1830
- delete fsTree[path6];
2195
+ delete fsTree[path7];
1831
2196
  } else if (event === "unlinkDir") {
1832
- const dirPrefix = path6.endsWith("/") ? path6 : path6 + "/";
2197
+ const dirPrefix = path7.endsWith("/") ? path7 : path7 + "/";
1833
2198
  for (const treePath of Object.keys(fsTree)) {
1834
2199
  if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
1835
2200
  }
@@ -1961,14 +2326,18 @@ import { availableParallelism } from "node:os";
1961
2326
  async function run(config) {
1962
2327
  const cachedContent = await buildCachedContent(config, config.htmlPaths);
1963
2328
  if (config.watch) {
1964
- cachedContent._preBuildPromise = buildTestBundle(config, cachedContent);
2329
+ const preBuildPromise = buildTestBundle(config, cachedContent);
2330
+ preBuildPromise.catch(() => {
2331
+ });
2332
+ cachedContent._preBuildPromise = preBuildPromise;
1965
2333
  const [connections] = await Promise.all([
1966
2334
  setupBrowser(config, cachedContent),
1967
2335
  writeOutputStaticFiles(config, cachedContent)
1968
2336
  ]);
1969
2337
  config.expressApp = connections.server;
1970
2338
  setupKeyboardEvents(config, cachedContent, connections);
1971
- if (config.open) {
2339
+ const isHeadedWatchMode = config.open === true && config.watch;
2340
+ if (config.open && !isHeadedWatchMode) {
1972
2341
  void openOutputInBrowser(config);
1973
2342
  }
1974
2343
  if (config.before) {
@@ -1983,6 +2352,10 @@ async function run(config) {
1983
2352
  ]);
1984
2353
  throw error;
1985
2354
  }
2355
+ if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2356
+ await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2357
+ });
2358
+ }
1986
2359
  if (config.watch) {
1987
2360
  const { ready: watcherReady } = setupFileWatchers(
1988
2361
  config.testFileLookupPaths,
@@ -2006,7 +2379,13 @@ async function run(config) {
2006
2379
  }
2007
2380
  await runTestsInBrowser(config, cachedContent, connections, [file]);
2008
2381
  },
2009
- (_path, _event) => connections.server.publish("refresh")
2382
+ async (_path, _event) => {
2383
+ connections.server.publish("refresh");
2384
+ if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2385
+ await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2386
+ });
2387
+ }
2388
+ }
2010
2389
  );
2011
2390
  await watcherReady;
2012
2391
  }
@@ -2015,7 +2394,14 @@ async function run(config) {
2015
2394
  const allFiles = Object.keys(config.fsTree);
2016
2395
  const groupCount = Math.min(allFiles.length, availableParallelism());
2017
2396
  const groups = splitIntoGroups(allFiles, groupCount);
2018
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
2397
+ config.COUNTER = {
2398
+ testCount: 0,
2399
+ failCount: 0,
2400
+ skipCount: 0,
2401
+ todoCount: 0,
2402
+ passCount: 0,
2403
+ errorCount: 0
2404
+ };
2019
2405
  config.lastRanTestFiles = allFiles;
2020
2406
  const groupConfigs = groups.map((groupFiles, i) => ({
2021
2407
  ...config,
@@ -2026,9 +2412,10 @@ async function run(config) {
2026
2412
  _phase: "bundling"
2027
2413
  }));
2028
2414
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
2029
- console.log("TAP version 13");
2030
- console.log(
2031
- `# Running ${allFiles.length} test file${allFiles.length === 1 ? "" : "s"} across ${groupCount} group${groupCount === 1 ? "" : "s"}`
2415
+ process.stdout.write("TAP version 13\n");
2416
+ process.stdout.write(
2417
+ `# Running ${allFiles.length} test file${allFiles.length === 1 ? "" : "s"} across ${groupCount} group${groupCount === 1 ? "" : "s"}
2418
+ `
2032
2419
  );
2033
2420
  const [browser] = await Promise.all([
2034
2421
  launchBrowser(config),
@@ -2103,6 +2490,12 @@ async function run(config) {
2103
2490
  config.COUNTER.failCount > 0 ? 1 : 0
2104
2491
  );
2105
2492
  process.exitCode = exitCode;
2493
+ if (config.COUNTER.testCount === 0 && exitCode === 0) {
2494
+ const fileWord = allFiles.length === 1 ? "file" : "files";
2495
+ console.log(
2496
+ `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allFiles.length} ${fileWord}`
2497
+ );
2498
+ }
2106
2499
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
2107
2500
  if (config.after) {
2108
2501
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
@@ -2226,7 +2619,7 @@ init_color();
2226
2619
  var package_default = {
2227
2620
  name: "qunitx-cli",
2228
2621
  type: "module",
2229
- version: "0.17.8",
2622
+ version: "0.19.0",
2230
2623
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2231
2624
  author: "Izel Nakri",
2232
2625
  license: "MIT",
@@ -2458,17 +2851,17 @@ function pathToModuleName(filePath) {
2458
2851
  async function generateTestFiles() {
2459
2852
  const projectRoot = await findProjectRoot();
2460
2853
  const moduleName = pathToModuleName(process.argv[3]);
2461
- const path6 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2462
- if (await pathExists(path6)) {
2463
- console.log(`${path6} already exists!`);
2854
+ const path7 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2855
+ if (await pathExists(path7)) {
2856
+ console.log(`${path7} already exists!`);
2464
2857
  return;
2465
2858
  }
2466
2859
  const testJSContent = await readTemplate("test.js");
2467
- const targetFolderPaths = path6.split("/");
2860
+ const targetFolderPaths = path7.split("/");
2468
2861
  targetFolderPaths.pop();
2469
2862
  await fs5.mkdir(targetFolderPaths.join("/"), { recursive: true });
2470
- await fs5.writeFile(path6, testJSContent.replace("{{moduleName}}", moduleName));
2471
- console.log(green(`${path6} written`));
2863
+ await fs5.writeFile(path7, testJSContent.replace("{{moduleName}}", moduleName));
2864
+ console.log(green(`${path7} written`));
2472
2865
  }
2473
2866
 
2474
2867
  // lib/setup/config.ts
@@ -2569,20 +2962,20 @@ function setupTestFilePaths(_projectRoot, inputs2) {
2569
2962
  });
2570
2963
  return result.map((metaItem) => metaItem.input);
2571
2964
  }
2572
- function pathIsFile(path6) {
2573
- const inputs2 = path6.split("/");
2965
+ function pathIsFile(path7) {
2966
+ const inputs2 = path7.split("/");
2574
2967
  return inputs2[inputs2.length - 1].includes(".");
2575
2968
  }
2576
2969
  function pathIsIncludedInPaths(paths, targetPath) {
2577
- return paths.some((path6) => {
2578
- if (path6 === targetPath) {
2970
+ return paths.some((path7) => {
2971
+ if (path7 === targetPath) {
2579
2972
  return false;
2580
2973
  }
2581
- return matchesGlob(targetPath.input, buildGlobFormat(path6));
2974
+ return matchesGlob(targetPath.input, buildGlobFormat(path7));
2582
2975
  });
2583
2976
  }
2584
- function buildGlobFormat(path6) {
2585
- return path6.isFile ? path6.input : `${path6.input}/**`;
2977
+ function buildGlobFormat(path7) {
2978
+ return path7.isFile ? path7.input : `${path7.input}/**`;
2586
2979
  }
2587
2980
 
2588
2981
  // lib/utils/parse-cli-flags.ts
@@ -2636,7 +3029,9 @@ function parseCliFlags(projectRoot) {
2636
3029
  console.warn(`# Warning: Unknown flag "${arg}" \u2014 ignored`);
2637
3030
  return result;
2638
3031
  }
2639
- result.inputs.add(arg.startsWith(projectRoot) ? arg : `${process.cwd()}/${arg}`);
3032
+ result.inputs.add(
3033
+ arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : `${process.cwd()}/${arg}`
3034
+ );
2640
3035
  return result;
2641
3036
  },
2642
3037
  { inputs: /* @__PURE__ */ new Set([]) }
@@ -2674,7 +3069,14 @@ async function setupConfig() {
2674
3069
  testFileLookupPaths: setupTestFilePaths(projectRoot, inputs2),
2675
3070
  lastFailedTestFiles: null,
2676
3071
  lastRanTestFiles: null,
2677
- COUNTER: { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 },
3072
+ COUNTER: {
3073
+ testCount: 0,
3074
+ failCount: 0,
3075
+ skipCount: 0,
3076
+ todoCount: 0,
3077
+ passCount: 0,
3078
+ errorCount: 0
3079
+ },
2678
3080
  _testRunDone: null,
2679
3081
  _resetTestTimeout: null,
2680
3082
  _onWsOpen: null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.17.8",
4
+ "version": "0.19.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",