as-test 1.5.2 → 1.6.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.
@@ -20,6 +20,11 @@ import { PassThrough } from "stream";
20
20
  import { buildWebRunnerSource } from "./web-runner-source.js";
21
21
  import { PersistentWebSessionHost } from "./web-session.js";
22
22
  import { build } from "./build-core.js";
23
+ import {
24
+ cacheStorage,
25
+ reportHasFailure,
26
+ sha256OfFile,
27
+ } from "../build-cache.js";
23
28
  import { resolveSpecFiles, emitSelectorWarnings } from "../selectors.js";
24
29
  import { createReporter as createDefaultReporter } from "../reporters/default.js";
25
30
  import { createTapReporter } from "../reporters/tap.js";
@@ -808,6 +813,7 @@ export async function run(
808
813
  ? await PersistentWebSessionHost.start(false)
809
814
  : null;
810
815
  const webSession = options.webSession ?? ownedWebSession;
816
+ const cacheCtx = cacheStorage.getStore();
811
817
  try {
812
818
  for (let i = 0; i < inputFiles.length; i++) {
813
819
  const file = inputFiles[i];
@@ -815,6 +821,72 @@ export async function run(
815
821
  config.outDir,
816
822
  resolveArtifactPath(file, config.input),
817
823
  );
824
+ // Tier 2: replay a stored passing report instead of running. build() ran
825
+ // first this session and validated the build is fresh (else it cleared
826
+ // the stored report), so here we only re-check the run-specific inputs.
827
+ if (cacheCtx?.replay) {
828
+ const snapPath = resolveSnapshotPath(
829
+ file,
830
+ config.snapshotDir,
831
+ config.input,
832
+ );
833
+ const snapshotSha = existsSync(snapPath)
834
+ ? sha256OfFile(snapPath)
835
+ : null;
836
+ if (
837
+ cacheCtx.cache.canReplay(options.modeName, file, {
838
+ runtimeCmd: runtimeCommand,
839
+ snapshotSha,
840
+ })
841
+ ) {
842
+ const cached = cacheCtx.cache.getReport(options.modeName, file);
843
+ if (cached && !reportHasFailure(cached)) {
844
+ const cachedSuites = Array.isArray(cached.suites)
845
+ ? cached.suites
846
+ : [];
847
+ const selected = options.suiteSelectors?.length
848
+ ? filterSelectedSuites(
849
+ cachedSuites,
850
+ options.suiteSelectors,
851
+ file,
852
+ options.modeName ?? "default",
853
+ )
854
+ : cachedSuites;
855
+ replayCachedReport(reporter, file, selected);
856
+ const cachedSnap = cached.snapshotSummary ?? {
857
+ matched: 0,
858
+ created: 0,
859
+ updated: 0,
860
+ failed: 0,
861
+ };
862
+ snapshotSummary.matched += cachedSnap.matched ?? 0;
863
+ snapshotSummary.created += cachedSnap.created ?? 0;
864
+ snapshotSummary.updated += cachedSnap.updated ?? 0;
865
+ snapshotSummary.failed += cachedSnap.failed ?? 0;
866
+ reports.push({
867
+ file,
868
+ modeName: options.modeName ?? "default",
869
+ suites: selected,
870
+ coverage: cached.coverage ?? {
871
+ total: 0,
872
+ covered: 0,
873
+ uncovered: 0,
874
+ percent: 100,
875
+ points: [],
876
+ },
877
+ runCommand: cached.runCommand ?? "",
878
+ buildCommand:
879
+ options.buildCommandsByFile?.[file] ??
880
+ options.buildCommand ??
881
+ cached.buildCommand ??
882
+ "",
883
+ snapshotSummary: cachedSnap,
884
+ cached: true,
885
+ });
886
+ continue;
887
+ }
888
+ }
889
+ }
818
890
  if (!existsSync(outFile)) {
819
891
  const buildStartedAt = Date.now();
820
892
  await build(
@@ -929,7 +1001,7 @@ export async function run(
929
1001
  snapshotSummary.created += snapshotStore.created;
930
1002
  snapshotSummary.updated += snapshotStore.updated;
931
1003
  snapshotSummary.failed += snapshotStore.failed;
932
- reports.push({
1004
+ const fileReport = {
933
1005
  file,
934
1006
  modeName: options.modeName ?? "default",
935
1007
  suites: selectedSuites,
@@ -943,7 +1015,29 @@ export async function run(
943
1015
  updated: snapshotStore.updated,
944
1016
  failed: snapshotStore.failed,
945
1017
  },
946
- });
1018
+ // When the cache is active, mark freshly-run reports `false` (replays
1019
+ // are marked `true`) so the summary can show a cache hit/miss split.
1020
+ // Left undefined when the cache is off so no Cache line is shown.
1021
+ ...(cacheCtx?.cache ? { cached: false } : {}),
1022
+ };
1023
+ reports.push(fileReport);
1024
+ // Persist this report so an unchanged future run can replay it (Tier 2).
1025
+ // recordBuild ran during build() this session, so the entry exists.
1026
+ if (cacheCtx?.cache) {
1027
+ const snapPath = resolveSnapshotPath(
1028
+ file,
1029
+ config.snapshotDir,
1030
+ config.input,
1031
+ );
1032
+ const snapshotSha = existsSync(snapPath)
1033
+ ? sha256OfFile(snapPath)
1034
+ : null;
1035
+ cacheCtx.cache.recordReport(options.modeName, file, {
1036
+ report: fileReport,
1037
+ snapshotSha,
1038
+ runtimeCmd: runtimeCommand,
1039
+ });
1040
+ }
947
1041
  }
948
1042
  } finally {
949
1043
  await ownedWebSession?.close();
@@ -2160,6 +2254,22 @@ async function runProcess(
2160
2254
  });
2161
2255
  return synthesized;
2162
2256
  }
2257
+ // A spec file with no test suites never calls `run()`, so it emits no
2258
+ // lifecycle frames and exits cleanly. That is an empty test file, not a
2259
+ // crash — mark it skipped instead of surfacing "missing report payload".
2260
+ if (
2261
+ code === 0 &&
2262
+ reportStream.dataFrames === 0 &&
2263
+ !runtimeEvents.sawFileStart &&
2264
+ !runtimeEvents.sawFileEnd &&
2265
+ runtimeEvents.suiteStarts === 0 &&
2266
+ !hasMeaningfulRuntimeOutput(stderrBuffer)
2267
+ ) {
2268
+ reporter.onWarning?.({
2269
+ message: `${formatSpecDisplayPath(specFile)} contains no tests; marked as skipped`,
2270
+ });
2271
+ return createEmptyFileSkipReport(specFile, modeName);
2272
+ }
2163
2273
  const errorText = "missing report payload from test runtime";
2164
2274
  const diagnostics = buildRuntimeReportDiagnostics(
2165
2275
  code,
@@ -2650,6 +2760,24 @@ function buildRuntimeReportDiagnostics(
2650
2760
  `runtime events: fileStart=${runtimeEvents.sawFileStart ? "yes" : "no"}, fileEnd=${runtimeEvents.sawFileEnd ? "yes" : "no"}, fileVerdict=${runtimeEvents.fileVerdict}, suiteStarts=${runtimeEvents.suiteStarts}, suiteEnds=${runtimeEvents.suiteEnds}, assertionFails=${runtimeEvents.assertionFails}, warnings=${runtimeEvents.warnings}, logs=${runtimeEvents.logs}`,
2651
2761
  ].join("\n");
2652
2762
  }
2763
+ function createEmptyFileSkipReport(specFile, modeName) {
2764
+ // No suites: a file with zero suites contributes no skipped-suite count, just
2765
+ // a skipped file (an empty `suites` array yields a "none" file verdict, which
2766
+ // collectRunStats tallies as a skipped file). The accompanying onWarning tells
2767
+ // the user the file had no tests.
2768
+ return {
2769
+ file: specFile,
2770
+ modeName: modeName ?? "default",
2771
+ suites: [],
2772
+ coverage: {
2773
+ total: 0,
2774
+ covered: 0,
2775
+ uncovered: 0,
2776
+ percent: 100,
2777
+ points: [],
2778
+ },
2779
+ };
2780
+ }
2653
2781
  function createRuntimeFailureReport(
2654
2782
  specFile,
2655
2783
  modeName,
@@ -2745,6 +2873,58 @@ function shouldSuppressWasiWarningLine(line) {
2745
2873
  }
2746
2874
  return false;
2747
2875
  }
2876
+ // Drive the reporter from a stored (passing) file report so a replayed spec
2877
+ // still scrolls past the live display and is counted, exactly as a fresh run
2878
+ // would render it. Only passing/skipped reports are replayed, so there are no
2879
+ // assertion failures to re-emit.
2880
+ function replayCachedReport(reporter, file, suites) {
2881
+ reporter.onFileStart?.({
2882
+ file,
2883
+ depth: 0,
2884
+ suiteKind: "file",
2885
+ description: file,
2886
+ });
2887
+ let verdict = "none";
2888
+ for (const suite of suites) {
2889
+ verdict = mergeReplayVerdict(
2890
+ verdict,
2891
+ emitReplaySuite(reporter, file, suite),
2892
+ );
2893
+ }
2894
+ reporter.onFileEnd?.({
2895
+ file,
2896
+ depth: 0,
2897
+ suiteKind: "file",
2898
+ description: file,
2899
+ verdict,
2900
+ cached: true,
2901
+ });
2902
+ }
2903
+ function emitReplaySuite(reporter, file, suite) {
2904
+ const depth = Number(suite?.depth ?? 0);
2905
+ const kind = String(suite?.kind ?? "");
2906
+ const description = String(suite?.description ?? "");
2907
+ reporter.onSuiteStart?.({ file, depth, suiteKind: kind, description });
2908
+ let verdict = String(suite?.verdict ?? "none");
2909
+ const subs = Array.isArray(suite?.suites) ? suite.suites : [];
2910
+ for (const sub of subs) {
2911
+ verdict = mergeReplayVerdict(verdict, emitReplaySuite(reporter, file, sub));
2912
+ }
2913
+ reporter.onSuiteEnd?.({
2914
+ file,
2915
+ depth,
2916
+ suiteKind: kind,
2917
+ description,
2918
+ verdict: String(suite?.verdict ?? verdict),
2919
+ });
2920
+ return verdict;
2921
+ }
2922
+ function mergeReplayVerdict(a, b) {
2923
+ if (a === "fail" || b === "fail") return "fail";
2924
+ if (a === "ok" || b === "ok") return "ok";
2925
+ if (a === "skip" || b === "skip") return "skip";
2926
+ return "none";
2927
+ }
2748
2928
  function collectRunStats(reports) {
2749
2929
  const stats = {
2750
2930
  passedFiles: 0,
@@ -29,6 +29,8 @@ export async function executeTestCommand(
29
29
  browser: deps.resolveBrowserOverride(rawArgs, "test"),
30
30
  reporterPath: deps.resolveReporterOverride(rawArgs, "test"),
31
31
  watch: flags.includes("--watch") || flags.includes("-w"),
32
+ cache: flags.includes("--cache"),
33
+ noCache: flags.includes("--no-cache"),
32
34
  };
33
35
  const fuzzEnabled = flags.includes("--fuzz");
34
36
  const fuzzOverrides = deps.resolveFuzzOverrides(rawArgs, "test");
package/bin/index.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  flushModeWarnings,
7
7
  formatInvocation as formatBuildInvocation,
8
8
  getBuildInvocationPreview,
9
+ getBuildReuseInfo,
9
10
  warnOnUnknownModeReferences,
10
11
  } from "./commands/build.js";
11
12
  import { createRunReporter, resetCollectedLogs, run } from "./commands/run.js";
@@ -41,6 +42,8 @@ import { BuildWorkerPool } from "./build-worker-pool.js";
41
42
  import { PersistentWebSessionHost } from "./commands/web-session.js";
42
43
  import { buildRecorderStorage } from "./commands/build-core.js";
43
44
  import { DependencyGraph } from "./dependency-graph.js";
45
+ import { BuildCache, cacheStorage, resolveCacheDir } from "./build-cache.js";
46
+ import { resolveCacheSettings } from "./util.js";
44
47
  import { resolveSpecFiles, emitSelectorWarnings } from "./selectors.js";
45
48
  const _args = process.argv.slice(2);
46
49
  const flags = [];
@@ -477,6 +480,12 @@ function printCommandHelp(command) {
477
480
  process.stdout.write(
478
481
  " --watch, -w Re-run on source or spec changes\n",
479
482
  );
483
+ process.stdout.write(
484
+ " --cache Skip recompiling/rerunning specs unchanged since the last run\n",
485
+ );
486
+ process.stdout.write(
487
+ " --no-cache Ignore the cache for this run (overrides config)\n",
488
+ );
480
489
  process.stdout.write(" --help, -h Show this help\n");
481
490
  return;
482
491
  }
@@ -1239,31 +1248,57 @@ class ParallelQueueDisplay {
1239
1248
  this.showStartLines = showStartLines;
1240
1249
  this.active = new Map();
1241
1250
  this.renderedLines = 0;
1251
+ // Files complete out of order under --parallel (a cache replay finishes
1252
+ // instantly while a fresh build is still running). To keep output in the
1253
+ // order specs were resolved, each token gets a start sequence (start() is
1254
+ // called in resolved/index order) and completed outputs are emitted only as
1255
+ // the contiguous prefix of that sequence fills in.
1256
+ this.seqByToken = new Map();
1257
+ this.nextSeq = 0;
1258
+ this.nextFlushSeq = 0;
1259
+ this.pending = new Map();
1242
1260
  this.enabled = showStartLines && canRewriteParallelQueue();
1243
1261
  }
1244
1262
  start(file) {
1245
1263
  const token = Symbol(file);
1246
- if (!this.showStartLines) return token;
1247
- const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
1264
+ this.seqByToken.set(token, this.nextSeq++);
1248
1265
  if (!this.enabled) return token;
1266
+ const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
1249
1267
  this.clear();
1250
1268
  this.active.set(token, line);
1251
1269
  this.render();
1252
1270
  return token;
1253
1271
  }
1254
1272
  complete(token, output) {
1255
- if (!this.showStartLines || !this.enabled) {
1256
- process.stdout.write(output);
1257
- return;
1258
- }
1259
- this.clear();
1260
- process.stdout.write(output);
1273
+ const seq = this.seqByToken.get(token) ?? this.nextFlushSeq;
1274
+ this.seqByToken.delete(token);
1261
1275
  this.active.delete(token);
1262
- this.render();
1276
+ this.pending.set(seq, output);
1277
+ this.flushOrdered();
1278
+ }
1279
+ // Emit the contiguous run of completed outputs starting at nextFlushSeq, so
1280
+ // results print in resolved order regardless of completion order.
1281
+ flushOrdered() {
1282
+ if (!this.pending.has(this.nextFlushSeq)) return;
1283
+ if (this.enabled) this.clear();
1284
+ while (this.pending.has(this.nextFlushSeq)) {
1285
+ process.stdout.write(this.pending.get(this.nextFlushSeq));
1286
+ this.pending.delete(this.nextFlushSeq);
1287
+ this.nextFlushSeq++;
1288
+ }
1289
+ if (this.enabled) this.render();
1263
1290
  }
1264
1291
  flush() {
1265
- if (!this.enabled) return;
1266
- this.clear();
1292
+ // Drain anything still buffered (e.g. a gap left by an errored spec) in
1293
+ // sequence order, then clear the live block.
1294
+ if (this.pending.size) {
1295
+ if (this.enabled) this.clear();
1296
+ for (const seq of [...this.pending.keys()].sort((a, b) => a - b)) {
1297
+ process.stdout.write(this.pending.get(seq));
1298
+ }
1299
+ this.pending.clear();
1300
+ }
1301
+ if (this.enabled) this.clear();
1267
1302
  }
1268
1303
  clear() {
1269
1304
  if (!this.renderedLines) return;
@@ -1329,6 +1364,29 @@ async function buildFileForMode(args) {
1329
1364
  // recorder so the dependency graph still gets populated under --parallel.
1330
1365
  const recorder = buildRecorderStorage.getStore();
1331
1366
  if (args.buildPool) {
1367
+ // The non-pool branch delegates to build(), which owns the cache logic.
1368
+ // The pool builds in child processes, so handle the cache here: skip when
1369
+ // fresh, else collect the worker's reported reads and record them.
1370
+ const cacheCtx = cacheStorage.getStore();
1371
+ const reuse = cacheCtx
1372
+ ? await getBuildReuseInfo(
1373
+ args.configPath,
1374
+ args.file,
1375
+ args.modeName,
1376
+ args.buildFeatureToggles,
1377
+ )
1378
+ : null;
1379
+ if (
1380
+ cacheCtx &&
1381
+ reuse &&
1382
+ cacheCtx.cache.isBuildFresh(args.modeName, args.file, {
1383
+ signature: reuse.signature,
1384
+ coverageEnabled: reuse.coverageEnabled,
1385
+ })
1386
+ ) {
1387
+ return;
1388
+ }
1389
+ const reads = cacheCtx && reuse ? new Set() : null;
1332
1390
  const buildInvocation = await getBuildInvocationPreview(
1333
1391
  args.configPath,
1334
1392
  args.file,
@@ -1341,12 +1399,24 @@ async function buildFileForMode(args) {
1341
1399
  modeName: args.modeName,
1342
1400
  buildCommand: formatBuildInvocation(buildInvocation),
1343
1401
  featureToggles: args.buildFeatureToggles,
1344
- onReads: recorder
1345
- ? (reads) => {
1346
- for (const r of reads) recorder.record(r.mode, r.spec, r.file);
1347
- }
1348
- : undefined,
1402
+ onReads:
1403
+ recorder || reads
1404
+ ? (entries) => {
1405
+ for (const r of entries) {
1406
+ recorder?.record(r.mode, r.spec, r.file);
1407
+ reads?.add(r.file);
1408
+ }
1409
+ }
1410
+ : undefined,
1349
1411
  });
1412
+ if (cacheCtx && reuse && reads) {
1413
+ cacheCtx.cache.recordBuild(args.modeName, args.file, {
1414
+ signature: reuse.signature,
1415
+ outFile: reuse.outFile,
1416
+ deps: reads,
1417
+ coverageEnabled: reuse.coverageEnabled,
1418
+ });
1419
+ }
1350
1420
  } else {
1351
1421
  await build(
1352
1422
  args.configPath,
@@ -1949,17 +2019,70 @@ async function runTestModes(
1949
2019
  );
1950
2020
  return;
1951
2021
  }
1952
- const failed = await runTestModesCore(
1953
- runFlags,
1954
- configPath,
1955
- selectors,
1956
- suiteSelectors,
1957
- fuzzerSelectors,
1958
- modes,
1959
- buildFeatureToggles,
1960
- fuzzEnabled,
1961
- fuzzOverrides,
2022
+ // Opt-in incremental cache for the whole non-watch run (watch keeps its own
2023
+ // in-memory dependency graph). Fuzzing is non-deterministic, so never cache.
2024
+ const baseConfig = loadConfig(
2025
+ configPath ?? path.join(process.cwd(), "./as-test.config.json"),
1962
2026
  );
2027
+ const { mode: cacheMode, maxTimeMs } = resolveCacheSettings(
2028
+ baseConfig.cache,
2029
+ {
2030
+ cache: runFlags.cache,
2031
+ noCache: runFlags.noCache,
2032
+ },
2033
+ );
2034
+ const cacheEnabled = cacheMode !== "off" && !fuzzEnabled;
2035
+ if (!cacheEnabled) {
2036
+ const failed = await runTestModesCore(
2037
+ runFlags,
2038
+ configPath,
2039
+ selectors,
2040
+ suiteSelectors,
2041
+ fuzzerSelectors,
2042
+ modes,
2043
+ buildFeatureToggles,
2044
+ fuzzEnabled,
2045
+ fuzzOverrides,
2046
+ );
2047
+ process.exit(failed ? 1 : 0);
2048
+ }
2049
+ const cacheDir = resolveCacheDir(baseConfig.outDir);
2050
+ const cache = BuildCache.load(cacheDir, getCliVersion(), { maxTimeMs });
2051
+ // Replay (Tier 2) is unsafe while writing snapshots — those mutate .snap, so
2052
+ // a replayed snapshot summary would be wrong. Build-skip still applies.
2053
+ const replay =
2054
+ cacheMode === "full" &&
2055
+ !runFlags.createSnapshots &&
2056
+ !runFlags.overwriteSnapshots;
2057
+ // Only prune entries on a full run: a selector-scoped run (`ast test foo`)
2058
+ // resolves a subset, so pruning to it would wipe every other spec's entry.
2059
+ let liveKeys = null;
2060
+ if (!selectors.length) {
2061
+ const files = await resolveSelectedFiles(configPath, [], false);
2062
+ liveKeys = new Set();
2063
+ for (const modeName of modes) {
2064
+ for (const file of files) liveKeys.add(cache.keyFor(modeName, file));
2065
+ }
2066
+ }
2067
+ let failed = false;
2068
+ try {
2069
+ failed = await cacheStorage.run({ cache, replay }, () =>
2070
+ runTestModesCore(
2071
+ runFlags,
2072
+ configPath,
2073
+ selectors,
2074
+ suiteSelectors,
2075
+ fuzzerSelectors,
2076
+ modes,
2077
+ buildFeatureToggles,
2078
+ fuzzEnabled,
2079
+ fuzzOverrides,
2080
+ ),
2081
+ );
2082
+ } finally {
2083
+ if (liveKeys) cache.prune(liveKeys);
2084
+ cache.save();
2085
+ }
1963
2086
  process.exit(failed ? 1 : 0);
1964
2087
  }
1965
2088
  async function runWatchLoop(
@@ -1977,6 +2100,43 @@ async function runWatchLoop(
1977
2100
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
1978
2101
  const absConfigPath = path.resolve(resolvedConfigPath);
1979
2102
  let config = loadConfig(resolvedConfigPath, false);
2103
+ // Persistent incremental cache, honored under --watch too (the dependency
2104
+ // graph above only governs which specs re-run; the cache skips recompiling /
2105
+ // replays unchanged ones, which makes the initial watch run and "run all"
2106
+ // fast). Resolved from flags+config; toggleable live with `c`.
2107
+ const initialCache = resolveCacheSettings(config.cache, {
2108
+ cache: runFlags.cache,
2109
+ noCache: runFlags.noCache,
2110
+ });
2111
+ let cacheMode = fuzzEnabled ? "off" : initialCache.mode;
2112
+ const cacheMaxTimeMs = initialCache.maxTimeMs;
2113
+ // The mode `c` toggles back on to (the configured mode, or "full" if the
2114
+ // cache started off).
2115
+ const cacheToggleMode =
2116
+ initialCache.mode === "off" ? "full" : initialCache.mode;
2117
+ // Wraps a run in the cache context (fresh per run so `now`/maxTime are
2118
+ // current), unless the cache is off. Entry-pruning is intentionally skipped
2119
+ // under watch — scoped re-runs resolve only a subset of specs, so pruning to
2120
+ // them would wipe the rest of the cache.
2121
+ async function withCache(fn) {
2122
+ if (cacheMode === "off") {
2123
+ await fn();
2124
+ return;
2125
+ }
2126
+ const cacheDir = resolveCacheDir(config.outDir);
2127
+ const cache = BuildCache.load(cacheDir, getCliVersion(), {
2128
+ maxTimeMs: cacheMaxTimeMs,
2129
+ });
2130
+ const replay =
2131
+ cacheMode === "full" &&
2132
+ !runFlags.createSnapshots &&
2133
+ !runFlags.overwriteSnapshots;
2134
+ try {
2135
+ await cacheStorage.run({ cache, replay }, fn);
2136
+ } finally {
2137
+ cache.save();
2138
+ }
2139
+ }
1980
2140
  // Respect the user's parallelism flags. Worker-pool builds forward their
1981
2141
  // file-read records back through IPC (see BuildWorkerPool / build-worker
1982
2142
  // and buildFileForMode), so the dependency graph stays correct under
@@ -2026,12 +2186,14 @@ async function runWatchLoop(
2026
2186
  : "";
2027
2187
  return chalk.dim(
2028
2188
  `Auto-run paused${pending}. ` +
2029
- chalk.bold("w") +
2030
- " = resume, " +
2031
- chalk.bold("a") +
2032
- " = re-run all, " +
2033
2189
  chalk.bold("space") +
2034
2190
  " = retry failing, " +
2191
+ chalk.bold("a") +
2192
+ " = re-run all, " +
2193
+ chalk.bold("w") +
2194
+ " = resume, " +
2195
+ chalk.bold("c") +
2196
+ ` = cache (${cacheMode === "off" ? "off" : "on"}), ` +
2035
2197
  chalk.bold("ctrl+c") +
2036
2198
  " = stop.\n",
2037
2199
  );
@@ -2044,10 +2206,23 @@ async function runWatchLoop(
2044
2206
  " = re-run all, " +
2045
2207
  chalk.bold("w") +
2046
2208
  " = pause, " +
2209
+ chalk.bold("c") +
2210
+ ` = cache (${cacheMode === "off" ? "off" : "on"}), ` +
2047
2211
  chalk.bold("ctrl+c") +
2048
2212
  " = stop.\n",
2049
2213
  );
2050
2214
  }
2215
+ // Rewrites the footer line in place (the cursor sits one line below it after
2216
+ // every doRun / prior rewrite), so toggling w/c updates the hint without
2217
+ // accumulating new lines in scrollback. Falls back to a plain write when not
2218
+ // on a rewritable TTY.
2219
+ function rewriteFooter() {
2220
+ if (process.stdout.isTTY) {
2221
+ process.stdout.write("\x1b[1A\r\x1b[2K" + watchFooter());
2222
+ } else {
2223
+ process.stdout.write(watchFooter());
2224
+ }
2225
+ }
2051
2226
  function writeWatchHeader(headline, detail) {
2052
2227
  // Preserve scrollback — never `console.clear()`. A blank line plus a
2053
2228
  // dim rule visually delimits each iteration so prior output stays
@@ -2185,21 +2360,23 @@ async function runWatchLoop(
2185
2360
  },
2186
2361
  };
2187
2362
  try {
2188
- await buildRecorderStorage.run(recorder, async () => {
2189
- await runTestModesCore(
2190
- watchRunFlags,
2191
- configPath,
2192
- runSelectors,
2193
- suiteSelectors,
2194
- fuzzerSelectors,
2195
- modes,
2196
- buildFeatureToggles,
2197
- fuzzEnabled,
2198
- fuzzOverrides,
2199
- (outcome) =>
2200
- recordSpecOutcome(outcome.file, outcome.mode, outcome.failed),
2201
- );
2202
- });
2363
+ await withCache(() =>
2364
+ buildRecorderStorage.run(recorder, async () => {
2365
+ await runTestModesCore(
2366
+ watchRunFlags,
2367
+ configPath,
2368
+ runSelectors,
2369
+ suiteSelectors,
2370
+ fuzzerSelectors,
2371
+ modes,
2372
+ buildFeatureToggles,
2373
+ fuzzEnabled,
2374
+ fuzzOverrides,
2375
+ (outcome) =>
2376
+ recordSpecOutcome(outcome.file, outcome.mode, outcome.failed),
2377
+ );
2378
+ }),
2379
+ );
2203
2380
  } catch (error) {
2204
2381
  const message = error instanceof Error ? error.message : String(error);
2205
2382
  process.stderr.write(chalk.red("Error: ") + message + "\n");
@@ -2352,31 +2529,14 @@ async function runWatchLoop(
2352
2529
  }
2353
2530
  if (isRunning) break;
2354
2531
  if (byte === 0x77 || byte === 0x57) {
2355
- // `w` — toggle auto-run / manual mode.
2532
+ // `w` — toggle auto-run / manual mode. Resuming with edits pending
2533
+ // kicks off a run (which prints its own header); otherwise just
2534
+ // refresh the footer in place.
2356
2535
  autoRun = !autoRun;
2357
- if (autoRun) {
2358
- const hadPending = changedWhilePaused.size > 0;
2359
- if (hadPending) {
2360
- process.stdout.write(
2361
- "\n" +
2362
- chalk.dim(
2363
- "Auto-run resumed — re-running all (files changed while paused).\n",
2364
- ),
2365
- );
2366
- scheduleManualRerun("manual-runall");
2367
- } else {
2368
- process.stdout.write(
2369
- "\n" + chalk.dim("Auto-run resumed.\n") + watchFooter(),
2370
- );
2371
- }
2536
+ if (autoRun && changedWhilePaused.size > 0) {
2537
+ scheduleManualRerun("manual-runall");
2372
2538
  } else {
2373
- process.stdout.write(
2374
- "\n" +
2375
- chalk.dim(
2376
- "Auto-run paused — edits won't re-run. Press w to resume, or a / space to run now.\n",
2377
- ) +
2378
- watchFooter(),
2379
- );
2539
+ rewriteFooter();
2380
2540
  }
2381
2541
  break;
2382
2542
  }
@@ -2388,6 +2548,15 @@ async function runWatchLoop(
2388
2548
  scheduleManualRerun("manual-runall");
2389
2549
  break;
2390
2550
  }
2551
+ if (byte === 0x63 || byte === 0x43) {
2552
+ // `c` — toggle the incremental cache for subsequent runs. No-op
2553
+ // while fuzzing (the cache is unsafe there). Updates the footer in
2554
+ // place to reflect the new state.
2555
+ if (fuzzEnabled) break;
2556
+ cacheMode = cacheMode === "off" ? cacheToggleMode : "off";
2557
+ rewriteFooter();
2558
+ break;
2559
+ }
2391
2560
  }
2392
2561
  });
2393
2562
  } catch {
@@ -3575,6 +3744,22 @@ function formatMatrixFileResultLine(
3575
3744
  showPerModeTimes,
3576
3745
  ) {
3577
3746
  const verdict = resolveMatrixVerdict(results);
3747
+ // A file whose every mode was replayed from cache is de-emphasized: dim-grey
3748
+ // badge + filename, and "(cache)" in place of the timing.
3749
+ const cached =
3750
+ results.length > 0 &&
3751
+ results.every(
3752
+ (r) => r.reports.length > 0 && r.reports.every((rep) => rep.cached),
3753
+ );
3754
+ if (cached) {
3755
+ const badge =
3756
+ verdict == "fail"
3757
+ ? chalk.bgRed.white(" FAIL ")
3758
+ : verdict == "ok"
3759
+ ? chalk.bgGreenBright.white(" PASS ")
3760
+ : chalk.bgBlackBright.white(" SKIP ");
3761
+ return `${badge} ${chalk.dim(file)} ${chalk.dim("(cache)")}`;
3762
+ }
3578
3763
  const badge =
3579
3764
  verdict == "fail"
3580
3765
  ? chalk.bgRed.white(" FAIL ")