qunitx-cli 0.19.0 → 0.19.2

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 +237 -117
  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;
@@ -385,21 +385,6 @@ var init_color = __esm({
385
385
  }
386
386
  });
387
387
 
388
- // lib/utils/path-exists.ts
389
- import fs2 from "node:fs/promises";
390
- async function pathExists(path7) {
391
- try {
392
- await fs2.access(path7);
393
- return true;
394
- } catch {
395
- return false;
396
- }
397
- }
398
- var init_path_exists = __esm({
399
- "lib/utils/path-exists.ts"() {
400
- }
401
- });
402
-
403
388
  // lib/utils/read-template.ts
404
389
  import fs3 from "node:fs/promises";
405
390
  import { dirname, join as join2 } from "node:path";
@@ -906,9 +891,10 @@ function setupWebServer(config, cachedContent) {
906
891
  cachedContent.mainHTML.filePath,
907
892
  config.projectRoot
908
893
  );
894
+ const runtimeScript = testRuntimeToInject(config);
909
895
  server.wss.on("connection", function connection(socket) {
910
896
  socket.on("message", function message(data) {
911
- const { event, details, abort } = JSON.parse(data);
897
+ const { event, details, qunitResult, abort } = JSON.parse(data);
912
898
  if (event === "wsOpen") {
913
899
  config._phase = "loading";
914
900
  config._onWsOpen?.();
@@ -941,6 +927,7 @@ function setupWebServer(config, cachedContent) {
941
927
  TAPDisplayTestResult(config.COUNTER, details);
942
928
  } else if (event === "done") {
943
929
  config._phase = "done";
930
+ config._lastQUnitResult = qunitResult ?? null;
944
931
  if (config.debug && config._groupMode) {
945
932
  process.stdout.write(
946
933
  `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)
@@ -956,7 +943,7 @@ function setupWebServer(config, cachedContent) {
956
943
  });
957
944
  server.get("/tests.js", (_req, res) => {
958
945
  const bytes = cachedContent.allTestCode?.length ?? null;
959
- process.stdout.write(
946
+ config.debug && process.stdout.write(
960
947
  `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}
961
948
  `
962
949
  );
@@ -968,12 +955,16 @@ function setupWebServer(config, cachedContent) {
968
955
  return;
969
956
  }
970
957
  config._onTestsJsServed?.();
971
- res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
958
+ res.writeHead(200, {
959
+ "Content-Type": "application/javascript",
960
+ "Cache-Control": "no-store",
961
+ "Content-Length": bytes
962
+ });
972
963
  res.end(cachedContent.allTestCode);
973
964
  });
974
965
  server.get("/filtered-tests.js", (_req, res) => {
975
966
  const bytes = cachedContent.filteredTestCode?.length ?? null;
976
- process.stdout.write(
967
+ config.debug && process.stdout.write(
977
968
  `# [HTTPServer] GET /filtered-tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (filteredTestCode is null)"}
978
969
  `
979
970
  );
@@ -985,7 +976,11 @@ function setupWebServer(config, cachedContent) {
985
976
  return;
986
977
  }
987
978
  config._onTestsJsServed?.();
988
- res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
979
+ res.writeHead(200, {
980
+ "Content-Type": "application/javascript",
981
+ "Cache-Control": "no-store",
982
+ "Content-Length": bytes
983
+ });
989
984
  res.end(cachedContent.filteredTestCode);
990
985
  });
991
986
  server.get("/", async (_req, res) => {
@@ -1006,10 +1001,9 @@ function setupWebServer(config, cachedContent) {
1006
1001
  res.end();
1007
1002
  return;
1008
1003
  }
1009
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1010
1004
  const htmlContent = escapeAndInjectTestsToHTML(
1011
1005
  mainHTMLWithReplacedAssets,
1012
- TEST_RUNTIME_TO_INJECT,
1006
+ runtimeScript,
1013
1007
  "./tests.js"
1014
1008
  );
1015
1009
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1038,10 +1032,9 @@ function setupWebServer(config, cachedContent) {
1038
1032
  res.end();
1039
1033
  return;
1040
1034
  }
1041
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1042
1035
  const htmlContent = escapeAndInjectTestsToHTML(
1043
1036
  mainHTMLWithReplacedAssets,
1044
- TEST_RUNTIME_TO_INJECT,
1037
+ runtimeScript,
1045
1038
  "./filtered-tests.js"
1046
1039
  );
1047
1040
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1055,10 +1048,9 @@ function setupWebServer(config, cachedContent) {
1055
1048
  server.get("/*", async (req, res) => {
1056
1049
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
1057
1050
  if (possibleDynamicHTML) {
1058
- const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
1059
1051
  const htmlContent = escapeAndInjectTestsToHTML(
1060
1052
  possibleDynamicHTML,
1061
- TEST_RUNTIME_TO_INJECT,
1053
+ runtimeScript,
1062
1054
  "/tests.js"
1063
1055
  );
1064
1056
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
@@ -1070,21 +1062,26 @@ function setupWebServer(config, cachedContent) {
1070
1062
  );
1071
1063
  }
1072
1064
  const url = req.url;
1073
- const requestStartedAt = /* @__PURE__ */ new Date();
1065
+ const requestStartedAt = Date.now();
1074
1066
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
1075
- const statusCode = await pathExists(filePath) ? 200 : 404;
1076
- res.writeHead(statusCode, {
1077
- "Content-Type": req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html
1067
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1068
+ const stream = fs8.createReadStream(filePath);
1069
+ stream.on("open", () => {
1070
+ res.writeHead(200, { "Content-Type": contentType });
1071
+ stream.pipe(res);
1072
+ config.debug && process.stdout.write(
1073
+ `# [HTTPServer] GET ${url} 200 - ${Date.now() - requestStartedAt}ms
1074
+ `
1075
+ );
1078
1076
  });
1079
- if (statusCode === 404) {
1080
- res.end();
1081
- } else {
1082
- fs8.createReadStream(filePath).pipe(res);
1083
- }
1084
- process.stdout.write(
1085
- `# [HTTPServer] GET ${url} ${statusCode} - ${/* @__PURE__ */ new Date() - requestStartedAt}ms
1077
+ stream.on("error", () => {
1078
+ res.writeHead(404, { "Content-Type": contentType });
1079
+ res.end(contentType === MIME_TYPES.html ? NOT_FOUND_HTML : void 0);
1080
+ config.debug && process.stdout.write(
1081
+ `# [HTTPServer] GET ${url} 404 - ${Date.now() - requestStartedAt}ms
1086
1082
  `
1087
- );
1083
+ );
1084
+ });
1088
1085
  });
1089
1086
  return server;
1090
1087
  }
@@ -1096,7 +1093,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
1096
1093
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1097
1094
  }, html);
1098
1095
  }
