qunitx-cli 0.19.2 → 0.19.3

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 +420 -188
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -55,68 +55,67 @@ var init_kill_process_group = __esm({
55
55
 
56
56
  // lib/utils/cleanup-browser-dir.ts
57
57
  import fs from "node:fs/promises";
58
- async function cleanupBrowserDir(dirPath) {
59
- if (process.platform !== "linux") {
60
- await fs.rm(dirPath, { recursive: true, force: true }).catch(() => {
61
- });
62
- return;
58
+ async function processReferencesDir(entry, dirPath, dirName) {
59
+ try {
60
+ const [cwd, cmdline] = await Promise.all([
61
+ fs.readlink(`/proc/${entry}/cwd`).catch(() => ""),
62
+ fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "")
63
+ ]);
64
+ if (cwd.startsWith(dirPath) || cmdline.includes(dirName)) return true;
65
+ const fds = await fs.readdir(`/proc/${entry}/fd`).catch(() => []);
66
+ const fdTargets = await Promise.all(
67
+ fds.map((fd) => fs.readlink(`/proc/${entry}/fd/${fd}`).catch(() => ""))
68
+ );
69
+ return fdTargets.some((target) => target.startsWith(dirPath));
70
+ } catch {
71
+ return false;
63
72
  }
64
- const dirName = dirPath.split("/").pop();
65
- const killedPids = /* @__PURE__ */ new Set();
73
+ }
74
+ async function killAllReferencingProcesses(dirPath, dirName) {
66
75
  const procEntries = await fs.readdir("/proc").catch(() => []);
67
76
  await Promise.all(
68
77
  procEntries.map(async (entry) => {
69
78
  if (!/^\d+$/.test(entry)) return;
70
- const pid = parseInt(entry);
79
+ if (!await processReferencesDir(entry, dirPath, dirName)) return;
71
80
  try {
72
- const [cwd, cmdline] = await Promise.all([
73
- fs.readlink(`/proc/${entry}/cwd`).catch(() => ""),
74
- fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "")
75
- ]);
76
- if (!cwd.startsWith(dirPath) && !cmdline.includes(dirName)) return;
77
- try {
78
- process.kill(pid, "SIGKILL");
79
- killedPids.add(pid);
80
- } catch {
81
- }
81
+ process.kill(parseInt(entry), "SIGKILL");
82
82
  } catch {
83
83
  }
84
84
  })
85
85
  );
86
- while (killedPids.size > 0) {
87
- await new Promise((resolve) => setTimeout(resolve, 20));
88
- for (const pid of killedPids) {
89
- try {
90
- process.kill(pid, 0);
91
- } catch {
92
- killedPids.delete(pid);
93
- }
94
- }
86
+ }
87
+ async function cleanupBrowserDir(dirPath) {
88
+ if (process.platform !== "linux") {
89
+ await fs.rm(dirPath, { recursive: true, force: true }).catch(() => {
90
+ });
91
+ return;
95
92
  }
93
+ const dirName = dirPath.split("/").pop();
94
+ await killAllReferencingProcesses(dirPath, dirName);
96
95
  const deadline = Date.now() + 5e3;
97
96
  while (Date.now() < deadline) {
98
97
  const removed = await fs.rm(dirPath, { recursive: true, force: true }).then(() => true).catch(() => false);
99
- if (removed) break;
100
- await new Promise((resolve) => setTimeout(resolve, 20));
98
+ if (removed) return;
99
+ await killAllReferencingProcesses(dirPath, dirName);
100
+ await new Promise((resolve) => setTimeout(resolve, 50));
101
101
  }
102
- if (await fs.access(dirPath).then(() => true).catch(() => false)) {
103
- const diagEntries = await fs.readdir("/proc").catch(() => []);
104
- await Promise.all(
105
- diagEntries.map(async (entry) => {
106
- if (!/^\d+$/.test(entry)) return;
107
- try {
108
- const cwd = await fs.readlink(`/proc/${entry}/cwd`).catch(() => "");
109
- if (!cwd.startsWith(dirPath)) return;
110
- const cmdline = await fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "");
111
- process.stderr.write(
112
- `# [qunitx] cleanup failed: pid ${entry} still holds ${dirPath} as cwd (cmdline: ${cmdline.replace(/\0/g, " ").slice(0, 120)})
102
+ if (!await fs.access(dirPath).then(() => true).catch(() => false))
103
+ return;
104
+ const diagEntries = await fs.readdir("/proc").catch(() => []);
105
+ await Promise.all(
106
+ diagEntries.map(async (entry) => {
107
+ if (!/^\d+$/.test(entry)) return;
108
+ try {
109
+ if (!await processReferencesDir(entry, dirPath, dirName)) return;
110
+ const cmdline = await fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "");
111
+ process.stderr.write(
112
+ `# [qunitx] cleanup failed: pid ${entry} still references ${dirPath} (cmdline: ${cmdline.replace(/\0/g, " ").slice(0, 120)})
113
113
  `
114
- );
115
- } catch {
116
- }
117
- })
118
- );
119
- }
114
+ );
115
+ } catch {
116
+ }
117
+ })
118
+ );
120
119
  }
121
120
  var init_cleanup_browser_dir = __esm({
122
121
  "lib/utils/cleanup-browser-dir.ts"() {
@@ -168,36 +167,11 @@ async function preLaunchChrome(chromePath, args, headless = true) {
168
167
  });
169
168
  async function shutdown() {
170
169
  proc.ref();
171
- const closed = new Promise((resolve) => {
172
- if (proc.exitCode !== null) {
173
- resolve();
174
- return;
175
- }
176
- proc.once("close", resolve);
177
- });
178
- if (proc.exitCode === null) killProcessGroup(proc.pid);
179
- await closed;
180
- await rm(userDataDir, { recursive: true, force: true }).catch(async () => {
181
- const pgid = proc.pid;
182
- const warnTimer = setTimeout(
183
- () => process.stderr.write(
184
- `# [qunitx] warning: Chrome process group ${pgid} still alive 500ms after SIGKILL, waiting...
185
- `
186
- ),
187
- 500
188
- );
189
- warnTimer.unref();
190
- while (true) {
191
- try {
192
- process.kill(-pgid, 0);
193
- } catch {
194
- break;
195
- }
196
- await new Promise((resolve) => setTimeout(resolve, 20));
197
- }
198
- clearTimeout(warnTimer);
199
- await cleanupBrowserDir(userDataDir);
200
- });
170
+ if (proc.exitCode === null) {
171
+ killProcessGroup(proc.pid);
172
+ await new Promise((resolve) => proc.once("close", resolve));
173
+ }
174
+ await cleanupBrowserDir(userDataDir);
201
175
  }
202
176
  }
203
177
  var CDP_URL_REGEX;
@@ -780,11 +754,13 @@ var init_http = __esm({
780
754
  if (!this.routes[method]) {
781
755
  this.routes[method] = {};
782
756
  }
757
+ const paramNames = this.#extractParamNames(path7);
783
758
  this.routes[method][path7] = {
784
759
  path: path7,
785
760
  handler,
786
- paramNames: this.#extractParamNames(path7),
787
- isWildcard: path7 === "/*"
761
+ paramNames,
762
+ isWildcard: path7 === "/*",
763
+ compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path7, paramNames)}$`) : null
788
764
  };
789
765
  }
790
766
  #handleRequest(req, res) {
@@ -827,10 +803,8 @@ var init_http = __esm({
827
803
  return false;
828
804
  }
829
805
  if (isWildcard || this.#matchPathSegments(path7, url)) {
830
- if (route.paramNames.length > 0) {
831
- const regexPattern = this.#buildRegexPattern(path7, route.paramNames);
832
- const regex = new RegExp(`^${regexPattern}$`);
833
- const regexMatches = regex.exec(url);
806
+ if (route.compiledRegex) {
807
+ const regexMatches = route.compiledRegex.exec(url);
834
808
  if (regexMatches) {
835
809
  route.paramValues = regexMatches.slice(1);
836
810
  }
@@ -892,6 +866,20 @@ function setupWebServer(config, cachedContent) {
892
866
  config.projectRoot
893
867
  );
894
868
  const runtimeScript = testRuntimeToInject(config);
869
+ const mainIndexHTML = escapeAndInjectTestsToHTML(
870
+ mainHTMLWithReplacedAssets,
871
+ runtimeScript,
872
+ "./tests.js"
873
+ );
874
+ const mainQunitxHTML = escapeAndInjectTestsToHTML(
875
+ mainHTMLWithReplacedAssets,
876
+ runtimeScript,
877
+ "./filtered-tests.js"
878
+ );
879
+ const saveHTML = (filePath, html) => fsPromise.writeFile(filePath, html).catch(
880
+ (err) => config.debug && process.stderr.write(`# [qunitx] writeFile ${filePath}: ${err.message}
881
+ `)
882
+ );
895
883
  server.wss.on("connection", function connection(socket) {
896
884
  socket.on("message", function message(data) {
897
885
  const { event, details, qunitResult, abort } = JSON.parse(data);
@@ -901,17 +889,7 @@ function setupWebServer(config, cachedContent) {
901
889
  } else if (event === "connection") {
902
890
  config._phase = "running";
903
891
  if (!config._groupMode) process.stdout.write("TAP version 13\n");
904
- if (config.debug && config._groupMode) {
905
- const allFiles = Object.keys(config.fsTree);
906
- const relFiles = allFiles.map(
907
- (filePath) => filePath.replace(`${config.projectRoot}/`, "")
908
- );
909
- const shown = relFiles.slice(0, 2);
910
- const rest = relFiles.length - shown.length;
911
- const fileList = rest > 0 ? `${shown.join(" ")} +${rest} more` : shown.join(" ");
912
- process.stdout.write(`# ${blue(`\u2500\u2500 ${fileList} \u2500\u2500`)}
913
- `);
914
- }
892
+ if (config.debug && config._groupMode) debugGroupHeader(config);
915
893
  config._resetTestTimeout?.();
916
894
  } else if (event === "testEnd" && !abort) {
917
895
  if (details.status === "failed") {
@@ -983,69 +961,41 @@ function setupWebServer(config, cachedContent) {
983
961
  });
984
962
  res.end(cachedContent.filteredTestCode);
985
963
  });
986
- server.get("/", async (_req, res) => {
964
+ server.get("/", (_req, res) => {
987
965
  if (cachedContent._buildError) {
988
- const htmlContent2 = buildErrorHTML(cachedContent._buildError);
989
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
990
- res.write(htmlContent2);
991
- res.end();
992
- return await fsPromise.writeFile(
993
- `${config.projectRoot}/${config.output}/index.html`,
994
- htmlContent2
995
- );
966
+ const htmlContent = buildErrorHTML(cachedContent._buildError);
967
+ res.writeHead(200, HTML_HEADERS);
968
+ res.end(htmlContent);
969
+ saveHTML(`${config.projectRoot}/${config.output}/index.html`, htmlContent);
970
+ return;
996
971
  }
997
972
  if (cachedContent._noTestsWarning) {
998
- const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
999
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1000
- res.write(htmlContent2);
1001
- res.end();
973
+ res.writeHead(200, HTML_HEADERS);
974
+ res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
1002
975
  return;
1003
976
  }
1004
- const htmlContent = escapeAndInjectTestsToHTML(
1005
- mainHTMLWithReplacedAssets,
1006
- runtimeScript,
1007
- "./tests.js"
1008
- );
1009
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1010
- res.write(htmlContent);
1011
- res.end();
1012
- return await fsPromise.writeFile(
1013
- `${config.projectRoot}/${config.output}/index.html`,
1014
- htmlContent
1015
- );
977
+ res.writeHead(200, HTML_HEADERS);
978
+ res.end(mainIndexHTML);
979
+ saveHTML(`${config.projectRoot}/${config.output}/index.html`, mainIndexHTML);
1016
980
  });
1017
- server.get("/qunitx.html", async (_req, res) => {
981
+ server.get("/qunitx.html", (_req, res) => {
1018
982
  if (cachedContent._buildError) {
1019
- const htmlContent2 = buildErrorHTML(cachedContent._buildError);
1020
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1021
- res.write(htmlContent2);
1022
- res.end();
1023
- return await fsPromise.writeFile(
1024
- `${config.projectRoot}/${config.output}/qunitx.html`,
1025
- htmlContent2
1026
- );
983
+ const htmlContent = buildErrorHTML(cachedContent._buildError);
984
+ res.writeHead(200, HTML_HEADERS);
985
+ res.end(htmlContent);
986
+ saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, htmlContent);
987
+ return;
1027
988
  }
1028
989
  if (cachedContent._noTestsWarning) {
1029
- const htmlContent2 = buildNoTestsHTML(cachedContent._noTestsWarning);
1030
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1031
- res.write(htmlContent2);
1032
- res.end();
990
+ res.writeHead(200, HTML_HEADERS);
991
+ res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
1033
992
  return;
1034
993
  }
1035
- const htmlContent = escapeAndInjectTestsToHTML(
1036
- mainHTMLWithReplacedAssets,
1037
- runtimeScript,
1038
- "./filtered-tests.js"
1039
- );
1040
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1041
- res.write(htmlContent);
1042
- res.end();
1043
- return await fsPromise.writeFile(
1044
- `${config.projectRoot}/${config.output}/qunitx.html`,
1045
- htmlContent
1046
- );
994
+ res.writeHead(200, HTML_HEADERS);
995
+ res.end(mainQunitxHTML);
996
+ saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, mainQunitxHTML);
1047
997
  });
1048
- server.get("/*", async (req, res) => {
998
+ server.get("/*", (req, res) => {
1049
999
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
1050
1000
  if (possibleDynamicHTML) {
1051
1001
  const htmlContent = escapeAndInjectTestsToHTML(
@@ -1053,13 +1003,10 @@ function setupWebServer(config, cachedContent) {
1053
1003
  runtimeScript,
1054
1004
  "/tests.js"
1055
1005
  );
1056
- res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
1057
- res.write(htmlContent);
1058
- res.end();
1059
- return await fsPromise.writeFile(
1060
- `${config.projectRoot}/${config.output}${req.path}`,
1061
- htmlContent
1062
- );
1006
+ res.writeHead(200, HTML_HEADERS);
1007
+ res.end(htmlContent);
1008
+ saveHTML(`${config.projectRoot}/${config.output}${req.path}`, htmlContent);
1009
+ return;
1063
1010
  }
1064
1011
  const url = req.url;
1065
1012
  const requestStartedAt = Date.now();
@@ -1093,7 +1040,8 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
1093
1040
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1094
1041
  }, html);
1095
1042
  }
1096
- function testRuntimeToInject(config) {
1043
+ function testRuntimeToInject(config, groupId) {
1044
+ const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1097
1045
  return `<script>
1098
1046
  window.testTimeout = 0;
1099
1047
  setInterval(() => {
@@ -1142,7 +1090,7 @@ function testRuntimeToInject(config) {
1142
1090
  // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1143
1091
  // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1144
1092
  if (navigator.webdriver) {
1145
- window.socket.send(JSON.stringify({ event: 'wsOpen' }));
1093
+ window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1146
1094
  }
1147
1095
  maybeStart();
1148
1096
  });
@@ -1457,7 +1405,146 @@ function buildErrorHTML(buildError) {
1457
1405
  </body>
1458
1406
  </html>`;
1459
1407
  }
1460
- var fsPromise, NOT_FOUND_HTML;
1408
+ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
1409
+ const mainHTMLWithReplacedAssets = replaceAssetPaths(
1410
+ groupCachedContent.mainHTML.html,
1411
+ groupCachedContent.mainHTML.filePath,
1412
+ groupConfig.projectRoot
1413
+ );
1414
+ const runtimeScript = testRuntimeToInject(groupConfig, groupId);
1415
+ const mainGroupHTML = escapeAndInjectTestsToHTML(
1416
+ mainHTMLWithReplacedAssets,
1417
+ runtimeScript,
1418
+ "./tests.js"
1419
+ );
1420
+ const saveHTML = (filePath, html) => fsPromise.writeFile(filePath, html).catch(
1421
+ (err) => groupConfig.debug && process.stderr.write(`# [qunitx] writeFile ${filePath}: ${err.message}
1422
+ `)
1423
+ );
1424
+ server.get(`/group-${groupId}/`, (_req, res) => {
1425
+ if (groupCachedContent._buildError) {
1426
+ res.writeHead(200, HTML_HEADERS);
1427
+ res.end(buildErrorHTML(groupCachedContent._buildError));
1428
+ return;
1429
+ }
1430
+ if (groupCachedContent._noTestsWarning) {
1431
+ res.writeHead(200, HTML_HEADERS);
1432
+ res.end(buildNoTestsHTML(groupCachedContent._noTestsWarning));
1433
+ return;
1434
+ }
1435
+ res.writeHead(200, HTML_HEADERS);
1436
+ res.end(mainGroupHTML);
1437
+ saveHTML(`${groupConfig.projectRoot}/${groupConfig.output}/index.html`, mainGroupHTML);
1438
+ });
1439
+ server.get(`/group-${groupId}/tests.js`, (_req, res) => {
1440
+ const bytes = groupCachedContent.allTestCode?.length ?? null;
1441
+ if (bytes === null) {
1442
+ res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
1443
+ res.end(
1444
+ 'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
1445
+ );
1446
+ return;
1447
+ }
1448
+ groupConfig._onTestsJsServed?.();
1449
+ res.writeHead(200, {
1450
+ "Content-Type": "application/javascript",
1451
+ "Cache-Control": "no-store",
1452
+ "Content-Length": bytes
1453
+ });
1454
+ res.end(groupCachedContent.allTestCode);
1455
+ });
1456
+ }
1457
+ function debugGroupHeader(config) {
1458
+ const files = Object.keys(config.fsTree);
1459
+ const rel = files.map((f) => f.replace(`${config.projectRoot}/`, ""));
1460
+ const shown = rel.slice(0, 2);
1461
+ const rest = rel.length - shown.length;
1462
+ process.stdout.write(
1463
+ `# ${blue(`\u2500\u2500 ${shown.join(" ")}${rest > 0 ? ` +${rest} more` : ""} \u2500\u2500`)}
1464
+ `
1465
+ );
1466
+ }
1467
+ function setupGroupWSHandler(server, groupConfigs) {
1468
+ const socketToGroupId = /* @__PURE__ */ new WeakMap();
1469
+ server.wss.on("connection", function connection(socket) {
1470
+ socket.on("message", function message(data) {
1471
+ const { event, groupId, details, qunitResult, abort } = JSON.parse(data);
1472
+ let resolvedGroupId = socketToGroupId.get(socket);
1473
+ if (event === "wsOpen" && typeof groupId === "number") {
1474
+ resolvedGroupId = groupId;
1475
+ socketToGroupId.set(socket, groupId);
1476
+ }
1477
+ if (resolvedGroupId === void 0) return;
1478
+ const config = groupConfigs[resolvedGroupId];
1479
+ if (!config) return;
1480
+ if (event === "wsOpen") {
1481
+ config._phase = "loading";
1482
+ config._onWsOpen?.();
1483
+ } else if (event === "connection") {
1484
+ config._phase = "running";
1485
+ if (config.debug) debugGroupHeader(config);
1486
+ config._resetTestTimeout?.();
1487
+ } else if (event === "testEnd" && !abort) {
1488
+ if (details.status === "failed") {
1489
+ config.lastFailedTestFiles = config.lastRanTestFiles;
1490
+ }
1491
+ if (config.debug && details.runtime > config.timeout * 0.8) {
1492
+ process.stdout.write(
1493
+ `# SLOW (${details.runtime.toFixed(0)}ms / ${config.timeout}ms timeout): ${details.fullName.join(" | ")}
1494
+ `
1495
+ );
1496
+ }
1497
+ config._resetTestTimeout?.();
1498
+ TAPDisplayTestResult(config.COUNTER, details);
1499
+ } else if (event === "done") {
1500
+ config._phase = "done";
1501
+ config._lastQUnitResult = qunitResult ?? null;
1502
+ if (config.debug) {
1503
+ process.stdout.write(
1504
+ `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)
1505
+ `
1506
+ );
1507
+ }
1508
+ if (typeof config._testRunDone === "function") {
1509
+ config._testRunDone();
1510
+ config._testRunDone = null;
1511
+ }
1512
+ }
1513
+ });
1514
+ });
1515
+ }
1516
+ function registerSharedStaticHandler(server, groupConfigs) {
1517
+ const groupUrlRegex = /^\/group-(\d+)(\/.*)?$/;
1518
+ server.get("/*", (req, res) => {
1519
+ const match = groupUrlRegex.exec(req.path);
1520
+ if (!match) {
1521
+ res.writeHead(404, { "Content-Type": "text/plain" });
1522
+ res.end("Not found");
1523
+ return;
1524
+ }
1525
+ const groupId = parseInt(match[1], 10);
1526
+ const groupConfig = groupConfigs[groupId];
1527
+ if (!groupConfig) {
1528
+ res.writeHead(404, { "Content-Type": "text/plain" });
1529
+ res.end("Not found");
1530
+ return;
1531
+ }
1532
+ const STATIC_FILES_PATH = path4.join(groupConfig.projectRoot, groupConfig.output);
1533
+ const subPath = match[2] || "/";
1534
+ const filePath = (subPath.endsWith("/") ? [STATIC_FILES_PATH, subPath, "index.html"] : [STATIC_FILES_PATH, subPath]).join("");
1535
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1536
+ const stream = fs8.createReadStream(filePath);
1537
+ stream.on("open", () => {
1538
+ res.writeHead(200, { "Content-Type": contentType });
1539
+ stream.pipe(res);
1540
+ });
1541
+ stream.on("error", () => {
1542
+ res.writeHead(404, { "Content-Type": contentType });
1543
+ res.end(contentType === MIME_TYPES.html ? NOT_FOUND_HTML : void 0);
1544
+ });
1545
+ });
1546
+ }
1547
+ var fsPromise, HTML_HEADERS, NOT_FOUND_HTML;
1461
1548
  var init_web_server = __esm({
1462
1549
  "lib/setup/web-server.ts"() {
1463
1550
  init_find_internal_assets_from_html();
@@ -1466,6 +1553,7 @@ var init_web_server = __esm({
1466
1553
  init_color();
1467
1554
  init_http();
1468
1555
  fsPromise = fs8.promises;
1556
+ HTML_HEADERS = { "Content-Type": "text/html", "Cache-Control": "no-store" };
1469
1557
  NOT_FOUND_HTML = `<!DOCTYPE html>
1470
1558
  <html lang="en">
1471
1559
  <head>
@@ -1541,19 +1629,27 @@ async function launchBrowser(config) {
1541
1629
  handleSIGHUP: false
1542
1630
  });
1543
1631
  }
1544
- async function setupBrowser(config, cachedContent, existingBrowser = null) {
1632
+ async function setupBrowser(config, cachedContent, existingBrowser = null, sharedServer = null) {
1545
1633
  const setupStart = Date.now();
1546
- const [server, resolvedExistingBrowser] = await Promise.all([
1547
- setupWebServer(config, cachedContent),
1548
- Promise.resolve(existingBrowser)
1549
- ]);
1550
- perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1551
- const browser = resolvedExistingBrowser || await launchBrowser(config);
1552
- const pageStart = Date.now();
1553
- const isHeadedWatchMode = config.open === true && config.watch;
1554
- const getPage = isHeadedWatchMode ? () => browser.contexts()[0]?.pages()[0] ?? browser.newPage() : () => browser.newPage();
1555
- const [page] = await Promise.all([getPage(), bindServerToPort(server, config)]);
1556
- perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1634
+ const [server, browser, page] = await (async () => {
1635
+ if (sharedServer) {
1636
+ const newPage2 = await existingBrowser.newPage();
1637
+ perfLog(`browser.js: newPage (shared server) took ${Date.now() - setupStart}ms`);
1638
+ return [sharedServer, existingBrowser, newPage2];
1639
+ }
1640
+ const [newServer, resolvedBrowser] = await Promise.all([
1641
+ setupWebServer(config, cachedContent),
1642
+ Promise.resolve(existingBrowser)
1643
+ ]);
1644
+ perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1645
+ const activeBrowser = resolvedBrowser ?? await launchBrowser(config);
1646
+ const pageStart = Date.now();
1647
+ const isHeadedWatchMode = config.open === true && config.watch;
1648
+ const getPage = isHeadedWatchMode ? () => activeBrowser.contexts()[0]?.pages()[0] ?? activeBrowser.newPage() : () => activeBrowser.newPage();
1649
+ const [newPage] = await Promise.all([getPage(), bindServerToPort(newServer, config)]);
1650
+ perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1651
+ return [newServer, activeBrowser, newPage];
1652
+ })();
1557
1653
  if (config.browser === "firefox") {
1558
1654
  await page.addInitScript(() => {
1559
1655
  const preSerialize = (arg) => {
@@ -1875,8 +1971,10 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1875
1971
  fs9.writeFile(
1876
1972
  `${projectRoot}/${output}/qunitx.html`,
1877
1973
  buildErrorHTML(cachedContent._buildError)
1878
- ).catch(() => {
1879
- });
1974
+ ).catch(
1975
+ (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
1976
+ `)
1977
+ );
1880
1978
  }
1881
1979
  if (config.watch) {
1882
1980
  console.log(`# ${exception}`);
@@ -1908,9 +2006,6 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1908
2006
  );
1909
2007
  }
1910
2008
  async function runWithOverlayfsRetry(getContents, needsDisk) {
1911
- const RETRY_DELAY_MS = 100;
1912
- const MAX_RETRIES = 3;
1913
- const EMPTY_BUNDLE_THRESHOLD = 500;
1914
2009
  let { result, js } = await getContents();
1915
2010
  const initialSize = js.length;
1916
2011
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
@@ -2060,7 +2155,110 @@ async function flushConsoleHandlers(handlers, deadline = Date.now() + 2e3) {
2060
2155
  await Promise.allSettled([...handlers]);
2061
2156
  return flushConsoleHandlers(handlers, deadline);
2062
2157
  }
2063
- var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES;
2158
+ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2159
+ groupCachedContents.forEach((cachedContent) => {
2160
+ cachedContent._buildError = null;
2161
+ cachedContent._noTestsWarning = null;
2162
+ });
2163
+ const { projectRoot, debug, watch, browser } = groupConfigs[0];
2164
+ const activeGroups = groupConfigs.reduce(
2165
+ (acc, groupConfig, groupIndex) => {
2166
+ const files = Object.keys(groupConfig.fsTree);
2167
+ if (files.length > 0)
2168
+ acc.push({
2169
+ groupIndex,
2170
+ config: groupConfig,
2171
+ cachedContent: groupCachedContents[groupIndex],
2172
+ files
2173
+ });
2174
+ return acc;
2175
+ },
2176
+ []
2177
+ );
2178
+ if (activeGroups.length === 0)
2179
+ return console.log(
2180
+ "# [buildAllGroupBundles] all groups empty \u2014 skipping build (no test files found)"
2181
+ );
2182
+ await Promise.all(
2183
+ activeGroups.map(
2184
+ (group) => fs9.mkdir(`${group.config.projectRoot}/${group.config.output}`, { recursive: true })
2185
+ )
2186
+ );
2187
+ const sourcemap = debug ? "inline" : watch ? "linked" : false;
2188
+ const groupEntryPlugin = {
2189
+ name: "group-entry-loader",
2190
+ setup(build) {
2191
+ build.onResolve({ filter: /^group-entry-\d+$/ }, (args) => ({
2192
+ path: args.path,
2193
+ namespace: "group-entry"
2194
+ }));
2195
+ build.onLoad({ filter: /.*/, namespace: "group-entry" }, (args) => {
2196
+ const slotIndex = parseInt(args.path.replace("group-entry-", ""));
2197
+ return {
2198
+ contents: activeGroups[slotIndex].files.map((filePath) => `import "${filePath}";`).join(""),
2199
+ resolveDir: process.cwd()
2200
+ };
2201
+ });
2202
+ }
2203
+ };
2204
+ const buildOptions = {
2205
+ entryPoints: activeGroups.map((_, slotIndex) => ({
2206
+ in: `group-entry-${slotIndex}`,
2207
+ out: `group-${slotIndex}`
2208
+ })),
2209
+ plugins: [groupEntryPlugin],
2210
+ nodePaths: ANCESTOR_NODE_MODULES,
2211
+ bundle: true,
2212
+ logLevel: "silent",
2213
+ // outdir only labels the paths in outputFiles[].path — nothing is written to disk
2214
+ // (write:false). Use projectRoot/tmp as a stable sentinel; mkdir is not required.
2215
+ outdir: path5.join(projectRoot, "tmp"),
2216
+ keepNames: true,
2217
+ legalComments: "none",
2218
+ target: esbuildTarget(browser),
2219
+ sourcemap,
2220
+ write: false,
2221
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2222
+ };
2223
+ const hasSmallOutput = (result) => (result.outputFiles ?? []).some(
2224
+ (outputFile) => GROUP_OUTPUT_REGEX.test(outputFile.path) && !outputFile.path.endsWith(".map") && outputFile.contents.length < EMPTY_BUNDLE_THRESHOLD
2225
+ );
2226
+ const buildWithRetry = async (retriesLeft) => {
2227
+ const result = await esbuild.build(buildOptions);
2228
+ if (!hasSmallOutput(result) || retriesLeft === 0) return result;
2229
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
2230
+ return buildWithRetry(retriesLeft - 1);
2231
+ };
2232
+ try {
2233
+ const result = await buildWithRetry(MAX_RETRIES);
2234
+ await Promise.all(
2235
+ (result.outputFiles ?? []).map((outputFile) => {
2236
+ const match = GROUP_OUTPUT_REGEX.exec(outputFile.path);
2237
+ if (!match) return Promise.resolve();
2238
+ const slotIndex = parseInt(match[1]);
2239
+ const isMap = Boolean(match[2]);
2240
+ const { config, cachedContent } = activeGroups[slotIndex];
2241
+ const destPath = `${config.projectRoot}/${config.output}/tests.js${isMap ? ".map" : ""}`;
2242
+ if (!isMap) cachedContent.allTestCode = Buffer.from(outputFile.contents);
2243
+ return fs9.writeFile(destPath, outputFile.contents);
2244
+ })
2245
+ );
2246
+ } catch (error) {
2247
+ const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
2248
+ const errorHtml = buildErrorHTML(buildError);
2249
+ await Promise.all(
2250
+ activeGroups.map((group) => {
2251
+ group.cachedContent._buildError = buildError;
2252
+ return fs9.writeFile(`${group.config.projectRoot}/${group.config.output}/index.html`, errorHtml).catch(
2253
+ (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2254
+ `)
2255
+ );
2256
+ })
2257
+ );
2258
+ throw error;
2259
+ }
2260
+ }
2261
+ var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES, RETRY_DELAY_MS, MAX_RETRIES, EMPTY_BUNDLE_THRESHOLD, GROUP_OUTPUT_REGEX;
2064
2262
  var init_tests_in_browser = __esm({
2065
2263
  "lib/commands/run/tests-in-browser.ts"() {
2066
2264
  init_color();
@@ -2080,6 +2278,10 @@ var init_tests_in_browser = __esm({
2080
2278
  (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2081
2279
  );
2082
2280
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
2281
+ RETRY_DELAY_MS = 100;
2282
+ MAX_RETRIES = 3;
2283
+ EMPTY_BUNDLE_THRESHOLD = 500;
2284
+ GROUP_OUTPUT_REGEX = /group-(\d+)\.js(\.map)?$/;
2083
2285
  }
2084
2286
  });
2085
2287
 
@@ -2472,6 +2674,13 @@ async function run(config) {
2472
2674
  _phase: "bundling"
2473
2675
  }));
2474
2676
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
2677
+ const sharedServer = groupCount > 1 && cachedContent.htmlPathsToRunTests[0] === "/" && cachedContent.htmlPathsToRunTests.length === 1 ? (() => {
2678
+ const s = new HTTPServer();
2679
+ setupGroupWSHandler(s, groupConfigs);
2680
+ groupConfigs.forEach((gc, i) => registerGroupRoutes(s, gc, groupCachedContents[i], i));
2681
+ registerSharedStaticHandler(s, groupConfigs);
2682
+ return s;
2683
+ })() : null;
2475
2684
  process.stdout.write("TAP version 13\n");
2476
2685
  process.stdout.write(
2477
2686
  `# Running ${allFiles.length} test file${allFiles.length === 1 ? "" : "s"} across ${groupCount} group${groupCount === 1 ? "" : "s"}
@@ -2479,14 +2688,18 @@ async function run(config) {
2479
2688
  );
2480
2689
  const [browser] = await Promise.all([
2481
2690
  launchBrowser(config),
2482
- Promise.all(
2483
- groupConfigs.map(
2484
- (groupConfig, i) => Promise.all([
2485
- buildTestBundle(groupConfig, groupCachedContents[i]),
2486
- writeOutputStaticFiles(groupConfig, groupCachedContents[i])
2487
- ])
2691
+ sharedServer ? bindServerToPort(sharedServer, config).then(
2692
+ () => groupConfigs.forEach((gc, i) => {
2693
+ gc.port = config.port;
2694
+ groupCachedContents[i].htmlPathsToRunTests = [`/group-${i}/`];
2695
+ })
2696
+ ) : Promise.resolve(),
2697
+ Promise.all([
2698
+ groupCount > 1 ? buildAllGroupBundles(groupConfigs, groupCachedContents) : buildTestBundle(groupConfigs[0], groupCachedContents[0]),
2699
+ Promise.all(
2700
+ groupConfigs.map((gc, i) => writeOutputStaticFiles(gc, groupCachedContents[i]))
2488
2701
  )
2489
- )
2702
+ ])
2490
2703
  ]);
2491
2704
  if (config.open) {
2492
2705
  void openOutputInBrowser(config);
@@ -2495,7 +2708,7 @@ async function run(config) {
2495
2708
  const wallTimes = /* @__PURE__ */ new Map();
2496
2709
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
2497
2710
  const keepAlive = setInterval(() => {
2498
- }, 1e3);
2711
+ }, 1e4);
2499
2712
  const groupResults = await Promise.allSettled(
2500
2713
  groupConfigs.map((groupConfig, i) => {
2501
2714
  const groupTimeout = new Promise((_, reject) => {
@@ -2515,7 +2728,12 @@ async function run(config) {
2515
2728
  const startMs = Date.now();
2516
2729
  const work = (async () => {
2517
2730
  groupConfig._phase = "connecting";
2518
- const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
2731
+ const connections = await setupBrowser(
2732
+ groupConfig,
2733
+ groupCachedContents[i],
2734
+ browser,
2735
+ sharedServer
2736
+ );
2519
2737
  groupConfig.expressApp = connections.server;
2520
2738
  if (config.before) {
2521
2739
  await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, "before");
@@ -2525,7 +2743,7 @@ async function run(config) {
2525
2743
  } finally {
2526
2744
  await flushConsoleHandlers(groupConfig._pendingConsoleHandlers);
2527
2745
  await Promise.all([
2528
- connections.server && connections.server.close(),
2746
+ !sharedServer && connections.server?.close(),
2529
2747
  connections.page && // Unref'd: the keepAlive interval above holds the event loop open, so this
2530
2748
  // timer still fires if page.close() hangs, without preventing process exit later.
2531
2749
  Promise.race([
@@ -2561,8 +2779,10 @@ async function run(config) {
2561
2779
  }
2562
2780
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
2563
2781
  const fileTimes = computeFileTimes(groups, weights, wallTimes);
2564
- persistTimings(fileTimes, config.projectRoot).catch(() => {
2565
- });
2782
+ persistTimings(fileTimes, config.projectRoot).catch(
2783
+ (err) => config.debug && process.stderr.write(`# [qunitx] persistTimings: ${err.message}
2784
+ `)
2785
+ );
2566
2786
  printFileTimings(fileTimes, config.projectRoot);
2567
2787
  if (config.after) {
2568
2788
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
@@ -2572,8 +2792,16 @@ async function run(config) {
2572
2792
  process.stdout.write("\n", async () => {
2573
2793
  clearTimeout(exitTimer);
2574
2794
  clearInterval(keepAlive);
2575
- await browser.close().catch(() => {
2576
- });
2795
+ await Promise.all([
2796
+ sharedServer?.close().catch(
2797
+ (err) => config.debug && process.stderr.write(`# [qunitx] server.close: ${err.message}
2798
+ `)
2799
+ ),
2800
+ browser.close().catch(
2801
+ (err) => config.debug && process.stderr.write(`# [qunitx] browser.close: ${err.message}
2802
+ `)
2803
+ )
2804
+ ]);
2577
2805
  await shutdownPrelaunch();
2578
2806
  process.exit(exitCode);
2579
2807
  });
@@ -2670,7 +2898,7 @@ ${lines.join("\n")}
2670
2898
  async function splitIntoGroups(files, groupCount, timings) {
2671
2899
  const sizes = await Promise.all(
2672
2900
  files.map(
2673
- (f) => fs12.stat(f).then((s) => s.size).catch(() => 0)
2901
+ (f) => timings[f] > 0 ? Promise.resolve(0) : fs12.stat(f).then((s) => s.size).catch(() => 0)
2674
2902
  )
2675
2903
  );
2676
2904
  const knownRates = files.map((f, i) => ({ ms: timings[f], size: sizes[i] })).filter(({ ms, size }) => ms > 0 && size > 0);
@@ -2707,6 +2935,9 @@ var init_run = __esm({
2707
2935
  "lib/commands/run.ts"() {
2708
2936
  init_browser();
2709
2937
  init_chrome_prelaunch();
2938
+ init_http();
2939
+ init_bind_server_to_port();
2940
+ init_web_server();
2710
2941
  init_open_output_in_browser();
2711
2942
  init_color();
2712
2943
  init_tests_in_browser();
@@ -2733,7 +2964,7 @@ init_color();
2733
2964
  var package_default = {
2734
2965
  name: "qunitx-cli",
2735
2966
  type: "module",
2736
- version: "0.19.2",
2967
+ version: "0.19.3",
2737
2968
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2738
2969
  author: "Izel Nakri",
2739
2970
  license: "MIT",
@@ -3238,6 +3469,7 @@ process4.title = "qunitx";
3238
3469
  } catch (error) {
3239
3470
  console.error(error);
3240
3471
  process4.exitCode = 1;
3472
+ await shutdownPrelaunch();
3241
3473
  process4.stdout.write("\n", () => process4.exit(1));
3242
3474
  }
3243
3475
  })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.19.2",
4
+ "version": "0.19.3",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",