qunitx-cli 0.21.2 → 0.22.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 +80 -15
  2. package/dist/cli.js +787 -678
  3. package/package.json +6 -3
package/dist/cli.js CHANGED
@@ -12,6 +12,9 @@ var __export = (target, all) => {
12
12
  // lib/utils/find-chrome.ts
13
13
  import { accessSync, constants } from "node:fs";
14
14
  import { join } from "node:path";
15
+ function findChrome() {
16
+ return Promise.resolve(findChromeSync());
17
+ }
15
18
  function findChromeSync() {
16
19
  if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
17
20
  for (const dir of PATH_DIRS) {
@@ -26,9 +29,6 @@ function findChromeSync() {
26
29
  }
27
30
  return null;
28
31
  }
29
- function findChrome() {
30
- return Promise.resolve(findChromeSync());
31
- }
32
32
  var CANDIDATES, PATH_DIRS;
33
33
  var init_find_chrome = __esm({
34
34
  "lib/utils/find-chrome.ts"() {
@@ -56,35 +56,6 @@ var init_kill_process_group = __esm({
56
56
 
57
57
  // lib/utils/cleanup-browser-dir.ts
58
58
  import fs from "node:fs/promises";
59
- async function processReferencesDir(entry, dirPath, dirName) {
60
- try {
61
- const [cwd, cmdline] = await Promise.all([
62
- fs.readlink(`/proc/${entry}/cwd`).catch(() => ""),
63
- fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "")
64
- ]);
65
- if (cwd.startsWith(dirPath) || cmdline.includes(dirName)) return true;
66
- const fds = await fs.readdir(`/proc/${entry}/fd`).catch(() => []);
67
- const fdTargets = await Promise.all(
68
- fds.map((fd) => fs.readlink(`/proc/${entry}/fd/${fd}`).catch(() => ""))
69
- );
70
- return fdTargets.some((target) => target.startsWith(dirPath));
71
- } catch {
72
- return false;
73
- }
74
- }
75
- async function killAllReferencingProcesses(dirPath, dirName) {
76
- const procEntries = await fs.readdir("/proc").catch(() => []);
77
- await Promise.all(
78
- procEntries.map(async (entry) => {
79
- if (!/^\d+$/.test(entry)) return;
80
- if (!await processReferencesDir(entry, dirPath, dirName)) return;
81
- try {
82
- process.kill(parseInt(entry), "SIGKILL");
83
- } catch {
84
- }
85
- })
86
- );
87
- }
88
59
  async function cleanupBrowserDir(dirPath) {
89
60
  if (process.platform !== "linux") {
90
61
  await fs.rm(dirPath, { recursive: true, force: true }).catch(() => {
@@ -123,6 +94,35 @@ async function cleanupBrowserDir(dirPath) {
123
94
  })
124
95
  );
125
96
  }
97
+ async function processReferencesDir(entry, dirPath, dirName) {
98
+ try {
99
+ const [cwd, cmdline] = await Promise.all([
100
+ fs.readlink(`/proc/${entry}/cwd`).catch(() => ""),
101
+ fs.readFile(`/proc/${entry}/cmdline`, "utf8").catch(() => "")
102
+ ]);
103
+ if (cwd.startsWith(dirPath) || cmdline.includes(dirName)) return true;
104
+ const fds = await fs.readdir(`/proc/${entry}/fd`).catch(() => []);
105
+ const fdTargets = await Promise.all(
106
+ fds.map((fd) => fs.readlink(`/proc/${entry}/fd/${fd}`).catch(() => ""))
107
+ );
108
+ return fdTargets.some((target) => target.startsWith(dirPath));
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+ async function killAllReferencingProcesses(dirPath, dirName) {
114
+ const procEntries = await fs.readdir("/proc").catch(() => []);
115
+ await Promise.all(
116
+ procEntries.map(async (entry) => {
117
+ if (!/^\d+$/.test(entry)) return;
118
+ if (!await processReferencesDir(entry, dirPath, dirName)) return;
119
+ try {
120
+ process.kill(parseInt(entry), "SIGKILL");
121
+ } catch {
122
+ }
123
+ })
124
+ );
125
+ }
126
126
  var CLEANUP_DEADLINE_MS, CLEANUP_RETRY_MS;
127
127
  var init_cleanup_browser_dir = __esm({
128
128
  "lib/utils/cleanup-browser-dir.ts"() {
@@ -377,6 +377,21 @@ var init_color = __esm({
377
377
  }
378
378
  });
379
379
 
380
+ // lib/setup/default-project-config-values.ts
381
+ var defaultProjectConfigValues;
382
+ var init_default_project_config_values = __esm({
383
+ "lib/setup/default-project-config-values.ts"() {
384
+ defaultProjectConfigValues = {
385
+ output: "tmp",
386
+ timeout: 2e4,
387
+ failFast: false,
388
+ port: 1234,
389
+ extensions: ["js", "ts", "jsx", "tsx"],
390
+ browser: "chromium"
391
+ };
392
+ }
393
+ });
394
+
380
395
  // lib/utils/read-template.ts
381
396
  import fs3 from "node:fs/promises";
382
397
  import { dirname, join as join2 } from "node:path";
@@ -447,6 +462,18 @@ var init_html = __esm({
447
462
  });
448
463
 
449
464
  // lib/tap/dump-yaml.ts
465
+ function dumpYaml({
466
+ name,
467
+ actual,
468
+ expected,
469
+ message,
470
+ stack,
471
+ source,
472
+ at
473
+ }) {
474
+ return `name: ${dumpString(name, "")}
475
+ ` + 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) : "");
476
+ }
450
477
  function needsQuoting(str) {
451
478
  return NEEDS_QUOTING.test(str);
452
479
  }
@@ -484,18 +511,6 @@ function yamlLine(key, value) {
484
511
  ` : `${key}: ${serialized}
485
512
  `;
486
513
  }
487
- function dumpYaml({
488
- name,
489
- actual,
490
- expected,
491
- message,
492
- stack,
493
- source,
494
- at
495
- }) {
496
- return `name: ${dumpString(name, "")}
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) : "");
498
- }
499
514
  var NEEDS_QUOTING;
500
515
  var init_dump_yaml = __esm({
501
516
  "lib/tap/dump-yaml.ts"() {
@@ -518,43 +533,35 @@ var init_indent_string = __esm({
518
533
  });
519
534
 
520
535
  // 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
536
  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;
537
+ const result = [];
538
+ const cursor = { position: 0 };
539
+ const mappingsLength = mappings.length;
540
+ let segments = [];
541
+ let generatedCol = 0, sourceIndex = 0, sourceLine = 0, sourceCol = 0;
542
+ for (; ; ) {
543
+ if (cursor.position >= mappingsLength) break;
544
+ const charCode = mappings.charCodeAt(cursor.position);
545
+ if (charCode !== COMMA && charCode !== SEMICOLON) {
546
+ generatedCol += readVlqAt(mappings, cursor);
547
+ if (atFieldStart(mappings, cursor.position)) {
548
+ sourceIndex += readVlqAt(mappings, cursor);
549
+ sourceLine += readVlqAt(mappings, cursor);
550
+ sourceCol += readVlqAt(mappings, cursor);
551
+ segments.push({ generatedCol, sourceIndex, sourceLine, sourceCol });
552
+ if (atFieldStart(mappings, cursor.position)) readVlqAt(mappings, cursor);
542
553
  }
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
- });
554
+ } else if (charCode === COMMA) {
555
+ cursor.position++;
556
+ } else {
557
+ result.push(segments);
558
+ segments = [];
559
+ generatedCol = 0;
560
+ cursor.position++;
561
+ }
562
+ }
563
+ result.push(segments);
564
+ return result;
558
565
  }
559
566
  function parseSourceMap(json, outDir) {
560
567
  const map = JSON.parse(json);
@@ -566,151 +573,185 @@ function parseSourceMap(json, outDir) {
566
573
  sourcesContent: map.sourcesContent ?? []
567
574
  };
568
575
  }
569
- function base64DecodeUtf8(b64) {
570
- const binary = atob(b64);
571
- return UTF8.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
572
- }
573
576
  function extractInlineSourceMap(bundle, outDir) {
574
577
  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;
578
+ const base64Payload = readMarkerPayload(bundle);
579
+ if (!base64Payload) return null;
580
580
  try {
581
- return parseSourceMap(base64DecodeUtf8(match[1]), outDir);
581
+ return parseSourceMap(decodeBase64Utf8(base64Payload), outDir);
582
582
  } catch {
583
583
  return null;
584
584
  }
585
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
586
  function lookupPosition(decoder, generatedLine, generatedCol) {
607
587
  const segments = decoder.segmentsByLine[generatedLine - 1];
608
588
  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];
589
+ const targetCol = generatedCol - 1;
590
+ const segment = segments.findLast((s) => s.generatedCol <= targetCol);
591
+ if (!segment) return null;
592
+ const rawSource = decoder.sources[segment.sourceIndex];
623
593
  if (!rawSource) return null;
624
- const content = decoder.sourcesContent[sourceIndex];
625
- const sourceText = content ? content.split("\n", sourceLine + 1)[sourceLine]?.trim() || null : null;
626
594
  return {
627
595
  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
596
+ line: segment.sourceLine + 1,
597
+ col: segment.sourceCol + 1,
598
+ sourceText: extractSourceLine(decoder.sourcesContent[segment.sourceIndex], segment.sourceLine)
633
599
  };
634
600
  }
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 };
601
+ function parseFrameLocation(text) {
602
+ const match = FRAME_LOCATION_RE.exec(text);
603
+ return match ? { url: match[1], line: +match[2], col: +match[3] } : null;
645
604
  }
646
605
  function isBundleUrl(url) {
647
- const normalized = url.startsWith("async ") ? url.slice(6) : url;
648
- return /^https?:\/\//.test(normalized) && /\/(tests|filtered-tests)\.js$/.test(normalized);
606
+ return BUNDLE_URL_RE.test(url);
607
+ }
608
+ function resolveFrame(frame, decoder, projectRoot) {
609
+ const hit = FRAME_FORMATS.map((format) => ({ format, match: frame.match(format.pattern) })).find(
610
+ ({ match }) => match !== null
611
+ );
612
+ if (!hit?.match) return null;
613
+ const original = tryResolve(hit.match[hit.format.urlGroup], decoder, projectRoot);
614
+ return original && {
615
+ resolved: hit.format.format(hit.match, original.display),
616
+ userPath: original.userPath,
617
+ sourceText: original.sourceText
618
+ };
619
+ }
620
+ function resolveStack(stack, decoder, projectRoot) {
621
+ const decoratedFrames = stack.split("\n").map((frame) => {
622
+ const resolution = resolveFrame(frame, decoder, projectRoot);
623
+ return {
624
+ line: resolution?.resolved ?? frame,
625
+ userPath: resolution?.userPath ?? null,
626
+ sourceText: resolution?.sourceText ?? null
627
+ };
628
+ });
629
+ const firstUser = decoratedFrames.find((entry) => entry.userPath !== null);
630
+ return {
631
+ resolvedStack: decoratedFrames.map((entry) => entry.line).join("\n"),
632
+ firstUserFrame: firstUser?.userPath ?? null,
633
+ firstUserSourceText: firstUser?.sourceText ?? null
634
+ };
649
635
  }
650
- function isNodeModulesPath(absolutePath) {
651
- return absolutePath.includes("/node_modules/") || absolutePath.includes("\\node_modules\\");
636
+ function readVlqAt(text, cursor) {
637
+ let value = 0, bitShift = 0, position = cursor.position;
638
+ for (; ; ) {
639
+ const digit = BASE64_LOOKUP[text.charCodeAt(position++)];
640
+ value |= (digit & VLQ_DATA_MASK) << bitShift;
641
+ if (!(digit & VLQ_CONTINUATION)) break;
642
+ bitShift += 5;
643
+ }
644
+ cursor.position = position;
645
+ return value & 1 ? -(value >>> 1) : value >>> 1;
646
+ }
647
+ function atFieldStart(text, position) {
648
+ if (position >= text.length) return false;
649
+ const charCode = text.charCodeAt(position);
650
+ return charCode !== COMMA && charCode !== SEMICOLON;
651
+ }
652
+ function decodeBase64Utf8(base64) {
653
+ if (Buffer2) return Buffer2.from(base64, "base64").toString("utf8");
654
+ return UTF8_DECODER.decode(Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)));
655
+ }
656
+ function readMarkerPayload(bundle) {
657
+ if (typeof bundle === "string") return sliceMarkerFromString(bundle);
658
+ if (Buffer2) return sliceMarkerFromBuffer(asBuffer(bundle, Buffer2));
659
+ return sliceMarkerFromString(UTF8_DECODER.decode(bundle));
660
+ }
661
+ function asBuffer(view, BufferCtor) {
662
+ return BufferCtor.isBuffer(view) ? view : BufferCtor.from(view.buffer, view.byteOffset, view.byteLength);
663
+ }
664
+ function sliceMarkerFromString(text) {
665
+ const markerStart = text.lastIndexOf(SOURCE_MAP_MARKER);
666
+ if (markerStart < 0) return null;
667
+ const payloadStart = markerStart + SOURCE_MAP_MARKER.length;
668
+ const payloadEnd = text.indexOf("\n", payloadStart);
669
+ return (payloadEnd < 0 ? text.slice(payloadStart) : text.slice(payloadStart, payloadEnd)).trim();
670
+ }
671
+ function sliceMarkerFromBuffer(buffer) {
672
+ const markerBytes = SOURCE_MAP_MARKER_BYTES;
673
+ const markerStart = buffer.lastIndexOf(markerBytes);
674
+ if (markerStart < 0) return null;
675
+ const payloadStart = markerStart + markerBytes.length;
676
+ const newlineIndex = buffer.indexOf(NEWLINE, payloadStart);
677
+ const payloadEnd = newlineIndex < 0 ? buffer.length : newlineIndex;
678
+ return buffer.toString("latin1", payloadStart, payloadEnd).trim();
679
+ }
680
+ function extractSourceLine(content, lineIndex) {
681
+ if (!content || lineIndex < 0) return null;
682
+ const line = content.split("\n", lineIndex + 1)[lineIndex];
683
+ return line?.trim() || null;
684
+ }
685
+ function normalizePosix(path10) {
686
+ const parts = path10.split("/").reduce((acc, part) => {
687
+ if (part === "..") acc.pop();
688
+ else if (part && part !== ".") acc.push(part);
689
+ return acc;
690
+ }, []);
691
+ return (path10.startsWith("/") ? "/" : "") + parts.join("/");
692
+ }
693
+ function toAbsolutePath(rawSource, outDir, sourceRoot) {
694
+ if (rawSource.startsWith("file://")) return rawSource.slice(7);
695
+ if (rawSource.startsWith("/")) return rawSource;
696
+ const base = sourceRoot ? normalizePosix(`${outDir}/${sourceRoot}`) : outDir;
697
+ return normalizePosix(`${base}/${rawSource}`);
698
+ }
699
+ function isNodeModulesPath(path10) {
700
+ return path10.includes("/node_modules/") || path10.includes("\\node_modules\\");
652
701
  }
653
702
  function makeDisplayPath(absolutePath, projectRoot) {
654
703
  const prefix = projectRoot + "/";
655
704
  return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
656
705
  }
657
706
  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}`;
707
+ const location = parseFrameLocation(urlLineCol);
708
+ if (!location || !isBundleUrl(location.url)) return null;
709
+ const original = lookupPosition(decoder, location.line, location.col);
710
+ if (!original) return null;
711
+ const display = `${makeDisplayPath(original.absolutePath, projectRoot)}:${original.line}:${original.col}`;
663
712
  return {
664
713
  display,
665
- userPath: isNodeModulesPath(orig.absolutePath) ? null : display,
666
- sourceText: orig.sourceText
714
+ userPath: isNodeModulesPath(original.absolutePath) ? null : display,
715
+ sourceText: original.sourceText
667
716
  };
668
717
  }
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;
718
+ var BASE64_ALPHABET, BASE64_LOOKUP, COMMA, SEMICOLON, NEWLINE, VLQ_CONTINUATION, VLQ_DATA_MASK, SOURCE_MAP_MARKER, FRAME_LOCATION_RE, BUNDLE_URL_RE, FRAME_FORMATS, Buffer2, SOURCE_MAP_MARKER_BYTES, UTF8_DECODER;
706
719
  var init_source_map_decoder = __esm({
707
720
  "lib/utils/source-map-decoder.ts"() {
708
- BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
721
+ BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
709
722
  BASE64_LOOKUP = new Uint8Array(128);
710
- [...BASE64].forEach((ch, i) => {
711
- BASE64_LOOKUP[ch.charCodeAt(0)] = i;
712
- });
713
- UTF8 = new TextDecoder();
723
+ [...BASE64_ALPHABET].forEach((char, index) => BASE64_LOOKUP[char.charCodeAt(0)] = index);
724
+ COMMA = 44;
725
+ SEMICOLON = 59;
726
+ NEWLINE = 10;
727
+ VLQ_CONTINUATION = 32;
728
+ VLQ_DATA_MASK = 31;
729
+ SOURCE_MAP_MARKER = "//# sourceMappingURL=data:application/json;base64,";
730
+ FRAME_LOCATION_RE = /^(.+):(\d+):(\d+)$/;
731
+ BUNDLE_URL_RE = /^(?:async )?https?:\/\/.*\/(?:tests|filtered-tests)\.js$/;
732
+ FRAME_FORMATS = [
733
+ // Chrome named: " at FUNC (URL:LINE:COL)"
734
+ {
735
+ pattern: /^(\s*at\s+)(.*?)\s+\(([^)]+)\)\s*$/,
736
+ urlGroup: 3,
737
+ format: (match, displayPath) => `${match[1]}${match[2]} (${displayPath})`
738
+ },
739
+ // Chrome anonymous (incl. async): " at [async] URL:LINE:COL"
740
+ {
741
+ pattern: /^(\s*at\s+(?:async\s+)?)(.+)/,
742
+ urlGroup: 2,
743
+ format: (match, displayPath) => `${match[1]}${displayPath}`
744
+ },
745
+ // Firefox / WebKit: "FUNC@URL:LINE:COL"
746
+ {
747
+ pattern: /^([^@]*)@(.+)$/,
748
+ urlGroup: 2,
749
+ format: (match, displayPath) => `${match[1]}@${displayPath}`
750
+ }
751
+ ];
752
+ Buffer2 = globalThis.Buffer;
753
+ SOURCE_MAP_MARKER_BYTES = Buffer2 ? Buffer2.from(SOURCE_MAP_MARKER, "utf8") : null;
754
+ UTF8_DECODER = new TextDecoder();
714
755
  }
715
756
  });
716
757
 
@@ -836,12 +877,12 @@ var init_bind_server_to_port = __esm({
836
877
  }
837
878
  });
838
879
 
839
- // lib/servers/http.ts
880
+ // lib/servers/web.ts
840
881
  import http from "node:http";
841
882
  import WebSocket, { WebSocketServer } from "ws";
842
883
  var MIME_TYPES, HTTPServer;
843
- var init_http = __esm({
844
- "lib/servers/http.ts"() {
884
+ var init_web = __esm({
885
+ "lib/servers/web.ts"() {
845
886
  init_bind_server_to_port();
846
887
  MIME_TYPES = {
847
888
  html: "text/html; charset=UTF-8",
@@ -1298,166 +1339,18 @@ function setupWebServer(config, cachedContent) {
1298
1339
  });
1299
1340
  return server;
1300
1341
  }
1301
- function replaceAssetPaths(html, htmlPath, projectRoot) {
1302
- const assetPaths = findInternalAssetsFromHTML(html);
1303
- const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
1304
- return assetPaths.reduce((result, assetPath) => {
1305
- const normalizedFullAbsolutePath = path5.normalize(`${htmlDirectory}/${assetPath}`);
1306
- return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1307
- }, html);
1308
- }
1309
- function testRuntimeToInject(config, groupId) {
1310
- const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1311
- return `<script>
1312
- (function() {
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 });
1324
- });
1325
-
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.
1330
- if (window.location.protocol === 'file:') return;
1331
-
1332
- let wsRetryCount = 0;
1333
- const WS_MAX_RETRIES = Math.ceil(${config.timeout} / ${WS_RETRY_INTERVAL_MS}); // retry for the full test timeout window
1334
-
1335
- function setupWebSocket() {
1336
- try {
1337
- window.socket = new WebSocket(\`ws://localhost:\${location.port}\`);
1338
- } catch (error) {
1339
- console.log(error);
1340
- retryOrFail();
1341
- return;
1342
- }
1343
-
1344
- window.socket.addEventListener('open', function() {
1345
- resolveWsReady();
1346
- // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
1347
- // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1348
- // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1349
- if (navigator.webdriver) {
1350
- window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1351
- }
1352
- });
1353
- window.socket.addEventListener('error', function() {
1354
- retryOrFail();
1355
- });
1356
- window.socket.addEventListener('message', function(messageEvent) {
1357
- if (!navigator.webdriver && messageEvent.data === 'refresh') {
1358
- window.location.reload(true);
1359
- } else if (navigator.webdriver && messageEvent.data === 'abort') {
1360
- window.abortQUnit = true;
1361
- window.QUnit.config.queue.length = 0;
1362
- window.socket.send(JSON.stringify({ event: 'abort' }));
1363
- }
1364
- });
1365
- }
1366
-
1367
- function retryOrFail() {
1368
- wsRetryCount++;
1369
- if (wsRetryCount > WS_MAX_RETRIES) {
1370
- console.log('WebSocket connection failed after ' + WS_MAX_RETRIES + ' retries');
1371
- return;
1372
- }
1373
- window.setTimeout(setupWebSocket, ${WS_RETRY_INTERVAL_MS});
1374
- }
1375
-
1376
- setupWebSocket();
1377
- })();
1378
-
1379
- function getCircularReplacer() {
1380
- const ancestors = [];
1381
- return function (key, value) {
1382
- if (typeof value !== "object" || value === null) {
1383
- return value;
1384
- }
1385
- while (ancestors.length > 0 && ancestors.at(-1) !== this) {
1386
- ancestors.pop();
1387
- }
1388
- if (ancestors.includes(value)) {
1389
- return "[Circular]";
1390
- }
1391
- ancestors.push(value);
1392
- return value;
1393
- };
1394
- }
1395
-
1396
- function setupQUnit() {
1397
- window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: '' };
1398
-
1399
- if (!window.QUnit) {
1400
- console.log('QUnit not found after WebSocket connected');
1401
- if (navigator.webdriver) {
1402
- // Signal the Playwright runner that the run is complete with 0 tests rather than
1403
- // waiting for the inactivity timeout. The runner treats totalTests === 0 as a
1404
- // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1405
- window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1406
- window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1407
- }
1408
- return;
1409
- }
1410
-
1411
- window.QUnit.begin(() => { // NOTE: might be useful in future for hanged module tracking
1412
- if (navigator.webdriver) {
1413
- window.socket.send(JSON.stringify({ event: 'connection' }));
1414
- }
1415
- });
1416
- window.QUnit.on('testStart', (details) => {
1417
- window.QUNIT_RESULT.totalTests++;
1418
- window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
1419
- });
1420
- window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
1421
- window.QUNIT_RESULT.finishedTests++;
1422
- if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
1423
- window.QUNIT_RESULT.currentTest = null;
1424
- if (navigator.webdriver) {
1425
- const isFailed = details.status === 'failed';
1426
- const payload = isFailed ? details : { status: details.status, fullName: details.fullName, runtime: details.runtime };
1427
- window.socket.send(JSON.stringify({ event: 'testEnd', details: payload, abort: window.abortQUnit }, isFailed ? getCircularReplacer() : undefined));
1428
-
1429
- if (${config.failFast} && details.status === 'failed') {
1430
- window.QUnit.config.queue.length = 0;
1431
- }
1432
- }
1433
- });
1434
- window.QUnit.done((details) => {
1435
- if (navigator.webdriver) {
1436
- window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1437
- }
1438
- });
1439
-
1440
- window.QUnit.config.testTimeout = ${config.timeout};
1441
- window.QUnit.start();
1442
- }
1443
- </script>`;
1444
- }
1445
- function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
1446
- return injectScript(html, `${testRuntimeCode}
1447
- <script src="${testBundleUrl}" async></script>`);
1448
- }
1449
- function buildNoTestsHTML(files) {
1450
- const escaped = files.map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")).join("\n");
1451
- return `<!DOCTYPE html>
1452
- <html lang="en">
1453
- <head>
1454
- <meta charset="utf-8">
1455
- <meta name="viewport" content="width=device-width">
1456
- <title>No Tests Registered \u2014 qunitx</title>
1457
- <style>
1458
- * { box-sizing: border-box; margin: 0; padding: 0; }
1459
- #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1460
- font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1342
+ function buildNoTestsHTML(files) {
1343
+ const escaped = files.map((f) => f.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")).join("\n");
1344
+ return `<!DOCTYPE html>
1345
+ <html lang="en">
1346
+ <head>
1347
+ <meta charset="utf-8">
1348
+ <meta name="viewport" content="width=device-width">
1349
+ <title>No Tests Registered \u2014 qunitx</title>
1350
+ <style>
1351
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1352
+ #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-tests, #qunit-tests li {
1353
+ font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
1461
1354
  }
1462
1355
  #qunit-header {
1463
1356
  padding: 0.5em 0 0.5em 1em;
@@ -1700,16 +1593,6 @@ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
1700
1593
  res.end(groupCachedContent.allTestCode);
1701
1594
  });
1702
1595
  }
