qunitx-cli 0.23.7 → 0.25.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 +363 -147
  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.7",
377
+ version: "0.25.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)$/, "");
@@ -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) || path5.isAbsolute(arg) ? 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;
@@ -2778,9 +2947,16 @@ async function launchBrowser(config, skipPrelaunch = false) {
2778
2947
  }
2779
2948
  async function setupBrowser(config, cachedContent, existingBrowser = null, sharedServer = null) {
2780
2949
  const setupStart = Date.now();
2950
+ const slot = config._daemonPageSlot;
2951
+ const slotPage = slot?.page && !slot.page.isClosed() ? slot.page : null;
2952
+ if (slotPage) {
2953
+ slotPage.removeAllListeners("console");
2954
+ slotPage.removeAllListeners("pageerror");
2955
+ slot.page = null;
2956
+ }
2781
2957
  const [server, browser, page] = await (async () => {
2782
2958
  if (sharedServer) {
2783
- const newPage2 = await existingBrowser.newPage();
2959
+ const newPage2 = slotPage ?? await existingBrowser.newPage();
2784
2960
  perfLog(`browser.js: newPage (shared server) took ${Date.now() - setupStart}ms`);
2785
2961
  return [sharedServer, existingBrowser, newPage2];
2786
2962
  }
@@ -2789,12 +2965,12 @@ async function setupBrowser(config, cachedContent, existingBrowser = null, share
2789
2965
  const activeBrowser = existingBrowser ?? await launchBrowser(config);
2790
2966
  const pageStart = Date.now();
2791
2967
  const isHeadedWatchMode = config.open === true && config.watch;
2792
- const getPage = isHeadedWatchMode ? () => activeBrowser.contexts()[0]?.pages()[0] ?? activeBrowser.newPage() : () => activeBrowser.newPage();
2968
+ const getPage = slotPage ? () => Promise.resolve(slotPage) : isHeadedWatchMode ? () => activeBrowser.contexts()[0]?.pages()[0] ?? activeBrowser.newPage() : () => activeBrowser.newPage();
2793
2969
  const [newPage] = await Promise.all([getPage(), bindServerToPort(newServer, config)]);
2794
2970
  perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
2795
2971
  return [newServer, activeBrowser, newPage];
2796
2972
  })();
2797
- if (config.browser === "firefox") {
2973
+ if (config.browser === "firefox" && !slotPage) {
2798
2974
  await page.addInitScript(() => {
2799
2975
  const preSerialize = (arg) => {
2800
2976
  if (arg === null || typeof arg !== "object") return arg;
@@ -2905,8 +3081,8 @@ var init_display_final_result = __esm({
2905
3081
  });
2906
3082
 
2907
3083
  // lib/commands/run/tests-in-browser.ts
2908
- import fs10 from "node:fs/promises";
2909
- import path7 from "node:path";
3084
+ import fs11 from "node:fs/promises";
3085
+ import path10 from "node:path";
2910
3086
  import esbuild from "esbuild";
2911
3087
  function deriveBuildErrorType(error) {
2912
3088
  const msgs = error?.errors ?? [];
@@ -2946,9 +3122,9 @@ async function buildTestBundle(config, cachedContent) {
2946
3122
  console.log("# [buildTestBundle] fsTree is empty \u2014 skipping build (no test files found)");
2947
3123
  return;
2948
3124
  }
2949
- const outDir = path7.resolve(projectRoot, output);
2950
- const outfile = path7.join(outDir, "tests.js");
2951
- await fs10.mkdir(outDir, { recursive: true });
3125
+ const outDir = path10.resolve(projectRoot, output);
3126
+ const outfile = path10.join(outDir, "tests.js");
3127
+ await fs11.mkdir(outDir, { recursive: true });
2952
3128
  const sourcemap = "inline";
2953
3129
  const needsDisk = true;
2954
3130
  const buildOptions = {
@@ -2972,6 +3148,10 @@ async function buildTestBundle(config, cachedContent) {
2972
3148
  // tsconfig's `jsxImportSource` or a `@jsxImportSource <pkg>` pragma cover Vue/Preact/Solid.
2973
3149
  jsx: "automatic",
2974
3150
  plugins: config.plugins,
3151
+ // Required for --changed/--since dep-graph filter on subsequent runs (cache
3152
+ // populates here, reads in setupConfig). Inputs map carries the full reverse-dep
3153
+ // graph; output cost is negligible.
3154
+ metafile: true,
2975
3155
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
2976
3156
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
2977
3157
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
@@ -2983,32 +3163,33 @@ async function buildTestBundle(config, cachedContent) {
2983
3163
  const cacheHolder = config._daemonEsbuildCache ?? cachedContent;
2984
3164
  const fileKey = bundleCacheKey(buildOptions, allTestFilePaths);
2985
3165
  try {
2986
- const [allTestCode] = await Promise.all([
3166
+ const [{ js: allTestCode, metafile }] = await Promise.all([
2987
3167
  config.watch || config._daemonMode ? buildIncrementally(buildOptions, fileKey, cacheHolder, needsDisk) : buildWithOverlayfsRetry(buildOptions, needsDisk),
2988
3168
  Promise.all(
2989
3169
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
2990
- const targetPath = path7.join(outDir, htmlPath);
3170
+ const targetPath = path10.join(outDir, htmlPath);
2991
3171
  if (htmlPath !== "/") {
2992
- await fs10.rm(targetPath, { force: true, recursive: true });
2993
- await fs10.mkdir(path7.dirname(targetPath), { recursive: true });
3172
+ await fs11.rm(targetPath, { force: true, recursive: true });
3173
+ await fs11.mkdir(path10.dirname(targetPath), { recursive: true });
2994
3174
  }
2995
3175
  })
2996
3176
  )
2997
3177
  ]);
2998
3178
  cachedContent.allTestCode = allTestCode;
2999
3179
  config._sourceMapDecoder = extractInlineSourceMap(allTestCode, outDir);
3180
+ if (metafile) void writeMetafileCache(projectRoot, process.cwd(), metafile);
3000
3181
  } catch (error) {
3001
3182
  cachedContent._buildError = {
3002
3183
  type: deriveBuildErrorType(error),
3003
3184
  formatted: formatBuildErrors(error)
3004
3185
  };
3005
- await fs10.writeFile(path7.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
3186
+ await fs11.writeFile(path10.join(outDir, "index.html"), buildErrorHTML(cachedContent._buildError));
3006
3187
  throw error;
3007
3188
  }
3008
3189
  }
3009
3190
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
3010
3191
  const { projectRoot, output } = config;
3011
- const outDir = path7.resolve(projectRoot, output);
3192
+ const outDir = path10.resolve(projectRoot, output);
3012
3193
  const allTestFilePaths = Object.keys(config.fsTree);
3013
3194
  const runHasFilter = !!targetTestFilesToFilter;
3014
3195
  if (!config._groupMode) {
@@ -3036,7 +3217,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3036
3217
  return connections;
3037
3218
  }
3038
3219
  if (runHasFilter) {
3039
- const outputPath = path7.join(outDir, "filtered-tests.js");
3220
+ const outputPath = path10.join(outDir, "filtered-tests.js");
3040
3221
  cachedContent.filteredTestCode = await buildFilteredTests(
3041
3222
  targetTestFilesToFilter,
3042
3223
  outputPath,
@@ -3076,7 +3257,7 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3076
3257
  console.log(
3077
3258
  `# Warning: 0 tests registered \u2014 no QUnit test cases found in ${allTestFilePaths.length} ${fileWord}`
3078
3259
  );
3079
- fs10.writeFile(path7.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
3260
+ fs11.writeFile(path10.join(outDir, "index.html"), buildNoTestsHTML(displayFiles)).catch(
3080
3261
  () => {
3081
3262
  }
3082
3263
  );
@@ -3108,8 +3289,8 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
3108
3289
  type: deriveBuildErrorType(error),
3109
3290
  formatted: formatBuildErrors(error)
3110
3291
  };
3111
- fs10.writeFile(
3112
- path7.join(outDir, "qunitx.html"),
3292
+ fs11.writeFile(
3293
+ path10.join(outDir, "qunitx.html"),
3113
3294
  buildErrorHTML(cachedContent._buildError)
3114
3295
  ).catch(
3115
3296
  (err) => config.debug && process.stderr.write(`# [qunitx] writeFile qunitx.html: ${err.message}
@@ -3159,7 +3340,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3159
3340
  );
3160
3341
  await Promise.all(
3161
3342
  activeGroups.map(
3162
- (group) => fs10.mkdir(path7.resolve(group.config.projectRoot, group.config.output), { recursive: true })
3343
+ (group) => fs11.mkdir(path10.resolve(group.config.projectRoot, group.config.output), { recursive: true })
3163
3344
  )
3164
3345
  );
3165
3346
  const sourcemap = "inline";
@@ -3179,7 +3360,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3179
3360
  });
3180
3361
  }
3181
3362
  };
3182
- const esbuildOutdir = path7.join(projectRoot, "tmp");
3363
+ const esbuildOutdir = path10.join(projectRoot, "tmp");
3183
3364
  const buildOptions = {
3184
3365
  entryPoints: activeGroups.map((_, slotIndex) => ({
3185
3366
  in: `group-entry-${slotIndex}`,
@@ -3201,6 +3382,9 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3201
3382
  sourcemap,
3202
3383
  write: false,
3203
3384
  jsx: "automatic",
3385
+ // Required for --changed/--since dep-graph filter on subsequent runs. Same
3386
+ // contract as the single-group path in `buildTestBundle`.
3387
+ metafile: true,
3204
3388
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
3205
3389
  };
3206
3390
  const hasSmallOutput = (result2) => (result2.outputFiles ?? []).some(
@@ -3221,8 +3405,8 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3221
3405
  const slotIndex = parseInt(match[1]);
3222
3406
  const isMap = Boolean(match[2]);
3223
3407
  const { config, cachedContent } = activeGroups[slotIndex];
3224
- const destPath = path7.join(
3225
- path7.resolve(config.projectRoot, config.output),
3408
+ const destPath = path10.join(
3409
+ path10.resolve(config.projectRoot, config.output),
3226
3410
  "tests.js" + (isMap ? ".map" : "")
3227
3411
  );
3228
3412
  if (!isMap) {
@@ -3232,17 +3416,20 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
3232
3416
  esbuildOutdir
3233
3417
  );
3234
3418
  }
3235
- return fs10.writeFile(destPath, outputFile.contents);
3419
+ return fs11.writeFile(destPath, outputFile.contents);
3236
3420
  })
3237
3421
  );
3422
+ if (result2.metafile) {
3423
+ void writeMetafileCache(projectRoot, process.cwd(), result2.metafile);
3424
+ }
3238
3425
  } catch (error) {
3239
3426
  const buildError = { type: deriveBuildErrorType(error), formatted: formatBuildErrors(error) };
3240
3427
  const errorHtml = buildErrorHTML(buildError);
3241
3428
  await Promise.all(
3242
3429
  activeGroups.map((group) => {
3243
3430
  group.cachedContent._buildError = buildError;
3244
- return fs10.writeFile(
3245
- path7.join(path7.resolve(group.config.projectRoot, group.config.output), "index.html"),
3431
+ return fs11.writeFile(
3432
+ path10.join(path10.resolve(group.config.projectRoot, group.config.output), "index.html"),
3246
3433
  errorHtml
3247
3434
  ).catch(
3248
3435
  (err) => debug && process.stderr.write(`# [qunitx] writeFile index.html: ${err.message}
@@ -3274,7 +3461,7 @@ function buildFilteredTests(filteredTests, outputPath, config) {
3274
3461
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
3275
3462
  },
3276
3463
  needsDisk
3277
- );
3464
+ ).then((r) => r.js);
3278
3465
  }
3279
3466
  async function runWithOverlayfsRetry(getContents, needsDisk) {
3280
3467
  let { result: result2, js } = await getContents();
@@ -3291,10 +3478,10 @@ async function runWithOverlayfsRetry(getContents, needsDisk) {
3291
3478
  }
3292
3479
  if (needsDisk) {
3293
3480
  await Promise.all(
3294
- result2.outputFiles.map((outputFile) => fs10.writeFile(outputFile.path, outputFile.contents))
3481
+ result2.outputFiles.map((outputFile) => fs11.writeFile(outputFile.path, outputFile.contents))
3295
3482
  );
3296
3483
  }
3297
- return js;
3484
+ return { js, metafile: result2.metafile };
3298
3485
  }
3299
3486
  function buildWithOverlayfsRetry(options, needsDisk) {
3300
3487
  const buildOpts = { ...options, write: false };
@@ -3428,9 +3615,9 @@ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode
3428
3615
  process.exit(1);
3429
3616
  }
3430
3617
  function toEsbuildImportPath(filePath) {
3431
- const rel = path7.relative(process.cwd(), filePath);
3618
+ const rel = path10.relative(process.cwd(), filePath);
3432
3619
  const normalized = rel.replace(/\\/g, "/");
3433
- if (path7.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
3620
+ if (path10.isAbsolute(rel)) return filePath.replace(/\\/g, "/");
3434
3621
  return normalized.startsWith(".") ? normalized : "./" + normalized;
3435
3622
  }
3436
3623
  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 +3631,9 @@ var init_tests_in_browser = __esm({
3444
3631
  init_display_final_result();
3445
3632
  init_web_server();
3446
3633
  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")
3634
+ init_metafile_cache();
3635
+ ancestorNodeModules = (dir) => dir.split(path10.sep).map(
3636
+ (_, i, parts) => path10.join(parts.slice(0, parts.length - i).join(path10.sep) || path10.sep, "node_modules")
3449
3637
  );
3450
3638
  ANCESTOR_NODE_MODULES = ancestorNodeModules(process.cwd());
3451
3639
  RETRY_DELAY_MS = 100;
@@ -3481,11 +3669,11 @@ var init_tests_in_browser = __esm({
3481
3669
 
3482
3670
  // lib/utils/open-output-in-browser.ts
3483
3671
  import { spawn as spawn2 } from "node:child_process";
3484
- import path8 from "node:path";
3672
+ import path11 from "node:path";
3485
3673
  import { pathToFileURL as pathToFileURL3 } from "node:url";
3486
3674
  async function openOutputInBrowser(config) {
3487
3675
  try {
3488
- const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL3(path8.join(path8.resolve(config.projectRoot, config.output), "index.html")).href;
3676
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL3(path11.join(path11.resolve(config.projectRoot, config.output), "index.html")).href;
3489
3677
  if (typeof config.open === "string") {
3490
3678
  spawnDetached(config.open, [outputFile]);
3491
3679
  return;
@@ -3518,9 +3706,9 @@ var init_open_output_in_browser = __esm({
3518
3706
  });
3519
3707
 
3520
3708
  // lib/setup/file-watcher.ts
3521
- import fs11 from "node:fs";
3709
+ import fs12 from "node:fs";
3522
3710
  import { readdir, stat, lstat } from "node:fs/promises";
3523
- import path9 from "node:path";
3711
+ import path12 from "node:path";
3524
3712
  function recordJustAdded(config, filePath) {
3525
3713
  let map = justAddedAt.get(config);
3526
3714
  if (!map) justAddedAt.set(config, map = /* @__PURE__ */ new Map());
@@ -3539,7 +3727,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3539
3727
  if (symlinkPollers.has(filePath)) return;
3540
3728
  const handler = (curr, prev) => {
3541
3729
  if (curr.nlink === 0) {
3542
- fs11.unwatchFile(filePath, handler);
3730
+ fs12.unwatchFile(filePath, handler);
3543
3731
  symlinkPollers.delete(filePath);
3544
3732
  if (filePath in config.fsTree) {
3545
3733
  handleWatchEvent(config, extensions, "unlink", filePath, onEventFunc, onFinishFunc);
@@ -3558,8 +3746,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3558
3746
  }
3559
3747
  }
3560
3748
  };
3561
- fs11.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
3562
- symlinkPollers.set(filePath, () => fs11.unwatchFile(filePath, handler));
3749
+ fs12.watchFile(filePath, { interval: SYMLINK_POLL_INTERVAL_MS, persistent: false }, handler);
3750
+ symlinkPollers.set(filePath, () => fs12.unwatchFile(filePath, handler));
3563
3751
  }
3564
3752
  function untrackSymlink(filePath) {
3565
3753
  symlinkPollers.get(filePath)?.();
@@ -3570,7 +3758,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3570
3758
  let rescanInProgress = false;
3571
3759
  const lastEventMs = {};
3572
3760
  const seenMtimeMs = {};
3573
- const childWatcher = fs11.watch(watchPath, { recursive: true }, async (eventType, filename) => {
3761
+ const childWatcher = fs12.watch(watchPath, { recursive: true }, async (eventType, filename) => {
3574
3762
  if (!ready) return;
3575
3763
  if (!filename) {
3576
3764
  if (process.platform === "darwin" && !rescanInProgress) {
@@ -3588,7 +3776,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3588
3776
  }
3589
3777
  return;
3590
3778
  }
3591
- const fullPath = filename === path9.basename(watchPath) ? watchPath : path9.join(watchPath, filename);
3779
+ const fullPath = filename === path12.basename(watchPath) ? watchPath : path12.join(watchPath, filename);
3592
3780
  if (eventType === "change") {
3593
3781
  const now = Date.now();
3594
3782
  const last = lastEventMs[fullPath] ?? 0;
@@ -3626,8 +3814,8 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3626
3814
  }
3627
3815
  handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
3628
3816
  });
3629
- const parentDir = path9.dirname(watchPath);
3630
- const watchedBasename = path9.basename(watchPath);
3817
+ const parentDir = path12.dirname(watchPath);
3818
+ const watchedBasename = path12.basename(watchPath);
3631
3819
  let parentUnlinkFired = false;
3632
3820
  let rescanTimer = null;
3633
3821
  const tryFireParentUnlink = async () => {
@@ -3646,7 +3834,7 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
3646
3834
  return true;
3647
3835
  }
3648
3836
  };
3649
- const parentWatcher = fs11.watch(parentDir, async (eventType, filename) => {
3837
+ const parentWatcher = fs12.watch(parentDir, async (eventType, filename) => {
3650
3838
  if (!ready || filename !== watchedBasename || eventType !== "rename") return;
3651
3839
  await tryFireParentUnlink();
3652
3840
  });
@@ -3760,11 +3948,11 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
3760
3948
  const trackedToRecheck = [];
3761
3949
  for (const entry of entries) {
3762
3950
  if (entry.isDirectory()) {
3763
- presentDirs.add(path9.join(entry.parentPath, entry.name));
3951
+ presentDirs.add(path12.join(entry.parentPath, entry.name));
3764
3952
  continue;
3765
3953
  }
3766
3954
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
3767
- const entryPath = path9.join(entry.parentPath, entry.name);
3955
+ const entryPath = path12.join(entry.parentPath, entry.name);
3768
3956
  presentDirs.add(entry.parentPath);
3769
3957
  if (!extensions.some((ext) => entryPath.endsWith(`.${ext}`))) continue;
3770
3958
  presentPaths.add(entryPath);
@@ -3787,13 +3975,13 @@ async function rescanDirectoryForDelta(watchPath, config, extensions, onEventFun
3787
3975
  }
3788
3976
  })
3789
3977
  );
3790
- const watchPrefix = watchPath + path9.sep;
3978
+ const watchPrefix = watchPath + path12.sep;
3791
3979
  const firedDirPrefixes = [];
3792
3980
  for (const trackedPath of Object.keys(config.fsTree)) {
3793
3981
  if (!trackedPath.startsWith(watchPrefix) || presentPaths.has(trackedPath)) continue;
3794
- if (firedDirPrefixes.some((p) => trackedPath.startsWith(p + path9.sep))) continue;
3795
- const parts = trackedPath.slice(watchPrefix.length).split(path9.sep);
3796
- const goneDirPath = parts.slice(0, -1).map((_, i) => watchPrefix + parts.slice(0, i + 1).join(path9.sep)).find((p) => !presentDirs.has(p)) ?? null;
3982
+ if (firedDirPrefixes.some((p) => trackedPath.startsWith(p + path12.sep))) continue;
3983
+ const parts = trackedPath.slice(watchPrefix.length).split(path12.sep);
3984
+ const goneDirPath = parts.slice(0, -1).map((_, i) => watchPrefix + parts.slice(0, i + 1).join(path12.sep)).find((p) => !presentDirs.has(p)) ?? null;
3797
3985
  if (goneDirPath !== null) {
3798
3986
  firedDirPrefixes.push(goneDirPath);
3799
3987
  handleWatchEvent(config, extensions, "unlinkDir", goneDirPath, onEventFunc, onFinishFunc);
@@ -3846,7 +4034,7 @@ var init_file_watcher = __esm({
3846
4034
  SYMLINK_POLL_INTERVAL_MS = 500;
3847
4035
  OVERLAYFS_RENAME_RETRY_MS = 50;
3848
4036
  RESCAN_INTERVAL_MS = 1e3;
3849
- CHANGE_COALESCE_MS = 50;
4037
+ CHANGE_COALESCE_MS = 75;
3850
4038
  ADD_SUPPRESS_WINDOW_MS = 1e3;
3851
4039
  justAddedAt = /* @__PURE__ */ new WeakMap();
3852
4040
  }
@@ -3928,28 +4116,28 @@ var init_keyboard_events = __esm({
3928
4116
  });
3929
4117
 
3930
4118
  // lib/setup/write-output-static-files.ts
3931
- import fs12 from "node:fs/promises";
3932
- import path10 from "node:path";
4119
+ import fs13 from "node:fs/promises";
4120
+ import path13 from "node:path";
3933
4121
  async function writeOutputStaticFiles({ projectRoot, output }, cachedContent) {
3934
4122
  const staticHTMLPromises = Object.keys(cachedContent.staticHTMLs).map(async (staticHTMLKey) => {
3935
- const htmlRelativePath = path10.relative(projectRoot, staticHTMLKey);
3936
- const outDir = path10.resolve(projectRoot, output);
3937
- await ensureFolderExists(path10.join(outDir, htmlRelativePath));
3938
- await fs12.writeFile(
3939
- path10.join(outDir, htmlRelativePath),
4123
+ const htmlRelativePath = path13.relative(projectRoot, staticHTMLKey);
4124
+ const outDir = path13.resolve(projectRoot, output);
4125
+ await ensureFolderExists(path13.join(outDir, htmlRelativePath));
4126
+ await fs13.writeFile(
4127
+ path13.join(outDir, htmlRelativePath),
3940
4128
  cachedContent.staticHTMLs[staticHTMLKey]
3941
4129
  );
3942
4130
  });
3943
4131
  const assetPromises = Array.from(cachedContent.assets).map(async (assetAbsolutePath) => {
3944
- const assetRelativePath = path10.relative(projectRoot, assetAbsolutePath);
3945
- const outDir = path10.resolve(projectRoot, output);
3946
- await ensureFolderExists(path10.join(outDir, assetRelativePath));
3947
- await fs12.copyFile(assetAbsolutePath, path10.join(outDir, assetRelativePath));
4132
+ const assetRelativePath = path13.relative(projectRoot, assetAbsolutePath).replace(/^(?:\.\.[\\/])+/, "");
4133
+ const outDir = path13.resolve(projectRoot, output);
4134
+ await ensureFolderExists(path13.join(outDir, assetRelativePath));
4135
+ await fs13.copyFile(assetAbsolutePath, path13.join(outDir, assetRelativePath));
3948
4136
  });
3949
4137
  await Promise.all(staticHTMLPromises.concat(assetPromises));
3950
4138
  }
3951
4139
  async function ensureFolderExists(assetPath) {
3952
- await fs12.mkdir(path10.dirname(assetPath), { recursive: true });
4140
+ await fs13.mkdir(path13.dirname(assetPath), { recursive: true });
3953
4141
  }
3954
4142
  var init_write_output_static_files = __esm({
3955
4143
  "lib/setup/write-output-static-files.ts"() {
@@ -3957,9 +4145,9 @@ var init_write_output_static_files = __esm({
3957
4145
  });
3958
4146
 
3959
4147
  // lib/utils/daemon-hint.ts
3960
- import fs13 from "node:fs/promises";
4148
+ import fs14 from "node:fs/promises";
3961
4149
  import os3 from "node:os";
3962
- import path11 from "node:path";
4150
+ import path14 from "node:path";
3963
4151
  function shouldShowDaemonHint(ctx) {
3964
4152
  const env = ctx.env ?? process.env;
3965
4153
  if (ctx.watch) return false;
@@ -3977,14 +4165,14 @@ async function maybePrintDaemonHint(ctx, opts = {}) {
3977
4165
  if (!shouldShowDaemonHint(ctx)) return;
3978
4166
  const sentinel = opts.sentinelPath ?? DEFAULT_SENTINEL;
3979
4167
  try {
3980
- await fs13.access(sentinel);
4168
+ await fs14.access(sentinel);
3981
4169
  return;
3982
4170
  } catch {
3983
4171
  }
3984
4172
  (opts.write ?? ((t) => process.stderr.write(t)))(HINT_TEXT);
3985
4173
  try {
3986
- await fs13.mkdir(path11.dirname(sentinel), { recursive: true });
3987
- await fs13.writeFile(sentinel, (/* @__PURE__ */ new Date()).toISOString());
4174
+ await fs14.mkdir(path14.dirname(sentinel), { recursive: true });
4175
+ await fs14.writeFile(sentinel, (/* @__PURE__ */ new Date()).toISOString());
3988
4176
  } catch {
3989
4177
  }
3990
4178
  }
@@ -3993,7 +4181,7 @@ var init_daemon_hint = __esm({
3993
4181
  "lib/utils/daemon-hint.ts"() {
3994
4182
  FAST_RUN_THRESHOLD_MS = 500;
3995
4183
  HINT_TEXT = "\n\x1B[34m\u2139\x1B[39m Tip: export QUNITX_DAEMON=1 for ~2\xD7 faster repeated runs (qunitx daemon --help)\n";
3996
- DEFAULT_SENTINEL = path11.join(os3.homedir(), ".cache", "qunitx", "hint-shown");
4184
+ DEFAULT_SENTINEL = path14.join(os3.homedir(), ".cache", "qunitx", "hint-shown");
3997
4185
  }
3998
4186
  });
3999
4187
 
@@ -4006,7 +4194,7 @@ __export(run_exports, {
4006
4194
  readTimingCache: () => readTimingCache,
4007
4195
  run: () => run
4008
4196
  });
4009
- import fs14 from "node:fs/promises";
4197
+ import fs15 from "node:fs/promises";
4010
4198
  import { join as join3, normalize } from "node:path";
4011
4199
  import { createRequire as createRequire2 } from "node:module";
4012
4200
  import { availableParallelism } from "node:os";
@@ -4093,6 +4281,21 @@ async function run(config) {
4093
4281
  logWatcherAndKeyboardShortcutInfo(config, connections.server);
4094
4282
  } else {
4095
4283
  const allFiles = Object.keys(config.fsTree);
4284
+ if (allFiles.length === 0) {
4285
+ process.stdout.write("TAP version 13\n");
4286
+ process.stdout.write(
4287
+ `# Running 0 test files${config._daemonMode ? " (daemon)" : ""}
4288
+ 1..0
4289
+ `
4290
+ );
4291
+ if (config._daemonMode) throw new DaemonRunError(0);
4292
+ if (!config.watch) {
4293
+ const browser2 = config._daemonBrowser ? null : await browserPromise;
4294
+ await closeWithGrace([browser2?.close(), shutdownPrelaunch()]);
4295
+ return process.exit(0);
4296
+ }
4297
+ return;
4298
+ }
4096
4299
  const groupCount = Math.min(allFiles.length, availableParallelism());
4097
4300
  const { groups, weights } = await splitIntoGroups(allFiles, groupCount, timings ?? {});
4098
4301
  config.COUNTER = {
@@ -4109,6 +4312,12 @@ async function run(config) {
4109
4312
  fsTree: Object.fromEntries(groupFiles.map((filePath) => [filePath, config.fsTree[filePath]])),
4110
4313
  // Single group keeps the root output dir for backward-compatible file paths.
4111
4314
  output: groupCount === 1 ? config.output : `${config.output}/group-${i}`,
4315
+ // Page reuse is single-group only: in concurrent group mode group 0 would
4316
+ // otherwise drain `slot.page` (setupBrowser consumes it) without re-stashing
4317
+ // (cleanup's `groupCount === 1` guard rejects), leaving the slot empty for
4318
+ // the next single-file run. Withhold the slot here so the warm page survives
4319
+ // a transient multi-file invocation untouched.
4320
+ _daemonPageSlot: groupCount === 1 ? config._daemonPageSlot : void 0,
4112
4321
  _groupMode: true,
4113
4322
  _phase: "bundling"
4114
4323
  }));
@@ -4181,9 +4390,11 @@ async function run(config) {
4181
4390
  await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
4182
4391
  } finally {
4183
4392
  await flushConsoleHandlers(groupConfig._pendingConsoleHandlers, connections.page);
4393
+ const reusePage = groupCount === 1 && groupConfig._daemonPageSlot && connections.page && !connections.page.isClosed();
4394
+ if (reusePage) groupConfig._daemonPageSlot.page = connections.page;
4184
4395
  await closeWithGrace([
4185
4396
  sharedServer ? void 0 : connections.server?.close(),
4186
- connections.page?.close()
4397
+ reusePage ? void 0 : connections.page?.close()
4187
4398
  ]);
4188
4399
  }
4189
4400
  })();
@@ -4250,7 +4461,7 @@ async function run(config) {
4250
4461
  }
4251
4462
  async function buildCachedContent(config, htmlPaths) {
4252
4463
  const htmlBuffers = await Promise.all(
4253
- config.htmlPaths.map((htmlPath) => fs14.readFile(htmlPath).catch(() => null))
4464
+ config.htmlPaths.map((htmlPath) => fs15.readFile(htmlPath).catch(() => null))
4254
4465
  );
4255
4466
  const cachedContent = htmlPaths.reduce(
4256
4467
  (result2, _htmlPath, index) => {
@@ -4305,7 +4516,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
4305
4516
  }
4306
4517
  async function readTimingCache(projectRoot) {
4307
4518
  try {
4308
- const parsed = JSON.parse(await fs14.readFile(`${projectRoot}/tmp/test-timings.json`, "utf8"));
4519
+ const parsed = JSON.parse(await fs15.readFile(`${projectRoot}/tmp/test-timings.json`, "utf8"));
4309
4520
  return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
4310
4521
  } catch {
4311
4522
  return {};
@@ -4324,7 +4535,7 @@ function computeFileTimes(groups, weights, wallTimes) {
4324
4535
  return result2;
4325
4536
  }
4326
4537
  async function persistTimings(fileTimes, projectRoot) {
4327
- await fs14.writeFile(
4538
+ await fs15.writeFile(
4328
4539
  `${projectRoot}/tmp/test-timings.json`,
4329
4540
  JSON.stringify(Object.fromEntries(fileTimes), null, 2)
4330
4541
  );
@@ -4339,7 +4550,7 @@ ${lines.join("\n")}
4339
4550
  async function splitIntoGroups(files, groupCount, timings) {
4340
4551
  const sizes = await Promise.all(
4341
4552
  files.map(
4342
- (f) => timings[f] > 0 ? Promise.resolve(0) : fs14.stat(f).then((s) => s.size).catch(() => 0)
4553
+ (f) => timings[f] > 0 ? Promise.resolve(0) : fs15.stat(f).then((s) => s.size).catch(() => 0)
4343
4554
  )
4344
4555
  );
4345
4556
  const knownRates = files.map((f, i) => ({ ms: timings[f], size: sizes[i] })).filter(({ ms, size }) => ms > 0 && size > 0);
@@ -4413,19 +4624,19 @@ __export(server_exports, {
4413
4624
  runDaemonServer: () => runDaemonServer
4414
4625
  });
4415
4626
  import net2 from "node:net";
4416
- import fs15 from "node:fs";
4627
+ import fs16 from "node:fs";
4417
4628
  import { writeFile, unlink, stat as stat2, chmod } from "node:fs/promises";
4418
- import path12 from "node:path";
4629
+ import path15 from "node:path";
4419
4630
  async function runDaemonServer() {
4420
4631
  const cwd = process.cwd();
4421
4632
  const socketPath = daemonSocketPath(cwd);
4422
4633
  const infoPath = daemonInfoPath(cwd);
4423
- if (fs15.existsSync(infoPath) && await isLiveSocket(socketPath)) process.exit(0);
4634
+ if (fs16.existsSync(infoPath) && await isLiveSocket(socketPath)) process.exit(0);
4424
4635
  await unlink(socketPath).catch(() => {
4425
4636
  });
4426
4637
  const logPath = process.env.QUNITX_DAEMON_LOG;
4427
4638
  if (logPath) {
4428
- const log = fs15.createWriteStream(logPath, { flags: "a" });
4639
+ const log = fs16.createWriteStream(logPath, { flags: "a" });
4429
4640
  log.on("error", () => {
4430
4641
  });
4431
4642
  const forward = log.write.bind(log);
@@ -4459,7 +4670,8 @@ async function runDaemonServer() {
4459
4670
  infoPath,
4460
4671
  consecutiveCrashes: 0,
4461
4672
  listenSucceeded: false,
4462
- esbuildCache: { _esbuildContext: null }
4673
+ esbuildCache: { _esbuildContext: null },
4674
+ pageSlot: { page: null }
4463
4675
  };
4464
4676
  const shutdown = (reason) => shutdownDaemon2(state, reason);
4465
4677
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
@@ -4519,6 +4731,8 @@ async function shutdownDaemon2(state, reason) {
4519
4731
  }) : null,
4520
4732
  state.listenSucceeded ? unlink(state.infoPath).catch(() => {
4521
4733
  }) : null,
4734
+ state.pageSlot.page?.close().catch(() => {
4735
+ }),
4522
4736
  state.browser.close().catch(() => {
4523
4737
  }),
4524
4738
  state.esbuildCache._esbuildContext?.dispose().catch(() => {
@@ -4551,7 +4765,7 @@ async function dispatch(req, socket, state) {
4551
4765
  socket.end();
4552
4766
  } else if (req.type === "shutdown") {
4553
4767
  try {
4554
- fs15.unlinkSync(state.infoPath);
4768
+ fs16.unlinkSync(state.infoPath);
4555
4769
  } catch {
4556
4770
  }
4557
4771
  writeChunk(socket, { type: "done", exitCode: 0 });
@@ -4648,6 +4862,7 @@ async function recoverBrowser(state) {
4648
4862
  );
4649
4863
  state.browser.close().catch(() => {
4650
4864
  });
4865
+ state.pageSlot.page = null;
4651
4866
  try {
4652
4867
  state.browser = await launchBrowser(state.baseConfig, true);
4653
4868
  } catch (err) {
@@ -4670,6 +4885,7 @@ async function runOnce(argv, env, state) {
4670
4885
  config._daemonMode = true;
4671
4886
  config._daemonBrowser = state.browser;
4672
4887
  config._daemonEsbuildCache = state.esbuildCache;
4888
+ config._daemonPageSlot = state.pageSlot;
4673
4889
  config.watch = false;
4674
4890
  config.open = false;
4675
4891
  try {
@@ -4693,7 +4909,7 @@ async function isLiveSocket(socketPath) {
4693
4909
  }
4694
4910
  async function readPkgMtime(cwd) {
4695
4911
  try {
4696
- return (await stat2(path12.join(cwd, "package.json"))).mtimeMs;
4912
+ return (await stat2(path15.join(cwd, "package.json"))).mtimeMs;
4697
4913
  } catch {
4698
4914
  return 0;
4699
4915
  }
@@ -4721,8 +4937,8 @@ __export(daemon_exports, {
4721
4937
  runDaemonCommand: () => runDaemonCommand
4722
4938
  });
4723
4939
  import { spawn as spawn3 } from "node:child_process";
4724
- import fs16, { existsSync as existsSync3 } from "node:fs";
4725
- import path13 from "node:path";
4940
+ import fs17, { existsSync as existsSync3 } from "node:fs";
4941
+ import path16 from "node:path";
4726
4942
  async function buildDaemonSpawn() {
4727
4943
  const sea = await import("node:sea").catch(() => null);
4728
4944
  if (sea?.isSea()) return { bin: process.execPath, args: ["daemon", "_serve"] };
@@ -4747,15 +4963,15 @@ async function runServeMode() {
4747
4963
  function waitForFile(filePath, timeoutMs) {
4748
4964
  if (existsSync3(filePath)) return Promise.resolve(true);
4749
4965
  return new Promise((resolve) => {
4750
- const dir = path13.dirname(filePath);
4751
- const fileName = path13.basename(filePath);
4966
+ const dir = path16.dirname(filePath);
4967
+ const fileName = path16.basename(filePath);
4752
4968
  const settle = (ok) => {
4753
4969
  clearTimeout(timer);
4754
4970
  watcher.close();
4755
4971
  resolve(ok);
4756
4972
  };
4757
4973
  const timer = setTimeout(() => settle(false), timeoutMs);
4758
- const watcher = fs16.watch(dir, (_event, name) => {
4974
+ const watcher = fs17.watch(dir, (_event, name) => {
4759
4975
  if (name === fileName && existsSync3(filePath)) settle(true);
4760
4976
  });
4761
4977
  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.7",
4
+ "version": "0.25.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",