qunitx-cli 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +774 -259
  2. package/package.json +1 -3
package/dist/cli.js CHANGED
@@ -55,71 +55,78 @@ 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
  }
96
- const deadline = Date.now() + 5e3;
93
+ const dirName = dirPath.split("/").pop();
94
+ await killAllReferencingProcesses(dirPath, dirName);
95
+ const deadline = Date.now() + CLEANUP_DEADLINE_MS;
97
96
  while (Date.now() < deadline) {
98
- 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));
97
+ const removed = await fs.rm(dirPath, { recursive: true, force: true }).then(
98
+ () => fs.access(dirPath).then(
99
+ () => false,
100
+ () => true
101
+ )
102
+ ).catch(() => false);
103
+ if (removed) return;
104
+ await killAllReferencingProcesses(dirPath, dirName);
105
+ await new Promise((resolve) => setTimeout(resolve, CLEANUP_RETRY_MS));
101
106
  }
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)})
107
+ if (!await fs.access(dirPath).then(() => true).catch(() => false))
108
+ return;
109
+ const diagEntries = await fs.readdir("/proc").catch(() => []);
110
+ await Promise.all(
111
+ diagEntries.map(async (entry) => {
112
+ if (!/^\d+$/.test(entry)) return;
113
+ try {
114
+ if (!await processReferencesDir(entry, dirPath, dirName)) return;
115
+ const cmdline = await fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "");
116
+ process.stderr.write(
117
+ `# [qunitx] cleanup failed: pid ${entry} still references ${dirPath} (cmdline: ${cmdline.replace(/\0/g, " ").slice(0, 120)})
113
118
  `
114
- );
115
- } catch {
116
- }
117
- })
118
- );
119
- }
119
+ );
120
+ } catch {
121
+ }
122
+ })
123
+ );
120
124
  }
125
+ var CLEANUP_DEADLINE_MS, CLEANUP_RETRY_MS;
121
126
  var init_cleanup_browser_dir = __esm({
122
127
  "lib/utils/cleanup-browser-dir.ts"() {
128
+ CLEANUP_DEADLINE_MS = 5e3;
129
+ CLEANUP_RETRY_MS = 50;
123
130
  }
124
131
  });
125
132
 
@@ -168,36 +175,11 @@ async function preLaunchChrome(chromePath, args, headless = true) {
168
175
  });
169
176
  async function shutdown() {
170
177
  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
- });
178
+ if (proc.exitCode === null) {
179
+ killProcessGroup(proc.pid);
180
+ await new Promise((resolve) => proc.once("close", resolve));
181
+ }
182
+ await cleanupBrowserDir(userDataDir);
201
183
  }
202
184
  }
203
185
  var CDP_URL_REGEX;
