qunitx-cli 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +395 -212
  2. package/package.json +5 -5
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S node --experimental-strip-types
1
+ #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
@@ -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,
@@ -689,7 +788,9 @@ function setupWebServer(config, cachedContent) {
689
788
  if (!config._groupMode) console.log("TAP version 13");
690
789
  if (config.debug && config._groupMode) {
691
790
  const allFiles = Object.keys(config.fsTree);
692
- const relFiles = allFiles.map((f) => f.replace(`${config.projectRoot}/`, ""));
791
+ const relFiles = allFiles.map(
792
+ (filePath) => filePath.replace(`${config.projectRoot}/`, "")
793
+ );
693
794
  const shown = relFiles.slice(0, 2);
694
795
  const rest = relFiles.length - shown.length;
695
796
  const fileList = rest > 0 ? `${shown.join(" ")} +${rest} more` : shown.join(" ");
@@ -805,7 +906,7 @@ function setupWebServer(config, cachedContent) {
805
906
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
806
907
  const statusCode = await pathExists(filePath) ? 200 : 404;
807
908
  res.writeHead(statusCode, {
808
- "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
809
910
  });
810
911
  if (statusCode === 404) {
811
912
  res.end();
@@ -820,7 +921,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
820
921
  const assetPaths = findInternalAssetsFromHTML(html);
821
922
  const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
822
923
  return assetPaths.reduce((result, assetPath) => {
823
- const normalizedFullAbsolutePath = path3.normalize(`${htmlDirectory}/${assetPath}`);
924
+ const normalizedFullAbsolutePath = path4.normalize(`${htmlDirectory}/${assetPath}`);
824
925
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
825
926
  }, html);
826
927
  }
@@ -999,18 +1100,18 @@ async function launchBrowser(config) {
999
1100
  const browserName = config.browser || "chromium";
1000
1101
  if (browserName === "chromium") {
1001
1102
  const waitStart = Date.now();
1002
- const [playwrightCore2, earlyChrome] = await Promise.all([
1103
+ const [playwrightCore2, earlyChrome2] = await Promise.all([
1003
1104
  playwrightCorePromise,
1004
1105
  earlyBrowserPromise
1005
1106
  ]);
1006
1107
  perfLog(
1007
1108
  `browser.js: playwright-core + earlyChrome resolved in ${Date.now() - waitStart}ms, earlyChrome:`,
1008
- earlyChrome?.cdpEndpoint ?? null
1109
+ earlyChrome2?.cdpEndpoint ?? null
1009
1110
  );
1010
- if (earlyChrome) {
1111
+ if (earlyChrome2) {
1011
1112
  const connectStart = Date.now();
1012
1113
  const browser = await playwrightCore2.chromium.connectOverCDP({
1013
- endpointURL: earlyChrome.cdpEndpoint
1114
+ endpointURL: earlyChrome2.cdpEndpoint
1014
1115
  });
1015
1116
  perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
1016
1117
  return browser;
@@ -1180,25 +1281,29 @@ async function buildTestBundle(config, cachedContent) {
1180
1281
  return;
1181
1282
  }
1182
1283
  const outfile = `${projectRoot}/${output}/tests.js`;
1183
- await Promise.all([
1284
+ const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1285
+ const needsDisk = true;
1286
+ const [allTestCode] = await Promise.all([
1184
1287
  buildWithOverlayfsRetry(
1185
1288
  {
1186
1289
  stdin: {
1187
- contents: allTestFilePaths.map((f) => `import "${f}";`).join(""),
1290
+ contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
1188
1291
  resolveDir: process.cwd()
1189
1292
  },
1190
1293
  bundle: true,
1191
1294
  logLevel: "error",
1192
1295
  outfile,
1193
1296
  keepNames: true,
1194
- sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1297
+ legalComments: "none",
1298
+ target: esbuildTarget(config.browser),
1299
+ sourcemap,
1195
1300
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
1196
1301
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
1197
1302
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
1198
1303
  // all browsers and does not require changes to user test code.
1199
1304
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1200
1305
  },
1201
- outfile
1306
+ needsDisk
1202
1307
  ),
1203
1308
  Promise.all(
1204
1309
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
@@ -1210,7 +1315,7 @@ async function buildTestBundle(config, cachedContent) {
1210
1315
  })
1211
1316
  )
1212
1317
  ]);
1213
- cachedContent.allTestCode = await fs8.readFile(outfile);
1318
+ cachedContent.allTestCode = allTestCode;
1214
1319
  }
1215
1320
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1216
1321
  const { projectRoot, output } = config;
@@ -1229,8 +1334,11 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1229
1334
  }
1230
1335
  if (runHasFilter) {
1231
1336
  const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
1232
- await buildFilteredTests(targetTestFilesToFilter, outputPath, config);
1233
- cachedContent.filteredTestCode = (await fs8.readFile(outputPath)).toString();
1337
+ cachedContent.filteredTestCode = await buildFilteredTests(
1338
+ targetTestFilesToFilter,
1339
+ outputPath,
1340
+ config
1341
+ );
1234
1342
  }
1235
1343
  const TIME_COUNTER = timeCounter();
1236
1344
  if (runHasFilter) {
@@ -1253,6 +1361,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1253
1361
  connections.server && connections.server.close(),
1254
1362
  connections.browser && connections.browser.close()
1255
1363
  ]);
1364
+ await shutdownEarlyBrowser();
1256
1365
  return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
1257
1366
  }
1258
1367
  }
@@ -1269,42 +1378,60 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1269
1378
  return connections;
1270
1379
  }
1271
1380
  function buildFilteredTests(filteredTests, outputPath, config) {
1381
+ const sourcemap = config.debug ? "inline" : config.watch ? "linked" : false;
1382
+ const needsDisk = sourcemap === "linked" || Boolean(config.open);
1272
1383
  return buildWithOverlayfsRetry(
1273
1384
  {
1274
1385
  stdin: {
1275
- contents: filteredTests.map((f) => `import "${f}";`).join(""),
1386
+ contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
1276
1387
  resolveDir: process.cwd()
1277
1388
  },
1278
1389
  bundle: true,
1279
1390
  logLevel: "error",
1280
1391
  outfile: outputPath,
1281
- sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1392
+ legalComments: "none",
1393
+ target: esbuildTarget(config.browser),
1394
+ sourcemap,
1282
1395
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1283
1396
  },
1284
- outputPath
1397
+ needsDisk
1285
1398
  );
1286
1399
  }
1287
- async function buildWithOverlayfsRetry(options, outfile) {
1400
+ async function buildWithOverlayfsRetry(options, needsDisk) {
1288
1401
  const RETRY_DELAY_MS = 100;
1289
1402
  const MAX_RETRIES = 3;
1290
1403
  const EMPTY_BUNDLE_THRESHOLD = 500;
1291
- 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();
1292
1411
  for (let retry = 1; retry <= MAX_RETRIES; retry++) {
1293
- const bytes2 = (await fs8.stat(outfile)).size;
1294
- if (bytes2 >= EMPTY_BUNDLE_THRESHOLD) return result;
1412
+ if (js.length >= EMPTY_BUNDLE_THRESHOLD) break;
1295
1413
  console.log(
1296
- `# [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`
1297
1415
  );
1298
1416
  await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
1299
- result = await esbuild.build(options);
1417
+ ({ result, js } = await getContents());
1300
1418
  }
1301
- const bytes = (await fs8.stat(outfile)).size;
1302
- if (bytes < EMPTY_BUNDLE_THRESHOLD) {
1419
+ if (js.length < EMPTY_BUNDLE_THRESHOLD) {
1303
1420
  console.log(
1304
- `# [buildWithOverlayfsRetry] bundle is ${bytes} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1421
+ `# [buildWithOverlayfsRetry] bundle is ${js.length} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1422
+ );
1423
+ }
1424
+ if (needsDisk) {
1425
+ await Promise.all(
1426
+ result.outputFiles.map((outputFile) => fs8.writeFile(outputFile.path, outputFile.contents))
1305
1427
  );
1306
1428
  }
1307
- return result;
1429
+ return js;
1430
+ }
1431
+ function esbuildTarget(browser) {
1432
+ if (browser === "firefox") return ["firefox115"];
1433
+ if (browser === "webkit") return ["safari16"];
1434
+ return ["chrome120"];
1308
1435
  }
1309
1436
  async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
1310
1437
  let QUNIT_RESULT;
@@ -1313,6 +1440,9 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1313
1440
  let wsConnected = false;
1314
1441
  try {
1315
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);
1316
1446
  let resolveTestRace;
1317
1447
  const testRaceResult = new Promise((resolve) => {
1318
1448
  resolveTestRace = resolve;
@@ -1321,11 +1451,11 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1321
1451
  config._onWsOpen = () => {
1322
1452
  wsConnected = true;
1323
1453
  clearTimeout(timeoutHandle);
1324
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1454
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
1325
1455
  };
1326
1456
  config._onTestsJsServed = () => {
1327
1457
  clearTimeout(timeoutHandle);
1328
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 4);
1458
+ timeoutHandle = setTimeout(resolveTestRace, testsJsMs);
1329
1459
  };
1330
1460
  config._resetTestTimeout = () => {
1331
1461
  wsConnected = true;
@@ -1333,14 +1463,14 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1333
1463
  timeoutHandle = setTimeout(resolveTestRace, config.timeout);
1334
1464
  };
1335
1465
  const targetUrl = `http://localhost:${config.port}${filePath}`;
1336
- const navOptions = { timeout: config.timeout + 1e4, waitUntil: "commit" };
1466
+ const navOptions = { timeout: navMs, waitUntil: "commit" };
1337
1467
  if (page.url().split("?")[0] === targetUrl) {
1338
1468
  await page.reload(navOptions);
1339
1469
  } else {
1340
1470
  await page.goto(targetUrl, navOptions);
1341
1471
  }
1342
1472
  clearTimeout(timeoutHandle);
1343
- timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1473
+ timeoutHandle = setTimeout(resolveTestRace, startupMs);
1344
1474
  await testRaceResult;
1345
1475
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
1346
1476
  } catch (error) {
@@ -1382,6 +1512,7 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
1382
1512
  connections.server && connections.server.close(),
1383
1513
  connections.browser && connections.browser.close()
1384
1514
  ]);
1515
+ await shutdownEarlyBrowser();
1385
1516
  process.exit(1);
1386
1517
  }
1387
1518
  }
@@ -1389,6 +1520,7 @@ var BundleError;
1389
1520
  var init_tests_in_browser = __esm({
1390
1521
  "lib/commands/run/tests-in-browser.ts"() {
1391
1522
  init_color();
1523
+ init_early_chrome();
1392
1524
  init_time_counter();
1393
1525
  init_run_user_module();
1394
1526
  init_display_final_result();
@@ -1404,70 +1536,81 @@ var init_tests_in_browser = __esm({
1404
1536
 
1405
1537
  // lib/setup/file-watcher.ts
1406
1538
  import fs9 from "node:fs";
1407
- import { stat } from "node:fs/promises";
1408
- import path4 from "node:path";
1539
+ import { stat, lstat } from "node:fs/promises";
1540
+ import path5 from "node:path";
1409
1541
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1410
1542
  const extensions = config.extensions || ["js", "ts"];
1411
1543
  const readyPromises = [];
1412
1544
  const parentWatchers = [];
1413
- 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) {
1414
1566
  let ready = false;
1415
1567
  const lastChangeMs = {};
1416
- const CHANGE_DEDUPE_MS = 30;
1417
1568
  const childWatcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1418
1569
  if (!ready || !filename) return;
1419
- const fullPath = path4.join(watchPath, filename);
1570
+ const fullPath = path5.join(watchPath, filename);
1420
1571
  if (eventType === "change") {
1421
1572
  if (!config._building) {
1422
1573
  const now = Date.now();
1423
- 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
+ }
1424
1578
  lastChangeMs[fullPath] = now;
1425
1579
  }
1426
1580
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
1427
1581
  }
1428
- try {
1429
- const s = await stat(fullPath);
1430
- handleWatchEvent(
1431
- config,
1432
- extensions,
1433
- s.isDirectory() ? "addDir" : "add",
1434
- fullPath,
1435
- onEventFunc,
1436
- onFinishFunc
1437
- );
1438
- } catch {
1439
- await new Promise((resolve) => setTimeout(resolve, 50));
1582
+ const event = await classifyRenameEvent(fullPath, config.fsTree);
1583
+ if (!event) return;
1584
+ if (event === "add") {
1440
1585
  try {
1441
- const s = await stat(fullPath);
1442
- handleWatchEvent(
1443
- config,
1444
- extensions,
1445
- s.isDirectory() ? "addDir" : "add",
1446
- fullPath,
1447
- onEventFunc,
1448
- onFinishFunc
1449
- );
1450
- return;
1586
+ const lstatResult = await lstat(fullPath);
1587
+ if (lstatResult.isSymbolicLink()) trackSymlink(fullPath);
1451
1588
  } catch {
1452
1589
  }
1453
- if (!(config.fsTree && fullPath in config.fsTree)) return;
1454
- handleWatchEvent(config, extensions, "unlink", fullPath, onEventFunc, onFinishFunc);
1590
+ } else if (event === "unlink") {
1591
+ untrackSymlink(fullPath);
1455
1592
  }
1593
+ handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1456
1594
  });
1457
- const parentDir = path4.dirname(watchPath);
1458
- const watchedBasename = path4.basename(watchPath);
1595
+ const parentDir = path5.dirname(watchPath);
1596
+ const watchedBasename = path5.basename(watchPath);
1597
+ let parentUnlinkFired = false;
1459
1598
  const parentWatcher = fs9.watch(parentDir, async (eventType, filename) => {
1460
1599
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
1600
+ if (parentUnlinkFired) return;
1601
+ parentUnlinkFired = true;
1461
1602
  try {
1462
1603
  await stat(watchPath);
1604
+ parentUnlinkFired = false;
1463
1605
  } catch {
1464
1606
  handleWatchEvent(config, extensions, "unlinkDir", watchPath, onEventFunc, onFinishFunc);
1465
1607
  childWatcher.close();
1466
1608
  parentWatcher.close();
1467
- delete watchers[watchPath];
1609
+ delete fileWatchers[watchPath];
1468
1610
  }
1469
1611
  });
1470
1612
  parentWatchers.push(parentWatcher);
1613
+ fileWatchers[watchPath] = childWatcher;
1471
1614
  readyPromises.push(
1472
1615
  new Promise(
1473
1616
  (resolve) => setImmediate(() => {
@@ -1476,8 +1619,18 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1476
1619
  })
1477
1620
  )
1478
1621
  );
1479
- return Object.assign(watchers, { [watchPath]: childWatcher });
1480
- }, {});
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
+ );
1481
1634
  return {
1482
1635
  fileWatchers,
1483
1636
  ready: Promise.all(readyPromises).then(() => {
@@ -1485,72 +1638,85 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1485
1638
  killFileWatchers() {
1486
1639
  Object.keys(fileWatchers).forEach((key) => fileWatchers[key].close());
1487
1640
  parentWatchers.forEach((pw) => pw.close());
1641
+ symlinkPollers.forEach((cancel) => cancel());
1642
+ symlinkPollers.clear();
1488
1643
  return fileWatchers;
1489
1644
  }
1490
1645
  };
1491
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
+ }
1492
1661
  function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
1493
- const isFileEvent = extensions.some((ext) => filePath.endsWith(`.${ext}`));
1494
- if (!isFileEvent && event !== "unlinkDir") return;
1495
- if (event === "change" && config._building && config._justAddedFiles?.has(filePath)) 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();
1496
1666
  mutateFSTree(config.fsTree, event, filePath);
1497
1667
  console.log(
1498
1668
  "#",
1499
1669
  magenta().bold("==================================================================")
1500
1670
  );
1501
- console.log("#", getEventColor(event), filePath.split(config.projectRoot)[1]);
1671
+ console.log("#", colorEvent(event), filePath.split(config.projectRoot)[1]);
1502
1672
  console.log(
1503
1673
  "#",
1504
1674
  magenta().bold("==================================================================")
1505
1675
  );
1506
- if (!config._building) {
1507
- config._building = true;
1508
- config._justAddedFiles = event === "add" ? /* @__PURE__ */ new Set([filePath]) : /* @__PURE__ */ new Set();
1509
- const result = onEventFunc(event, filePath);
1510
- if (!(result instanceof Promise)) {
1511
- config._building = false;
1512
- return result;
1513
- }
1514
- result.then(() => {
1515
- onFinishFunc ? onFinishFunc(event, filePath) : null;
1516
- }).catch((error) => {
1517
- console.error("#", red("Build error:"), error.message || error);
1518
- }).finally(() => {
1519
- config._building = false;
1520
- if (config._pendingBuildTrigger) {
1521
- const trigger = config._pendingBuildTrigger;
1522
- config._pendingBuildTrigger = null;
1523
- trigger();
1524
- }
1525
- });
1526
- } else {
1676
+ if (config._building) {
1527
1677
  if (event === "add") config._justAddedFiles?.add(filePath);
1528
1678
  config._pendingBuildTrigger = () => handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc);
1529
- }
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
+ });
1530
1697
  }
1531
- function mutateFSTree(fsTree, event, path5) {
1698
+ function mutateFSTree(fsTree, event, path6) {
1532
1699
  if (event === "add") {
1533
- fsTree[path5] = null;
1700
+ fsTree[path6] = null;
1534
1701
  } else if (event === "unlink") {
1535
- delete fsTree[path5];
1702
+ delete fsTree[path6];
1536
1703
  } else if (event === "unlinkDir") {
1704
+ const dirPrefix = path6.endsWith("/") ? path6 : path6 + "/";
1537
1705
  for (const treePath of Object.keys(fsTree)) {
1538
- if (treePath.startsWith(path5)) delete fsTree[treePath];
1706
+ if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
1539
1707
  }
1540
1708
  }
1541
1709
  }
1542
- function getEventColor(event) {
1543
- if (event === "change") {
1544
- return yellow("CHANGED:");
1545
- } else if (event === "add" || event === "addDir") {
1546
- return green("ADDED:");
1547
- } else if (event === "unlink" || event === "unlinkDir") {
1548
- return red("REMOVED:");
1549
- }
1710
+ function colorEvent(event) {
1711
+ if (event === "change") return yellow("CHANGED:");
1712
+ if (event === "add" || event === "addDir") return green("ADDED:");
1713
+ return red("REMOVED:");
1550
1714
  }
1715
+ var CHANGE_DEDUPE_MS;
1551
1716
  var init_file_watcher = __esm({
1552
1717
  "lib/setup/file-watcher.ts"() {
1553
1718
  init_color();
1719
+ CHANGE_DEDUPE_MS = 30;
1554
1720
  }
1555
1721
  });
1556
1722
 
@@ -1723,7 +1889,7 @@ async function run(config) {
1723
1889
  config.lastRanTestFiles = allFiles;
1724
1890
  const groupConfigs = groups.map((groupFiles, i) => ({
1725
1891
  ...config,
1726
- fsTree: Object.fromEntries(groupFiles.map((f) => [f, config.fsTree[f]])),
1892
+ fsTree: Object.fromEntries(groupFiles.map((filePath) => [filePath, config.fsTree[filePath]])),
1727
1893
  // Single group keeps the root output dir for backward-compatible file paths.
1728
1894
  output: groupCount === 1 ? config.output : `${config.output}/group-${i}`,
1729
1895
  _groupMode: true,
@@ -1755,9 +1921,9 @@ async function run(config) {
1755
1921
  const groupResults = await Promise.allSettled(
1756
1922
  groupConfigs.map((groupConfig, i) => {
1757
1923
  const groupTimeout = new Promise((_, reject) => {
1758
- const t = setTimeout(() => {
1924
+ const timeoutId = setTimeout(() => {
1759
1925
  const files = Object.keys(groupConfig.fsTree).map(
1760
- (f) => f.replace(`${groupConfig.projectRoot}/`, "")
1926
+ (filePath) => filePath.replace(`${groupConfig.projectRoot}/`, "")
1761
1927
  );
1762
1928
  reject(
1763
1929
  new Error(
@@ -1766,7 +1932,7 @@ async function run(config) {
1766
1932
  )
1767
1933
  );
1768
1934
  }, GROUP_TIMEOUT_MS);
1769
- t.unref();
1935
+ timeoutId.unref();
1770
1936
  });
1771
1937
  return Promise.race([
1772
1938
  (async () => {
@@ -1786,8 +1952,8 @@ async function run(config) {
1786
1952
  Promise.race([
1787
1953
  connections.page.close(),
1788
1954
  new Promise((resolve) => {
1789
- const t = setTimeout(resolve, 1e4);
1790
- t.unref();
1955
+ const pageCloseTimeoutId = setTimeout(resolve, 1e4);
1956
+ pageCloseTimeoutId.unref();
1791
1957
  })
1792
1958
  ]).catch(() => {
1793
1959
  })
@@ -1813,11 +1979,12 @@ async function run(config) {
1813
1979
  }
1814
1980
  const exitTimer = setTimeout(() => process.exit(exitCode), 5e3);
1815
1981
  exitTimer.unref();
1816
- process.stdout.write("\n", () => {
1982
+ process.stdout.write("\n", async () => {
1817
1983
  clearTimeout(exitTimer);
1818
1984
  clearInterval(keepAlive);
1819
- browser.close().catch(() => {
1985
+ await browser.close().catch(() => {
1820
1986
  });
1987
+ await shutdownEarlyBrowser();
1821
1988
  process.exit(exitCode);
1822
1989
  });
1823
1990
  }
@@ -1880,7 +2047,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
1880
2047
  function splitIntoGroups(files, groupCount) {
1881
2048
  const groups = Array.from({ length: groupCount }, () => []);
1882
2049
  files.forEach((file, i) => groups[i % groupCount].push(file));
1883
- return groups.filter((g) => g.length > 0);
2050
+ return groups.filter((group) => group.length > 0);
1884
2051
  }
1885
2052
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
1886
2053
  const prefix = "Watching files...";
@@ -1902,6 +2069,7 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
1902
2069
  var init_run = __esm({
1903
2070
  "lib/commands/run.ts"() {
1904
2071
  init_browser();
2072
+ init_early_chrome();
1905
2073
  init_open_output_in_browser();
1906
2074
  init_color();
1907
2075
  init_tests_in_browser();
@@ -1928,7 +2096,7 @@ init_color();
1928
2096
  var package_default = {
1929
2097
  name: "qunitx-cli",
1930
2098
  type: "module",
1931
- version: "0.15.0",
2099
+ version: "0.17.0",
1932
2100
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
1933
2101
  author: "Izel Nakri",
1934
2102
  license: "MIT",
@@ -1958,10 +2126,10 @@ var package_default = {
1958
2126
  "changelog:preview": "git-cliff",
1959
2127
  "changelog:update": "git-cliff --output CHANGELOG.md",
1960
2128
  postinstall: "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
1961
- test: "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test test/**/*-test.ts",
1962
- "test:debug": "node --experimental-strip-types test/setup.ts && QUNITX_DEBUG=1 node --experimental-strip-types --test test/**/*-test.ts",
1963
- dev: "node --experimental-strip-types test/setup.ts && node --experimental-strip-types --test --watch test/**/*-test.ts",
1964
- "test:browser": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test 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 --watch",
2132
+ "test:browser": "node test/runner.ts test/flags/*-test.ts test/inputs/*-test.ts",
1965
2133
  "test:release": "bash scripts/test-release.sh",
1966
2134
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
1967
2135
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"
@@ -2039,7 +2207,7 @@ ${color("$ qunitx new $testFileName")} # Creates a qunitx test file
2039
2207
 
2040
2208
  // lib/commands/init.ts
2041
2209
  import fs3 from "node:fs/promises";
2042
- import path from "node:path";
2210
+ import path2 from "node:path";
2043
2211
 
2044
2212
  // lib/utils/find-project-root.ts
2045
2213
  import process2 from "node:process";
@@ -2111,8 +2279,8 @@ async function writeTestsHTML(projectRoot, config, oldPackageJSON) {
2111
2279
  if (await pathExists(targetPath)) {
2112
2280
  return console.log(`${htmlPath} already exists`);
2113
2281
  } else {
2114
- const targetDirectory = path.dirname(targetPath);
2115
- const _targetOutputPath = path.relative(
2282
+ const targetDirectory = path2.dirname(targetPath);
2283
+ const _targetOutputPath = path2.relative(
2116
2284
  targetDirectory,
2117
2285
  `${projectRoot}/${config.output}/tests.js`
2118
2286
  );
@@ -2148,17 +2316,17 @@ init_read_boilerplate();
2148
2316
  async function generateTestFiles() {
2149
2317
  const projectRoot = await findProjectRoot();
2150
2318
  const moduleName = process.argv[3];
2151
- const path5 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
2152
- if (await pathExists(path5)) {
2153
- 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!`);
2154
2322
  return;
2155
2323
  }
2156
2324
  const testJSContent = await readBoilerplate("test.js");
2157
- const targetFolderPaths = path5.split("/");
2325
+ const targetFolderPaths = path6.split("/");
2158
2326
  targetFolderPaths.pop();
2159
2327
  await fs4.mkdir(targetFolderPaths.join("/"), { recursive: true });
2160
- await fs4.writeFile(path5, testJSContent.replace("{{moduleName}}", moduleName));
2161
- console.log(green(`${path5} written`));
2328
+ await fs4.writeFile(path6, testJSContent.replace("{{moduleName}}", moduleName));
2329
+ console.log(green(`${path6} written`));
2162
2330
  }
2163
2331
 
2164
2332
  // lib/setup/config.ts
@@ -2166,13 +2334,28 @@ import fs6 from "node:fs/promises";
2166
2334
 
2167
2335
  // lib/setup/fs-tree.ts
2168
2336
  import fs5, { glob as fsGlob } from "node:fs/promises";
2169
- import path2 from "node:path";
2337
+ import path3 from "node:path";
2170
2338
  function isGlob(str) {
2171
2339
  return /[*?{[]/.test(str);
2172
2340
  }
2173
2341
  async function readDirRecursive(dir, filter) {
2174
2342
  const entries = await fs5.readdir(dir, { recursive: true, withFileTypes: true });
2175
- 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);
2176
2359
  }
2177
2360
  async function buildFSTree(fileAbsolutePaths, config = {}) {
2178
2361
  const targetExtensions = config.extensions || ["js", "ts"];
@@ -2244,20 +2427,20 @@ function setupTestFilePaths(_projectRoot, inputs2) {
2244
2427
  });
2245
2428
  return result.map((metaItem) => metaItem.input);
2246
2429
  }
2247
- function pathIsFile(path5) {
2248
- const inputs2 = path5.split("/");
2430
+ function pathIsFile(path6) {
2431
+ const inputs2 = path6.split("/");
2249
2432
  return inputs2[inputs2.length - 1].includes(".");
2250
2433
  }
2251
2434
  function pathIsIncludedInPaths(paths, targetPath) {
2252
- return paths.some((path5) => {
2253
- if (path5 === targetPath) {
2435
+ return paths.some((path6) => {
2436
+ if (path6 === targetPath) {
2254
2437
  return false;
2255
2438
  }
2256
- return matchesGlob(targetPath.input, buildGlobFormat(path5));
2439
+ return matchesGlob(targetPath.input, buildGlobFormat(path6));
2257
2440
  });
2258
2441
  }
2259
- function buildGlobFormat(path5) {
2260
- return path5.isFile ? path5.input : `${path5.input}/**`;
2442
+ function buildGlobFormat(path6) {
2443
+ return path6.isFile ? path6.input : `${path6.input}/**`;
2261
2444
  }
2262
2445
 
2263
2446
  // lib/utils/parse-cli-flags.ts
@@ -2289,7 +2472,7 @@ function parseCliFlags(projectRoot) {
2289
2472
  return Object.assign(result, { port: Number(arg.split("=")[1]), portExplicit: true });
2290
2473
  } else if (arg.startsWith("--extensions")) {
2291
2474
  return Object.assign(result, {
2292
- extensions: arg.split("=")[1].split(",").map((e) => e.trim())
2475
+ extensions: arg.split("=")[1].split(",").map((extension) => extension.trim())
2293
2476
  });
2294
2477
  } else if (arg.startsWith("--browser")) {
2295
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.15.0",
4
+ "version": "0.17.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,10 +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/**/*-test.ts",
35
- "test:debug": "node --experimental-strip-types test/setup.ts && QUNITX_DEBUG=1 node --experimental-strip-types --test test/**/*-test.ts",
36
- "dev": "node --experimental-strip-types test/setup.ts && node --experimental-strip-types --test --watch test/**/*-test.ts",
37
- "test:browser": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test 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 --watch",
37
+ "test:browser": "node test/runner.ts test/flags/*-test.ts test/inputs/*-test.ts",
38
38
  "test:release": "bash scripts/test-release.sh",
39
39
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
40
40
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"