qunitx-cli 0.23.6 → 0.24.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 +353 -149
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -374,7 +374,7 @@ var init_package = __esm({
374
374
  package_default = {
375
375
  name: "qunitx-cli",
376
376
  type: "module",
377
- version: "0.23.6",
377
+ version: "0.24.0",
378
378
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
379
379
  author: "Izel Nakri",
380
380
  license: "MIT",
@@ -519,6 +519,8 @@ ${color("--browser")} : browser engine to run tests in: chromium, firefox, webki
519
519
  ${color("--before")} : run a script before the tests(i.e start a new web server before tests)
520
520
  ${color("--after")} : run a script after the tests(i.e save test results to a file)
521
521
  ${color("--no-daemon")} : don't use the daemon for this run \u2014 skips a running daemon and prevents ${color("QUNITX_DAEMON")} auto-spawn
522
+ ${color("--changed")} : run only test files affected by changes since ${color("HEAD")} (requires git; falls back to running all on first use)
523
+ ${color("--since")} : run only test files affected by changes since the given git ref (e.g. ${color("--since=main")}); ${color("--changed")} = ${color("--since=HEAD")}
522
524
  ${color("--trace-perf")} : write timestamped startup-perf trace lines to stderr (Chrome pre-launch, module load, browser bind)
523
525
 
524
526
  ${highlight("Example:")} $ ${color("qunitx test/foo.ts app/e2e --debug --watch --before=scripts/start-new-webserver.js --after=scripts/write-test-results.js")}
@@ -547,9 +549,9 @@ var init_help = __esm({
547
549
 
548
550
  // lib/utils/path-exists.ts
549
551
  import fs2 from "node:fs/promises";
550
- async function pathExists(path14) {
552
+ async function pathExists(path17) {
551
553
  try {
552
- await fs2.access(path14);
554
+ await fs2.access(path17);
553
555
  return true;
554
556
  } catch {
555
557
  return false;
@@ -641,17 +643,17 @@ import fs4 from "node:fs/promises";
641
643
  async function generateTestFiles() {
642
644
  const projectRoot = await findProjectRoot();
643
645
  const moduleName = pathToModuleName(process.argv[3]);
644
- const path14 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
645
- if (await pathExists(path14)) {
646
- console.log(`${path14} already exists!`);
646
+ const path17 = process.argv[3].endsWith(".js") || process.argv[3].endsWith(".ts") ? `${projectRoot}/${process.argv[3]}` : `${projectRoot}/${process.argv[3]}.js`;
647
+ if (await pathExists(path17)) {
648
+ console.log(`${path17} already exists!`);
647
649
  return;
648
650
  }
649
651
  const testJSContent = await readTemplate("test.js");
650
- const targetFolderPaths = path14.split("/");
652
+ const targetFolderPaths = path17.split("/");
651
653
  targetFolderPaths.pop();
652
654
  await fs4.mkdir(targetFolderPaths.join("/"), { recursive: true });
653
- await fs4.writeFile(path14, testJSContent.replace("{{moduleName}}", moduleName));
654
- console.log(green(`${path14} written`));
655
+ await fs4.writeFile(path17, testJSContent.replace("{{moduleName}}", moduleName));
656
+ console.log(green(`${path17} written`));
655
657
  }
656
658
  function pathToModuleName(filePath) {
657
659
  const withoutExt = filePath.replace(/\.(js|ts)$/, "");
@@ -892,9 +894,9 @@ async function pingDaemon() {
892
894
  socket.end();
893
895
  return pong;
894
896
  }
895
- async function shutdownDaemon() {
896
- const pid = await readDaemonPid();
897
- const socket = await tryConnect();
897
+ async function shutdownDaemon(cwd = process.cwd()) {
898
+ const pid = await readDaemonPid(cwd);
899
+ const socket = await tryConnect(cwd);
898
900
  if (!socket) return false;
899
901
  attachLineParser(socket, () => {
900
902
  });
@@ -903,9 +905,9 @@ async function shutdownDaemon() {
903
905
  if (pid !== null) await waitForPidExit(pid, SHUTDOWN_PID_WAIT_MS);
904
906
  return true;
905
907
  }
906
- async function readDaemonPid() {
908
+ async function readDaemonPid(cwd = process.cwd()) {
907
909
  try {
908
- const info = JSON.parse(await fs6.readFile(daemonInfoPath(), "utf8"));
910
+ const info = JSON.parse(await fs6.readFile(daemonInfoPath(cwd), "utf8"));
909
911
  return typeof info.pid === "number" ? info.pid : null;
910
912
  } catch {
911
913
  return null;
@@ -1068,11 +1070,11 @@ function setupTestFilePaths(inputs2) {
1068
1070
  }, []);
1069
1071
  return dedupedFolders.concat(dedupedGlobFiles, dedupedPlainFiles).map((meta) => meta.input);
1070
1072
  }
1071
- function pathIsFile(path14) {
1072
- return path14.includes(".", path14.lastIndexOf("/") + 1);
1073
+ function pathIsFile(path17) {
1074
+ return path17.includes(".", path17.lastIndexOf("/") + 1);
1073
1075
  }
1074
1076
  function isIncludedIn(paths, target) {
1075
- return paths.some((path14) => path14 !== target && matchesGlob(target.input, path14.globFormat));
1077
+ return paths.some((path17) => path17 !== target && matchesGlob(target.input, path17.globFormat));
1076
1078
  }
1077
1079
  function isGlob2(str) {
1078
1080
  return GLOB_CHARS.test(str);
@@ -1084,8 +1086,162 @@ var init_test_file_paths = __esm({
1084
1086
  }
1085
1087
  });
1086
1088
 
1087
- // lib/utils/parse-cli-flags.ts
1089
+ // lib/utils/get-changed-files.ts
1088
1090
  import path5 from "node:path";
1091
+ function getChangedFiles(metafile, esbuildCwd, changedAbsPaths, testFiles) {
1092
+ const deps = new Map(
1093
+ Object.entries(metafile.inputs).map(([relPath, info]) => [
1094
+ path5.resolve(esbuildCwd, relPath),
1095
+ (info.imports ?? []).map((i) => path5.resolve(esbuildCwd, i.path))
1096
+ ])
1097
+ );
1098
+ const memo = /* @__PURE__ */ new Map();
1099
+ return new Set(
1100
+ testFiles.filter((test) => reachesChange(test, deps, changedAbsPaths, memo, /* @__PURE__ */ new Set()))
1101
+ );
1102
+ }
1103
+ function reachesChange(node, deps, changedAbsPaths, memo, stack) {
1104
+ const cached = memo.get(node);
1105
+ if (cached !== void 0) return cached;
1106
+ if (stack.has(node)) return false;
1107
+ stack.add(node);
1108
+ const result2 = changedAbsPaths.has(node) || (deps.get(node) ?? []).some((dep) => reachesChange(dep, deps, changedAbsPaths, memo, stack));
1109
+ stack.delete(node);
1110
+ memo.set(node, result2);
1111
+ return result2;
1112
+ }
1113
+ var init_get_changed_files = __esm({
1114
+ "lib/utils/get-changed-files.ts"() {
1115
+ }
1116
+ });
1117
+
1118
+ // lib/utils/get-changed-file-paths-in-git-since.ts
1119
+ import { execFile } from "node:child_process";
1120
+ import { promisify } from "node:util";
1121
+ import path6 from "node:path";
1122
+ async function getChangedFilePathsInGitSince(projectRoot, ref) {
1123
+ const [diffOut, statusOut] = await Promise.all([
1124
+ execFileAsync("git", ["diff", "--name-only", "--no-renames", ref, "--", projectRoot], {
1125
+ cwd: projectRoot,
1126
+ maxBuffer: 16 * 1024 * 1024
1127
+ }).then((r) => r.stdout),
1128
+ execFileAsync("git", ["status", "--porcelain", "--untracked-files=all"], {
1129
+ cwd: projectRoot,
1130
+ maxBuffer: 16 * 1024 * 1024
1131
+ }).then((r) => r.stdout)
1132
+ ]);
1133
+ const fromStatus = (line) => {
1134
+ const rest = line.slice(3);
1135
+ const arrow = rest.indexOf(" -> ");
1136
+ return arrow === -1 ? rest : rest.slice(arrow + 4);
1137
+ };
1138
+ const relPaths = /* @__PURE__ */ new Set([
1139
+ ...diffOut.split("\n").filter(Boolean),
1140
+ ...statusOut.split("\n").filter((l) => l.length >= 4).map(fromStatus)
1141
+ ]);
1142
+ const isBlastRadius = (rel) => {
1143
+ const base = path6.basename(rel);
1144
+ return BLAST_RADIUS_FILES.has(base) || BLAST_RADIUS_PATTERNS.some((re) => re.test(base));
1145
+ };
1146
+ if (Array.from(relPaths).some(isBlastRadius)) return null;
1147
+ return new Set(Array.from(relPaths, (rel) => path6.resolve(projectRoot, rel)));
1148
+ }
1149
+ var execFileAsync, BLAST_RADIUS_FILES, BLAST_RADIUS_PATTERNS;
1150
+ var init_get_changed_file_paths_in_git_since = __esm({
1151
+ "lib/utils/get-changed-file-paths-in-git-since.ts"() {
1152
+ execFileAsync = promisify(execFile);
1153
+ BLAST_RADIUS_FILES = /* @__PURE__ */ new Set(["package.json", "package-lock.json", "deno.json", "deno.lock"]);
1154
+ BLAST_RADIUS_PATTERNS = [/^tsconfig.*\.json$/];
1155
+ }
1156
+ });
1157
+
1158
+ // lib/utils/metafile-cache.ts
1159
+ import fs8 from "node:fs/promises";
1160
+ import path7 from "node:path";
1161
+ import { createHash as createHash2 } from "node:crypto";
1162
+ function metafileCachePath(projectRoot) {
1163
+ const tag = createHash2("sha1").update(projectRoot).digest("hex").slice(0, 12);
1164
+ return path7.join(projectRoot, "node_modules", ".cache", "qunitx", tag, CACHE_FILE);
1165
+ }
1166
+ async function writeMetafileCache(projectRoot, esbuildCwd, metafile) {
1167
+ const file = metafileCachePath(projectRoot);
1168
+ try {
1169
+ await fs8.mkdir(path7.dirname(file), { recursive: true });
1170
+ await fs8.writeFile(
1171
+ file,
1172
+ JSON.stringify({ esbuildCwd, metafile })
1173
+ );
1174
+ } catch {
1175
+ }
1176
+ }
1177
+ async function readMetafileCache(projectRoot) {
1178
+ try {
1179
+ const raw = await fs8.readFile(metafileCachePath(projectRoot), "utf8");
1180
+ const parsed = JSON.parse(raw);
1181
+ if (typeof parsed?.esbuildCwd !== "string" || !parsed.metafile?.inputs) return null;
1182
+ return parsed;
1183
+ } catch {
1184
+ return null;
1185
+ }
1186
+ }
1187
+ var CACHE_FILE;
1188
+ var init_metafile_cache = __esm({
1189
+ "lib/utils/metafile-cache.ts"() {
1190
+ CACHE_FILE = "metafile.json";
1191
+ }
1192
+ });
1193
+
1194
+ // lib/setup/get-changed-fs-tree.ts
1195
+ async function getChangedFsTree(fsTree, projectRoot, changedSince) {
1196
+ const testFiles = Object.keys(fsTree);
1197
+ if (testFiles.length === 0) return fsTree;
1198
+ const cache = await readMetafileCache(projectRoot);
1199
+ if (!cache) {
1200
+ process.stdout.write(
1201
+ `# --changed: no metafile cache yet \u2014 running all ${testFiles.length} test files (cache populates on this run)
1202
+ `
1203
+ );
1204
+ return fsTree;
1205
+ }
1206
+ const changed = await getChangedFilePathsInGitSince(projectRoot, changedSince).catch(
1207
+ (err) => err
1208
+ );
1209
+ if (changed instanceof Error) {
1210
+ process.stdout.write(
1211
+ `# --changed: git lookup failed (${changed.message.split("\n")[0]}) \u2014 running all ${testFiles.length} test files
1212
+ `
1213
+ );
1214
+ return fsTree;
1215
+ } else if (changed === null) {
1216
+ process.stdout.write(
1217
+ `# --changed: blast-radius file changed (package.json / tsconfig.json / lockfile) \u2014 running all ${testFiles.length} test files
1218
+ `
1219
+ );
1220
+ return fsTree;
1221
+ } else if (changed.size === 0) {
1222
+ process.stdout.write(
1223
+ `# --changed: 0 files changed since ${changedSince} \u2014 running 0 test files
1224
+ `
1225
+ );
1226
+ return {};
1227
+ }
1228
+ const affected = getChangedFiles(cache.metafile, cache.esbuildCwd, changed, testFiles);
1229
+ process.stdout.write(
1230
+ `# --changed: ${affected.size} of ${testFiles.length} test files affected by changes since ${changedSince}
1231
+ `
1232
+ );
1233
+ return Object.fromEntries(testFiles.filter((f) => affected.has(f)).map((f) => [f, null]));
1234
+ }
1235
+ var init_get_changed_fs_tree = __esm({
1236
+ "lib/setup/get-changed-fs-tree.ts"() {
1237
+ init_get_changed_files();
1238
+ init_get_changed_file_paths_in_git_since();
1239
+ init_metafile_cache();
1240
+ }
1241
+ });
1242
+
1243
+ // lib/utils/parse-cli-flags.ts
1244
+ import path8 from "node:path";
1089
1245
  function parseCliFlags(projectRoot) {
1090
1246
  const providedFlags = process.argv.slice(2).reduce(
1091
1247
  (result2, arg) => {
@@ -1129,6 +1285,15 @@ function parseCliFlags(projectRoot) {
1129
1285
  return Object.assign(result2, { before: parseModule(arg.split("=")[1]) });
1130
1286
  } else if (arg.startsWith("--after")) {
1131
1287
  return Object.assign(result2, { after: parseModule(arg.split("=")[1]) });
1288
+ } else if (arg === "--changed") {
1289
+ return Object.assign(result2, { changedSince: "HEAD" });
1290
+ } else if (arg.startsWith("--since")) {
1291
+ const ref = arg.split("=")[1];
1292
+ if (!ref) {
1293
+ console.error(`Invalid --since value: empty. Expected --since=<git-ref>.`);
1294
+ process.exit(1);
1295
+ }
1296
+ return Object.assign(result2, { changedSince: ref });
1132
1297
  } else if (arg === "--trace-perf") {
1133
1298
  return result2;
1134
1299
  }
@@ -1137,7 +1302,7 @@ function parseCliFlags(projectRoot) {
1137
1302
  return result2;
1138
1303
  }
1139
1304
  result2.inputs.add(
1140
- arg.startsWith(projectRoot) || arg.startsWith("/") ? arg : path5.join(process.cwd(), arg)
1305
+ arg.startsWith(projectRoot) || path8.isAbsolute(arg) ? arg : path8.join(process.cwd(), arg)
1141
1306
  );
1142
1307
  return result2;
1143
1308
  },
@@ -1185,7 +1350,7 @@ __export(config_exports, {
1185
1350
  default: () => setupConfig,
1186
1351
  setupConfig: () => setupConfig
1187
1352
  });
1188
- import fs8 from "node:fs/promises";
1353
+ import fs9 from "node:fs/promises";
1189
1354
  import { createRequire } from "node:module";
1190
1355
  import { pathToFileURL } from "node:url";
1191
1356
  async function setupConfig() {
@@ -1223,10 +1388,13 @@ async function setupConfig() {
1223
1388
  buildFSTree(config.testFileLookupPaths, config),
1224
1389
  pluginsPromise
1225
1390
  ]);
1391
+ if (config.changedSince && !config.watch) {
1392
+ config.fsTree = await getChangedFsTree(config.fsTree, config.projectRoot, config.changedSince);
1393
+ }
1226
1394
  return config;
1227
1395
  }
1228
1396
  async function readConfigFromPackageJSON(projectRoot) {
1229
- const packageJSON = await fs8.readFile(`${projectRoot}/package.json`);
1397
+ const packageJSON = await fs9.readFile(`${projectRoot}/package.json`);
1230
1398
  return JSON.parse(packageJSON.toString());
1231
1399
  }
1232
1400
  function normalizeHTMLPaths(projectRoot, htmlPaths) {
@@ -1258,6 +1426,7 @@ var init_config = __esm({
1258
1426
  init_find_project_root();
1259
1427
  init_fs_tree();
1260
1428
  init_test_file_paths();
1429
+ init_get_changed_fs_tree();
1261
1430
  init_parse_cli_flags();
1262
1431
  }
1263
1432
  });
@@ -1529,13 +1698,13 @@ function extractSourceLine(content, lineIndex) {
1529
1698
  const line = content.split("\n", lineIndex + 1)[lineIndex];
1530
1699
  return line?.trim() || null;
1531
1700
  }
1532
- function normalizePosix(path14) {
1533
- const parts = path14.split("/").reduce((acc, part) => {
1701
+ function normalizePosix(path17) {
1702
+ const parts = path17.split("/").reduce((acc, part) => {
1534
1703
  if (part === "..") acc.pop();
1535
1704
  else if (part && part !== ".") acc.push(part);
1536
1705
  return acc;
1537
1706
  }, []);
1538
- return (path14.startsWith("/") ? "/" : "") + parts.join("/");
1707
+ return (path17.startsWith("/") ? "/" : "") + parts.join("/");
1539
1708
  }
1540
1709
  function toAbsolutePath(rawSource, outDir, sourceRoot) {
1541
1710
  if (rawSource.startsWith("file://")) return rawSource.slice(7);
@@ -1543,8 +1712,8 @@ function toAbsolutePath(rawSource, outDir, sourceRoot) {
1543
1712
  const base = sourceRoot ? normalizePosix(`${outDir}/${sourceRoot}`) : outDir;
1544
1713
  return normalizePosix(`${base}/${rawSource}`);
1545
1714
  }
1546
- function isNodeModulesPath(path14) {
1547
- return path14.includes("/node_modules/") || path14.includes("\\node_modules\\");
1715
+ function isNodeModulesPath(path17) {
1716
+ return path17.includes("/node_modules/") || path17.includes("\\node_modules\\");
1548
1717
  }
1549
1718
  function makeDisplayPath(absolutePath, projectRoot) {
1550
1719
  const prefix = projectRoot + "/";
@@ -1820,8 +1989,8 @@ var init_web = __esm({
1820
1989
  });
1821
1990
  }
1822
1991
  /** Registers a GET route handler. */
1823
- get(path14, handler) {
1824
- this.#registerRouteHandler("GET", path14, handler);
1992
+ get(path17, handler) {
1993
+ this.#registerRouteHandler("GET", path17, handler);
1825
1994
  }
1826
1995
  /**
1827
1996
  * Starts listening on the given port (0 = OS-assigned).
@@ -1852,32 +2021,32 @@ var init_web = __esm({
1852
2021
  });
1853
2022
  }
1854
2023
  /** Registers a POST route handler. */
1855
- post(path14, handler) {
1856
- this.#registerRouteHandler("POST", path14, handler);
2024
+ post(path17, handler) {
2025
+ this.#registerRouteHandler("POST", path17, handler);
1857
2026
  }
1858
2027
  /** Registers a DELETE route handler. */
1859
- delete(path14, handler) {
1860
- this.#registerRouteHandler("DELETE", path14, handler);
2028
+ delete(path17, handler) {
2029
+ this.#registerRouteHandler("DELETE", path17, handler);
1861
2030
  }
1862
2031
  /** Registers a PUT route handler. */
1863
- put(path14, handler) {
1864
- this.#registerRouteHandler("PUT", path14, handler);
2032
+ put(path17, handler) {
2033
+ this.#registerRouteHandler("PUT", path17, handler);
1865
2034
  }
1866
2035
  /** Adds a middleware function to the chain. */
1867
2036
  use(middleware) {
1868
2037
  this.middleware.push(middleware);
1869
2038
  }
1870
- #registerRouteHandler(method, path14, handler) {
2039
+ #registerRouteHandler(method, path17, handler) {
1871
2040
  if (!this.routes[method]) {
1872
2041
  this.routes[method] = {};
1873
2042
  }
1874
- const paramNames = this.#extractParamNames(path14);
1875
- this.routes[method][path14] = {
1876
- path: path14,
2043
+ const paramNames = this.#extractParamNames(path17);
2044
+ this.routes[method][path17] = {
2045
+ path: path17,
1877
2046
  handler,
1878
2047
  paramNames,
1879
- isWildcard: path14 === "/*",
1880
- compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path14, paramNames)}$`) : null
2048
+ isWildcard: path17 === "/*",
2049
+ compiledRegex: paramNames.length > 0 ? new RegExp(`^${this.#buildRegexPattern(path17, paramNames)}$`) : null
1881
2050
  };
1882
2051
  }
1883
2052
  #handleRequest(req, res) {
@@ -1915,11 +2084,11 @@ var init_web = __esm({
1915
2084
  return null;
1916
2085
  }
1917
2086
  return routes[url] || Object.values(routes).find((route) => {
1918
- const { path: path14, isWildcard } = route;
1919
- if (!isWildcard && !path14.includes(":")) {
2087
+ const { path: path17, isWildcard } = route;
2088
+ if (!isWildcard && !path17.includes(":")) {
1920
2089
  return false;
1921
2090
  }
1922
- if (isWildcard || this.#matchPathSegments(path14, url)) {
2091
+ if (isWildcard || this.#matchPathSegments(path17, url)) {
1923
2092
  if (route.compiledRegex) {
1924
2093
  const regexMatches = route.compiledRegex.exec(url);
1925
2094
  if (regexMatches) {
@@ -1931,8 +2100,8 @@ var init_web = __esm({
1931
2100
  return false;
1932
2101
  }) || null;
1933
2102
  }
1934
- #matchPathSegments(path14, url) {
1935
- const pathSegments = path14.split("/");
2103
+ #matchPathSegments(path17, url) {
2104
+ const pathSegments = path17.split("/");
1936
2105
  const urlSegments = url.split("/");
1937
2106
  if (pathSegments.length !== urlSegments.length) {
1938
2107
  return false;
@@ -1949,14 +2118,14 @@ var init_web = __esm({
1949
2118
  }
1950
2119
  return true;
1951
2120
  }
1952
- #buildRegexPattern(path14, _paramNames) {
1953
- let regexPattern = path14.replace(/:[^/]+/g, "([^/]+)");
2121
+ #buildRegexPattern(path17, _paramNames) {
2122
+ let regexPattern = path17.replace(/:[^/]+/g, "([^/]+)");
1954
2123
  regexPattern = regexPattern.replace(/\//g, "\\/");
1955
2124
  return regexPattern;
1956
2125
  }
1957
- #extractParamNames(path14) {
2126
+ #extractParamNames(path17) {
1958
2127
  const paramRegex = /:(\w+)/g;
1959
- const paramMatches = path14.match(paramRegex);
2128
+ const paramMatches = path17.match(paramRegex);
1960
2129
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
1961
2130
  }
1962
2131
  #extractParams(route, _url) {
@@ -1972,10 +2141,10 @@ var init_web = __esm({
1972
2141
  });
1973
2142
 
1974
2143
  // lib/setup/web-server.ts
1975
- import fs9 from "node:fs";
1976
- import path6 from "node:path";
2144
+ import fs10 from "node:fs";
2145
+ import path9 from "node:path";
1977
2146
  function setupWebServer(config, cachedContent) {
1978
- const STATIC_FILES_PATH = path6.resolve(config.projectRoot, config.output);
2147
+ const STATIC_FILES_PATH = path9.resolve(config.projectRoot, config.output);
1979
2148
  const server = new HTTPServer();
1980
2149
  const mainHTMLWithReplacedAssets = replaceAssetPaths(
1981
2150
  cachedContent.mainHTML.html,
@@ -2113,7 +2282,7 @@ function setupWebServer(config, cachedContent) {
2113
2282
  config._testRunDone = null;
2114
2283
  }
2115
2284
  return saveHTML(
2116
- path6.join(path6.resolve(config.projectRoot, config.output), "index.html"),
2285
+ path9.join(path9.resolve(config.projectRoot, config.output), "index.html"),
2117
2286
  htmlContent
2118
2287
  );
2119
2288
  }
@@ -2124,7 +2293,7 @@ function setupWebServer(config, cachedContent) {
2124
2293
  res.writeHead(200, HTML_HEADERS);
2125
2294
  res.end(mainIndexHTML);
2126
2295
  saveHTML(
2127
- path6.join(path6.resolve(config.projectRoot, config.output), "index.html"),
2296
+ path9.join(path9.resolve(config.projectRoot, config.output), "index.html"),
2128
2297
  mainIndexHTML
2129
2298
  );
2130
2299
  });
@@ -2134,7 +2303,7 @@ function setupWebServer(config, cachedContent) {
2134
2303
  res.writeHead(200, HTML_HEADERS);
2135
2304
  res.end(htmlContent);
2136
2305
  return saveHTML(
2137
- path6.join(path6.resolve(config.projectRoot, config.output), "qunitx.html"),
2306
+ path9.join(path9.resolve(config.projectRoot, config.output), "qunitx.html"),
2138
2307
  htmlContent
2139
2308
  );
2140
2309
  }
@@ -2145,7 +2314,7 @@ function setupWebServer(config, cachedContent) {
2145
2314
  res.writeHead(200, HTML_HEADERS);
2146
2315
  res.end(mainQunitxHTML);
2147
2316
  saveHTML(
2148
- path6.join(path6.resolve(config.projectRoot, config.output), "qunitx.html"),
2317
+ path9.join(path9.resolve(config.projectRoot, config.output), "qunitx.html"),
2149
2318
  mainQunitxHTML
2150
2319
  );
2151
2320
  });
@@ -2159,14 +2328,14 @@ function setupWebServer(config, cachedContent) {
2159
2328
  );
2160
2329
  res.writeHead(200, HTML_HEADERS);
2161
2330
  res.end(htmlContent);
2162
- saveHTML(path6.join(path6.resolve(config.projectRoot, config.output), req.path), htmlContent);
2331
+ saveHTML(path9.join(path9.resolve(config.projectRoot, config.output), req.path), htmlContent);
2163
2332
  return;
2164
2333
  }
2165
2334
  const url = req.url;
2166
2335
  const requestStartedAt = Date.now();
2167
2336
  const filePath = (url.endsWith("/") ? [STATIC_FILES_PATH, url, "index.html"] : [STATIC_FILES_PATH, url]).join("");
2168
- const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path6.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
2169
- const stream = fs9.createReadStream(filePath);
2337
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path9.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
2338
+ const stream = fs10.createReadStream(filePath);
2170
2339
  stream.on("open", () => {
2171
2340
  res.writeHead(200, { "Content-Type": contentType });
2172
2341
  stream.pipe(res);
@@ -2419,7 +2588,7 @@ function registerGroupRoutes(server, groupConfig, groupCachedContent, groupId) {
2419
2588
  res.writeHead(200, HTML_HEADERS);
2420
2589
  res.end(mainGroupHTML);
2421
2590
  saveHTML(
2422
- path6.join(path6.resolve(groupConfig.projectRoot, groupConfig.output), "index.html"),
2591
+ path9.join(path9.resolve(groupConfig.projectRoot, groupConfig.output), "index.html"),
2423
2592
  mainGroupHTML
2424
2593
  );
2425
2594
  });
@@ -2505,11 +2674,11 @@ function registerSharedStaticHandler(server, groupConfigs) {
2505
2674
  res.end("Not found");
2506
2675
  return;
2507
2676
  }
2508
- const STATIC_FILES_PATH = path6.resolve(groupConfig.projectRoot, groupConfig.output);
2677
+ const STATIC_FILES_PATH = path9.resolve(groupConfig.projectRoot, groupConfig.output);
2509
2678
  const subPath = match[2] || "/";
2510
2679
  const filePath = (subPath.endsWith("/") ? [STATIC_FILES_PATH, subPath, "index.html"] : [STATIC_FILES_PATH, subPath]).join("");
2511
- const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path6.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
2512
- const stream = fs9.createReadStream(filePath);
2680
+ const contentType = req.headers.accept?.includes("text/html") ? MIME_TYPES.html : MIME_TYPES[path9.extname(filePath).substring(1).toLowerCase()] || MIME_TYPES.html;
2681
+ const stream = fs10.createReadStream(filePath);
2513
2682
  stream.on("open", () => {
2514
2683
  res.writeHead(200, { "Content-Type": contentType });
2515
2684
  stream.pipe(res);
@@ -2524,7 +2693,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
2524
2693
  const assetPaths = findInternalAssetsFromHTML(html);
2525
2694
  const htmlDirectory = htmlPath.split("/").slice(0, -1).join("/");
2526
2695
  return assetPaths.reduce((result2, assetPath) => {
2527
- const normalizedFullAbsolutePath = path6.normalize(`${htmlDirectory}/${assetPath}`);
2696
+ const normalizedFullAbsolutePath = path9.normalize(`${htmlDirectory}/${assetPath}`);
2528
2697
  return result2.replace(assetPath, normalizedFullAbsolutePath.replace(projectRoot, "."));
2529
2698
  }, html);
2530
2699
  }
@@ -2686,7 +2855,7 @@ var init_web_server = __esm({
2686
2855
  init_display_test_result();
2687
2856
  init_color();
2688
2857
  init_web();
2689
- fsPromise = fs9.promises;
2858
+ fsPromise = fs10.promises;
2690
2859
  HTML_HEADERS = { "Content-Type": "text/html", "Cache-Control": "no-store" };
2691
2860
  WATCH_WS_RECONNECT_INTERVAL_MS = 1e3;
2692
2861
  WATCH_WS_RECONNECT_MAX_RETRIES = 120;
@@ -2905,8 +3074,8 @@ var init_display_final_result = __esm({
2905
3074
  });
2906
3075
 
2907
3076
  // lib/commands/run/tests-in-browser.ts
2908
- import fs10 from "node:fs/promises";
2909
- import path7 from "node:path";
3077
+ import fs11 from "node:fs/promises";
3078
+ import path10 from "node:path";
2910
3079
  import esbuild from "esbuild";
2911
3080
  function deriveBuildErrorType(error) {
2912
3081
  const msgs = error?.errors ?? [];
@@ -2946,9 +3115,9 @@ async function buildTestBundle(config, cachedContent) {
2946
3115
  console.log("# [buildTestBundle] fsTree is empty \u2014 skipping build (no test files found)");
2947
3116
  return;
2948
3117
  }
2949
- const outDir = path7.resolve(projectRoot, output);
2950
- const outfile = path7.join(outDir, "tests.js");
2951
- await fs10.mkdir(outDir, { recursive: true });
3118
+ const outDir = path10.resolve(projectRoot, output);
3119
+ const outfile = path10.join(outDir, "tests.js");
3120
+ await fs11.mkdir(outDir, { recursive: true });
2952
3121
  const sourcemap = "inline";
2953
3122
  const needsDisk = true;
2954
3123
  const buildOptions = {
@@ -2972,6 +3141,10 @@ async function buildTestBundle(config, cachedContent) {
2972
3141
  // tsconfig's `jsxImportSource` or a `@jsxImportSource <pkg>` pragma cover Vue/Preact/Solid.
2973
3142
  jsx: "automatic",
2974
3143
  plugins: config.plugins,
3144
+ // Required for --changed/--since dep-graph filter on subsequent runs (cache
3145
+ // populates here, reads in setupConfig). Inputs map carries the full reverse-dep
3146
+ // graph; output cost is negligible.
3147
+ metafile: true,
2975
3148
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
2976
3149
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
2977
3150
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
@@ -2983,32 +3156,33 @@ async function buildTestBundle(config, cachedContent) {
2983
3156
  const cacheHolder = config._daemonEsbuildCache ?? cachedContent;
2984
3157
  const fileKey = bundleCacheKey(buildOptions, allTestFilePaths);
2985
3158
  try {
2986
- const [allTestCode] = await Promise.all([
3159
+ const [{ js: allTestCode, metafile }] = await Promise.all([
2987
3160
  config.watch || config._daemonMode ? buildIncrementally(buildOptions, fileKey, cacheHolder, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
2988
3161
  Promise.all(
2989
3162
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
2990
- const targetPath = path7.join(outDir, htmlPath);
3163
+ const targetPath = path10.join(outDir, htmlPath);
2991
3164
  if (htmlPath !== "/") {
2992
- await fs10.rm(targetPath, { force: true, recursive: true });
2993
- await fs10.mkdir(path7.dirname(targetPath), { recursive: true });
3165
+ await fs11.rm(targetPath, { force: true, recursive: true });
3166
+ await fs11.mkdir(path10.dirname(targetPath), { recursive: true });
2994
3167
  }
2995
3168
  })
2996
3169
  )
2997
3170
  ]);
2998
3171
  cachedContent.allTestCode = allTestCode;
2999
3172
  config._sourceMapDecoder = extractInlineSourceMap(allTestCode, outDir);
3173
+ if (metafile) void writeMetafileCache(projectRoot, process.cwd(), metafile);
3000
3174
  } catch (error) {
3001
3175
  cachedContent._buildError = {
3002
3176
  type: deriveBuildErrorType(error),
3003
3177
  formatted: formatBuildErrors(error)
3004
3178
  };
3005
- await fs10.writeFile(path7.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
3179
+ await fs11.writeFile(path10.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
3006
3180
  throw error;
3007
3181
  }
3008
3182
  }
3009
3183
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
3010
3184
  const { projectRoot, output } = config;
3011
- const outDir = path7.resolve(projectRoot, output);
3185
+ const outDir = path10.resolve(projectRoot, output);
3012
3186
  const allTestFilePaths = Object.keys(config.fsTree);
3013
3187
  const runHasFilter = !!targetTestFilesToFilter;
3014
3188
  if (!config._groupMode) {
@@ -3036,7 +3210,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3036
3210
  return connections;
3037
3211
  }
3038
3212
  if (runHasFilter) {
3039
- const outputPath = path7.join(outDir, "filtered-tests.js");
3213
+ const outputPath = path10.join(outDir, "filtered-tests.js");
3040
3214
  cachedContent.filteredTestCode = await buildFilteredTests(
3041
3215
  targetTestFilesToFilter,
3042
3216
  outputPath,
@@ -3076,7 +3250,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3076
3250
  console.log(
3077
3251
  `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
3078
3252
  );
3079
- fs10.writeFile(path7.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
3253
+ fs11.writeFile(path10.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
3080
3254
  () => {
3081
3255
  }
3082
3256
  );
@@ -3108,8 +3282,8 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3108
3282
  type: deriveBuildErrorType(error),
3109
3283
  formatted: formatBuildErrors(error)
3110
3284
  };
3111
- fs10.writeFile(
3112
- path7.join(outDir, "qunitx.html"),
3285
+ fs11.writeFile(
3286
+ path10.join(outDir, "qunitx.html"),
3113
3287
  buildErrorHTML(cachedContent._buildError)
3114
3288
  ).catch(
3115
3289
  (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
@@ -3159,7 +3333,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3159
3333
  );
3160
3334
  await Promise.all(
3161
3335
  activeGroups.map(
3162
- (group) => fs10.mkdir(path7.resolve(group.config.projectRoot, group.config.output), { recursive: true })
3336
+ (group) => fs11.mkdir(path10.resolve(group.config.projectRoot, group.config.output), { recursive: true })
3163
3337
  )
3164
3338
  );
3165
3339
  const sourcemap = "inline";
@@ -3179,7 +3353,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3179
3353
  });
3180
3354
  }
3181
3355
  };
3182
- const esbuildOutdir = path7.join(projectRoot, "tmp");
3356
+ const esbuildOutdir = path10.join(projectRoot, "tmp");
3183
3357
  const buildOptions = {
3184
3358
  entryPoints: activeGroups.map((_, slotIndex) => ({
3185
3359
  in: `group-entry-${slotIndex}`,
@@ -3201,6 +3375,9 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3201
3375
  sourcemap,
3202
3376
  write: false,
3203
3377
  jsx: "automatic",
3378
+ // Required for --changed/--since dep-graph filter on subsequent runs. Same
3379
+ // contract as the single-group path in `buildTestBundle`.
3380
+ metafile: true,
3204
3381
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
3205
3382
  };
3206
3383
  const hasSmallOutput = (result2) => (result2.outputFiles ?? []).some(
@@ -3221,8 +3398,8 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3221
3398
  const slotIndex = parseInt(match[1]);
3222
3399
  const isMap = Boolean(match[2]);
3223
3400
  const { config, cachedContent } = activeGroups[slotIndex];
3224
- const destPath = path7.join(
3225
- path7.resolve(config.projectRoot, config.output),
3401
+ const destPath = path10.join(
3402
+ path10.resolve(config.projectRoot, config.output),
3226
3403
  "tests.js" + (isMap ? ".map" : "")
3227
3404
  );
3228
3405
  if (!isMap) {
@@ -3232,17 +3409,20 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3232
3409
  esbuildOutdir
3233
3410
  );
3234
3411
  }
3235
- return fs10.writeFile(destPath, outputFile.contents);
3412
+ return fs11.writeFile(destPath, outputFile.contents);
3236
3413
  })
3237
3414
  );
3415
+ if (result2.metafile) {
3416
+ void writeMetafileCache(projectRoot, process.cwd(), result2.metafile);
3417
+ }
3238
3418
  } catch (error) {
3239
3419
  const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
3240
3420
  const errorHtml = buildErrorHTML(buildError);
3241
3421
  await Promise.all(
3242
3422
  activeGroups.map((group) => {
3243
3423
  group.cachedContent._buildError = buildError;
3244
- return fs10.writeFile(
3245
- path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
3424
+ return fs11.writeFile(
3425
+ path10.join(path10.resolve(group.config.projectRoot, group.config.output), "index.html"),
3246
3426
  errorHtml
3247
3427
  ).catch(
3248
3428
  (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
@@ -3274,7 +3454,7 @@ function buildFilteredTests(filteredTests, outputPath, config) {
3274
3454
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
3275
3455
  },
3276
3456
  needsDisk
3277
- );
3457
+ ).then((r) => r.js);
3278
3458
  }
3279
3459
  async function runWithOverlayfsRetry(getContents, needsDisk) {
3280
3460
  let { result: result2, js } = await getContents();
@@ -3291,10 +3471,10 @@ async function runWithOverlayfsRetry(getContents, needsDisk) {
3291
3471
  }
3292
3472
  if (needsDisk) {
3293
3473
  await Promise.all(
3294
- result2.outputFiles.map((outputFile) => fs10.writeFile(outputFile.path, outputFile.contents))
3474
+ result2.outputFiles.map((outputFile) => fs11.writeFile(outputFile.path, outputFile.contents))
3295
3475
  );
3296
3476
  }
3297
- return js;
3477
+ return { js, metafile: result2.metafile };
3298
3478
  }
3299
3479
  function buildWithOverlayfsRetry(options, needsDisk) {
3300
3480
  const buildOpts = { ...options, write: false };
@@ -3428,9 +3608,9 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
3428
3608
  process.exit(1);
3429
3609
  }
3430
3610
  function toEsbuildImportPath(filePath) {
3431
- const rel = path7.relative(process.cwd(), filePath);
3611
+ const rel = path10.relative(process.cwd(), filePath);
3432
3612
  const normalized = rel.replace(/\\/g, "/");
3433
- if (path7.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
3613
+ if (path10.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
3434
3614
  return normalized.startsWith(".") ? normalized : "./" + normalized;
3435
3615
  }
3436
3616
  var ancestorNodeModules, ANCESTOR_NODE_MODULES, RETRY_DELAY_MS, MAX_RETRIES, EMPTY_BUNDLE_THRESHOLD, NAV_GRACE_MS, MAX_NAV_SLOWDOWN_FACTOR, MIN_NAV_MS, STARTUP_TIMEOUT_FACTOR, TESTS_JS_TIMEOUT_FACTOR, CONSOLE_FLUSH_TIMEOUT_MS, TEST_STALL_BUFFER_MS, GROUP_OUTPUT_REGEX, BundleError, DaemonRunError;
@@ -3444,8 +3624,9 @@ var init_tests_in_browser = __esm({
3444
3624
  init_display_final_result();
3445
3625
  init_web_server();
3446
3626
  init_source_map_decoder();
3447
- ancestorNodeModules = (dir) => dir.split(path7.sep).map(
3448
- (_, i, parts) => path7.join(parts.slice(0, parts.length - i).join(path7.sep) || path7.sep, "node_modules")
3627
+ init_metafile_cache();
3628
+ ancestorNodeModules = (dir) => dir.split(path10.sep).map(
3629
+ (_, i, parts) => path10.join(parts.slice(0, parts.length - i).join(path10.sep) || path10.sep, "node_modules")
3449
3630
  );
3450
3631
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
3451
3632
  RETRY_DELAY_MS = 100;
@@ -3481,11 +3662,11 @@ var init_tests_in_browser = __esm({
3481
3662
 
3482
3663
  // lib/utils/open-output-in-browser.ts
3483
3664
  import { spawn as spawn2 } from "node:child_process";
3484
- import path8 from "node:path";
3665
+ import path11 from "node:path";
3485
3666
  import { pathToFileURL as pathToFileURL3 } from "node:url";
3486
3667
  async function openOutputInBrowser(config) {
3487
3668
  try {
3488
- const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL3(path8.join(path8.resolve(config.projectRoot, config.output), "index.html")).href;
3669
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL3(path11.join(path11.resolve(config.projectRoot, config.output), "index.html")).href;
3489
3670
  if (typeof config.open === "string") {
3490
3671
  spawnDetached(config.open, [outputFile]);
3491
3672
  return;
@@ -3518,9 +3699,9 @@ var init_open_output_in_browser = __esm({
3518
3699
  });
3519
3700
 
3520
3701
  // lib/setup/file-watcher.ts
3521
- import fs11 from "node:fs";
3702
+ import fs12 from "node:fs";
3522
3703
  import { readdir, stat, lstat } from "node:fs/promises";
3523
- import path9 from "node:path";
3704
+ import path12 from "node:path";
3524
3705
  function recordJustAdded(config, filePath) {
3525
3706
  let map = justAddedAt.get(config);
3526
3707
  if (!map) justAddedAt.set(config, map = /* @__PURE__ */ new Map());
@@ -3539,19 +3720,27 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3539
3720
  if (symlinkPollers.has(filePath)) return;
3540
3721
  const handler = (curr, prev) => {
3541
3722
  if (curr.nlink === 0) {
3542
- fs11.unwatchFile(filePath, handler);
3723
+ fs12.unwatchFile(filePath, handler);
3543
3724
  symlinkPollers.delete(filePath);
3544
3725
  if (filePath in config.fsTree) {
3545
3726
  handleWatchEvent(config, extensions, "unlink", filePath, onEventFunc, onFinishFunc);
3546
3727
  }
3547
3728
  } else if ((process.platform === "win32" || process.platform === "darwin") && curr.mtimeMs !== prev.mtimeMs) {
3548
3729
  if (filePath in config.fsTree) {
3549
- handleWatchEvent(config, extensions, "change", filePath, onEventFunc, onFinishFunc);
3730
+ const existing = pendingChangeTimers.get(filePath);
3731
+ if (existing) clearTimeout(existing);
3732
+ pendingChangeTimers.set(
3733
+ filePath,
3734
+ setTimeout(() => {
3735
+ pendingChangeTimers.delete(filePath);
3736
+ handleWatchEvent(config, extensions, "change", filePath, onEventFunc, onFinishFunc);
3737
+ }, CHANGE_COALESCE_MS)
3738
+ );
3550
3739
  }
3551
3740
  }
3552
3741
  };
3553
- fs11.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
3554
- symlinkPollers.set(filePath, () => fs11.unwatchFile(filePath, handler));
3742
+ fs12.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
3743
+ symlinkPollers.set(filePath, () => fs12.unwatchFile(filePath, handler));
3555
3744
  }
3556
3745
  function untrackSymlink(filePath) {
3557
3746
  symlinkPollers.get(filePath)?.();
@@ -3562,7 +3751,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3562
3751
  let rescanInProgress = false;
3563
3752
  const lastEventMs = {};
3564
3753
  const seenMtimeMs = {};
3565
- const childWatcher = fs11.watch(watchPath, { recursive: true }, async (eventType, filename) => {
3754
+ const childWatcher = fs12.watch(watchPath, { recursive: true }, async (eventType, filename) => {
3566
3755
  if (!ready) return;
3567
3756
  if (!filename) {
3568
3757
  if (process.platform === "darwin" && !rescanInProgress) {
@@ -3580,7 +3769,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3580
3769
  }
3581
3770
  return;
3582
3771
  }
3583
- const fullPath = filename === path9.basename(watchPath) ? watchPath : path9.join(watchPath, filename);
3772
+ const fullPath = filename === path12.basename(watchPath) ? watchPath : path12.join(watchPath, filename);
3584
3773
  if (eventType === "change") {
3585
3774
  const now = Date.now();
3586
3775
  const last = lastEventMs[fullPath] ?? 0;
@@ -3618,8 +3807,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3618
3807
  }
3619
3808
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
3620
3809
  });
3621
- const parentDir = path9.dirname(watchPath);
3622
- const watchedBasename = path9.basename(watchPath);
3810
+ const parentDir = path12.dirname(watchPath);
3811
+ const watchedBasename = path12.basename(watchPath);
3623
3812
  let parentUnlinkFired = false;
3624
3813
  let rescanTimer = null;
3625
3814
  const tryFireParentUnlink = async () => {
@@ -3638,7 +3827,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3638
3827
  return true;
3639
3828
  }
3640
3829
  };
3641
- const parentWatcher = fs11.watch(parentDir, async (eventType, filename) => {
3830
+ const parentWatcher = fs12.watch(parentDir, async (eventType, filename) => {
3642
3831
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
3643
3832
  await tryFireParentUnlink();
3644
3833
  });
@@ -3752,11 +3941,11 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
3752
3941
  const trackedToRecheck = [];
3753
3942
  for (const entry of entries) {
3754
3943
  if (entry.isDirectory()) {
3755
- presentDirs.add(path9.join(entry.parentPath, entry.name));
3944
+ presentDirs.add(path12.join(entry.parentPath, entry.name));
3756
3945
  continue;
3757
3946
  }
3758
3947
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
3759
- const entryPath = path9.join(entry.parentPath, entry.name);
3948
+ const entryPath = path12.join(entry.parentPath, entry.name);
3760
3949
  presentDirs.add(entry.parentPath);
3761
3950
  if (!extensions.some((ext) => entryPath.endsWith(`.${ext}`))) continue;
3762
3951
  presentPaths.add(entryPath);
@@ -3779,13 +3968,13 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
3779
3968
  }
3780
3969
  })
3781
3970
  );
3782
- const watchPrefix = watchPath + path9.sep;
3971
+ const watchPrefix = watchPath + path12.sep;
3783
3972
  const firedDirPrefixes = [];
3784
3973
  for (const trackedPath of Object.keys(config.fsTree)) {
3785
3974
  if (!trackedPath.startsWith(watchPrefix) || presentPaths.has(trackedPath)) continue;
3786
- if (firedDirPrefixes.some((p) => trackedPath.startsWith(p + path9.sep))) continue;
3787
- const parts = trackedPath.slice(watchPrefix.length).split(path9.sep);
3788
- const goneDirPath = parts.slice(0, -1).map((_, i) => watchPrefix + parts.slice(0, i + 1).join(path9.sep)).find((p) => !presentDirs.has(p)) ?? null;
3975
+ if (firedDirPrefixes.some((p) => trackedPath.startsWith(p + path12.sep))) continue;
3976
+ const parts = trackedPath.slice(watchPrefix.length).split(path12.sep);
3977
+ const goneDirPath = parts.slice(0, -1).map((_, i) => watchPrefix + parts.slice(0, i + 1).join(path12.sep)).find((p) => !presentDirs.has(p)) ?? null;
3789
3978
  if (goneDirPath !== null) {
3790
3979
  firedDirPrefixes.push(goneDirPath);
3791
3980
  handleWatchEvent(config, extensions, "unlinkDir", goneDirPath, onEventFunc, onFinishFunc);
@@ -3838,7 +4027,7 @@ var init_file_watcher = __esm({
3838
4027
  SYMLINK_POLL_INTERVAL_MS = 500;
3839
4028
  OVERLAYFS_RENAME_RETRY_MS = 50;
3840
4029
  RESCAN_INTERVAL_MS = 1e3;
3841
- CHANGE_COALESCE_MS = 50;
4030
+ CHANGE_COALESCE_MS = 75;
3842
4031
  ADD_SUPPRESS_WINDOW_MS = 1e3;
3843
4032
  justAddedAt = /* @__PURE__ */ new WeakMap();
3844
4033
  }
@@ -3920,28 +4109,28 @@ var init_keyboard_events = __esm({
3920
4109
  });
3921
4110
 
3922
4111
  // lib/setup/write-output-static-files.ts
3923
- import fs12 from "node:fs/promises";
3924
- import path10 from "node:path";
4112
+ import fs13 from "node:fs/promises";
4113
+ import path13 from "node:path";
3925
4114
  async function writeOutputStaticFiles({ projectRoot, output }, cachedContent) {
3926
4115
  const staticHTMLPromises = Object.keys(cachedContent.staticHTMLs).map(async (staticHTMLKey) => {
3927
- const htmlRelativePath = path10.relative(projectRoot, staticHTMLKey);
3928
- const outDir = path10.resolve(projectRoot, output);
3929
- await ensureFolderExists(path10.join(outDir, htmlRelativePath));
3930
- await fs12.writeFile(
3931
- path10.join(outDir, htmlRelativePath),
4116
+ const htmlRelativePath = path13.relative(projectRoot, staticHTMLKey);
4117
+ const outDir = path13.resolve(projectRoot, output);
4118
+ await ensureFolderExists(path13.join(outDir, htmlRelativePath));
4119
+ await fs13.writeFile(
4120
+ path13.join(outDir, htmlRelativePath),
3932
4121
  cachedContent.staticHTMLs[staticHTMLKey]
3933
4122
  );
3934
4123
  });
3935
4124
  const assetPromises = Array.from(cachedContent.assets).map(async (assetAbsolutePath) => {
3936
- const assetRelativePath = path10.relative(projectRoot, assetAbsolutePath);
3937
- const outDir = path10.resolve(projectRoot, output);
3938
- await ensureFolderExists(path10.join(outDir, assetRelativePath));
3939
- await fs12.copyFile(assetAbsolutePath, path10.join(outDir, assetRelativePath));
4125
+ const assetRelativePath = path13.relative(projectRoot, assetAbsolutePath).replace(/^(?:\.\.[\\/])+/, "");
4126
+ const outDir = path13.resolve(projectRoot, output);
4127
+ await ensureFolderExists(path13.join(outDir, assetRelativePath));
4128
+ await fs13.copyFile(assetAbsolutePath, path13.join(outDir, assetRelativePath));
3940
4129
  });
3941
4130
  await Promise.all(staticHTMLPromises.concat(assetPromises));
3942
4131
  }
3943
4132
  async function ensureFolderExists(assetPath) {
3944
- await fs12.mkdir(path10.dirname(assetPath), { recursive: true });
4133
+ await fs13.mkdir(path13.dirname(assetPath), { recursive: true });
3945
4134
  }
3946
4135
  var init_write_output_static_files = __esm({
3947
4136
  "lib/setup/write-output-static-files.ts"() {
@@ -3949,9 +4138,9 @@ var init_write_output_static_files = __esm({
3949
4138
  });
3950
4139
 
3951
4140
  // lib/utils/daemon-hint.ts
3952
- import fs13 from "node:fs/promises";
4141
+ import fs14 from "node:fs/promises";
3953
4142
  import os3 from "node:os";
3954
- import path11 from "node:path";
4143
+ import path14 from "node:path";
3955
4144
  function shouldShowDaemonHint(ctx) {
3956
4145
  const env = ctx.env ?? process.env;
3957
4146
  if (ctx.watch) return false;
@@ -3969,14 +4158,14 @@ async function maybePrintDaemonHint(ctx, opts = {}) {
3969
4158
  if (!shouldShowDaemonHint(ctx)) return;
3970
4159
  const sentinel = opts.sentinelPath ?? DEFAULT_SENTINEL;
3971
4160
  try {
3972
- await fs13.access(sentinel);
4161
+ await fs14.access(sentinel);
3973
4162
  return;
3974
4163
  } catch {
3975
4164
  }
3976
4165
  (opts.write ?? ((t) => process.stderr.write(t)))(HINT_TEXT);
3977
4166
  try {
3978
- await fs13.mkdir(path11.dirname(sentinel), { recursive: true });
3979
- await fs13.writeFile(sentinel, (/* @__PURE__ */ new Date()).toISOString());
4167
+ await fs14.mkdir(path14.dirname(sentinel), { recursive: true });
4168
+ await fs14.writeFile(sentinel, (/* @__PURE__ */ new Date()).toISOString());
3980
4169
  } catch {
3981
4170
  }
3982
4171
  }
@@ -3985,7 +4174,7 @@ var init_daemon_hint = __esm({
3985
4174
  "lib/utils/daemon-hint.ts"() {
3986
4175
  FAST_RUN_THRESHOLD_MS = 500;
3987
4176
  HINT_TEXT = "\n\x1B[34m\u2139\x1B[39m Tip: export QUNITX_DAEMON=1 for ~2\xD7 faster repeated runs (qunitx daemon --help)\n";
3988
- DEFAULT_SENTINEL = path11.join(os3.homedir(), ".cache", "qunitx", "hint-shown");
4177
+ DEFAULT_SENTINEL = path14.join(os3.homedir(), ".cache", "qunitx", "hint-shown");
3989
4178
  }
3990
4179
  });
3991
4180
 
@@ -3998,7 +4187,7 @@ __export(run_exports, {
3998
4187
  readTimingCache: () => readTimingCache,
3999
4188
  run: () => run
4000
4189
  });
4001
- import fs14 from "node:fs/promises";
4190
+ import fs15 from "node:fs/promises";
4002
4191
  import { join as join3, normalize } from "node:path";
4003
4192
  import { createRequire as createRequire2 } from "node:module";
4004
4193
  import { availableParallelism } from "node:os";
@@ -4085,6 +4274,21 @@ async function run(config) {
4085
4274
  logWatcherAndKeyboardShortcutInfo(config, connections.server);
4086
4275
  } else {
4087
4276
  const allFiles = Object.keys(config.fsTree);
4277
+ if (allFiles.length === 0) {
4278
+ process.stdout.write("TAP version 13\n");
4279
+ process.stdout.write(
4280
+ `# Running 0 test files${config._daemonMode ? " (daemon)" : ""}
4281
+ 1..0
4282
+ `
4283
+ );
4284
+ if (config._daemonMode) throw new DaemonRunError(0);
4285
+ if (!config.watch) {
4286
+ const browser2 = config._daemonBrowser ? null : await browserPromise;
4287
+ await closeWithGrace([browser2?.close(), shutdownPrelaunch()]);
4288
+ return process.exit(0);
4289
+ }
4290
+ return;
4291
+ }
4088
4292
  const groupCount = Math.min(allFiles.length, availableParallelism());
4089
4293
  const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings ?? {});
4090
4294
  config.COUNTER = {
@@ -4242,7 +4446,7 @@ async function run(config) {
4242
4446
  }
4243
4447
  async function buildCachedContent(config, htmlPaths) {
4244
4448
  const htmlBuffers = await Promise.all(
4245
- config.htmlPaths.map((htmlPath) => fs14.readFile(htmlPath).catch(() => null))
4449
+ config.htmlPaths.map((htmlPath) => fs15.readFile(htmlPath).catch(() => null))
4246
4450
  );
4247
4451
  const cachedContent = htmlPaths.reduce(
4248
4452
  (result2, _htmlPath, index) => {
@@ -4297,7 +4501,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
4297
4501
  }
4298
4502
  async function readTimingCache(projectRoot) {
4299
4503
  try {
4300
- const parsed = JSON.parse(await fs14.readFile(`${projectRoot}/tmp/test-timings.json`, "utf8"));
4504
+ const parsed = JSON.parse(await fs15.readFile(`${projectRoot}/tmp/test-timings.json`, "utf8"));
4301
4505
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
4302
4506
  } catch {
4303
4507
  return {};
@@ -4316,7 +4520,7 @@ function computeFileTimes(groups, weights, wallTimes) {
4316
4520
  return result2;
4317
4521
  }
4318
4522
  async function persistTimings(fileTimes, projectRoot) {
4319
- await fs14.writeFile(
4523
+ await fs15.writeFile(
4320
4524
  `${projectRoot}/tmp/test-timings.json`,
4321
4525
  JSON.stringify(Object.fromEntries(fileTimes), null, 2)
4322
4526
  );
@@ -4331,7 +4535,7 @@ ${lines.join("\n")}
4331
4535
  async function splitIntoGroups(files, groupCount, timings) {
4332
4536
  const sizes = await Promise.all(
4333
4537
  files.map(
4334
- (f) => timings[f] > 0 ? Promise.resolve(0) : fs14.stat(f).then((s) => s.size).catch(() => 0)
4538
+ (f) => timings[f] > 0 ? Promise.resolve(0) : fs15.stat(f).then((s) => s.size).catch(() => 0)
4335
4539
  )
4336
4540
  );
4337
4541
  const knownRates = files.map((f, i) => ({ ms: timings[f], size: sizes[i] })).filter(({ ms, size }) => ms > 0 && size > 0);
@@ -4393,7 +4597,7 @@ var init_run = __esm({
4393
4597
  init_close_with_grace();
4394
4598
  init_daemon_hint();
4395
4599
  WATCH_NAV_TIMEOUT_MS = 5e3;
4396
- STDOUT_FLUSH_GRACE_MS = 5e3;
4600
+ STDOUT_FLUSH_GRACE_MS = 3e4;
4397
4601
  KEEP_ALIVE_INTERVAL_MS = 1e4;
4398
4602
  EXIT_CODE_SIGTERM = 128 + 15;
4399
4603
  }
@@ -4405,19 +4609,19 @@ __export(server_exports, {
4405
4609
  runDaemonServer: () => runDaemonServer
4406
4610
  });
4407
4611
  import net2 from "node:net";
4408
- import fs15 from "node:fs";
4612
+ import fs16 from "node:fs";
4409
4613
  import { writeFile, unlink, stat as stat2, chmod } from "node:fs/promises";
4410
- import path12 from "node:path";
4614
+ import path15 from "node:path";
4411
4615
  async function runDaemonServer() {
4412
4616
  const cwd = process.cwd();
4413
4617
  const socketPath = daemonSocketPath(cwd);
4414
4618
  const infoPath = daemonInfoPath(cwd);
4415
- if (fs15.existsSync(infoPath) && await isLiveSocket(socketPath)) process.exit(0);
4619
+ if (fs16.existsSync(infoPath) && await isLiveSocket(socketPath)) process.exit(0);
4416
4620
  await unlink(socketPath).catch(() => {
4417
4621
  });
4418
4622
  const logPath = process.env.QUNITX_DAEMON_LOG;
4419
4623
  if (logPath) {
4420
- const log = fs15.createWriteStream(logPath, { flags: "a" });
4624
+ const log = fs16.createWriteStream(logPath, { flags: "a" });
4421
4625
  log.on("error", () => {
4422
4626
  });
4423
4627
  const forward = log.write.bind(log);
@@ -4543,7 +4747,7 @@ async function dispatch(req, socket, state) {
4543
4747
  socket.end();
4544
4748
  } else if (req.type === "shutdown") {
4545
4749
  try {
4546
- fs15.unlinkSync(state.infoPath);
4750
+ fs16.unlinkSync(state.infoPath);
4547
4751
  } catch {
4548
4752
  }
4549
4753
  writeChunk(socket, { type: "done", exitCode: 0 });
@@ -4685,7 +4889,7 @@ async function isLiveSocket(socketPath) {
4685
4889
  }
4686
4890
  async function readPkgMtime(cwd) {
4687
4891
  try {
4688
- return (await stat2(path12.join(cwd, "package.json"))).mtimeMs;
4892
+ return (await stat2(path15.join(cwd, "package.json"))).mtimeMs;
4689
4893
  } catch {
4690
4894
  return 0;
4691
4895
  }
@@ -4713,8 +4917,8 @@ __export(daemon_exports, {
4713
4917
  runDaemonCommand: () => runDaemonCommand
4714
4918
  });
4715
4919
  import { spawn as spawn3 } from "node:child_process";
4716
- import fs16, { existsSync as existsSync3 } from "node:fs";
4717
- import path13 from "node:path";
4920
+ import fs17, { existsSync as existsSync3 } from "node:fs";
4921
+ import path16 from "node:path";
4718
4922
  async function buildDaemonSpawn() {
4719
4923
  const sea = await import("node:sea").catch(() => null);
4720
4924
  if (sea?.isSea()) return { bin: process.execPath, args: ["daemon", "_serve"] };
@@ -4739,15 +4943,15 @@ async function runServeMode() {
4739
4943
  function waitForFile(filePath, timeoutMs) {
4740
4944
  if (existsSync3(filePath)) return Promise.resolve(true);
4741
4945
  return new Promise((resolve) => {
4742
- const dir = path13.dirname(filePath);
4743
- const fileName = path13.basename(filePath);
4946
+ const dir = path16.dirname(filePath);
4947
+ const fileName = path16.basename(filePath);
4744
4948
  const settle = (ok) => {
4745
4949
  clearTimeout(timer);
4746
4950
  watcher.close();
4747
4951
  resolve(ok);
4748
4952
  };
4749
4953
  const timer = setTimeout(() => settle(false), timeoutMs);
4750
- const watcher = fs16.watch(dir, (_event, name) => {
4954
+ const watcher = fs17.watch(dir, (_event, name) => {
4751
4955
  if (name === fileName && existsSync3(filePath)) settle(true);
4752
4956
  });
4753
4957
  watcher.on("error", () => settle(false));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.23.6",
4
+ "version": "0.24.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",