@@ -498,10 +480,11 @@ function dumpYaml({
498
480
  expected,
499
481
  message,
500
482
  stack,
483
+ source,
501
484
  at
502
485
  }) {
503
486
  return `name: ${dumpString(name, "")}
504
- ` + yamlLine("actual", actual) + yamlLine("expected", expected) + (message !== null ? yamlLine("message", message) : "") + (stack !== null ? yamlLine("stack", stack) : "") + (at !== null ? yamlLine("at", at) : "");
487
+ ` + yamlLine("actual", actual) + yamlLine("expected", expected) + (message !== null ? yamlLine("message", message) : "") + (stack !== null ? yamlLine("stack", stack) : "") + (source !== null ? yamlLine("source", source) : "") + (at !== null ? yamlLine("at", at) : "");
505
488
  }
506
489
  var NEEDS_QUOTING;
507
490
  var init_dump_yaml = __esm({
@@ -524,8 +507,205 @@ var init_indent_string = __esm({
524
507
  }
525
508
  });
526
509
 
510
+ // lib/utils/source-map-decoder.ts
511
+ function readVLQ(s, pos) {
512
+ let accumulated = 0;
513
+ let shift = 0;
514
+ let digit;
515
+ do {
516
+ digit = BASE64_LOOKUP[s.charCodeAt(pos++)];
517
+ accumulated |= (digit & 31) << shift;
518
+ shift += 5;
519
+ } while (digit & 32);
520
+ return [accumulated & 1 ? -(accumulated >>> 1) : accumulated >>> 1, pos];
521
+ }
522
+ function decodeMappings(mappings) {
523
+ let sourceIndex = 0, sourceLine = 0, sourceCol = 0;
524
+ return mappings.split(";").map((lineStr) => {
525
+ const segments = [];
526
+ let generatedCol = 0;
527
+ let pos = 0;
528
+ while (pos < lineStr.length) {
529
+ if (lineStr[pos] === ",") {
530
+ pos++;
531
+ continue;
532
+ }
533
+ let delta;
534
+ [delta, pos] = readVLQ(lineStr, pos);
535
+ generatedCol += delta;
536
+ if (pos >= lineStr.length || lineStr[pos] === ",") continue;
537
+ [delta, pos] = readVLQ(lineStr, pos);
538
+ sourceIndex += delta;
539
+ [delta, pos] = readVLQ(lineStr, pos);
540
+ sourceLine += delta;
541
+ [delta, pos] = readVLQ(lineStr, pos);
542
+ sourceCol += delta;
543
+ if (pos < lineStr.length && lineStr[pos] !== ",") [, pos] = readVLQ(lineStr, pos);
544
+ segments.push({ generatedCol, sourceIndex, sourceLine, sourceCol });
545
+ }
546
+ return segments;
547
+ });
548
+ }
549
+ function parseSourceMap(json, outDir) {
550
+ const map = JSON.parse(json);
551
+ return {
552
+ segmentsByLine: decodeMappings(map.mappings),
553
+ sources: map.sources ?? [],
554
+ sourceRoot: map.sourceRoot ?? "",
555
+ outDir,
556
+ sourcesContent: map.sourcesContent ?? []
557
+ };
558
+ }
559
+ function base64DecodeUtf8(b64) {
560
+ const binary = atob(b64);
561
+ return UTF8.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
562
+ }
563
+ function extractInlineSourceMap(bundle, outDir) {
564
+ if (!bundle) return null;
565
+ const text = typeof bundle === "string" ? bundle : UTF8.decode(bundle);
566
+ const match = text.match(
567
+ /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/
568
+ );
569
+ if (!match) return null;
570
+ try {
571
+ return parseSourceMap(base64DecodeUtf8(match[1]), outDir);
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ function normalizePosix(p) {
577
+ const abs = p.startsWith("/");
578
+ const parts = p.split("/");
579
+ const out = [];
580
+ for (const part of parts) {
581
+ if (part === "..") out.pop();
582
+ else if (part !== "" && part !== ".") out.push(part);
583
+ }
584
+ return (abs ? "/" : "") + out.join("/");
585
+ }
586
+ function posixResolve(base, relative) {
587
+ if (relative.startsWith("/")) return normalizePosix(relative);
588
+ return normalizePosix(base + "/" + relative);
589
+ }
590
+ function toAbsolutePath(raw, outDir, sourceRoot) {
591
+ if (raw.startsWith("file://")) return raw.slice(7);
592
+ if (raw.startsWith("/")) return raw;
593
+ const base = sourceRoot ? normalizePosix(outDir + "/" + sourceRoot) : outDir;
594
+ return posixResolve(base, raw);
595
+ }
596
+ function lookupPosition(decoder, generatedLine, generatedCol) {
597
+ const segments = decoder.segmentsByLine[generatedLine - 1];
598
+ if (!segments?.length) return null;
599
+ const col0 = generatedCol - 1;
600
+ let lo = 0, hi = segments.length - 1, best = -1;
601
+ while (lo <= hi) {
602
+ const mid = lo + hi >>> 1;
603
+ if (segments[mid].generatedCol <= col0) {
604
+ best = mid;
605
+ lo = mid + 1;
606
+ } else {
607
+ hi = mid - 1;
608
+ }
609
+ }
610
+ if (best === -1) return null;
611
+ const { sourceIndex, sourceLine, sourceCol } = segments[best];
612
+ const rawSource = decoder.sources[sourceIndex];
613
+ if (!rawSource) return null;
614
+ const content = decoder.sourcesContent[sourceIndex];
615
+ const sourceText = content ? content.split("\n", sourceLine + 1)[sourceLine]?.trim() || null : null;
616
+ return {
617
+ absolutePath: toAbsolutePath(rawSource, decoder.outDir, decoder.sourceRoot),
618
+ line: sourceLine + 1,
619
+ // 0-based → 1-based
620
+ col: sourceCol + 1,
621
+ // 0-based → 1-based
622
+ sourceText
623
+ };
624
+ }
625
+ function parseFrameLocation(s) {
626
+ const colSep = s.lastIndexOf(":");
627
+ if (colSep < 0) return null;
628
+ const colStr = s.slice(colSep + 1);
629
+ if (!/^\d+$/.test(colStr)) return null;
630
+ const lineSep = s.lastIndexOf(":", colSep - 1);
631
+ if (lineSep < 0) return null;
632
+ const lineStr = s.slice(lineSep + 1, colSep);
633
+ if (!/^\d+$/.test(lineStr)) return null;
634
+ return { url: s.slice(0, lineSep), line: +lineStr, col: +colStr };
635
+ }
636
+ function isBundleUrl(url) {
637
+ const normalized = url.startsWith("async ") ? url.slice(6) : url;
638
+ return /^https?:\/\//.test(normalized) && /\/(tests|filtered-tests)\.js$/.test(normalized);
639
+ }
640
+ function isNodeModulesPath(absolutePath) {
641
+ return absolutePath.includes("/node_modules/") || absolutePath.includes("\\node_modules\\");
642
+ }
643
+ function makeDisplayPath(absolutePath, projectRoot) {
644
+ const prefix = projectRoot + "/";
645
+ return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
646
+ }
647
+ function tryResolve(urlLineCol, decoder, projectRoot) {
648
+ const loc = parseFrameLocation(urlLineCol);
649
+ if (!loc || !isBundleUrl(loc.url)) return null;
650
+ const orig = lookupPosition(decoder, loc.line, loc.col);
651
+ if (!orig) return null;
652
+ const display = `${makeDisplayPath(orig.absolutePath, projectRoot)}:${orig.line}:${orig.col}`;
653
+ return {
654
+ display,
655
+ userPath: isNodeModulesPath(orig.absolutePath) ? null : display,
656
+ sourceText: orig.sourceText
657
+ };
658
+ }
659
+ function resolveFrame(frame, decoder, projectRoot) {
660
+ const chromeName = frame.match(/^(\s*at\s+)(.*?)\s+\(([^)]+)\)\s*$/);
661
+ if (chromeName) {
662
+ const r = tryResolve(chromeName[3], decoder, projectRoot);
663
+ return r ? {
664
+ resolved: `${chromeName[1]}${chromeName[2]} (${r.display})`,
665
+ userPath: r.userPath,
666
+ sourceText: r.sourceText
667
+ } : null;
668
+ }
669
+ const chromeAnon = frame.match(/^(\s*at\s+(?:async\s+)?)(.+)/);
670
+ if (chromeAnon) {
671
+ const r = tryResolve(chromeAnon[2], decoder, projectRoot);
672
+ return r ? { resolved: `${chromeAnon[1]}${r.display}`, userPath: r.userPath, sourceText: r.sourceText } : null;
673
+ }
674
+ const gecko = frame.match(/^([^@]*)@(.+)$/);
675
+ if (gecko) {
676
+ const r = tryResolve(gecko[2], decoder, projectRoot);
677
+ return r ? { resolved: `${gecko[1]}@${r.display}`, userPath: r.userPath, sourceText: r.sourceText } : null;
678
+ }
679
+ return null;
680
+ }
681
+ function resolveStack(stack, decoder, projectRoot) {
682
+ let firstUserFrame = null;
683
+ let firstUserSourceText = null;
684
+ const resolvedLines = stack.split("\n").map((frame) => {
685
+ const result = resolveFrame(frame, decoder, projectRoot);
686
+ if (!result) return frame;
687
+ if (!firstUserFrame && result.userPath) {
688
+ firstUserFrame = result.userPath;
689
+ firstUserSourceText = result.sourceText;
690
+ }
691
+ return result.resolved;
692
+ });
693
+ return { resolvedStack: resolvedLines.join("\n"), firstUserFrame, firstUserSourceText };
694
+ }
695
+ var BASE64, BASE64_LOOKUP, UTF8;
696
+ var init_source_map_decoder = __esm({
697
+ "lib/utils/source-map-decoder.ts"() {
698
+ BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
699
+ BASE64_LOOKUP = new Uint8Array(128);
700
+ [...BASE64].forEach((ch, i) => {
701
+ BASE64_LOOKUP[ch.charCodeAt(0)] = i;
702
+ });
703
+ UTF8 = new TextDecoder();
704
+ }
705
+ });
706
+
527
707
  // lib/tap/display-test-result.ts
528
- function TAPDisplayTestResult(COUNTER, details) {
708
+ function TAPDisplayTestResult(COUNTER, details, decoder, projectRoot) {
529
709
  COUNTER.testCount++;
530
710
  if (details.status === "skipped") {
531
711
  COUNTER.skipCount++;
@@ -545,6 +725,19 @@ function TAPDisplayTestResult(COUNTER, details) {
545
725
  if (!assertion.passed && assertion.todo === false) {
546
726
  COUNTER.errorCount = (COUNTER.errorCount ?? 0) + 1;
547
727
  process.stdout.write(" ---\n");
728
+ let stackStr = assertion.stack?.trim() || null;
729
+ let atStr = extractStackAt(assertion.stack);
730
+ let sourceText = null;
731
+ if (decoder && projectRoot && assertion.stack) {
732
+ const { resolvedStack, firstUserFrame, firstUserSourceText } = resolveStack(
733
+ assertion.stack,
734
+ decoder,
735
+ projectRoot
736
+ );
737
+ stackStr = resolvedStack.trim() || null;
738
+ atStr = firstUserFrame;
739
+ sourceText = firstUserSourceText;
740
+ }
548
741
  process.stdout.write(
549
742
  indentString(
550
743
  dumpYaml({
@@ -552,10 +745,9 @@ function TAPDisplayTestResult(COUNTER, details) {
552
745
  actual: assertion.actual !== null && typeof assertion.actual === "object" ? JSON.parse(JSON.stringify(assertion.actual, getCircularReplacer())) : assertion.actual,
553
746
  expected: assertion.expected !== null && typeof assertion.expected === "object" ? JSON.parse(JSON.stringify(assertion.expected, getCircularReplacer())) : assertion.expected,
554
747
  message: assertion.message || null,
555
- // Trim leading/trailing whitespace: Chrome stacks start with " at ..."
556
- // (4 spaces per frame) which would otherwise render as "stack: at ..." in YAML.
557
- stack: assertion.stack?.trim() || null,
558
- at: extractStackAt(assertion.stack)
748
+ stack: stackStr,
749
+ source: sourceText,
750
+ at: atStr
559
751
  }),
560
752
  4
561
753
  )
@@ -599,6 +791,7 @@ var init_display_test_result = __esm({
599
791
  "lib/tap/display-test-result.ts"() {
600
792
  init_dump_yaml();
601
793
  init_indent_string();
794
+ init_source_map_decoder();
602
795
  }
603
796
  });
604
797
 
@@ -780,11 +973,13 @@ var init_http = __esm({
780
973
  if (!this.routes[method]) {
781
974
  this.routes[method] = {};
782
975
  }
976
+ const paramNames = this.#extractParamNames(path7);
783
977
  this.routes[method][path7] = {
784
978
  path: path7,
785
979
  handler,
786
- paramNames: this.#extractParamNames(path7),
787
- isWildcard: path7 === "/*"
980
+ paramNames,
981
+ isWildcard: path7 === "/*",
982
+ compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path7, paramNames)}$`) : null
788
983
  };
789
984
  }
790
985
  #handleRequest(req, res) {
@@ -827,10 +1022,8 @@ var init_http = __esm({
827
1022
  return false;
828
1023
  }
829
1024
  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);
1025
+ if (route.compiledRegex) {
1026
+ const regexMatches = route.compiledRegex.exec(url);
834
1027
  if (regexMatches) {
835
1028
  route.paramValues = regexMatches.slice(1);
836
1029
  }
@@ -838,7 +1031,7 @@ var init_http = __esm({
838
1031
  return true;
839
1032
  }
840
1033
  return false;
841
- }) || routes["/*"] || null;
1034
+ }) || null;
842
1035
  }
843
1036
  #matchPathSegments(path7, url) {
844
1037
  const pathSegments = path7.split("/");
@@ -892,6 +1085,20 @@ function setupWebServer(config, cachedContent) {
892
1085
  config.projectRoot
893
1086
  );
894
1087
  const runtimeScript = testRuntimeToInject(config);
