as-test 1.3.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
@@ -8,7 +8,7 @@ import {
8
8
  getBuildInvocationPreview,
9
9
  warnOnUnknownModeReferences,
10
10
  } from "./commands/build.js";
11
- import { createRunReporter, run } from "./commands/run.js";
11
+ import { createRunReporter, resetCollectedLogs, run } from "./commands/run.js";
12
12
  import { executeBuildCommand } from "./commands/build.js";
13
13
  import { executeRunCommand } from "./commands/run.js";
14
14
  import { executeTestCommand } from "./commands/test.js";
@@ -26,6 +26,7 @@ import {
26
26
  loadConfig,
27
27
  resolveArtifactPath,
28
28
  resolveModeNames,
29
+ resolveSnapshotPath,
29
30
  resolveSpecRelativePath,
30
31
  } from "./util.js";
31
32
  import { normalizeFeatureName } from "./types.js";
@@ -34,9 +35,12 @@ import { spawnSync } from "child_process";
34
35
  import { glob } from "glob";
35
36
  import { createInterface } from "readline";
36
37
  import { existsSync, watch as fsWatch } from "fs";
38
+ import { minimatch } from "minimatch";
37
39
  import { availableParallelism, cpus } from "os";
38
40
  import { BuildWorkerPool } from "./build-worker-pool.js";
39
41
  import { PersistentWebSessionHost } from "./commands/web-session.js";
42
+ import { buildRecorderStorage } from "./commands/build-core.js";
43
+ import { DependencyGraph } from "./dependency-graph.js";
40
44
  const _args = process.argv.slice(2);
41
45
  const flags = [];
42
46
  const args = [];
@@ -285,9 +289,6 @@ function printCommandHelp(command) {
285
289
  );
286
290
  process.stdout.write("Compile selected specs into wasm artifacts.\n\n");
287
291
  process.stdout.write(chalk.bold("Flags:\n"));
288
- process.stdout.write(
289
- " --config <path> Use a specific config file\n",
290
- );
291
292
  process.stdout.write(
292
293
  " --mode <name[,name...]> Run one or multiple named config modes\n",
293
294
  );
@@ -298,7 +299,7 @@ function printCommandHelp(command) {
298
299
  " --disable <list> Disable features, comma-separated\n",
299
300
  );
300
301
  process.stdout.write(
301
- " --parallel Run files through an ordered worker pool using an automatic worker count\n",
302
+ " --config <path> Use a specific config file\n",
302
303
  );
