as-test 1.5.1 → 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.
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,9 @@ 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";
47
+ import { resolveSpecFiles, emitSelectorWarnings } from "./selectors.js";
44
48
  const _args = process.argv.slice(2);
45
49
  const flags = [];
46
50
  const args = [];
@@ -476,6 +480,12 @@ function printCommandHelp(command) {
476
480
  process.stdout.write(
477
481
  " --watch, -w Re-run on source or spec changes\n",
478
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
+ );
479
489
  process.stdout.write(" --help, -h Show this help\n");
480
490
  return;
481
491
  }
@@ -1238,31 +1248,57 @@ class ParallelQueueDisplay {
1238
1248
  this.showStartLines = showStartLines;
1239
1249
  this.active = new Map();
1240
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();
1241
1260
  this.enabled = showStartLines && canRewriteParallelQueue();
1242
1261
  }
1243
1262
  start(file) {
1244
1263
  const token = Symbol(file);
1245
- if (!this.showStartLines) return token;
1246
- const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
1264
+ this.seqByToken.set(token, this.nextSeq++);
1247
1265
  if (!this.enabled) return token;
1266
+ const line = `${chalk.bgBlackBright.white(" .... ")} ${file}`;
1248
1267
  this.clear();
1249
1268
  this.active.set(token, line);
1250
1269
  this.render();
1251
1270
  return token;
1252
1271
  }
1253
1272
  complete(token, output) {
1254
- if (!this.showStartLines || !this.enabled) {
1255
- process.stdout.write(output);
1256
- return;
1257
- }
1258
- this.clear();
1259
- process.stdout.write(output);
1273
+ const seq = this.seqByToken.get(token) ?? this.nextFlushSeq;
1274
+ this.seqByToken.delete(token);
1260
1275
  this.active.delete(token);
1261
- 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();
1262
1290
  }
1263
1291
  flush() {
1264
- if (!this.enabled) return;
1265
- 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();
1266
1302
  }
