qunitx-cli 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +111 -0
  2. package/dist/cli.js +210 -148
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -227,6 +227,117 @@ Options:
227
227
  --browser=<name> Browser engine: chromium (default), firefox, or webkit
228
228
  ```
229
229
 
230
+ ## Timezone
231
+
232
+ The browser inherits the **OS system timezone** automatically — no Playwright `timezoneId` option is involved. The browser's `Intl.DateTimeFormat().resolvedOptions().timeZone` will match the timezone that Node.js itself reads from the OS.
233
+
234
+ ### Setting a timezone for tests
235
+
236
+ | Platform | How Chrome resolves the timezone | Override |
237
+ |----------|----------------------------------|---------|
238
+ | **Linux** | glibc reads `TZ` env var first, then `/etc/localtime` | `TZ=America/New_York npx qunitx …` works |
239
+ | **macOS** | CoreFoundation reads the system timezone (ignores `TZ`) | Must set the system timezone: `sudo systemsetup -settimezone America/New_York` |
240
+ | **Windows** | Reads the registry timezone (ignores `TZ`) | Must set the system timezone: `tzutil /s "Eastern Standard Time"` |
241
+
242
+ On Linux, the `TZ` env var is the simplest way to run tests in a specific timezone:
243
+
244
+ ```sh
245
+ TZ=UTC npx qunitx test/**/*.ts
246
+ TZ=America/Los_Angeles npx qunitx test/**/*.ts
247
+ TZ=Europe/Berlin npx qunitx test/**/*.ts
248
+ ```
249
+
250
+ ### CI pitfalls
251
+
252
+ GitHub Actions (and most CI providers) run with **UTC** by default on all platforms. This is usually what you want for reproducible test results. If your tests assert on specific local times or date formatting, be aware:
253
+
254
+ **Linux CI** — override with `TZ` in your workflow step:
255
+
256
+ ```yaml
257
+ - run: npx qunitx test/**/*.ts
258
+ env:
259
+ TZ: America/New_York
260
+ ```
261
+
262
+ **macOS CI** — `TZ` does not affect Chrome. Set the system timezone before running tests:
263
+
264
+ ```yaml
265
+ - run: sudo systemsetup -settimezone America/New_York
266
+ - run: npx qunitx test/**/*.ts
267
+ ```
268
+
269
+ **Windows CI** — same constraint, use `tzutil`:
270
+
271
+ ```yaml
272
+ - run: tzutil /s "Eastern Standard Time"
273
+ - run: npx qunitx test/**/*.ts
274
+ ```
275
+
276
+ If your test suite does not assert on local times or timezone-sensitive date formatting, none of this matters — the default UTC CI timezone is fine.
277
+
278
+ ### Mocking dates and times in tests
279
+
280
+ For most cases you do not need to touch system settings or env vars at all. `Date`, `Intl`, and timers are plain browser globals — mock them in a qunitx `before` / `beforeEach` hook just like any other value:
281
+
282
+ ```js
283
+ // test/some-test.ts
284
+ import { module, test } from 'qunitx';
285
+
286
+ module('Invoice formatting', (hooks) => {
287
+ let realDate;
288
+
289
+ hooks.before(() => {
290
+ realDate = globalThis.Date;
291
+ // Pin "now" to a fixed instant for the whole module
292
+ const FIXED = new realDate('2024-06-01T12:00:00Z');
293
+ globalThis.Date = class extends realDate {
294
+ constructor(...args) { super(args.length ? args : [FIXED]); }
295
+ static now() { return FIXED.getTime(); }
296
+ };
297
+ });
298
+
299
+ hooks.after(() => { globalThis.Date = realDate; });
300
+
301
+ test('formats the current date correctly', (assert) => {
302
+ assert.equal(new Date().toISOString().slice(0, 10), '2024-06-01');
303
+ });
304
+ });
305
+ ```
306
+
307
+ For richer control over timers (`setTimeout`, `setInterval`, `requestAnimationFrame`, …) use a fake-timer library such as [Sinon.JS](https://sinonjs.org/releases/latest/fake-timers/):
308
+
309
+ ```js
310
+ import sinon from 'sinon';
311
+
312
+ module('Debounce logic', (hooks) => {
313
+ let clock;
314
+
315
+ hooks.before(() => { clock = sinon.useFakeTimers({ now: new Date('2024-06-01T00:00:00Z') }); });
316
+ hooks.after(() => { clock.restore(); });
317
+
318
+ test('fires after 300 ms', (assert) => {
319
+ // clock.tick(300) advances fake time without waiting in real time
320
+ clock.tick(300);
321
+ assert.ok(/* your assertion */);
322
+ });
323
+ });
324
+ ```
325
+
326
+ If you need the mock active across the entire test run rather than inside a single module, put it in a `--before` script:
327
+
328
+ ```js
329
+ // scripts/mock-date.js (passed as: qunitx … --before=scripts/mock-date.js)
330
+ const realDate = globalThis.Date;
331
+ const FIXED = new realDate('2024-06-01T12:00:00Z');
332
+
333
+ globalThis.Date = class extends realDate {
334
+ constructor(...args) { super(args.length ? args : [FIXED]); }
335
+ static now() { return FIXED.getTime(); }
336
+ };
337
+ ```
338
+
339
+ This runs in the browser context before any test module loads, so every test in the run sees the mocked `Date` with no changes to the OS, no env vars, and no qunitx-cli configuration.
340
+
230
341
  ## Development
231
342
 
232
343
  ```sh
package/dist/cli.js CHANGED
@@ -38,10 +38,11 @@ var init_find_chrome = __esm({
38
38
  });
39
39
 
40
40
  // lib/utils/kill-process-group.ts