303
304
  process.stdout.write(
304
305
  " --jobs <n> Run files through an ordered worker pool\n",
@@ -394,16 +395,19 @@ function printCommandHelp(command) {
394
395
  );
395
396
  process.stdout.write(chalk.bold("Flags:\n"));
396
397
  process.stdout.write(
397
- " --config <path> Use a specific config file\n",
398
+ " --mode <name[,name...]> Run one or multiple named config modes\n",
398
399
  );
399
400
  process.stdout.write(
400
- " --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",
401
402
  );
402
403
  process.stdout.write(
403
- " --browser <name|path> Use chrome, chromium, firefox, webkit, or an executable path for web modes\n",
404
+ " --disable <list> Disable features, comma-separated\n",
404
405
  );
405
406
  process.stdout.write(
406
- " --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",
407
411
  );
408
412
  process.stdout.write(
409
413
  " --jobs <n> Run files through an ordered worker pool\n",
@@ -430,12 +434,6 @@ function printCommandHelp(command) {
430
434
  " --suite <name[,name...]> Filter results to matching suite names or suite slug paths\n",
431
435
  );
432
436
  process.stdout.write(" --suites <name[,name...]> Alias for --suite\n");
433
- process.stdout.write(
434
- " --enable <list> Enable features, comma-separated (e.g. coverage,try-as,simd)\n",
435
- );
436
- process.stdout.write(
437
- " --disable <list> Disable features, comma-separated\n",
438
- );
439
437
  process.stdout.write(
440
438
  " --fuzz Run fuzz targets after the normal test pass\n",
441
439
  );
@@ -476,7 +474,7 @@ function printCommandHelp(command) {
476
474
  " --list-modes Preview configured and selected mode names\n",
477
475
  );
478
476
  process.stdout.write(
479
- " --watch Re-run on source or spec changes\n",
477
+ " --watch, -w Re-run on source or spec changes\n",
480
478
  );
481
479
  process.stdout.write(" --help, -h Show this help\n");
482
480
  return;
@@ -694,7 +692,7 @@ function resolveCommandArgs(rawArgs, command) {
694
692
  if (arg == "--parallel") {
695
693
  continue;
696
694
  }
697
- if (arg == "--watch") {
695
+ if (arg == "--watch" || arg == "-w") {
698
696
  continue;
699
697
  }
700
698
  if (
@@ -1325,6 +1323,10 @@ function resolveCommandTokens(rawArgs, command) {
1325
1323
  return values;
1326
1324
  }
1327
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();
1328
1330
  if (args.buildPool) {
1329
1331
  const buildInvocation = await getBuildInvocationPreview(
1330
1332
  args.configPath,
@@ -1338,6 +1340,11 @@ async function buildFileForMode(args) {
1338
1340
  modeName: args.modeName,
1339
1341
  buildCommand: formatBuildInvocation(buildInvocation),
1340
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,
1341
1348
  });
1342
1349
  } else {
1343
1350
  await build(
@@ -1361,6 +1368,7 @@ async function runTestSequential(
1361
1368
  reporterOverride,
1362
1369
  webSession = null,
1363
1370
  emitRunComplete = true,
1371
+ onSpecOutcome,
1364
1372
  ) {
1365
1373
  const files = await resolveSelectedFiles(configPath, selectors);
1366
1374
  if (!files.length) {
@@ -1379,6 +1387,7 @@ async function runTestSequential(
1379
1387
  runtimeName: reporterSession.runtimeName,
1380
1388
  clean: runFlags.clean,
1381
1389
  verbose: runFlags.verbose,
1390
+ showLogs: runFlags.showLogs,
1382
1391
  snapshotEnabled,
1383
1392
  createSnapshots: runFlags.createSnapshots,
1384
1393
  });
@@ -1417,6 +1426,7 @@ async function runTestSequential(
1417
1426
  }
1418
1427
  results.push(result);
1419
1428
  if (result?.failed) failed = true;
1429
+ onSpecOutcome?.({ file, mode: modeName, failed: !!result?.failed });
1420
1430
  }
1421
1431
  const summary = aggregateRunResults(results);
1422
1432
  summary.stats = applyConfiguredFileTotalToStats(
@@ -1435,6 +1445,8 @@ async function runTestSequential(
1435
1445
  coverageSummary: summary.coverageSummary,
1436
1446
  stats: summary.stats,
1437
1447
  reports: summary.reports,
1448
+ showLogs: runFlags.showLogs,
1449
+ logSummary: summary.logSummary,
1438
1450
  modeSummary: buildSingleModeSummary(
1439
1451
  summary.stats,
1440
1452
  summary.snapshotSummary,
@@ -1452,6 +1464,7 @@ async function runTestSequential(
1452
1464
  coverageSummary: summary.coverageSummary,
1453
1465
  stats: summary.stats,
1454
1466
  reports: summary.reports,
1467
+ logSummary: summary.logSummary,
1455
1468
  },
1456
1469
  };
1457
1470
  }
@@ -1644,6 +1657,7 @@ async function runRuntimeMatrix(
1644
1657
  runtimeName: reporterSession.runtimeName,
1645
1658
  clean: runFlags.clean,
1646
1659
  verbose: runFlags.verbose,
1660
+ showLogs: runFlags.showLogs,
1647
1661
  snapshotEnabled,
1648
1662
  createSnapshots: runFlags.createSnapshots,
1649
1663
  });
@@ -1747,6 +1761,8 @@ async function runRuntimeMatrix(
1747
1761
  coverageSummary: summary.coverageSummary,
1748
1762
  stats: summary.stats,
1749
1763
  reports: summary.reports,
1764
+ showLogs: runFlags.showLogs,
1765
+ logSummary: summary.logSummary,
1750
1766
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
1751
1767
  });
1752
1768
  return allResults.some((result) => result.failed);
@@ -1761,6 +1777,7 @@ async function runTestModesCore(
1761
1777
  buildFeatureToggles,
1762
1778
  fuzzEnabled,
1763
1779
  fuzzOverrides,
1780
+ onSpecOutcome,
1764
1781
  ) {
1765
1782
  await ensureWebBrowsersReady(configPath, modes, runFlags.browser);
1766
1783
  const modeSummaryTotal = Math.max(modes.length, 1);
@@ -1793,6 +1810,7 @@ async function runTestModesCore(
1793
1810
  fileSummaryTotal,
1794
1811
  fuzzEnabled,
1795
1812
  fuzzOverrides,
1813
+ onSpecOutcome,
1796
1814
  );
1797
1815
  return failed;
1798
1816
  }
@@ -1810,6 +1828,7 @@ async function runTestModesCore(
1810
1828
  fuzzEnabled,
1811
1829
  fuzzOverrides,
1812
1830
  modeName,
1831
+ onSpecOutcome,
1813
1832
  );
1814
1833
  if (modeFailed) failed = true;
1815
1834
  }
@@ -1828,6 +1847,7 @@ async function runTestModesCore(
1828
1847
  fileSummaryTotal,
1829
1848
  fuzzEnabled,
1830
1849
  fuzzOverrides,
1850
+ onSpecOutcome,
1831
1851
  );
1832
1852
  return failed;
1833
1853
  }
@@ -1856,6 +1876,7 @@ async function runTestModesCore(
1856
1876
  reporterSession.reporter,
1857
1877
  sharedWebSession,
1858
1878
  !fuzzEnabled,
1879
+ onSpecOutcome,
1859
1880
  );
1860
1881
  if (modeResult.failed) failed = true;
1861
1882
  if (fuzzEnabled) {
@@ -1884,6 +1905,8 @@ async function runTestModesCore(
1884
1905
  coverageSummary: modeResult.summary.coverageSummary,
1885
1906
  stats: modeResult.summary.stats,
1886
1907
  reports: modeResult.summary.reports,
1908
+ showLogs: effectiveRunFlags.showLogs,
1909
+ logSummary: modeResult.summary.logSummary,
1887
1910
  fuzzSummary: summarizeFuzzExecutions(fuzzResults),
1888
1911
  modeSummary: buildSingleModeSummary(
1889
1912
  modeResult.summary.stats,
@@ -1951,99 +1974,461 @@ async function runWatchLoop(
1951
1974
  ) {
1952
1975
  const resolvedConfigPath =
1953
1976
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
1954
- const config = loadConfig(resolvedConfigPath, false);
1955
- 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
+ }
1956
2003
  let isRunning = false;
1957
- let pendingFile = null;
2004
+ let pendingTrigger = null;
1958
2005
  let debounceTimer = null;
1959
- async function doRun(changedFile) {
1960
- if (changedFile) {
1961
- console.clear();
1962
- 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" +
1963
2058
  chalk.dim(`[${new Date().toLocaleTimeString()}] `) +
1964
- chalk.yellow("Change detected: ") +
1965
- chalk.bold(changedFile) +
1966
- "\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),
1967
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
+ }
1968
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
+ };
1969
2186
  try {
1970
- await runTestModesCore(
1971
- runFlags,
1972
- configPath,
1973
- selectors,
1974
- suiteSelectors,
1975
- fuzzerSelectors,
1976
- modes,
1977
- buildFeatureToggles,
1978
- fuzzEnabled,
1979
- fuzzOverrides,
1980
- );
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
+ });
1981
2202
  } catch (error) {
1982
2203
  const message = error instanceof Error ? error.message : String(error);
1983
2204
  process.stderr.write(chalk.red("Error: ") + message + "\n");
1984
2205
  }
1985
- process.stdout.write(
1986
- "\n" + chalk.dim("Watching for changes. Press Ctrl+C to stop.\n"),
1987
- );
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
+ }
1988
2290
  }
1989
2291
  async function triggerRerun() {
1990
- const changedFile = pendingFile ?? undefined;
1991
- pendingFile = null;
2292
+ const trigger = pendingTrigger ?? { kind: "initial" };
2293
+ pendingTrigger = null;
1992
2294
  isRunning = true;
1993
2295
  try {
1994
- await doRun(changedFile);
2296
+ await doRun(trigger);
1995
2297
  } finally {
1996
2298
  isRunning = false;
1997
- if (pendingFile) {
2299
+ if (pendingTrigger) {
1998
2300
  void triggerRerun();
1999
2301
  }
2000
2302
  }
2001
2303
  }
2002
- function scheduleRerun(filename) {
2003
- 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;
2004
2308
  if (debounceTimer) clearTimeout(debounceTimer);
2005
2309
  debounceTimer = setTimeout(() => {
2006
2310
  if (isRunning) return;
2007
2311
  void triggerRerun();
2008
- }, 150);
2009
- }
2010
- // Initial run
2011
- await doRun();
2012
- // Set up file watchers
2013
- const watchers = [];
2014
- for (const dir of watchDirs) {
2015
- if (!existsSync(dir)) continue;
2016
- try {
2017
- const w = fsWatch(dir, { recursive: true }, (_evt, filename) => {
2018
- if (!filename) return;
2019
- const full = path.join(dir, filename);
2020
- if (shouldIgnoreWatchPath(full, config)) return;
2021
- scheduleRerun(path.relative(process.cwd(), full));
2022
- });
2023
- watchers.push(w);
2024
- } catch {
2025
- // 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;
2026
2319
  }
2320
+ scheduleTrigger({ kind: "change", file: filename }, 150);
2321
+ }
2322
+ function scheduleManualRerun(kind) {
2323
+ scheduleTrigger({ kind }, 0);
2027
2324
  }
2028
- 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") {
2029
2334
  try {
2030
- const w = fsWatch(resolvedConfigPath, () => {
2031
- 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
+ }
2032
2388
  });
2033
- watchers.push(w);
2034
- } catch {}
2389
+ } catch {
2390
+ // some terminals don't support raw mode (e.g. piped stdin); just fall
2391
+ // back to SIGINT-only behavior.
2392
+ }
2035
2393
  }
2036
2394
  process.on("SIGINT", () => {
2037
- for (const w of watchers) w.close();
2395
+ if (rawModeEnabled) stdin.setRawMode(false);
2396
+ closeAllWatchers();
2038
2397
  process.exit(0);
2039
2398
  });
2040
2399
  // Keep the process alive
2041
2400
  await new Promise(() => {});
2042
2401
  }