1703
- function debugGroupHeader(config) {
1704
- const files = Object.keys(config.fsTree);
1705
- const rel = files.map((f) => f.replace(`${config.projectRoot}/`, ""));
1706
- const shown = rel.slice(0, 2);
1707
- const rest = rel.length - shown.length;
1708
- process.stdout.write(
1709
- `# ${blue(`\u2500\u2500 ${shown.join(" ")}${rest > 0 ? ` +${rest} more` : ""} \u2500\u2500`)}
1710
- `
1711
- );
1712
- }
1713
1596
  function setupGroupWSHandler(server, groupConfigs) {
1714
1597
  const socketToGroupId = /* @__PURE__ */ new WeakMap();
1715
1598
  server.wss.on("connection", function connection(socket) {
@@ -1790,6 +1673,164 @@ function registerSharedStaticHandler(server, groupConfigs) {
1790
1673
  });
1791
1674
  });
1792
1675
  }
1676
+ function replaceAssetPaths(html, htmlPath, projectRoot) {
1677
+ const assetPaths = findInternalAssetsFromHTML(html);
1678
+ const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
1679
+ return assetPaths.reduce((result, assetPath) => {
1680
+ const normalizedFullAbsolutePath = path5.normalize(`${htmlDirectory}/${assetPath}`);
1681
+ return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1682
+ }, html);
1683
+ }
1684
+ function testRuntimeToInject(config, groupId) {
1685
+ const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1686
+ return `<script>
1687
+ (function() {
1688
+ // setupQUnit runs exactly once, after both the WebSocket is open and tests.js has loaded.
1689
+ // Promise.all is naturally idempotent \u2014 resolving a Promise a second time is a no-op,
1690
+ // so WebKit firing WS error after open (causing a retry that re-opens) cannot double-start.
1691
+ let resolveWsReady = () => {};
1692
+ const wsReadyPromise = window.location.protocol === 'file:'
1693
+ ? Promise.resolve()
1694
+ : new Promise(resolve => { resolveWsReady = resolve; });
1695
+
1696
+ // { once: true } auto-removes the listener after the first fire.
1697
+ const testsReadyPromise = new Promise(resolve => {
1698
+ window.addEventListener('qunitx:tests-ready', resolve, { once: true });
1699
+ });
1700
+
1701
+ Promise.all([wsReadyPromise, testsReadyPromise]).then(setupQUnit);
1702
+
1703
+ // For static files (file:// protocol) there is no WebSocket server; wsReadyPromise
1704
+ // is already resolved above, so setupQUnit fires as soon as tests load.
1705
+ if (window.location.protocol === 'file:') return;
1706
+
1707
+ let wsRetryCount = 0;
1708
+ const WS_MAX_RETRIES = Math.ceil(${config.timeout} / ${WS_RETRY_INTERVAL_MS}); // retry for the full test timeout window
1709
+
1710
+ function setupWebSocket() {
1711
+ try {
1712
+ window.socket = new WebSocket(\`ws://localhost:\${location.port}\`);
1713
+ } catch (error) {
1714
+ console.log(error);
1715
+ retryOrFail();
1716
+ return;
1717
+ }
1718
+
1719
+ window.socket.addEventListener('open', function() {
1720
+ resolveWsReady();
1721
+ // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
1722
+ // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1723
+ // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1724
+ if (navigator.webdriver) {
1725
+ window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1726
+ }
1727
+ });
1728
+ window.socket.addEventListener('error', function() {
1729
+ retryOrFail();
1730
+ });
1731
+ window.socket.addEventListener('message', function(messageEvent) {
1732
+ if (!navigator.webdriver && messageEvent.data === 'refresh') {
1733
+ window.location.reload(true);
1734
+ } else if (navigator.webdriver && messageEvent.data === 'abort') {
1735
+ window.abortQUnit = true;
1736
+ window.QUnit.config.queue.length = 0;
1737
+ window.socket.send(JSON.stringify({ event: 'abort' }));
1738
+ }
1739
+ });
1740
+ }
1741
+
1742
+ function retryOrFail() {
1743
+ wsRetryCount++;
1744
+ if (wsRetryCount > WS_MAX_RETRIES) {
1745
+ console.log('WebSocket connection failed after ' + WS_MAX_RETRIES + ' retries');
1746
+ return;
1747
+ }
1748
+ window.setTimeout(setupWebSocket, ${WS_RETRY_INTERVAL_MS});
1749
+ }
1750
+
1751
+ setupWebSocket();
1752
+ })();
1753
+
1754
+ function getCircularReplacer() {
1755
+ const ancestors = [];
1756
+ return function (key, value) {
1757
+ if (typeof value !== "object" || value === null) {
1758
+ return value;
1759
+ }
1760
+ while (ancestors.length > 0 && ancestors.at(-1) !== this) {
1761
+ ancestors.pop();
1762
+ }
1763
+ if (ancestors.includes(value)) {
1764
+ return "[Circular]";
1765
+ }
1766
+ ancestors.push(value);
1767
+ return value;
1768
+ };
1769
+ }
1770
+
1771
+ function setupQUnit() {
1772
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: '' };
1773
+
1774
+ if (!window.QUnit) {
1775
+ console.log('QUnit not found after WebSocket connected');
1776
+ if (navigator.webdriver) {
1777
+ // Signal the Playwright runner that the run is complete with 0 tests rather than
1778
+ // waiting for the inactivity timeout. The runner treats totalTests === 0 as a
1779
+ // "no tests registered" warning (not a failure), so this gives a fast, clean result.
1780
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: null };
1781
+ window.socket.send(JSON.stringify({ event: 'done', details: { passed: 0, failed: 0, runtime: 0 } }));
1782
+ }
1783
+ return;
1784
+ }
1785
+
1786
+ window.QUnit.begin(() => { // NOTE: might be useful in future for hanged module tracking
1787
+ if (navigator.webdriver) {
1788
+ window.socket.send(JSON.stringify({ event: 'connection' }));
1789
+ }
1790
+ });
1791
+ window.QUnit.on('testStart', (details) => {
1792
+ window.QUNIT_RESULT.totalTests++;
1793
+ window.QUNIT_RESULT.currentTest = details.fullName.join(' | ');
1794
+ });
1795
+ window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
1796
+ window.QUNIT_RESULT.finishedTests++;
1797
+ if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
1798
+ window.QUNIT_RESULT.currentTest = null;
1799
+ if (navigator.webdriver) {
1800
+ const isFailed = details.status === 'failed';
1801
+ const payload = isFailed ? details : { status: details.status, fullName: details.fullName, runtime: details.runtime };
1802
+ window.socket.send(JSON.stringify({ event: 'testEnd', details: payload, abort: window.abortQUnit }, isFailed ? getCircularReplacer() : undefined));
1803
+
1804
+ if (${config.failFast} && details.status === 'failed') {
1805
+ window.QUnit.config.queue.length = 0;
1806
+ }
1807
+ }
1808
+ });
1809
+ window.QUnit.done((details) => {
1810
+ if (navigator.webdriver) {
1811
+ window.socket.send(JSON.stringify({ event: 'done', details: details, qunitResult: window.QUNIT_RESULT, abort: window.abortQUnit }, getCircularReplacer()));
1812
+ }
1813
+ });
1814
+
1815
+ window.QUnit.config.testTimeout = ${config.timeout};
1816
+ window.QUnit.start();
1817
+ }
1818
+ </script>`;
1819
+ }
1820
+ function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
1821
+ return injectScript(html, `${testRuntimeCode}
1822
+ <script src="${testBundleUrl}" async></script>`);
1823
+ }
1824
+ function debugGroupHeader(config) {
1825
+ const files = Object.keys(config.fsTree);
1826
+ const rel = files.map((f) => f.replace(`${config.projectRoot}/`, ""));
1827
+ const shown = rel.slice(0, 2);
1828
+ const rest = rel.length - shown.length;
1829
+ process.stdout.write(
1830
+ `# ${blue(`\u2500\u2500 ${shown.join(" ")}${rest > 0 ? ` +${rest} more` : ""} \u2500\u2500`)}
1831
+ `
1832
+ );
1833
+ }
1793
1834
  var fsPromise, HTML_HEADERS, WATCH_WS_RECONNECT_INTERVAL_MS, WATCH_WS_RECONNECT_MAX_RETRIES, WS_RETRY_INTERVAL_MS, NOT_FOUND_HTML;