41
+ import { spawnSync } from "node:child_process";
41
42
  function killProcessGroup(pid) {
42
43
  try {
43
44
  if (process.platform === "win32") {
44
- process.kill(pid, "SIGKILL");
45
+ spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
45
46
  } else {
46
47
  process.kill(-pid, "SIGKILL");
47
48
  }
@@ -199,8 +200,11 @@ var init_chromium_args = __esm({
199
200
  // ── Sandbox / rendering ──────────────────────────────────────────────────────
200
201
  "--no-sandbox",
201
202
  // required in most CI/container environments
202
- "--disable-gpu",
203
- // no GPU in headless; avoids GPU process startup
203
+ // SwiftShader software WebGL: needed on Linux CI (containerised, no GPU).
204
+ // Omit on macOS — Chrome uses Metal natively; SwiftShader crashes the renderer
205
+ // process on macOS arm64, causing "Target page, context or browser has been closed".
206
+ // Omit on Windows — ANGLE/D3D11 is available and SwiftShader is not needed.
207
+ ...process.platform === "linux" ? ["--enable-unsafe-swiftshader"] : [],
204
208
  // ── Window / UI ──────────────────────────────────────────────────────────────
205
209
  "--window-size=1440,900",
206
210
  "--hide-scrollbars",
@@ -307,7 +311,13 @@ var init_chrome_prelaunch = __esm({
307
311
  else if (arg === "--watch" || arg === "-w") flags.watchFromArgv = true;
308
312
  return flags;
309
313
  },
310
- { browserFromArgv: "chromium", openFromArgv: false, watchFromArgv: false }
314
+ // QUNITX_BROWSER env var seeds the default so prelaunch is skipped for firefox/webkit
315
+ // even when --browser is not passed on the command line (e.g. browser-compat CI).
316
+ {
317
+ browserFromArgv: process.env.QUNITX_BROWSER || "chromium",
318
+ openFromArgv: false,
319
+ watchFromArgv: false
320
+ }
311
321
  ));
312
322
  openWatchMode = openFromArgv && watchFromArgv;
313
323
  earlyChrome = null;
@@ -318,7 +328,7 @@ var init_chrome_prelaunch = __esm({
318
328
  });
319
329
  }
320
330
  perfLog("chrome-prelaunch.ts: module evaluated");
321
- prelaunchPromise = isRunCommand && browserFromArgv === "chromium" ? findChrome().then((chromePath) => {
331
+ prelaunchPromise = isRunCommand && browserFromArgv === "chromium" && process.platform !== "darwin" ? findChrome().then((chromePath) => {
322
332
  perfLog("chrome-prelaunch.ts: findChrome resolved", chromePath);
323
333
  return preLaunchChrome(chromePath, CHROMIUM_ARGS, !openWatchMode);
324
334
  }).then((info) => {
@@ -922,8 +932,8 @@ var init_http = __esm({
922
932
  });
923
933
  }
924
934
  /** Registers a GET route handler. */
925
- get(path7, handler) {
926
- this.#registerRouteHandler("GET", path7, handler);
935
+ get(path10, handler) {
936
+ this.#registerRouteHandler("GET", path10, handler);
927
937
  }
928
938
  /**
929
939
  * Starts listening on the given port (0 = OS-assigned).
@@ -954,32 +964,32 @@ var init_http = __esm({
954
964
  });
955
965
  }
956
966
  /** Registers a POST route handler. */
957
- post(path7, handler) {
958
- this.#registerRouteHandler("POST", path7, handler);
967
+ post(path10, handler) {
968
+ this.#registerRouteHandler("POST", path10, handler);
959
969
  }
960
970
  /** Registers a DELETE route handler. */
961
- delete(path7, handler) {
962
- this.#registerRouteHandler("DELETE", path7, handler);
971
+ delete(path10, handler) {
972
+ this.#registerRouteHandler("DELETE", path10, handler);
963
973
  }
964
974
  /** Registers a PUT route handler. */
965
- put(path7, handler) {
966
- this.#registerRouteHandler("PUT", path7, handler);
975
+ put(path10, handler) {
976
+ this.#registerRouteHandler("PUT", path10, handler);
967
977
  }
968
978
  /** Adds a middleware function to the chain. */
969
979
  use(middleware) {
970
980
  this.middleware.push(middleware);
971
981
  }
972
- #registerRouteHandler(method, path7, handler) {
982
+ #registerRouteHandler(method, path10, handler) {
973
983
  if (!this.routes[method]) {
974
984
  this.routes[method] = {};
975
985
  }
976
- const paramNames = this.#extractParamNames(path7);
977
- this.routes[method][path7] = {
978
- path: path7,
986
+ const paramNames = this.#extractParamNames(path10);
987
+ this.routes[method][path10] = {
988
+ path: path10,
979
989
  handler,
980
990
  paramNames,
981
- isWildcard: path7 === "/*",
982
- compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path7, paramNames)}$`) : null
991
+ isWildcard: path10 === "/*",
992
+ compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path10, paramNames)}$`) : null
983
993
  };
984
994
  }
985
995
  #handleRequest(req, res) {
@@ -1017,11 +1027,11 @@ var init_http = __esm({
1017
1027
  return null;
1018
1028
  }
1019
1029
  return routes[url] || Object.values(routes).find((route) => {
1020
- const { path: path7, isWildcard } = route;
1021
- if (!isWildcard && !path7.includes(":")) {
1030
+ const { path: path10, isWildcard } = route;
1031
+ if (!isWildcard && !path10.includes(":")) {
1022
1032
  return false;
1023
1033
  }
1024
- if (isWildcard || this.#matchPathSegments(path7, url)) {
1034
+ if (isWildcard || this.#matchPathSegments(path10, url)) {
1025
1035
  if (route.compiledRegex) {
1026
1036
  const regexMatches = route.compiledRegex.exec(url);
1027
1037
  if (regexMatches) {
@@ -1033,8 +1043,8 @@ var init_http = __esm({
1033
1043
  return false;
1034
1044
  }) || null;
1035
1045
  }
1036
- #matchPathSegments(path7, url) {
1037
- const pathSegments = path7.split("/");
1046
+ #matchPathSegments(path10, url) {
1047
+ const pathSegments = path10.split("/");
1038
1048
  const urlSegments = url.split("/");
1039
1049
  if (pathSegments.length !== urlSegments.length) {
1040
1050
  return false;
@@ -1051,14 +1061,14 @@ var init_http = __esm({
1051
1061
  }
1052
1062
  return true;
1053
1063
  }
1054
- #buildRegexPattern(path7, _paramNames) {
1055
- let regexPattern = path7.replace(/:[^/]+/g, "([^/]+)");
1064
+ #buildRegexPattern(path10, _paramNames) {
1065
+ let regexPattern = path10.replace(/:[^/]+/g, "([^/]+)");
1056
1066
  regexPattern = regexPattern.replace(/\//g, "\\/");
1057
1067
  return regexPattern;
1058
1068
  }
1059
- #extractParamNames(path7) {
1069
+ #extractParamNames(path10) {
1060
1070
  const paramRegex = /:(\w+)/g;
1061
- const paramMatches = path7.match(paramRegex);
1071
+ const paramMatches = path10.match(paramRegex);
1062
1072
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
1063
1073
  }
1064
1074
  #extractParams(route, _url) {
@@ -1075,9 +1085,9 @@ var init_http = __esm({
1075
1085
 
1076
1086
  // lib/setup/web-server.ts
1077
1087
  import fs8 from "node:fs";
1078
- import path4 from "node:path";
1088
+ import path5 from "node:path";
1079
1089
  function setupWebServer(config, cachedContent) {
1080
- const STATIC_FILES_PATH = path4.join(config.projectRoot, config.output);
1090
+ const STATIC_FILES_PATH = path5.resolve(config.projectRoot, config.output);
1081
1091
  const server = new HTTPServer();
1082
1092
  const mainHTMLWithReplacedAssets = replaceAssetPaths(
1083
1093
  cachedContent.mainHTML.html,
@@ -1214,7 +1224,10 @@ function setupWebServer(config, cachedContent) {
1214
1224
  config._testRunDone?.();
1215
1225
  config._testRunDone = null;
1216
1226
  }
1217
- return saveHTML(`${config.projectRoot}/${config.output}/index.html`, htmlContent);
1227
+ return saveHTML(
1228
+ path5.join(path5.resolve(config.projectRoot, config.output), "index.html"),
1229
+ htmlContent
1230
+ );
1218
1231
  }
1219
1232
  if (cachedContent._noTestsWarning) {
1220
1233
  res.writeHead(200, HTML_HEADERS);
@@ -1222,14 +1235,20 @@ function setupWebServer(config, cachedContent) {
1222
1235
  }
1223
1236
  res.writeHead(200, HTML_HEADERS);
1224
1237
  res.end(mainIndexHTML);
1225
- saveHTML(`${config.projectRoot}/${config.output}/index.html`, mainIndexHTML);
1238
+ saveHTML(
1239
+ path5.join(path5.resolve(config.projectRoot, config.output), "index.html"),
1240
+ mainIndexHTML
1241
+ );
1226
1242
  });
1227
1243
  server.get("/qunitx.html", (_req, res) => {
1228
1244
  if (cachedContent._buildError) {
1229
1245
  const htmlContent = buildErrorHTML(cachedContent._buildError);
1230
1246
  res.writeHead(200, HTML_HEADERS);
1231
1247
  res.end(htmlContent);
1232
- return saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, htmlContent);
1248
+ return saveHTML(
1249
+ path5.join(path5.resolve(config.projectRoot, config.output), "qunitx.html"),
1250
+ htmlContent
1251
+ );
1233
1252
  }
1234
1253
  if (cachedContent._noTestsWarning) {
1235
1254
  res.writeHead(200, HTML_HEADERS);
@@ -1237,7 +1256,10 @@ function setupWebServer(config, cachedContent) {
1237
1256
  }
1238
1257
  res.writeHead(200, HTML_HEADERS);
1239
1258
  res.end(mainQunitxHTML);
1240
- saveHTML(`${config.projectRoot}/${config.output}/qunitx.html`, mainQunitxHTML);
1259
+ saveHTML(
1260
+ path5.join(path5.resolve(config.projectRoot, config.output), "qunitx.html"),
1261
+ mainQunitxHTML
1262
+ );
1241
1263
  });
1242
1264
  server.get("/*", (req, res) => {
1243
1265
  const possibleDynamicHTML = cachedContent.dynamicContentHTMLs[`${config.projectRoot}${req.path}`];
@@ -1249,13 +1271,13 @@ function setupWebServer(config, cachedContent) {
1249
1271
  );
1250
1272
  res.writeHead(200, HTML_HEADERS);
1251
1273
  res.end(htmlContent);
1252
- saveHTML(`${config.projectRoot}/${config.output}${req.path}`, htmlContent);
1274
+ saveHTML(path5.join(path5.resolve(config.projectRoot, config.output), req.path), htmlContent);
1253
1275
  return;
1254
1276
  }
1255
1277
  const url = req.url;
1256
1278
  const requestStartedAt = Date.now();
1257
1279
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
1258
- const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1280
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path5.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1259
1281
  const stream = fs8.createReadStream(filePath);
1260
1282
  stream.on("open", () => {
1261
1283
  res.writeHead(200, { "Content-Type": contentType });
@@ -1280,7 +1302,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
1280
1302
  const assetPaths = findInternalAssetsFromHTML(html);
1281
1303
  const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
1282
1304
  return assetPaths.reduce((result, assetPath) => {
1283
- const normalizedFullAbsolutePath = path4.normalize(`${htmlDirectory}/${assetPath}`);
1305
+ const normalizedFullAbsolutePath = path5.normalize(`${htmlDirectory}/${assetPath}`);
1284
1306
  return result.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
1285
1307
  }, html);
1286
1308
  }
@@ -1288,27 +1310,23 @@ function testRuntimeToInject(config, groupId) {
1288
1310
  const groupIdPart = groupId !== void 0 ? `, groupId: ${groupId}` : "";
1289
1311
  return `<script>
1290
1312
  (function() {
1291
- // wsOpenStatus: true once the WebSocket 'open' event fires (or immediately for static files).
1292
- // testsLoaded: true once tests.js has executed and dispatched 'qunitx:tests-ready'.
1293
- // Both must be true before setupQUnit() is called, ensuring QUnit.start() only runs
1294
- // after all test modules are registered.
1295
- let wsOpenStatus = window.location.protocol === 'file:';
1296
- let testsLoaded = false;
1297
-
1298
- function maybeStart() {
1299
- if (wsOpenStatus && testsLoaded) setupQUnit();
1300
- }
1301
-
1302
- // tests.js (loaded as an async external script) dispatches this event after registering
1303
- // all test modules. Decoupled from WS so Chrome can compile tests.js in a background
1304
- // thread while the main thread handles the WebSocket handshake.
1305
- window.addEventListener('qunitx:tests-ready', function() {
1306
- testsLoaded = true;
1307
- maybeStart();
1313
+ // setupQUnit runs exactly once, after both the WebSocket is open and tests.js has loaded.
1314
+ // Promise.all is naturally idempotent \u2014 resolving a Promise a second time is a no-op,
1315
+ // so WebKit firing WS error after open (causing a retry that re-opens) cannot double-start.
1316
+ let resolveWsReady = () => {};
1317
+ const wsReadyPromise = window.location.protocol === 'file:'
1318
+ ? Promise.resolve()
1319
+ : new Promise(resolve => { resolveWsReady = resolve; });
1320
+
1321
+ // { once: true } auto-removes the listener after the first fire.
1322
+ const testsReadyPromise = new Promise(resolve => {
1323
+ window.addEventListener('qunitx:tests-ready', resolve, { once: true });
1308
1324
  });
1309
1325
 
1310
- // For static files (file:// protocol) there is no WebSocket server.
1311
- // wsOpenStatus is already true above; setupQUnit fires when tests load.
1326
+ Promise.all([wsReadyPromise, testsReadyPromise]).then(setupQUnit);
1327
+
1328
+ // For static files (file:// protocol) there is no WebSocket server; wsReadyPromise
1329
+ // is already resolved above, so setupQUnit fires as soon as tests load.
1312
1330
  if (window.location.protocol === 'file:') return;
1313
1331
 
1314
1332
  let wsRetryCount = 0;
@@ -1324,14 +1342,13 @@ function testRuntimeToInject(config, groupId) {
1324
1342
  }
1325
1343
 
1326
1344
  window.socket.addEventListener('open', function() {
1327
- wsOpenStatus = true;
1345
+ resolveWsReady();
1328
1346
  // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
1329
1347
  // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
1330
1348
  // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
1331
1349
  if (navigator.webdriver) {
1332
1350
  window.socket.send(JSON.stringify({ event: 'wsOpen'${groupIdPart} }));
1333
1351
  }
1334
- maybeStart();
1335
1352
  });
1336
1353
  window.socket.addEventListener('error', function() {
1337
1354
  retryOrFail();
@@ -1661,7 +1678,10 @@ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
1661
1678
  }
1662
1679
  res.writeHead(200, HTML_HEADERS);
1663
1680
  res.end(mainGroupHTML);
1664
- saveHTML(`${groupConfig.projectRoot}/${groupConfig.output}/index.html`, mainGroupHTML);
1681
+ saveHTML(
1682
+ path5.join(path5.resolve(groupConfig.projectRoot, groupConfig.output), "index.html"),
1683
+ mainGroupHTML
1684
+ );
1665
1685
  });
1666
1686
  server.get(`/group-${groupId}/tests.js`, (_req, res) => {
1667
1687
  const bytes = groupCachedContent.allTestCode?.length ?? null;
@@ -1755,10 +1775,10 @@ function registerSharedStaticHandler(server, groupConfigs) {
1755
1775
  res.end("Not found");
1756
1776
  return;
1757
1777
  }
1758
- const STATIC_FILES_PATH = path4.join(groupConfig.projectRoot, groupConfig.output);
1778
+ const STATIC_FILES_PATH = path5.resolve(groupConfig.projectRoot, groupConfig.output);
1759
1779
  const subPath = match[2] || "/";
1760
1780
  const filePath = (subPath.endsWith("/") ? [STATIC_FILES_PATH, subPath, "index.html"] : [STATIC_FILES_PATH, subPath]).join("");
1761
- const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path4.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1781
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path5.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
1762
1782
  const stream = fs8.createReadStream(filePath);
1763
1783
  stream.on("open", () => {
1764
1784
  res.writeHead(200, { "Content-Type": contentType });
@@ -1828,13 +1848,23 @@ async function launchBrowser(config) {
1828
1848
  );
1829
1849
  if (prelaunch) {
1830
1850
  const connectStart = Date.now();
1831
- const browser = await playwrightCore2.chromium.connectOverCDP({
1832
- endpointURL: prelaunch.cdpEndpoint
1833
- });
1834
- perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
1835
- return browser;
1851
+ try {
1852
+ const browser = await playwrightCore2.chromium.connectOverCDP({
1853
+ endpointURL: prelaunch.cdpEndpoint,
1854
+ // Short timeout: if Chrome isn't CDP-ready within 5s (e.g. resource contention on
1855
+ // slow CI runners with many concurrent pre-launches), fall back to chromium.launch().
1856
+ timeout: 5e3
1857
+ });
1858
+ perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
1859
+ return browser;
1860
+ } catch {
1861
+ perfLog(
1862
+ `browser.js: connectOverCDP failed after ${Date.now() - connectStart}ms \u2014 falling back to chromium.launch()`
1863
+ );
1864
+ await shutdownPrelaunch();
1865
+ }
1836
1866
  }
1837
- const executablePath = await findChrome();
1867
+ const executablePath = process.platform !== "darwin" ? await findChrome() : null;
1838
1868
  const launchOptions = {
1839
1869
  args: CHROMIUM_ARGS,
1840
1870
  headless: true,
@@ -1930,9 +1960,11 @@ var init_browser = __esm({
1930
1960
 
1931
1961
  // lib/utils/open-output-in-browser.ts
1932
1962
  import { spawn as spawn2 } from "node:child_process";
1963
+ import path6 from "node:path";
1964
+ import { pathToFileURL } from "node:url";
1933
1965
  async function openOutputInBrowser(config) {
1934
1966
  try {
1935
- const outputFile = config.watch ? `http://localhost:${config.port}` : `file://${config.projectRoot}/${config.output}/index.html`;
1967
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
1936
1968
  if (typeof config.open === "string") {
1937
1969
  spawnDetached(config.open, [outputFile]);
1938
1970
  return;
@@ -1978,9 +2010,10 @@ var init_time_counter = __esm({
1978
2010
  });
1979
2011
 
1980
2012
  // lib/utils/run-user-module.ts
2013
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
1981
2014
  async function runUserModule(modulePath, params, scriptPosition) {
1982
2015
  try {
1983
- const func = await import(modulePath);
2016
+ const func = await import(pathToFileURL2(modulePath).href);
1984
2017
  if (func) {
1985
2018
  func.default ? await func.default(params) : typeof func === "function" ? await func(params) : null;
1986
2019
  }
@@ -2023,8 +2056,14 @@ var init_display_final_result = __esm({
2023
2056
 
2024
2057
  // lib/commands/run/tests-in-browser.ts
2025
2058
  import fs9 from "node:fs/promises";
2026
- import path5 from "node:path";
2059
+ import path7 from "node:path";
2027
2060
  import esbuild from "esbuild";
2061
+ function toEsbuildImportPath(filePath) {
2062
+ const rel = path7.relative(process.cwd(), filePath);
2063
+ const normalized = rel.replace(/\\/g, "/");
2064
+ if (path7.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
2065
+ return normalized.startsWith(".") ? normalized : "./" + normalized;
2066
+ }
2028
2067
  function deriveBuildErrorType(error) {
2029
2068
  const msgs = error?.errors ?? [];
2030
2069
  const text = msgs[0]?.text ?? (error instanceof Error ? error.message : String(error));
@@ -2060,13 +2099,14 @@ async function buildTestBundle(config, cachedContent) {
2060
2099
  console.log("# [buildTestBundle] fsTree is empty \u2014 skipping build (no test files found)");
2061
2100
  return;
2062
2101
  }
2063
- const outfile = `${projectRoot}/${output}/tests.js`;
2064
- await fs9.mkdir(`${projectRoot}/${output}`, { recursive: true });
2102
+ const outDir = path7.resolve(projectRoot, output);
2103
+ const outfile = path7.join(outDir, "tests.js");
2104
+ await fs9.mkdir(outDir, { recursive: true });
2065
2105
  const sourcemap = "inline";
2066
2106
  const needsDisk = true;
2067
2107
  const buildOptions = {
2068
2108
  stdin: {
2069
- contents: allTestFilePaths.map((filePath) => `import "${filePath}";`).join(""),
2109
+ contents: allTestFilePaths.map((filePath) => `import "${toEsbuildImportPath(filePath)}";`).join(""),
2070
2110
  resolveDir: process.cwd()
2071
2111
  },
2072
2112
  // Allow test files outside the project root (e.g. /tmp/my-test.ts) to import
@@ -2093,30 +2133,28 @@ async function buildTestBundle(config, cachedContent) {
2093
2133
  config.watch ? buildIncrementally(buildOptions, allTestFilePaths.join("\0"), cachedContent, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
2094
2134
  Promise.all(
2095
2135
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
2096
- const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
2136
+ const targetPath = path7.join(outDir, htmlPath);
2097
2137
  if (htmlPath !== "/") {
2098
2138
  await fs9.rm(targetPath, { force: true, recursive: true });
2099
- await fs9.mkdir(targetPath.split("/").slice(0, -1).join("/"), { recursive: true });
2139
+ await fs9.mkdir(path7.dirname(targetPath), { recursive: true });
2100
2140
  }
2101
2141
  })
2102
2142
  )
2103
2143
  ]);
2104
2144
  cachedContent.allTestCode = allTestCode;
2105
- config._sourceMapDecoder = extractInlineSourceMap(allTestCode, `${projectRoot}/${output}`);
2145
+ config._sourceMapDecoder = extractInlineSourceMap(allTestCode, outDir);
2106
2146
  } catch (error) {
2107
2147
  cachedContent._buildError = {
2108
2148
  type: deriveBuildErrorType(error),
2109
2149
  formatted: formatBuildErrors(error)
2110
2150
  };
2111
- await fs9.writeFile(
2112
- `${projectRoot}/${output}/index.html`,
2113
- buildErrorHTML(cachedContent._buildError)
2114
- );
2151
+ await fs9.writeFile(path7.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
2115
2152
  throw error;
2116
2153
  }
2117
2154
  }
2118
2155
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
2119
2156
  const { projectRoot, output } = config;
2157
+ const outDir = path7.resolve(projectRoot, output);
2120
2158
  const allTestFilePaths = Object.keys(config.fsTree);
2121
2159
  const runHasFilter = !!targetTestFilesToFilter;
2122
2160
  if (!config._groupMode) {
@@ -2144,16 +2182,13 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
2144
2182
  return connections;
2145
2183
  }
2146
2184
  if (runHasFilter) {
2147
- const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
2185
+ const outputPath = path7.join(outDir, "filtered-tests.js");
2148
2186
  cachedContent.filteredTestCode = await buildFilteredTests(
2149
2187
  targetTestFilesToFilter,
2150
2188
  outputPath,
2151
2189
  config
2152
2190
  );
2153
- config._sourceMapDecoder = extractInlineSourceMap(
2154
- cachedContent.filteredTestCode,
2155
- `${projectRoot}/${output}`
2156
- );
2191
+ config._sourceMapDecoder = extractInlineSourceMap(cachedContent.filteredTestCode, outDir);
2157
2192
  }
2158
2193
  const TIME_COUNTER = timeCounter();
2159
2194
  if (runHasFilter) {
@@ -2187,7 +2222,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
2187
2222
  console.log(
2188
2223
  `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
2189
2224
  );
2190
- fs9.writeFile(`${projectRoot}/${output}/index.html`, buildNoTestsHTML(displayFiles)).catch(
2225
+ fs9.writeFile(path7.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
2191
2226
  () => {
2192
2227
  }
2193
2228
  );
@@ -2216,7 +2251,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
2216
2251
  formatted: formatBuildErrors(error)
2217
2252
  };
2218
2253
  fs9.writeFile(
2219
- `${projectRoot}/${output}/qunitx.html`,
2254
+ path7.join(outDir, "qunitx.html"),
2220
2255
  buildErrorHTML(cachedContent._buildError)
2221
2256
  ).catch(
2222
2257
  (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
@@ -2237,7 +2272,7 @@ function buildFilteredTests(filteredTests, outputPath, config) {
2237
2272
  return buildWithOverlayfsRetry(
2238
2273
  {
2239
2274
  stdin: {
2240
- contents: filteredTests.map((filePath) => `import "${filePath}";`).join(""),
2275
+ contents: filteredTests.map((filePath) => `import "${filePath.replace(/\\/g, "/")}";`).join(""),
2241
2276
  resolveDir: process.cwd()
2242
2277
  },
2243
2278
  nodePaths: ANCESTOR_NODE_MODULES,
@@ -2428,7 +2463,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2428
2463
  );
2429
2464
  await Promise.all(
2430
2465
  activeGroups.map(
2431
- (group) => fs9.mkdir(`${group.config.projectRoot}/${group.config.output}`, { recursive: true })
2466
+ (group) => fs9.mkdir(path7.resolve(group.config.projectRoot, group.config.output), { recursive: true })
2432
2467
  )
2433
2468
  );
2434
2469
  const sourcemap = "inline";
@@ -2442,13 +2477,13 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2442
2477
  build.onLoad({ filter: /.*/, namespace: "group-entry" }, (args) => {
2443
2478
  const slotIndex = parseInt(args.path.replace("group-entry-", ""));
2444
2479
  return {
2445
- contents: activeGroups[slotIndex].files.map((filePath) => `import "${filePath}";`).join(""),
2480
+ contents: activeGroups[slotIndex].files.map((filePath) => `import "${toEsbuildImportPath(filePath)}";`).join(""),
2446
2481
  resolveDir: process.cwd()
2447
2482
  };
2448
2483
  });
2449
2484
  }
2450
2485
  };
2451
- const esbuildOutdir = path5.join(projectRoot, "tmp");
2486
+ const esbuildOutdir = path7.join(projectRoot, "tmp");
2452
2487
  const buildOptions = {
2453
2488
  entryPoints: activeGroups.map((_, slotIndex) => ({
2454
2489
  in: `group-entry-${slotIndex}`,
@@ -2486,7 +2521,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2486
2521
  const slotIndex = parseInt(match[1]);
2487
2522
  const isMap = Boolean(match[2]);
2488
2523
  const { config, cachedContent } = activeGroups[slotIndex];
2489
- const destPath = `${config.projectRoot}/${config.output}/tests.js${isMap ? ".map" : ""}`;
2524
+ const destPath = path7.join(
2525
+ path7.resolve(config.projectRoot, config.output),
2526
+ "tests.js" + (isMap ? ".map" : "")
2527
+ );
2490
2528
  if (!isMap) {
2491
2529
  cachedContent.allTestCode = Buffer.from(outputFile.contents);
2492
2530
  config._sourceMapDecoder = extractInlineSourceMap(
@@ -2503,7 +2541,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2503
2541
  await Promise.all(
2504
2542
  activeGroups.map((group) => {
2505
2543
  group.cachedContent._buildError = buildError;
2506
- return fs9.writeFile(`${group.config.projectRoot}/${group.config.output}/index.html`, errorHtml).catch(
2544
+ return fs9.writeFile(
2545
+ path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
2546
+ errorHtml
2547
+ ).catch(
2507
2548
  (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
2508
2549
  `)
2509
2550
  );
@@ -2529,8 +2570,8 @@ var init_tests_in_browser = __esm({
2529
2570
  this.message = `esbuild Bundle Error: ${message}`.split("\n").join("\n# ");
2530
2571
  }
2531
2572
  };
2532
- ancestorNodeModules = (dir) => dir.split(path5.sep).map(
2533
- (_, i, parts) => path5.join(parts.slice(0, parts.length - i).join(path5.sep) || path5.sep, "node_modules")
2573
+ ancestorNodeModules = (dir) => dir.split(path7.sep).map(
2574
+ (_, i, parts) => path7.join(parts.slice(0, parts.length - i).join(path7.sep) || path7.sep, "node_modules")
2534
2575
  );
2535
2576
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
2536
2577
  RETRY_DELAY_MS = 100;
@@ -2548,7 +2589,7 @@ var init_tests_in_browser = __esm({
2548
2589
  // lib/setup/file-watcher.ts
2549
2590
  import fs10 from "node:fs";
2550
2591
  import { stat, lstat } from "node:fs/promises";
2551
- import path6 from "node:path";
2592
+ import path8 from "node:path";
2552
2593
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
2553
2594
  const extensions = config.extensions || ["js", "ts"];
2554
2595
  const readyPromises = [];
@@ -2557,13 +2598,17 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2557
2598
  const symlinkPollers = /* @__PURE__ */ new Map();
2558
2599
  function trackSymlink(filePath) {
2559
2600
  if (symlinkPollers.has(filePath)) return;
2560
- const handler = (curr) => {
2601
+ const handler = (curr, prev) => {
2561
2602
  if (curr.nlink === 0) {
2562
2603
  fs10.unwatchFile(filePath, handler);
2563
2604
  symlinkPollers.delete(filePath);
2564
2605
  if (filePath in config.fsTree) {
2565
2606
  handleWatchEvent(config, extensions, "unlink", filePath, onEventFunc, onFinishFunc);
2566
2607
  }
2608
+ } else if ((process.platform === "win32" || process.platform === "darwin") && curr.mtimeMs !== prev.mtimeMs) {
2609
+ if (filePath in config.fsTree) {
2610
+ handleWatchEvent(config, extensions, "change", filePath, onEventFunc, onFinishFunc);
2611
+ }
2567
2612
  }
2568
2613
  };
2569
2614
  fs10.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
@@ -2575,22 +2620,23 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2575
2620
  }
2576
2621
  for (const watchPath of testFileLookupPaths) {
2577
2622
  let ready = false;
2578
- const lastChangeMs = {};
2623
+ const lastEventMs = {};
2624
+ const seenMtimeMs = {};
2579
2625
  const childWatcher = fs10.watch(watchPath, { recursive: true }, async (eventType, filename) => {
2580
2626
  if (!ready || !filename) return;
2581
- const fullPath = filename === path6.basename(watchPath) ? watchPath : path6.join(watchPath, filename);
2627
+ const fullPath = filename === path8.basename(watchPath) ? watchPath : path8.join(watchPath, filename);
2582
2628
  if (eventType === "change") {
2583
2629
  const now = Date.now();
2584
- const last = lastChangeMs[fullPath] ?? 0;
2585
- lastChangeMs[fullPath] = now;
2586
- if (now - last < CHANGE_DEDUPE_MS && (config._building || !config._lastBuildEndMs || config._lastBuildEndMs <= last))
2587
- return;
2588
- if (config._lastBuildEndMs) {
2589
- try {
2590
- const { mtimeMs } = await stat(fullPath);
2591
- if (mtimeMs < Math.floor(config._lastBuildEndMs / 1e3) * 1e3) return;
2592
- } catch {
2593
- }
2630
+ const last = lastEventMs[fullPath] ?? 0;
2631
+ lastEventMs[fullPath] = now;
2632
+ try {
2633
+ const { mtimeMs } = await stat(fullPath);
2634
+ const prevMtime = seenMtimeMs[fullPath] ?? 0;
2635
+ seenMtimeMs[fullPath] = mtimeMs;
2636
+ if (now - last < CHANGE_DEDUPE_MS && mtimeMs > 0 && mtimeMs === prevMtime) return;
2637
+ if (config._lastBuildEndMs && mtimeMs < Math.floor(config._lastBuildEndMs / 1e3) * 1e3)
2638
+ return;
2639
+ } catch {
2594
2640
  }
2595
2641
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
2596
2642
  }
@@ -2607,8 +2653,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
2607
2653
  }
2608
2654
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
2609
2655
  });
2610
- const parentDir = path6.dirname(watchPath);
2611
- const watchedBasename = path6.basename(watchPath);
2656
+ const parentDir = path8.dirname(watchPath);
2657
+ const watchedBasename = path8.basename(watchPath);
2612
2658
  let parentUnlinkFired = false;
2613
2659
  const parentWatcher = fs10.watch(parentDir, async (eventType, filename) => {
2614
2660
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
@@ -2664,14 +2710,16 @@ async function classifyRenameEvent(fullPath, fsTree) {
2664
2710
  if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2665
2711
  try {
2666
2712
  const statResult = await stat(fullPath);
2667
- return statResult.isDirectory() ? "addDir" : "add";
2713
+ if (statResult.isDirectory()) return "addDir";
2714
+ return fsTree && fullPath in fsTree ? "change" : "add";
2668
2715
  } catch {
2669
2716
  }
2670
2717
  }
2671
2718
  if (!fsTree) return null;
2672
2719
  if (fullPath in fsTree) return "unlink";
2673
- const dirPrefix = fullPath + "/";
2674
- return Object.keys(fsTree).some((trackedPath) => trackedPath.startsWith(dirPrefix)) ? "unlinkDir" : null;
2720
+ return Object.keys(fsTree).some(
2721
+ (trackedPath) => trackedPath.startsWith(fullPath + "/") || trackedPath.startsWith(fullPath + "\\")
2722
+ ) ? "unlinkDir" : null;
2675
2723
  }
2676
2724
  function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
2677
2725
  if (event !== "unlinkDir" && !extensions.some((ext) => filePath.endsWith(`.${ext}`)))
@@ -2711,15 +2759,15 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
2711
2759
  }
2712
2760
  });
2713
2761
  }
2714
- function mutateFSTree(fsTree, event, path7) {
2762
+ function mutateFSTree(fsTree, event, filePath) {
2715
2763
  if (event === "add") {
2716
- fsTree[path7] = null;
2764
+ fsTree[filePath] = null;
2717
2765
  } else if (event === "unlink") {
2718
- delete fsTree[path7];
2766
+ delete fsTree[filePath];
2719
2767
  } else if (event === "unlinkDir") {
2720
- const dirPrefix = path7.endsWith("/") ? path7 : path7 + "/";
2721
2768
  for (const treePath of Object.keys(fsTree)) {
2722
- if (treePath.startsWith(dirPrefix)) delete fsTree[treePath];
2769
+ if (treePath.startsWith(filePath + "/") || treePath.startsWith(filePath + "\\"))
2770
+ delete fsTree[treePath];
2723
2771
  }
2724
2772
  }
2725
2773
  }
@@ -2815,24 +2863,27 @@ var init_keyboard_events = __esm({
2815
2863
 
2816
2864
  // lib/setup/write-output-static-files.ts
2817
2865
  import fs11 from "node:fs/promises";
2866
+ import path9 from "node:path";
2818
2867
  async function writeOutputStaticFiles({ projectRoot, output }, cachedContent) {
2819
2868
  const staticHTMLPromises = Object.keys(cachedContent.staticHTMLs).map(async (staticHTMLKey) => {
2820
- const htmlRelativePath = staticHTMLKey.replace(`${projectRoot}/`, "");
2821
- await ensureFolderExists(`${projectRoot}/${output}/${htmlRelativePath}`);
2869
+ const htmlRelativePath = path9.relative(projectRoot, staticHTMLKey);
2870
+ const outDir = path9.resolve(projectRoot, output);
2871
+ await ensureFolderExists(path9.join(outDir, htmlRelativePath));
2822
2872
  await fs11.writeFile(
2823
- `${projectRoot}/${output}/${htmlRelativePath}`,
2873
+ path9.join(outDir, htmlRelativePath),
2824
2874
  cachedContent.staticHTMLs[staticHTMLKey]
2825
2875
  );
2826
2876
  });
2827
2877
  const assetPromises = Array.from(cachedContent.assets).map(async (assetAbsolutePath) => {
2828
- const assetRelativePath = assetAbsolutePath.replace(`${projectRoot}/`, "");
2829
- await ensureFolderExists(`${projectRoot}/${output}/${assetRelativePath}`);
2830
- await fs11.copyFile(assetAbsolutePath, `${projectRoot}/${output}/${assetRelativePath}`);
2878
+ const assetRelativePath = path9.relative(projectRoot, assetAbsolutePath);
2879
+ const outDir = path9.resolve(projectRoot, output);
2880
+ await ensureFolderExists(path9.join(outDir, assetRelativePath));
2881
+ await fs11.copyFile(assetAbsolutePath, path9.join(outDir, assetRelativePath));
2831
2882
  });
2832
2883
  await Promise.all(staticHTMLPromises.concat(assetPromises));
2833
2884
  }
2834
2885
  async function ensureFolderExists(assetPath) {
2835
- await fs11.mkdir(assetPath.split("/").slice(0, -1).join("/"), { recursive: true });
2886
+ await fs11.mkdir(path9.dirname(assetPath), { recursive: true });
2836
2887
  }
2837
2888
  var init_write_output_static_files = __esm({
2838
2889
  "lib/setup/write-output-static-files.ts"() {
@@ -3062,7 +3113,7 @@ async function run(config) {
3062
3113
  (err) => config.debug && process.stderr.write(`# [qunitx] persistTimings: ${err.message}
3063
3114
  `)
3064
3115
  );
3065
- printFileTimings(fileTimes, config.projectRoot);
3116
+ if (config.debug) printFileTimings(fileTimes, config.projectRoot);
3066
3117
  if (config.after) {
3067
3118
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, "after");
3068
3119
  }
@@ -3248,7 +3299,7 @@ init_color();
3248
3299
  var package_default = {
3249
3300
  name: "qunitx-cli",
3250
3301
  type: "module",
3251
- version: "0.20.0",
3302
+ version: "0.21.0",
3252
3303
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
3253
3304
  author: "Izel Nakri",
3254
3305
  license: "MIT",
@@ -3304,9 +3355,9 @@ var package_default = {
3304
3355
  },
3305
3356
  devDependencies: {
3306
3357
  "js-yaml": "^4.1.1",
3307
- prettier: "^3.8.2",
3308
- qunitx: "^1.2.7",
3309
- typescript: "^6.0.2"
3358
+ prettier: "^3.8.3",
3359
+ qunitx: "^1.2.8",
3360
+ typescript: "^6.0.3"
3310
3361
  },
3311
3362
  volta: {
3312
3363
  node: "24.14.0"
@@ -3364,9 +3415,9 @@ import process2 from "node:process";
3364
3415
 
3365
3416
  // lib/utils/path-exists.ts
3366
3417
  import fs2 from "node:fs/promises";
3367
- async function pathExists(path7) {
3418
+ async function pathExists(path10) {
3368
3419
  try {
3369
- await fs2.access(path7);
3420
+ await fs2.access(path10);
3370
3421
  return true;
3371
3422
  } catch {
3372
3423
  return false;
@@ -3438,7 +3489,7 @@ async function writeTestsHTML(projectRoot, config, oldPackageJSON) {
3438
3489
  const targetDirectory = path2.dirname(targetPath);
3439
3490
  const _targetOutputPath = path2.relative(
3440
3491
  targetDirectory,
3441
- `${projectRoot}/${config.output}/tests.js`
3492
+ path2.join(path2.resolve(projectRoot, config.output), "tests.js")
3442
3493
  );
3443
3494
  const testHTMLTemplate = testHTMLTemplateBuffer.replace(
3444
3495
  "{{applicationName}}",
@@ -3484,17 +3535,17 @@ function pathToModuleName(filePath) {
3484
3535
  async function generateTestFiles() {
3485
3536
  const projectRoot = await findProjectRoot();
3486
3537
  const moduleName = pathToModuleName(process.argv[3]);
3487
- const path7 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
3488
- if (await pathExists(path7)) {
3489
- console.log(`${path7} already exists!`);
3538
+ const path10 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
3539
+ if (await pathExists(path10)) {
3540
+ console.log(`${path10} already exists!`);
3490
3541
  return;
3491
3542
  }
3492
3543
  const testJSContent = await readTemplate("test.js");
3493
- const targetFolderPaths = path7.split("/");
3544
+ const targetFolderPaths = path10.split("/");
3494
3545
  targetFolderPaths.pop();
3495
3546
  await fs5.mkdir(targetFolderPaths.join("/"), { recursive: true });
3496
- await fs5.writeFile(path7, testJSContent.replace("{{moduleName}}", moduleName));
3497
- console.log(green(`${path7} written`));
3547
+ await fs5.writeFile(path10, testJSContent.replace("{{moduleName}}", moduleName));
3548
+ console.log(green(`${path10} written`));
3498
3549
  }
3499
3550
 
3500
3551
  // lib/setup/config.ts
@@ -3595,23 +3646,24 @@ function setupTestFilePaths(_projectRoot, inputs2) {
3595
3646
  });
3596
3647
  return result.map((metaItem) => metaItem.input);
3597
3648
  }
3598
- function pathIsFile(path7) {
3599
- const inputs2 = path7.split("/");
3649
+ function pathIsFile(path10) {
3650
+ const inputs2 = path10.split("/");
3600
3651
  return inputs2[inputs2.length - 1].includes(".");
3601
3652
  }
3602
3653
  function pathIsIncludedInPaths(paths, targetPath) {
3603
- return paths.some((path7) => {
3604
- if (path7 === targetPath) {
3654
+ return paths.some((path10) => {
3655
+ if (path10 === targetPath) {
3605
3656
  return false;
3606
3657
  }
3607
- return matchesGlob(targetPath.input, buildGlobFormat(path7));
3658
+ return matchesGlob(targetPath.input, buildGlobFormat(path10));
3608
3659
  });
3609
3660
  }
3610
- function buildGlobFormat(path7) {
3611
- return path7.isFile ? path7.input : `${path7.input}/**`;
3661
+ function buildGlobFormat(path10) {
3662
+ return path10.isFile ? path10.input : `${path10.input}/**`;
3612
3663
  }
3613
3664
 
3614
3665
  // lib/utils/parse-cli-flags.ts
3666
+ import path4 from "node:path";
3615
3667
  var FALLBACK_TIMEOUT_MS = 1e4;
3616
3668
  function parseCliFlags(projectRoot) {
3617
3669
  const providedFlags = process.argv.slice(2).reduce(
@@ -3664,12 +3716,22 @@ function parseCliFlags(projectRoot) {
3664
3716
  return result;
3665
3717
  }
3666
3718
  result.inputs.add(
3667
- arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : `${process.cwd()}/${arg}`
3719
+ arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : path4.join(process.cwd(), arg)
3668
3720
  );
3669
3721
  return result;
3670
3722
  },
3671
3723
  { inputs: /* @__PURE__ */ new Set([]) }
3672
3724
  );
3725
+ if (!providedFlags.browser && process.env.QUNITX_BROWSER) {
3726
+ const envBrowser = process.env.QUNITX_BROWSER;
3727
+ if (!["chromium", "firefox", "webkit"].includes(envBrowser)) {
3728
+ console.error(
3729
+ `Invalid QUNITX_BROWSER value: "${envBrowser}". Must be one of: chromium, firefox, webkit`
3730
+ );
3731
+ process.exit(1);
3732
+ }
3733
+ providedFlags.browser = envBrowser;
3734
+ }
3673
3735
  return { ...providedFlags, inputs: Array.from(providedFlags.inputs) };
3674
3736
  }
3675
3737
  function parseBoolean(result, defaultValue = true) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.20.0",
4
+ "version": "0.21.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "js-yaml": "^4.1.1",
60
- "prettier": "^3.8.2",
61
- "qunitx": "^1.2.7",
62
- "typescript": "^6.0.2"
60
+ "prettier": "^3.8.3",
61
+ "qunitx": "^1.2.8",
62
+ "typescript": "^6.0.3"
63
63
  },
64
64
  "volta": {
65
65
  "node": "24.14.0"