1088
+ const mainIndexHTML = escapeAndInjectTestsToHTML(
1089
+ mainHTMLWithReplacedAssets,
1090
+ runtimeScript,
1091
+ "./tests.js"
1092
+ );
1093
+ const mainQunitxHTML = escapeAndInjectTestsToHTML(
1094
+ mainHTMLWithReplacedAssets,
1095
+ runtimeScript,
1096
+ "./filtered-tests.js"
1097
+ );
1098
+ const saveHTML = (filePath, html) => fsPromise.writeFile(filePath, html).catch(
1099
+ (err) => config.debug && process.stderr.write(`# [qunitx] writeFile ${filePath}: ${err.message}
1100
+ `)
1101
+ );
895
1102
  server.wss.on("connection", function connection(socket) {
896
1103
  socket.on("message", function message(data) {
897
1104
  const { event, details, qunitResult, abort } = JSON.parse(data);
@@ -901,17 +1108,7 @@ function setupWebServer(config, cachedContent) {
901
1108
  } else if (event === "connection") {
902
1109
  config._phase = "running";
903
1110
  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
- }
1111
+ if (config.debug && config._groupMode) debugGroupHeader(config);
915
1112
  config._resetTestTimeout?.();
916
1113
  } else if (event === "testEnd" && !abort) {
917
1114
  if (details.status === "failed") {
@@ -924,7 +1121,7 @@ function setupWebServer(config, cachedContent) {
924
1121
  );
925
1122
  }
926
1123
  config._resetTestTimeout?.();
927
- TAPDisplayTestResult(config.COUNTER, details);
1124
+ TAPDisplayTestResult(config.COUNTER, details, config._sourceMapDecoder, config.projectRoot);
928
1125
  } else if (event === "done") {
929
1126
  config._phase = "done";
930
1127
  config._lastQUnitResult = qunitResult ?? null;
@@ -941,7 +1138,26 @@ function setupWebServer(config, cachedContent) {
941
1138
  }
942
1139
  });
943
1140
  });