2043
- 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) {
2044
2424
  const cwd = process.cwd();
2045
2425
  const dirs = new Set();
2046
- 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;
2047
2432
  const starIdx = pattern.indexOf("*");
2048
2433
  const base = starIdx >= 0 ? pattern.slice(0, starIdx) : pattern;
2049
2434
  let dir = path.resolve(cwd, base);
@@ -2052,20 +2437,62 @@ function resolveWatchDirectories(config) {
2052
2437
  }
2053
2438
  if (existsSync(dir)) dirs.add(dir);
2054
2439
  }
2055
- const assemblyDir = path.resolve(cwd, "assembly");
2056
- if (existsSync(assemblyDir)) dirs.add(assemblyDir);
2057
- if (dirs.size === 0) dirs.add(cwd);
2440
+ const snapshotDir = path.resolve(cwd, config.snapshotDir);
2441
+ if (existsSync(snapshotDir)) dirs.add(snapshotDir);
2058
2442
  return [...dirs];
2059
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
+ }
2060
2464
  function shouldIgnoreWatchPath(filePath, config) {
2061
2465
  const cwd = process.cwd();
2062
2466
  const rel = path.relative(cwd, filePath);
2063
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
+ }
2064
2477
  const outRel = path.normalize(
2065
2478
  path.relative(cwd, path.resolve(cwd, config.outDir)),
2066
2479
  );
