qunitx-cli 0.19.3 → 0.21.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 (3) hide show
  1. package/README.md +111 -0
  2. package/dist/cli.js +575 -230
  3. package/package.json +4 -6
package/dist/cli.js CHANGED
@@ -38,10 +38,11 @@ var init_find_chrome = __esm({
38
38
  });
39
39
 
40
40
  // lib/utils/kill-process-group.ts
41
+ import { spawnSync } from "node:child_process";
41
42
  function killProcessGroup(pid) {
42
43
  try {
43
44
  if (process.platform === "win32") {
44
- process.kill(pid, "SIGKILL");
45
+ spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
45
46
  } else {
46
47
  process.kill(-pid, "SIGKILL");
47
48
  }
@@ -92,12 +93,17 @@ async function cleanupBrowserDir(dirPath) {
92
93
  }
93
94
  const dirName = dirPath.split("/").pop();
94
95
  await killAllReferencingProcesses(dirPath, dirName);
95
- const deadline = Date.now() + 5e3;
96
+ const deadline = Date.now() + CLEANUP_DEADLINE_MS;
96
97
  while (Date.now() < deadline) {
97
- const removed = await fs.rm(dirPath, { recursive: true, force: true }).then(() => true).catch(() => false);
98
+ const removed = await fs.rm(dirPath, { recursive: true, force: true }).then(
99
+ () => fs.access(dirPath).then(
100
+ () => false,
101
+ () => true
102
+ )
103
+ ).catch(() => false);
98
104
  if (removed) return;
99
105
  await killAllReferencingProcesses(dirPath, dirName);
100
- await new Promise((resolve) => setTimeout(resolve, 50));
106
+ await new Promise((resolve) => setTimeout(resolve, CLEANUP_RETRY_MS));
101
107
  }
102
108
  if (!await fs.access(dirPath).then(() => true).catch(() => false))
103
109
  return;
@@ -117,8 +123,11 @@ async function cleanupBrowserDir(dirPath) {
117
123
  })
118
124
  );
119
125
  }
126
+ var CLEANUP_DEADLINE_MS, CLEANUP_RETRY_MS;
120
127
  var init_cleanup_browser_dir = __esm({
121
128
  "lib/utils/cleanup-browser-dir.ts"() {
129
+ CLEANUP_DEADLINE_MS = 5e3;
130
+ CLEANUP_RETRY_MS = 50;
122
131
  }
123
132
  });
124
133
 
@@ -191,8 +200,11 @@ var init_chromium_args = __esm({
191
200
  // ── Sandbox / rendering ──────────────────────────────────────────────────────
192
201
  "--no-sandbox",
193
202
  // required in most CI/container environments
194
- "--disable-gpu",
195
- // no GPU in headless; avoids GPU process startup
203
+ // SwiftShader software WebGL: needed on Linux CI (containerised, no GPU).
204
+ // Omit on macOS — Chrome uses Metal natively; SwiftShader crashes the renderer
205
+ // process on macOS arm64, causing "Target page, context or browser has been closed".
206
+ // Omit on Windows — ANGLE/D3D11 is available and SwiftShader is not needed.
207
+ ...process.platform === "linux" ? ["--enable-unsafe-swiftshader"] : [],
196
208
  // ── Window / UI ──────────────────────────────────────────────────────────────
197
209
  "--window-size=1440,900",
198
210
  "--hide-scrollbars",
@@ -299,7 +311,13 @@ var init_chrome_prelaunch = __esm({
299
311
  else if (arg === "--watch" || arg === "-w") flags.watchFromArgv = true;
300
312
  return flags;
301
313
  },
302
- { browserFromArgv: "chromium", openFromArgv: false, watchFromArgv: false }
314
+ // QUNITX_BROWSER env var seeds the default so prelaunch is skipped for firefox/webkit
315
+ // even when --browser is not passed on the command line (e.g. browser-compat CI).
316
+ {
317
+ browserFromArgv: process.env.QUNITX_BROWSER || "chromium",
318
+ openFromArgv: false,
319
+ watchFromArgv: false
320
+ }
303
321
  ));
304
322
  openWatchMode = openFromArgv && watchFromArgv;
305
323
  earlyChrome = null;
@@ -310,7 +328,7 @@ var init_chrome_prelaunch = __esm({
310
328
  });
311
329
  }
312
330
  perfLog("chrome-prelaunch.ts: module evaluated");
