as-test 1.2.0 → 1.4.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
@@ -3,10 +3,12 @@ import chalk from "chalk";
3
3
  import {
4
4
  build,
5
5
  BuildFailureError,
6
+ flushModeWarnings,
6
7
  formatInvocation as formatBuildInvocation,
7
8
  getBuildInvocationPreview,
9
+ warnOnUnknownModeReferences,
8
10
  } from "./commands/build.js";
9
- import { createRunReporter, run } from "./commands/run.js";
11
+ import { createRunReporter, resetCollectedLogs, run } from "./commands/run.js";
10
12
  import { executeBuildCommand } from "./commands/build.js";
11
13
  import { executeRunCommand } from "./commands/run.js";
12
14
  import { executeTestCommand } from "./commands/test.js";
@@ -24,16 +26,21 @@ import {
24
26
  loadConfig,
25
27
  resolveArtifactPath,
26
28
  resolveModeNames,
29
+ resolveSnapshotPath,
27
30
  resolveSpecRelativePath,
28
31
  } from "./util.js";
32
+ import { normalizeFeatureName } from "./types.js";
29
33
  import * as path from "path";
30
34
  import { spawnSync } from "child_process";
31
35
  import { glob } from "glob";
32
36
  import { createInterface } from "readline";
33
37
  import { existsSync, watch as fsWatch } from "fs";
38
+ import { minimatch } from "minimatch";
34
39
  import { availableParallelism, cpus } from "os";
35
40
  import { BuildWorkerPool } from "./build-worker-pool.js";
36
41
  import { PersistentWebSessionHost } from "./commands/web-session.js";
42
+ import { buildRecorderStorage } from "./commands/build-core.js";
43
+ import { DependencyGraph } from "./dependency-graph.js";
37
44
  const _args = process.argv.slice(2);
38
45
  const flags = [];
39
46
  const args = [];
@@ -282,20 +289,17 @@ function printCommandHelp(command) {
282
289
  );
283
290
  process.stdout.write("Compile selected specs into wasm artifacts.\n\n");
284
291
  process.stdout.write(chalk.bold("Flags:\n"));
285
- process.stdout.write(
286
- " --config <path> Use a specific config file\n",
287
- );
288
292
  process.stdout.write(
289
293
  " --mode <name[,name...]> Run one or multiple named config modes\n",
290
294
  );
291
295
  process.stdout.write(
292
- " --enable <feature> Enable build feature (coverage|try-as)\n",
296
+ " --enable <list> Enable features, comma-separated (e.g. coverage,try-as,simd)\n",
293
297
  );
294
298
  process.stdout.write(
295
- " --disable <feature> Disable build feature (coverage|try-as)\n",
299
+ " --disable <list> Disable features, comma-separated\n",
296
300
  );
297
301
  process.stdout.write(
298
- " --parallel Run files through an ordered worker pool using an automatic worker count\n",
302
+ " --config <path> Use a specific config file\n",
299
303
  );