2067
- if (rel.startsWith(outRel + path.sep) || rel === outRel) return true;
2068
- 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
+ }
2069
2496
  return false;
2070
2497
  }
2071
2498
  async function runTestMatrix(
@@ -2080,6 +2507,7 @@ async function runTestMatrix(
2080
2507
  fileSummaryTotal,
2081
2508
  fuzzEnabled,
2082
2509
  fuzzOverrides,
2510
+ onSpecOutcome,
2083
2511
  ) {
2084
2512
  const files = await resolveSelectedFiles(configPath, selectors);
2085
2513
  if (files.length && configPath) {
@@ -2110,6 +2538,7 @@ async function runTestMatrix(
2110
2538
  runtimeName: reporterSession.runtimeName,
2111
2539
  clean: runFlags.clean,
2112
2540
  verbose: runFlags.verbose,
2541
+ showLogs: runFlags.showLogs,
2113
2542
  snapshotEnabled,
2114
2543
  createSnapshots: runFlags.createSnapshots,
2115
2544
  });
@@ -2185,6 +2614,7 @@ async function runTestMatrix(
2185
2614
  }
2186
2615
  fileResults.push(result);
2187
2616
  allResults.push(result);
2617
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2188
2618
  }
2189
2619
  if (reporterSession.reporterKind == "default") {
2190
2620
  renderMatrixFileResult(
@@ -2238,6 +2668,8 @@ async function runTestMatrix(
2238
2668
  coverageSummary: summary.coverageSummary,
2239
2669
  stats: summary.stats,
2240
2670
  reports: summary.reports,
2671
+ showLogs: runFlags.showLogs,
2672
+ logSummary: summary.logSummary,
2241
2673
  fuzzSummary,
2242
2674
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2243
2675
  });
@@ -2323,6 +2755,7 @@ async function runRuntimeSingleParallel(
2323
2755
  runtimeName: reporterSession.runtimeName,
2324
2756
  clean: runFlags.clean,
2325
2757
  verbose: runFlags.verbose,
2758
+ showLogs: runFlags.showLogs,
2326
2759
  snapshotEnabled,
2327
2760
  createSnapshots: runFlags.createSnapshots,
2328
2761
  });
@@ -2393,6 +2826,8 @@ async function runRuntimeSingleParallel(
2393
2826
  coverageSummary: summary.coverageSummary,
2394
2827
  stats: summary.stats,
2395
2828
  reports: summary.reports,
2829
+ showLogs: runFlags.showLogs,
2830
+ logSummary: summary.logSummary,
2396
2831
  modeSummary: buildSingleModeSummary(
2397
2832
  summary.stats,
2398
2833
  summary.snapshotSummary,
@@ -2426,6 +2861,7 @@ async function runRuntimeMatrixParallel(
2426
2861
  runtimeName: reporterSession.runtimeName,
2427
2862
  clean: runFlags.clean,
2428
2863
  verbose: runFlags.verbose,
2864
+ showLogs: runFlags.showLogs,
2429
2865
  snapshotEnabled,
2430
2866
  createSnapshots: runFlags.createSnapshots,
2431
2867
  });
@@ -2542,6 +2978,8 @@ async function runRuntimeMatrixParallel(
2542
2978
  coverageSummary: summary.coverageSummary,
2543
2979
  stats: summary.stats,
2544
2980
  reports: summary.reports,
2981
+ showLogs: runFlags.showLogs,
2982
+ logSummary: summary.logSummary,
2545
2983
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2546
2984
  });
2547
2985
  reporter.flush?.();
@@ -2560,6 +2998,7 @@ async function runTestSingleParallel(
2560
2998
  fuzzEnabled,
2561
2999
  fuzzOverrides,
2562
3000
  modeName,
3001
+ onSpecOutcome,
2563
3002
  ) {
2564
3003
  const files = await resolveSelectedFiles(configPath, selectors);
2565
3004
  if (!files.length && !fuzzEnabled) {
@@ -2576,6 +3015,7 @@ async function runTestSingleParallel(
2576
3015
  runtimeName: reporterSession.runtimeName,
2577
3016
  clean: runFlags.clean,
2578
3017
  verbose: runFlags.verbose,
3018
+ showLogs: runFlags.showLogs,
2579
3019
  snapshotEnabled,
2580
3020
  createSnapshots: runFlags.createSnapshots,
2581
3021
  });
@@ -2646,6 +3086,7 @@ async function runTestSingleParallel(
2646
3086
  }
2647
3087
  buffered?.reporter.flush?.();
2648
3088
  results[index] = result;
3089
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2649
3090
  if (buffered && token != null) {
2650
3091
  queueDisplay.complete(token, buffered.output());
2651
3092
  }
@@ -2693,6 +3134,8 @@ async function runTestSingleParallel(
2693
3134
  coverageSummary: summary.coverageSummary,
2694
3135
  stats: summary.stats,
2695
3136
  reports: summary.reports,
3137
+ showLogs: runFlags.showLogs,
3138
+ logSummary: summary.logSummary,
2696
3139
  fuzzSummary,
2697
3140
  modeSummary: buildSingleModeSummary(
2698
3141
  summary.stats,
@@ -2716,6 +3159,7 @@ async function runTestMatrixParallel(
2716
3159
  fileSummaryTotal,
2717
3160
  fuzzEnabled,
2718
3161
  fuzzOverrides,
3162
+ onSpecOutcome,
2719
3163
  ) {
2720
3164
  const files = await resolveSelectedFiles(configPath, selectors);
2721
3165
  if (files.length && configPath) {
@@ -2749,6 +3193,7 @@ async function runTestMatrixParallel(
2749
3193
  runtimeName: reporterSession.runtimeName,
2750
3194
  clean: runFlags.clean,
2751
3195
  verbose: runFlags.verbose,
3196
+ showLogs: runFlags.showLogs,
2752
3197
  snapshotEnabled,
2753
3198
  createSnapshots: runFlags.createSnapshots,
2754
3199
  });
@@ -2814,6 +3259,7 @@ async function runTestMatrixParallel(
2814
3259
  }
2815
3260
  modeTimes[i] = formatMatrixModeTime(result.stats.time);
2816
3261
  fileResults.push(result);
3262
+ onSpecOutcome?.({ file, mode: modeName, failed: result.failed });
2817
3263
  }
2818
3264
  ordered[fileIndex] = { fileName, fileResults, modeTimes };
2819
3265
  if (token != null) {
@@ -2886,6 +3332,8 @@ async function runTestMatrixParallel(
2886
3332
  coverageSummary: summary.coverageSummary,
2887
3333
  stats: summary.stats,
2888
3334
  reports: summary.reports,
3335
+ showLogs: runFlags.showLogs,
3336
+ logSummary: summary.logSummary,
2889
3337
  fuzzSummary,
2890
3338
  modeSummary: buildModeSummary(modeState, modeSummaryTotal),
2891
3339
  });
@@ -3289,6 +3737,7 @@ function createBuildFailureRunResult(error) {
3289
3737
  files: [],
3290
3738
  },
3291
3739
  reports: [report],
3740
+ logSummary: { count: 0, file: null, groups: [], text: "" },
3292
3741
  };
3293
3742
  }
3294
3743
  function formatBuildFailureMessage(error) {
@@ -4276,6 +4725,9 @@ function aggregateRunResults(results) {
4276
4725
  let fallbackCoverageUncovered = 0;
4277
4726
  const fallbackCoverageFiles = [];
4278
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: "" };
4279
4731
  for (const result of results) {
4280
4732
  stats.passedFiles += result.stats.passedFiles;
4281
4733
  stats.failedFiles += result.stats.failedFiles;
@@ -4315,6 +4767,9 @@ function aggregateRunResults(results) {
4315
4767
  }
4316
4768
  }
4317
4769
  reports.push(...result.reports);
4770
+ if (result.logSummary && result.logSummary.count >= logSummary.count) {
4771
+ logSummary = result.logSummary;
4772
+ }
4318
4773
  }
4319
4774
  if (uniqueCoveragePoints.size > 0) {
4320
4775
  const byFile = new Map();
@@ -4360,7 +4815,7 @@ function aggregateRunResults(results) {
4360
4815
  coverageSummary.percent = coverageSummary.total
4361
4816
  ? (coverageSummary.covered * 100) / coverageSummary.total
4362
4817
  : 100;
4363
- return { stats, snapshotSummary, coverageSummary, reports };
4818
+ return { stats, snapshotSummary, coverageSummary, reports, logSummary };
4364
4819
  }
4365
4820
  function printCliError(error) {
4366
4821
  const message = error instanceof Error ? error.message : String(error);