1794
1835
  var init_web_server = __esm({
1795
1836
  "lib/setup/web-server.ts"() {
@@ -1797,7 +1838,7 @@ var init_web_server = __esm({
1797
1838
  init_html();
1798
1839
  init_display_test_result();
1799
1840
  init_color();
1800
- init_http();
1841
+ init_web();
1801
1842
  fsPromise = fs8.promises;
1802
1843
  HTML_HEADERS = { "Content-Type": "text/html", "Cache-Control": "no-store" };
1803
1844
  WATCH_WS_RECONNECT_INTERVAL_MS = 1e3;
@@ -1961,10 +2002,10 @@ var init_browser = __esm({
1961
2002
  // lib/utils/open-output-in-browser.ts
1962
2003
  import { spawn as spawn2 } from "node:child_process";
1963
2004
  import path6 from "node:path";
1964
- import { pathToFileURL } from "node:url";
2005
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
1965
2006
  async function openOutputInBrowser(config) {
1966
2007
  try {
1967
- const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
2008
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL2(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
1968
2009
  if (typeof config.open === "string") {
1969
2010
  spawnDetached(config.open, [outputFile]);
1970
2011
  return;
@@ -2010,10 +2051,10 @@ var init_time_counter = __esm({
2010
2051
  });
2011
2052
 
2012
2053
  // lib/utils/run-user-module.ts
2013
- import { pathToFileURL as pathToFileURL2 } from "node:url";
2054
+ import { pathToFileURL as pathToFileURL3 } from "node:url";
2014
2055
  async function runUserModule(modulePath, params, scriptPosition) {
2015
2056
  try {
2016
- const func = await import(pathToFileURL2(modulePath).href);
2057
+ const func = await import(pathToFileURL3(modulePath).href);
2017
2058
  if (func) {
2018
2059
  func.default ? await func.default(params) : typeof func === "function" ? await func(params) : null;
2019
2060
  }
@@ -2058,12 +2099,6 @@ var init_display_final_result = __esm({
2058
2099
  import fs9 from "node:fs/promises";
2059
2100
  import path7 from "node:path";
2060
2101
  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
- }
2067
2102
  function deriveBuildErrorType(error) {
2068
2103
  const msgs = error?.errors ?? [];
2069
2104
  const text = msgs[0]?.text ?? (error instanceof Error ? error.message : String(error));
@@ -2120,6 +2155,11 @@ async function buildTestBundle(config, cachedContent) {
2120
2155
  legalComments: "none",
2121
2156
  target: esbuildTarget(config.browser),
2122
2157
  sourcemap,
2158
+ // jsx: 'automatic' is a no-op for .ts/.js files (extension-gated by esbuild) and emits
2159
+ // `import { jsx } from 'react/jsx-runtime'` for .tsx/.jsx files. Per-file overrides via
2160
+ // tsconfig's `jsxImportSource` or a `@jsxImportSource <pkg>` pragma cover Vue/Preact/Solid.
2161
+ jsx: "automatic",
2162
+ plugins: config.plugins,
2123
2163
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
2124
2164
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
2125
2165
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
@@ -2266,174 +2306,10 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
2266
2306
  }
2267
2307
  return connections;
2268
2308
  }
2269
- function buildFilteredTests(filteredTests, outputPath, config) {
2270
- const sourcemap = "inline";
2271
- const needsDisk = Boolean(config.open);
2272
- return buildWithOverlayfsRetry(
2273
- {
2274
- stdin: {
2275
- contents: filteredTests.map((filePath) => `import "${filePath.replace(/\\/g, "/")}";`).join(""),
2276
- resolveDir: process.cwd()
2277
- },
2278
- nodePaths: ANCESTOR_NODE_MODULES,
2279
- bundle: true,
2280
- logLevel: "silent",
2281
- outfile: outputPath,
2282
- legalComments: "none",
2283
- target: esbuildTarget(config.browser),
2284
- sourcemap,
2285
- footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2286
- },
2287
- needsDisk
2288
- );
2289
- }
2290
- async function runWithOverlayfsRetry(getContents, needsDisk) {
2291
- let { result, js } = await getContents();
2292
- const initialSize = js.length;
2293
- for (let retry = 1; retry <= MAX_RETRIES; retry++) {
2294
- if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
2295
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
2296
- ({ result, js } = await getContents());
2297
- }
2298
- if (js.length < EMPTY_BUNDLE_THRESHOLD && js.length !== initialSize) {
2299
- console.log(
2300
- `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
2301
- );
2302
- }
2303
- if (needsDisk) {
2304
- await Promise.all(
2305
- result.outputFiles.map((outputFile) => fs9.writeFile(outputFile.path, outputFile.contents))
2306
- );
2307
- }
2308
- return js;
2309
- }
2310
- function buildWithOverlayfsRetry(options, needsDisk) {
2311
- const buildOpts = { ...options, write: false };
2312
- return runWithOverlayfsRetry(async () => {
2313
- const result = await esbuild.build(buildOpts);
2314
- const jsFile = result.outputFiles.find((f) => !f.path.endsWith(".map"));
2315
- return { result, js: Buffer.from(jsFile.contents) };
2316
- }, needsDisk);
2317
- }
2318
- async function buildIncrementally(options, fileKey, cachedContent, needsDisk) {
2319
- const buildOpts = { ...options, write: false };
2320
- if (!cachedContent._esbuildContext || cachedContent._esbuildContextKey !== fileKey) {
2321
- cachedContent._esbuildContext?.dispose().catch(() => {
2322
- });
2323
- cachedContent._esbuildContext = await esbuild.context(buildOpts);
2324
- cachedContent._esbuildContextKey = fileKey;
2325
- }
2326
- const ctx = cachedContent._esbuildContext;
2327
- return runWithOverlayfsRetry(async () => {
2328
- const result = await ctx.rebuild();
2329
- const jsFile = result.outputFiles.find((f) => !f.path.endsWith(".map"));
2330
- return { result, js: Buffer.from(jsFile.contents) };
2331
- }, needsDisk);
2332
- }
2333
- function esbuildTarget(browser) {
2334
- if (browser === "firefox") return ["firefox115"];
2335
- if (browser === "webkit") return ["safari16"];
2336
- return ["chrome120"];
2337
- }
2338
- async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
2339
- let QUNIT_RESULT;
2340
- let targetError;
2341
- let timeoutHandle;
2342
- let wsConnected = false;
2343
- try {
2344
- console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
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);
2348
- let resolveTestRace;
2349
- const testRaceResult = new Promise((resolve) => {
2350
- resolveTestRace = resolve;
2351
- });
2352
- config._testRunDone = resolveTestRace;
2353
- config._onWsOpen = () => {
2354
- wsConnected = true;
2355
- clearTimeout(timeoutHandle);
2356
- timeoutHandle = setTimeout(resolveTestRace, startupMs);
2357
- };
2358
- config._onTestsJsServed = () => {
2359
- clearTimeout(timeoutHandle);
2360
- timeoutHandle = setTimeout(resolveTestRace, testsJsMs);
2361
- };
2362
- config._resetTestTimeout = () => {
2363
- wsConnected = true;
2364
- clearTimeout(timeoutHandle);
2365
- timeoutHandle = setTimeout(resolveTestRace, config.timeout + TEST_STALL_BUFFER_MS);
2366
- };
2367
- const targetUrl = `http://localhost:${config.port}${filePath}`;
2368
- const navOptions = { timeout: navMs, waitUntil: "commit" };
2369
- if (page.url().split("?")[0] === targetUrl) {
2370
- await page.reload(navOptions);
2371
- } else {
2372
- await page.goto(targetUrl, navOptions);
2373
- }
2374
- clearTimeout(timeoutHandle);
2375
- timeoutHandle = setTimeout(resolveTestRace, startupMs);
2376
- await testRaceResult;
2377
- QUNIT_RESULT = config._lastQUnitResult ?? await page.evaluate(() => window.QUNIT_RESULT);
2378
- } catch (error) {
2379
- targetError = error;
2380
- console.log(error);
2381
- console.error(error);
2382
- } finally {
2383
- clearTimeout(timeoutHandle);
2384
- config._onWsOpen = null;
2385
- config._onTestsJsServed = null;
2386
- config._resetTestTimeout = null;
2387
- config._testRunDone = null;
2388
- config._lastQUnitResult = null;
2389
- }
2390
- if (!QUNIT_RESULT) {
2391
- if (targetError) console.log(targetError);
2392
- const wsReason = !wsConnected ? "WebSocket connection never received \u2014 Chrome may be CPU-starved or the page failed to load" : "WebSocket connected but no tests ran \u2014 QUnit may have failed to start";
2393
- console.log(`# TIMEOUT: ${wsReason}`);
2394
- console.log("BROWSER: runtime error thrown during executing tests");
2395
- console.error("BROWSER: runtime error thrown during executing tests");
2396
- await failOnNonWatchMode(
2397
- config.watch,
2398
- { server, browser },
2399
- config._groupMode,
2400
- config._pendingConsoleHandlers
2401
- );
2402
- } else if (QUNIT_RESULT.totalTests === 0) {
2403
- return;
2404
- } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
2405
- if (targetError) console.log(targetError);
2406
- console.log(
2407
- `# TIMEOUT: test stalled after ${QUNIT_RESULT.finishedTests}/${QUNIT_RESULT.totalTests} finished \u2014 last active: ${QUNIT_RESULT.currentTest}`
2408
- );
2409
- console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
2410
- console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
2411
- await failOnNonWatchMode(
2412
- config.watch,
2413
- { server, browser },
2414
- config._groupMode,
2415
- config._pendingConsoleHandlers
2416
- );
2417
- } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
2418
- config.COUNTER.failCount = QUNIT_RESULT.failedTests;
2419
- }
2420
- }
2421
- async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false, pendingHandlers) {
2422
- if (!watchMode) {
2423
- if (groupMode) {
2424
- throw new Error("Browser test run failed");
2425
- }
2426
- await flushConsoleHandlers(pendingHandlers);
2427
- await Promise.all([
2428
- connections.server && connections.server.close(),
2429
- connections.browser && connections.browser.close()
2430
- ]);
2431
- await shutdownPrelaunch();
2432
- process.exit(1);
2433
- }
2434
- }
2435
2309
  async function flushConsoleHandlers(handlers, deadline = Date.now() + CONSOLE_FLUSH_TIMEOUT_MS) {
2436
- if (!handlers || handlers.size === 0 || Date.now() >= deadline) return;
2310
+ if (!handlers || Date.now() >= deadline) return;
2311
+ await new Promise((resolve) => setImmediate(resolve));
2312
+ if (handlers.size === 0) return;
2437
2313
  await Promise.allSettled([...handlers]);
2438
2314
  return flushConsoleHandlers(handlers, deadline);
2439
2315
  }
@@ -2489,7 +2365,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2489
2365
  in: `group-entry-${slotIndex}`,
2490
2366
  out: `group-${slotIndex}`
2491
2367
  })),
2492
- plugins: [groupEntryPlugin],
2368
+ // groupEntryPlugin must run first — it owns the virtual entry-point modules every other
2369
+ // plugin sees. User plugins follow and apply to the resolved test files just like in
2370
+ // single-group mode (`buildTestBundle`).
2371
+ plugins: [groupEntryPlugin, ...groupConfigs[0].plugins ?? []],
2493
2372
  nodePaths: ANCESTOR_NODE_MODULES,
2494
2373
  bundle: true,
2495
2374
  logLevel: "silent",
@@ -2501,6 +2380,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2501
2380
  target: esbuildTarget(browser),
2502
2381
  sourcemap,
2503
2382
  write: false,
2383
+ jsx: "automatic",
2504
2384
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2505
2385
  };
2506
2386
  const hasSmallOutput = (result) => (result.outputFiles ?? []).some(
@@ -2536,24 +2416,198 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2536
2416
  })
2537
2417
  );
2538
2418
  } catch (error) {
2539
- const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
2540
- const errorHtml = buildErrorHTML(buildError);
2541
- await Promise.all(
2542
- activeGroups.map((group) => {
2543
- group.cachedContent._buildError = buildError;
2544
- return fs9.writeFile(
2545
- path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
2546
- errorHtml
2547
- ).catch(
2548
- (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2549
- `)
2550
- );
2551
- })
2419
+ const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
2420
+ const errorHtml = buildErrorHTML(buildError);
2421
+ await Promise.all(
2422
+ activeGroups.map((group) => {
2423
+ group.cachedContent._buildError = buildError;
2424
+ return fs9.writeFile(
2425
+ path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
2426
+ errorHtml
2427
+ ).catch(
2428
+ (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2429
+ `)
2430
+ );
2431
+ })
2432
+ );
2433
+ throw error;
2434
+ }
2435
+ }
2436
+ function buildFilteredTests(filteredTests, outputPath, config) {
2437
+ const sourcemap = "inline";
2438
+ const needsDisk = Boolean(config.open);
2439
+ return buildWithOverlayfsRetry(
2440
+ {
2441
+ stdin: {
2442
+ contents: filteredTests.map((filePath) => `import "${filePath.replace(/\\/g, "/")}";`).join(""),
2443
+ resolveDir: process.cwd()
2444
+ },
2445
+ nodePaths: ANCESTOR_NODE_MODULES,
2446
+ bundle: true,
2447
+ logLevel: "silent",
2448
+ outfile: outputPath,
2449
+ legalComments: "none",
2450
+ target: esbuildTarget(config.browser),
2451
+ sourcemap,
2452
+ jsx: "automatic",
2453
+ plugins: config.plugins,
2454
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2455
+ },
2456
+ needsDisk
2457
+ );
2458
+ }
2459
+ async function runWithOverlayfsRetry(getContents, needsDisk) {
2460
+ let { result, js } = await getContents();
2461
+ const initialSize = js.length;
2462
+ for (let retry = 1; retry <= MAX_RETRIES; retry++) {
2463
+ if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
2464
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
2465
+ ({ result, js } = await getContents());
2466
+ }
2467
+ if (js.length < EMPTY_BUNDLE_THRESHOLD && js.length !== initialSize) {
2468
+ console.log(
2469
+ `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
2470
+ );
2471
+ }
2472
+ if (needsDisk) {
2473
+ await Promise.all(
2474
+ result.outputFiles.map((outputFile) => fs9.writeFile(outputFile.path, outputFile.contents))
2475
+ );
2476
+ }
2477
+ return js;
2478
+ }
2479
+ function buildWithOverlayfsRetry(options, needsDisk) {
2480
+ const buildOpts = { ...options, write: false };
2481
+ return runWithOverlayfsRetry(async () => {
2482
+ const result = await esbuild.build(buildOpts);
2483
+ const jsFile = result.outputFiles.find((f) => !f.path.endsWith(".map"));
2484
+ return { result, js: Buffer.from(jsFile.contents) };
2485
+ }, needsDisk);
2486
+ }
2487
+ async function buildIncrementally(options, fileKey, cachedContent, needsDisk) {
2488
+ const buildOpts = { ...options, write: false };
2489
+ if (!cachedContent._esbuildContext || cachedContent._esbuildContextKey !== fileKey) {
2490
+ cachedContent._esbuildContext?.dispose().catch(() => {
2491
+ });
2492
+ cachedContent._esbuildContext = await esbuild.context(buildOpts);
2493
+ cachedContent._esbuildContextKey = fileKey;
2494
+ }
2495
+ const ctx = cachedContent._esbuildContext;
2496
+ return runWithOverlayfsRetry(async () => {
2497
+ const result = await ctx.rebuild();
2498
+ const jsFile = result.outputFiles.find((f) => !f.path.endsWith(".map"));
2499
+ return { result, js: Buffer.from(jsFile.contents) };
2500
+ }, needsDisk);
2501
+ }
2502
+ function esbuildTarget(browser) {
2503
+ if (browser === "firefox") return ["firefox115"];
2504
+ if (browser === "webkit") return ["safari16"];
2505
+ return ["chrome120"];
2506
+ }
2507
+ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
2508
+ let QUNIT_RESULT;
2509
+ let targetError;
2510
+ let timeoutHandle;
2511
+ let wsConnected = false;
2512
+ try {
2513
+ console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
2514
+ const navMs = Math.max(config.timeout + NAV_GRACE_MS, MIN_NAV_MS);
2515
+ const startupMs = Math.max(config.timeout * STARTUP_TIMEOUT_FACTOR, navMs);
2516
+ const testsJsMs = Math.max(config.timeout * TESTS_JS_TIMEOUT_FACTOR, navMs);
2517
+ let resolveTestRace;
2518
+ const testRaceResult = new Promise((resolve) => {
2519
+ resolveTestRace = resolve;
2520
+ });
2521
+ config._testRunDone = resolveTestRace;
2522
+ config._onWsOpen = () => {
2523
+ wsConnected = true;
2524
+ clearTimeout(timeoutHandle);
2525
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
2526
+ };
2527
+ config._onTestsJsServed = () => {
2528
+ clearTimeout(timeoutHandle);
2529
+ timeoutHandle = setTimeout(resolveTestRace, testsJsMs);
2530
+ };
2531
+ config._resetTestTimeout = () => {
2532
+ wsConnected = true;
2533
+ clearTimeout(timeoutHandle);
2534
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout + TEST_STALL_BUFFER_MS);
2535
+ };
2536
+ const targetUrl = `http://localhost:${config.port}${filePath}`;
2537
+ const navOptions = { timeout: navMs, waitUntil: "commit" };
2538
+ if (page.url().split("?")[0] === targetUrl) {
2539
+ await page.reload(navOptions);
2540
+ } else {
2541
+ await page.goto(targetUrl, navOptions);
2542
+ }
2543
+ clearTimeout(timeoutHandle);
2544
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
2545
+ await testRaceResult;
2546
+ QUNIT_RESULT = config._lastQUnitResult ?? await page.evaluate(() => window.QUNIT_RESULT);
2547
+ } catch (error) {
2548
+ targetError = error;
2549
+ console.log(error);
2550
+ console.error(error);
2551
+ } finally {
2552
+ clearTimeout(timeoutHandle);
2553
+ config._onWsOpen = null;
2554
+ config._onTestsJsServed = null;
2555
+ config._resetTestTimeout = null;
2556
+ config._testRunDone = null;
2557
+ config._lastQUnitResult = null;
2558
+ }
2559
+ if (!QUNIT_RESULT) {
2560
+ if (targetError) console.log(targetError);
2561
+ const wsReason = !wsConnected ? "WebSocket connection never received \u2014 Chrome may be CPU-starved or the page failed to load" : "WebSocket connected but no tests ran \u2014 QUnit may have failed to start";
2562
+ console.log(`# TIMEOUT: ${wsReason}`);
2563
+ console.log("BROWSER: runtime error thrown during executing tests");
2564
+ console.error("BROWSER: runtime error thrown during executing tests");
2565
+ await failOnNonWatchMode(
2566
+ config.watch,
2567
+ { server, browser },
2568
+ config._groupMode,
2569
+ config._pendingConsoleHandlers
2552
2570
  );
2553
- throw error;
2571
+ } else if (QUNIT_RESULT.totalTests === 0) {
2572
+ return;
2573
+ } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
2574
+ if (targetError) console.log(targetError);
2575
+ console.log(
2576
+ `# TIMEOUT: test stalled after ${QUNIT_RESULT.finishedTests}/${QUNIT_RESULT.totalTests} finished \u2014 last active: ${QUNIT_RESULT.currentTest}`
2577
+ );
2578
+ console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
2579
+ console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
2580
+ await failOnNonWatchMode(
2581
+ config.watch,
2582
+ { server, browser },
2583
+ config._groupMode,
2584
+ config._pendingConsoleHandlers
2585
+ );
2586
+ } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
2587
+ config.COUNTER.failCount = QUNIT_RESULT.failedTests;
2588
+ }
2589
+ }
2590
+ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false, pendingHandlers) {
2591
+ if (!watchMode) {
2592
+ if (groupMode) {
2593
+ throw new Error("Browser test run failed");
2594
+ }
2595
+ await flushConsoleHandlers(pendingHandlers);
2596
+ await Promise.all([
2597
+ connections.server && connections.server.close(),
2598
+ connections.browser && connections.browser.close()
2599
+ ]);
2600
+ await shutdownPrelaunch();
2601
+ process.exit(1);
2554
2602
  }
2555
2603
  }
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;
2604
+ function toEsbuildImportPath(filePath) {
2605
+ const rel = path7.relative(process.cwd(), filePath);
2606
+ const normalized = rel.replace(/\\/g, "/");
2607
+ if (path7.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
2608
+ return normalized.startsWith(".") ? normalized : "./" + normalized;
2609
+ }
2610
+ var ancestorNodeModules, ANCESTOR_NODE_MODULES, RETRY_DELAY_MS, MAX_RETRIES, EMPTY_BUNDLE_THRESHOLD, NAV_GRACE_MS, MIN_NAV_MS, STARTUP_TIMEOUT_FACTOR, TESTS_JS_TIMEOUT_FACTOR, CONSOLE_FLUSH_TIMEOUT_MS, TEST_STALL_BUFFER_MS, GROUP_OUTPUT_REGEX, BundleError;
2557
2611
  var init_tests_in_browser = __esm({
2558
2612
  "lib/commands/run/tests-in-browser.ts"() {
2559
2613
  init_color();
@@ -2563,13 +2617,6 @@ var init_tests_in_browser = __esm({
2563
2617
  init_display_final_result();
2564
2618
  init_web_server();
2565
2619
  init_source_map_decoder();
2566
- BundleError = class extends Error {
2567
- constructor(message) {
2568
- super(message);
2569
- this.name = "BundleError";
2570
- this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
2571
- }
2572
- };
2573
2620
  ancestorNodeModules = (dir) => dir.split(path7.sep).map(
2574
2621
  (_, i, parts) => path7.join(parts.slice(0, parts.length - i).join(path7.sep) || path7.sep, "node_modules")
2575
2622
  );
@@ -2578,11 +2625,19 @@ var init_tests_in_browser = __esm({
2578
2625
  MAX_RETRIES = 3;
2579
2626
  EMPTY_BUNDLE_THRESHOLD = 500;
2580
2627
  NAV_GRACE_MS = 1e4;
2628
+ MIN_NAV_MS = 3e4;
2581
2629
  STARTUP_TIMEOUT_FACTOR = 3;
2582
2630
  TESTS_JS_TIMEOUT_FACTOR = 4;
2583
2631
  CONSOLE_FLUSH_TIMEOUT_MS = 2e3;
2584
2632
  TEST_STALL_BUFFER_MS = 5e3;
2585
2633
  GROUP_OUTPUT_REGEX = /group-(\d+)\.js(\.map)?$/;
2634
+ BundleError = class extends Error {
2635
+ constructor(message) {
2636
+ super(message);
2637
+ this.name = "BundleError";
2638
+ this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
2639
+ }
2640
+ };
2586
2641
  }
2587
2642
  });
2588
2643
 
@@ -2591,9 +2646,10 @@ import fs10 from "node:fs";
2591
2646
  import { readdir, stat, lstat } from "node:fs/promises";
2592
2647
  import path8 from "node:path";
2593
2648
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
2594
- const extensions = config.extensions || ["js", "ts"];
2649
+ const extensions = config.extensions || defaultProjectConfigValues.extensions;
2595
2650
  const readyPromises = [];
2596
2651
  const parentWatchers = [];
2652
+ const rescanTimers = [];
2597
2653
  const fileWatchers = {};
2598
2654
  const symlinkPollers = /* @__PURE__ */ new Map();
2599
2655
  function trackSymlink(filePath) {
@@ -2620,20 +2676,24 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2620
2676
  }
2621
2677
  for (const watchPath of testFileLookupPaths) {
2622
2678
  let ready = false;
2679
+ let rescanInProgress = false;
2623
2680
  const lastEventMs = {};
2624
2681
  const seenMtimeMs = {};
2625
2682
  const childWatcher = fs10.watch(watchPath, { recursive: true }, async (eventType, filename) => {
2626
2683
  if (!ready) return;
2627
2684
  if (!filename) {
2628
- if (process.platform === "darwin") {
2629
- await rescanDirectoryForDelta(
2685
+ if (process.platform === "darwin" && !rescanInProgress) {
2686
+ rescanInProgress = true;
2687
+ rescanDirectoryForDelta(
2630
2688
  watchPath,
2631
2689
  config,
2632
2690
  extensions,
2633
2691
  onEventFunc,
2634
2692
  onFinishFunc,
2635
2693
  trackSymlink
2636
- );
2694
+ ).finally(() => {
2695
+ rescanInProgress = false;
2696
+ });
2637
2697
  }
2638
2698
  return;
2639
2699
  }
@@ -2693,6 +2753,24 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2693
2753
  })
2694
2754
  )
2695
2755
  );
2756
+ if (process.platform === "darwin") {
2757
+ rescanTimers.push(
2758
+ setInterval(() => {
2759
+ if (!ready || rescanInProgress) return;
2760
+ rescanInProgress = true;
2761
+ rescanDirectoryForDelta(
2762
+ watchPath,
2763
+ config,
2764
+ extensions,
2765
+ onEventFunc,
2766
+ onFinishFunc,
2767
+ trackSymlink
2768
+ ).finally(() => {
2769
+ rescanInProgress = false;
2770
+ });
2771
+ }, RESCAN_INTERVAL_MS).unref()
2772
+ );
2773
+ }
2696
2774
  }
2697
2775
  readyPromises.push(
2698
2776
  (async () => {
@@ -2712,28 +2790,13 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2712
2790
  killFileWatchers() {
2713
2791
  Object.keys(fileWatchers).forEach((key) => fileWatchers[key].close());
2714
2792
  parentWatchers.forEach((pw) => pw.close());
2793
+ rescanTimers.forEach((t) => clearInterval(t));
2715
2794
  symlinkPollers.forEach((cancel) => cancel());
2716
2795
  symlinkPollers.clear();
2717
2796
  return fileWatchers;
2718
2797
  }
2719
2798
  };
2720
2799
  }
2721
- async function classifyRenameEvent(fullPath, fsTree) {
2722
- for (const delay of [0, OVERLAYFS_RENAME_RETRY_MS]) {
2723
- if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2724
- try {
2725
- const statResult = await stat(fullPath);
2726
- if (statResult.isDirectory()) return "addDir";
2727
- return fsTree && fullPath in fsTree ? "change" : "add";
2728
- } catch {
2729
- }
2730
- }
2731
- if (!fsTree) return null;
2732
- if (fullPath in fsTree) return "unlink";
2733
- return Object.keys(fsTree).some(
2734
- (trackedPath) => trackedPath.startsWith(fullPath + "/") || trackedPath.startsWith(fullPath + "\\")
2735
- ) ? "unlinkDir" : null;
2736
- }
2737
2800
  function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
2738
2801
  if (event !== "unlinkDir" && !extensions.some((ext) => filePath.endsWith(`.${ext}`)))
2739
2802
  return Promise.resolve();
@@ -2776,9 +2839,16 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
2776
2839
  try {
2777
2840
  const entries = await readdir(watchPath, { withFileTypes: true, recursive: true });
2778
2841
  const presentPaths = /* @__PURE__ */ new Set();
2842
+ const presentDirs = /* @__PURE__ */ new Set();
2843
+ presentDirs.add(watchPath);
2779
2844
  for (const entry of entries) {
2845
+ if (entry.isDirectory()) {
2846
+ presentDirs.add(path8.join(entry.parentPath, entry.name));
2847
+ continue;
2848
+ }
2780
2849
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
2781
2850
  const entryPath = path8.join(entry.parentPath, entry.name);
2851
+ presentDirs.add(entry.parentPath);
2782
2852
  if (!extensions.some((ext) => entryPath.endsWith(`.${ext}`))) continue;
2783
2853
  presentPaths.add(entryPath);
2784
2854
  if (!(entryPath in config.fsTree)) {
@@ -2787,9 +2857,18 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
2787
2857
  }
2788
2858
  }
2789
2859
  const watchPrefix = watchPath + path8.sep;
2860
+ const firedDirPrefixes = [];
2790
2861
  for (const trackedPath of Object.keys(config.fsTree)) {
2791
- if (trackedPath.startsWith(watchPrefix) && !presentPaths.has(trackedPath))
2862
+ if (!trackedPath.startsWith(watchPrefix) || presentPaths.has(trackedPath)) continue;
2863
+ if (firedDirPrefixes.some((p) => trackedPath.startsWith(p + path8.sep))) continue;
2864
+ const parts = trackedPath.slice(watchPrefix.length).split(path8.sep);
2865
+ const goneDirPath = parts.slice(0, -1).map((_, i) => watchPrefix + parts.slice(0, i + 1).join(path8.sep)).find((p) => !presentDirs.has(p)) ?? null;
2866
+ if (goneDirPath !== null) {
2867
+ firedDirPrefixes.push(goneDirPath);
2868
+ handleWatchEvent(config, extensions, "unlinkDir", goneDirPath, onEventFunc, onFinishFunc);
2869
+ } else {
2792
2870
  handleWatchEvent(config, extensions, "unlink", trackedPath, onEventFunc, onFinishFunc);
2871
+ }
2793
2872
  }
2794
2873
  } catch {
2795
2874
  }
@@ -2806,18 +2885,36 @@ function mutateFSTree(fsTree, event, filePath) {
2806
2885
  }
2807
2886
  }
2808
2887
  }
2888
+ async function classifyRenameEvent(fullPath, fsTree) {
2889
+ for (const delay of [0, OVERLAYFS_RENAME_RETRY_MS]) {
2890
+ if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2891
+ try {
2892
+ const statResult = await stat(fullPath);
2893
+ if (statResult.isDirectory()) return "addDir";
2894
+ return fsTree && fullPath in fsTree ? "change" : "add";
2895
+ } catch {
2896
+ }
2897
+ }
2898
+ if (!fsTree) return null;
2899
+ if (fullPath in fsTree) return "unlink";
2900
+ return Object.keys(fsTree).some(
2901
+ (trackedPath) => trackedPath.startsWith(fullPath + "/") || trackedPath.startsWith(fullPath + "\\")
2902
+ ) ? "unlinkDir" : null;
2903
+ }
2809
2904
  function colorEvent(event) {
2810
2905
  if (event === "change") return yellow("CHANGED:");
2811
2906
  if (event === "add" || event === "addDir") return green("ADDED:");
2812
2907
  return red("REMOVED:");
2813
2908
  }
2814
- var CHANGE_DEDUPE_MS, SYMLINK_POLL_INTERVAL_MS, OVERLAYFS_RENAME_RETRY_MS;
2909
+ var CHANGE_DEDUPE_MS, SYMLINK_POLL_INTERVAL_MS, OVERLAYFS_RENAME_RETRY_MS, RESCAN_INTERVAL_MS;
2815
2910
  var init_file_watcher = __esm({
2816
2911
  "lib/setup/file-watcher.ts"() {
2817
2912
  init_color();
2913
+ init_default_project_config_values();
2818
2914
  CHANGE_DEDUPE_MS = 10;
2819
2915
  SYMLINK_POLL_INTERVAL_MS = 500;
2820
2916
  OVERLAYFS_RENAME_RETRY_MS = 50;
2917
+ RESCAN_INTERVAL_MS = 1e3;
2821
2918
  }
2822
2919
  });
2823
2920
 
@@ -2934,7 +3031,8 @@ __export(run_exports, {
2934
3031
  run: () => run
2935
3032
  });
2936
3033
  import fs12 from "node:fs/promises";
2937
- import { normalize } from "node:path";
3034
+ import { join as join3, normalize } from "node:path";
3035
+ import { createRequire as createRequire2 } from "node:module";
2938
3036
  import { availableParallelism } from "node:os";
2939
3037
  async function run(config) {
2940
3038
  const browserPromise = config.watch ? null : launchBrowser(config);
@@ -3226,7 +3324,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
3226
3324
  } else {
3227
3325
  const html = await readTemplate("setup/tests.hbs");
3228
3326
  cachedContent.mainHTML = { filePath: `${projectRoot}/test/tests.html`, html };
3229
- cachedContent.assets.add(`${projectRoot}/node_modules/qunitx/vendor/qunit.css`);
3327
+ cachedContent.assets.add(join3(resolveQunitxRoot(projectRoot), "vendor/qunit.css"));
3230
3328
  }
3231
3329
  return cachedContent;
3232
3330
  }
@@ -3299,12 +3397,18 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
3299
3397
  const currentDirectory = htmlPath ? htmlPath.split("/").slice(0, -1).join("/") : projectRoot;
3300
3398
  return assetPath.startsWith("./") ? normalize(`${currentDirectory}/${assetPath.slice(2)}`) : normalize(`${currentDirectory}/${assetPath}`);
3301
3399
  }
3400
+ function resolveQunitxRoot(projectRoot) {
3401
+ const mainEntry = createRequire2(`${projectRoot}/package.json`).resolve("qunitx");
3402
+ const match = /^(.*[\\/]qunitx)[\\/]/.exec(mainEntry);
3403
+ if (!match) throw new Error(`Could not derive qunitx root from ${mainEntry}`);
3404
+ return match[1];
3405
+ }
3302
3406
  var WATCH_NAV_TIMEOUT_MS, PAGE_CLOSE_GRACE_MS, STDOUT_FLUSH_GRACE_MS, KEEP_ALIVE_INTERVAL_MS, EXIT_CODE_SIGTERM;
3303
3407
  var init_run = __esm({
3304
3408
  "lib/commands/run.ts"() {
3305
3409
  init_browser();
3306
3410
  init_chrome_prelaunch();
3307
- init_http();
3411
+ init_web();
3308
3412
  init_bind_server_to_port();
3309
3413
  init_web_server();
3310
3414
  init_open_output_in_browser();
@@ -3338,7 +3442,7 @@ init_color();
3338
3442
  var package_default = {
3339
3443
  name: "qunitx-cli",
3340
3444
  type: "module",
3341
- version: "0.21.2",
3445
+ version: "0.22.0",
3342
3446
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
3343
3447
  author: "Izel Nakri",
3344
3448
  license: "MIT",
@@ -3395,8 +3499,11 @@ var package_default = {
3395
3499
  devDependencies: {
3396
3500
  "js-yaml": "^4.1.1",
3397
3501
  prettier: "^3.8.3",
3398
- qunitx: "^1.2.8",
3399
- typescript: "^6.0.3"
3502
+ qunitx: "^1.2.9",
3503
+ react: "^19.2.5",
3504
+ "react-dom": "^19.2.5",
3505
+ typescript: "^6.0.3",
3506
+ vue: "^3.5.33"
3400
3507
  },
3401
3508
  volta: {
3402
3509
  node: "24.14.0"
@@ -3432,7 +3539,7 @@ ${color("--timeout")} : change default timeout per test case
3432
3539
  ${color("--output")} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
3433
3540
  ${color("--failFast")} : run the target file or folders with immediate abort if a single test fails
3434
3541
  ${color("--port")} : HTTP server port (auto-selects a free port if the given port is taken)[default: 1234]
3435
- ${color("--extensions")} : comma-separated file extensions to track for discovery and watch-mode rebuilds[default: js,ts]
3542
+ ${color("--extensions")} : comma-separated file extensions to track for discovery and watch-mode rebuilds[default: js,ts,jsx,tsx]
3436
3543
  ${color("--browser")} : browser engine to run tests in: chromium, firefox, webkit[default: chromium]
3437
3544
  ${color("--before")} : run a script before the tests(i.e start a new web server before tests)
3438
3545
  ${color("--after")} : run a script after the tests(i.e save test results to a file)
@@ -3491,17 +3598,8 @@ async function findProjectRoot() {
3491
3598
  }
3492
3599
  }
3493
3600
 
3494
- // lib/setup/default-project-config-values.ts
3495
- var defaultProjectConfigValues = {
3496
- output: "tmp",
3497
- timeout: 2e4,
3498
- failFast: false,
3499
- port: 1234,
3500
- extensions: ["js", "ts"],
3501
- browser: "chromium"
3502
- };
3503
-
3504
3601
  // lib/commands/init.ts
3602
+ init_default_project_config_values();
3505
3603
  init_read_template();
3506
3604
  async function initializeProject() {
3507
3605
  const projectRoot = await findProjectRoot();
@@ -3565,12 +3663,6 @@ function convertToPascalCase(str) {
3565
3663
  }
3566
3664
 
3567
3665
  // lib/commands/generate.ts
3568
- function pathToModuleName(filePath) {
3569
- const withoutExt = filePath.replace(/\.(js|ts)$/, "");
3570
- const segments = withoutExt.split("/");
3571
- const targetNames = segments[0] === "test" || segments[0] === "tests" ? segments.slice(1) : segments;
3572
- return targetNames.map(convertToPascalCase).join(" | ");
3573
- }
3574
3666
  async function generateTestFiles() {
3575
3667
  const projectRoot = await findProjectRoot();
3576
3668
  const moduleName = pathToModuleName(process.argv[3]);
@@ -3586,37 +3678,25 @@ async function generateTestFiles() {
3586
3678
  await fs5.writeFile(path10, testJSContent.replace("{{moduleName}}", moduleName));
3587
3679
  console.log(green(`${path10} written`));
3588
3680
  }
3681
+ function pathToModuleName(filePath) {
3682
+ const withoutExt = filePath.replace(/\.(js|ts)$/, "");
3683
+ const segments = withoutExt.split("/");
3684
+ const targetNames = segments[0] === "test" || segments[0] === "tests" ? segments.slice(1) : segments;
3685
+ return targetNames.map(convertToPascalCase).join(" | ");
3686
+ }
3589
3687
 
3590
3688
  // lib/setup/config.ts
3689
+ init_default_project_config_values();
3591
3690
  import fs7 from "node:fs/promises";
3691
+ import { createRequire } from "node:module";
3692
+ import { pathToFileURL } from "node:url";
3592
3693
 
3593
3694
  // lib/setup/fs-tree.ts
3695
+ init_default_project_config_values();
3594
3696
  import fs6, { glob as fsGlob } from "node:fs/promises";
3595
3697
  import path3 from "node:path";
3596
- function isGlob(str) {
3597
- return /[*?{[]/.test(str);
3598
- }
3599
- async function readDirRecursive(dir, filter) {
3600
- const entries = await fs6.readdir(dir, { recursive: true, withFileTypes: true });
3601
- const candidates = entries.filter(
3602
- (dirent) => (dirent.isFile() || dirent.isSymbolicLink()) && filter(dirent.name)
3603
- );
3604
- const resolvedPaths = await Promise.all(
3605
- candidates.map(async (dirent) => {
3606
- const fullPath = path3.join(dirent.parentPath, dirent.name);
3607
- if (dirent.isFile()) return fullPath;
3608
- try {
3609
- const statResult = await fs6.stat(fullPath);
3610
- return statResult.isFile() ? fullPath : null;
3611
- } catch {
3612
- return null;
3613
- }
3614
- })
3615
- );
3616
- return resolvedPaths.filter((resolvedPath) => resolvedPath !== null);
3617
- }
3618
3698
  async function buildFSTree(fileAbsolutePaths, config = {}) {
3619
- const targetExtensions = config.extensions || ["js", "ts"];
3699
+ const targetExtensions = config.extensions || defaultProjectConfigValues.extensions;
3620
3700
  const fsTree = {};
3621
3701
  await Promise.all(
3622
3702
  fileAbsolutePaths.map(async (fileAbsolutePath) => {
@@ -3648,57 +3728,65 @@ async function buildFSTree(fileAbsolutePaths, config = {}) {
3648
3728
  );
3649
3729
  return fsTree;
3650
3730
  }
3651
-
3652
- // lib/setup/test-file-paths.ts
3653
- import { matchesGlob } from "node:path";
3654
- function isGlob2(str) {
3731
+ function isGlob(str) {
3655
3732
  return /[*?{[]/.test(str);
3656
3733
  }
3657
- function setupTestFilePaths(_projectRoot, inputs2) {
3658
- const [folders, filesWithGlob, filesWithoutGlob] = inputs2.reduce(
3659
- (result2, input) => {
3660
- const glob = isGlob2(input);
3661
- if (!pathIsFile(input)) {
3662
- result2[0].push({ input, isFile: false, isGlob: glob });
3663
- } else {
3664
- result2[glob ? 1 : 2].push({ input, isFile: true, isGlob: glob });
3734
+ async function readDirRecursive(dir, filter) {
3735
+ const entries = await fs6.readdir(dir, { recursive: true, withFileTypes: true });
3736
+ const candidates = entries.filter(
3737
+ (dirent) => (dirent.isFile() || dirent.isSymbolicLink()) && filter(dirent.name)
3738
+ );
3739
+ const resolvedPaths = await Promise.all(
3740
+ candidates.map(async (dirent) => {
3741
+ const fullPath = path3.join(dirent.parentPath, dirent.name);
3742
+ if (dirent.isFile()) return fullPath;
3743
+ try {
3744
+ const statResult = await fs6.stat(fullPath);
3745
+ return statResult.isFile() ? fullPath : null;
3746
+ } catch {
3747
+ return null;
3665
3748
  }
3666
- return result2;
3667
- },
3668
- [[], [], []]
3749
+ })
3669
3750
  );
3670
- const result = folders.reduce((folderResult, folder) => {
3671
- if (!pathIsIncludedInPaths(folders, folder)) {
3672
- folderResult.push(folder);
3673
- }
3674
- return folderResult;
3675
- }, []);
3676
- filesWithGlob.forEach((file) => {
3677
- if (!pathIsIncludedInPaths(result, file) && !pathIsIncludedInPaths(filesWithGlob, file)) {
3678
- result.push(file);
3751
+ return resolvedPaths.filter((resolvedPath) => resolvedPath !== null);
3752
+ }
3753
+
3754
+ // lib/setup/test-file-paths.ts
3755
+ import { matchesGlob } from "node:path";
3756
+ var GLOB_CHARS = /[*?{[]/;
3757
+ function setupTestFilePaths(inputs2) {
3758
+ const folders = [];
3759
+ const filesWithGlob = [];
3760
+ const filesWithoutGlob = [];
3761
+ inputs2.forEach((input) => {
3762
+ if (!pathIsFile(input)) {
3763
+ folders.push({ input, globFormat: `${input}/**` });
3764
+ } else if (isGlob2(input)) {
3765
+ filesWithGlob.push({ input, globFormat: input });
3766
+ } else {
3767
+ filesWithoutGlob.push({ input, globFormat: input });
3679
3768
  }
3680
3769
  });
3681
- filesWithoutGlob.forEach((file) => {
3682
- if (!pathIsIncludedInPaths(result, file)) {
3683
- result.push(file);
3770
+ const dedupedFolders = folders.filter((folder) => !isIncludedIn(folders, folder));
3771
+ const dedupedGlobFiles = filesWithGlob.filter(
3772
+ (file) => !isIncludedIn(dedupedFolders, file) && !isIncludedIn(filesWithGlob, file)
3773
+ );
3774
+ const dedupedPlainFiles = filesWithoutGlob.reduce((acc, file) => {
3775
+ if (!isIncludedIn(dedupedFolders, file) && !isIncludedIn(dedupedGlobFiles, file) && !isIncludedIn(acc, file)) {
3776
+ acc.push(file);
3684
3777
  }
3685
- });
3686
- return result.map((metaItem) => metaItem.input);
3778
+ return acc;
3779
+ }, []);
3780
+ return dedupedFolders.concat(dedupedGlobFiles, dedupedPlainFiles).map((meta) => meta.input);
3687
3781
  }
3688
3782
  function pathIsFile(path10) {
3689
- const inputs2 = path10.split("/");
3690
- return inputs2[inputs2.length - 1].includes(".");
3783
+ return path10.includes(".", path10.lastIndexOf("/") + 1);
3691
3784
  }
3692
- function pathIsIncludedInPaths(paths, targetPath) {
3693
- return paths.some((path10) => {
3694
- if (path10 === targetPath) {
3695
- return false;
3696
- }
3697
- return matchesGlob(targetPath.input, buildGlobFormat(path10));
3698
- });
3785
+ function isIncludedIn(paths, target) {
3786
+ return paths.some((path10) => path10 !== target && matchesGlob(target.input, path10.globFormat));
3699
3787
  }
3700
- function buildGlobFormat(path10) {
3701
- return path10.isFile ? path10.input : `${path10.input}/**`;
3788
+ function isGlob2(str) {
3789
+ return GLOB_CHARS.test(str);
3702
3790
  }
3703
3791
 
3704
3792
  // lib/utils/parse-cli-flags.ts
@@ -3793,15 +3881,17 @@ async function setupConfig() {
3793
3881
  const projectRoot = await findProjectRoot();
3794
3882
  const cliConfigFlags = parseCliFlags(projectRoot);
3795
3883
  const projectPackageJSON = await readConfigFromPackageJSON(projectRoot);
3884
+ const { plugins: rawPlugins, ...userQunitx } = projectPackageJSON.qunitx ?? {};
3885
+ const pluginsPromise = resolvePlugins(rawPlugins, projectRoot);
3796
3886
  const inputs2 = cliConfigFlags.inputs.concat(readInputsFromPackageJSON(projectPackageJSON));
3797
3887
  const config = {
3798
3888
  ...defaultProjectConfigValues,
3799
3889
  htmlPaths: [],
3800
- ...projectPackageJSON.qunitx || {},
3890
+ ...userQunitx,
3801
3891
  ...cliConfigFlags,
3802
3892
  projectRoot,
3803
3893
  inputs: inputs2,
3804
- testFileLookupPaths: setupTestFilePaths(projectRoot, inputs2),
3894
+ testFileLookupPaths: setupTestFilePaths(inputs2),
3805
3895
  lastFailedTestFiles: null,
3806
3896
  lastRanTestFiles: null,
3807
3897
  COUNTER: {
@@ -3818,7 +3908,10 @@ async function setupConfig() {
3818
3908
  _onTestsJsServed: null
3819
3909
  };
3820
3910
  config.htmlPaths = normalizeHTMLPaths(config.projectRoot, config.htmlPaths);
3821
- config.fsTree = await buildFSTree(config.testFileLookupPaths, config);
3911
+ [config.fsTree, config.plugins] = await Promise.all([
3912
+ buildFSTree(config.testFileLookupPaths, config),
3913
+ pluginsPromise
3914
+ ]);
3822
3915
  return config;
3823
3916
  }
3824
3917
  async function readConfigFromPackageJSON(projectRoot) {
@@ -3832,6 +3925,22 @@ function readInputsFromPackageJSON(packageJSON) {
3832
3925
  const qunitx = packageJSON.qunitx;
3833
3926
  return qunitx && qunitx.inputs ? qunitx.inputs : [];
3834
3927
  }
3928
+ function resolvePlugins(raw, projectRoot) {
3929
+ if (raw == null) return Promise.resolve([]);
3930
+ if (!Array.isArray(raw)) {
3931
+ console.error(`# qunitx: package.json#qunitx.plugins must be an array`);
3932
+ process.exit(1);
3933
+ }
3934
+ const projectRequire = createRequire(`${projectRoot}/package.json`);
3935
+ return Promise.all(
3936
+ raw.map(async (entry) => {
3937
+ const [spec, options] = Array.isArray(entry) ? entry : [entry];
3938
+ const mod = await import(pathToFileURL(projectRequire.resolve(spec)).href);
3939
+ const exported = mod.default ?? mod;
3940
+ return typeof exported === "function" ? exported(options) : exported;
3941
+ })
3942
+ );
3943
+ }
3835
3944
 
3836
3945
  // cli.ts
3837
3946
  process4.title = "qunitx";