qunitx-cli 0.11.0 → 0.16.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 +7 -0
  2. package/dist/cli.js +444 -212
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -188,6 +188,13 @@ All CLI flags can also be set in `package.json` under the `qunitx` key, so you d
188
188
 
189
189
  CLI flags always override `package.json` values when both are present.
190
190
 
191
+ ### Environment variables
192
+
193
+ | Variable | Description |
194
+ |------------------|---------------------------------------------------------------------------------------------------------------|
195
+ | `CHROME_BIN` | Path to the Chrome/Chromium executable. Required on systems where Chrome is not on `PATH` (e.g. many CI environments). Set automatically when using `browser-actions/setup-chrome` in GitHub Actions. |
196
+ | `QUNITX_BROWSER` | Browser engine to use (`chromium`, `firefox`, `webkit`). Equivalent to `--browser` on the CLI. Useful in CI matrix jobs. |
197
+
191
198
  If you do not provide any HTML template, qunitx falls back to its built-in `test/tests.html` boilerplate internally, so `qunitx init` is optional.
192
199
 
193
200
  You can also pass a custom HTML file on the CLI:
package/dist/cli.js CHANGED
@@ -39,26 +39,57 @@ var init_find_chrome = __esm({
39
39
 
40
40
  // lib/utils/pre-launch-chrome.ts
41
41
  import { spawn } from "node:child_process";
42
- function preLaunchChrome(chromePath, args, headless = true) {
43
- if (!chromePath) return Promise.resolve(null);
42
+ import { mkdtemp, rm } from "node:fs/promises";
43
+ import os from "node:os";
44
+ import path from "node:path";
45
+ async function preLaunchChrome(chromePath, args, headless = true) {
46
+ if (!chromePath) return null;
47
+ const userDataDir = await mkdtemp(path.join(os.tmpdir(), "qunitx-chrome-"));
48
+ const cleanup = () => rm(userDataDir, { recursive: true, force: true }).catch(() => {
49
+ });
44
50
  const headlessArgs = headless ? ["--headless=new"] : [];
51
+ const proc = spawn(
52
+ chromePath,
53
+ ["--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, ...headlessArgs, ...args],
54
+ { stdio: ["ignore", "ignore", "pipe"] }
55
+ );
56
+ proc.on("close", () => {
57
+ cleanup();
58
+ resolveWith(null);
59
+ });
60
+ proc.on("error", () => resolveWith(null));
61
+ let resolveWith;
45
62
  return new Promise((resolve) => {
46
- const proc = spawn(chromePath, ["--remote-debugging-port=0", ...headlessArgs, ...args], {
47
- stdio: ["ignore", "ignore", "pipe"]
48
- });
63
+ resolveWith = resolve;
49
64
  let buffer = "";
50
65
  proc.stderr.on("data", (chunk) => {
51
66
  buffer += chunk.toString();
52
67
  const match = buffer.match(CDP_URL_REGEX);
53
- if (match) {
54
- proc.unref();
55
- proc.stderr.unref();
56
- resolve({ proc, cdpEndpoint: match[1] });
57
- }
68
+ if (!match) return;
69
+ proc.unref();
70
+ proc.stderr.unref();
71
+ resolve({
72
+ proc,
73
+ cdpEndpoint: match[1],
74
+ shutdown
75
+ });
58
76
  });
59
- proc.on("error", () => resolve(null));
60
- proc.on("close", () => resolve(null));
61
77
  });
78
+ async function shutdown() {
79
+ proc.ref();
80
+ const closed = new Promise((resolve) => {
81
+ if (proc.exitCode !== null) {
82
+ resolve();
83
+ return;
84
+ }
85
+ proc.once("close", resolve);
86
+ });
87
+ try {
88
+ if (proc.exitCode === null) proc.kill("SIGKILL");
89
+ } catch {
90
+ }
91
+ await closed.then(() => cleanup());
92
+ }
62
93
  }
63
94
  var CDP_URL_REGEX;
64
95
  var init_pre_launch_chrome = __esm({
@@ -72,21 +103,73 @@ var chromium_args_default;
72
103
  var init_chromium_args = __esm({
73
104
  "lib/utils/chromium-args.ts"() {
74
105
  chromium_args_default = [
106
+ // ── Sandbox / rendering ──────────────────────────────────────────────────────
75
107
  "--no-sandbox",
108
+ // required in most CI/container environments
76
109
  "--disable-gpu",
110
+ // no GPU in headless; avoids GPU process startup
111
+ // ── Window / UI ──────────────────────────────────────────────────────────────
77
112
  "--window-size=1440,900",
78
- "--disable-extensions",
79
- "--disable-sync",
113
+ "--hide-scrollbars",
114
+ // no scrollbar rendering overhead
115
+ // ── Automation markers ────────────────────────────────────────────────────────
116
+ "--enable-automation",
117
+ // sets navigator.webdriver=true; disables some UX-only overhead
118
+ "--no-default-browser-check",
119
+ // skip the OS-level "set as default" check on startup
80
120
  "--no-first-run",
81
- "--disable-default-apps",
82
- "--mute-audio",
121
+ // skip first-run wizard
122
+ // ── Network ───────────────────────────────────────────────────────────────────
83
123
  "--disable-background-networking",
124
+ "--disable-sync",
125
+ "--disable-translate",
126
+ // ── Extensions / apps ─────────────────────────────────────────────────────────
127
+ "--disable-extensions",
128
+ "--disable-default-apps",
129
+ "--disable-component-update",
130
+ // no background update checks
131
+ "--disable-field-trial-config",
132
+ // no A/B experiment config fetches at startup
133
+ // ── Crash / diagnostics ───────────────────────────────────────────────────────
134
+ "--disable-breakpad",
135
+ // no crash reporter process spawned
136
+ "--disable-client-side-phishing-detection",
137
+ // no ML model loaded on startup
138
+ "--metrics-recording-only",
139
+ "--disable-hang-monitor",
140
+ // ── Timers / scheduling ───────────────────────────────────────────────────────
84
141
  "--disable-background-timer-throttling",
85
142
  "--disable-renderer-backgrounding",
143
+ // ── Memory ───────────────────────────────────────────────────────────────────
86
144
  "--disable-dev-shm-usage",
87
- "--disable-translate",
88
- "--metrics-recording-only",
89
- "--disable-hang-monitor"
145
+ // write to /tmp instead; avoids shm exhaustion with many Chromes
146
+ // ── Navigation ───────────────────────────────────────────────────────────────
147
+ "--disable-back-forward-cache",
148
+ // no BFCache state setup; qunitx never navigates back
149
+ // ── Audio ─────────────────────────────────────────────────────────────────────
150
+ "--mute-audio",
151
+ // ── Keychain / credentials ────────────────────────────────────────────────────
152
+ "--password-store=basic",
153
+ // avoids dbus/kwallet stalls on Linux
154
+ "--use-mock-keychain",
155
+ // avoids system keychain calls on macOS
156
+ // ── Feature flags ────────────────────────────────────────────────────────────
157
+ //
158
+ // Only features that are invisible to user test code are disabled here.
159
+ //
160
+ // PaintHolding — Chrome delays first paint by up to 500ms to prevent flash-of-
161
+ // unstyled-content. Pure dead time in headless; disabling it makes
162
+ // every page load return faster.
163
+ // HttpsUpgrades — Prevents Chrome from silently upgrading HTTP→HTTPS. Critical:
164
+ // qunitx's local test server runs on HTTP; an upgrade attempt would
165
+ // cause the connection to fail.
166
+ // DestroyProfileOnBrowserClose — avoids async profile teardown on exit.
167
+ // DialMediaRouteProvider, GlobalMediaControls, LensOverlay, MediaRouter — UI chrome with no
168
+ // test relevance.
169
+ // OptimizationHints — background network requests for Chrome's optimization service.
170
+ // Translate — translation UI.
171
+ // AvoidUnnecessaryBeforeUnloadCheckSync — reduces beforeunload handler overhead.
172
+ "--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,OptimizationHints,PaintHolding,Translate"
90
173
  ];
91
174
  }
92
175
  });
@@ -108,7 +191,13 @@ var init_perf_logger = __esm({
108
191
  });
109
192
 
110
193
  // lib/utils/early-chrome.ts
111
- var NON_RUN_COMMANDS, isRunCommand, browserFromArgv, openFromArgv, watchFromArgv, openWatchMode, earlyChromeProcRef, earlyBrowserPromise;
194
+ async function shutdownEarlyBrowser() {
195
+ if (!earlyChrome) return;
196
+ const { shutdown } = earlyChrome;
197
+ earlyChrome = null;
198
+ await shutdown();
199
+ }
200
+ var NON_RUN_COMMANDS, isRunCommand, browserFromArgv, openFromArgv, watchFromArgv, openWatchMode, earlyChrome, earlyBrowserPromise;
112
201
  var init_early_chrome = __esm({
113
202
  "lib/utils/early-chrome.ts"() {
114
203
  init_find_chrome();
@@ -127,12 +216,12 @@ var init_early_chrome = __esm({
127
216
  { browserFromArgv: "chromium", openFromArgv: false, watchFromArgv: false }
128
217
  ));
129
218
  openWatchMode = openFromArgv && watchFromArgv;
130
- earlyChromeProcRef = null;
219
+ earlyChrome = null;
131
220
  if (!openWatchMode) {
132
221
  process.on("exit", () => {
133
- if (!earlyChromeProcRef) return;
222
+ if (!earlyChrome) return;
134
223
  try {
135
- earlyChromeProcRef.kill("SIGKILL");
224
+ earlyChrome.proc.kill("SIGKILL");
136
225
  } catch {
137
226
  }
138
227
  });
@@ -143,7 +232,7 @@ var init_early_chrome = __esm({
143
232
  return preLaunchChrome(chromePath, chromium_args_default, !openWatchMode);
144
233
  }).then((info) => {
145
234
  perfLog("early-chrome.js: Chrome CDP ready", info?.cdpEndpoint ?? null);
146
- if (info) earlyChromeProcRef = info.proc;
235
+ if (info) earlyChrome = info;
147
236
  return info;
148
237
  }) : Promise.resolve(null);
149
238
  }
@@ -151,45 +240,47 @@ var init_early_chrome = __esm({
151
240
 
152
241
  // lib/utils/color.ts
153
242
  function createColors(enabled2) {
154
- const c = (open, close) => (text) => enabled2 ? `\x1B[${open}m${text}\x1B[${close}m` : String(text);
155
- const red2 = c(31, 39);
156
- const green2 = c(32, 39);
157
- const yellow2 = c(33, 39);
158
- const blue2 = c(34, 39);
243
+ const makeColor = (open, close) => (text) => enabled2 ? `\x1B[${open}m${text}\x1B[${close}m` : String(text);
244
+ const red2 = makeColor(31, 39);
245
+ const green2 = makeColor(32, 39);
246
+ const yellow2 = makeColor(33, 39);
247
+ const blue2 = makeColor(34, 39);
159
248
  const magenta2 = ((text) => {
160
249
  if (text !== void 0) return enabled2 ? `\x1B[35m${text}\x1B[39m` : String(text);
161
- return { bold: (t) => enabled2 ? `\x1B[35m\x1B[1m${t}\x1B[22m\x1B[39m` : String(t) };
250
+ return {
251
+ bold: (boldText) => enabled2 ? `\x1B[35m\x1B[1m${boldText}\x1B[22m\x1B[39m` : String(boldText)
252
+ };
162
253
  });
163
254
  return { red: red2, green: green2, yellow: yellow2, blue: blue2, magenta: magenta2 };
164
255
  }
165
256
  function red(text) {
166
- return _c.red(text);
257
+ return colors.red(text);
167
258
  }
168
259
  function green(text) {
169
- return _c.green(text);
260
+ return colors.green(text);
170
261
  }
171
262
  function yellow(text) {
172
- return _c.yellow(text);
263
+ return colors.yellow(text);
173
264
  }
174
265
  function blue(text) {
175
- return _c.blue(text);
266
+ return colors.blue(text);
176
267
  }
177
268
  function magenta(text) {
178
- return _c.magenta(text);
269
+ return colors.magenta(text);
179
270
  }
180
- var enabled, _c;
271
+ var enabled, colors;
181
272
  var init_color = __esm({
182
273
  "lib/utils/color.ts"() {
183
274
  enabled = !process.env.NODE_DISABLE_COLORS && process.env.NO_COLOR == null && process.env.TERM !== "dumb" && (process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== "0" || !!process.stdout?.isTTY);
184
- _c = createColors(enabled);
275
+ colors = createColors(enabled);
185
276
  }
186
277
  });
187
278
 
188
279
  // lib/utils/path-exists.ts
189
280
  import fs from "node:fs/promises";
190
- async function pathExists(path5) {
281
+ async function pathExists(path6) {
191
282
  try {
192
- await fs.access(path5);
283
+ await fs.access(path6);
193
284
  return true;
194
285
  } catch {
195
286
  return false;
@@ -225,8 +316,8 @@ var init_read_boilerplate = __esm({
225
316
 
226
317
  // lib/utils/find-internal-assets-from-html.ts
227
318
  function findInternalAssetsFromHTML(htmlContent) {
228
- const links = [...htmlContent.matchAll(LINK_HREF_REGEX)].map((m) => m[1]).filter((uri) => !ABSOLUTE_URL_REGEX.test(uri));
229
- const scripts = [...htmlContent.matchAll(SCRIPT_SRC_REGEX)].map((m) => m[1]).filter((uri) => !ABSOLUTE_URL_REGEX.test(uri));
319
+ const links = [...htmlContent.matchAll(LINK_HREF_REGEX)].map((match) => match[1]).filter((uri) => !ABSOLUTE_URL_REGEX.test(uri));
320
+ const scripts = [...htmlContent.matchAll(SCRIPT_SRC_REGEX)].map((match) => match[1]).filter((uri) => !ABSOLUTE_URL_REGEX.test(uri));
230
321
  return links.concat(scripts);
231
322
  }
232
323
  var ABSOLUTE_URL_REGEX, SCRIPT_SRC_REGEX, LINK_HREF_REGEX;
@@ -288,17 +379,17 @@ function dumpValue(value, indent) {
288
379
  if (Array.isArray(value)) {
289
380
  if (value.length === 0) return "[]";
290
381
  const next2 = `${indent} `;
291
- return "\n" + value.map((v) => `${next2}- ${dumpValue(v, next2)}`).join("\n");
382
+ return "\n" + value.map((item) => `${next2}- ${dumpValue(item, next2)}`).join("\n");
292
383
  }
293
384
  const entries = Object.entries(value);
294
385
  if (entries.length === 0) return "{}";
295
386
  const next = `${indent} `;
296
- return "\n" + entries.map(([k, v]) => `${next}${k}: ${dumpValue(v, next)}`).join("\n");
387
+ return "\n" + entries.map(([entryKey, entryValue]) => `${next}${entryKey}: ${dumpValue(entryValue, next)}`).join("\n");
297
388
  }
298
389
  function yamlLine(key, value) {
299
- const v = dumpValue(value, "");
300
- return v[0] === "\n" ? `${key}:${v}
301
- ` : `${key}: ${v}
390
+ const serialized = dumpValue(value, "");
391
+ return serialized[0] === "\n" ? `${key}:${serialized}
392
+ ` : `${key}: ${serialized}
302
393
  `;
303
394
  }
304
395
  function dumpYaml({
@@ -404,23 +495,31 @@ var init_display_test_result = __esm({
404
495
  // lib/setup/bind-server-to-port.ts
405
496
  async function bindServerToPort(server, config) {
406
497
  let port = config.port;
498
+ let attempt = 0;
407
499
  while (true) {
408
500
  try {
409
501
  await server.listen(port);
410
502
  break;
411
503
  } catch (err) {
412
- if (err.code === "EADDRINUSE" && !config.portExplicit) {
504
+ const isEADDRINUSE = err.code === "EADDRINUSE";
505
+ if (!isEADDRINUSE) throw err;
506
+ if (config.portExplicit) {
507
+ if (attempt >= EXPLICIT_PORT_RETRIES) throw err;
508
+ attempt++;
509
+ await new Promise((resolve) => setTimeout(resolve, EXPLICIT_PORT_RETRY_DELAY_MS));
510
+ } else {
413
511
  port++;
414
- continue;
415
512
  }
416
- throw err;
417
513
  }
418
514
  }
419
515
  config.port = server._server.address().port;
420
516
  return server;
421
517
  }
518
+ var EXPLICIT_PORT_RETRIES, EXPLICIT_PORT_RETRY_DELAY_MS;
422
519
  var init_bind_server_to_port = __esm({
423
520
  "lib/setup/bind-server-to-port.ts"() {
521
+ EXPLICIT_PORT_RETRIES = 5;
522
+ EXPLICIT_PORT_RETRY_DELAY_MS = 20;
424
523
  }
425
524
  });
426
525
 
@@ -516,8 +615,8 @@ var init_http = __esm({
516
615
  return new Promise((resolve) => this._server.close(resolve));
517
616
  }
518
617
  /** Registers a GET route handler. */
519
- get(path5, handler) {
520
- this.#registerRouteHandler("GET", path5, handler);
618
+ get(path6, handler) {
619
+ this.#registerRouteHandler("GET", path6, handler);
521
620
  }
522
621
  /**
523
622
  * Starts listening on the given port (0 = OS-assigned).
@@ -548,30 +647,30 @@ var init_http = __esm({
548
647
  });
549
648
  }
550
649
  /** Registers a POST route handler. */
551
- post(path5, handler) {
552
- this.#registerRouteHandler("POST", path5, handler);
650
+ post(path6, handler) {
651
+ this.#registerRouteHandler("POST", path6, handler);
553
652
  }
554
653
  /** Registers a DELETE route handler. */
555
- delete(path5, handler) {
556
- this.#registerRouteHandler("DELETE", path5, handler);
654
+ delete(path6, handler) {
655
+ this.#registerRouteHandler("DELETE", path6, handler);
557
656
  }
558
657
  /** Registers a PUT route handler. */
559
- put(path5, handler) {
560
- this.#registerRouteHandler("PUT", path5, handler);
658
+ put(path6, handler) {
659
+ this.#registerRouteHandler("PUT", path6, handler);
561
660
  }
562
661
  /** Adds a middleware function to the chain. */
563
662
  use(middleware) {
564
663
  this.middleware.push(middleware);
565
664
  }
566
- #registerRouteHandler(method, path5, handler) {
665
+ #registerRouteHandler(method, path6, handler) {
567
666
  if (!this.routes[method]) {
568
667
  this.routes[method] = {};
569
668
  }
570
- this.routes[method][path5] = {
571
- path: path5,
669
+ this.routes[method][path6] = {
670
+ path: path6,
572
671
  handler,
573
- paramNames: this.#extractParamNames(path5),
574
- isWildcard: path5 === "/*"
672
+ paramNames: this.#extractParamNames(path6),
673
+ isWildcard: path6 === "/*"
575
674
  };
576
675
  }
577
676
  #handleRequest(req, res) {
@@ -609,13 +708,13 @@ var init_http = __esm({
609
708
  return null;
610
709
  }
611
710
  return routes[url] || Object.values(routes).find((route) => {
612
- const { path: path5, isWildcard } = route;
613
- if (!isWildcard && !path5.includes(":")) {
711
+ const { path: path6, isWildcard } = route;
712
+ if (!isWildcard && !path6.includes(":")) {
614
713
  return false;
615
714
  }
616
- if (isWildcard || this.#matchPathSegments(path5, url)) {
715
+ if (isWildcard || this.#matchPathSegments(path6, url)) {
617
716
  if (route.paramNames.length > 0) {
618
- const regexPattern = this.#buildRegexPattern(path5, route.paramNames);
717
+ const regexPattern = this.#buildRegexPattern(path6, route.paramNames);
619
718
  const regex = new RegExp(`^${regexPattern}$`);
620
719
  const regexMatches = regex.exec(url);
621
720
  if (regexMatches) {
@@ -627,8 +726,8 @@ var init_http = __esm({
627
726
  return false;
628
727
  }) || routes["/*"] || null;
629
728
  }
630
- #matchPathSegments(path5, url) {
631
- const pathSegments = path5.split("/");
729
+ #matchPathSegments(path6, url) {
730
+ const pathSegments = path6.split("/");
632
731
  const urlSegments = url.split("/");
633
732
  if (pathSegments.length !== urlSegments.length) {
634
733
  return false;
@@ -645,14 +744,14 @@ var init_http = __esm({
645
744
  }
646
745
  return true;
647
746
  }
648
- #buildRegexPattern(path5, _paramNames) {
649
- let regexPattern = path5.replace(/:[^/]+/g, "([^/]+)");
747
+ #buildRegexPattern(path6, _paramNames) {
748
+ let regexPattern = path6.replace(/:[^/]+/g, "([^/]+)");
650
749
  regexPattern = regexPattern.replace(/\//g, "\\/");
651
750
  return regexPattern;
652
751
  }
653
- #extractParamNames(path5) {
752
+ #extractParamNames(path6) {
654
753
  const paramRegex = /:(\w+)/g;
655
- const paramMatches = path5.match(paramRegex);
754
+ const paramMatches = path6.match(paramRegex);
656
755
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
657
756
  }
658
757
  #extractParams(route, _url) {
@@ -669,9 +768,9 @@ var init_http = __esm({
669
768
 
670
769
  // lib/setup/web-server.ts
671
770
  import fs7 from "node:fs";
672
- import path3 from "node:path";
771
+ import path4 from "node:path";
673
772
  function setupWebServer(config, cachedContent) {
674
- const STATIC_FILES_PATH = path3.join(config.projectRoot, config.output);
773
+ const STATIC_FILES_PATH = path4.join(config.projectRoot, config.output);
675
774
  const server = new HTTPServer();
676
775
  const mainHTMLWithReplacedAssets = replaceAssetPaths(
677
776
  cachedContent.mainHTML.html,
@@ -682,17 +781,40 @@ function setupWebServer(config, cachedContent) {
682
781
  socket.on("message", function message(data) {
683
782
  const { event, details, abort } = JSON.parse(data);
684
783
  if (event === "wsOpen") {
784
+ config._phase = "loading";
685
785
  config._onWsOpen?.();
686
786
  } else if (event === "connection") {
787
+ config._phase = "running";
687
788
  if (!config._groupMode) console.log("TAP version 13");
789
+ if (config.debug && config._groupMode) {
790
+ const allFiles = Object.keys(config.fsTree);
791
+ const relFiles = allFiles.map(
792
+ (filePath) => filePath.replace(`${config.projectRoot}/`, "")
793
+ );
794
+ const shown = relFiles.slice(0, 2);
795
+ const rest = relFiles.length - shown.length;
796
+ const fileList = rest > 0 ? `${shown.join(" ")} +${rest} more` : shown.join(" ");
797
+ console.log("#", blue(`\u2500\u2500 ${fileList} \u2500\u2500`));
798
+ }
688
799
  config._resetTestTimeout?.();
689
800
  } else if (event === "testEnd" && !abort) {
690
801
  if (details.status === "failed") {
691
802
  config.lastFailedTestFiles = config.lastRanTestFiles;
692
803
  }
804
+ if (config.debug && details.runtime > config.timeout * 0.8) {
805
+ console.log(
806
+ `# SLOW (${details.runtime.toFixed(0)}ms / ${config.timeout}ms timeout): ${details.fullName.join(" | ")}`
807
+ );
808
+ }
693
809
  config._resetTestTimeout?.();
694
810
  TAPDisplayTestResult(config.COUNTER, details);
695
811
  } else if (event === "done") {
812
+ config._phase = "done";
813
+ if (config.debug && config._groupMode) {
814
+ console.log(
815
+ `# group done: ${details.passed} passed, ${details.failed} failed (${details.runtime}ms)`
816
+ );
817
+ }
696
818
  if (typeof config._testRunDone === "function") {
697
819
  config._testRunDone();
698
820
  config._testRunDone = null;
@@ -784,7 +906,7 @@ function setupWebServer(config, cachedContent) {
784
906
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
785
907
  const statusCode = await pathExists(filePath) ? 200 : 404;
786
908
  res.writeHead(statusCode, {
787
- "Content-Type": req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path3.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html
909
+ "Content-Type": req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html
788
910
  });
789
911
  if (statusCode === 404) {
790
912
  res.end();
@@ -799,7 +921,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
799
921
  const assetPaths = findInternalAssetsFromHTML(html);
800
922
  const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
801
923
  return assetPaths.reduce((result, assetPath) => {
802
- const normalizedFullAbsolutePath = path3.normalize(`${htmlDirectory}/${assetPath}`);
924
+ const normalizedFullAbsolutePath = path4.normalize(`${htmlDirectory}/${assetPath}`);
803
925
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
804
926
  }, html);
805
927
  }
@@ -966,6 +1088,7 @@ var init_web_server = __esm({
966
1088
  init_find_internal_assets_from_html();
967
1089
  init_html_content_marker();
968
1090
  init_display_test_result();
1091
+ init_color();
969
1092
  init_path_exists();
970
1093
  init_http();
971
1094
  fsPromise = fs7.promises;
@@ -977,18 +1100,18 @@ async function launchBrowser(config) {
977
1100
  const browserName = config.browser || "chromium";
978
1101
  if (browserName === "chromium") {
979
1102
  const waitStart = Date.now();
980
- const [playwrightCore2, earlyChrome] = await Promise.all([
1103
+ const [playwrightCore2, earlyChrome2] = await Promise.all([
981
1104
  playwrightCorePromise,
982
1105
  earlyBrowserPromise
983
1106
  ]);
984
1107
  perfLog(
985
1108
  `browser.js: playwright-core + earlyChrome resolved in ${Date.now() - waitStart}ms, earlyChrome:`,
986
- earlyChrome?.cdpEndpoint ?? null
1109
+ earlyChrome2?.cdpEndpoint ?? null
987
1110
  );
988
- if (earlyChrome) {
1111
+ if (earlyChrome2) {
989
1112
  const connectStart = Date.now();
990
1113
  const browser = await playwrightCore2.chromium.connectOverCDP({
991
- endpointURL: earlyChrome.cdpEndpoint
1114
+ endpointURL: earlyChrome2.cdpEndpoint
992
1115
  });
993
1116
  perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
994
1117
  return browser;
@@ -1122,7 +1245,7 @@ async function runUserModule(modulePath, params, scriptPosition) {
1122
1245
  console.log("#", red(`QUnitX ${scriptPosition} script failed:`));
1123
1246
  console.trace(error);
1124
1247
  console.error(error);
1125
- return process.exit(1);
1248
+ process.stdout.write("", () => process.exit(1));
1126
1249
  }
1127
1250
  }
1128
1251
  var init_run_user_module = __esm({
@@ -1158,25 +1281,29 @@ async function buildTestBundle(config, cachedContent) {
1158
1281
  return;
1159
1282
  }
1160
1283
  const outfile = `${projectRoot}/${output}/tests.js`;
1161
- await Promise.all([
1284
+ const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1285
+ const needsDisk = true;
1286
+ const [allTestCode] = await Promise.all([
1162
1287
  buildWithOverlayfsRetry(
1163
1288
  {
1164
1289
  stdin: {
1165
- contents: allTestFilePaths.map((f) => `import "${f}";`).join(""),
1290
+ contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
1166
1291
  resolveDir: process.cwd()
1167
1292
  },
1168
1293
  bundle: true,
1169
1294
  logLevel: "error",
1170
1295
  outfile,
1171
1296
  keepNames: true,
1172
- sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1297
+ legalComments: "none",
1298
+ target: esbuildTarget(config.browser),
1299
+ sourcemap,
1173
1300
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
1174
1301
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
1175
1302
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
1176
1303
  // all browsers and does not require changes to user test code.
1177
1304
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1178
1305
  },
1179
- outfile
1306
+ needsDisk
1180
1307
  ),
1181
1308
  Promise.all(
1182
1309
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
@@ -1188,7 +1315,7 @@ async function buildTestBundle(config, cachedContent) {
1188
1315
  })
1189
1316
  )
1190
1317
  ]);
1191
- cachedContent.allTestCode = await fs8.readFile(outfile);
1318
+ cachedContent.allTestCode = allTestCode;
1192
1319
  }
1193
1320
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1194
1321
  const { projectRoot, output } = config;
@@ -1207,8 +1334,11 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1207
1334
  }
1208
1335
  if (runHasFilter) {
1209
1336
  const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
1210
- await buildFilteredTests(targetTestFilesToFilter, outputPath, config);
1211
- cachedContent.filteredTestCode = (await fs8.readFile(outputPath)).toString();
1337
+ cachedContent.filteredTestCode = await buildFilteredTests(
1338
+ targetTestFilesToFilter,
1339
+ outputPath,
1340
+ config
1341
+ );
1212
1342
  }
1213
1343
  const TIME_COUNTER = timeCounter();
1214
1344
  if (runHasFilter) {
@@ -1231,6 +1361,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1231
1361
  connections.server && connections.server.close(),
1232
1362
  connections.browser && connections.browser.close()
1233
1363
  ]);
1364
+ await shutdownEarlyBrowser();
1234
1365
  return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
1235
1366
  }
1236
1367
  }
@@ -1247,42 +1378,60 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1247
1378
  return connections;
1248
1379
  }
1249
1380
  function buildFilteredTests(filteredTests, outputPath, config) {
1381
+ const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1382
+ const needsDisk = sourcemap === "linked" || Boolean(config.open);
1250
1383
  return buildWithOverlayfsRetry(
1251
1384
  {
1252
1385
  stdin: {
1253
- contents: filteredTests.map((f) => `import "${f}";`).join(""),
1386
+ contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
1254
1387
  resolveDir: process.cwd()
1255
1388
  },
1256
1389
  bundle: true,
1257
1390
  logLevel: "error",
1258
1391
  outfile: outputPath,
1259
- sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1392
+ legalComments: "none",
1393
+ target: esbuildTarget(config.browser),
1394
+ sourcemap,
1260
1395
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1261
1396
  },
1262
- outputPath
1397
+ needsDisk
1263
1398
  );
1264
1399
  }
1265
- async function buildWithOverlayfsRetry(options, outfile) {
1400
+ async function buildWithOverlayfsRetry(options, needsDisk) {
1266
1401
  const RETRY_DELAY_MS = 100;
1267
1402
  const MAX_RETRIES = 3;
1268
1403
  const EMPTY_BUNDLE_THRESHOLD = 500;
1269
- let result = await esbuild.build(options);
1404
+ const buildOpts = { ...options, write: false };
1405
+ const getContents = async () => {
1406
+ const result2 = await esbuild.build(buildOpts);
1407
+ const jsFile = result2.outputFiles.find((outputFile) => !outputFile.path.endsWith(".map"));
1408
+ return { result: result2, js: Buffer.from(jsFile.contents) };
1409
+ };
1410
+ let { result, js } = await getContents();
1270
1411
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
1271
- const bytes2 = (await fs8.stat(outfile)).size;
1272
- if (bytes2 >= EMPTY_BUNDLE_THRESHOLD) return result;
1412
+ if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
1273
1413
  console.log(
1274
- `# [buildWithOverlayfsRetry] bundle is ${bytes2} bytes (< ${EMPTY_BUNDLE_THRESHOLD}) on attempt ${retry}/${MAX_RETRIES} \u2014 overlayfs flush race, retrying in ${RETRY_DELAY_MS}ms`
1414
+ `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes (< ${EMPTY_BUNDLE_THRESHOLD}) on attempt ${retry}/${MAX_RETRIES} \u2014 overlayfs flush race, retrying in ${RETRY_DELAY_MS}ms`
1275
1415
  );
1276
1416
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
1277
- result = await esbuild.build(options);
1417
+ ({ result, js } = await getContents());
1278
1418
  }
1279
- const bytes = (await fs8.stat(outfile)).size;
1280
- if (bytes < EMPTY_BUNDLE_THRESHOLD) {
1419
+ if (js.length < EMPTY_BUNDLE_THRESHOLD) {
1281
1420
  console.log(
1282
- `# [buildWithOverlayfsRetry] bundle is ${bytes} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1421
+ `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1283
1422
  );
1284
1423
  }
1285
- return result;
1424
+ if (needsDisk) {
1425
+ await Promise.all(
1426
+ result.outputFiles.map((outputFile) => fs8.writeFile(outputFile.path, outputFile.contents))
1427
+ );
1428
+ }
1429
+ return js;
1430
+ }
1431
+ function esbuildTarget(browser) {
1432
+ if (browser === "firefox") return ["firefox115"];
1433
+ if (browser === "webkit") return ["safari16"];
1434
+ return ["chrome120"];
1286
1435
  }
1287
1436
  async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
1288
1437
  let QUNIT_RESULT;
@@ -1291,6 +1440,9 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1291
1440
  let wsConnected = false;
1292
1441
  try {
1293
1442
  console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
1443
+ const navMs = config.timeout + 1e4;
1444
+ const startupMs = Math.max(config.timeout * 3, navMs);
1445
+ const testsJsMs = Math.max(config.timeout * 4, navMs);
1294
1446
  let resolveTestRace;
1295
1447
  const testRaceResult = new Promise((resolve) => {
1296
1448
  resolveTestRace = resolve;
@@ -1299,11 +1451,11 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1299
1451
  config._onWsOpen = () => {
1300
1452
  wsConnected = true;
1301
1453
  clearTimeout(timeoutHandle);
1302
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1454
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
1303
1455
  };
1304
1456
  config._onTestsJsServed = () => {
1305
1457
  clearTimeout(timeoutHandle);
1306
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 4);
1458
+ timeoutHandle = setTimeout(resolveTestRace, testsJsMs);
1307
1459
  };
1308
1460
  config._resetTestTimeout = () => {
1309
1461
  wsConnected = true;
@@ -1311,14 +1463,14 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1311
1463
  timeoutHandle = setTimeout(resolveTestRace, config.timeout);
1312
1464
  };
1313
1465
  const targetUrl = `http://localhost:${config.port}${filePath}`;
1314
- const navOptions = { timeout: config.timeout + 1e4, waitUntil: "commit" };
1466
+ const navOptions = { timeout: navMs, waitUntil: "commit" };
1315
1467
  if (page.url().split("?")[0] === targetUrl) {
1316
1468
  await page.reload(navOptions);
1317
1469
  } else {
1318
1470
  await page.goto(targetUrl, navOptions);
1319
1471
  }
1320
1472
  clearTimeout(timeoutHandle);
1321
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1473
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
1322
1474
  await testRaceResult;
1323
1475
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
1324
1476
  } catch (error) {
@@ -1360,6 +1512,7 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
1360
1512
  connections.server && connections.server.close(),
1361
1513
  connections.browser && connections.browser.close()
1362
1514
  ]);
1515
+ await shutdownEarlyBrowser();
1363
1516
  process.exit(1);
1364
1517
  }
1365
1518
  }
@@ -1367,6 +1520,7 @@ var BundleError;
1367
1520
  var init_tests_in_browser = __esm({
1368
1521
  "lib/commands/run/tests-in-browser.ts"() {
1369
1522
  init_color();
1523
+ init_early_chrome();
1370
1524
  init_time_counter();
1371
1525
  init_run_user_module();
1372
1526
  init_display_final_result();
@@ -1382,70 +1536,81 @@ var init_tests_in_browser = __esm({
1382
1536
 
1383
1537
  // lib/setup/file-watcher.ts
1384
1538
  import fs9 from "node:fs";
1385
- import { stat } from "node:fs/promises";
1386
- import path4 from "node:path";
1539
+ import { stat, lstat } from "node:fs/promises";
1540
+ import path5 from "node:path";
1387
1541
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1388
1542
  const extensions = config.extensions || ["js", "ts"];
1389
1543
  const readyPromises = [];
1390
1544
  const parentWatchers = [];
1391
- const fileWatchers = testFileLookupPaths.reduce((watchers, watchPath) => {
1545
+ const fileWatchers = {};
1546
+ const symlinkPollers = /* @__PURE__ */ new Map();
1547
+ function trackSymlink(filePath) {
1548
+ if (symlinkPollers.has(filePath)) return;
1549
+ const handler = (curr) => {
1550
+ if (curr.nlink === 0) {
1551
+ fs9.unwatchFile(filePath, handler);
1552
+ symlinkPollers.delete(filePath);
1553
+ if (filePath in config.fsTree) {
1554
+ handleWatchEvent(config, extensions, "unlink", filePath, onEventFunc, onFinishFunc);
1555
+ }
1556
+ }
1557
+ };
1558
+ fs9.watchFile(filePath, { interval: 500, persistent: false }, handler);
1559
+ symlinkPollers.set(filePath, () => fs9.unwatchFile(filePath, handler));
1560
+ }
1561
+ function untrackSymlink(filePath) {
1562
+ symlinkPollers.get(filePath)?.();
1563
+ symlinkPollers.delete(filePath);
1564
+ }
1565
+ for (const watchPath of testFileLookupPaths) {
1392
1566
  let ready = false;
1393
1567
  const lastChangeMs = {};
1394
- const CHANGE_DEDUPE_MS = 30;
1395
1568
  const childWatcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1396
1569
  if (!ready || !filename) return;
1397
- const fullPath = path4.join(watchPath, filename);
1570
+ const fullPath = path5.join(watchPath, filename);
1398
1571
  if (eventType === "change") {
1399
1572
  if (!config._building) {
1400
1573
  const now = Date.now();
1401
- if (now - (lastChangeMs[fullPath] ?? 0) < CHANGE_DEDUPE_MS) return;
1574
+ const last = lastChangeMs[fullPath] ?? 0;
1575
+ if (now - last < CHANGE_DEDUPE_MS) {
1576
+ if (!config._lastBuildEndMs || config._lastBuildEndMs <= last) return;
1577
+ }
1402
1578
  lastChangeMs[fullPath] = now;
1403
1579
  }
1404
1580
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
1405
1581
  }
1406
- try {
1407
- const s = await stat(fullPath);
1408
- handleWatchEvent(
1409
- config,
1410
- extensions,
1411
- s.isDirectory() ? "addDir" : "add",
1412
- fullPath,
1413
- onEventFunc,
1414
- onFinishFunc
1415
- );
1416
- } catch {
1417
- await new Promise((resolve) => setTimeout(resolve, 50));
1582
+ const event = await classifyRenameEvent(fullPath, config.fsTree);
1583
+ if (!event) return;
1584
+ if (event === "add") {
1418
1585
  try {
1419
- const s = await stat(fullPath);
1420
- handleWatchEvent(
1421
- config,
1422
- extensions,
1423
- s.isDirectory() ? "addDir" : "add",
1424
- fullPath,
1425
- onEventFunc,
1426
- onFinishFunc
1427
- );
1428
- return;
1586
+ const lstatResult = await lstat(fullPath);
1587
+ if (lstatResult.isSymbolicLink()) trackSymlink(fullPath);
1429
1588
  } catch {
1430
1589
  }
1431
- if (!(config.fsTree && fullPath in config.fsTree)) return;
1432
- handleWatchEvent(config, extensions, "unlink", fullPath, onEventFunc, onFinishFunc);
1590
+ } else if (event === "unlink") {
1591
+ untrackSymlink(fullPath);
1433
1592
  }
1593
+ handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1434
1594
  });
1435
- const parentDir = path4.dirname(watchPath);
1436
- const watchedBasename = path4.basename(watchPath);
1595
+ const parentDir = path5.dirname(watchPath);
1596
+ const watchedBasename = path5.basename(watchPath);
1597
+ let parentUnlinkFired = false;
1437
1598
  const parentWatcher = fs9.watch(parentDir, async (eventType, filename) => {
1438
1599
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
1600
+ if (parentUnlinkFired) return;
1601
+ parentUnlinkFired = true;
1439
1602
  try {
1440
1603
  await stat(watchPath);
1604
+ parentUnlinkFired = false;
1441
1605
  } catch {
1442
1606
  handleWatchEvent(config, extensions, "unlinkDir", watchPath, onEventFunc, onFinishFunc);
1443
1607
  childWatcher.close();
1444
1608
  parentWatcher.close();
1445
- delete watchers[watchPath];
1609
+ delete fileWatchers[watchPath];
1446
1610
  }
1447
1611
  });
1448
1612
  parentWatchers.push(parentWatcher);
1613
+ fileWatchers[watchPath] = childWatcher;
1449
1614
  readyPromises.push(
1450
1615
  new Promise(
1451
1616
  (resolve) => setImmediate(() => {
@@ -1454,8 +1619,18 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1454
1619
  })
1455
1620
  )
1456
1621
  );
1457
- return Object.assign(watchers, { [watchPath]: childWatcher });
1458
- }, {});
1622
+ }
1623
+ readyPromises.push(
1624
+ (async () => {
1625
+ for (const filePath of Object.keys(config.fsTree)) {
1626
+ try {
1627
+ const lstatResult = await lstat(filePath);
1628
+ if (lstatResult.isSymbolicLink()) trackSymlink(filePath);
1629
+ } catch {
1630
+ }
1631
+ }
1632
+ })()
1633
+ );
1459
1634
  return {
1460
1635
  fileWatchers,
1461
1636
  ready: Promise.all(readyPromises).then(() => {
@@ -1463,69 +1638,85 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1463
1638
  killFileWatchers() {
1464
1639
  Object.keys(fileWatchers).forEach((key) => fileWatchers[key].close());
1465
1640
  parentWatchers.forEach((pw) => pw.close());
1641
+ symlinkPollers.forEach((cancel) => cancel());
1642
+ symlinkPollers.clear();
1466
1643
  return fileWatchers;
1467
1644
  }
1468
1645
  };
1469
1646
  }
1647
+ async function classifyRenameEvent(fullPath, fsTree) {
1648
+ for (const delay of [0, 50]) {
1649
+ if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
1650
+ try {
1651
+ const statResult = await stat(fullPath);
1652
+ return statResult.isDirectory() ? "addDir" : "add";
1653
+ } catch {
1654
+ }
1655
+ }
1656
+ if (!fsTree) return null;
1657
+ if (fullPath in fsTree) return "unlink";
1658
+ const dirPrefix = fullPath + "/";
1659
+ return Object.keys(fsTree).some((trackedPath) => trackedPath.startsWith(dirPrefix)) ? "unlinkDir" : null;
1660
+ }
1470
1661
  function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
1471
- const isFileEvent = extensions.some((ext) => filePath.endsWith(`.${ext}`));
1472
- if (!isFileEvent && event !== "unlinkDir") return;
1662
+ if (event !== "unlinkDir" && !extensions.some((ext) => filePath.endsWith(`.${ext}`)))
1663
+ return Promise.resolve();
1664
+ if (event === "change" && config._building && config._justAddedFiles?.has(filePath))
1665
+ return Promise.resolve();
1473
1666
  mutateFSTree(config.fsTree, event, filePath);
1474
1667
  console.log(
1475
1668
  "#",
1476
1669
  magenta().bold("==================================================================")
1477
1670
  );
1478
- console.log("#", getEventColor(event), filePath.split(config.projectRoot)[1]);
1671
+ console.log("#", colorEvent(event), filePath.split(config.projectRoot)[1]);
1479
1672
  console.log(
1480
1673
  "#",
1481
1674
  magenta().bold("==================================================================")
1482
1675
  );
1483
- if (!config._building) {
1484
- config._building = true;
1485
- const result = onEventFunc(event, filePath);
1486
- if (!(result instanceof Promise)) {
1487
- config._building = false;
1488
- return result;
1489
- }
1490
- result.then(() => {
1491
- onFinishFunc ? onFinishFunc(event, filePath) : null;
1492
- }).catch((error) => {
1493
- console.error("#", red("Build error:"), error.message || error);
1494
- }).finally(() => {
1495
- config._building = false;
1496
- if (config._pendingBuildTrigger) {
1497
- const trigger = config._pendingBuildTrigger;
1498
- config._pendingBuildTrigger = null;
1499
- trigger();
1500
- }
1501
- });
1502
- } else {
1676
+ if (config._building) {
1677
+ if (event === "add") config._justAddedFiles?.add(filePath);
1503
1678
  config._pendingBuildTrigger = () => handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc);
1504
- }
1679
+ return Promise.resolve();
1680
+ }
1681
+ config._building = true;
1682
+ config._justAddedFiles = event === "add" ? /* @__PURE__ */ new Set([filePath]) : /* @__PURE__ */ new Set();
1683
+ const result = onEventFunc(event, filePath);
1684
+ if (!(result instanceof Promise)) {
1685
+ config._building = false;
1686
+ return Promise.resolve();
1687
+ }
1688
+ return result.then(() => onFinishFunc?.(filePath, event)).catch((error) => console.error("#", red("Build error:"), error.message || error)).finally(() => {
1689
+ config._building = false;
1690
+ config._lastBuildEndMs = Date.now();
1691
+ if (config._pendingBuildTrigger) {
1692
+ const trigger = config._pendingBuildTrigger;
1693
+ config._pendingBuildTrigger = null;
1694
+ trigger();
1695
+ }
1696
+ });
1505
1697
  }
1506
- function mutateFSTree(fsTree, event, path5) {
1698
+ function mutateFSTree(fsTree, event, path6) {
1507
1699
  if (event === "add") {
1508
- fsTree[path5] = null;
1700
+ fsTree[path6] = null;
1509
1701
  } else if (event === "unlink") {
1510
- delete fsTree[path5];
1702
+ delete fsTree[path6];
1511
1703
  } else if (event === "unlinkDir") {
1704
+ const dirPrefix = path6.endsWith("/") ? path6 : path6 + "/";
1512
1705
  for (const treePath of Object.keys(fsTree)) {
1513
- if (treePath.startsWith(path5)) delete fsTree[treePath];
1706
+ if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
1514
1707
  }
1515
1708
  }
1516
1709
  }
1517
- function getEventColor(event) {
1518
- if (event === "change") {
1519
- return yellow("CHANGED:");
1520
- } else if (event === "add" || event === "addDir") {
1521
- return green("ADDED:");
1522
- } else if (event === "unlink" || event === "unlinkDir") {
1523
- return red("REMOVED:");
1524
- }
1710
+ function colorEvent(event) {
1711
+ if (event === "change") return yellow("CHANGED:");
1712
+ if (event === "add" || event === "addDir") return green("ADDED:");
1713
+ return red("REMOVED:");
1525
1714
  }
1715
+ var CHANGE_DEDUPE_MS;
1526
1716
  var init_file_watcher = __esm({
1527
1717
  "lib/setup/file-watcher.ts"() {
1528
1718
  init_color();
1719
+ CHANGE_DEDUPE_MS = 30;
1529
1720
  }
1530
1721
  });
1531
1722
 
@@ -1594,7 +1785,7 @@ function setupKeyboardEvents(config, cachedContent, connections) {
1594
1785
  });
1595
1786
  }
1596
1787
  function abortBrowserQUnit(_config, connections) {
1597
- connections.server.publish("abort", "abort");
1788
+ connections.server.publish("abort");
1598
1789
  }
1599
1790
  var init_keyboard_events = __esm({
1600
1791
  "lib/setup/keyboard-events.ts"() {
@@ -1671,11 +1862,21 @@ async function run(config) {
1671
1862
  if (["change", "unlink", "unlinkDir"].includes(event)) {
1672
1863
  if (event === "change" && !(file in config.fsTree)) return;
1673
1864
  cachedContent.allTestCode = null;
1865
+ if (config.debug) {
1866
+ console.log(
1867
+ `# Rerun triggered: ${event} \u2192 ${file.replace(`${config.projectRoot}/`, "")}`
1868
+ );
1869
+ }
1674
1870
  return await runTestsInBrowser(config, cachedContent, connections);
1675
1871
  }
1872
+ if (config.debug) {
1873
+ console.log(
1874
+ `# Rerun triggered: ${event} \u2192 ${file.replace(`${config.projectRoot}/`, "")}`
1875
+ );
1876
+ }
1676
1877
  await runTestsInBrowser(config, cachedContent, connections, [file]);
1677
1878
  },
1678
- (_path, _event) => connections.server.publish("refresh", "refresh")
1879
+ (_path, _event) => connections.server.publish("refresh")
1679
1880
  );
1680
1881
  await watcherReady;
1681
1882
  }
@@ -1688,13 +1889,17 @@ async function run(config) {
1688
1889
  config.lastRanTestFiles = allFiles;
1689
1890
  const groupConfigs = groups.map((groupFiles, i) => ({
1690
1891
  ...config,
1691
- fsTree: Object.fromEntries(groupFiles.map((f) => [f, config.fsTree[f]])),
1892
+ fsTree: Object.fromEntries(groupFiles.map((filePath) => [filePath, config.fsTree[filePath]])),
1692
1893
  // Single group keeps the root output dir for backward-compatible file paths.
1693
1894
  output: groupCount === 1 ? config.output : `${config.output}/group-${i}`,
1694
- _groupMode: true
1895
+ _groupMode: true,
1896
+ _phase: "bundling"
1695
1897
  }));
1696
1898
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
1697
1899
  console.log("TAP version 13");
1900
+ console.log(
1901
+ `# Running ${allFiles.length} test file${allFiles.length === 1 ? "" : "s"} across ${groupCount} group${groupCount === 1 ? "" : "s"}`
1902
+ );
1698
1903
  const [browser] = await Promise.all([
1699
1904
  launchBrowser(config),
1700
1905
  Promise.all(
@@ -1716,14 +1921,22 @@ async function run(config) {
1716
1921
  const groupResults = await Promise.allSettled(
1717
1922
  groupConfigs.map((groupConfig, i) => {
1718
1923
  const groupTimeout = new Promise((_, reject) => {
1719
- const t = setTimeout(
1720
- () => reject(new Error(`Group ${i} timed out after ${GROUP_TIMEOUT_MS}ms`)),
1721
- GROUP_TIMEOUT_MS
1722
- );
1723
- t.unref();
1924
+ const timeoutId = setTimeout(() => {
1925
+ const files = Object.keys(groupConfig.fsTree).map(
1926
+ (filePath) => filePath.replace(`${groupConfig.projectRoot}/`, "")
1927
+ );
1928
+ reject(
1929
+ new Error(
1930
+ `Group ${i} timed out after ${GROUP_TIMEOUT_MS / 1e3}s in phase '${groupConfig._phase ?? "unknown"}'
1931
+ Files: ${files.join(", ")}`
1932
+ )
1933
+ );
1934
+ }, GROUP_TIMEOUT_MS);
1935
+ timeoutId.unref();
1724
1936
  });
1725
1937
  return Promise.race([
1726
1938
  (async () => {
1939
+ groupConfig._phase = "connecting";
1727
1940
  const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
1728
1941
  groupConfig.expressApp = connections.server;
1729
1942
  if (config.before) {
@@ -1739,8 +1952,8 @@ async function run(config) {
1739
1952
  Promise.race([
1740
1953
  connections.page.close(),
1741
1954
  new Promise((resolve) => {
1742
- const t = setTimeout(resolve, 1e4);
1743
- t.unref();
1955
+ const pageCloseTimeoutId = setTimeout(resolve, 1e4);
1956
+ pageCloseTimeoutId.unref();
1744
1957
  })
1745
1958
  ]).catch(() => {
1746
1959
  })
@@ -1766,11 +1979,12 @@ async function run(config) {
1766
1979
  }
1767
1980
  const exitTimer = setTimeout(() => process.exit(exitCode), 5e3);
1768
1981
  exitTimer.unref();
1769
- process.stdout.write("\n", () => {
1982
+ process.stdout.write("\n", async () => {
1770
1983
  clearTimeout(exitTimer);
1771
1984
  clearInterval(keepAlive);
1772
- browser.close().catch(() => {
1985
+ await browser.close().catch(() => {
1773
1986
  });
1987
+ await shutdownEarlyBrowser();
1774
1988
  process.exit(exitCode);
1775
1989
  });
1776
1990
  }
@@ -1833,7 +2047,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
1833
2047
  function splitIntoGroups(files, groupCount) {
1834
2048
  const groups = Array.from({ length: groupCount }, () => []);
1835
2049
  files.forEach((file, i) => groups[i % groupCount].push(file));
1836
- return groups.filter((g) => g.length > 0);
2050
+ return groups.filter((group) => group.length > 0);
1837
2051
  }
1838
2052
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
1839
2053
  const prefix = "Watching files...";
@@ -1855,6 +2069,7 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
1855
2069
  var init_run = __esm({
1856
2070
  "lib/commands/run.ts"() {
1857
2071
  init_browser();
2072
+ init_early_chrome();
1858
2073
  init_open_output_in_browser();
1859
2074
  init_color();
1860
2075
  init_tests_in_browser();
@@ -1881,7 +2096,7 @@ init_color();
1881
2096
  var package_default = {
1882
2097
  name: "qunitx-cli",
1883
2098
  type: "module",
1884
- version: "0.11.0",
2099
+ version: "0.16.0",
1885
2100
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
1886
2101
  author: "Izel Nakri",
1887
2102
  license: "MIT",
@@ -1911,8 +2126,10 @@ var package_default = {
1911
2126
  "changelog:preview": "git-cliff",
1912
2127
  "changelog:update": "git-cliff --output CHANGELOG.md",
1913
2128
  postinstall: "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
1914
- test: `node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require("os").availableParallelism()') test/**/*-test.ts`,
1915
- "test:browser": `node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require("os").availableParallelism()') test/flags/*-test.ts test/inputs/*-test.ts`,
2129
+ test: "node test/runner.ts",
2130
+ "test:debug": "QUNITX_DEBUG=1 node test/runner.ts",
2131
+ dev: "node test/runner.ts",
2132
+ "test:browser": "node test/runner.ts test/flags/*-test.ts test/inputs/*-test.ts",
1916
2133
  "test:release": "bash scripts/test-release.sh",
1917
2134
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
1918
2135
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"
@@ -1990,7 +2207,7 @@ ${color("$ qunitx new $testFileName")} # Creates a qunitx test file
1990
2207
 
1991
2208
  // lib/commands/init.ts
1992
2209
  import fs3 from "node:fs/promises";
1993
- import path from "node:path";
2210
+ import path2 from "node:path";
1994
2211
 
1995
2212
  // lib/utils/find-project-root.ts
1996
2213
  import process2 from "node:process";
@@ -2062,8 +2279,8 @@ async function writeTestsHTML(projectRoot, config, oldPackageJSON) {
2062
2279
  if (await pathExists(targetPath)) {
2063
2280
  return console.log(`${htmlPath} already exists`);
2064
2281
  } else {
2065
- const targetDirectory = path.dirname(targetPath);
2066
- const _targetOutputPath = path.relative(
2282
+ const targetDirectory = path2.dirname(targetPath);
2283
+ const _targetOutputPath = path2.relative(
2067
2284
  targetDirectory,
2068
2285
  `${projectRoot}/${config.output}/tests.js`
2069
2286
  );
@@ -2099,17 +2316,17 @@ init_read_boilerplate();
2099
2316
  async function generateTestFiles() {
2100
2317
  const projectRoot = await findProjectRoot();
2101
2318
  const moduleName = process.argv[3];
2102
- const path5 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2103
- if (await pathExists(path5)) {
2104
- console.log(`${path5} already exists!`);
2319
+ const path6 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2320
+ if (await pathExists(path6)) {
2321
+ console.log(`${path6} already exists!`);
2105
2322
  return;
2106
2323
  }
2107
2324
  const testJSContent = await readBoilerplate("test.js");
2108
- const targetFolderPaths = path5.split("/");
2325
+ const targetFolderPaths = path6.split("/");
2109
2326
  targetFolderPaths.pop();
2110
2327
  await fs4.mkdir(targetFolderPaths.join("/"), { recursive: true });
2111
- await fs4.writeFile(path5, testJSContent.replace("{{moduleName}}", moduleName));
2112
- console.log(green(`${path5} written`));
2328
+ await fs4.writeFile(path6, testJSContent.replace("{{moduleName}}", moduleName));
2329
+ console.log(green(`${path6} written`));
2113
2330
  }
2114
2331
 
2115
2332
  // lib/setup/config.ts
@@ -2117,13 +2334,28 @@ import fs6 from "node:fs/promises";
2117
2334
 
2118
2335
  // lib/setup/fs-tree.ts
2119
2336
  import fs5, { glob as fsGlob } from "node:fs/promises";
2120
- import path2 from "node:path";
2337
+ import path3 from "node:path";
2121
2338
  function isGlob(str) {
2122
2339
  return /[*?{[]/.test(str);
2123
2340
  }
2124
2341
  async function readDirRecursive(dir, filter) {
2125
2342
  const entries = await fs5.readdir(dir, { recursive: true, withFileTypes: true });
2126
- return entries.filter((e) => e.isFile() && filter(e.name)).map((e) => path2.join(e.parentPath, e.name));
2343
+ const candidates = entries.filter(
2344
+ (dirent) => (dirent.isFile() || dirent.isSymbolicLink()) && filter(dirent.name)
2345
+ );
2346
+ const resolvedPaths = await Promise.all(
2347
+ candidates.map(async (dirent) => {
2348
+ const fullPath = path3.join(dirent.parentPath, dirent.name);
2349
+ if (dirent.isFile()) return fullPath;
2350
+ try {
2351
+ const statResult = await fs5.stat(fullPath);
2352
+ return statResult.isFile() ? fullPath : null;
2353
+ } catch {
2354
+ return null;
2355
+ }
2356
+ })
2357
+ );
2358
+ return resolvedPaths.filter((resolvedPath) => resolvedPath !== null);
2127
2359
  }
2128
2360
  async function buildFSTree(fileAbsolutePaths, config = {}) {
2129
2361
  const targetExtensions = config.extensions || ["js", "ts"];
@@ -2195,20 +2427,20 @@ function setupTestFilePaths(_projectRoot, inputs2) {
2195
2427
  });
2196
2428
  return result.map((metaItem) => metaItem.input);
2197
2429
  }
2198
- function pathIsFile(path5) {
2199
- const inputs2 = path5.split("/");
2430
+ function pathIsFile(path6) {
2431
+ const inputs2 = path6.split("/");
2200
2432
  return inputs2[inputs2.length - 1].includes(".");
2201
2433
  }
2202
2434
  function pathIsIncludedInPaths(paths, targetPath) {
2203
- return paths.some((path5) => {
2204
- if (path5 === targetPath) {
2435
+ return paths.some((path6) => {
2436
+ if (path6 === targetPath) {
2205
2437
  return false;
2206
2438
  }
2207
- return matchesGlob(targetPath.input, buildGlobFormat(path5));
2439
+ return matchesGlob(targetPath.input, buildGlobFormat(path6));
2208
2440
  });
2209
2441
  }
2210
- function buildGlobFormat(path5) {
2211
- return path5.isFile ? path5.input : `${path5.input}/**`;
2442
+ function buildGlobFormat(path6) {
2443
+ return path6.isFile ? path6.input : `${path6.input}/**`;
2212
2444
  }
2213
2445
 
2214
2446
  // lib/utils/parse-cli-flags.ts
@@ -2240,7 +2472,7 @@ function parseCliFlags(projectRoot) {
2240
2472
  return Object.assign(result, { port: Number(arg.split("=")[1]), portExplicit: true });
2241
2473
  } else if (arg.startsWith("--extensions")) {
2242
2474
  return Object.assign(result, {
2243
- extensions: arg.split("=")[1].split(",").map((e) => e.trim())
2475
+ extensions: arg.split("=")[1].split(",").map((extension) => extension.trim())
2244
2476
  });
2245
2477
  } else if (arg.startsWith("--browser")) {
2246
2478
  const value = arg.split("=")[1];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.16.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",
@@ -31,8 +31,10 @@
31
31
  "changelog:preview": "git-cliff",
32
32
  "changelog:update": "git-cliff --output CHANGELOG.md",
33
33
  "postinstall": "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
34
- "test": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/**/*-test.ts",
35
- "test:browser": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/flags/*-test.ts test/inputs/*-test.ts",
34
+ "test": "node test/runner.ts",
35
+ "test:debug": "QUNITX_DEBUG=1 node test/runner.ts",
36
+ "dev": "node test/runner.ts",
37
+ "test:browser": "node test/runner.ts test/flags/*-test.ts test/inputs/*-test.ts",
36
38
  "test:release": "bash scripts/test-release.sh",
37
39
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
38
40
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"