313
- prelaunchPromise = isRunCommand && browserFromArgv === "chromium" ? findChrome().then((chromePath) => {
331
+ prelaunchPromise = isRunCommand && browserFromArgv === "chromium" && process.platform !== "darwin" ? findChrome().then((chromePath) => {
314
332
  perfLog("chrome-prelaunch.ts: findChrome resolved", chromePath);
315
333
  return preLaunchChrome(chromePath, CHROMIUM_ARGS, !openWatchMode);
316
334
  }).then((info) => {
@@ -472,10 +490,11 @@ function dumpYaml({
472
490
  expected,
473
491
  message,
474
492
  stack,
493
+ source,
475
494
  at
476
495
  }) {
477
496
  return `name: ${dumpString(name, "")}
478
- ` + yamlLine("actual", actual) + yamlLine("expected", expected) + (message !== null ? yamlLine("message", message) : "") + (stack !== null ? yamlLine("stack", stack) : "") + (at !== null ? yamlLine("at", at) : "");
497
+ ` + 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) : "");
479
498
  }
480
499
  var NEEDS_QUOTING;
481
500
  var init_dump_yaml = __esm({
@@ -498,8 +517,205 @@ var init_indent_string = __esm({
498
517
  }
499
518
  });
500
519
 
520
+ // lib/utils/source-map-decoder.ts
521
+ function readVLQ(s, pos) {
522
+ let accumulated = 0;
523
+ let shift = 0;
524
+ let digit;
525
+ do {
526
+ digit = BASE64_LOOKUP[s.charCodeAt(pos++)];
527
+ accumulated |= (digit & 31) << shift;
528
+ shift += 5;
529
+ } while (digit & 32);
530
+ return [accumulated & 1 ? -(accumulated >>> 1) : accumulated >>> 1, pos];
531
+ }
532
+ function decodeMappings(mappings) {
533
+ let sourceIndex = 0, sourceLine = 0, sourceCol = 0;
534
+ return mappings.split(";").map((lineStr) => {
535
+ const segments = [];
536
+ let generatedCol = 0;
537
+ let pos = 0;
538
+ while (pos < lineStr.length) {
539
+ if (lineStr[pos] === ",") {
540
+ pos++;
541
+ continue;
542
+ }
543
+ let delta;
544
+ [delta, pos] = readVLQ(lineStr, pos);
545
+ generatedCol += delta;
546
+ if (pos >= lineStr.length || lineStr[pos] === ",") continue;
547
+ [delta, pos] = readVLQ(lineStr, pos);
548
+ sourceIndex += delta;
549
+ [delta, pos] = readVLQ(lineStr, pos);
550
+ sourceLine += delta;
551
+ [delta, pos] = readVLQ(lineStr, pos);
552
+ sourceCol += delta;
553
+ if (pos < lineStr.length && lineStr[pos] !== ",") [, pos] = readVLQ(lineStr, pos);
554
+ segments.push({ generatedCol, sourceIndex, sourceLine, sourceCol });
555
+ }
556
+ return segments;
557
+ });
558
+ }
559
+ function parseSourceMap(json, outDir) {
560
+ const map = JSON.parse(json);
561
+ return {
562
+ segmentsByLine: decodeMappings(map.mappings),
563
+ sources: map.sources ?? [],
564
+ sourceRoot: map.sourceRoot ?? "",
565
+ outDir,
566
+ sourcesContent: map.sourcesContent ?? []
567
+ };
568
+ }
569
+ function base64DecodeUtf8(b64) {
570
+ const binary = atob(b64);
571
+ return UTF8.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
572
+ }
573
+ function extractInlineSourceMap(bundle, outDir) {
574
+ if (!bundle) return null;
575
+ const text = typeof bundle === "string" ? bundle : UTF8.decode(bundle);
576
+ const match = text.match(
577
+ /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/
578
+ );
579
+ if (!match) return null;
580
+ try {
581
+ return parseSourceMap(base64DecodeUtf8(match[1]), outDir);
582
+ } catch {
583
+ return null;
584
+ }
585
+ }
586
+ function normalizePosix(p) {
587
+ const abs = p.startsWith("/");
588
+ const parts = p.split("/");
589
+ const out = [];
590
+ for (const part of parts) {
591
+ if (part === "..") out.pop();
592
+ else if (part !== "" && part !== ".") out.push(part);
593
+ }
594
+ return (abs ? "/" : "") + out.join("/");
595
+ }
596
+ function posixResolve(base, relative) {
597
+ if (relative.startsWith("/")) return normalizePosix(relative);
598
+ return normalizePosix(base + "/" + relative);
599
+ }
600
+ function toAbsolutePath(raw, outDir, sourceRoot) {
601
+ if (raw.startsWith("file://")) return raw.slice(7);
602
+ if (raw.startsWith("/")) return raw;
603
+ const base = sourceRoot ? normalizePosix(outDir + "/" + sourceRoot) : outDir;
604
+ return posixResolve(base, raw);
605
+ }
606
+ function lookupPosition(decoder, generatedLine, generatedCol) {
607
+ const segments = decoder.segmentsByLine[generatedLine - 1];
608
+ if (!segments?.length) return null;
609
+ const col0 = generatedCol - 1;
610
+ let lo = 0, hi = segments.length - 1, best = -1;
611
+ while (lo <= hi) {
612
+ const mid = lo + hi >>> 1;
613
+ if (segments[mid].generatedCol <= col0) {
614
+ best = mid;
615
+ lo = mid + 1;
616
+ } else {
617
+ hi = mid - 1;
618
+ }
619
+ }
620
+ if (best === -1) return null;
621
+ const { sourceIndex, sourceLine, sourceCol } = segments[best];
622
+ const rawSource = decoder.sources[sourceIndex];
623
+ if (!rawSource) return null;
624
+ const content = decoder.sourcesContent[sourceIndex];
625
+ const sourceText = content ? content.split("\n", sourceLine + 1)[sourceLine]?.trim() || null : null;
626
+ return {
627
+ absolutePath: toAbsolutePath(rawSource, decoder.outDir, decoder.sourceRoot),
628
+ line: sourceLine + 1,
629
+ // 0-based → 1-based
630
+ col: sourceCol + 1,
631
+ // 0-based → 1-based
632
+ sourceText
633
+ };
634
+ }
635
+ function parseFrameLocation(s) {
636
+ const colSep = s.lastIndexOf(":");
637
+ if (colSep < 0) return null;
638
+ const colStr = s.slice(colSep + 1);
639
+ if (!/^\d+$/.test(colStr)) return null;
640
+ const lineSep = s.lastIndexOf(":", colSep - 1);
641
+ if (lineSep < 0) return null;
642
+ const lineStr = s.slice(lineSep + 1, colSep);
643
+ if (!/^\d+$/.test(lineStr)) return null;
644
+ return { url: s.slice(0, lineSep), line: +lineStr, col: +colStr };
645
+ }
646
+ function isBundleUrl(url) {
647
+ const normalized = url.startsWith("async ") ? url.slice(6) : url;
648
+ return /^https?:\/\//.test(normalized) && /\/(tests|filtered-tests)\.js$/.test(normalized);
649
+ }
650
+ function isNodeModulesPath(absolutePath) {
651
+ return absolutePath.includes("/node_modules/") || absolutePath.includes("\\node_modules\\");
652
+ }
653
+ function makeDisplayPath(absolutePath, projectRoot) {
654
+ const prefix = projectRoot + "/";
655
+ return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
656
+ }
657
+ function tryResolve(urlLineCol, decoder, projectRoot) {
658
+ const loc = parseFrameLocation(urlLineCol);
659
+ if (!loc || !isBundleUrl(loc.url)) return null;
660
+ const orig = lookupPosition(decoder, loc.line, loc.col);
661
+ if (!orig) return null;
662
+ const display = `${makeDisplayPath(orig.absolutePath, projectRoot)}:${orig.line}:${orig.col}`;
663
+ return {
664
+ display,
665
+ userPath: isNodeModulesPath(orig.absolutePath) ? null : display,
666
+ sourceText: orig.sourceText
667
+ };
668
+ }
669
+ function resolveFrame(frame, decoder, projectRoot) {
670
+ const chromeName = frame.match(/^(\s*at\s+)(.*?)\s+\(([^)]+)\)\s*$/);
671
+ if (chromeName) {
672
+ const r = tryResolve(chromeName[3], decoder, projectRoot);
673
+ return r ? {
674
+ resolved: `${chromeName[1]}${chromeName[2]} (${r.display})`,
675
+ userPath: r.userPath,
676
+ sourceText: r.sourceText
677
+ } : null;
678
+ }
679
+ const chromeAnon = frame.match(/^(\s*at\s+(?:async\s+)?)(.+)/);
680
+ if (chromeAnon) {
681
+ const r = tryResolve(chromeAnon[2], decoder, projectRoot);
682
+ return r ? { resolved: `${chromeAnon[1]}${r.display}`, userPath: r.userPath, sourceText: r.sourceText } : null;
683
+ }
684
+ const gecko = frame.match(/^([^@]*)@(.+)$/);
685
+ if (gecko) {
686
+ const r = tryResolve(gecko[2], decoder, projectRoot);
687
+ return r ? { resolved: `${gecko[1]}@${r.display}`, userPath: r.userPath, sourceText: r.sourceText } : null;
688
+ }
689
+ return null;
690
+ }
691
+ function resolveStack(stack, decoder, projectRoot) {
692
+ let firstUserFrame = null;
693
+ let firstUserSourceText = null;
694
+ const resolvedLines = stack.split("\n").map((frame) => {
695
+ const result = resolveFrame(frame, decoder, projectRoot);
696
+ if (!result) return frame;
697
+ if (!firstUserFrame && result.userPath) {
698
+ firstUserFrame = result.userPath;
699
+ firstUserSourceText = result.sourceText;
700
+ }
701
+ return result.resolved;
702
+ });
703
+ return { resolvedStack: resolvedLines.join("\n"), firstUserFrame, firstUserSourceText };
704
+ }
705
+ var BASE64, BASE64_LOOKUP, UTF8;
706
+ var init_source_map_decoder = __esm({
707
+ "lib/utils/source-map-decoder.ts"() {
708
+ BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
709
+ BASE64_LOOKUP = new Uint8Array(128);
710
+ [...BASE64].forEach((ch, i) => {
711
+ BASE64_LOOKUP[ch.charCodeAt(0)] = i;
712
+ });
713
+ UTF8 = new TextDecoder();
714
+ }
715
+ });
716
+
501
717
  // lib/tap/display-test-result.ts
502
- function TAPDisplayTestResult(COUNTER, details) {
718
+ function TAPDisplayTestResult(COUNTER, details, decoder, projectRoot) {
503
719
  COUNTER.testCount++;
504
720
  if (details.status === "skipped") {
505
721
  COUNTER.skipCount++;
@@ -519,6 +735,19 @@ function TAPDisplayTestResult(COUNTER, details) {
519
735
  if (!assertion.passed && assertion.todo === false) {
520
736
  COUNTER.errorCount = (COUNTER.errorCount ?? 0) + 1;
521
737
  process.stdout.write(" ---\n");
738
+ let stackStr = assertion.stack?.trim() || null;
739
+ let atStr = extractStackAt(assertion.stack);
740
+ let sourceText = null;
741
+ if (decoder && projectRoot && assertion.stack) {
742
+ const { resolvedStack, firstUserFrame, firstUserSourceText } = resolveStack(
743
+ assertion.stack,
744
+ decoder,
745
+ projectRoot
746
+ );
747
+ stackStr = resolvedStack.trim() || null;
748
+ atStr = firstUserFrame;
749
+ sourceText = firstUserSourceText;
750
+ }
522
751
  process.stdout.write(
523
752
  indentString(
524
753
  dumpYaml({
@@ -526,10 +755,9 @@ function TAPDisplayTestResult(COUNTER, details) {
526
755
  actual: assertion.actual !== null && typeof assertion.actual === "object" ? JSON.parse(JSON.stringify(assertion.actual, getCircularReplacer())) : assertion.actual,
527
756
  expected: assertion.expected !== null && typeof assertion.expected === "object" ? JSON.parse(JSON.stringify(assertion.expected, getCircularReplacer())) : assertion.expected,
528
757
  message: assertion.message || null,
529
- // Trim leading/trailing whitespace: Chrome stacks start with " at ..."
530
- // (4 spaces per frame) which would otherwise render as "stack: at ..." in YAML.
531
- stack: assertion.stack?.trim() || null,
532
- at: extractStackAt(assertion.stack)
758
+ stack: stackStr,
759
+ source: sourceText,
760
+ at: atStr
533
761
  }),
534
762
  4
535
763
  )
@@ -573,6 +801,7 @@ var init_display_test_result = __esm({
573
801
  "lib/tap/display-test-result.ts"() {
574
802
  init_dump_yaml();
575
803
  init_indent_string();
804
+ init_source_map_decoder();
576
805
  }
577
806
  });
578
807
 
@@ -703,8 +932,8 @@ var init_http = __esm({
703
932
  });
704
933
  }
705
934
  /** Registers a GET route handler. */
706
- get(path7, handler) {
707
- this.#registerRouteHandler("GET", path7, handler);
935
+ get(path10, handler) {
936
+ this.#registerRouteHandler("GET", path10, handler);
708
937
  }
709
938
  /**
710
939
  * Starts listening on the given port (0 = OS-assigned).
@@ -735,32 +964,32 @@ var init_http = __esm({
735
964
  });
736
965
  }
737
966
  /** Registers a POST route handler. */
738
- post(path7, handler) {
739
- this.#registerRouteHandler("POST", path7, handler);
967
+ post(path10, handler) {
968
+ this.#registerRouteHandler("POST", path10, handler);
740
969
  }
741
970
  /** Registers a DELETE route handler. */
742
- delete(path7, handler) {
743
- this.#registerRouteHandler("DELETE", path7, handler);
971
+ delete(path10, handler) {
972
+ this.#registerRouteHandler("DELETE", path10, handler);
744
973
  }
745
974
  /** Registers a PUT route handler. */
746
- put(path7, handler) {
747
- this.#registerRouteHandler("PUT", path7, handler);
975
+ put(path10, handler) {
976
+ this.#registerRouteHandler("PUT", path10, handler);
748
977
  }
749
978
  /** Adds a middleware function to the chain. */
750
979
  use(middleware) {
751
980
  this.middleware.push(middleware);
752
981
  }
753
- #registerRouteHandler(method, path7, handler) {
982
+ #registerRouteHandler(method, path10, handler) {
754
983
  if (!this.routes[method]) {
755
984
  this.routes[method] = {};
756
985
  }
757
- const paramNames = this.#extractParamNames(path7);
758
- this.routes[method][path7] = {
759
- path: path7,
986
+ const paramNames = this.#extractParamNames(path10);
987
+ this.routes[method][path10] = {
988
+ path: path10,
760
989
  handler,
761
990
  paramNames,
762
- isWildcard: path7 === "/*",
763
- compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path7, paramNames)}$`) : null
991
+ isWildcard: path10 === "/*",
992
+ compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path10, paramNames)}$`) : null
764
993
  };
765
994
  }
766
995
  #handleRequest(req, res) {
@@ -798,11 +1027,11 @@ var init_http = __esm({
798
1027
  return null;
799
1028
  }
800
1029
  return routes[url] || Object.values(routes).find((route) => {
801
- const { path: path7, isWildcard } = route;
802
- if (!isWildcard && !path7.includes(":")) {
1030
+ const { path: path10, isWildcard } = route;
1031
+ if (!isWildcard && !path10.includes(":")) {
803
1032
  return false;
804
1033
  }
805
- if (isWildcard || this.#matchPathSegments(path7, url)) {
1034
+ if (isWildcard || this.#matchPathSegments(path10, url)) {
806
1035
  if (route.compiledRegex) {
807
1036
  const regexMatches = route.compiledRegex.exec(url);
808
1037
  if (regexMatches) {
@@ -812,10 +1041,10 @@ var init_http = __esm({
812
1041
  return true;
813
1042
  }
814
1043
  return false;
815
- }) || routes["/*"] || null;
1044
+ }) || null;
816
1045
  }
817
- #matchPathSegments(path7, url) {
818
- const pathSegments = path7.split("/");
1046
+ #matchPathSegments(path10, url) {
1047
+ const pathSegments = path10.split("/");
819
1048
  const urlSegments = url.split("/");
820
1049
  if (pathSegments.length !== urlSegments.length) {
821
1050
  return false;
@@ -832,14 +1061,14 @@ var init_http = __esm({
832
1061
  }
833
1062
  return true;
834
1063
  }
835
- #buildRegexPattern(path7, _paramNames) {
836
- let regexPattern = path7.replace(/:[^/]+/g, "([^/]+)");
1064
+ #buildRegexPattern(path10, _paramNames) {
1065
+ let regexPattern = path10.replace(/:[^/]+/g, "([^/]+)");
837
1066
  regexPattern = regexPattern.replace(/\//g, "\\/");
838
1067
  return regexPattern;
839
1068
  }
840
- #extractParamNames(path7) {
1069
+ #extractParamNames(path10) {
841
1070
  const paramRegex = /:(\w+)/g;
842
- const paramMatches = path7.match(paramRegex);
1071
+ const paramMatches = path10.match(paramRegex);
843
1072
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
844
1073
  }
845
1074
  #extractParams(route, _url) {
@@ -856,9 +1085,9 @@ var init_http = __esm({
856
1085
 
857
1086
  // lib/setup/web-server.ts
858
1087
  import fs8 from "node:fs";
859
- import path4 from "node:path";
1088
+ import path5 from "node:path";
860
1089
  function setupWebServer(config, cachedContent) {
861
- const STATIC_FILES_PATH = path4.join(config.projectRoot, config.output);
1090
+ const STATIC_FILES_PATH = path5.resolve(config.projectRoot, config.output);
862
1091
  const server = new HTTPServer();
863
1092
  const mainHTMLWithReplacedAssets = replaceAssetPaths(
864
1093
  cachedContent.mainHTML.html,
@@ -902,7 +1131,7 @@ function setupWebServer(config, cachedContent) {
902
1131
  );
903
1132
  }
904
1133
  config._resetTestTimeout?.();
905
- TAPDisplayTestResult(config.COUNTER, details);
1134
+ TAPDisplayTestResult(config.COUNTER, details, config._sourceMapDecoder, config.projectRoot);
906
1135
  } else if (event === "done") {
907
1136
  config._phase = "done";
908
1137
  config._lastQUnitResult = qunitResult ?? null;
@@ -919,7 +1148,26 @@ function setupWebServer(config, cachedContent) {
919
1148
  }
920
1149
  });
921
1150
  });
922
- server.get("/tests.js", (_req, res) => {
1151
+ server.get("/tests.js", async (_req, res) => {
1152
+ if (cachedContent._activeRebuild) {
1153
+ await cachedContent._activeRebuild.catch(() => {
1154
+ });
1155
+ if (!cachedContent.allTestCode) {
1156
+ config._lastQUnitResult = {
1157
+ totalTests: 0,
1158
+ finishedTests: 0,
1159
+ failedTests: 0,
1160
+ currentTest: null
1161
+ };
1162
+ config._testRunDone?.();
1163
+ config._testRunDone = null;
1164
+ res.writeHead(200, {
1165
+ "Content-Type": "application/javascript",
1166
+ "Cache-Control": "no-store"
1167
+ });
1168
+ return void res.end();
1169
+ }
1170
+ }
923
1171
  const bytes = cachedContent.allTestCode?.length ?? null;
924
1172
  config.debug && process.stdout.write(
925
1173
  `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}
@@ -927,10 +1175,9 @@ function setupWebServer(config, cachedContent) {
927
1175
  );
928
1176
  if (bytes === null) {
929
1177
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
930
- res.end(
1178
+ return void res.end(
931
1179
  'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
932
1180
  );
933
- return;
934
1181
  }
935
1182
  config._onTestsJsServed?.();
936
1183
  res.writeHead(200, {
@@ -948,10 +1195,9 @@ function setupWebServer(config, cachedContent) {
948
1195
  );
949
1196
  if (bytes === null) {
950
1197
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
951
- res.end(
1198
+ return res.end(
952
1199
  'console.error("[qunitx] /filtered-tests.js requested before bundle was built \u2014 filteredTestCode is null");'
953
1200
  );
954
- return;
955
1201
  }
956
1202
  config._onTestsJsServed?.();
957
1203
  res.writeHead(200, {
@@ -961,39 +1207,59 @@ function setupWebServer(config, cachedContent) {
961
1207
  });
962
1208
  res.end(cachedContent.filteredTestCode);
963
1209
  });
964
- server.get("/", (_req, res) => {
1210
+ server.get("/", async (_req, res) => {
1211
+ await cachedContent._activeRebuild?.catch(() => {
1212
+ });
965
1213
  if (cachedContent._buildError) {
966
1214
  const htmlContent = buildErrorHTML(cachedContent._buildError);
967
1215
  res.writeHead(200, HTML_HEADERS);
968
1216
  res.end(htmlContent);
969
- saveHTML(`${config.projectRoot}/${config.output}/index.html`, htmlContent);
970
- return;
1217
+ if (cachedContent._activeRebuild) {
1218
+ config._lastQUnitResult = {
1219
+ totalTests: 0,
1220
+ finishedTests: 0,
1221
+ failedTests: 0,
1222
+ currentTest: null
1223
+ };
1224
+ config._testRunDone?.();
1225
+ config._testRunDone = null;
1226
+ }
1227
+ return saveHTML(
1228
+ path5.join(path5.resolve(config.projectRoot, config.output), "index.html"),
1229
+ htmlContent
1230
+ );
971
1231
  }
972
1232
  if (cachedContent._noTestsWarning) {
973
1233
  res.writeHead(200, HTML_HEADERS);
974
- res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
975
- return;
1234
+ return res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
976
1235
  }
977
1236
  res.writeHead(200, HTML_HEADERS);
978
1237
  res.end(mainIndexHTML);
979
- saveHTML(`${config.projectRoot}/${config.output}/index.html`, mainIndexHTML);
1238
+ saveHTML(
1239
+ path5.join(path5.resolve(config.projectRoot, config.output), "index.html"),
1240
+ mainIndexHTML
1241
+ );
980
1242
  });
981
1243
  server.get("/qunitx.html", (_req, res) => {
982
1244
  if (cachedContent._buildError) {
983
1245
  const htmlContent = buildErrorHTML(cachedContent._buildError);
984
1246
  res.writeHead(200, HTML_HEADERS);
985
1247
  res.end(htmlContent);
986
- saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, htmlContent);
987
- return;
1248
+ return saveHTML(
1249
+ path5.join(path5.resolve(config.projectRoot, config.output), "qunitx.html"),
1250
+ htmlContent
1251
+ );
988
1252
  }
989
1253
  if (cachedContent._noTestsWarning) {
990
1254
  res.writeHead(200, HTML_HEADERS);
991
- res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
992
- return;
1255
+ return res.end(buildNoTestsHTML(cachedContent._noTestsWarning));
993
1256
  }
994
1257
  res.writeHead(200, HTML_HEADERS);
995
1258
  res.end(mainQunitxHTML);
996
- saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, mainQunitxHTML);
1259
+ saveHTML(
1260
+ path5.join(path5.resolve(config.projectRoot, config.output), "qunitx.html"),
1261
+ mainQunitxHTML
1262
+ );
997
1263
  });
998
1264
  server.get("/*", (req, res) => {
999
1265
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
@@ -1005,13 +1271,13 @@ function setupWebServer(config, cachedContent) {
1005
1271
  );
1006
1272
  res.writeHead(200, HTML_HEADERS);
1007
1273
  res.end(htmlContent);
1008
- saveHTML(`${config.projectRoot}/${config.output}${req.path}`, htmlContent);
1274
+ saveHTML(path5.join(path5.resolve(config.projectRoot, config.output), req.path), htmlContent);
1009
1275
  return;
1010
1276
  }
1011
1277
  const url = req.url;
1012
1278
  const requestStartedAt = Date.now();
1013
1279
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
1014
- const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1280
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path5.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1015
1281
  const stream = fs8.createReadStream(filePath);
1016
1282
  stream.on("open", () => {
1017
1283
  res.writeHead(200, { "Content-Type": contentType });
@@ -1036,44 +1302,35 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
1036
1302
  const assetPaths = findInternalAssetsFromHTML(html);
1037
1303
  const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
1038
1304
  return assetPaths.reduce((result, assetPath) => {
1039
- const normalizedFullAbsolutePath = path4.normalize(`${htmlDirectory}/${assetPath}`);
1305
+ const normalizedFullAbsolutePath = path5.normalize(`${htmlDirectory}/${assetPath}`);
1040
1306
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1041
1307
  }, html);
1042
1308
  }
1043
1309
  function testRuntimeToInject(config, groupId) {
1044
1310
  const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1045
1311
  return `<script>
1046
- window.testTimeout = 0;
1047
- setInterval(() => {
1048
- window.testTimeout = window.testTimeout + 1000;
1049
- }, 1000);
1050
-
1051
1312
  (function() {
1052
- // wsOpenStatus: true once the WebSocket 'open' event fires (or immediately for static files).
1053
- // testsLoaded: true once tests.js has executed and dispatched 'qunitx:tests-ready'.
1054
- // Both must be true before setupQUnit() is called, ensuring QUnit.start() only runs
1055
- // after all test modules are registered.
1056
- let wsOpenStatus = window.location.protocol === 'file:';
1057
- let testsLoaded = false;
1058
-
1059
- function maybeStart() {
1060
- if (wsOpenStatus && testsLoaded) setupQUnit();
1061
- }
1062
-
1063
- // tests.js (loaded as an async external script) dispatches this event after registering
1064
- // all test modules. Decoupled from WS so Chrome can compile tests.js in a background
1065
- // thread while the main thread handles the WebSocket handshake.
1066
- window.addEventListener('qunitx:tests-ready', function() {
1067
- testsLoaded = true;
1068
- maybeStart();
1313
+ // setupQUnit runs exactly once, after both the WebSocket is open and tests.js has loaded.
1314
+ // Promise.all is naturally idempotent \u2014 resolving a Promise a second time is a no-op,
1315
+ // so WebKit firing WS error after open (causing a retry that re-opens) cannot double-start.
1316
+ let resolveWsReady = () => {};
1317
+ const wsReadyPromise = window.location.protocol === 'file:'
1318
+ ? Promise.resolve()
1319
+ : new Promise(resolve => { resolveWsReady = resolve; });
1320
+
1321
+ // { once: true } auto-removes the listener after the first fire.
1322
+ const testsReadyPromise = new Promise(resolve => {
1323
+ window.addEventListener('qunitx:tests-ready', resolve, { once: true });
1069
1324
  });
1070
1325
 
1071
- // For static files (file:// protocol) there is no WebSocket server.
1072
- // wsOpenStatus is already true above; setupQUnit fires when tests load.
1326
+ Promise.all([wsReadyPromise, testsReadyPromise]).then(setupQUnit);
1327
+
1328
+ // For static files (file:// protocol) there is no WebSocket server; wsReadyPromise
1329
+ // is already resolved above, so setupQUnit fires as soon as tests load.
1073
1330
  if (window.location.protocol === 'file:') return;
1074
1331
 
1075
1332
  let wsRetryCount = 0;
1076
- const WS_MAX_RETRIES = Math.ceil(${config.timeout} / 10); // retry for the full test timeout window
1333
+ const WS_MAX_RETRIES = Math.ceil(${config.timeout} / ${WS_RETRY_INTERVAL_MS}); // retry for the full test timeout window
1077
1334
 
1078
1335
  function setupWebSocket() {
1079
1336
  try {
@@ -1085,14 +1342,13 @@ function testRuntimeToInject(config, groupId) {
1085
1342
  }
1086
1343
 
1087
1344
  window.socket.addEventListener('open', function() {
1088
- wsOpenStatus = true;
1345
+ resolveWsReady();
1089
1346
  // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
1090
1347
  // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1091
1348
  // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1092
1349
  if (navigator.webdriver) {
1093
1350
  window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1094
1351
  }
1095
- maybeStart();
1096
1352
  });
1097
1353
  window.socket.addEventListener('error', function() {
1098
1354
  retryOrFail();
@@ -1112,10 +1368,9 @@ function testRuntimeToInject(config, groupId) {
1112
1368
  wsRetryCount++;
1113
1369
  if (wsRetryCount > WS_MAX_RETRIES) {
1114
1370
  console.log('WebSocket connection failed after ' + WS_MAX_RETRIES + ' retries');
1115
- window.testTimeout = ${config.timeout};
1116
1371
  return;
1117
1372
  }
1118
- window.setTimeout(setupWebSocket, 10);
1373
+ window.setTimeout(setupWebSocket, ${WS_RETRY_INTERVAL_MS});
1119
1374
  }
1120
1375
 
1121
1376
  setupWebSocket();
@@ -1149,8 +1404,6 @@ function testRuntimeToInject(config, groupId) {
1149
1404
  // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1150
1405
  window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1151
1406
  window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1152
- } else {
1153
- window.testTimeout = ${config.timeout};
1154
1407
  }
1155
1408
  return;
1156
1409
  }
@@ -1165,7 +1418,6 @@ function testRuntimeToInject(config, groupId) {
1165
1418
  window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
1166
1419
  });
1167
1420
  window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
1168
- window.testTimeout = 0;
1169
1421
  window.QUNIT_RESULT.finishedTests++;
1170
1422
  if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
1171
1423
  window.QUNIT_RESULT.currentTest = null;
@@ -1182,16 +1434,10 @@ function testRuntimeToInject(config, groupId) {
1182
1434
  window.QUnit.done((details) => {
1183
1435
  if (navigator.webdriver) {
1184
1436
  window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1185
- // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
1186
- // canonical completion signal for Playwright runs. waitForFunction is reserved
1187
- // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
1188
- // Setting testTimeout after done caused a race: under CI load, waitForFunction could
1189
- // win before Node.js processed the WS done message, dropping all testEnd events.
1190
- } else {
1191
- window.testTimeout = ${config.timeout};
1192
1437
  }
1193
1438
  });
1194
1439
 
1440
+ window.QUnit.config.testTimeout = ${config.timeout};
1195
1441
  window.QUnit.start();
1196
1442
  }
1197
1443
  </script>`;
@@ -1292,8 +1538,8 @@ function buildNoTestsHTML(files) {
1292
1538
  var retries = 0;
1293
1539
  function connect() {
1294
1540
  var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1295
- ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1296
- ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1541
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh' && !navigator.webdriver) location.reload(true); });
1542
+ ws.addEventListener('close', function () { if (retries++ < ${WATCH_WS_RECONNECT_MAX_RETRIES}) setTimeout(connect, ${WATCH_WS_RECONNECT_INTERVAL_MS}); });
1297
1543
  ws.addEventListener('error', function () { ws.close(); });
1298
1544
  }
1299
1545
  connect();
@@ -1394,8 +1640,8 @@ function buildErrorHTML(buildError) {
1394
1640
  var retries = 0;
1395
1641
  function connect() {
1396
1642
  var ws = new WebSocket(\`ws://\${location.hostname}:\${location.port}\`);
1397
- ws.addEventListener('message', function (e) { if (e.data === 'refresh') location.reload(true); });
1398
- ws.addEventListener('close', function () { if (retries++ < 120) setTimeout(connect, 1000); });
1643
+ ws.addEventListener('message', function (e) { if (e.data === 'refresh' && !navigator.webdriver) location.reload(true); });
1644
+ ws.addEventListener('close', function () { if (retries++ < ${WATCH_WS_RECONNECT_MAX_RETRIES}) setTimeout(connect, ${WATCH_WS_RECONNECT_INTERVAL_MS}); });
1399
1645
  ws.addEventListener('error', function () { ws.close(); });
1400
1646
  }
1401
1647
  connect();
@@ -1424,26 +1670,26 @@ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
1424
1670
  server.get(`/group-${groupId}/`, (_req, res) => {
1425
1671
  if (groupCachedContent._buildError) {
1426
1672
  res.writeHead(200, HTML_HEADERS);
1427
- res.end(buildErrorHTML(groupCachedContent._buildError));
1428
- return;
1673
+ return res.end(buildErrorHTML(groupCachedContent._buildError));
1429
1674
  }
1430
1675
  if (groupCachedContent._noTestsWarning) {
1431
1676
  res.writeHead(200, HTML_HEADERS);
1432
- res.end(buildNoTestsHTML(groupCachedContent._noTestsWarning));
1433
- return;
1677
+ return res.end(buildNoTestsHTML(groupCachedContent._noTestsWarning));
1434
1678
  }
1435
1679
  res.writeHead(200, HTML_HEADERS);
1436
1680
  res.end(mainGroupHTML);
1437
- saveHTML(`${groupConfig.projectRoot}/${groupConfig.output}/index.html`, mainGroupHTML);
1681
+ saveHTML(
1682
+ path5.join(path5.resolve(groupConfig.projectRoot, groupConfig.output), "index.html"),
1683
+ mainGroupHTML
1684
+ );
1438
1685
  });
1439
1686
  server.get(`/group-${groupId}/tests.js`, (_req, res) => {
1440
1687
  const bytes = groupCachedContent.allTestCode?.length ?? null;
1441
1688
  if (bytes === null) {
1442
1689
  res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
1443
- res.end(
1690
+ return res.end(
1444
1691
  'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
1445
1692
  );
1446
- return;
1447
1693
  }
1448
1694
  groupConfig._onTestsJsServed?.();
1449
1695
  res.writeHead(200, {
@@ -1495,7 +1741,7 @@ function setupGroupWSHandler(server, groupConfigs) {
1495
1741
  );
1496
1742
  }
1497
1743
  config._resetTestTimeout?.();
1498
- TAPDisplayTestResult(config.COUNTER, details);
1744
+ TAPDisplayTestResult(config.COUNTER, details, config._sourceMapDecoder, config.projectRoot);
1499
1745
  } else if (event === "done") {
1500
1746
  config._phase = "done";
1501
1747
  config._lastQUnitResult = qunitResult ?? null;
@@ -1529,10 +1775,10 @@ function registerSharedStaticHandler(server, groupConfigs) {
1529
1775
  res.end("Not found");
1530
1776
  return;
1531
1777
  }
1532
- const STATIC_FILES_PATH = path4.join(groupConfig.projectRoot, groupConfig.output);
1778
+ const STATIC_FILES_PATH = path5.resolve(groupConfig.projectRoot, groupConfig.output);
1533
1779
  const subPath = match[2] || "/";
1534
1780
  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;
1781
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path5.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1536
1782
  const stream = fs8.createReadStream(filePath);
1537
1783
  stream.on("open", () => {
1538
1784
  res.writeHead(200, { "Content-Type": contentType });
@@ -1544,7 +1790,7 @@ function registerSharedStaticHandler(server, groupConfigs) {
1544
1790
  });
1545
1791
  });
1546
1792
  }
1547
- var fsPromise, HTML_HEADERS, NOT_FOUND_HTML;
1793
+ var fsPromise, HTML_HEADERS, WATCH_WS_RECONNECT_INTERVAL_MS, WATCH_WS_RECONNECT_MAX_RETRIES, WS_RETRY_INTERVAL_MS, NOT_FOUND_HTML;
1548
1794
  var init_web_server = __esm({
1549
1795
  "lib/setup/web-server.ts"() {
1550
1796
  init_find_internal_assets_from_html();
@@ -1554,6 +1800,9 @@ var init_web_server = __esm({
1554
1800
  init_http();
1555
1801
  fsPromise = fs8.promises;
1556
1802
  HTML_HEADERS = { "Content-Type": "text/html", "Cache-Control": "no-store" };
1803
+ WATCH_WS_RECONNECT_INTERVAL_MS = 1e3;
1804
+ WATCH_WS_RECONNECT_MAX_RETRIES = 120;
1805
+ WS_RETRY_INTERVAL_MS = 10;
1557
1806
  NOT_FOUND_HTML = `<!DOCTYPE html>
1558
1807
  <html lang="en">
1559
1808
  <head>
@@ -1599,13 +1848,23 @@ async function launchBrowser(config) {
1599
1848
  );
1600
1849
  if (prelaunch) {
1601
1850
  const connectStart = Date.now();
1602
- const browser = await playwrightCore2.chromium.connectOverCDP({
1603
- endpointURL: prelaunch.cdpEndpoint
1604
- });
1605
- perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
1606
- return browser;
1851
+ try {
1852
+ const browser = await playwrightCore2.chromium.connectOverCDP({
1853
+ endpointURL: prelaunch.cdpEndpoint,
1854
+ // Short timeout: if Chrome isn't CDP-ready within 5s (e.g. resource contention on
1855
+ // slow CI runners with many concurrent pre-launches), fall back to chromium.launch().
1856
+ timeout: 5e3
1857
+ });
1858
+ perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
1859
+ return browser;
1860
+ } catch {
1861
+ perfLog(
1862
+ `browser.js: connectOverCDP failed after ${Date.now() - connectStart}ms \u2014 falling back to chromium.launch()`
1863
+ );
1864
+ await shutdownPrelaunch();
1865
+ }
1607
1866
  }
1608
- const executablePath = await findChrome();
1867
+ const executablePath = process.platform !== "darwin" ? await findChrome() : null;
1609
1868
  const launchOptions = {
1610
1869
  args: CHROMIUM_ARGS,
1611
1870
  headless: true,
@@ -1637,12 +1896,9 @@ async function setupBrowser(config, cachedContent, existingBrowser = null, share
1637
1896
  perfLog(`browser.js: newPage (shared server) took ${Date.now() - setupStart}ms`);
1638
1897
  return [sharedServer, existingBrowser, newPage2];
1639
1898
  }
1640
- const [newServer, resolvedBrowser] = await Promise.all([
1641
- setupWebServer(config, cachedContent),
1642
- Promise.resolve(existingBrowser)
1643
- ]);
1899
+ const newServer = setupWebServer(config, cachedContent);
1644
1900
  perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
1645
- const activeBrowser = resolvedBrowser ?? await launchBrowser(config);
1901
+ const activeBrowser = existingBrowser ?? await launchBrowser(config);
1646
1902
  const pageStart = Date.now();
1647
1903
  const isHeadedWatchMode = config.open === true && config.watch;
1648
1904
  const getPage = isHeadedWatchMode ? () => activeBrowser.contexts()[0]?.pages()[0] ?? activeBrowser.newPage() : () => activeBrowser.newPage();
@@ -1704,9 +1960,11 @@ var init_browser = __esm({
1704
1960
 
1705
1961
  // lib/utils/open-output-in-browser.ts
1706
1962
  import { spawn as spawn2 } from "node:child_process";
1963
+ import path6 from "node:path";
1964
+ import { pathToFileURL } from "node:url";
1707
1965
  async function openOutputInBrowser(config) {
1708
1966
  try {
1709
- const outputFile = config.watch ? `http://localhost:${config.port}` : `file://${config.projectRoot}/${config.output}/index.html`;
1967
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
1710
1968
  if (typeof config.open === "string") {
1711
1969
  spawnDetached(config.open, [outputFile]);
1712
1970
  return;
@@ -1752,9 +2010,10 @@ var init_time_counter = __esm({
1752
2010
  });
1753
2011
 
1754
2012
  // lib/utils/run-user-module.ts
2013
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
1755
2014
  async function runUserModule(modulePath, params, scriptPosition) {
1756
2015
  try {
1757
- const func = await import(modulePath);
2016
+ const func = await import(pathToFileURL2(modulePath).href);
1758
2017
  if (func) {
1759
2018
  func.default ? await func.default(params) : typeof func === "function" ? await func(params) : null;
1760
2019
  }
@@ -1797,8 +2056,14 @@ var init_display_final_result = __esm({
1797
2056
 
1798
2057
  // lib/commands/run/tests-in-browser.ts
1799
2058
  import fs9 from "node:fs/promises";
1800
- import path5 from "node:path";
2059
+ import path7 from "node:path";
1801
2060
  import esbuild from "esbuild";
2061
+ function toEsbuildImportPath(filePath) {
2062
+ const rel = path7.relative(process.cwd(), filePath);
2063
+ const normalized = rel.replace(/\\/g, "/");
2064
+ if (path7.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
2065
+ return normalized.startsWith(".") ? normalized : "./" + normalized;
2066
+ }
1802
2067
  function deriveBuildErrorType(error) {
1803
2068
  const msgs = error?.errors ?? [];
1804
2069
  const text = msgs[0]?.text ?? (error instanceof Error ? error.message : String(error));
@@ -1834,13 +2099,14 @@ async function buildTestBundle(config, cachedContent) {
1834
2099
  console.log("# [buildTestBundle] fsTree is empty \u2014 skipping build (no test files found)");
1835
2100
  return;
1836
2101
  }
1837
- const outfile = `${projectRoot}/${output}/tests.js`;
1838
- await fs9.mkdir(`${projectRoot}/${output}`, { recursive: true });
1839
- const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
2102
+ const outDir = path7.resolve(projectRoot, output);
2103
+ const outfile = path7.join(outDir, "tests.js");
2104
+ await fs9.mkdir(outDir, { recursive: true });
2105
+ const sourcemap = "inline";
1840
2106
  const needsDisk = true;
1841
2107
  const buildOptions = {
1842
2108
  stdin: {
1843
- contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
2109
+ contents: allTestFilePaths.map((filePath) => `import "${toEsbuildImportPath(filePath)}";`).join(""),
1844
2110
  resolveDir: process.cwd()
1845
2111
  },
1846
2112
  // Allow test files outside the project root (e.g. /tmp/my-test.ts) to import
@@ -1867,29 +2133,28 @@ async function buildTestBundle(config, cachedContent) {
1867
2133
  config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
1868
2134
  Promise.all(
1869
2135
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
1870
- const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
2136
+ const targetPath = path7.join(outDir, htmlPath);
1871
2137
  if (htmlPath !== "/") {
1872
2138
  await fs9.rm(targetPath, { force: true, recursive: true });
1873
- await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
2139
+ await fs9.mkdir(path7.dirname(targetPath), { recursive: true });
1874
2140
  }
1875
2141
  })
1876
2142
  )
1877
2143
  ]);
1878
2144
  cachedContent.allTestCode = allTestCode;
2145
+ config._sourceMapDecoder = extractInlineSourceMap(allTestCode, outDir);
1879
2146
  } catch (error) {
1880
2147
  cachedContent._buildError = {
1881
2148
  type: deriveBuildErrorType(error),
1882
2149
  formatted: formatBuildErrors(error)
1883
2150
  };
1884
- await fs9.writeFile(
1885
- `${projectRoot}/${output}/index.html`,
1886
- buildErrorHTML(cachedContent._buildError)
1887
- );
2151
+ await fs9.writeFile(path7.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
1888
2152
  throw error;
1889
2153
  }
1890
2154
  }
1891
2155
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1892
2156
  const { projectRoot, output } = config;
2157
+ const outDir = path7.resolve(projectRoot, output);
1893
2158
  const allTestFilePaths = Object.keys(config.fsTree);
1894
2159
  const runHasFilter = !!targetTestFilesToFilter;
1895
2160
  if (!config._groupMode) {
@@ -1907,18 +2172,23 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1907
2172
  const preBuildPromise = cachedContent._preBuildPromise;
1908
2173
  cachedContent._preBuildPromise = null;
1909
2174
  if (!cachedContent.allTestCode) {
1910
- await (preBuildPromise ?? buildTestBundle(config, cachedContent));
2175
+ if (preBuildPromise) {
2176
+ cachedContent._activeRebuild = preBuildPromise;
2177
+ } else {
2178
+ await buildTestBundle(config, cachedContent);
2179
+ }
1911
2180
  }
1912
- if (!cachedContent.allTestCode) {
2181
+ if (!cachedContent.allTestCode && !cachedContent._activeRebuild) {
1913
2182
  return connections;
1914
2183
  }
1915
2184
  if (runHasFilter) {
1916
- const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
2185
+ const outputPath = path7.join(outDir, "filtered-tests.js");
1917
2186
  cachedContent.filteredTestCode = await buildFilteredTests(
1918
2187
  targetTestFilesToFilter,
1919
2188
  outputPath,
1920
2189
  config
1921
2190
  );
2191
+ config._sourceMapDecoder = extractInlineSourceMap(cachedContent.filteredTestCode, outDir);
1922
2192
  }
1923
2193
  const TIME_COUNTER = timeCounter();
1924
2194
  if (runHasFilter) {
@@ -1930,6 +2200,17 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1930
2200
  )
1931
2201
  );
1932
2202
  }
2203
+ if (cachedContent._activeRebuild) {
2204
+ await cachedContent._activeRebuild.catch(() => {
2205
+ });
2206
+ cachedContent._activeRebuild = null;
2207
+ if (!cachedContent.allTestCode) {
2208
+ config.watch && cachedContent._buildError && console.log(
2209
+ `# esbuild Bundle Error: ${cachedContent._buildError.formatted}`.split("\n").join("\n# ")
2210
+ );
2211
+ return connections;
2212
+ }
2213
+ }
1933
2214
  const TIME_TAKEN = TIME_COUNTER.stop();
1934
2215
  if (!config._groupMode) {
1935
2216
  if (config.COUNTER.testCount === 0 && !cachedContent._buildError) {
@@ -1941,7 +2222,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1941
2222
  console.log(
1942
2223
  `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
1943
2224
  );
1944
- fs9.writeFile(`${projectRoot}/${output}/index.html`, buildNoTestsHTML(displayFiles)).catch(
2225
+ fs9.writeFile(path7.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
1945
2226
  () => {
1946
2227
  }
1947
2228
  );
@@ -1961,6 +2242,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1961
2242
  }
1962
2243
  }
1963
2244
  } catch (error) {
2245
+ cachedContent._activeRebuild = null;
1964
2246
  config.lastFailedTestFiles = config.lastRanTestFiles;
1965
2247
  const exception = new BundleError(error);
1966
2248
  if (!cachedContent._buildError && error.errors?.length) {
@@ -1969,7 +2251,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1969
2251
  formatted: formatBuildErrors(error)
1970
2252
  };
1971
2253
  fs9.writeFile(
1972
- `${projectRoot}/${output}/qunitx.html`,
2254
+ path7.join(outDir, "qunitx.html"),
1973
2255
  buildErrorHTML(cachedContent._buildError)
1974
2256
  ).catch(
1975
2257
  (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
@@ -1985,12 +2267,12 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1985
2267
  return connections;
1986
2268
  }
1987
2269
  function buildFilteredTests(filteredTests, outputPath, config) {
1988
- const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1989
- const needsDisk = sourcemap === "linked" || Boolean(config.open);
2270
+ const sourcemap = "inline";
2271
+ const needsDisk = Boolean(config.open);
1990
2272
  return buildWithOverlayfsRetry(
1991
2273
  {
1992
2274
  stdin: {
1993
- contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
2275
+ contents: filteredTests.map((filePath) => `import "${filePath.replace(/\\/g, "/")}";`).join(""),
1994
2276
  resolveDir: process.cwd()
1995
2277
  },
1996
2278
  nodePaths: ANCESTOR_NODE_MODULES,
@@ -2060,9 +2342,9 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
2060
2342
  let wsConnected = false;
2061
2343
  try {
2062
2344
  console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
2063
- const navMs = config.timeout + 1e4;
2064
- const startupMs = Math.max(config.timeout * 3, navMs);
2065
- const testsJsMs = Math.max(config.timeout * 4, navMs);
2345
+ const navMs = config.timeout + NAV_GRACE_MS;
2346
+ const startupMs = Math.max(config.timeout * STARTUP_TIMEOUT_FACTOR, navMs);
2347
+ const testsJsMs = Math.max(config.timeout * TESTS_JS_TIMEOUT_FACTOR, navMs);
2066
2348
  let resolveTestRace;
2067
2349
  const testRaceResult = new Promise((resolve) => {
2068
2350
  resolveTestRace = resolve;
@@ -2080,7 +2362,7 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
2080
2362
  config._resetTestTimeout = () => {
2081
2363
  wsConnected = true;
2082
2364
  clearTimeout(timeoutHandle);
2083
- timeoutHandle = setTimeout(resolveTestRace, config.timeout);
2365
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout + TEST_STALL_BUFFER_MS);
2084
2366
  };
2085
2367
  const targetUrl = `http://localhost:${config.port}${filePath}`;
2086
2368
  const navOptions = { timeout: navMs, waitUntil: "commit" };
@@ -2150,7 +2432,7 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
2150
2432
  process.exit(1);
2151
2433
  }
2152
2434
  }
2153
- async function flushConsoleHandlers(handlers, deadline = Date.now() + 2e3) {
2435
+ async function flushConsoleHandlers(handlers, deadline = Date.now() + CONSOLE_FLUSH_TIMEOUT_MS) {
2154
2436
  if (!handlers || handlers.size === 0 || Date.now() >= deadline) return;
2155
2437
  await Promise.allSettled([...handlers]);
2156
2438
  return flushConsoleHandlers(handlers, deadline);
@@ -2160,7 +2442,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2160
2442
  cachedContent._buildError = null;
2161
2443
  cachedContent._noTestsWarning = null;
2162
2444
  });
2163
- const { projectRoot, debug, watch, browser } = groupConfigs[0];
2445
+ const { projectRoot, debug, browser } = groupConfigs[0];
2164
2446
  const activeGroups = groupConfigs.reduce(
2165
2447
  (acc, groupConfig, groupIndex) => {
2166
2448
  const files = Object.keys(groupConfig.fsTree);
@@ -2181,10 +2463,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2181
2463
  );
2182
2464
  await Promise.all(
2183
2465
  activeGroups.map(
2184
- (group) => fs9.mkdir(`${group.config.projectRoot}/${group.config.output}`, { recursive: true })
2466
+ (group) => fs9.mkdir(path7.resolve(group.config.projectRoot, group.config.output), { recursive: true })
2185
2467
  )
2186
2468
  );
2187
- const sourcemap = debug ? "inline" : watch ? "linked" : false;
2469
+ const sourcemap = "inline";
2188
2470
  const groupEntryPlugin = {
2189
2471
  name: "group-entry-loader",
2190
2472
  setup(build) {
@@ -2195,12 +2477,13 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2195
2477
  build.onLoad({ filter: /.*/, namespace: "group-entry" }, (args) => {
2196
2478
  const slotIndex = parseInt(args.path.replace("group-entry-", ""));
2197
2479
  return {
2198
- contents: activeGroups[slotIndex].files.map((filePath) => `import "${filePath}";`).join(""),
2480
+ contents: activeGroups[slotIndex].files.map((filePath) => `import "${toEsbuildImportPath(filePath)}";`).join(""),
2199
2481
  resolveDir: process.cwd()
2200
2482
  };
2201
2483
  });
2202
2484
  }
2203
2485
  };
2486
+ const esbuildOutdir = path7.join(projectRoot, "tmp");
2204
2487
  const buildOptions = {
2205
2488
  entryPoints: activeGroups.map((_, slotIndex) => ({
2206
2489
  in: `group-entry-${slotIndex}`,
@@ -2212,7 +2495,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2212
2495
  logLevel: "silent",
2213
2496
  // outdir only labels the paths in outputFiles[].path — nothing is written to disk
2214
2497
  // (write:false). Use projectRoot/tmp as a stable sentinel; mkdir is not required.
2215
- outdir: path5.join(projectRoot, "tmp"),
2498
+ outdir: esbuildOutdir,
2216
2499
  keepNames: true,
2217
2500
  legalComments: "none",
2218
2501
  target: esbuildTarget(browser),
@@ -2238,8 +2521,17 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2238
2521
  const slotIndex = parseInt(match[1]);
2239
2522
  const isMap = Boolean(match[2]);
2240
2523
  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);
2524
+ const destPath = path7.join(
2525
+ path7.resolve(config.projectRoot, config.output),
2526
+ "tests.js" + (isMap ? ".map" : "")
2527
+ );
2528
+ if (!isMap) {
2529
+ cachedContent.allTestCode = Buffer.from(outputFile.contents);
2530
+ config._sourceMapDecoder = extractInlineSourceMap(
2531
+ cachedContent.allTestCode,
2532
+ esbuildOutdir
2533
+ );
2534
+ }
2243
2535
  return fs9.writeFile(destPath, outputFile.contents);
2244
2536
  })
2245
2537
  );
@@ -2249,7 +2541,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2249
2541
  await Promise.all(
2250
2542
  activeGroups.map((group) => {
2251
2543
  group.cachedContent._buildError = buildError;
2252
- return fs9.writeFile(`${group.config.projectRoot}/${group.config.output}/index.html`, errorHtml).catch(
2544
+ return fs9.writeFile(
2545
+ path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
2546
+ errorHtml
2547
+ ).catch(
2253
2548
  (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2254
2549
  `)
2255
2550
  );
@@ -2258,7 +2553,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2258
2553
  throw error;
2259
2554
  }
2260
2555
  }
2261
- var BundleError, ancestorNodeModules, ANCESTOR_NODE_MODULES, RETRY_DELAY_MS, MAX_RETRIES, EMPTY_BUNDLE_THRESHOLD, GROUP_OUTPUT_REGEX;
2556
+ 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;
2262
2557
  var init_tests_in_browser = __esm({
2263
2558
  "lib/commands/run/tests-in-browser.ts"() {
2264
2559
  init_color();
@@ -2267,6 +2562,7 @@ var init_tests_in_browser = __esm({
2267
2562
  init_run_user_module();
2268
2563
  init_display_final_result();
2269
2564
  init_web_server();
2565
+ init_source_map_decoder();
2270
2566
  BundleError = class extends Error {
2271
2567
  constructor(message) {
2272
2568
  super(message);
@@ -2274,13 +2570,18 @@ var init_tests_in_browser = __esm({
2274
2570
  this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
2275
2571
  }
2276
2572
  };
2277
- ancestorNodeModules = (dir) => dir.split(path5.sep).map(
2278
- (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2573
+ ancestorNodeModules = (dir) => dir.split(path7.sep).map(
2574
+ (_, i, parts) => path7.join(parts.slice(0, parts.length - i).join(path7.sep) || path7.sep, "node_modules")
2279
2575
  );
2280
2576
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
2281
2577
  RETRY_DELAY_MS = 100;
2282
2578
  MAX_RETRIES = 3;
2283
2579
  EMPTY_BUNDLE_THRESHOLD = 500;
2580
+ NAV_GRACE_MS = 1e4;
2581
+ STARTUP_TIMEOUT_FACTOR = 3;
2582
+ TESTS_JS_TIMEOUT_FACTOR = 4;
2583
+ CONSOLE_FLUSH_TIMEOUT_MS = 2e3;
2584
+ TEST_STALL_BUFFER_MS = 5e3;
2284
2585
  GROUP_OUTPUT_REGEX = /group-(\d+)\.js(\.map)?$/;
2285
2586
  }
2286
2587
  });
@@ -2288,7 +2589,7 @@ var init_tests_in_browser = __esm({
2288
2589
  // lib/setup/file-watcher.ts
2289
2590
  import fs10 from "node:fs";
2290
2591
  import { stat, lstat } from "node:fs/promises";
2291
- import path6 from "node:path";
2592
+ import path8 from "node:path";
2292
2593
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
2293
2594
  const extensions = config.extensions || ["js", "ts"];
2294
2595
  const readyPromises = [];
@@ -2297,16 +2598,20 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2297
2598
  const symlinkPollers = /* @__PURE__ */ new Map();
2298
2599
  function trackSymlink(filePath) {
2299
2600
  if (symlinkPollers.has(filePath)) return;
2300
- const handler = (curr) => {
2601
+ const handler = (curr, prev) => {
2301
2602
  if (curr.nlink === 0) {
2302
2603
  fs10.unwatchFile(filePath, handler);
2303
2604
  symlinkPollers.delete(filePath);
2304
2605
  if (filePath in config.fsTree) {
2305
2606
  handleWatchEvent(config, extensions, "unlink", filePath, onEventFunc, onFinishFunc);
2306
2607
  }
2608
+ } else if ((process.platform === "win32" || process.platform === "darwin") && curr.mtimeMs !== prev.mtimeMs) {
2609
+ if (filePath in config.fsTree) {
2610
+ handleWatchEvent(config, extensions, "change", filePath, onEventFunc, onFinishFunc);
2611
+ }
2307
2612
  }
2308
2613
  };
2309
- fs10.watchFile(filePath, { interval: 500, persistent: false }, handler);
2614
+ fs10.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
2310
2615
  symlinkPollers.set(filePath, () => fs10.unwatchFile(filePath, handler));
2311
2616
  }
2312
2617
  function untrackSymlink(filePath) {
@@ -2315,18 +2620,23 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2315
2620
  }
2316
2621
  for (const watchPath of testFileLookupPaths) {
2317
2622
  let ready = false;
2318
- const lastChangeMs = {};
2623
+ const lastEventMs = {};
2624
+ const seenMtimeMs = {};
2319
2625
  const childWatcher = fs10.watch(watchPath, { recursive: true }, async (eventType, filename) => {
2320
2626
  if (!ready || !filename) return;
2321
- const fullPath = filename === path6.basename(watchPath) ? watchPath : path6.join(watchPath, filename);
2627
+ const fullPath = filename === path8.basename(watchPath) ? watchPath : path8.join(watchPath, filename);
2322
2628
  if (eventType === "change") {
2323
- if (!config._building) {
2324
- const now = Date.now();
2325
- const last = lastChangeMs[fullPath] ?? 0;
2326
- if (now - last < CHANGE_DEDUPE_MS) {
2327
- if (!config._lastBuildEndMs || config._lastBuildEndMs <= last) return;
2328
- }
2329
- lastChangeMs[fullPath] = now;
2629
+ const now = Date.now();
2630
+ const last = lastEventMs[fullPath] ?? 0;
2631
+ lastEventMs[fullPath] = now;
2632
+ try {
2633
+ const { mtimeMs } = await stat(fullPath);
2634
+ const prevMtime = seenMtimeMs[fullPath] ?? 0;
2635
+ seenMtimeMs[fullPath] = mtimeMs;
2636
+ if (now - last < CHANGE_DEDUPE_MS && mtimeMs > 0 && mtimeMs === prevMtime) return;
2637
+ if (config._lastBuildEndMs && mtimeMs < Math.floor(config._lastBuildEndMs / 1e3) * 1e3)
2638
+ return;
2639
+ } catch {
2330
2640
  }
2331
2641
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
2332
2642
  }
@@ -2343,8 +2653,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2343
2653
  }
2344
2654
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
2345
2655
  });
2346
- const parentDir = path6.dirname(watchPath);
2347
- const watchedBasename = path6.basename(watchPath);
2656
+ const parentDir = path8.dirname(watchPath);
2657
+ const watchedBasename = path8.basename(watchPath);
2348
2658
  let parentUnlinkFired = false;
2349
2659
  const parentWatcher = fs10.watch(parentDir, async (eventType, filename) => {
2350
2660
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
@@ -2396,18 +2706,20 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2396
2706
  };
2397
2707
  }
2398
2708
  async function classifyRenameEvent(fullPath, fsTree) {
2399
- for (const delay of [0, 50]) {
2709
+ for (const delay of [0, OVERLAYFS_RENAME_RETRY_MS]) {
2400
2710
  if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2401
2711
  try {
2402
2712
  const statResult = await stat(fullPath);
2403
- return statResult.isDirectory() ? "addDir" : "add";
2713
+ if (statResult.isDirectory()) return "addDir";
2714
+ return fsTree && fullPath in fsTree ? "change" : "add";
2404
2715
  } catch {
2405
2716
  }
2406
2717
  }
2407
2718
  if (!fsTree) return null;
2408
2719
  if (fullPath in fsTree) return "unlink";
2409
- const dirPrefix = fullPath + "/";
2410
- return Object.keys(fsTree).some((trackedPath) => trackedPath.startsWith(dirPrefix)) ? "unlinkDir" : null;
2720
+ return Object.keys(fsTree).some(
2721
+ (trackedPath) => trackedPath.startsWith(fullPath + "/") || trackedPath.startsWith(fullPath + "\\")
2722
+ ) ? "unlinkDir" : null;
2411
2723
  }
2412
2724
  function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
2413
2725
  if (event !== "unlinkDir" && !extensions.some((ext) => filePath.endsWith(`.${ext}`)))
@@ -2447,15 +2759,15 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
2447
2759
  }
2448
2760
  });
2449
2761
  }
2450
- function mutateFSTree(fsTree, event, path7) {
2762
+ function mutateFSTree(fsTree, event, filePath) {
2451
2763
  if (event === "add") {
2452
- fsTree[path7] = null;
2764
+ fsTree[filePath] = null;
2453
2765
  } else if (event === "unlink") {
2454
- delete fsTree[path7];
2766
+ delete fsTree[filePath];
2455
2767
  } else if (event === "unlinkDir") {
2456
- const dirPrefix = path7.endsWith("/") ? path7 : path7 + "/";
2457
2768
  for (const treePath of Object.keys(fsTree)) {
2458
- if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
2769
+ if (treePath.startsWith(filePath + "/") || treePath.startsWith(filePath + "\\"))
2770
+ delete fsTree[treePath];
2459
2771
  }
2460
2772
  }
2461
2773
  }
@@ -2464,11 +2776,13 @@ function colorEvent(event) {
2464
2776
  if (event === "add" || event === "addDir") return green("ADDED:");
2465
2777
  return red("REMOVED:");
2466
2778
  }
2467
- var CHANGE_DEDUPE_MS;
2779
+ var CHANGE_DEDUPE_MS, SYMLINK_POLL_INTERVAL_MS, OVERLAYFS_RENAME_RETRY_MS;
2468
2780
  var init_file_watcher = __esm({
2469
2781
  "lib/setup/file-watcher.ts"() {
2470
2782
  init_color();
2471
2783
  CHANGE_DEDUPE_MS = 10;
2784
+ SYMLINK_POLL_INTERVAL_MS = 500;
2785
+ OVERLAYFS_RENAME_RETRY_MS = 50;
2472
2786
  }
2473
2787
  });
2474
2788
 
@@ -2549,24 +2863,27 @@ var init_keyboard_events = __esm({
2549
2863
 
2550
2864
  // lib/setup/write-output-static-files.ts
2551
2865
  import fs11 from "node:fs/promises";
2866
+ import path9 from "node:path";
2552
2867
  async function writeOutputStaticFiles({ projectRoot, output }, cachedContent) {
2553
2868
  const staticHTMLPromises = Object.keys(cachedContent.staticHTMLs).map(async (staticHTMLKey) => {
2554
- const htmlRelativePath = staticHTMLKey.replace(`${projectRoot}/`, "");
2555
- await ensureFolderExists(`${projectRoot}/${output}/${htmlRelativePath}`);
2869
+ const htmlRelativePath = path9.relative(projectRoot, staticHTMLKey);
2870
+ const outDir = path9.resolve(projectRoot, output);
2871
+ await ensureFolderExists(path9.join(outDir, htmlRelativePath));
2556
2872
  await fs11.writeFile(
2557
- `${projectRoot}/${output}/${htmlRelativePath}`,
2873
+ path9.join(outDir, htmlRelativePath),
2558
2874
  cachedContent.staticHTMLs[staticHTMLKey]
2559
2875
  );
2560
2876
  });
2561
2877
  const assetPromises = Array.from(cachedContent.assets).map(async (assetAbsolutePath) => {
2562
- const assetRelativePath = assetAbsolutePath.replace(`${projectRoot}/`, "");
2563
- await ensureFolderExists(`${projectRoot}/${output}/${assetRelativePath}`);
2564
- await fs11.copyFile(assetAbsolutePath, `${projectRoot}/${output}/${assetRelativePath}`);
2878
+ const assetRelativePath = path9.relative(projectRoot, assetAbsolutePath);
2879
+ const outDir = path9.resolve(projectRoot, output);
2880
+ await ensureFolderExists(path9.join(outDir, assetRelativePath));
2881
+ await fs11.copyFile(assetAbsolutePath, path9.join(outDir, assetRelativePath));
2565
2882
  });
2566
2883
  await Promise.all(staticHTMLPromises.concat(assetPromises));
2567
2884
  }
2568
2885
  async function ensureFolderExists(assetPath) {
2569
- await fs11.mkdir(assetPath.split("/").slice(0, -1).join("/"), { recursive: true });
2886
+ await fs11.mkdir(path9.dirname(assetPath), { recursive: true });
2570
2887
  }
2571
2888
  var init_write_output_static_files = __esm({
2572
2889
  "lib/setup/write-output-static-files.ts"() {
@@ -2585,7 +2902,11 @@ import fs12 from "node:fs/promises";
2585
2902
  import { normalize } from "node:path";
2586
2903
  import { availableParallelism } from "node:os";
2587
2904
  async function run(config) {
2588
- const cachedContent = await buildCachedContent(config, config.htmlPaths);
2905
+ const browserPromise = config.watch ? null : launchBrowser(config);
2906
+ const [cachedContent, timings] = await Promise.all([
2907
+ buildCachedContent(config, config.htmlPaths),
2908
+ config.watch ? Promise.resolve(null) : readTimingCache(config.projectRoot)
2909
+ ]);
2589
2910
  if (config.watch) {
2590
2911
  const preBuildPromise = buildTestBundle(config, cachedContent);
2591
2912
  preBuildPromise.catch(() => {
@@ -2595,7 +2916,7 @@ async function run(config) {
2595
2916
  setupBrowser(config, cachedContent),
2596
2917
  writeOutputStaticFiles(config, cachedContent)
2597
2918
  ]);
2598
- config.expressApp = connections.server;
2919
+ config.webServer = connections.server;
2599
2920
  setupKeyboardEvents(config, cachedContent, connections);
2600
2921
  const isHeadedWatchMode = config.open === true && config.watch;
2601
2922
  if (config.open && !isHeadedWatchMode) {
@@ -2614,7 +2935,10 @@ async function run(config) {
2614
2935
  throw error;
2615
2936
  }
2616
2937
  if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2617
- await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2938
+ await connections.page.goto(`http://localhost:${config.port}/`, {
2939
+ waitUntil: "commit",
2940
+ timeout: WATCH_NAV_TIMEOUT_MS
2941
+ }).catch(() => {
2618
2942
  });
2619
2943
  }
2620
2944
  if (config.watch) {
@@ -2631,6 +2955,10 @@ async function run(config) {
2631
2955
  `# Rerun triggered: ${event} \u2192 ${file.replace(`${config.projectRoot}/`, "")}`
2632
2956
  );
2633
2957
  }
2958
+ const rebuildPromise = buildTestBundle(config, cachedContent);
2959
+ rebuildPromise.catch(() => {
2960
+ });
2961
+ cachedContent._preBuildPromise = rebuildPromise;
2634
2962
  return await runTestsInBrowser(config, cachedContent, connections);
2635
2963
  }
2636
2964
  if (config.debug) {
@@ -2643,7 +2971,10 @@ async function run(config) {
2643
2971
  async (_path, _event) => {
2644
2972
  connections.server.publish("refresh");
2645
2973
  if (isHeadedWatchMode && (cachedContent._buildError || cachedContent._noTestsWarning)) {
2646
- await connections.page.goto(`http://localhost:${config.port}/`, { waitUntil: "commit", timeout: 5e3 }).catch(() => {
2974
+ await connections.page.goto(`http://localhost:${config.port}/`, {
2975
+ waitUntil: "commit",
2976
+ timeout: WATCH_NAV_TIMEOUT_MS
2977
+ }).catch(() => {
2647
2978
  });
2648
2979
  }
2649
2980
  }
@@ -2654,8 +2985,7 @@ async function run(config) {
2654
2985
  } else {
2655
2986
  const allFiles = Object.keys(config.fsTree);
2656
2987
  const groupCount = Math.min(allFiles.length, availableParallelism());
2657
- const timings = await readTimingCache(config.projectRoot);
2658
- const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings);
2988
+ const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings ?? {});
2659
2989
  config.COUNTER = {
2660
2990
  testCount: 0,
2661
2991
  failCount: 0,
@@ -2687,7 +3017,7 @@ async function run(config) {
2687
3017
  `
2688
3018
  );
2689
3019
  const [browser] = await Promise.all([
2690
- launchBrowser(config),
3020
+ browserPromise,
2691
3021
  sharedServer ? bindServerToPort(sharedServer, config).then(
2692
3022
  () => groupConfigs.forEach((gc, i) => {
2693
3023
  gc.port = config.port;
@@ -2708,7 +3038,7 @@ async function run(config) {
2708
3038
  const wallTimes = /* @__PURE__ */ new Map();
2709
3039
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
2710
3040
  const keepAlive = setInterval(() => {
2711
- }, 1e4);
3041
+ }, KEEP_ALIVE_INTERVAL_MS);
2712
3042
  const groupResults = await Promise.allSettled(
2713
3043
  groupConfigs.map((groupConfig, i) => {
2714
3044
  const groupTimeout = new Promise((_, reject) => {
@@ -2734,7 +3064,7 @@ async function run(config) {
2734
3064
  browser,
2735
3065
  sharedServer
2736
3066
  );
2737
- groupConfig.expressApp = connections.server;
3067
+ groupConfig.webServer = connections.server;
2738
3068
  if (config.before) {
2739
3069
  await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, "before");
2740
3070
  }
@@ -2749,7 +3079,7 @@ async function run(config) {
2749
3079
  Promise.race([
2750
3080
  connections.page.close(),
2751
3081
  new Promise((resolve) => {
2752
- const pageCloseTimeoutId = setTimeout(resolve, 1e4);
3082
+ const pageCloseTimeoutId = setTimeout(resolve, PAGE_CLOSE_GRACE_MS);
2753
3083
  pageCloseTimeoutId.unref();
2754
3084
  })
2755
3085
  ]).catch(() => {
@@ -2783,15 +3113,14 @@ async function run(config) {
2783
3113
  (err) => config.debug && process.stderr.write(`# [qunitx] persistTimings: ${err.message}
2784
3114
  `)
2785
3115
  );
2786
- printFileTimings(fileTimes, config.projectRoot);
3116
+ if (config.debug) printFileTimings(fileTimes, config.projectRoot);
2787
3117
  if (config.after) {
2788
3118
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
2789
3119
  }
2790
- const exitTimer = setTimeout(() => process.exit(exitCode), 5e3);
3120
+ const exitTimer = setTimeout(() => process.exit(exitCode), STDOUT_FLUSH_GRACE_MS);
2791
3121
  exitTimer.unref();
2792
3122
  process.stdout.write("\n", async () => {
2793
3123
  clearTimeout(exitTimer);
2794
- clearInterval(keepAlive);
2795
3124
  await Promise.all([
2796
3125
  sharedServer?.close().catch(
2797
3126
  (err) => config.debug && process.stderr.write(`# [qunitx] server.close: ${err.message}
@@ -2803,6 +3132,7 @@ async function run(config) {
2803
3132
  )
2804
3133
  ]);
2805
3134
  await shutdownPrelaunch();
3135
+ clearInterval(keepAlive);
2806
3136
  process.exit(exitCode);
2807
3137
  });
2808
3138
  }
@@ -2931,6 +3261,7 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
2931
3261
  const currentDirectory = htmlPath ? htmlPath.split("/").slice(0, -1).join("/") : projectRoot;
2932
3262
  return assetPath.startsWith("./") ? normalize(`${currentDirectory}/${assetPath.slice(2)}`) : normalize(`${currentDirectory}/${assetPath}`);
2933
3263
  }
3264
+ var WATCH_NAV_TIMEOUT_MS, PAGE_CLOSE_GRACE_MS, STDOUT_FLUSH_GRACE_MS, KEEP_ALIVE_INTERVAL_MS;
2934
3265
  var init_run = __esm({
2935
3266
  "lib/commands/run.ts"() {
2936
3267
  init_browser();
@@ -2950,6 +3281,10 @@ var init_run = __esm({
2950
3281
  init_display_final_result();
2951
3282
  init_read_template();
2952
3283
  init_html();
3284
+ WATCH_NAV_TIMEOUT_MS = 5e3;
3285
+ PAGE_CLOSE_GRACE_MS = 1e4;
3286
+ STDOUT_FLUSH_GRACE_MS = 5e3;
3287
+ KEEP_ALIVE_INTERVAL_MS = 1e4;
2953
3288
  }
2954
3289
  });
2955
3290
 
@@ -2964,7 +3299,7 @@ init_color();
2964
3299
  var package_default = {
2965
3300
  name: "qunitx-cli",
2966
3301
  type: "module",
2967
- version: "0.19.3",
3302
+ version: "0.21.0",
2968
3303
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
2969
3304
  author: "Izel Nakri",
2970
3305
  license: "MIT",
@@ -3019,12 +3354,10 @@ var package_default = {
3019
3354
  ws: "^8.20.0"
3020
3355
  },
3021
3356
  devDependencies: {
3022
- cors: "^2.8.6",
3023
- express: "^5.2.1",
3024
3357
  "js-yaml": "^4.1.1",
3025
- prettier: "^3.8.2",
3026
- qunitx: "^1.2.7",
3027
- typescript: "^6.0.2"
3358
+ prettier: "^3.8.3",
3359
+ qunitx: "^1.2.8",
3360
+ typescript: "^6.0.3"
3028
3361
  },
3029
3362
  volta: {
3030
3363
  node: "24.14.0"
@@ -3082,9 +3415,9 @@ import process2 from "node:process";
3082
3415
 
3083
3416
  // lib/utils/path-exists.ts
3084
3417
  import fs2 from "node:fs/promises";
3085
- async function pathExists(path7) {
3418
+ async function pathExists(path10) {
3086
3419
  try {
3087
- await fs2.access(path7);
3420
+ await fs2.access(path10);
3088
3421
  return true;
3089
3422
  } catch {
3090
3423
  return false;
@@ -3156,7 +3489,7 @@ async function writeTestsHTML(projectRoot, config, oldPackageJSON) {
3156
3489
  const targetDirectory = path2.dirname(targetPath);
3157
3490
  const _targetOutputPath = path2.relative(
3158
3491
  targetDirectory,
3159
- `${projectRoot}/${config.output}/tests.js`
3492
+ path2.join(path2.resolve(projectRoot, config.output), "tests.js")
3160
3493
  );
3161
3494
  const testHTMLTemplate = testHTMLTemplateBuffer.replace(
3162
3495
  "{{applicationName}}",
@@ -3202,17 +3535,17 @@ function pathToModuleName(filePath) {
3202
3535
  async function generateTestFiles() {
3203
3536
  const projectRoot = await findProjectRoot();
3204
3537
  const moduleName = pathToModuleName(process.argv[3]);
3205
- const path7 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
3206
- if (await pathExists(path7)) {
3207
- console.log(`${path7} already exists!`);
3538
+ const path10 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
3539
+ if (await pathExists(path10)) {
3540
+ console.log(`${path10} already exists!`);
3208
3541
  return;
3209
3542
  }
3210
3543
  const testJSContent = await readTemplate("test.js");
3211
- const targetFolderPaths = path7.split("/");
3544
+ const targetFolderPaths = path10.split("/");
3212
3545
  targetFolderPaths.pop();
3213
3546
  await fs5.mkdir(targetFolderPaths.join("/"), { recursive: true });
3214
- await fs5.writeFile(path7, testJSContent.replace("{{moduleName}}", moduleName));
3215
- console.log(green(`${path7} written`));
3547
+ await fs5.writeFile(path10, testJSContent.replace("{{moduleName}}", moduleName));
3548
+ console.log(green(`${path10} written`));
3216
3549
  }
3217
3550
 
3218
3551
  // lib/setup/config.ts
@@ -3313,23 +3646,25 @@ function setupTestFilePaths(_projectRoot, inputs2) {
3313
3646
  });
3314
3647
  return result.map((metaItem) => metaItem.input);
3315
3648
  }
3316
- function pathIsFile(path7) {
3317
- const inputs2 = path7.split("/");
3649
+ function pathIsFile(path10) {
3650
+ const inputs2 = path10.split("/");
3318
3651
  return inputs2[inputs2.length - 1].includes(".");
3319
3652
  }
3320
3653
  function pathIsIncludedInPaths(paths, targetPath) {
3321
- return paths.some((path7) => {
3322
- if (path7 === targetPath) {
3654
+ return paths.some((path10) => {
3655
+ if (path10 === targetPath) {
3323
3656
  return false;
3324
3657
  }
3325
- return matchesGlob(targetPath.input, buildGlobFormat(path7));
3658
+ return matchesGlob(targetPath.input, buildGlobFormat(path10));
3326
3659
  });
3327
3660
  }
3328
- function buildGlobFormat(path7) {
3329
- return path7.isFile ? path7.input : `${path7.input}/**`;
3661
+ function buildGlobFormat(path10) {
3662
+ return path10.isFile ? path10.input : `${path10.input}/**`;
3330
3663
  }
3331
3664
 
3332
3665
  // lib/utils/parse-cli-flags.ts
3666
+ import path4 from "node:path";
3667
+ var FALLBACK_TIMEOUT_MS = 1e4;
3333
3668
  function parseCliFlags(projectRoot) {
3334
3669
  const providedFlags = process.argv.slice(2).reduce(
3335
3670
  (result, arg) => {
@@ -3344,7 +3679,7 @@ function parseCliFlags(projectRoot) {
3344
3679
  } else if (arg.startsWith("--failfast") || arg.startsWith("--failFast")) {
3345
3680
  return Object.assign(result, { failFast: parseBoolean(arg.split("=")[1]) });
3346
3681
  } else if (arg.startsWith("--timeout")) {
3347
- return Object.assign(result, { timeout: Number(arg.split("=")[1]) || 1e4 });
3682
+ return Object.assign(result, { timeout: Number(arg.split("=")[1]) || FALLBACK_TIMEOUT_MS });
3348
3683
  } else if (arg.startsWith("--output")) {
3349
3684
  return Object.assign(result, { output: arg.split("=")[1] });
3350
3685
  } else if (arg.endsWith(".html")) {
@@ -3381,12 +3716,22 @@ function parseCliFlags(projectRoot) {
3381
3716
  return result;
3382
3717
  }
3383
3718
  result.inputs.add(
3384
- arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : `${process.cwd()}/${arg}`
3719
+ arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : path4.join(process.cwd(), arg)
3385
3720
  );
3386
3721
  return result;
3387
3722
  },
3388
3723
  { inputs: /* @__PURE__ */ new Set([]) }
3389
3724
  );
3725
+ if (!providedFlags.browser && process.env.QUNITX_BROWSER) {
3726
+ const envBrowser = process.env.QUNITX_BROWSER;
3727
+ if (!["chromium", "firefox", "webkit"].includes(envBrowser)) {
3728
+ console.error(
3729
+ `Invalid QUNITX_BROWSER value: "${envBrowser}". Must be one of: chromium, firefox, webkit`
3730
+ );
3731
+ process.exit(1);
3732
+ }
3733
+ providedFlags.browser = envBrowser;
3734
+ }
3390
3735
  return { ...providedFlags, inputs: Array.from(providedFlags.inputs) };
3391
3736
  }
3392
3737
  function parseBoolean(result, defaultValue = true) {