300
304
  process.stdout.write(
301
305
  " --jobs <n> Run files through an ordered worker pool\n",
@@ -356,10 +360,10 @@ function printCommandHelp(command) {
356
360
  );
357
361
  process.stdout.write(" --suites <name[,name...]> Alias for --suite\n");
358
362
  process.stdout.write(
359
- " --enable <feature> Enable feature (coverage|try-as)\n",
363
+ " --enable <list> Enable features, comma-separated (e.g. coverage,try-as,simd)\n",
360
364
  );
361
365
  process.stdout.write(
362
- " --disable <feature> Disable feature (coverage|try-as)\n",
366
+ " --disable <list> Disable features, comma-separated\n",
363
367
  );
364
368
  process.stdout.write(
365
369
  " --reporter <name|path> Use built-in reporter (default|tap) or custom module path\n",
@@ -391,16 +395,19 @@ function printCommandHelp(command) {
391
395
  );
392
396
  process.stdout.write(chalk.bold("Flags:\n"));
393
397
  process.stdout.write(
394
- " --config <path> Use a specific config file\n",
398
+ " --mode <name[,name...]> Run one or multiple named config modes\n",
395
399
  );
396
400
  process.stdout.write(
397
- " --mode <name[,name...]> Run one or multiple named config modes\n",
401
+ " --enable <list> Enable features, comma-separated (e.g. coverage,try-as,simd)\n",
398
402
  );
399
403
  process.stdout.write(
400
- " --browser <name|path> Use chrome, chromium, firefox, webkit, or an executable path for web modes\n",
404
+ " --disable <list> Disable features, comma-separated\n",
401
405
  );
402
406
  process.stdout.write(
403
- " --parallel Run files through an ordered worker pool using an automatic worker count\n",
407
+ " --config <path> Use a specific config file\n",
408
+ );
409
+ process.stdout.write(
410
+ " --browser <name|path> Use chrome, chromium, firefox, webkit, or an executable path for web modes\n",
404
411
  );
405
412
  process.stdout.write(
406
413
  " --jobs <n> Run files through an ordered worker pool\n",
@@ -427,12 +434,6 @@ function printCommandHelp(command) {
427
434
  " --suite <name[,name...]> Filter results to matching suite names or suite slug paths\n",
428
435
  );
429
436
  process.stdout.write(" --suites <name[,name...]> Alias for --suite\n");
430
- process.stdout.write(
431
- " --enable <feature> Enable feature (coverage|try-as)\n",
432
- );
433
- process.stdout.write(
434
- " --disable <feature> Disable feature (coverage|try-as)\n",
435
- );
436
437
  process.stdout.write(
437
438
  " --fuzz Run fuzz targets after the normal test pass\n",
438
439
  );
@@ -473,7 +474,7 @@ function printCommandHelp(command) {
473
474
  " --list-modes Preview configured and selected mode names\n",
474
475
  );
475
476
  process.stdout.write(
476
- " --watch Re-run on source or spec changes\n",
477
+ " --watch, -w Re-run on source or spec changes\n",
477
478
  );
478
479
  process.stdout.write(" --help, -h Show this help\n");
479
480
  return;
@@ -534,6 +535,12 @@ function printCommandHelp(command) {
534
535
  process.stdout.write(
535
536
  " --example <minimal|full|none> Set example template\n",
536
537
  );
538
+ process.stdout.write(
539
+ " --enable <list> Enable features, comma-separated (coverage,try-as)\n",
540
+ );
541
+ process.stdout.write(
542
+ " --disable <list> Disable features, comma-separated\n",
543
+ );
537
544
  process.stdout.write(
538
545
  " --install Install dependencies after scaffolding\n",
539
546
  );
@@ -685,7 +692,7 @@ function resolveCommandArgs(rawArgs, command) {
685
692
  if (arg == "--parallel") {
686
693
  continue;
687
694
  }
688
- if (arg == "--watch") {
695
+ if (arg == "--watch" || arg == "-w") {
689
696
  continue;
690
697
  }
691
698
  if (
@@ -709,8 +716,9 @@ function resolveCommandArgs(rawArgs, command) {
709
716
  return values;
710
717
  }
711
718
  function resolveFeatureToggles(rawArgs, command) {
712
- if (command !== "build" && command !== "run" && command !== "test") return {};
713
- const out = {};
719
+ if (command !== "build" && command !== "run" && command !== "test")
720
+ return { featureOverrides: {} };
721
+ const out = { featureOverrides: {} };
714
722
  let seenCommand = false;
715
723
  for (let i = 0; i < rawArgs.length; i++) {
716
724
  const arg = rawArgs[i];
@@ -722,7 +730,9 @@ function resolveFeatureToggles(rawArgs, command) {
722
730
  const enabled = arg == "--enable";
723
731
  const next = rawArgs[i + 1];
724
732
  if (next && !next.startsWith("-")) {
725
- applyFeatureToggle(out, next, enabled);
733
+ for (const name of splitFeatureList(next)) {
734
+ applyFeatureToggle(out, name, enabled);
735
+ }
726
736
  i++;
727
737
  }
728
738
  continue;
@@ -730,9 +740,9 @@ function resolveFeatureToggles(rawArgs, command) {
730
740
  if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) {
731
741
  const enabled = arg.startsWith("--enable=");
732
742
  const eq = arg.indexOf("=");
733
- const value = arg.slice(eq + 1).trim();
734
- if (value.length) {
735
- applyFeatureToggle(out, value, enabled);
743
+ const value = arg.slice(eq + 1);
744
+ for (const name of splitFeatureList(value)) {
745
+ applyFeatureToggle(out, name, enabled);
736
746
  }
737
747
  }
738
748
  }
@@ -1280,19 +1290,24 @@ function parseIntegerFlag(flag, value) {
1280
1290
  }
1281
1291
  return Math.floor(parsed);
1282
1292
  }
1293
+ function splitFeatureList(value) {
1294
+ return value
1295
+ .split(",")
1296
+ .map((part) => part.trim())
1297
+ .filter((part) => part.length > 0);
1298
+ }
1283
1299
  function applyFeatureToggle(out, rawFeature, enabled) {
1284
- const key = rawFeature.trim().toLowerCase();
1300
+ const key = normalizeFeatureName(rawFeature);
1301
+ if (!key.length) {
1302
+ throw new Error(
1303
+ `empty feature name passed to ${enabled ? "--enable" : "--disable"}`,
1304
+ );
1305
+ }
1285
1306
  if (key == "coverage") {
1286
1307
  out.coverage = enabled;
1287
1308
  return;
1288
1309
  }
1289
- if (key == "try-as" || key == "try_as" || key == "tryas") {
1290
- out.tryAs = enabled;
1291
- return;
1292
- }
1293
- throw new Error(
1294
- `unknown feature "${rawFeature}". Supported features: coverage, try-as`,
1295
- );
1310
+ out.featureOverrides[key] = enabled;
1296
1311
  }
1297
1312
  function resolveCommandTokens(rawArgs, command) {
1298
1313
  const values = [];
@@ -1308,6 +1323,10 @@ function resolveCommandTokens(rawArgs, command) {
1308
1323
  return values;
1309
1324
  }
1310
1325
  async function buildFileForMode(args) {
1326
+ // If the caller has an active buildRecorderStorage context (e.g. the watch
1327
+ // loop), forward reads from the pool's worker process back into the same
1328
+ // recorder so the dependency graph still gets populated under --parallel.
1329
+ const recorder = buildRecorderStorage.getStore();
1311
1330
  if (args.buildPool) {
1312
1331
  const buildInvocation = await getBuildInvocationPreview(
1313
1332
  args.configPath,
@@ -1321,6 +1340,11 @@ async function buildFileForMode(args) {
1321
1340
  modeName: args.modeName,
1322
1341
  buildCommand: formatBuildInvocation(buildInvocation),
1323
1342
  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,
1324
1348
  });
1325
1349
  } else {
1326
1350
  await build(
@@ -1344,6 +1368,7 @@ async function runTestSequential(
1344
1368
  reporterOverride,
1345
1369
  webSession = null,
1346
1370
  emitRunComplete = true,
1371
+ onSpecOutcome,
1347
1372
  ) {
1348
1373
  const files = await resolveSelectedFiles(configPath, selectors);
1349
1374
  if (!files.length) {
@@ -1362,6 +1387,7 @@ async function runTestSequential(
1362
1387
  runtimeName: reporterSession.runtimeName,
1363
1388
  clean: runFlags.clean,
1364
1389
  verbose: runFlags.verbose,
1390
+ showLogs: runFlags.showLogs,
1365
1391
  snapshotEnabled,
1366
1392
  createSnapshots: runFlags.createSnapshots,
1367
1393
  });
@@ -1400,6 +1426,7 @@ async function runTestSequential(
1400
1426
  }
1401
1427
  results.push(result);
1402
1428
  if (result?.failed) failed = true;
1429
+ onSpecOutcome?.({ file, mode: modeName, failed: !!result?.failed });
1403
1430
  }
1404
1431
  const summary = aggregateRunResults(results);
1405
1432
  summary.stats = applyConfiguredFileTotalToStats(
@@ -1418,6 +1445,8 @@ async function runTestSequential(
1418
1445
  coverageSummary: summary.coverageSummary,
1419
1446
  stats: summary.stats,
1420
1447
  reports: summary.reports,
1448
+ showLogs: runFlags.showLogs,
1449
+ logSummary: summary.logSummary,
1421
1450
  modeSummary: buildSingleModeSummary(
1422
1451
  summary.stats,
1423
1452
  summary.snapshotSummary,
@@ -1425,6 +1454,7 @@ async function runTestSequential(
1425
1454
  ),
1426
1455
  });
1427
1456
  reporter.flush?.();
1457
+ flushModeWarnings(process.argv.includes("--show-warnings"));
1428
1458
  }
1429
1459
  return {
1430
1460
  failed,
@@ -1434,6 +1464,7 @@ async function runTestSequential(
1434
1464
  coverageSummary: summary.coverageSummary,
1435
1465
  stats: summary.stats,
1436
1466
  reports: summary.reports,
1467
+ logSummary: summary.logSummary,
1437
1468
  },
1438
1469
  };
1439
1470
  }
@@ -1626,6 +1657,7 @@ async function runRuntimeMatrix(
1626
1657
  runtimeName: reporterSession.runtimeName,
1627
1658
  clean: runFlags.clean,
1628
1659
  verbose: runFlags.verbose,
1660
+ showLogs: runFlags.showLogs,
1629
1661
  snapshotEnabled,
1630
1662
  createSnapshots: runFlags.createSnapshots,
1631
1663
  });
@@ -1729,6 +1761,8 @@ async function runRuntimeMatrix(
1729
1761
  coverageSummary: summary.coverageSummary,
1730
1762
  stats: summary.stats,
1731
1763
  reports: summary.reports,
1764
+ showLogs: runFlags.showLogs,
1765
+ logSummary: summary.logSummary,
1732
1766
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
1733
1767
  });
1734
1768
  return allResults.some((result) => result.failed);
@@ -1743,6 +1777,7 @@ async function runTestModesCore(
1743
1777
  buildFeatureToggles,
1744
1778
  fuzzEnabled,
1745
1779
  fuzzOverrides,
1780
+ onSpecOutcome,
1746
1781
  ) {
1747
1782
  await ensureWebBrowsersReady(configPath, modes, runFlags.browser);
1748
1783
  const modeSummaryTotal = Math.max(modes.length, 1);
@@ -1775,6 +1810,7 @@ async function runTestModesCore(
1775
1810
  fileSummaryTotal,
1776
1811
  fuzzEnabled,
1777
1812
  fuzzOverrides,
1813
+ onSpecOutcome,
1778
1814
  );
1779
1815
  return failed;
1780
1816
  }
@@ -1792,6 +1828,7 @@ async function runTestModesCore(
1792
1828
  fuzzEnabled,
1793
1829
  fuzzOverrides,
1794
1830
  modeName,
1831
+ onSpecOutcome,
1795
1832
  );
1796
1833
  if (modeFailed) failed = true;
1797
1834
  }
@@ -1810,6 +1847,7 @@ async function runTestModesCore(
1810
1847
  fileSummaryTotal,
1811
1848
  fuzzEnabled,
1812
1849
  fuzzOverrides,
1850
+ onSpecOutcome,
1813
1851
  );
1814
1852
  return failed;
1815
1853
  }
@@ -1838,6 +1876,7 @@ async function runTestModesCore(
1838
1876
  reporterSession.reporter,
1839
1877
  sharedWebSession,
1840
1878
  !fuzzEnabled,
1879
+ onSpecOutcome,
1841
1880
  );
1842
1881
  if (modeResult.failed) failed = true;
1843
1882
  if (fuzzEnabled) {
@@ -1866,6 +1905,8 @@ async function runTestModesCore(
1866
1905
  coverageSummary: modeResult.summary.coverageSummary,
1867
1906
  stats: modeResult.summary.stats,
1868
1907
  reports: modeResult.summary.reports,
1908
+ showLogs: effectiveRunFlags.showLogs,
1909
+ logSummary: modeResult.summary.logSummary,
1869
1910
  fuzzSummary: summarizeFuzzExecutions(fuzzResults),
1870
1911
  modeSummary: buildSingleModeSummary(
1871
1912
  modeResult.summary.stats,
@@ -1874,6 +1915,7 @@ async function runTestModesCore(
1874
1915
  ),
1875
1916
  });
1876
1917
  reporterSession.reporter.flush?.();
1918
+ flushModeWarnings(process.argv.includes("--show-warnings"));
1877
1919
  }
1878
1920
  }
1879
1921
  } finally {
@@ -1932,99 +1974,461 @@ async function runWatchLoop(
1932
1974
  ) {
1933
1975
  const resolvedConfigPath =
1934
1976
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
1935
- const config = loadConfig(resolvedConfigPath, false);
1936
- const watchDirs = resolveWatchDirectories(config);
1977
+ const absConfigPath = path.resolve(resolvedConfigPath);
1978
+ let config = loadConfig(resolvedConfigPath, false);
1979
+ // Respect the user's parallelism flags. Worker-pool builds forward their
1980
+ // file-read records back through IPC (see BuildWorkerPool / build-worker
1981
+ // and buildFileForMode), so the dependency graph stays correct under
1982
+ // --parallel as well.
1983
+ const watchRunFlags = { ...runFlags };
1984
+ const graph = new DependencyGraph();
1985
+ let graphPopulated = false;
1986
+ // Sticky failure tracker: each entry is "this (spec, mode) was last seen
1987
+ // failing." Only updated for (spec, mode) pairs that actually re-ran this
1988
+ // iteration, so a spec the watch loop skipped stays visible at the bottom
1989
+ // until it passes again. `space` retries this exact set.
1990
+ const failingSpecs = new Map();
1991
+ function recordSpecOutcome(file, mode, failed) {
1992
+ const abs = path.resolve(file);
1993
+ const modes = failingSpecs.get(abs) ?? new Set();
1994
+ if (failed) {
1995
+ modes.add(mode);
1996
+ failingSpecs.set(abs, modes);
1997
+ return;
1998
+ }
1999
+ if (!modes.size) return;
2000
+ modes.delete(mode);
2001
+ if (modes.size === 0) failingSpecs.delete(abs);
2002
+ }
1937
2003
  let isRunning = false;
1938
- let pendingFile = null;
2004
+ let pendingTrigger = null;
1939
2005
  let debounceTimer = null;
1940
- async function doRun(changedFile) {
1941
- if (changedFile) {
1942
- console.clear();
1943
- process.stdout.write(
2006
+ // Toggled with `w`. When false, file changes are remembered in
2007
+ // `changedWhilePaused` but not auto-run — the user invokes runs manually
2008
+ // (`a` / space). Resuming re-runs everything if anything changed meanwhile.
2009
+ let autoRun = true;
2010
+ const changedWhilePaused = new Set();
2011
+ // Keyed by absolute directory path so attachWatcherFor is idempotent;
2012
+ // makes it safe to re-scan & top up watchers after every iteration without
2013
+ // double-attaching to the same dir.
2014
+ const attachedDirWatchers = new Map();
2015
+ let configFileWatcher = null;
2016
+ function describeAffected(specs) {
2017
+ const list = Array.from(specs).map((s) => path.relative(process.cwd(), s));
2018
+ if (list.length <= 3) return list.join(", ");
2019
+ return `${list.slice(0, 3).join(", ")} (+${list.length - 3} more)`;
2020
+ }
2021
+ function watchFooter() {
2022
+ if (!autoRun) {
2023
+ const pending = changedWhilePaused.size
2024
+ ? ` (${changedWhilePaused.size} change(s) pending)`
2025
+ : "";
2026
+ return chalk.dim(
2027
+ `Auto-run paused${pending}. ` +
2028
+ chalk.bold("w") +
2029
+ " = resume, " +
2030
+ chalk.bold("a") +
2031
+ " = re-run all, " +
2032
+ chalk.bold("space") +
2033
+ " = retry failing, " +
2034
+ chalk.bold("ctrl+c") +
2035
+ " = stop.\n",
2036
+ );
2037
+ }
2038
+ return chalk.dim(
2039
+ "Watching for changes. " +
2040
+ chalk.bold("space") +
2041
+ " = retry failing, " +
2042
+ chalk.bold("a") +
2043
+ " = re-run all, " +
2044
+ chalk.bold("w") +
2045
+ " = pause, " +
2046
+ chalk.bold("ctrl+c") +
2047
+ " = stop.\n",
2048
+ );
2049
+ }
2050
+ function writeWatchHeader(headline, detail) {
2051
+ // Preserve scrollback — never `console.clear()`. A blank line plus a
2052
+ // dim rule visually delimits each iteration so prior output stays
2053
+ // readable above.
2054
+ process.stdout.write(
2055
+ "\n" +
2056
+ chalk.dim("─".repeat(Math.max(24, process.stdout.columns ?? 60))) +
2057
+ "\n" +
1944
2058
  chalk.dim(`[${new Date().toLocaleTimeString()}] `) +
1945
- chalk.yellow("Change detected: ") +
1946
- chalk.bold(changedFile) +
1947
- "\n\n",
2059
+ chalk.yellow(headline) +
2060
+ (detail ? chalk.bold(detail) : "") +
2061
+ "\n",
2062
+ );
2063
+ }
2064
+ // Render the sticky "currently failing" pin. Renders nothing when empty so
2065
+ // happy paths stay visually clean. Mode tags shown only when the user has
2066
+ // configured >1 mode; in the single-mode case the bare path is enough.
2067
+ function renderFailingSpecs() {
2068
+ if (failingSpecs.size === 0) return "";
2069
+ const multiMode = modes.length > 1;
2070
+ const entries = Array.from(failingSpecs.entries()).map(([abs, modeSet]) => {
2071
+ const rel = path.relative(process.cwd(), abs);
2072
+ const tags = multiMode
2073
+ ? Array.from(modeSet, (m) => m ?? "default").sort()
2074
+ : [];
2075
+ return { rel, tags };
2076
+ });
2077
+ entries.sort((a, b) => a.rel.localeCompare(b.rel));
2078
+ const MAX_LINES = 8;
2079
+ const shown = entries.slice(0, MAX_LINES);
2080
+ const overflow = entries.length - shown.length;
2081
+ const lines = [];
2082
+ lines.push(chalk.red.bold(`Currently failing (${failingSpecs.size}):`));
2083
+ for (const { rel, tags } of shown) {
2084
+ const tagSuffix = tags.length ? chalk.dim(` [${tags.join(", ")}]`) : "";
2085
+ lines.push(` ${chalk.gray(rel)}${tagSuffix}`);
2086
+ }
2087
+ if (overflow > 0) {
2088
+ lines.push(chalk.dim(` (+${overflow} more)`));
2089
+ }
2090
+ return lines.join("\n") + "\n\n";
2091
+ }
2092
+ async function doRun(trigger) {
2093
+ let runSelectors = selectors;
2094
+ let scopedRun = false;
2095
+ if (trigger.kind === "manual-rerun") {
2096
+ if (failingSpecs.size === 0) {
2097
+ // Nothing to retry; stay silent so we don't pollute scrollback.
2098
+ return;
2099
+ }
2100
+ const failingPaths = new Set(failingSpecs.keys());
2101
+ writeWatchHeader("Retrying failing specs");
2102
+ process.stdout.write(
2103
+ chalk.dim(`Retrying ${failingPaths.size} failing spec(s): `) +
2104
+ chalk.bold(describeAffected(failingPaths)) +
2105
+ "\n",
2106
+ );
2107
+ runSelectors = Array.from(failingPaths).map((spec) =>
2108
+ path.relative(process.cwd(), spec),
1948
2109
  );
2110
+ scopedRun = true;
2111
+ } else if (trigger.kind === "manual-runall") {
2112
+ writeWatchHeader("Re-running all specs");
2113
+ } else if (trigger.kind === "change") {
2114
+ const absChanged = path.resolve(trigger.file);
2115
+ if (absChanged === absConfigPath) {
2116
+ writeWatchHeader("Change detected: ", trigger.file);
2117
+ process.stdout.write(
2118
+ chalk.dim("Config changed; reloading and rebuilding everything.\n"),
2119
+ );
2120
+ try {
2121
+ config = loadConfig(resolvedConfigPath, false);
2122
+ } catch (e) {
2123
+ const msg = e instanceof Error ? e.message : String(e);
2124
+ process.stderr.write(
2125
+ chalk.red("Failed to reload config: ") + msg + "\n",
2126
+ );
2127
+ }
2128
+ graph.clear();
2129
+ graphPopulated = false;
2130
+ failingSpecs.clear();
2131
+ // The previous config's input/snapshotDir may no longer be relevant;
2132
+ // drop every dir watcher and re-derive from the fresh config so we
2133
+ // don't leak watchers for dirs we no longer care about.
2134
+ closeDirWatchers();
2135
+ refreshWatchedDirs();
2136
+ } else if (graphPopulated) {
2137
+ const affected = new Set(graph.specsAffectedBy(absChanged));
2138
+ // A new .spec.ts file the graph hasn't seen yet should still be
2139
+ // built and run. Cheapest heuristic without re-globbing: treat any
2140
+ // .spec.ts under cwd that we don't already know as a candidate.
2141
+ if (
2142
+ affected.size === 0 &&
2143
+ /\.spec\.ts$/i.test(absChanged) &&
2144
+ existsSync(absChanged) &&
2145
+ !graph.knownSpecs().has(absChanged)
2146
+ ) {
2147
+ affected.add(absChanged);
2148
+ }
2149
+ if (affected.size === 0) {
2150
+ // Nothing depends on this file; skip silently so scrollback isn't
2151
+ // littered with no-op events for unrelated edits.
2152
+ return;
2153
+ }
2154
+ writeWatchHeader("Change detected: ", trigger.file);
2155
+ process.stdout.write(
2156
+ chalk.dim(`Rebuilding ${affected.size} affected spec(s): `) +
2157
+ chalk.bold(describeAffected(affected)) +
2158
+ "\n",
2159
+ );
2160
+ runSelectors = Array.from(affected).map((spec) =>
2161
+ path.relative(process.cwd(), spec),
2162
+ );
2163
+ scopedRun = true;
2164
+ } else {
2165
+ // Change arrived before the graph was populated (e.g. queued during
2166
+ // the initial run). Run everything; surface the header so the user
2167
+ // knows the iteration was caused by the edit.
2168
+ writeWatchHeader("Change detected: ", trigger.file);
2169
+ }
1949
2170
  }
2171
+ process.stdout.write("\n");
2172
+ // A full re-run starts the aggregated-log collector fresh and clears any
2173
+ // changes tracked while paused (they're now covered). Scoped reruns keep
2174
+ // prior specs' logs (each re-run spec's entry is replaced in place), so
2175
+ // latest.log stays complete instead of collapsing to just the rerun.
2176
+ if (!scopedRun) {
2177
+ resetCollectedLogs();
2178
+ changedWhilePaused.clear();
2179
+ }
2180
+ const collected = [];
2181
+ const recorder = {
2182
+ record: (mode, specFile, absPath) => {
2183
+ collected.push({ mode, spec: specFile, file: absPath });
2184
+ },
2185
+ };
1950
2186
  try {
1951
- await runTestModesCore(
1952
- runFlags,
1953
- configPath,
1954
- selectors,
1955
- suiteSelectors,
1956
- fuzzerSelectors,
1957
- modes,
1958
- buildFeatureToggles,
1959
- fuzzEnabled,
1960
- fuzzOverrides,
1961
- );
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
+ });
1962
2202
  } catch (error) {
1963
2203
  const message = error instanceof Error ? error.message : String(error);
1964
2204
  process.stderr.write(chalk.red("Error: ") + message + "\n");
1965
2205
  }
1966
- process.stdout.write(
1967
- "\n" + chalk.dim("Watching for changes. Press Ctrl+C to stop.\n"),
1968
- );
2206
+ // Bucket reads by (mode, spec) then merge into the graph. Skipped specs
2207
+ // (those not touched this run) keep their prior entries — important for
2208
+ // scoped reruns and for builds that fail before any reads happen.
2209
+ const bySpec = new Map();
2210
+ for (const entry of collected) {
2211
+ const key = `${entry.mode ?? ""}${entry.spec}`;
2212
+ let bucket = bySpec.get(key);
2213
+ if (!bucket) {
2214
+ bucket = { mode: entry.mode, spec: entry.spec, files: new Set() };
2215
+ bySpec.set(key, bucket);
2216
+ }
2217
+ bucket.files.add(entry.file);
2218
+ }
2219
+ for (const bucket of bySpec.values()) {
2220
+ if (config.snapshotDir) {
2221
+ bucket.files.add(
2222
+ resolveSnapshotPath(bucket.spec, config.snapshotDir, config.input),
2223
+ );
2224
+ }
2225
+ graph.recordBuild(bucket.spec, bucket.mode, bucket.files);
2226
+ }
2227
+ // Only mark populated after a full (unscoped) run that recorded at least
2228
+ // one spec — that way we never trust a scoped run to validate the full
2229
+ // dependency picture.
2230
+ if (!scopedRun && bySpec.size > 0) {
2231
+ graphPopulated = true;
2232
+ }
2233
+ // Top up watchers after every iteration so newly-created dirs (the
2234
+ // snapshot dir on first run, dirs introduced by config edits, etc.) get
2235
+ // monitored without restarting the loop.
2236
+ refreshWatchedDirs();
2237
+ attachConfigWatcher();
2238
+ process.stdout.write("\n" + renderFailingSpecs() + watchFooter());
2239
+ }
2240
+ function attachWatcherFor(absDir, recursive) {
2241
+ if (attachedDirWatchers.has(absDir)) return;
2242
+ if (!existsSync(absDir)) return;
2243
+ try {
2244
+ const w = fsWatch(absDir, { recursive }, (_evt, filename) => {
2245
+ if (!filename) return;
2246
+ const full = path.join(absDir, filename);
2247
+ if (shouldIgnoreWatchPath(full, config)) return;
2248
+ scheduleRerun(path.relative(process.cwd(), full));
2249
+ });
2250
+ attachedDirWatchers.set(absDir, w);
2251
+ } catch {
2252
+ // some dirs (or filesystems) can't be watched recursively; skip.
2253
+ }
2254
+ }
2255
+ // Called after every iteration so dirs created lazily (snapshotDir on
2256
+ // first run, dirs introduced by config reload, etc.) get watchers.
2257
+ function refreshWatchedDirs() {
2258
+ const cwd = process.cwd();
2259
+ for (const dir of resolveWatchDirectories(config, modes)) {
2260
+ attachWatcherFor(dir, true);
2261
+ }
2262
+ for (const file of graph.allRecordedFiles()) {
2263
+ const rel = path.relative(cwd, file);
2264
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
2265
+ if (rel.startsWith("node_modules") || rel.startsWith(".git")) continue;
2266
+ attachWatcherFor(path.dirname(file), false);
2267
+ }
2268
+ }
2269
+ function attachConfigWatcher() {
2270
+ if (configFileWatcher) return;
2271
+ if (!existsSync(resolvedConfigPath)) return;
2272
+ try {
2273
+ configFileWatcher = fsWatch(resolvedConfigPath, () => {
2274
+ scheduleRerun(path.relative(process.cwd(), resolvedConfigPath));
2275
+ });
2276
+ } catch {
2277
+ // ignore — fs.watch on a single file isn't supported everywhere.
2278
+ }
2279
+ }
2280
+ function closeDirWatchers() {
2281
+ for (const w of attachedDirWatchers.values()) w.close();
2282
+ attachedDirWatchers.clear();
2283
+ }
2284
+ function closeAllWatchers() {
2285
+ closeDirWatchers();
2286
+ if (configFileWatcher) {
2287
+ configFileWatcher.close();
2288
+ configFileWatcher = null;
2289
+ }
1969
2290
  }
1970
2291
  async function triggerRerun() {
1971
- const changedFile = pendingFile ?? undefined;
1972
- pendingFile = null;
2292
+ const trigger = pendingTrigger ?? { kind: "initial" };
2293
+ pendingTrigger = null;
1973
2294
  isRunning = true;
1974
2295
  try {
1975
- await doRun(changedFile);
2296
+ await doRun(trigger);
1976
2297
  } finally {
1977
2298
  isRunning = false;
1978
- if (pendingFile) {
2299
+ if (pendingTrigger) {
1979
2300
  void triggerRerun();
1980
2301
  }
1981
2302
  }
1982
2303
  }
1983
- function scheduleRerun(filename) {
1984
- pendingFile = filename;
2304
+ function scheduleTrigger(next, delayMs) {
2305
+ // Manual triggers preempt any pending change-trigger; if a manual one is
2306
+ // already pending, leave it (latest wins regardless).
2307
+ pendingTrigger = next;
1985
2308
  if (debounceTimer) clearTimeout(debounceTimer);
1986
2309
  debounceTimer = setTimeout(() => {
1987
2310
  if (isRunning) return;
1988
2311
  void triggerRerun();
1989
- }, 150);
1990
- }
1991
- // Initial run
1992
- await doRun();
1993
- // Set up file watchers
1994
- const watchers = [];
1995
- for (const dir of watchDirs) {
1996
- if (!existsSync(dir)) continue;
1997
- try {
1998
- const w = fsWatch(dir, { recursive: true }, (_evt, filename) => {
1999
- if (!filename) return;
2000
- const full = path.join(dir, filename);
2001
- if (shouldIgnoreWatchPath(full, config)) return;
2002
- scheduleRerun(path.relative(process.cwd(), full));
2003
- });
2004
- watchers.push(w);
2005
- } catch {
2006
- // directory may not support recursive watching; skip
2312
+ }, delayMs);
2313
+ }
2314
+ function scheduleRerun(filename) {
2315
+ if (!autoRun) {
2316
+ // Manual mode: remember the edit (for the footer + resume) but don't run.
2317
+ changedWhilePaused.add(path.resolve(filename));
2318
+ return;
2007
2319
  }
2320
+ scheduleTrigger({ kind: "change", file: filename }, 150);
2321
+ }
2322
+ function scheduleManualRerun(kind) {
2323
+ scheduleTrigger({ kind }, 0);
2008
2324
  }
2009
- if (existsSync(resolvedConfigPath)) {
2325
+ // Attach watchers before the initial run so file events that happen during
2326
+ // the run are queued for the next iteration.
2327
+ refreshWatchedDirs();
2328
+ attachConfigWatcher();
2329
+ // Initial run populates the graph as a side effect of recording every read.
2330
+ await doRun({ kind: "initial" });
2331
+ const stdin = process.stdin;
2332
+ let rawModeEnabled = false;
2333
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
2010
2334
  try {
2011
- const w = fsWatch(resolvedConfigPath, () => {
2012
- scheduleRerun(path.relative(process.cwd(), resolvedConfigPath));
2335
+ stdin.setRawMode(true);
2336
+ rawModeEnabled = true;
2337
+ stdin.resume();
2338
+ stdin.on("data", (chunk) => {
2339
+ // A held key (or any chord with multiple bytes) arrives as a single
2340
+ // chunk; we only need to honour the first actionable byte. Without
2341
+ // this guard, holding `space` would schedule a fresh rerun for every
2342
+ // byte in the chunk.
2343
+ for (const byte of chunk) {
2344
+ if (byte === 0x03) {
2345
+ if (rawModeEnabled) stdin.setRawMode(false);
2346
+ closeAllWatchers();
2347
+ process.exit(0);
2348
+ }
2349
+ if (isRunning) break;
2350
+ if (byte === 0x77 || byte === 0x57) {
2351
+ // `w` — toggle auto-run / manual mode.
2352
+ autoRun = !autoRun;
2353
+ if (autoRun) {
2354
+ const hadPending = changedWhilePaused.size > 0;
2355
+ if (hadPending) {
2356
+ process.stdout.write(
2357
+ "\n" +
2358
+ chalk.dim(
2359
+ "Auto-run resumed — re-running all (files changed while paused).\n",
2360
+ ),
2361
+ );
2362
+ scheduleManualRerun("manual-runall");
2363
+ } else {
2364
+ process.stdout.write(
2365
+ "\n" + chalk.dim("Auto-run resumed.\n") + watchFooter(),
2366
+ );
2367
+ }
2368
+ } else {
2369
+ process.stdout.write(
2370
+ "\n" +
2371
+ chalk.dim(
2372
+ "Auto-run paused — edits won't re-run. Press w to resume, or a / space to run now.\n",
2373
+ ) +
2374
+ watchFooter(),
2375
+ );
2376
+ }
2377
+ break;
2378
+ }
2379
+ if (byte === 0x20 || byte === 0x0d || byte === 0x0a) {
2380
+ scheduleManualRerun("manual-rerun");
2381
+ break;
2382
+ }
2383
+ if (byte === 0x61 || byte === 0x41) {
2384
+ scheduleManualRerun("manual-runall");
2385
+ break;
2386
+ }
2387
+ }
2013
2388
  });
2014
- watchers.push(w);
2015
- } catch {}
2389
+ } catch {
2390
+ // some terminals don't support raw mode (e.g. piped stdin); just fall
2391
+ // back to SIGINT-only behavior.
2392
+ }
2016
2393
  }
2017
2394
  process.on("SIGINT", () => {
2018
- for (const w of watchers) w.close();
2395
+ if (rawModeEnabled) stdin.setRawMode(false);
2396
+ closeAllWatchers();
2019
2397
  process.exit(0);
2020
2398
  });
2021
2399
  // Keep the process alive
2022
2400
  await new Promise(() => {});
2023
2401
  }
2024
- function resolveWatchDirectories(config) {
2402
+ // Union of every glob the user has declared as a spec/fuzz source —
2403
+ // top-level plus each mode override. We watch only directories derived from
2404
+ // these so unrelated files (e.g. CLI source while developing as-test itself)
2405
+ // don't trigger spurious iterations.
2406
+ function collectInputPatterns(config, modes) {
2407
+ const out = new Set();
2408
+ for (const p of config.input) out.add(p);
2409
+ for (const p of config.fuzz.input) out.add(p);
2410
+ for (const modeName of modes) {
2411
+ if (!modeName) continue;
2412
+ let merged;
2413
+ try {
2414
+ merged = applyMode(config, modeName);
2415
+ } catch {
2416
+ continue;
2417
+ }
2418
+ for (const p of merged.config.input) out.add(p);
2419
+ for (const p of merged.config.fuzz.input) out.add(p);
2420
+ }
2421
+ return [...out];
2422
+ }
2423
+ function resolveWatchDirectories(config, modes) {
2025
2424
  const cwd = process.cwd();
2026
2425
  const dirs = new Set();
2027
- for (const pattern of config.input) {
2426
+ const patterns = collectInputPatterns(config, modes);
2427
+ for (const pattern of patterns) {
2428
+ // `!`-prefixed entries are exclusions, not include sources — their
2429
+ // "base" would be `!` (which never exists), causing the walk-up loop
2430
+ // to land on cwd and watch the whole repo.
2431
+ if (pattern.startsWith("!")) continue;
2028
2432
  const starIdx = pattern.indexOf("*");
2029
2433
  const base = starIdx >= 0 ? pattern.slice(0, starIdx) : pattern;
2030
2434
  let dir = path.resolve(cwd, base);
@@ -2033,20 +2437,62 @@ function resolveWatchDirectories(config) {
2033
2437
  }
2034
2438
  if (existsSync(dir)) dirs.add(dir);
2035
2439
  }
2036
- const assemblyDir = path.resolve(cwd, "assembly");
2037
- if (existsSync(assemblyDir)) dirs.add(assemblyDir);
2038
- if (dirs.size === 0) dirs.add(cwd);
2440
+ const snapshotDir = path.resolve(cwd, config.snapshotDir);
2441
+ if (existsSync(snapshotDir)) dirs.add(snapshotDir);
2039
2442
  return [...dirs];
2040
2443
  }
2444
+ // Returns true if `rel` (a cwd-relative path with `/` separators) matches any
2445
+ // `!`-prefixed pattern from the supplied input arrays. Lets users opt out of
2446
+ // watch events for files they've already excluded from their spec/fuzz globs
2447
+ // — no separate watch config needed.
2448
+ function matchesAnyExclusion(rel, inputs) {
2449
+ const normalized = rel.split(path.sep).join("/");
2450
+ for (const input of inputs) {
2451
+ if (!input) continue;
2452
+ const patterns = Array.isArray(input) ? input : [input];
2453
+ for (const raw of patterns) {
2454
+ if (typeof raw != "string" || !raw.startsWith("!")) continue;
2455
+ const pattern = raw.slice(1);
2456
+ if (!pattern.length) continue;
2457
+ if (minimatch(normalized, pattern, { dot: true, matchBase: true })) {
2458
+ return true;
2459
+ }
2460
+ }
2461
+ }
2462
+ return false;
2463
+ }
2041
2464
  function shouldIgnoreWatchPath(filePath, config) {
2042
2465
  const cwd = process.cwd();
2043
2466
  const rel = path.relative(cwd, filePath);
2044
2467
  if (rel.startsWith("node_modules") || rel.startsWith(".git")) return true;
2468
+ // Dotfiles (.swp, .DS_Store, …) are always noise.
2469
+ const base = path.basename(rel);
2470
+ if (base.startsWith(".")) return true;
2471
+ // Respect `!`-prefixed glob negations in the user's existing input arrays.
2472
+ // Files the user already excluded from their spec/fuzz globs are also out
2473
+ // of scope for watch — no separate "watch.ignore" config needed.
2474
+ if (matchesAnyExclusion(rel, [config.input, config.fuzz?.input])) {
2475
+ return true;
2476
+ }
2045
2477
  const outRel = path.normalize(
2046
2478
  path.relative(cwd, path.resolve(cwd, config.outDir)),
2047
2479
  );
2048
- if (rel.startsWith(outRel + path.sep) || rel === outRel) return true;
2049
- if (!rel.endsWith(".ts") && !rel.endsWith("as-test.config.json")) return true;
2480
+ const snapRel = path.normalize(
2481
+ path.relative(cwd, path.resolve(cwd, config.snapshotDir)),
2482
+ );
2483
+ const underSnap = rel === snapRel || rel.startsWith(snapRel + path.sep);
2484
+ const underOut = rel === outRel || rel.startsWith(outRel + path.sep);
2485
+ // Snapshots often live under outDir (default ./.as-test/snapshots); they
2486
+ // are dependencies of their specs, so we keep watching them even when the
2487
+ // rest of the output tree is ignored.
2488
+ if (underOut && !underSnap) return true;
2489
+ if (
2490
+ !rel.endsWith(".ts") &&
2491
+ !rel.endsWith(".snap") &&
2492
+ !rel.endsWith("as-test.config.json")
2493
+ ) {
2494
+ return true;
2495
+ }
2050
2496
  return false;
2051
2497
  }
2052
2498
  async function runTestMatrix(
@@ -2061,8 +2507,17 @@ async function runTestMatrix(
2061
2507
  fileSummaryTotal,
2062
2508
  fuzzEnabled,
2063
2509
  fuzzOverrides,
2510
+ onSpecOutcome,
2064
2511
  ) {
2065
2512
  const files = await resolveSelectedFiles(configPath, selectors);
2513
+ if (files.length && configPath) {
2514
+ try {
2515
+ const loaded = loadConfig(configPath, false);
2516
+ warnOnUnknownModeReferences(files, loaded.modes ?? {});
2517
+ } catch {
2518
+ // Best-effort: never fail the run on a scan error.
2519
+ }
2520
+ }
2066
2521
  if (!files.length) {
2067
2522
  if (!fuzzEnabled) {
2068
2523
  throw await buildNoTestFilesMatchedError(configPath, selectors);
@@ -2083,6 +2538,7 @@ async function runTestMatrix(
2083
2538
  runtimeName: reporterSession.runtimeName,
2084
2539
  clean: runFlags.clean,
2085
2540
  verbose: runFlags.verbose,
2541
+ showLogs: runFlags.showLogs,
2086
2542
  snapshotEnabled,
2087
2543
  createSnapshots: runFlags.createSnapshots,
2088
2544
  });
@@ -2158,6 +2614,7 @@ async function runTestMatrix(
2158
2614
  }
2159
2615
  fileResults.push(result);
2160
2616
  allResults.push(result);
2617
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2161
2618
  }
2162
2619
  if (reporterSession.reporterKind == "default") {
2163
2620
  renderMatrixFileResult(
@@ -2211,10 +2668,13 @@ async function runTestMatrix(
2211
2668
  coverageSummary: summary.coverageSummary,
2212
2669
  stats: summary.stats,
2213
2670
  reports: summary.reports,
2671
+ showLogs: runFlags.showLogs,
2672
+ logSummary: summary.logSummary,
2214
2673
  fuzzSummary,
2215
2674
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2216
2675
  });
2217
2676
  reporter.flush?.();
2677
+ flushModeWarnings(process.argv.includes("--show-warnings"));
2218
2678
  return failed;
2219
2679
  }
2220
2680
  async function runFuzzModes(
@@ -2295,6 +2755,7 @@ async function runRuntimeSingleParallel(
2295
2755
  runtimeName: reporterSession.runtimeName,
2296
2756
  clean: runFlags.clean,
2297
2757
  verbose: runFlags.verbose,
2758
+ showLogs: runFlags.showLogs,
2298
2759
  snapshotEnabled,
2299
2760
  createSnapshots: runFlags.createSnapshots,
2300
2761
  });
@@ -2365,6 +2826,8 @@ async function runRuntimeSingleParallel(
2365
2826
  coverageSummary: summary.coverageSummary,
2366
2827
  stats: summary.stats,
2367
2828
  reports: summary.reports,
2829
+ showLogs: runFlags.showLogs,
2830
+ logSummary: summary.logSummary,
2368
2831
  modeSummary: buildSingleModeSummary(
2369
2832
  summary.stats,
2370
2833
  summary.snapshotSummary,
@@ -2372,6 +2835,7 @@ async function runRuntimeSingleParallel(
2372
2835
  ),
2373
2836
  });
2374
2837
  reporter.flush?.();
2838
+ flushModeWarnings(process.argv.includes("--show-warnings"));
2375
2839
  return results.some((result) => result.failed);
2376
2840
  }
2377
2841
  async function runRuntimeMatrixParallel(
@@ -2397,6 +2861,7 @@ async function runRuntimeMatrixParallel(
2397
2861
  runtimeName: reporterSession.runtimeName,
2398
2862
  clean: runFlags.clean,
2399
2863
  verbose: runFlags.verbose,
2864
+ showLogs: runFlags.showLogs,
2400
2865
  snapshotEnabled,
2401
2866
  createSnapshots: runFlags.createSnapshots,
2402
2867
  });
@@ -2513,9 +2978,12 @@ async function runRuntimeMatrixParallel(
2513
2978
  coverageSummary: summary.coverageSummary,
2514
2979
  stats: summary.stats,
2515
2980
  reports: summary.reports,
2981
+ showLogs: runFlags.showLogs,
2982
+ logSummary: summary.logSummary,
2516
2983
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2517
2984
  });
2518
2985
  reporter.flush?.();
2986
+ flushModeWarnings(process.argv.includes("--show-warnings"));
2519
2987
  return allResults.some((result) => result.failed);
2520
2988
  }
2521
2989
  async function runTestSingleParallel(
@@ -2530,6 +2998,7 @@ async function runTestSingleParallel(
2530
2998
  fuzzEnabled,
2531
2999
  fuzzOverrides,
2532
3000
  modeName,
3001
+ onSpecOutcome,
2533
3002
  ) {
2534
3003
  const files = await resolveSelectedFiles(configPath, selectors);
2535
3004
  if (!files.length && !fuzzEnabled) {
@@ -2546,6 +3015,7 @@ async function runTestSingleParallel(
2546
3015
  runtimeName: reporterSession.runtimeName,
2547
3016
  clean: runFlags.clean,
2548
3017
  verbose: runFlags.verbose,
3018
+ showLogs: runFlags.showLogs,
2549
3019
  snapshotEnabled,
2550
3020
  createSnapshots: runFlags.createSnapshots,
2551
3021
  });
@@ -2616,6 +3086,7 @@ async function runTestSingleParallel(
2616
3086
  }
2617
3087
  buffered?.reporter.flush?.();
2618
3088
  results[index] = result;
3089
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2619
3090
  if (buffered && token != null) {
2620
3091
  queueDisplay.complete(token, buffered.output());
2621
3092
  }
@@ -2663,6 +3134,8 @@ async function runTestSingleParallel(
2663
3134
  coverageSummary: summary.coverageSummary,
2664
3135
  stats: summary.stats,
2665
3136
  reports: summary.reports,
3137
+ showLogs: runFlags.showLogs,
3138
+ logSummary: summary.logSummary,
2666
3139
  fuzzSummary,
2667
3140
  modeSummary: buildSingleModeSummary(
2668
3141
  summary.stats,
@@ -2671,6 +3144,7 @@ async function runTestSingleParallel(
2671
3144
  ),
2672
3145
  });
2673
3146
  reporter.flush?.();
3147
+ flushModeWarnings(process.argv.includes("--show-warnings"));
2674
3148
  return failed;
2675
3149
  }
2676
3150
  async function runTestMatrixParallel(
@@ -2685,8 +3159,17 @@ async function runTestMatrixParallel(
2685
3159
  fileSummaryTotal,
2686
3160
  fuzzEnabled,
2687
3161
  fuzzOverrides,
3162
+ onSpecOutcome,
2688
3163
  ) {
2689
3164
  const files = await resolveSelectedFiles(configPath, selectors);
3165
+ if (files.length && configPath) {
3166
+ try {
3167
+ const loaded = loadConfig(configPath, false);
3168
+ warnOnUnknownModeReferences(files, loaded.modes ?? {});
3169
+ } catch {
3170
+ // Best-effort: never fail the run on a scan error.
3171
+ }
3172
+ }
2690
3173
  if (!files.length) {
2691
3174
  if (!fuzzEnabled) {
2692
3175
  throw await buildNoTestFilesMatchedError(configPath, selectors);
@@ -2710,6 +3193,7 @@ async function runTestMatrixParallel(
2710
3193
  runtimeName: reporterSession.runtimeName,
2711
3194
  clean: runFlags.clean,
2712
3195
  verbose: runFlags.verbose,
3196
+ showLogs: runFlags.showLogs,
2713
3197
  snapshotEnabled,
2714
3198
  createSnapshots: runFlags.createSnapshots,
2715
3199
  });
@@ -2775,6 +3259,7 @@ async function runTestMatrixParallel(
2775
3259
  }
2776
3260
  modeTimes[i] = formatMatrixModeTime(result.stats.time);
2777
3261
  fileResults.push(result);
3262
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2778
3263
  }
2779
3264
  ordered[fileIndex] = { fileName, fileResults, modeTimes };
2780
3265
  if (token != null) {
@@ -2847,10 +3332,13 @@ async function runTestMatrixParallel(
2847
3332
  coverageSummary: summary.coverageSummary,
2848
3333
  stats: summary.stats,
2849
3334
  reports: summary.reports,
3335
+ showLogs: runFlags.showLogs,
3336
+ logSummary: summary.logSummary,
2850
3337
  fuzzSummary,
2851
3338
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2852
3339
  });
2853
3340
  reporter.flush?.();
3341
+ flushModeWarnings(process.argv.includes("--show-warnings"));
2854
3342
  return failed;
2855
3343
  }
2856
3344
  async function runFuzzMatrixResultsParallel(
@@ -3249,6 +3737,7 @@ function createBuildFailureRunResult(error) {
3249
3737
  files: [],
3250
3738
  },
3251
3739
  reports: [report],
3740
+ logSummary: { count: 0, file: null, groups: [], text: "" },
3252
3741
  };
3253
3742
  }
3254
3743
  function formatBuildFailureMessage(error) {
@@ -4236,6 +4725,9 @@ function aggregateRunResults(results) {
4236
4725
  let fallbackCoverageUncovered = 0;
4237
4726
  const fallbackCoverageFiles = [];
4238
4727
  const reports = [];
4728
+ // run() rewrites the cumulative latest.log on every call and returns the
4729
+ // running total, so the result with the highest count is the most complete.
4730
+ let logSummary = { count: 0, file: null, groups: [], text: "" };
4239
4731
  for (const result of results) {
4240
4732
  stats.passedFiles += result.stats.passedFiles;
4241
4733
  stats.failedFiles += result.stats.failedFiles;
@@ -4275,6 +4767,9 @@ function aggregateRunResults(results) {
4275
4767
  }
4276
4768
  }
4277
4769
  reports.push(...result.reports);
4770
+ if (result.logSummary && result.logSummary.count >= logSummary.count) {
4771
+ logSummary = result.logSummary;
4772
+ }
4278
4773
  }
4279
4774
  if (uniqueCoveragePoints.size > 0) {
4280
4775
  const byFile = new Map();
@@ -4320,7 +4815,7 @@ function aggregateRunResults(results) {
4320
4815
  coverageSummary.percent = coverageSummary.total
4321
4816
  ? (coverageSummary.covered * 100) / coverageSummary.total
4322
4817
  : 100;
4323
- return { stats, snapshotSummary, coverageSummary, reports };
4818
+ return { stats, snapshotSummary, coverageSummary, reports, logSummary };
4324
4819
  }
4325
4820
  function printCliError(error) {
4326
4821
  const message = error instanceof Error ? error.message : String(error);