1099
- function testRuntimeToInject(port, config) {
1096
+ function testRuntimeToInject(config) {
1100
1097
  return `<script>
1101
1098
  window.testTimeout = 0;
1102
1099
  setInterval(() => {
@@ -1132,7 +1129,7 @@ function testRuntimeToInject(port, config) {
1132
1129
 
1133
1130
  function setupWebSocket() {
1134
1131
  try {
1135
- window.socket = new WebSocket('ws://localhost:${port}');
1132
+ window.socket = new WebSocket(\`ws://localhost:\${location.port}\`);
1136
1133
  } catch (error) {
1137
1134
  console.log(error);
1138
1135
  retryOrFail();
@@ -1144,7 +1141,7 @@ function testRuntimeToInject(port, config) {
1144
1141
  // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
1145
1142
  // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1146
1143
  // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1147
- if (window.IS_PLAYWRIGHT) {
1144
+ if (navigator.webdriver) {
1148
1145
  window.socket.send(JSON.stringify({ event: 'wsOpen' }));
1149
1146
  }
1150
1147
  maybeStart();
@@ -1153,9 +1150,9 @@ function testRuntimeToInject(port, config) {
1153
1150
  retryOrFail();
1154
1151
  });
1155
1152
  window.socket.addEventListener('message', function(messageEvent) {
1156
- if (!window.IS_PLAYWRIGHT && messageEvent.data === 'refresh') {
1153
+ if (!navigator.webdriver && messageEvent.data === 'refresh') {
1157
1154
  window.location.reload(true);
1158
- } else if (window.IS_PLAYWRIGHT && messageEvent.data === 'abort') {
1155
+ } else if (navigator.webdriver && messageEvent.data === 'abort') {
1159
1156
  window.abortQUnit = true;
1160
1157
  window.QUnit.config.queue.length = 0;
1161
1158
  window.socket.send(JSON.stringify({ event: 'abort' }));
@@ -1198,7 +1195,7 @@ function testRuntimeToInject(port, config) {
1198
1195
 
1199
1196
  if (!window.QUnit) {
1200
1197
  console.log('QUnit not found after WebSocket connected');
1201
- if (window.IS_PLAYWRIGHT) {
1198
+ if (navigator.webdriver) {
1202
1199
  // Signal the Playwright runner that the run is complete with 0 tests rather than
1203
1200
  // waiting for the inactivity timeout. The runner treats totalTests === 0 as a
1204
1201
  // "no tests registered" warning (not a failure), so this gives a fast, clean result.
@@ -1211,15 +1208,10 @@ function testRuntimeToInject(port, config) {
1211
1208
  }
1212
1209
 
1213
1210
  window.QUnit.begin(() => { // NOTE: might be useful in future for hanged module tracking
1214
- if (window.IS_PLAYWRIGHT) {
1211
+ if (navigator.webdriver) {
1215
1212
  window.socket.send(JSON.stringify({ event: 'connection' }));
1216
1213
  }
1217
1214
  });
1218
- window.QUnit.moduleStart((details) => { // NOTE: might be useful in future for hanged module tracking
1219
- if (window.IS_PLAYWRIGHT) {
1220
- window.socket.send(JSON.stringify({ event: 'moduleStart', details: details }, getCircularReplacer()));
1221
- }
1222
- });
1223
1215
  window.QUnit.on('testStart', (details) => {
1224
1216
  window.QUNIT_RESULT.totalTests++;
1225
1217
  window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
@@ -1229,8 +1221,10 @@ function testRuntimeToInject(port, config) {
1229
1221
  window.QUNIT_RESULT.finishedTests++;
1230
1222
  if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
1231
1223
  window.QUNIT_RESULT.currentTest = null;
1232
- if (window.IS_PLAYWRIGHT) {
1233
- window.socket.send(JSON.stringify({ event: 'testEnd', details: details, abort: window.abortQUnit }, getCircularReplacer()));
1224
+ if (navigator.webdriver) {
1225
+ const isFailed = details.status === 'failed';
1226
+ const payload = isFailed ? details : { status: details.status, fullName: details.fullName, runtime: details.runtime };
1227
+ window.socket.send(JSON.stringify({ event: 'testEnd', details: payload, abort: window.abortQUnit }, isFailed ? getCircularReplacer() : undefined));
1234
1228
 
1235
1229
  if (${config.failFast} && details.status === 'failed') {
1236
1230
  window.QUnit.config.queue.length = 0;
@@ -1238,8 +1232,8 @@ function testRuntimeToInject(port, config) {
1238
1232
  }
1239
1233
  });
1240
1234
  window.QUnit.done((details) => {
1241
- if (window.IS_PLAYWRIGHT) {
1242
- window.socket.send(JSON.stringify({ event: 'done', details: details, abort: window.abortQUnit }, getCircularReplacer()));
1235
+ if (navigator.webdriver) {
1236
+ window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1243
1237
  // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
1244
1238
  // canonical completion signal for Playwright runs. waitForFunction is reserved
1245
1239
  // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
@@ -1329,7 +1323,7 @@ function buildNoTestsHTML(files) {
1329
1323
  </head>
1330
1324
  <body>
1331
1325
  <div id="qunit">
1332
- <h1 id="qunit-header">qunitx</h1>
1326
+ <h1 id="qunit-header"><a href="/" style="color:inherit;text-decoration:none">qunitx</a></h1>
1333
1327
  <h2 id="qunit-banner"></h2>
1334
1328
  <div id="qunit-userAgent">Warning: No Tests Registered</div>
1335
1329
  <ol id="qunit-tests">
@@ -1349,7 +1343,7 @@ function buildNoTestsHTML(files) {
1349
1343
  (function () {
1350
1344
  var retries = 0;
1351
1345
  function connect() {
1352
- var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1346
+ var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1353
1347
  ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1354
1348
  ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1355
1349
  ws.addEventListener('error', function () { ws.close(); });
@@ -1431,7 +1425,7 @@ function buildErrorHTML(buildError) {
1431
1425
  </head>
1432
1426
  <body>
1433
1427
  <div id="qunit">
1434
- <h1 id="qunit-header">qunitx</h1>
1428
+ <h1 id="qunit-header"><a href="/" style="color:inherit;text-decoration:none">qunitx</a></h1>
1435
1429
  <h2 id="qunit-banner"></h2>
1436
1430
  <div id="qunit-userAgent">Build Error: ${buildError.type}</div>
1437
1431
  <ol id="qunit-tests">
@@ -1451,7 +1445,7 @@ function buildErrorHTML(buildError) {
1451
1445
  (function () {
1452
1446
  var retries = 0;
1453
1447
  function connect() {
1454
- var ws = new WebSocket('ws://' + location.hostname + ':' + location.port);
1448
+ var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1455
1449
  ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1456
1450
  ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1457
1451
  ws.addEventListener('error', function () { ws.close(); });
@@ -1463,16 +1457,42 @@ function buildErrorHTML(buildError) {
1463
1457
  </body>
1464
1458
  </html>`;
1465
1459
  }
1466
- var fsPromise;
1460
+ var fsPromise, NOT_FOUND_HTML;
1467
1461
  var init_web_server = __esm({
1468
1462
  "lib/setup/web-server.ts"() {
1469
1463
  init_find_internal_assets_from_html();
1470
1464
  init_html();
1471
1465
  init_display_test_result();
1472
1466
  init_color();
1473
- init_path_exists();
1474
1467
  init_http();
1475
1468
  fsPromise = fs8.promises;
1469
+ NOT_FOUND_HTML = `<!DOCTYPE html>
1470
+ <html lang="en">
1471
+ <head>
1472
+ <meta charset="utf-8">
1473
+ <meta name="viewport" content="width=device-width">
1474
+ <title>404 Not Found \u2014 qunitx</title>
1475
+ <style>
1476
+ *{box-sizing:border-box;margin:0;padding:0}
1477
+ body{font-family:"Helvetica Neue Light","HelveticaNeue-Light","Helvetica Neue",Calibri,Helvetica,Arial,sans-serif}
1478
+ #qunit-header{padding:.5em 0 .5em 1em;color:#C2CCD1;background-color:#0D3349;font-size:1.5em;line-height:1em;font-weight:400;border-radius:5px 5px 0 0}
1479
+ #qunit-banner{height:5px;background-color:#EE5757}
1480
+ #qunit-userAgent{padding:.5em 1em;color:#fff;background-color:#2B81AF;text-shadow:rgba(0,0,0,.5) 2px 2px 1px;font-size:small}
1481
+ #qunit-tests{list-style:none;font-size:smaller}
1482
+ #qunit-tests li{display:list-item;padding:.4em 1em;color:#000;background-color:#EE5757;border-radius:0 0 5px 5px}
1483
+ </style>
1484
+ </head>
1485
+ <body>
1486
+ <div id="qunit">
1487
+ <h1 id="qunit-header"><a href="/" style="color:inherit;text-decoration:none">qunitx</a></h1>
1488
+ <h2 id="qunit-banner"></h2>
1489
+ <div id="qunit-userAgent">404 Not Found</div>
1490
+ <ol id="qunit-tests">
1491
+ <li id="qunit-testresult"><script>document.getElementById('qunit-testresult').prepend(location.pathname)</script> was not found on this server.</li>
1492
+ </ol>
1493
+ </div>
1494
+ </body>
1495
+ </html>`;
1476
1496
  }
1477
1497
  });
1478
1498
 
@@ -1534,19 +1554,37 @@ async function setupBrowser(config, cachedContent, existingBrowser = null) {
1534
1554
  const getPage = isHeadedWatchMode ? () => browser.contexts()[0]?.pages()[0] ?? browser.newPage() : () => browser.newPage();
1535
1555
  const [page] = await Promise.all([getPage(), bindServerToPort(server, config)]);
1536
1556
  perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1537
- await page.addInitScript(() => {
1538
- window.IS_PLAYWRIGHT = true;
1539
- });
1540
- page.on("console", async (msg) => {
1557
+ if (config.browser === "firefox") {
1558
+ await page.addInitScript(() => {
1559
+ const preSerialize = (arg) => {
1560
+ if (arg === null || typeof arg !== "object") return arg;
1561
+ try {
1562
+ return JSON.stringify(arg, (_key, v) => v instanceof Date ? v.toISOString() : v);
1563
+ } catch {
1564
+ return String(arg);
1565
+ }
1566
+ };
1567
+ ["log", "warn", "error", "info", "debug"].forEach((method) => {
1568
+ const orig = console[method].bind(console);
1569
+ console[method] = (...args) => orig(...args.map(preSerialize));
1570
+ });
1571
+ });
1572
+ }
1573
+ config._pendingConsoleHandlers = /* @__PURE__ */ new Set();
1574
+ page.on("console", (msg) => {
1541
1575
  const type = msg.type();
1542
1576
  const alwaysShow = type === "warning" || type === "error";
1543
1577
  if (!alwaysShow && !config.debug) return;
1544
- try {
1545
- const values = await Promise.all(msg.args().map((arg) => arg.jsonValue()));
1546
- console.log(...values);
1547
- } catch {
1548
- console.log(msg.text());
1549
- }
1578
+ const handler = (async () => {
1579
+ try {
1580
+ const values = await Promise.all(msg.args().map((arg) => arg.jsonValue()));
1581
+ console.log(...values);
1582
+ } catch {
1583
+ console.log(msg.text());
1584
+ }
1585
+ })();
1586
+ config._pendingConsoleHandlers.add(handler);
1587
+ handler.finally(() => config._pendingConsoleHandlers?.delete(handler));
1550
1588
  });
1551
1589
  page.on("pageerror", (error) => {
1552
1590
  console.error(error.toString());
@@ -1712,7 +1750,7 @@ async function buildTestBundle(config, cachedContent) {
1712
1750
  // Allow test files outside the project root (e.g. /tmp/my-test.ts) to import
1713
1751
  // packages from any node_modules on the ancestor chain of cwd — the same lookup
1714
1752
  // order Node itself uses when resolving require() from process.cwd().
1715
- nodePaths: ancestorNodeModules(process.cwd()),
1753
+ nodePaths: ANCESTOR_NODE_MODULES,
1716
1754
  bundle: true,
1717
1755
  logLevel: "silent",
1718
1756
  outfile,
@@ -1817,6 +1855,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1817
1855
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
1818
1856
  }
1819
1857
  if (!config.watch) {
1858
+ await flushConsoleHandlers(config._pendingConsoleHandlers);
1820
1859
  await Promise.all([
1821
1860
  connections.server && connections.server.close(),
1822
1861
  connections.browser && connections.browser.close()
@@ -1856,7 +1895,7 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1856
1895
  contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
1857
1896
  resolveDir: process.cwd()
1858
1897
  },
1859
- nodePaths: ancestorNodeModules(process.cwd()),
1898
+ nodePaths: ANCESTOR_NODE_MODULES,
1860
1899
  bundle: true,
1861
1900
  logLevel: "silent",
1862
1901
  outfile: outputPath,
@@ -1958,7 +1997,7 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1958
1997
  clearTimeout(timeoutHandle);
1959
1998
  timeoutHandle = setTimeout(resolveTestRace, startupMs);
1960
1999
  await testRaceResult;
1961
- QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
2000
+ QUNIT_RESULT = config._lastQUnitResult ?? await page.evaluate(() => window.QUNIT_RESULT);
1962
2001
  } catch (error) {
1963
2002
  targetError = error;
1964
2003
  console.log(error);
@@ -1969,6 +2008,7 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1969
2008
  config._onTestsJsServed = null;
1970
2009
  config._resetTestTimeout = null;
1971
2010
  config._testRunDone = null;
2011
+ config._lastQUnitResult = null;
1972
2012
  }
1973
2013
  if (!QUNIT_RESULT) {
1974
2014
  if (targetError) console.log(targetError);
@@ -1976,7 +2016,12 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1976
2016
  console.log(`# TIMEOUT: ${wsReason}`);
1977
2017
  console.log("BROWSER: runtime error thrown during executing tests");
1978
2018
  console.error("BROWSER: runtime error thrown during executing tests");
1979
- await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
2019
+ await failOnNonWatchMode(
2020
+ config.watch,
2021
+ { server, browser },
2022
+ config._groupMode,
2023
+ config._pendingConsoleHandlers
2024
+ );
1980
2025
  } else if (QUNIT_RESULT.totalTests === 0) {
1981
2026
  return;
1982
2027
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
@@ -1986,16 +2031,22 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1986
2031
  );
1987
2032
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1988
2033
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1989
- await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
2034
+ await failOnNonWatchMode(
2035
+ config.watch,
2036
+ { server, browser },
2037
+ config._groupMode,
2038
+ config._pendingConsoleHandlers
2039
+ );
1990
2040
  } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
1991
2041
  config.COUNTER.failCount = QUNIT_RESULT.failedTests;
1992
2042
  }
1993
2043
  }
1994
- async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false) {
2044
+ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false, pendingHandlers) {
1995
2045
  if (!watchMode) {
1996
2046
  if (groupMode) {
1997
2047
  throw new Error("Browser test run failed");
1998
2048
  }
2049
+ await flushConsoleHandlers(pendingHandlers);
1999
2050
  await Promise.all([
2000
2051
  connections.server && connections.server.close(),
2001
2052
  connections.browser && connections.browser.close()
@@ -2004,7 +2055,12 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
2004
2055
  process.exit(1);
2005
2056
  }
2006
2057
  }
2007
- var BundleError, ancestorNodeModules;
2058
+ async function flushConsoleHandlers(handlers, deadline = Date.now() + 2e3) {
2059
+ if (!handlers || handlers.size === 0 || Date.now() >= deadline) return;
2060
+ await Promise.allSettled([...handlers]);
2061
+ return flushConsoleHandlers(handlers, deadline);
2062
+ }
2063
+ var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES;
2008
2064
  var init_tests_in_browser = __esm({
2009
2065
  "lib/commands/run/tests-in-browser.ts"() {
2010
2066
  init_color();
@@ -2023,6 +2079,7 @@ var init_tests_in_browser = __esm({
2023
2079
  ancestorNodeModules = (dir) => dir.split(path5.sep).map(
2024
2080
  (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2025
2081
  );
2082
+ ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
2026
2083
  }
2027
2084
  });
2028
2085
 
@@ -2209,7 +2266,7 @@ var CHANGE_DEDUPE_MS;
2209
2266
  var init_file_watcher = __esm({
2210
2267
  "lib/setup/file-watcher.ts"() {
2211
2268
  init_color();
2212
- CHANGE_DEDUPE_MS = 30;
2269
+ CHANGE_DEDUPE_MS = 10;
2213
2270
  }
2214
2271
  });
2215
2272
 
@@ -2317,7 +2374,9 @@ var init_write_output_static_files = __esm({
2317
2374
  // lib/commands/run.ts
2318
2375
  var run_exports = {};
2319
2376
  __export(run_exports, {
2377
+ computeFileTimes: () => computeFileTimes,
2320
2378
  default: () => run,
2379
+ readTimingCache: () => readTimingCache,
2321
2380
  run: () => run
2322
2381
  });
2323
2382
  import fs12 from "node:fs/promises";
@@ -2393,7 +2452,8 @@ async function run(config) {
2393
2452
  } else {
2394
2453
  const allFiles = Object.keys(config.fsTree);
2395
2454
  const groupCount = Math.min(allFiles.length, availableParallelism());
2396
- const groups = splitIntoGroups(allFiles, groupCount);
2455
+ const timings = await readTimingCache(config.projectRoot);
2456
+ const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings);
2397
2457
  config.COUNTER = {
2398
2458
  testCount: 0,
2399
2459
  failCount: 0,
@@ -2432,6 +2492,7 @@ async function run(config) {
2432
2492
  void openOutputInBrowser(config);
2433
2493
  }
2434
2494
  const TIME_COUNTER = timeCounter();
2495
+ const wallTimes = /* @__PURE__ */ new Map();
2435
2496
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
2436
2497
  const keepAlive = setInterval(() => {
2437
2498
  }, 1e3);
@@ -2451,34 +2512,36 @@ async function run(config) {
2451
2512
  }, GROUP_TIMEOUT_MS);
2452
2513
  timeoutId.unref();
2453
2514
  });
2454
- return Promise.race([
2455
- (async () => {
2456
- groupConfig._phase = "connecting";
2457
- const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
2458
- groupConfig.expressApp = connections.server;
2459
- if (config.before) {
2460
- await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, "before");
2461
- }
2462
- try {
2463
- await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
2464
- } finally {
2465
- await Promise.all([
2466
- connections.server && connections.server.close(),
2467
- connections.page && // Unref'd: the keepAlive interval above holds the event loop open, so this
2468
- // timer still fires if page.close() hangs, without preventing process exit later.
2469
- Promise.race([
2470
- connections.page.close(),
2471
- new Promise((resolve) => {
2472
- const pageCloseTimeoutId = setTimeout(resolve, 1e4);
2473
- pageCloseTimeoutId.unref();
2474
- })
2475
- ]).catch(() => {
2515
+ const startMs = Date.now();
2516
+ const work = (async () => {
2517
+ groupConfig._phase = "connecting";
2518
+ const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
2519
+ groupConfig.expressApp = connections.server;
2520
+ if (config.before) {
2521
+ await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, "before");
2522
+ }
2523
+ try {
2524
+ await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
2525
+ } finally {
2526
+ await flushConsoleHandlers(groupConfig._pendingConsoleHandlers);
2527
+ await Promise.all([
2528
+ connections.server && connections.server.close(),
2529
+ connections.page && // Unref'd: the keepAlive interval above holds the event loop open, so this
2530
+ // timer still fires if page.close() hangs, without preventing process exit later.
2531
+ Promise.race([
2532
+ connections.page.close(),
2533
+ new Promise((resolve) => {
2534
+ const pageCloseTimeoutId = setTimeout(resolve, 1e4);
2535
+ pageCloseTimeoutId.unref();
2476
2536
  })
2477
- ]);
2478
- }
2479
- })(),
2480
- groupTimeout
2481
- ]);
2537
+ ]).catch(() => {
2538
+ })
2539
+ ]);
2540
+ }
2541
+ })();
2542
+ const record = () => wallTimes.set(i, Date.now() - startMs);
2543
+ work.then(record, record);
2544
+ return Promise.race([work, groupTimeout]);
2482
2545
  })
2483
2546
  );
2484
2547
  const exitCode = groupResults.reduce(
@@ -2497,6 +2560,10 @@ async function run(config) {
2497
2560
  );
2498
2561
  }
2499
2562
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
2563
+ const fileTimes = computeFileTimes(groups, weights, wallTimes);
2564
+ persistTimings(fileTimes, config.projectRoot).catch(() => {
2565
+ });
2566
+ printFileTimings(fileTimes, config.projectRoot);
2500
2567
  if (config.after) {
2501
2568
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
2502
2569
  }
@@ -2567,10 +2634,57 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
2567
2634
  }
2568
2635
  return cachedContent;
2569
2636
  }
2570
- function splitIntoGroups(files, groupCount) {
2571
- const groups = Array.from({ length: groupCount }, () => []);
2572
- files.forEach((file, i) => groups[i % groupCount].push(file));
2573
- return groups.filter((group) => group.length > 0);
2637
+ async function readTimingCache(projectRoot) {
2638
+ try {
2639
+ const parsed = JSON.parse(await fs12.readFile(`${projectRoot}/tmp/test-timings.json`, "utf8"));
2640
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
2641
+ } catch {
2642
+ return {};
2643
+ }
2644
+ }
2645
+ function computeFileTimes(groups, weights, wallTimes) {
2646
+ const result = /* @__PURE__ */ new Map();
2647
+ groups.forEach((group, i) => {
2648
+ const wallMs = wallTimes.get(i);
2649
+ if (wallMs === void 0) return;
2650
+ const total = group.reduce((sum, f) => sum + (weights.get(f) ?? 0), 0);
2651
+ group.forEach(
2652
+ (f) => result.set(f, total > 0 ? wallMs * ((weights.get(f) ?? 0) / total) : wallMs / group.length)
2653
+ );
2654
+ });
2655
+ return result;
2656
+ }
2657
+ async function persistTimings(fileTimes, projectRoot) {
2658
+ await fs12.writeFile(
2659
+ `${projectRoot}/tmp/test-timings.json`,
2660
+ JSON.stringify(Object.fromEntries(fileTimes), null, 2)
2661
+ );
2662
+ }
2663
+ function printFileTimings(fileTimes, projectRoot) {
2664
+ if (fileTimes.size === 0) return;
2665
+ const lines = [...fileTimes.entries()].sort(([, a], [, b]) => b - a).map(([f, ms]) => `# ${ms.toFixed(0)}ms ${f.replace(`${projectRoot}/`, "")}`);
2666
+ process.stdout.write(`# File execution times:
2667
+ ${lines.join("\n")}
2668
+ `);
2669
+ }
2670
+ async function splitIntoGroups(files, groupCount, timings) {
2671
+ const sizes = await Promise.all(
2672
+ files.map(
2673
+ (f) => fs12.stat(f).then((s) => s.size).catch(() => 0)
2674
+ )
2675
+ );
2676
+ const knownRates = files.map((f, i) => ({ ms: timings[f], size: sizes[i] })).filter(({ ms, size }) => ms > 0 && size > 0);
2677
+ const msPerByte = knownRates.length > 0 ? knownRates.reduce((sum, { ms, size }) => sum + ms / size, 0) / knownRates.length : 1;
2678
+ const weights = new Map(
2679
+ files.map((f, i) => [f, timings[f] > 0 ? timings[f] : sizes[i] * msPerByte])
2680
+ );
2681
+ const buckets = Array.from({ length: groupCount }, () => ({ files: [], total: 0 }));
2682
+ [...files].sort((a, b) => (weights.get(b) ?? 0) - (weights.get(a) ?? 0)).forEach((f) => {
2683
+ const min = buckets.reduce((m, _, i) => buckets[i].total < buckets[m].total ? i : m, 0);
2684
+ buckets[min].files.push(f);
2685
+ buckets[min].total += weights.get(f) ?? 0;
2686
+ });
2687
+ return { groups: buckets.filter((b) => b.files.length > 0).map((b) => b.files), weights };
2574
2688
  }
2575
2689
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
2576
2690
  const prefix = "Watching files...";
@@ -2619,7 +2733,7 @@ init_color();
2619
2733
  var package_default = {
2620
2734
  name: "qunitx-cli",
2621
2735
  type: "module",
2622
- version: "0.19.0",
2736
+ version: "0.19.2",
2623
2737
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2624
2738
  author: "Izel Nakri",
2625
2739
  license: "MIT",
@@ -2735,8 +2849,18 @@ import path2 from "node:path";
2735
2849
  // lib/utils/find-project-root.ts
2736
2850
  import process2 from "node:process";
2737
2851
 
2852
+ // lib/utils/path-exists.ts
2853
+ import fs2 from "node:fs/promises";
2854
+ async function pathExists(path7) {
2855
+ try {
2856
+ await fs2.access(path7);
2857
+ return true;
2858
+ } catch {
2859
+ return false;
2860
+ }
2861
+ }
2862
+
2738
2863
  // lib/utils/search-in-parent-directories.ts
2739
- init_path_exists();
2740
2864
  async function searchInParentDirectories(directory, targetEntry) {
2741
2865
  const resolvedDirectory = directory === "." ? process.cwd() : directory;
2742
2866
  if (await pathExists(`${resolvedDirectory}/${targetEntry}`)) {
@@ -2764,9 +2888,6 @@ async function findProjectRoot() {
2764
2888
  }
2765
2889
  }
2766
2890
 
2767
- // lib/commands/init.ts
2768
- init_path_exists();
2769
-
2770
2891
  // lib/setup/default-project-config-values.ts
2771
2892
  var defaultProjectConfigValues = {
2772
2893
  output: "tmp",
@@ -2833,7 +2954,6 @@ async function writeTSConfigIfNeeded(projectRoot) {
2833
2954
  // lib/commands/generate.ts
2834
2955
  init_color();
2835
2956
  import fs5 from "node:fs/promises";
2836
- init_path_exists();
2837
2957
  init_read_template();
2838
2958
 
2839
2959
  // lib/utils/convert-to-pascal-case.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.19.0",
4
+ "version": "0.19.2",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",