qunitx-cli 0.18.0 → 0.19.1

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 +530 -114
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -93,7 +93,7 @@ async function cleanupBrowserDir(dirPath) {
93
93
  }
94
94
  }
95
95
  }
96
- const deadline = Date.now() + 1e3;
96
+ const deadline = Date.now() + 5e3;
97
97
  while (Date.now() < deadline) {
98
98
  const removed = await fs.rm(dirPath, { recursive: true, force: true }).then(() => true).catch(() => false);
99
99
  if (removed) break;
@@ -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;
@@ -547,7 +547,8 @@ function TAPDisplayTestResult(COUNTER, details) {
547
547
  process.stdout.write(`ok ${COUNTER.testCount} ${details.fullName.join(" | ")} # skip
548
548
  `);
549
549
  } else if (details.status === "todo") {
550
- process.stdout.write(`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
551
552
  `);
552
553
  } else if (details.status === "failed") {
553
554
  COUNTER.failCount++;
@@ -743,8 +744,8 @@ var init_http = __esm({
743
744
  });
744
745
  }
745
746
  /** Registers a GET route handler. */
746
- get(path6, handler) {
747
- this.#registerRouteHandler("GET", path6, handler);
747
+ get(path7, handler) {
748
+ this.#registerRouteHandler("GET", path7, handler);
748
749
  }
749
750
  /**
750
751
  * Starts listening on the given port (0 = OS-assigned).
@@ -775,30 +776,30 @@ var init_http = __esm({
775
776
  });
776
777
  }
777
778
  /** Registers a POST route handler. */
778
- post(path6, handler) {
779
- this.#registerRouteHandler("POST", path6, handler);
779
+ post(path7, handler) {
780
+ this.#registerRouteHandler("POST", path7, handler);
780
781
  }
781
782
  /** Registers a DELETE route handler. */
782
- delete(path6, handler) {
783
- this.#registerRouteHandler("DELETE", path6, handler);
783
+ delete(path7, handler) {
784
+ this.#registerRouteHandler("DELETE", path7, handler);
784
785
  }
785
786
  /** Registers a PUT route handler. */
786
- put(path6, handler) {
787
- this.#registerRouteHandler("PUT", path6, handler);
787
+ put(path7, handler) {
788
+ this.#registerRouteHandler("PUT", path7, handler);
788
789
  }
789
790
  /** Adds a middleware function to the chain. */
790
791
  use(middleware) {
791
792
  this.middleware.push(middleware);
792
793
  }
793
- #registerRouteHandler(method, path6, handler) {
794
+ #registerRouteHandler(method, path7, handler) {
794
795
  if (!this.routes[method]) {
795
796
  this.routes[method] = {};
796
797
  }
797
- this.routes[method][path6] = {
798
- path: path6,
798
+ this.routes[method][path7] = {
799
+ path: path7,
799
800
  handler,
800
- paramNames: this.#extractParamNames(path6),
801
- isWildcard: path6 === "/*"
801
+ paramNames: this.#extractParamNames(path7),
802
+ isWildcard: path7 === "/*"
802
803
  };
803
804
  }
804
805
  #handleRequest(req, res) {
@@ -836,13 +837,13 @@ var init_http = __esm({
836
837
  return null;
837
838
  }
838
839
  return routes[url] || Object.values(routes).find((route) => {
839
- const { path: path6, isWildcard } = route;
840
- if (!isWildcard && !path6.includes(":")) {
840
+ const { path: path7, isWildcard } = route;
841
+ if (!isWildcard && !path7.includes(":")) {
841
842
  return false;
842
843
  }
843
- if (isWildcard || this.#matchPathSegments(path6, url)) {
844
+ if (isWildcard || this.#matchPathSegments(path7, url)) {
844
845
  if (route.paramNames.length > 0) {
845
- const regexPattern = this.#buildRegexPattern(path6, route.paramNames);
846
+ const regexPattern = this.#buildRegexPattern(path7, route.paramNames);
846
847
  const regex = new RegExp(`^${regexPattern}$`);
847
848
  const regexMatches = regex.exec(url);
848
849
  if (regexMatches) {
@@ -854,8 +855,8 @@ var init_http = __esm({
854
855
  return false;
855
856
  }) || routes["/*"] || null;
856
857
  }
857
- #matchPathSegments(path6, url) {
858
- const pathSegments = path6.split("/");
858
+ #matchPathSegments(path7, url) {
859
+ const pathSegments = path7.split("/");
859
860
  const urlSegments = url.split("/");
860
861
  if (pathSegments.length !== urlSegments.length) {
861
862
  return false;
@@ -872,14 +873,14 @@ var init_http = __esm({
872
873
  }
873
874
  return true;
874
875
  }
875
- #buildRegexPattern(path6, _paramNames) {
876
- let regexPattern = path6.replace(/:[^/]+/g, "([^/]+)");
876
+ #buildRegexPattern(path7, _paramNames) {
877
+ let regexPattern = path7.replace(/:[^/]+/g, "([^/]+)");
877
878
  regexPattern = regexPattern.replace(/\//g, "\\/");
878
879
  return regexPattern;
879
880
  }
880
- #extractParamNames(path6) {
881
+ #extractParamNames(path7) {
881
882
  const paramRegex = /:(\w+)/g;
882
- const paramMatches = path6.match(paramRegex);
883
+ const paramMatches = path7.match(paramRegex);
883
884
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
884
885
  }
885
886
  #extractParams(route, _url) {
@@ -907,7 +908,7 @@ function setupWebServer(config, cachedContent) {
907
908
  );
908
909
  server.wss.on("connection", function connection(socket) {
909
910
  socket.on("message", function message(data) {
910
- const { event, details, abort } = JSON.parse(data);
911
+ const { event, details, qunitResult, abort } = JSON.parse(data);
911
912
  if (event === "wsOpen") {
912
913
  config._phase = "loading";
913
914
  config._onWsOpen?.();
@@ -940,6 +941,7 @@ function setupWebServer(config, cachedContent) {
940
941
  TAPDisplayTestResult(config.COUNTER, details);
941
942
  } else if (event === "done") {
942
943
  config._phase = "done";
944
+ config._lastQUnitResult = qunitResult ?? null;
943
945
  if (config.debug && config._groupMode) {
944
946
  process.stdout.write(
945
947
  `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)
@@ -967,7 +969,11 @@ function setupWebServer(config, cachedContent) {
967
969
  return;
968
970
  }
969
971
  config._onTestsJsServed?.();
970
- res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
972
+ res.writeHead(200, {
973
+ "Content-Type": "application/javascript",
974
+ "Cache-Control": "no-store",
975
+ "Content-Length": bytes
976
+ });
971
977
  res.end(cachedContent.allTestCode);
972
978
  });
973
979
  server.get("/filtered-tests.js", (_req, res) => {
@@ -984,14 +990,34 @@ function setupWebServer(config, cachedContent) {
984
990
  return;
985
991
  }
986
992
  config._onTestsJsServed?.();
987
- res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
993
+ res.writeHead(200, {
994
+ "Content-Type": "application/javascript",
995
+ "Cache-Control": "no-store",
996
+ "Content-Length": bytes
997
+ });
988
998
  res.end(cachedContent.filteredTestCode);
989
999
  });
990
1000
  server.get("/", async (_req, res) => {
991
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1001
+ if (cachedContent._buildError) {
1002
+ const htmlContent2 = buildErrorHTML(cachedContent._buildError);
1003
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1004
+ res.write(htmlContent2);
1005
+ res.end();
1006
+ return await fsPromise.writeFile(
1007
+ `${config.projectRoot}/${config.output}/index.html`,
1008
+ htmlContent2
1009
+ );
1010
+ }
1011
+ if (cachedContent._noTestsWarning) {
1012
+ const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
1013
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1014
+ res.write(htmlContent2);
1015
+ res.end();
1016
+ return;
1017
+ }
992
1018
  const htmlContent = escapeAndInjectTestsToHTML(
993
1019
  mainHTMLWithReplacedAssets,
994
- TEST_RUNTIME_TO_INJECT,
1020
+ testRuntimeToInject(config.port, config),
995
1021
  "./tests.js"
996
1022
  );
997
1023
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1003,10 +1029,26 @@ function setupWebServer(config, cachedContent) {
1003
1029
  );
1004
1030
  });
1005
1031
  server.get("/qunitx.html", async (_req, res) => {
1006
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1032
+ if (cachedContent._buildError) {
1033
+ const htmlContent2 = buildErrorHTML(cachedContent._buildError);
1034
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1035
+ res.write(htmlContent2);
1036
+ res.end();
1037
+ return await fsPromise.writeFile(
1038
+ `${config.projectRoot}/${config.output}/qunitx.html`,
1039
+ htmlContent2
1040
+ );
1041
+ }
1042
+ if (cachedContent._noTestsWarning) {
1043
+ const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
1044
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1045
+ res.write(htmlContent2);
1046
+ res.end();
1047
+ return;
1048
+ }
1007
1049
  const htmlContent = escapeAndInjectTestsToHTML(
1008
1050
  mainHTMLWithReplacedAssets,
1009
- TEST_RUNTIME_TO_INJECT,
1051
+ testRuntimeToInject(config.port, config),
1010
1052
  "./filtered-tests.js"
1011
1053
  );
1012
1054
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1020,10 +1062,9 @@ function setupWebServer(config, cachedContent) {
1020
1062
  server.get("/*", async (req, res) => {
1021
1063
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
1022
1064
  if (possibleDynamicHTML) {
1023
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1024
1065
  const htmlContent = escapeAndInjectTestsToHTML(
1025
1066
  possibleDynamicHTML,
1026
- TEST_RUNTIME_TO_INJECT,
1067
+ testRuntimeToInject(config.port, config),
1027
1068
  "/tests.js"
1028
1069
  );
1029
1070
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1163,7 +1204,15 @@ function testRuntimeToInject(port, config) {
1163
1204
 
1164
1205
  if (!window.QUnit) {
1165
1206
  console.log('QUnit not found after WebSocket connected');
1166
- window.testTimeout = ${config.timeout};
1207
+ if (window.IS_PLAYWRIGHT) {
1208
+ // Signal the Playwright runner that the run is complete with 0 tests rather than
1209
+ // waiting for the inactivity timeout. The runner treats totalTests === 0 as a
1210
+ // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1211
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1212
+ window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1213
+ } else {
1214
+ window.testTimeout = ${config.timeout};
1215
+ }
1167
1216
  return;
1168
1217
  }
1169
1218
 
@@ -1172,11 +1221,6 @@ function testRuntimeToInject(port, config) {
1172
1221
  window.socket.send(JSON.stringify({ event: 'connection' }));
1173
1222
  }
1174
1223
  });
1175
- window.QUnit.moduleStart((details) => { // NOTE: might be useful in future for hanged module tracking
1176
- if (window.IS_PLAYWRIGHT) {
1177
- window.socket.send(JSON.stringify({ event: 'moduleStart', details: details }, getCircularReplacer()));
1178
- }
1179
- });
1180
1224
  window.QUnit.on('testStart', (details) => {
1181
1225
  window.QUNIT_RESULT.totalTests++;
1182
1226
  window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
@@ -1196,7 +1240,7 @@ function testRuntimeToInject(port, config) {
1196
1240
  });
1197
1241
  window.QUnit.done((details) => {
1198
1242
  if (window.IS_PLAYWRIGHT) {
1199
- window.socket.send(JSON.stringify({ event: 'done', details: details, abort: window.abortQUnit }, getCircularReplacer()));
1243
+ window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1200
1244
  // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
1201
1245
  // canonical completion signal for Playwright runs. waitForFunction is reserved
1202
1246
  // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
@@ -1215,6 +1259,211 @@ function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
1215
1259
  return injectScript(html, `${testRuntimeCode}
1216
1260
  <script src="${testBundleUrl}" async></script>`);
1217
1261
  }
1262
+ function buildNoTestsHTML(files) {
1263
+ const escaped = files.map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")).join("\n");
1264
+ return `<!DOCTYPE html>
1265
+ <html lang="en">
1266
+ <head>
1267
+ <meta charset="utf-8">
1268
+ <meta name="viewport" content="width=device-width">
1269
+ <title>No Tests Registered \u2014 qunitx</title>
1270
+ <style>
1271
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1272
+ #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1273
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1274
+ }
1275
+ #qunit-header {
1276
+ padding: 0.5em 0 0.5em 1em;
1277
+ color: #C2CCD1;
1278
+ background-color: #0D3349;
1279
+ font-size: 1.5em;
1280
+ line-height: 1em;
1281
+ font-weight: 400;
1282
+ border-radius: 5px 5px 0 0;
1283
+ }
1284
+ #qunit-banner { height: 5px; background-color: #F0AD4E; }
1285
+ #qunit-userAgent {
1286
+ padding: 0.5em 1em;
1287
+ color: #fff;
1288
+ background-color: #EC971F;
1289
+ text-shadow: rgba(0,0,0,.3) 2px 2px 1px;
1290
+ font-size: small;
1291
+ }
1292
+ #qunit-tests { list-style: none; font-size: smaller; }
1293
+ #qunit-tests li.warn {
1294
+ display: list-item;
1295
+ padding: 0.4em 1em;
1296
+ border-bottom: 1px solid #fff;
1297
+ color: #000;
1298
+ background-color: #FCF8E3;
1299
+ border-left: 5px solid #F0AD4E;
1300
+ }
1301
+ #qunit-tests li.warn:last-child { border-radius: 0 0 5px 5px; }
1302
+ .qunit-assert-list { margin-top: 0.5em; padding: 0.5em; background-color: #fff; border-radius: 5px; list-style: none; }
1303
+ .qunit-assert-list > li {
1304
+ padding: 5px;
1305
+ background-color: #FFF8DC;
1306
+ border-left: 10px solid #F0AD4E;
1307
+ color: #8A6D3B;
1308
+ }
1309
+ .qunit-assert-list pre {
1310
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
1311
+ font-size: 12px;
1312
+ line-height: 1.6;
1313
+ white-space: pre-wrap;
1314
+ word-break: break-word;
1315
+ color: #8A6D3B;
1316
+ margin: 0;
1317
+ }
1318
+ #qunit-testresult {
1319
+ padding: 0.5em 1em;
1320
+ color: #366097;
1321
+ background-color: #E2F0F7;
1322
+ border-bottom: 1px solid #fff;
1323
+ font-size: small;
1324
+ }
1325
+ .dots span { display: inline-block; animation: pulse 1.4s ease-in-out infinite; }
1326
+ .dots span:nth-child(2) { animation-delay: .2s; }
1327
+ .dots span:nth-child(3) { animation-delay: .4s; }
1328
+ @keyframes pulse { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
1329
+ </style>
1330
+ </head>
1331
+ <body>
1332
+ <div id="qunit">
1333
+ <h1 id="qunit-header">qunitx</h1>
1334
+ <h2 id="qunit-banner"></h2>
1335
+ <div id="qunit-userAgent">Warning: No Tests Registered</div>
1336
+ <ol id="qunit-tests">
1337
+ <li class="warn">
1338
+ <strong>0 QUnit tests were registered in the bundled file(s)</strong>
1339
+ <ol class="qunit-assert-list">
1340
+ <li><pre>${escaped}</pre></li>
1341
+ </ol>
1342
+ </li>
1343
+ </ol>
1344
+ <div id="qunit-testresult">
1345
+ Watching for changes&nbsp;<span class="dots"><span>&#9679;</span><span>&#9679;</span><span>&#9679;</span></span>
1346
+ </div>
1347
+ </div>
1348
+ <script>
1349
+ if (location.port) {
1350
+ (function () {
1351
+ var retries = 0;
1352
+ function connect() {
1353
+ var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1354
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1355
+ ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1356
+ ws.addEventListener('error', function () { ws.close(); });
1357
+ }
1358
+ connect();
1359
+ })();
1360
+ }
1361
+ </script>
1362
+ </body>
1363
+ </html>`;
1364
+ }
1365
+ function buildErrorHTML(buildError) {
1366
+ const escaped = buildError.formatted.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1367
+ return `<!DOCTYPE html>
1368
+ <html lang="en">
1369
+ <head>
1370
+ <meta charset="utf-8">
1371
+ <meta name="viewport" content="width=device-width">
1372
+ <title>Build Error \u2014 qunitx</title>
1373
+ <style>
1374
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1375
+ #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1376
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1377
+ }
1378
+ #qunit-header {
1379
+ padding: 0.5em 0 0.5em 1em;
1380
+ color: #C2CCD1;
1381
+ background-color: #0D3349;
1382
+ font-size: 1.5em;
1383
+ line-height: 1em;
1384
+ font-weight: 400;
1385
+ border-radius: 5px 5px 0 0;
1386
+ }
1387
+ #qunit-banner { height: 5px; background-color: #EE5757; }
1388
+ #qunit-userAgent {
1389
+ padding: 0.5em 1em;
1390
+ color: #fff;
1391
+ background-color: #2B81AF;
1392
+ text-shadow: rgba(0,0,0,.5) 2px 2px 1px;
1393
+ font-size: small;
1394
+ }
1395
+ #qunit-tests { list-style: none; font-size: smaller; }
1396
+ #qunit-tests li.fail {
1397
+ display: list-item;
1398
+ padding: 0.4em 1em;
1399
+ border-bottom: 1px solid #fff;
1400
+ color: #000;
1401
+ background-color: #EE5757;
1402
+ }
1403
+ #qunit-tests li.fail:last-child { border-radius: 0 0 5px 5px; }
1404
+ .qunit-assert-list { margin-top: 0.5em; padding: 0.5em; background-color: #fff; border-radius: 5px; list-style: none; }
1405
+ .qunit-assert-list > li {
1406
+ padding: 5px;
1407
+ background-color: #fff;
1408
+ border-left: 10px solid #EE5757;
1409
+ color: #710909;
1410
+ }
1411
+ .qunit-assert-list pre {
1412
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
1413
+ font-size: 12px;
1414
+ line-height: 1.6;
1415
+ white-space: pre-wrap;
1416
+ word-break: break-word;
1417
+ color: #710909;
1418
+ margin: 0;
1419
+ }
1420
+ #qunit-testresult {
1421
+ padding: 0.5em 1em;
1422
+ color: #366097;
1423
+ background-color: #E2F0F7;
1424
+ border-bottom: 1px solid #fff;
1425
+ font-size: small;
1426
+ }
1427
+ .dots span { display: inline-block; animation: pulse 1.4s ease-in-out infinite; }
1428
+ .dots span:nth-child(2) { animation-delay: .2s; }
1429
+ .dots span:nth-child(3) { animation-delay: .4s; }
1430
+ @keyframes pulse { 0%,100% { opacity: .2; } 50% { opacity: 1; } }
1431
+ </style>
1432
+ </head>
1433
+ <body>
1434
+ <div id="qunit">
1435
+ <h1 id="qunit-header">qunitx</h1>
1436
+ <h2 id="qunit-banner"></h2>
1437
+ <div id="qunit-userAgent">Build Error: ${buildError.type}</div>
1438
+ <ol id="qunit-tests">
1439
+ <li class="fail">
1440
+ <strong>esbuild failed to bundle test files</strong>
1441
+ <ol class="qunit-assert-list">
1442
+ <li><pre>${escaped}</pre></li>
1443
+ </ol>
1444
+ </li>
1445
+ </ol>
1446
+ <div id="qunit-testresult">
1447
+ Watching for changes&nbsp;<span class="dots"><span>&#9679;</span><span>&#9679;</span><span>&#9679;</span></span>
1448
+ </div>
1449
+ </div>
1450
+ <script>
1451
+ if (location.port) {
1452
+ (function () {
1453
+ var retries = 0;
1454
+ function connect() {
1455
+ var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1456
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1457
+ ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1458
+ ws.addEventListener('error', function () { ws.close(); });
1459
+ }
1460
+ connect();
1461
+ })();
1462
+ }
1463
+ </script>
1464
+ </body>
1465
+ </html>`;
1466
+ }
1218
1467
  var fsPromise;
1219
1468
  var init_web_server = __esm({
1220
1469
  "lib/setup/web-server.ts"() {
@@ -1282,21 +1531,32 @@ async function setupBrowser(config, cachedContent, existingBrowser = null) {
1282
1531
  perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1283
1532
  const browser = resolvedExistingBrowser || await launchBrowser(config);
1284
1533
  const pageStart = Date.now();
1285
- const [page] = await Promise.all([browser.newPage(), bindServerToPort(server, config)]);
1534
+ const isHeadedWatchMode = config.open === true && config.watch;
1535
+ const getPage = isHeadedWatchMode ? () => browser.contexts()[0]?.pages()[0] ?? browser.newPage() : () => browser.newPage();
1536
+ const [page] = await Promise.all([getPage(), bindServerToPort(server, config)]);
1286
1537
  perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1287
1538
  await page.addInitScript(() => {
1288
1539
  window.IS_PLAYWRIGHT = true;
1289
1540
  });
1290
- page.on("console", async (msg) => {
1541
+ config._pendingConsoleHandlers = /* @__PURE__ */ new Set();
1542
+ page.on("console", (msg) => {
1291
1543
  const type = msg.type();
1292
1544
  const alwaysShow = type === "warning" || type === "error";
1293
1545
  if (!alwaysShow && !config.debug) return;
1294
- try {
1295
- const values = await Promise.all(msg.args().map((arg) => arg.jsonValue()));
1296
- console.log(...values);
1297
- } catch {
1298
- console.log(msg.text());
1299
- }
1546
+ const handler = (async () => {
1547
+ try {
1548
+ const values = await Promise.all(
1549
+ msg.args().map(
1550
+ (arg) => arg.jsonValue().catch(() => arg.evaluate((v) => JSON.stringify(v)).then(JSON.parse))
1551
+ )
1552
+ );
1553
+ console.log(...values);
1554
+ } catch {
1555
+ console.log(msg.text());
1556
+ }
1557
+ })();
1558
+ config._pendingConsoleHandlers.add(handler);
1559
+ handler.finally(() => config._pendingConsoleHandlers?.delete(handler));
1300
1560
  });
1301
1561
  page.on("pageerror", (error) => {
1302
1562
  console.error(error.toString());
@@ -1388,7 +1648,7 @@ var init_run_user_module = __esm({
1388
1648
  });
1389
1649
 
1390
1650
  // lib/tap/display-final-result.ts
1391
- function TAPDisplayFinalResult({ testCount, passCount, skipCount, failCount }, timeTaken) {
1651
+ function TAPDisplayFinalResult({ testCount, passCount, skipCount, todoCount, failCount }, timeTaken) {
1392
1652
  process.stdout.write("\n");
1393
1653
  process.stdout.write(`1..${testCount}
1394
1654
  `);
@@ -1397,6 +1657,8 @@ function TAPDisplayFinalResult({ testCount, passCount, skipCount, failCount }, t
1397
1657
  process.stdout.write(`# pass ${passCount}
1398
1658
  `);
1399
1659
  process.stdout.write(`# skip ${skipCount}
1660
+ `);
1661
+ process.stdout.write(`# todo ${todoCount}
1400
1662
  `);
1401
1663
  process.stdout.write(`# fail ${failCount}
1402
1664
  `);
@@ -1411,7 +1673,36 @@ var init_display_final_result = __esm({
1411
1673
 
1412
1674
  // lib/commands/run/tests-in-browser.ts
1413
1675
  import fs9 from "node:fs/promises";
1676
+ import path5 from "node:path";
1414
1677
  import esbuild from "esbuild";
1678
+ function deriveBuildErrorType(error) {
1679
+ const msgs = error?.errors ?? [];
1680
+ const text = msgs[0]?.text ?? (error instanceof Error ? error.message : String(error));
1681
+ if (/could not resolve|cannot find module|no such file/i.test(text))
1682
+ return "Module Resolution Error";
1683
+ if (/unexpected token|expected .* but found|unterminated/i.test(text)) return "Syntax Error";
1684
+ if (/is not (defined|a function)|cannot read prop/i.test(text)) return "Reference Error";
1685
+ return "Build Error";
1686
+ }
1687
+ function formatBuildErrors(error) {
1688
+ const msgs = error?.errors ?? [];
1689
+ if (msgs.length > 0) {
1690
+ return msgs.map((msg, i) => {
1691
+ const loc = msg.location;
1692
+ const lineNum = loc ? String(loc.line) : "";
1693
+ const pad = loc ? " ".repeat(lineNum.length) : "";
1694
+ const locationLines = loc ? [
1695
+ ` ${loc.file}:${loc.line}:${loc.column}`,
1696
+ ` ${lineNum} \u2502 ${loc.lineText}`,
1697
+ ` ${pad} \u2502 ${" ".repeat(loc.column)}${"~".repeat(Math.max(1, loc.length))}`
1698
+ ] : [];
1699
+ const noteLines = msg.notes.filter((n) => n.text).map((n) => ` Note: ${n.text}`);
1700
+ return [`[${i + 1}] ${msg.text}`].concat(locationLines, noteLines).join("\n");
1701
+ }).join("\n\n");
1702
+ }
1703
+ const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1704
+ return raw.replace(/\x1b\[[0-9;]*[mGKH]/g, "").replace(/\r\n/g, "\n");
1705
+ }
1415
1706
  async function buildTestBundle(config, cachedContent) {
1416
1707
  const { projectRoot, output } = config;
1417
1708
  const allTestFilePaths = Object.keys(config.fsTree);
@@ -1428,8 +1719,12 @@ async function buildTestBundle(config, cachedContent) {
1428
1719
  contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
1429
1720
  resolveDir: process.cwd()
1430
1721
  },
1722
+ // Allow test files outside the project root (e.g. /tmp/my-test.ts) to import
1723
+ // packages from any node_modules on the ancestor chain of cwd — the same lookup
1724
+ // order Node itself uses when resolving require() from process.cwd().
1725
+ nodePaths: ANCESTOR_NODE_MODULES,
1431
1726
  bundle: true,
1432
- logLevel: "error",
1727
+ logLevel: "silent",
1433
1728
  outfile,
1434
1729
  keepNames: true,
1435
1730
  legalComments: "none",
@@ -1441,26 +1736,47 @@ async function buildTestBundle(config, cachedContent) {
1441
1736
  // all browsers and does not require changes to user test code.
1442
1737
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1443
1738
  };
1444
- const [allTestCode] = await Promise.all([
1445
- config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
1446
- Promise.all(
1447
- cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
1448
- const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
1449
- if (htmlPath !== "/") {
1450
- await fs9.rm(targetPath, { force: true, recursive: true });
1451
- await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
1452
- }
1453
- })
1454
- )
1455
- ]);
1456
- cachedContent.allTestCode = allTestCode;
1739
+ cachedContent._buildError = null;
1740
+ cachedContent._noTestsWarning = null;
1741
+ try {
1742
+ const [allTestCode] = await Promise.all([
1743
+ config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
1744
+ Promise.all(
1745
+ cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
1746
+ const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
1747
+ if (htmlPath !== "/") {
1748
+ await fs9.rm(targetPath, { force: true, recursive: true });
1749
+ await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
1750
+ }
1751
+ })
1752
+ )
1753
+ ]);
1754
+ cachedContent.allTestCode = allTestCode;
1755
+ } catch (error) {
1756
+ cachedContent._buildError = {
1757
+ type: deriveBuildErrorType(error),
1758
+ formatted: formatBuildErrors(error)
1759
+ };
1760
+ await fs9.writeFile(
1761
+ `${projectRoot}/${output}/index.html`,
1762
+ buildErrorHTML(cachedContent._buildError)
1763
+ );
1764
+ throw error;
1765
+ }
1457
1766
  }
1458
1767
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1459
1768
  const { projectRoot, output } = config;
1460
1769
  const allTestFilePaths = Object.keys(config.fsTree);
1461
1770
  const runHasFilter = !!targetTestFilesToFilter;
1462
1771
  if (!config._groupMode) {
1463
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
1772
+ config.COUNTER = {
1773
+ testCount: 0,
1774
+ failCount: 0,
1775
+ skipCount: 0,
1776
+ todoCount: 0,
1777
+ passCount: 0,
1778
+ errorCount: 0
1779
+ };
1464
1780
  }
1465
1781
  config.lastRanTestFiles = targetTestFilesToFilter || allTestFilePaths;
1466
1782
  try {
@@ -1492,11 +1808,26 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1492
1808
  }
1493
1809
  const TIME_TAKEN = TIME_COUNTER.stop();
1494
1810
  if (!config._groupMode) {
1811
+ if (config.COUNTER.testCount === 0 && !cachedContent._buildError) {
1812
+ const displayFiles = allTestFilePaths.map(
1813
+ (f) => f.startsWith(`${projectRoot}/`) ? f.slice(projectRoot.length + 1) : f
1814
+ );
1815
+ cachedContent._noTestsWarning = displayFiles;
1816
+ const fileWord = allTestFilePaths.length === 1 ? "file" : "files";
1817
+ console.log(
1818
+ `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
1819
+ );
1820
+ fs9.writeFile(`${projectRoot}/${output}/index.html`, buildNoTestsHTML(displayFiles)).catch(
1821
+ () => {
1822
+ }
1823
+ );
1824
+ }
1495
1825
  TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
1496
1826
  if (config.after) {
1497
1827
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
1498
1828
  }
1499
1829
  if (!config.watch) {
1830
+ await flushConsoleHandlers(config._pendingConsoleHandlers);
1500
1831
  await Promise.all([
1501
1832
  connections.server && connections.server.close(),
1502
1833
  connections.browser && connections.browser.close()
@@ -1507,8 +1838,18 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1507
1838
  }
1508
1839
  } catch (error) {
1509
1840
  config.lastFailedTestFiles = config.lastRanTestFiles;
1510
- console.log(error);
1511
1841
  const exception = new BundleError(error);
1842
+ if (!cachedContent._buildError && error.errors?.length) {
1843
+ cachedContent._buildError = {
1844
+ type: deriveBuildErrorType(error),
1845
+ formatted: formatBuildErrors(error)
1846
+ };
1847
+ fs9.writeFile(
1848
+ `${projectRoot}/${output}/qunitx.html`,
1849
+ buildErrorHTML(cachedContent._buildError)
1850
+ ).catch(() => {
1851
+ });
1852
+ }
1512
1853
  if (config.watch) {
1513
1854
  console.log(`# ${exception}`);
1514
1855
  } else {
@@ -1526,8 +1867,9 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1526
1867
  contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
1527
1868
  resolveDir: process.cwd()
1528
1869
  },
1870
+ nodePaths: ANCESTOR_NODE_MODULES,
1529
1871
  bundle: true,
1530
- logLevel: "error",
1872
+ logLevel: "silent",
1531
1873
  outfile: outputPath,
1532
1874
  legalComments: "none",
1533
1875
  target: esbuildTarget(config.browser),
@@ -1542,15 +1884,13 @@ async function runWithOverlayfsRetry(getContents, needsDisk) {
1542
1884
  const MAX_RETRIES = 3;
1543
1885
  const EMPTY_BUNDLE_THRESHOLD = 500;
1544
1886
  let { result, js } = await getContents();
1887
+ const initialSize = js.length;
1545
1888
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
1546
1889
  if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
1547
- console.log(
1548
- `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes (< ${EMPTY_BUNDLE_THRESHOLD}) on attempt ${retry}/${MAX_RETRIES} \u2014 overlayfs flush race, retrying in ${RETRY_DELAY_MS}ms`
1549
- );
1550
1890
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
1551
1891
  ({ result, js } = await getContents());
1552
1892
  }
1553
- if (js.length < EMPTY_BUNDLE_THRESHOLD) {
1893
+ if (js.length < EMPTY_BUNDLE_THRESHOLD && js.length !== initialSize) {
1554
1894
  console.log(
1555
1895
  `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1556
1896
  );
@@ -1629,7 +1969,7 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1629
1969
  clearTimeout(timeoutHandle);
1630
1970
  timeoutHandle = setTimeout(resolveTestRace, startupMs);
1631
1971
  await testRaceResult;
1632
- QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
1972
+ QUNIT_RESULT = config._lastQUnitResult ?? await page.evaluate(() => window.QUNIT_RESULT);
1633
1973
  } catch (error) {
1634
1974
  targetError = error;
1635
1975
  console.log(error);
@@ -1640,14 +1980,22 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1640
1980
  config._onTestsJsServed = null;
1641
1981
  config._resetTestTimeout = null;
1642
1982
  config._testRunDone = null;
1983
+ config._lastQUnitResult = null;
1643
1984
  }
1644
- if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
1985
+ if (!QUNIT_RESULT) {
1645
1986
  if (targetError) console.log(targetError);
1646
1987
  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";
1647
1988
  console.log(`# TIMEOUT: ${wsReason}`);
1648
1989
  console.log("BROWSER: runtime error thrown during executing tests");
1649
1990
  console.error("BROWSER: runtime error thrown during executing tests");
1650
- await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
1991
+ await failOnNonWatchMode(
1992
+ config.watch,
1993
+ { server, browser },
1994
+ config._groupMode,
1995
+ config._pendingConsoleHandlers
1996
+ );
1997
+ } else if (QUNIT_RESULT.totalTests === 0) {
1998
+ return;
1651
1999
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
1652
2000
  if (targetError) console.log(targetError);
1653
2001
  console.log(
@@ -1655,16 +2003,22 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1655
2003
  );
1656
2004
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1657
2005
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1658
- await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
2006
+ await failOnNonWatchMode(
2007
+ config.watch,
2008
+ { server, browser },
2009
+ config._groupMode,
2010
+ config._pendingConsoleHandlers
2011
+ );
1659
2012
  } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
1660
2013
  config.COUNTER.failCount = QUNIT_RESULT.failedTests;
1661
2014
  }
1662
2015
  }
1663
- async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false) {
2016
+ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false, pendingHandlers) {
1664
2017
  if (!watchMode) {
1665
2018
  if (groupMode) {
1666
2019
  throw new Error("Browser test run failed");
1667
2020
  }
2021
+ await flushConsoleHandlers(pendingHandlers);
1668
2022
  await Promise.all([
1669
2023
  connections.server && connections.server.close(),
1670
2024
  connections.browser && connections.browser.close()
@@ -1673,7 +2027,12 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
1673
2027
  process.exit(1);
1674
2028
  }
1675
2029
  }
1676
- var BundleError;
2030
+ async function flushConsoleHandlers(handlers, deadline = Date.now() + 2e3) {
2031
+ if (!handlers || handlers.size === 0 || Date.now() >= deadline) return;
2032
+ await Promise.allSettled([...handlers]);
2033
+ return flushConsoleHandlers(handlers, deadline);
2034
+ }
2035
+ var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES;
1677
2036
  var init_tests_in_browser = __esm({
1678
2037
  "lib/commands/run/tests-in-browser.ts"() {
1679
2038
  init_color();
@@ -1681,6 +2040,7 @@ var init_tests_in_browser = __esm({
1681
2040
  init_time_counter();
1682
2041
  init_run_user_module();
1683
2042
  init_display_final_result();
2043
+ init_web_server();
1684
2044
  BundleError = class extends Error {
1685
2045
  constructor(message) {
1686
2046
  super(message);
@@ -1688,13 +2048,17 @@ var init_tests_in_browser = __esm({
1688
2048
  this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
1689
2049
  }
1690
2050
  };
2051
+ ancestorNodeModules = (dir) => dir.split(path5.sep).map(
2052
+ (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2053
+ );
2054
+ ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
1691
2055
  }
1692
2056
  });
1693
2057
 
1694
2058
  // lib/setup/file-watcher.ts
1695
2059
  import fs10 from "node:fs";
1696
2060
  import { stat, lstat } from "node:fs/promises";
1697
- import path5 from "node:path";
2061
+ import path6 from "node:path";
1698
2062
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1699
2063
  const extensions = config.extensions || ["js", "ts"];
1700
2064
  const readyPromises = [];
@@ -1724,7 +2088,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1724
2088
  const lastChangeMs = {};
1725
2089
  const childWatcher = fs10.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1726
2090
  if (!ready || !filename) return;
1727
- const fullPath = path5.join(watchPath, filename);
2091
+ const fullPath = filename === path6.basename(watchPath) ? watchPath : path6.join(watchPath, filename);
1728
2092
  if (eventType === "change") {
1729
2093
  if (!config._building) {
1730
2094
  const now = Date.now();
@@ -1749,8 +2113,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1749
2113
  }
1750
2114
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1751
2115
  });
1752
- const parentDir = path5.dirname(watchPath);
1753
- const watchedBasename = path5.basename(watchPath);
2116
+ const parentDir = path6.dirname(watchPath);
2117
+ const watchedBasename = path6.basename(watchPath);
1754
2118
  let parentUnlinkFired = false;
1755
2119
  const parentWatcher = fs10.watch(parentDir, async (eventType, filename) => {
1756
2120
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
@@ -1825,7 +2189,8 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1825
2189
  "#",
1826
2190
  magenta().bold("==================================================================")
1827
2191
  );
1828
- console.log("#", colorEvent(event), filePath.split(config.projectRoot)[1]);
2192
+ const displayPath = filePath.startsWith(config.projectRoot) ? filePath.slice(config.projectRoot.length) : filePath;
2193
+ console.log("#", colorEvent(event), displayPath);
1829
2194
  console.log(
1830
2195
  "#",
1831
2196
  magenta().bold("==================================================================")
@@ -1852,13 +2217,13 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1852
2217
  }
1853
2218
  });
1854
2219
  }
1855
- function mutateFSTree(fsTree, event, path6) {
2220
+ function mutateFSTree(fsTree, event, path7) {
1856
2221
  if (event === "add") {
1857
- fsTree[path6] = null;
2222
+ fsTree[path7] = null;
1858
2223
  } else if (event === "unlink") {
1859
- delete fsTree[path6];
2224
+ delete fsTree[path7];
1860
2225
  } else if (event === "unlinkDir") {
1861
- const dirPrefix = path6.endsWith("/") ? path6 : path6 + "/";
2226
+ const dirPrefix = path7.endsWith("/") ? path7 : path7 + "/";
1862
2227
  for (const treePath of Object.keys(fsTree)) {
1863
2228
  if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
1864
2229
  }
@@ -1873,7 +2238,7 @@ var CHANGE_DEDUPE_MS;
1873
2238
  var init_file_watcher = __esm({
1874
2239
  "lib/setup/file-watcher.ts"() {
1875
2240
  init_color();
1876
- CHANGE_DEDUPE_MS = 30;
2241
+ CHANGE_DEDUPE_MS = 10;
1877
2242
  }
1878
2243
  });
1879
2244
 
@@ -1990,14 +2355,18 @@ import { availableParallelism } from "node:os";
1990
2355
  async function run(config) {
1991
2356
  const cachedContent = await buildCachedContent(config, config.htmlPaths);
1992
2357
  if (config.watch) {
1993
- cachedContent._preBuildPromise = buildTestBundle(config, cachedContent);
2358
+ const preBuildPromise = buildTestBundle(config, cachedContent);
2359
+ preBuildPromise.catch(() => {
2360
+ });
2361
+ cachedContent._preBuildPromise = preBuildPromise;
1994
2362
  const [connections] = await Promise.all([
1995
2363
  setupBrowser(config, cachedContent),
1996
2364
  writeOutputStaticFiles(config, cachedContent)
1997
2365
  ]);
1998
2366
  config.expressApp = connections.server;
1999
2367
  setupKeyboardEvents(config, cachedContent, connections);
2000
- if (config.open) {
2368
+ const isHeadedWatchMode = config.open === true && config.watch;
2369
+ if (config.open && !isHeadedWatchMode) {
2001
2370
  void openOutputInBrowser(config);
2002
2371
  }
2003
2372
  if (config.before) {
@@ -2012,6 +2381,10 @@ async function run(config) {
2012
2381
  ]);
2013
2382
  throw error;
2014
2383
  }
2384
+ if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2385
+ await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2386
+ });
2387
+ }
2015
2388
  if (config.watch) {
2016
2389
  const { ready: watcherReady } = setupFileWatchers(
2017
2390
  config.testFileLookupPaths,
@@ -2035,7 +2408,13 @@ async function run(config) {
2035
2408
  }
2036
2409
  await runTestsInBrowser(config, cachedContent, connections, [file]);
2037
2410
  },
2038
- (_path, _event) => connections.server.publish("refresh")
2411
+ async (_path, _event) => {
2412
+ connections.server.publish("refresh");
2413
+ if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2414
+ await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2415
+ });
2416
+ }
2417
+ }
2039
2418
  );
2040
2419
  await watcherReady;
2041
2420
  }
@@ -2043,8 +2422,15 @@ async function run(config) {
2043
2422
  } else {
2044
2423
  const allFiles = Object.keys(config.fsTree);
2045
2424
  const groupCount = Math.min(allFiles.length, availableParallelism());
2046
- const groups = splitIntoGroups(allFiles, groupCount);
2047
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
2425
+ const groups = await splitIntoGroups(allFiles, groupCount);
2426
+ config.COUNTER = {
2427
+ testCount: 0,
2428
+ failCount: 0,
2429
+ skipCount: 0,
2430
+ todoCount: 0,
2431
+ passCount: 0,
2432
+ errorCount: 0
2433
+ };
2048
2434
  config.lastRanTestFiles = allFiles;
2049
2435
  const groupConfigs = groups.map((groupFiles, i) => ({
2050
2436
  ...config,
@@ -2105,6 +2491,7 @@ async function run(config) {
2105
2491
  try {
2106
2492
  await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
2107
2493
  } finally {
2494
+ await flushConsoleHandlers(groupConfig._pendingConsoleHandlers);
2108
2495
  await Promise.all([
2109
2496
  connections.server && connections.server.close(),
2110
2497
  connections.page && // Unref'd: the keepAlive interval above holds the event loop open, so this
@@ -2133,6 +2520,12 @@ async function run(config) {
2133
2520
  config.COUNTER.failCount > 0 ? 1 : 0
2134
2521
  );
2135
2522
  process.exitCode = exitCode;
2523
+ if (config.COUNTER.testCount === 0 && exitCode === 0) {
2524
+ const fileWord = allFiles.length === 1 ? "file" : "files";
2525
+ console.log(
2526
+ `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allFiles.length} ${fileWord}`
2527
+ );
2528
+ }
2136
2529
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
2137
2530
  if (config.after) {
2138
2531
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
@@ -2204,10 +2597,24 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
2204
2597
  }
2205
2598
  return cachedContent;
2206
2599
  }
2207
- function splitIntoGroups(files, groupCount) {
2208
- const groups = Array.from({ length: groupCount }, () => []);
2209
- files.forEach((file, i) => groups[i % groupCount].push(file));
2210
- return groups.filter((group) => group.length > 0);
2600
+ async function splitIntoGroups(files, groupCount) {
2601
+ const withSizes = await Promise.all(
2602
+ files.map(
2603
+ (f) => fs12.stat(f).then(({ size }) => ({ f, size })).catch(() => ({ f, size: 0 }))
2604
+ )
2605
+ );
2606
+ return withSizes.sort((a, b) => b.size - a.size).reduce(
2607
+ (groups, { f, size }) => {
2608
+ const { idx } = groups.reduce(
2609
+ (min, { total }, i) => total < min.total ? { idx: i, total } : min,
2610
+ { idx: 0, total: groups[0].total }
2611
+ );
2612
+ groups[idx].files.push(f);
2613
+ groups[idx].total += size;
2614
+ return groups;
2615
+ },
2616
+ Array.from({ length: groupCount }, () => ({ files: [], total: 0 }))
2617
+ ).flatMap((g) => g.files.length > 0 ? [g.files] : []);
2211
2618
  }
2212
2619
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
2213
2620
  const prefix = "Watching files...";
@@ -2256,7 +2663,7 @@ init_color();
2256
2663
  var package_default = {
2257
2664
  name: "qunitx-cli",
2258
2665
  type: "module",
2259
- version: "0.18.0",
2666
+ version: "0.19.1",
2260
2667
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2261
2668
  author: "Izel Nakri",
2262
2669
  license: "MIT",
@@ -2488,17 +2895,17 @@ function pathToModuleName(filePath) {
2488
2895
  async function generateTestFiles() {
2489
2896
  const projectRoot = await findProjectRoot();
2490
2897
  const moduleName = pathToModuleName(process.argv[3]);
2491
- const path6 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2492
- if (await pathExists(path6)) {
2493
- console.log(`${path6} already exists!`);
2898
+ const path7 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2899
+ if (await pathExists(path7)) {
2900
+ console.log(`${path7} already exists!`);
2494
2901
  return;
2495
2902
  }
2496
2903
  const testJSContent = await readTemplate("test.js");
2497
- const targetFolderPaths = path6.split("/");
2904
+ const targetFolderPaths = path7.split("/");
2498
2905
  targetFolderPaths.pop();
2499
2906
  await fs5.mkdir(targetFolderPaths.join("/"), { recursive: true });
2500
- await fs5.writeFile(path6, testJSContent.replace("{{moduleName}}", moduleName));
2501
- console.log(green(`${path6} written`));
2907
+ await fs5.writeFile(path7, testJSContent.replace("{{moduleName}}", moduleName));
2908
+ console.log(green(`${path7} written`));
2502
2909
  }
2503
2910
 
2504
2911
  // lib/setup/config.ts
@@ -2599,20 +3006,20 @@ function setupTestFilePaths(_projectRoot, inputs2) {
2599
3006
  });
2600
3007
  return result.map((metaItem) => metaItem.input);
2601
3008
  }
2602
- function pathIsFile(path6) {
2603
- const inputs2 = path6.split("/");
3009
+ function pathIsFile(path7) {
3010
+ const inputs2 = path7.split("/");
2604
3011
  return inputs2[inputs2.length - 1].includes(".");
2605
3012
  }
2606
3013
  function pathIsIncludedInPaths(paths, targetPath) {
2607
- return paths.some((path6) => {
2608
- if (path6 === targetPath) {
3014
+ return paths.some((path7) => {
3015
+ if (path7 === targetPath) {
2609
3016
  return false;
2610
3017
  }
2611
- return matchesGlob(targetPath.input, buildGlobFormat(path6));
3018
+ return matchesGlob(targetPath.input, buildGlobFormat(path7));
2612
3019
  });
2613
3020
  }
2614
- function buildGlobFormat(path6) {
2615
- return path6.isFile ? path6.input : `${path6.input}/**`;
3021
+ function buildGlobFormat(path7) {
3022
+ return path7.isFile ? path7.input : `${path7.input}/**`;
2616
3023
  }
2617
3024
 
2618
3025
  // lib/utils/parse-cli-flags.ts
@@ -2666,7 +3073,9 @@ function parseCliFlags(projectRoot) {
2666
3073
  console.warn(`# Warning: Unknown flag "${arg}" \u2014 ignored`);
2667
3074
  return result;
2668
3075
  }
2669
- result.inputs.add(arg.startsWith(projectRoot) ? arg : `${process.cwd()}/${arg}`);
3076
+ result.inputs.add(
3077
+ arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : `${process.cwd()}/${arg}`
3078
+ );
2670
3079
  return result;
2671
3080
  },
2672
3081
  { inputs: /* @__PURE__ */ new Set([]) }
@@ -2704,7 +3113,14 @@ async function setupConfig() {
2704
3113
  testFileLookupPaths: setupTestFilePaths(projectRoot, inputs2),
2705
3114
  lastFailedTestFiles: null,
2706
3115
  lastRanTestFiles: null,
2707
- COUNTER: { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 },
3116
+ COUNTER: {
3117
+ testCount: 0,
3118
+ failCount: 0,
3119
+ skipCount: 0,
3120
+ todoCount: 0,
3121
+ passCount: 0,
3122
+ errorCount: 0
3123
+ },
2708
3124
  _testRunDone: null,
2709
3125
  _resetTestTimeout: null,
2710
3126
  _onWsOpen: null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.18.0",
4
+ "version": "0.19.1",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",