1267
1303
  clear() {
1268
1304
  if (!this.renderedLines) return;
@@ -1328,6 +1364,29 @@ async function buildFileForMode(args) {
1328
1364
  // recorder so the dependency graph still gets populated under --parallel.
1329
1365
  const recorder = buildRecorderStorage.getStore();
1330
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;
1331
1390
  const buildInvocation = await getBuildInvocationPreview(
1332
1391
  args.configPath,
1333
1392
  args.file,
@@ -1340,12 +1399,24 @@ async function buildFileForMode(args) {
1340
1399
  modeName: args.modeName,
1341
1400
  buildCommand: formatBuildInvocation(buildInvocation),
1342
1401
  featureToggles: args.buildFeatureToggles,
1343
- onReads: recorder
1344
- ? (reads) => {
1345
- for (const r of reads) recorder.record(r.mode, r.spec, r.file);
1346
- }
1347
- : 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,
1348
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
+ }
1349
1420
  } else {
1350
1421
  await build(
1351
1422
  args.configPath,
@@ -1948,17 +2019,70 @@ async function runTestModes(
1948
2019
  );
1949
2020
  return;
1950
2021
  }
1951
- const failed = await runTestModesCore(
1952
- runFlags,
1953
- configPath,
1954
- selectors,
1955
- suiteSelectors,
1956
- fuzzerSelectors,
1957
- modes,
1958
- buildFeatureToggles,
1959
- fuzzEnabled,
1960
- 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"),
2026
+ );
2027
+ const { mode: cacheMode, maxTimeMs } = resolveCacheSettings(
2028
+ baseConfig.cache,
2029
+ {
2030
+ cache: runFlags.cache,
2031
+ noCache: runFlags.noCache,
2032
+ },
1961
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
+ }
1962
2086
  process.exit(failed ? 1 : 0);
1963
2087
  }
1964
2088
  async function runWatchLoop(
@@ -1976,6 +2100,43 @@ async function runWatchLoop(
1976
2100
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
1977
2101
  const absConfigPath = path.resolve(resolvedConfigPath);
1978
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
+ }
1979
2140
  // Respect the user's parallelism flags. Worker-pool builds forward their
1980
2141
  // file-read records back through IPC (see BuildWorkerPool / build-worker
1981
2142
  // and buildFileForMode), so the dependency graph stays correct under
@@ -2025,12 +2186,14 @@ async function runWatchLoop(
2025
2186
  : "";
2026
2187
  return chalk.dim(
2027
2188
  `Auto-run paused${pending}. ` +
2028
- chalk.bold("w") +
2029
- " = resume, " +
2030
- chalk.bold("a") +
2031
- " = re-run all, " +
2032
2189
  chalk.bold("space") +
2033
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"}), ` +
2034
2197
  chalk.bold("ctrl+c") +
2035
2198
  " = stop.\n",
2036
2199
  );
@@ -2043,10 +2206,23 @@ async function runWatchLoop(
2043
2206
  " = re-run all, " +
2044
2207
  chalk.bold("w") +
2045
2208
  " = pause, " +
2209
+ chalk.bold("c") +
2210
+ ` = cache (${cacheMode === "off" ? "off" : "on"}), ` +
2046
2211
  chalk.bold("ctrl+c") +
2047
2212
  " = stop.\n",
2048
2213
  );
2049
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
+ }
2050
2226
  function writeWatchHeader(headline, detail) {
2051
2227
  // Preserve scrollback — never `console.clear()`. A blank line plus a
2052
2228
  // dim rule visually delimits each iteration so prior output stays
@@ -2184,21 +2360,23 @@ async function runWatchLoop(
2184
2360
  },
2185
2361
  };
2186
2362
  try {
2187
- await buildRecorderStorage.run(recorder, async () => {
2188
- await runTestModesCore(
2189
- watchRunFlags,
2190
- configPath,
2191
- runSelectors,
2192
- suiteSelectors,
2193
- fuzzerSelectors,
2194
- modes,
2195
- buildFeatureToggles,
2196
- fuzzEnabled,
2197
- fuzzOverrides,
2198
- (outcome) =>
2199
- recordSpecOutcome(outcome.file, outcome.mode, outcome.failed),
2200
- );
2201
- });
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
+ );
2202
2380
  } catch (error) {
2203
2381
  const message = error instanceof Error ? error.message : String(error);
2204
2382
  process.stderr.write(chalk.red("Error: ") + message + "\n");
@@ -2351,31 +2529,14 @@ async function runWatchLoop(
2351
2529
  }
2352
2530
  if (isRunning) break;
2353
2531
  if (byte === 0x77 || byte === 0x57) {
2354
- // `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.
2355
2535
  autoRun = !autoRun;
2356
- if (autoRun) {
2357
- const hadPending = changedWhilePaused.size > 0;
2358
- if (hadPending) {
2359
- process.stdout.write(
2360
- "\n" +
2361
- chalk.dim(
2362
- "Auto-run resumed — re-running all (files changed while paused).\n",
2363
- ),
2364
- );
2365
- scheduleManualRerun("manual-runall");
2366
- } else {
2367
- process.stdout.write(
2368
- "\n" + chalk.dim("Auto-run resumed.\n") + watchFooter(),
2369
- );
2370
- }
2536
+ if (autoRun && changedWhilePaused.size > 0) {
2537
+ scheduleManualRerun("manual-runall");
2371
2538
  } else {
2372
- process.stdout.write(
2373
- "\n" +
2374
- chalk.dim(
2375
- "Auto-run paused — edits won't re-run. Press w to resume, or a / space to run now.\n",
2376
- ) +
2377
- watchFooter(),
2378
- );
2539
+ rewriteFooter();
2379
2540
  }
2380
2541
  break;
2381
2542
  }
@@ -2387,6 +2548,15 @@ async function runWatchLoop(
2387
2548
  scheduleManualRerun("manual-runall");
2388
2549
  break;
2389
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
+ }
2390
2560
  }
2391
2561
  });
2392
2562
  } catch {
@@ -3574,6 +3744,22 @@ function formatMatrixFileResultLine(
3574
3744
  showPerModeTimes,
3575
3745
  ) {
3576
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
+ }
3577
3763
  const badge =
3578
3764
  verdict == "fail"
3579
3765
  ? chalk.bgRed.white(" FAIL ")
@@ -3855,9 +4041,9 @@ async function resolveSelectedFiles(configPath, selectors, warn = true) {
3855
4041
  const resolvedConfigPath =
3856
4042
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
3857
4043
  const config = loadConfig(resolvedConfigPath, warn);
3858
- const patterns = resolveInputPatterns(config.input, selectors);
3859
- const matches = await glob(patterns);
3860
- const specs = matches.filter((file) => file.endsWith(".spec.ts"));
4044
+ const { files, warnings } = await resolveSpecFiles(config.input, selectors);
4045
+ if (warn) emitSelectorWarnings(warnings);
4046
+ const specs = files.filter((file) => file.endsWith(".spec.ts"));
3861
4047
  return [...new Set(specs)].sort((a, b) => a.localeCompare(b));
3862
4048
  }
3863
4049
  async function resolveSelectedFuzzFiles(
@@ -3996,27 +4182,6 @@ function levenshteinDistance(left, right) {
3996
4182
  }
3997
4183
  return matrix[left.length][right.length];
3998
4184
  }
3999
- function resolveInputPatterns(configured, selectors) {
4000
- const configuredInputs = Array.isArray(configured)
4001
- ? configured
4002
- : [configured];
4003
- if (!selectors.length) return configuredInputs;
4004
- const patterns = new Set();
4005
- for (const selector of expandSelectors(selectors)) {
4006
- if (!selector) continue;
4007
- if (isBareSuiteSelector(selector)) {
4008
- const base = stripSuiteSuffix(selector);
4009
- for (const configuredInput of configuredInputs) {
4010
- patterns.add(
4011
- path.join(path.dirname(configuredInput), `${base}.spec.ts`),
4012
- );
4013
- }
4014
- continue;
4015
- }
4016
- patterns.add(selector);
4017
- }
4018
- return [...patterns];
4019
- }
4020
4185
  function resolveFuzzPatterns(configured, selectors) {
4021
4186
  const configuredInputs = Array.isArray(configured)
4022
4187
  ? configured
@@ -116,14 +116,28 @@ class DefaultReporter {
116
116
  renderFileResult(event) {
117
117
  const verdict = event.verdict ?? "none";
118
118
  const time = event.time ? ` ${chalk.dim(event.time)}` : "";
119
- const file = formatSpecDisplayPath(event.file);
119
+ const name = formatSpecDisplayPath(event.file);
120
+ // A replayed (cached) result is de-emphasized: badge, filename, and tag are
121
+ // all dimmed so freshly-run specs stand out from unchanged ones.
122
+ if (event.cached) {
123
+ // Replayed-from-cache: keep the coloured verdict badge (white text) so it
124
+ // stays scannable, but dim the filename and show "(cache)" in place of the
125
+ // timing so freshly-run specs still stand out.
126
+ const badge =
127
+ verdict == "fail"
128
+ ? chalk.bgRed.white(" FAIL ")
129
+ : verdict == "ok"
130
+ ? chalk.bgGreenBright.white(" PASS ")
131
+ : chalk.bgBlackBright.white(" SKIP ");
132
+ return `${badge} ${chalk.dim(name)} ${chalk.dim("(cache)")}`;
133
+ }
120
134
  if (verdict == "fail")
121
- return `${chalk.bgRed.white(" FAIL ")} ${file}${time}`;
135
+ return `${chalk.bgRed.white(" FAIL ")} ${name}${time}`;
122
136
  if (this.fileHasWarning)
123
- return `${chalk.bgYellow.black(" WARN ")} ${file}${time}`;
137
+ return `${chalk.bgYellow.black(" WARN ")} ${name}${time}`;
124
138
  if (verdict == "ok")
125
- return `${chalk.bgGreenBright.black(" PASS ")} ${file}${time}`;
126
- return `${chalk.bgBlackBright.white(" SKIP ")} ${file}${time}`;
139
+ return `${chalk.bgGreenBright.black(" PASS ")} ${name}${time}`;
140
+ return `${chalk.bgBlackBright.white(" SKIP ")} ${name}${time}`;
127
141
  }
128
142
  onRunStart(event) {
129
143
  this.verboseMode = Boolean(event.verbose);
@@ -805,8 +819,18 @@ function renderTotals(stats, event) {
805
819
  skipped: stats.skippedTests,
806
820
  total: stats.failedTests + stats.passedTests + stats.skippedTests,
807
821
  };
822
+ const cacheSummary = computeCacheSummary(event.reports);
808
823
  const layout = createSummaryLayout([
809
824
  event.fuzzSummary,
825
+ // "cached" and "failed" are the same length, so the cache summary aligns in
826
+ // the shared first column.
827
+ cacheSummary
828
+ ? {
829
+ failed: cacheSummary.cached,
830
+ skipped: cacheSummary.skipped,
831
+ total: cacheSummary.total,
832
+ }
833
+ : undefined,
810
834
  filesSummary,
811
835
  suitesSummary,
812
836
  testsSummary,
@@ -821,6 +845,9 @@ function renderTotals(stats, event) {
821
845
  if (event.modeSummary) {
822
846
  renderModeSummary(event.modeSummary, layout);
823
847
  }
848
+ if (cacheSummary) {
849
+ renderCacheSummary(cacheSummary, layout);
850
+ }
824
851
  process.stdout.write(
825
852
  chalk.bold("Time:".padEnd(9)) +
826
853
  formatTime(stats.time) +
@@ -828,6 +855,30 @@ function renderTotals(stats, event) {
828
855
  "\n",
829
856
  );
830
857
  }
858
+ // When the cache is active, every report carries a `cached` flag (true =
859
+ // replayed from cache, false = freshly run). Returns the hit/miss split, or
860
+ // undefined when the cache is off (no report sets the flag) so no line shows.
861
+ function computeCacheSummary(reports) {
862
+ const flagged = reports.filter((r) => typeof r?.cached === "boolean");
863
+ if (!flagged.length) return undefined;
864
+ const cached = flagged.filter((r) => r.cached).length;
865
+ return { cached, skipped: flagged.length - cached, total: flagged.length };
866
+ }
867
+ // Renders the "Cache:" line in the shared three-column layout (cached / skipped
868
+ // / total) so it lines up with Files/Suites/Tests/Modes.
869
+ function renderCacheSummary(summary, layout) {
870
+ const cachedText = `${summary.cached} cached`;
871
+ const skippedText = `${summary.skipped} skipped`;
872
+ const totalText = `${summary.total} total`;
873
+ process.stdout.write(chalk.bold("Cache:".padEnd(9)));
874
+ process.stdout.write(
875
+ chalk.bold.greenBright(cachedText.padStart(layout.failedWidth)),
876
+ );
877
+ process.stdout.write(", ");
878
+ process.stdout.write(chalk.gray(skippedText.padStart(layout.skippedWidth)));
879
+ process.stdout.write(", ");
880
+ process.stdout.write(totalText.padStart(layout.totalWidth) + "\n");
881
+ }
831
882
  function renderModeSummary(summary, layout) {
832
883
  renderSummaryLine("Modes:", summary, layout);
833
884
  }