944
- server.get("/tests.js", (_req, res) => {
1141
+ server.get("/tests.js", async (_req, res) => {
1142
+ if (cachedContent._activeRebuild) {
1143
+ await cachedContent._activeRebuild.catch(() => {
1144
+ });
1145
+ if (!cachedContent.allTestCode) {
1146
+ config._lastQUnitResult = {
1147
+ totalTests: 0,
1148
+ finishedTests: 0,
1149
+ failedTests: 0,
1150
+ currentTest: null
1151
+ };
1152
+ config._testRunDone?.();
1153
+ config._testRunDone = null;
1154
+ res.writeHead(200, {
1155
+ "Content-Type": "application/javascript",
1156
+ "Cache-Control": "no-store"
1157
+ });
1158
+ return void res.end();
1159
+ }
1160
+ }
945
1161
  const bytes = cachedContent.allTestCode?.length ?? null;
946
1162
  config.debug && process.stdout.write(
947
1163
  `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}
@@ -949,10 +1165,9 @@ function setupWebServer(config, cachedContent) {
949
1165
  );
950
1166
  if (bytes === null) {
951
1167
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
952
- res.end(
1168
+ return void res.end(
953
1169
  'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
954
1170
  );
955
- return;
956
1171
  }
957
1172
  config._onTestsJsServed?.();
958
1173
  res.writeHead(200, {
@@ -970,10 +1185,9 @@ function setupWebServer(config, cachedContent) {
970
1185
  );
971
1186
  if (bytes === null) {
972
1187
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
973
- res.end(
1188
+ return res.end(
974
1189
  'console.error("[qunitx] /filtered-tests.js requested before bundle was built \u2014 filteredTestCode is null");'
975
1190
  );
976
- return;
977
1191
  }
978
1192
  config._onTestsJsServed?.();
979
1193
  res.writeHead(200, {
@@ -984,68 +1198,48 @@ function setupWebServer(config, cachedContent) {
984
1198
  res.end(cachedContent.filteredTestCode);
985
1199
  });
986
1200
  server.get("/", async (_req, res) => {
1201
+ await cachedContent._activeRebuild?.catch(() => {
1202
+ });
987
1203
  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
- );
1204
+ const htmlContent = buildErrorHTML(cachedContent._buildError);
1205
+ res.writeHead(200, HTML_HEADERS);
1206
+ res.end(htmlContent);
1207
+ if (cachedContent._activeRebuild) {
1208
+ config._lastQUnitResult = {
1209
+ totalTests: 0,
1210
+ finishedTests: 0,
1211
+ failedTests: 0,
1212
+ currentTest: null
1213
+ };
1214
+ config._testRunDone?.();
1215
+ config._testRunDone = null;
1216
+ }
1217
+ return saveHTML(`${config.projectRoot}/${config.output}/index.html`, htmlContent);
996
1218
  }
997
1219
  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();
1002
- return;
1220
+ res.writeHead(200, HTML_HEADERS);
1221
+ return res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
1003
1222
  }
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
- );
1223
+ res.writeHead(200, HTML_HEADERS);
1224
+ res.end(mainIndexHTML);
1225
+ saveHTML(`${config.projectRoot}/${config.output}/index.html`, mainIndexHTML);
1016
1226
  });
1017
- server.get("/qunitx.html", async (_req, res) => {
1227
+ server.get("/qunitx.html", (_req, res) => {
1018
1228
  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
- );
1229
+ const htmlContent = buildErrorHTML(cachedContent._buildError);
1230
+ res.writeHead(200, HTML_HEADERS);
1231
+ res.end(htmlContent);
1232
+ return saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, htmlContent);
1027
1233
  }
1028
1234
  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();
1033
- return;
1235
+ res.writeHead(200, HTML_HEADERS);
1236
+ return res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
1034
1237
  }
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
- );
1238
+ res.writeHead(200, HTML_HEADERS);
1239
+ res.end(mainQunitxHTML);
1240
+ saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, mainQunitxHTML);
1047
1241
  });
1048
- server.get("/*", async (req, res) => {
1242
+ server.get("/*", (req, res) => {
1049
1243
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
1050
1244
  if (possibleDynamicHTML) {
1051
1245
  const htmlContent = escapeAndInjectTestsToHTML(
@@ -1053,13 +1247,10 @@ function setupWebServer(config, cachedContent) {
1053
1247
  runtimeScript,
1054
1248
  "/tests.js"
1055
1249
  );
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
- );
1250
+ res.writeHead(200, HTML_HEADERS);
1251
+ res.end(htmlContent);
1252
+ saveHTML(`${config.projectRoot}/${config.output}${req.path}`, htmlContent);
1253
+ return;
1063
1254
  }
1064
1255
  const url = req.url;
1065
1256
  const requestStartedAt = Date.now();
@@ -1093,13 +1284,9 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
1093
1284
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1094
1285
  }, html);
1095
1286
  }
1096
- function testRuntimeToInject(config) {
1287
+ function testRuntimeToInject(config, groupId) {
1288
+ const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1097
1289
  return `<script>
1098
- window.testTimeout = 0;
1099
- setInterval(() => {
1100
- window.testTimeout = window.testTimeout + 1000;
1101
- }, 1000);
1102
-
1103
1290
  (function() {
1104
1291
  // wsOpenStatus: true once the WebSocket 'open' event fires (or immediately for static files).
1105
1292
  // testsLoaded: true once tests.js has executed and dispatched 'qunitx:tests-ready'.
@@ -1125,7 +1312,7 @@ function testRuntimeToInject(config) {
1125
1312
  if (window.location.protocol === 'file:') return;
1126
1313
 
1127
1314
  let wsRetryCount = 0;
1128
- const WS_MAX_RETRIES = Math.ceil(${config.timeout} / 10); // retry for the full test timeout window
1315
+ const WS_MAX_RETRIES = Math.ceil(${config.timeout} / ${WS_RETRY_INTERVAL_MS}); // retry for the full test timeout window
1129
1316
 
1130
1317
  function setupWebSocket() {
1131
1318
  try {
@@ -1142,7 +1329,7 @@ function testRuntimeToInject(config) {
1142
1329
  // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1143
1330
  // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1144
1331
  if (navigator.webdriver) {
1145
- window.socket.send(JSON.stringify({ event: 'wsOpen' }));
1332
+ window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1146
1333
  }
1147
1334
  maybeStart();
1148
1335
  });
@@ -1164,10 +1351,9 @@ function testRuntimeToInject(config) {
1164
1351
  wsRetryCount++;
1165
1352
  if (wsRetryCount > WS_MAX_RETRIES) {
1166
1353
  console.log('WebSocket connection failed after ' + WS_MAX_RETRIES + ' retries');
1167
- window.testTimeout = ${config.timeout};
1168
1354
  return;
1169
1355
  }
1170
- window.setTimeout(setupWebSocket, 10);
1356
+ window.setTimeout(setupWebSocket, ${WS_RETRY_INTERVAL_MS});
1171
1357
  }
1172
1358
 
1173
1359
  setupWebSocket();
@@ -1201,8 +1387,6 @@ function testRuntimeToInject(config) {
1201
1387
  // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1202
1388
  window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1203
1389
  window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1204
- } else {
1205
- window.testTimeout = ${config.timeout};
1206
1390
  }
1207
1391
  return;
1208
1392
  }
@@ -1217,7 +1401,6 @@ function testRuntimeToInject(config) {
1217
1401
  window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
1218
1402
  });
1219
1403
  window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
1220
- window.testTimeout = 0;
1221
1404
  window.QUNIT_RESULT.finishedTests++;
1222
1405
  if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
1223
1406
  window.QUNIT_RESULT.currentTest = null;
@@ -1234,16 +1417,10 @@ function testRuntimeToInject(config) {
1234
1417
  window.QUnit.done((details) => {
1235
1418
  if (navigator.webdriver) {
1236
1419
  window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1237
- // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
1238
- // canonical completion signal for Playwright runs. waitForFunction is reserved
1239
- // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
1240
- // Setting testTimeout after done caused a race: under CI load, waitForFunction could
1241
- // win before Node.js processed the WS done message, dropping all testEnd events.
1242
- } else {
1243
- window.testTimeout = ${config.timeout};
1244
1420
  }
1245
1421
  });
1246
1422
 
1423
+ window.QUnit.config.testTimeout = ${config.timeout};
1247
1424
  window.QUnit.start();
1248
1425
  }
1249
1426
  </script>`;
@@ -1344,8 +1521,8 @@ function buildNoTestsHTML(files) {
1344
1521
  var retries = 0;
1345
1522
  function connect() {
1346
1523
  var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1347
- ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1348
- ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1524
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh' && !navigator.webdriver) location.reload(true); });
1525
+ ws.addEventListener('close', function () { if (retries++ < ${WATCH_WS_RECONNECT_MAX_RETRIES}) setTimeout(connect, ${WATCH_WS_RECONNECT_INTERVAL_MS}); });
1349
1526
  ws.addEventListener('error', function () { ws.close(); });
1350
1527
  }
1351
1528
  connect();
@@ -1446,8 +1623,8 @@ function buildErrorHTML(buildError) {
1446
1623
  var retries = 0;
1447
1624
  function connect() {
1448
1625
  var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1449
- ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1450
- ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1626
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh' && !navigator.webdriver) location.reload(true); });
1627
+ ws.addEventListener('close', function () { if (retries++ < ${WATCH_WS_RECONNECT_MAX_RETRIES}) setTimeout(connect, ${WATCH_WS_RECONNECT_INTERVAL_MS}); });
1451
1628
  ws.addEventListener('error', function () { ws.close(); });
1452
1629
  }
1453
1630
  connect();
@@ -1457,7 +1634,143 @@ function buildErrorHTML(buildError) {
1457
1634
  </body>
1458
1635
  </html>`;
1459
1636
  }
1460
- var fsPromise, NOT_FOUND_HTML;
1637
+ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
1638
+ const mainHTMLWithReplacedAssets = replaceAssetPaths(
1639
+ groupCachedContent.mainHTML.html,
1640
+ groupCachedContent.mainHTML.filePath,
1641
+ groupConfig.projectRoot
1642
+ );
1643
+ const runtimeScript = testRuntimeToInject(groupConfig, groupId);
1644
+ const mainGroupHTML = escapeAndInjectTestsToHTML(
1645
+ mainHTMLWithReplacedAssets,
1646
+ runtimeScript,
1647
+ "./tests.js"
1648
+ );
1649
+ const saveHTML = (filePath, html) => fsPromise.writeFile(filePath, html).catch(
1650
+ (err) => groupConfig.debug && process.stderr.write(`# [qunitx] writeFile ${filePath}: ${err.message}
1651
+ `)
1652
+ );
1653
+ server.get(`/group-${groupId}/`, (_req, res) => {
1654
+ if (groupCachedContent._buildError) {
1655
+ res.writeHead(200, HTML_HEADERS);
1656
+ return res.end(buildErrorHTML(groupCachedContent._buildError));
1657
+ }
1658
+ if (groupCachedContent._noTestsWarning) {
1659
+ res.writeHead(200, HTML_HEADERS);
1660
+ return res.end(buildNoTestsHTML(groupCachedContent._noTestsWarning));
1661
+ }
1662
+ res.writeHead(200, HTML_HEADERS);
1663
+ res.end(mainGroupHTML);
1664
+ saveHTML(`${groupConfig.projectRoot}/${groupConfig.output}/index.html`, mainGroupHTML);
1665
+ });
1666
+ server.get(`/group-${groupId}/tests.js`, (_req, res) => {
1667
+ const bytes = groupCachedContent.allTestCode?.length ?? null;
1668
+ if (bytes === null) {
1669
+ res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
1670
+ return res.end(
1671
+ 'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
1672
+ );
1673
+ }
1674
+ groupConfig._onTestsJsServed?.();
1675
+ res.writeHead(200, {
1676
+ "Content-Type": "application/javascript",
1677
+ "Cache-Control": "no-store",
1678
+ "Content-Length": bytes
1679
+ });
1680
+ res.end(groupCachedContent.allTestCode);
1681
+ });
1682
+ }
1683
+ function debugGroupHeader(config) {
1684
+ const files = Object.keys(config.fsTree);
1685
+ const rel = files.map((f) => f.replace(`${config.projectRoot}/`, ""));
1686
+ const shown = rel.slice(0, 2);
1687
+ const rest = rel.length - shown.length;
1688
+ process.stdout.write(
1689
+ `# ${blue(`\u2500\u2500 ${shown.join(" ")}${rest > 0 ? ` +${rest} more` : ""} \u2500\u2500`)}
1690
+ `
1691
+ );
1692
+ }
1693
+ function setupGroupWSHandler(server, groupConfigs) {
1694
+ const socketToGroupId = /* @__PURE__ */ new WeakMap();
1695
+ server.wss.on("connection", function connection(socket) {
1696
+ socket.on("message", function message(data) {
1697
+ const { event, groupId, details, qunitResult, abort } = JSON.parse(data);
1698
+ let resolvedGroupId = socketToGroupId.get(socket);
1699
+ if (event === "wsOpen" && typeof groupId === "number") {
1700
+ resolvedGroupId = groupId;
1701
+ socketToGroupId.set(socket, groupId);
1702
+ }
1703
+ if (resolvedGroupId === void 0) return;
1704
+ const config = groupConfigs[resolvedGroupId];
1705
+ if (!config) return;
1706
+ if (event === "wsOpen") {
1707
+ config._phase = "loading";
1708
+ config._onWsOpen?.();
1709
+ } else if (event === "connection") {
1710
+ config._phase = "running";
1711
+ if (config.debug) debugGroupHeader(config);
1712
+ config._resetTestTimeout?.();
1713
+ } else if (event === "testEnd" && !abort) {
1714
+ if (details.status === "failed") {
1715
+ config.lastFailedTestFiles = config.lastRanTestFiles;
1716
+ }
1717
+ if (config.debug && details.runtime > config.timeout * 0.8) {
1718
+ process.stdout.write(
1719
+ `# SLOW (${details.runtime.toFixed(0)}ms / ${config.timeout}ms timeout): ${details.fullName.join(" | ")}
1720
+ `
1721
+ );
1722
+ }
1723
+ config._resetTestTimeout?.();
1724
+ TAPDisplayTestResult(config.COUNTER, details, config._sourceMapDecoder, config.projectRoot);
1725
+ } else if (event === "done") {
1726
+ config._phase = "done";
1727
+ config._lastQUnitResult = qunitResult ?? null;
1728
+ if (config.debug) {
1729
+ process.stdout.write(
1730
+ `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)
1731
+ `
1732
+ );
1733
+ }
1734
+ if (typeof config._testRunDone === "function") {
1735
+ config._testRunDone();
1736
+ config._testRunDone = null;
1737
+ }
1738
+ }
1739
+ });
1740
+ });
1741
+ }
1742
+ function registerSharedStaticHandler(server, groupConfigs) {
1743
+ const groupUrlRegex = /^\/group-(\d+)(\/.*)?$/;
1744
+ server.get("/*", (req, res) => {
1745
+ const match = groupUrlRegex.exec(req.path);
1746
+ if (!match) {
1747
+ res.writeHead(404, { "Content-Type": "text/plain" });
1748
+ res.end("Not found");
1749
+ return;
1750
+ }
1751
+ const groupId = parseInt(match[1], 10);
1752
+ const groupConfig = groupConfigs[groupId];
1753
+ if (!groupConfig) {
1754
+ res.writeHead(404, { "Content-Type": "text/plain" });
1755
+ res.end("Not found");
1756
+ return;
1757
+ }
1758
+ const STATIC_FILES_PATH = path4.join(groupConfig.projectRoot, groupConfig.output);
1759
+ const subPath = match[2] || "/";
1760
+ const filePath = (subPath.endsWith("/") ? [STATIC_FILES_PATH, subPath, "index.html"] : [STATIC_FILES_PATH, subPath]).join("");
1761
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1762
+ const stream = fs8.createReadStream(filePath);
1763
+ stream.on("open", () => {
1764
+ res.writeHead(200, { "Content-Type": contentType });
1765
+ stream.pipe(res);
1766
+ });
1767
+ stream.on("error", () => {
1768
+ res.writeHead(404, { "Content-Type": contentType });
1769
+ res.end(contentType === MIME_TYPES.html ? NOT_FOUND_HTML : void 0);
1770
+ });
1771
+ });
1772
+ }
1773
+ var fsPromise, HTML_HEADERS, WATCH_WS_RECONNECT_INTERVAL_MS, WATCH_WS_RECONNECT_MAX_RETRIES, WS_RETRY_INTERVAL_MS, NOT_FOUND_HTML;
1461
1774
  var init_web_server = __esm({
1462
1775
  "lib/setup/web-server.ts"() {
1463
1776
  init_find_internal_assets_from_html();
@@ -1466,6 +1779,10 @@ var init_web_server = __esm({
1466
1779
  init_color();
1467
1780
  init_http();
1468
1781
  fsPromise = fs8.promises;
1782
+ HTML_HEADERS = { "Content-Type": "text/html", "Cache-Control": "no-store" };
1783
+ WATCH_WS_RECONNECT_INTERVAL_MS = 1e3;
1784
+ WATCH_WS_RECONNECT_MAX_RETRIES = 120;
1785
+ WS_RETRY_INTERVAL_MS = 10;
1469
1786
  NOT_FOUND_HTML = `<!DOCTYPE html>
1470
1787
  <html lang="en">
1471
1788
  <head>
@@ -1541,19 +1858,24 @@ async function launchBrowser(config) {
1541
1858
  handleSIGHUP: false
1542
1859
  });
1543
1860
  }
1544
- async function setupBrowser(config, cachedContent, existingBrowser = null) {
1861
+ async function setupBrowser(config, cachedContent, existingBrowser = null, sharedServer = null) {
1545
1862
  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`);
1863
+ const [server, browser, page] = await (async () => {
1864
+ if (sharedServer) {
1865
+ const newPage2 = await existingBrowser.newPage();
1866
+ perfLog(`browser.js: newPage (shared server) took ${Date.now() - setupStart}ms`);
1867
+ return [sharedServer, existingBrowser, newPage2];
1868
+ }
1869
+ const newServer = setupWebServer(config, cachedContent);
1870
+ perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1871
+ const activeBrowser = existingBrowser ?? await launchBrowser(config);
1872
+ const pageStart = Date.now();
1873
+ const isHeadedWatchMode = config.open === true && config.watch;
1874
+ const getPage = isHeadedWatchMode ? () => activeBrowser.contexts()[0]?.pages()[0] ?? activeBrowser.newPage() : () => activeBrowser.newPage();
1875
+ const [newPage] = await Promise.all([getPage(), bindServerToPort(newServer, config)]);
1876
+ perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
1877
+ return [newServer, activeBrowser, newPage];
1878
+ })();
1557
1879
  if (config.browser === "firefox") {
1558
1880
  await page.addInitScript(() => {
1559
1881
  const preSerialize = (arg) => {
@@ -1740,7 +2062,7 @@ async function buildTestBundle(config, cachedContent) {
1740
2062
  }
1741
2063
  const outfile = `${projectRoot}/${output}/tests.js`;
1742
2064
  await fs9.mkdir(`${projectRoot}/${output}`, { recursive: true });
1743
- const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
2065
+ const sourcemap = "inline";
1744
2066
  const needsDisk = true;
1745
2067
  const buildOptions = {
1746
2068
  stdin: {
@@ -1780,6 +2102,7 @@ async function buildTestBundle(config, cachedContent) {
1780
2102
  )
1781
2103
  ]);
1782
2104
  cachedContent.allTestCode = allTestCode;
2105
+ config._sourceMapDecoder = extractInlineSourceMap(allTestCode, `${projectRoot}/${output}`);
1783
2106
  } catch (error) {
1784
2107
  cachedContent._buildError = {
1785
2108
  type: deriveBuildErrorType(error),
@@ -1811,9 +2134,13 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1811
2134
  const preBuildPromise = cachedContent._preBuildPromise;
1812
2135
  cachedContent._preBuildPromise = null;
1813
2136
  if (!cachedContent.allTestCode) {
1814
- await (preBuildPromise ?? buildTestBundle(config, cachedContent));
2137
+ if (preBuildPromise) {
2138
+ cachedContent._activeRebuild = preBuildPromise;
2139
+ } else {
2140
+ await buildTestBundle(config, cachedContent);
2141
+ }
1815
2142
  }
1816
- if (!cachedContent.allTestCode) {
2143
+ if (!cachedContent.allTestCode && !cachedContent._activeRebuild) {
1817
2144
  return connections;
1818
2145
  }
1819
2146
  if (runHasFilter) {
@@ -1823,6 +2150,10 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1823
2150
  outputPath,
1824
2151
  config
1825
2152
  );
2153
+ config._sourceMapDecoder = extractInlineSourceMap(
2154
+ cachedContent.filteredTestCode,
2155
+ `${projectRoot}/${output}`
2156
+ );
1826
2157
  }
1827
2158
  const TIME_COUNTER = timeCounter();
1828
2159
  if (runHasFilter) {
@@ -1834,6 +2165,17 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1834
2165
  )
1835
2166
  );
1836
2167
  }
2168
+ if (cachedContent._activeRebuild) {
2169
+ await cachedContent._activeRebuild.catch(() => {
2170
+ });
2171
+ cachedContent._activeRebuild = null;
2172
+ if (!cachedContent.allTestCode) {
2173
+ config.watch && cachedContent._buildError && console.log(
2174
+ `# esbuild Bundle Error: ${cachedContent._buildError.formatted}`.split("\n").join("\n# ")
2175
+ );
2176
+ return connections;
2177
+ }
2178
+ }
1837
2179
  const TIME_TAKEN = TIME_COUNTER.stop();
1838
2180
  if (!config._groupMode) {
1839
2181
  if (config.COUNTER.testCount === 0 && !cachedContent._buildError) {
@@ -1865,6 +2207,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1865
2207
  }
1866
2208
  }
1867
2209
  } catch (error) {
2210
+ cachedContent._activeRebuild = null;
1868
2211
  config.lastFailedTestFiles = config.lastRanTestFiles;
1869
2212
  const exception = new BundleError(error);
1870
2213
  if (!cachedContent._buildError && error.errors?.length) {
@@ -1875,8 +2218,10 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1875
2218
  fs9.writeFile(
1876
2219
  `${projectRoot}/${output}/qunitx.html`,
1877
2220
  buildErrorHTML(cachedContent._buildError)
1878
- ).catch(() => {
1879
- });
2221
+ ).catch(
2222
+ (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
2223
+ `)
2224
+ );
1880
2225
  }
1881
2226
  if (config.watch) {
1882
2227
  console.log(`# ${exception}`);
@@ -1887,8 +2232,8 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1887
2232
  return connections;
1888
2233
  }
1889
2234
  function buildFilteredTests(filteredTests, outputPath, config) {
1890
- const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1891
- const needsDisk = sourcemap === "linked" || Boolean(config.open);
2235
+ const sourcemap = "inline";
2236
+ const needsDisk = Boolean(config.open);
1892
2237
  return buildWithOverlayfsRetry(
1893
2238
  {
1894
2239
  stdin: {
@@ -1908,9 +2253,6 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1908
2253
  );
1909
2254
  }
1910
2255
  async function runWithOverlayfsRetry(getContents, needsDisk) {
1911
- const RETRY_DELAY_MS = 100;
1912
- const MAX_RETRIES = 3;
1913
- const EMPTY_BUNDLE_THRESHOLD = 500;
1914
2256
  let { result, js } = await getContents();
1915
2257
  const initialSize = js.length;
1916
2258
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
@@ -1965,9 +2307,9 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1965
2307
  let wsConnected = false;
1966
2308
  try {
1967
2309
  console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
1968
- const navMs = config.timeout + 1e4;
1969
- const startupMs = Math.max(config.timeout * 3, navMs);
1970
- const testsJsMs = Math.max(config.timeout * 4, navMs);
2310
+ const navMs = config.timeout + NAV_GRACE_MS;
2311
+ const startupMs = Math.max(config.timeout * STARTUP_TIMEOUT_FACTOR, navMs);
2312
+ const testsJsMs = Math.max(config.timeout * TESTS_JS_TIMEOUT_FACTOR, navMs);
1971
2313
  let resolveTestRace;
1972
2314
  const testRaceResult = new Promise((resolve) => {
1973
2315
  resolveTestRace = resolve;
@@ -1985,7 +2327,7 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1985
2327
  config._resetTestTimeout = () => {
1986
2328
  wsConnected = true;
1987
2329
  clearTimeout(timeoutHandle);
1988
- timeoutHandle = setTimeout(resolveTestRace, config.timeout);
2330
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout + TEST_STALL_BUFFER_MS);
1989
2331
  };
1990
2332
  const targetUrl = `http://localhost:${config.port}${filePath}`;
1991
2333
  const navOptions = { timeout: navMs, waitUntil: "commit" };
@@ -2055,12 +2397,122 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
2055
2397
  process.exit(1);
2056
2398
  }
2057
2399
  }
2058
- async function flushConsoleHandlers(handlers, deadline = Date.now() + 2e3) {
2400
+ async function flushConsoleHandlers(handlers, deadline = Date.now() + CONSOLE_FLUSH_TIMEOUT_MS) {
2059
2401
  if (!handlers || handlers.size === 0 || Date.now() >= deadline) return;
2060
2402
  await Promise.allSettled([...handlers]);
2061
2403
  return flushConsoleHandlers(handlers, deadline);
2062
2404
  }
2063
- var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES;
2405
+ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2406
+ groupCachedContents.forEach((cachedContent) => {
2407
+ cachedContent._buildError = null;
2408
+ cachedContent._noTestsWarning = null;
2409
+ });
2410
+ const { projectRoot, debug, browser } = groupConfigs[0];
2411
+ const activeGroups = groupConfigs.reduce(
2412
+ (acc, groupConfig, groupIndex) => {
2413
+ const files = Object.keys(groupConfig.fsTree);
2414
+ if (files.length > 0)
2415
+ acc.push({
2416
+ groupIndex,
2417
+ config: groupConfig,
2418
+ cachedContent: groupCachedContents[groupIndex],
2419
+ files
2420
+ });
2421
+ return acc;
2422
+ },
2423
+ []
2424
+ );
2425
+ if (activeGroups.length === 0)
2426
+ return console.log(
2427
+ "# [buildAllGroupBundles] all groups empty \u2014 skipping build (no test files found)"
2428
+ );
2429
+ await Promise.all(
2430
+ activeGroups.map(
2431
+ (group) => fs9.mkdir(`${group.config.projectRoot}/${group.config.output}`, { recursive: true })
2432
+ )
2433
+ );
2434
+ const sourcemap = "inline";
2435
+ const groupEntryPlugin = {
2436
+ name: "group-entry-loader",
2437
+ setup(build) {
2438
+ build.onResolve({ filter: /^group-entry-\d+$/ }, (args) => ({
2439
+ path: args.path,
2440
+ namespace: "group-entry"
2441
+ }));
2442
+ build.onLoad({ filter: /.*/, namespace: "group-entry" }, (args) => {
2443
+ const slotIndex = parseInt(args.path.replace("group-entry-", ""));
2444
+ return {
2445
+ contents: activeGroups[slotIndex].files.map((filePath) => `import "${filePath}";`).join(""),
2446
+ resolveDir: process.cwd()
2447
+ };
2448
+ });
2449
+ }
2450
+ };
2451
+ const esbuildOutdir = path5.join(projectRoot, "tmp");
2452
+ const buildOptions = {
2453
+ entryPoints: activeGroups.map((_, slotIndex) => ({
2454
+ in: `group-entry-${slotIndex}`,
2455
+ out: `group-${slotIndex}`
2456
+ })),
2457
+ plugins: [groupEntryPlugin],
2458
+ nodePaths: ANCESTOR_NODE_MODULES,
2459
+ bundle: true,
2460
+ logLevel: "silent",
2461
+ // outdir only labels the paths in outputFiles[].path — nothing is written to disk
2462
+ // (write:false). Use projectRoot/tmp as a stable sentinel; mkdir is not required.
2463
+ outdir: esbuildOutdir,
2464
+ keepNames: true,
2465
+ legalComments: "none",
2466
+ target: esbuildTarget(browser),
2467
+ sourcemap,
2468
+ write: false,
2469
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2470
+ };
2471
+ const hasSmallOutput = (result) => (result.outputFiles ?? []).some(
2472
+ (outputFile) => GROUP_OUTPUT_REGEX.test(outputFile.path) && !outputFile.path.endsWith(".map") && outputFile.contents.length < EMPTY_BUNDLE_THRESHOLD
2473
+ );
2474
+ const buildWithRetry = async (retriesLeft) => {
2475
+ const result = await esbuild.build(buildOptions);
2476
+ if (!hasSmallOutput(result) || retriesLeft === 0) return result;
2477
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
2478
+ return buildWithRetry(retriesLeft - 1);
2479
+ };
2480
+ try {
2481
+ const result = await buildWithRetry(MAX_RETRIES);
2482
+ await Promise.all(
2483
+ (result.outputFiles ?? []).map((outputFile) => {
2484
+ const match = GROUP_OUTPUT_REGEX.exec(outputFile.path);
2485
+ if (!match) return Promise.resolve();
2486
+ const slotIndex = parseInt(match[1]);
2487
+ const isMap = Boolean(match[2]);
2488
+ const { config, cachedContent } = activeGroups[slotIndex];
2489
+ const destPath = `${config.projectRoot}/${config.output}/tests.js${isMap ? ".map" : ""}`;
2490
+ if (!isMap) {
2491
+ cachedContent.allTestCode = Buffer.from(outputFile.contents);
2492
+ config._sourceMapDecoder = extractInlineSourceMap(
2493
+ cachedContent.allTestCode,
2494
+ esbuildOutdir
2495
+ );
2496
+ }
2497
+ return fs9.writeFile(destPath, outputFile.contents);
2498
+ })
2499
+ );
2500
+ } catch (error) {
2501
+ const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
2502
+ const errorHtml = buildErrorHTML(buildError);
2503
+ await Promise.all(
2504
+ activeGroups.map((group) => {
2505
+ group.cachedContent._buildError = buildError;
2506
+ return fs9.writeFile(`${group.config.projectRoot}/${group.config.output}/index.html`, errorHtml).catch(
2507
+ (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2508
+ `)
2509
+ );
2510
+ })
2511
+ );
2512
+ throw error;
2513
+ }
2514
+ }
2515
+ var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES, RETRY_DELAY_MS, MAX_RETRIES, EMPTY_BUNDLE_THRESHOLD, NAV_GRACE_MS, STARTUP_TIMEOUT_FACTOR, TESTS_JS_TIMEOUT_FACTOR, CONSOLE_FLUSH_TIMEOUT_MS, TEST_STALL_BUFFER_MS, GROUP_OUTPUT_REGEX;
2064
2516
  var init_tests_in_browser = __esm({
2065
2517
  "lib/commands/run/tests-in-browser.ts"() {
2066
2518
  init_color();
@@ -2069,6 +2521,7 @@ var init_tests_in_browser = __esm({
2069
2521
  init_run_user_module();
2070
2522
  init_display_final_result();
2071
2523
  init_web_server();
2524
+ init_source_map_decoder();
2072
2525
  BundleError = class extends Error {
2073
2526
  constructor(message) {
2074
2527
  super(message);
@@ -2080,6 +2533,15 @@ var init_tests_in_browser = __esm({
2080
2533
  (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2081
2534
  );
2082
2535
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
2536
+ RETRY_DELAY_MS = 100;
2537
+ MAX_RETRIES = 3;
2538
+ EMPTY_BUNDLE_THRESHOLD = 500;
2539
+ NAV_GRACE_MS = 1e4;
2540
+ STARTUP_TIMEOUT_FACTOR = 3;
2541
+ TESTS_JS_TIMEOUT_FACTOR = 4;
2542
+ CONSOLE_FLUSH_TIMEOUT_MS = 2e3;
2543
+ TEST_STALL_BUFFER_MS = 5e3;
2544
+ GROUP_OUTPUT_REGEX = /group-(\d+)\.js(\.map)?$/;
2083
2545
  }
2084
2546
  });
2085
2547
 
@@ -2104,7 +2566,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2104
2566
  }
2105
2567
  }
2106
2568
  };
2107
- fs10.watchFile(filePath, { interval: 500, persistent: false }, handler);
2569
+ fs10.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
2108
2570
  symlinkPollers.set(filePath, () => fs10.unwatchFile(filePath, handler));
2109
2571
  }
2110
2572
  function untrackSymlink(filePath) {
@@ -2118,13 +2580,17 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2118
2580
  if (!ready || !filename) return;
2119
2581
  const fullPath = filename === path6.basename(watchPath) ? watchPath : path6.join(watchPath, filename);
2120
2582
  if (eventType === "change") {
2121
- if (!config._building) {
2122
- const now = Date.now();
2123
- const last = lastChangeMs[fullPath] ?? 0;
2124
- if (now - last < CHANGE_DEDUPE_MS) {
2125
- if (!config._lastBuildEndMs || config._lastBuildEndMs <= last) return;
2583
+ const now = Date.now();
2584
+ const last = lastChangeMs[fullPath] ?? 0;
2585
+ lastChangeMs[fullPath] = now;
2586
+ if (now - last < CHANGE_DEDUPE_MS && (config._building || !config._lastBuildEndMs || config._lastBuildEndMs <= last))
2587
+ return;
2588
+ if (config._lastBuildEndMs) {
2589
+ try {
2590
+ const { mtimeMs } = await stat(fullPath);
2591
+ if (mtimeMs < Math.floor(config._lastBuildEndMs / 1e3) * 1e3) return;
2592
+ } catch {
2126
2593
  }
2127
- lastChangeMs[fullPath] = now;
2128
2594
  }
2129
2595
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
2130
2596
  }
@@ -2194,7 +2660,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2194
2660
  };
2195
2661
  }
2196
2662
  async function classifyRenameEvent(fullPath, fsTree) {
2197
- for (const delay of [0, 50]) {
2663
+ for (const delay of [0, OVERLAYFS_RENAME_RETRY_MS]) {
2198
2664
  if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2199
2665
  try {
2200
2666
  const statResult = await stat(fullPath);
@@ -2262,11 +2728,13 @@ function colorEvent(event) {
2262
2728
  if (event === "add" || event === "addDir") return green("ADDED:");
2263
2729
  return red("REMOVED:");
2264
2730
  }
2265
- var CHANGE_DEDUPE_MS;
2731
+ var CHANGE_DEDUPE_MS, SYMLINK_POLL_INTERVAL_MS, OVERLAYFS_RENAME_RETRY_MS;
2266
2732
  var init_file_watcher = __esm({
2267
2733
  "lib/setup/file-watcher.ts"() {
2268
2734
  init_color();
2269
2735
  CHANGE_DEDUPE_MS = 10;
2736
+ SYMLINK_POLL_INTERVAL_MS = 500;
2737
+ OVERLAYFS_RENAME_RETRY_MS = 50;
2270
2738
  }
2271
2739
  });
2272
2740
 
@@ -2383,7 +2851,11 @@ import fs12 from "node:fs/promises";
2383
2851
  import { normalize } from "node:path";
2384
2852
  import { availableParallelism } from "node:os";
2385
2853
  async function run(config) {
2386
- const cachedContent = await buildCachedContent(config, config.htmlPaths);
2854
+ const browserPromise = config.watch ? null : launchBrowser(config);
2855
+ const [cachedContent, timings] = await Promise.all([
2856
+ buildCachedContent(config, config.htmlPaths),
2857
+ config.watch ? Promise.resolve(null) : readTimingCache(config.projectRoot)
2858
+ ]);
2387
2859
  if (config.watch) {
2388
2860
  const preBuildPromise = buildTestBundle(config, cachedContent);
2389
2861
  preBuildPromise.catch(() => {
@@ -2393,7 +2865,7 @@ async function run(config) {
2393
2865
  setupBrowser(config, cachedContent),
2394
2866
  writeOutputStaticFiles(config, cachedContent)
2395
2867
  ]);
2396
- config.expressApp = connections.server;
2868
+ config.webServer = connections.server;
2397
2869
  setupKeyboardEvents(config, cachedContent, connections);
2398
2870
  const isHeadedWatchMode = config.open === true && config.watch;
2399
2871
  if (config.open && !isHeadedWatchMode) {
@@ -2412,7 +2884,10 @@ async function run(config) {
2412
2884
  throw error;
2413
2885
  }
2414
2886
  if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2415
- await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2887
+ await connections.page.goto(`http://localhost:${config.port}/`, {
2888
+ waitUntil: "commit",
2889
+ timeout: WATCH_NAV_TIMEOUT_MS
2890
+ }).catch(() => {
2416
2891
  });
2417
2892
  }
2418
2893
  if (config.watch) {
@@ -2429,6 +2904,10 @@ async function run(config) {
2429
2904
  `# Rerun triggered: ${event} \u2192 ${file.replace(`${config.projectRoot}/`, "")}`
2430
2905
  );
2431
2906
  }
2907
+ const rebuildPromise = buildTestBundle(config, cachedContent);
2908
+ rebuildPromise.catch(() => {
2909
+ });
2910
+ cachedContent._preBuildPromise = rebuildPromise;
2432
2911
  return await runTestsInBrowser(config, cachedContent, connections);
2433
2912
  }
2434
2913
  if (config.debug) {
@@ -2441,7 +2920,10 @@ async function run(config) {
2441
2920
  async (_path, _event) => {
2442
2921
  connections.server.publish("refresh");
2443
2922
  if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2444
- await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2923
+ await connections.page.goto(`http://localhost:${config.port}/`, {
2924
+ waitUntil: "commit",
2925
+ timeout: WATCH_NAV_TIMEOUT_MS
2926
+ }).catch(() => {
2445
2927
  });
2446
2928
  }
2447
2929
  }
@@ -2452,8 +2934,7 @@ async function run(config) {
2452
2934
  } else {
2453
2935
  const allFiles = Object.keys(config.fsTree);
2454
2936
  const groupCount = Math.min(allFiles.length, availableParallelism());
2455
- const timings = await readTimingCache(config.projectRoot);
2456
- const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings);
2937
+ const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings ?? {});
2457
2938
  config.COUNTER = {
2458
2939
  testCount: 0,
2459
2940
  failCount: 0,
@@ -2472,21 +2953,32 @@ async function run(config) {
2472
2953
  _phase: "bundling"
2473
2954
  }));
2474
2955
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
2956
+ const sharedServer = groupCount > 1 && cachedContent.htmlPathsToRunTests[0] === "/" && cachedContent.htmlPathsToRunTests.length === 1 ? (() => {
2957
+ const s = new HTTPServer();
2958
+ setupGroupWSHandler(s, groupConfigs);
2959
+ groupConfigs.forEach((gc, i) => registerGroupRoutes(s, gc, groupCachedContents[i], i));
2960
+ registerSharedStaticHandler(s, groupConfigs);
2961
+ return s;
2962
+ })() : null;
2475
2963
  process.stdout.write("TAP version 13\n");
2476
2964
  process.stdout.write(
2477
2965
  `# Running ${allFiles.length} test file${allFiles.length === 1 ? "" : "s"} across ${groupCount} group${groupCount === 1 ? "" : "s"}
2478
2966
  `
2479
2967
  );
2480
2968
  const [browser] = await Promise.all([
2481
- launchBrowser(config),
2482
- Promise.all(
2483
- groupConfigs.map(
2484
- (groupConfig, i) => Promise.all([
2485
- buildTestBundle(groupConfig, groupCachedContents[i]),
2486
- writeOutputStaticFiles(groupConfig, groupCachedContents[i])
2487
- ])
2969
+ browserPromise,
2970
+ sharedServer ? bindServerToPort(sharedServer, config).then(
2971
+ () => groupConfigs.forEach((gc, i) => {
2972
+ gc.port = config.port;
2973
+ groupCachedContents[i].htmlPathsToRunTests = [`/group-${i}/`];
2974
+ })
2975
+ ) : Promise.resolve(),
2976
+ Promise.all([
2977
+ groupCount > 1 ? buildAllGroupBundles(groupConfigs, groupCachedContents) : buildTestBundle(groupConfigs[0], groupCachedContents[0]),
2978
+ Promise.all(
2979
+ groupConfigs.map((gc, i) => writeOutputStaticFiles(gc, groupCachedContents[i]))
2488
2980
  )
2489
- )
2981
+ ])
2490
2982
  ]);
2491
2983
  if (config.open) {
2492
2984
  void openOutputInBrowser(config);
@@ -2495,7 +2987,7 @@ async function run(config) {
2495
2987
  const wallTimes = /* @__PURE__ */ new Map();
2496
2988
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
2497
2989
  const keepAlive = setInterval(() => {
2498
- }, 1e3);
2990
+ }, KEEP_ALIVE_INTERVAL_MS);
2499
2991
  const groupResults = await Promise.allSettled(
2500
2992
  groupConfigs.map((groupConfig, i) => {
2501
2993
  const groupTimeout = new Promise((_, reject) => {
@@ -2515,8 +3007,13 @@ async function run(config) {
2515
3007
  const startMs = Date.now();
2516
3008
  const work = (async () => {
2517
3009
  groupConfig._phase = "connecting";
2518
- const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
2519
- groupConfig.expressApp = connections.server;
3010
+ const connections = await setupBrowser(
3011
+ groupConfig,
3012
+ groupCachedContents[i],
3013
+ browser,
3014
+ sharedServer
3015
+ );
3016
+ groupConfig.webServer = connections.server;
2520
3017
  if (config.before) {
2521
3018
  await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, "before");
2522
3019
  }
@@ -2525,13 +3022,13 @@ async function run(config) {
2525
3022
  } finally {
2526
3023
  await flushConsoleHandlers(groupConfig._pendingConsoleHandlers);
2527
3024
  await Promise.all([
2528
- connections.server && connections.server.close(),
3025
+ !sharedServer && connections.server?.close(),
2529
3026
  connections.page && // Unref'd: the keepAlive interval above holds the event loop open, so this
2530
3027
  // timer still fires if page.close() hangs, without preventing process exit later.
2531
3028
  Promise.race([
2532
3029
  connections.page.close(),
2533
3030
  new Promise((resolve) => {
2534
- const pageCloseTimeoutId = setTimeout(resolve, 1e4);
3031
+ const pageCloseTimeoutId = setTimeout(resolve, PAGE_CLOSE_GRACE_MS);
2535
3032
  pageCloseTimeoutId.unref();
2536
3033
  })
2537
3034
  ]).catch(() => {
@@ -2561,20 +3058,30 @@ async function run(config) {
2561
3058
  }
2562
3059
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
2563
3060
  const fileTimes = computeFileTimes(groups, weights, wallTimes);
2564
- persistTimings(fileTimes, config.projectRoot).catch(() => {
2565
- });
3061
+ persistTimings(fileTimes, config.projectRoot).catch(
3062
+ (err) => config.debug && process.stderr.write(`# [qunitx] persistTimings: ${err.message}
3063
+ `)
3064
+ );
2566
3065
  printFileTimings(fileTimes, config.projectRoot);
2567
3066
  if (config.after) {
2568
3067
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
2569
3068
  }
2570
- const exitTimer = setTimeout(() => process.exit(exitCode), 5e3);
3069
+ const exitTimer = setTimeout(() => process.exit(exitCode), STDOUT_FLUSH_GRACE_MS);
2571
3070
  exitTimer.unref();
2572
3071
  process.stdout.write("\n", async () => {
2573
3072
  clearTimeout(exitTimer);
2574
- clearInterval(keepAlive);
2575
- await browser.close().catch(() => {
2576
- });
3073
+ await Promise.all([
3074
+ sharedServer?.close().catch(
3075
+ (err) => config.debug && process.stderr.write(`# [qunitx] server.close: ${err.message}
3076
+ `)
3077
+ ),
3078
+ browser.close().catch(
3079
+ (err) => config.debug && process.stderr.write(`# [qunitx] browser.close: ${err.message}
3080
+ `)
3081
+ )
3082
+ ]);
2577
3083
  await shutdownPrelaunch();
3084
+ clearInterval(keepAlive);
2578
3085
  process.exit(exitCode);
2579
3086
  });
2580
3087
  }
@@ -2670,7 +3177,7 @@ ${lines.join("\n")}
2670
3177
  async function splitIntoGroups(files, groupCount, timings) {
2671
3178
  const sizes = await Promise.all(
2672
3179
  files.map(
2673
- (f) => fs12.stat(f).then((s) => s.size).catch(() => 0)
3180
+ (f) => timings[f] > 0 ? Promise.resolve(0) : fs12.stat(f).then((s) => s.size).catch(() => 0)
2674
3181
  )
2675
3182
  );
2676
3183
  const knownRates = files.map((f, i) => ({ ms: timings[f], size: sizes[i] })).filter(({ ms, size }) => ms > 0 && size > 0);
@@ -2703,10 +3210,14 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
2703
3210
  const currentDirectory = htmlPath ? htmlPath.split("/").slice(0, -1).join("/") : projectRoot;
2704
3211
  return assetPath.startsWith("./") ? normalize(`${currentDirectory}/${assetPath.slice(2)}`) : normalize(`${currentDirectory}/${assetPath}`);
2705
3212
  }
3213
+ var WATCH_NAV_TIMEOUT_MS, PAGE_CLOSE_GRACE_MS, STDOUT_FLUSH_GRACE_MS, KEEP_ALIVE_INTERVAL_MS;
2706
3214
  var init_run = __esm({
2707
3215
  "lib/commands/run.ts"() {
2708
3216
  init_browser();
2709
3217
  init_chrome_prelaunch();
3218
+ init_http();
3219
+ init_bind_server_to_port();
3220
+ init_web_server();
2710
3221
  init_open_output_in_browser();
2711
3222
  init_color();
2712
3223
  init_tests_in_browser();
@@ -2719,6 +3230,10 @@ var init_run = __esm({
2719
3230
  init_display_final_result();
2720
3231
  init_read_template();
2721
3232
  init_html();
3233
+ WATCH_NAV_TIMEOUT_MS = 5e3;
3234
+ PAGE_CLOSE_GRACE_MS = 1e4;
3235
+ STDOUT_FLUSH_GRACE_MS = 5e3;
3236
+ KEEP_ALIVE_INTERVAL_MS = 1e4;
2722
3237
  }
2723
3238
  });
2724
3239
 
@@ -2733,7 +3248,7 @@ init_color();
2733
3248
  var package_default = {
2734
3249
  name: "qunitx-cli",
2735
3250
  type: "module",
2736
- version: "0.19.2",
3251
+ version: "0.20.0",
2737
3252
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2738
3253
  author: "Izel Nakri",
2739
3254
  license: "MIT",
@@ -2788,8 +3303,6 @@ var package_default = {
2788
3303
  ws: "^8.20.0"
2789
3304
  },
2790
3305
  devDependencies: {
2791
- cors: "^2.8.6",
2792
- express: "^5.2.1",
2793
3306
  "js-yaml": "^4.1.1",
2794
3307
  prettier: "^3.8.2",
2795
3308
  qunitx: "^1.2.7",
@@ -3099,6 +3612,7 @@ function buildGlobFormat(path7) {
3099
3612
  }
3100
3613
 
3101
3614
  // lib/utils/parse-cli-flags.ts
3615
+ var FALLBACK_TIMEOUT_MS = 1e4;
3102
3616
  function parseCliFlags(projectRoot) {
3103
3617
  const providedFlags = process.argv.slice(2).reduce(
3104
3618
  (result, arg) => {
@@ -3113,7 +3627,7 @@ function parseCliFlags(projectRoot) {
3113
3627
  } else if (arg.startsWith("--failfast") || arg.startsWith("--failFast")) {
3114
3628
  return Object.assign(result, { failFast: parseBoolean(arg.split("=")[1]) });
3115
3629
  } else if (arg.startsWith("--timeout")) {
3116
- return Object.assign(result, { timeout: Number(arg.split("=")[1]) || 1e4 });
3630
+ return Object.assign(result, { timeout: Number(arg.split("=")[1]) || FALLBACK_TIMEOUT_MS });
3117
3631
  } else if (arg.startsWith("--output")) {
3118
3632
  return Object.assign(result, { output: arg.split("=")[1] });
3119
3633
  } else if (arg.endsWith(".html")) {
@@ -3238,6 +3752,7 @@ process4.title = "qunitx";
3238
3752
  } catch (error) {
3239
3753
  console.error(error);
3240
3754
  process4.exitCode = 1;
3755
+ await shutdownPrelaunch();
3241
3756
  process4.stdout.write("\n", () => process4.exit(1));
3242
3757
  }
3243
3758
  })();