as-test 1.0.4 → 1.0.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Change Log
2
2
 
3
+ ## 2026-03-31 - v1.0.6
4
+
5
+ ### Fuzzing
6
+
7
+ - feat: print exact failing fuzz seeds and one-run repro commands on logical fuzz failures, and persist captured `run(...)` inputs in `.as-test/crashes` so side-effectful generators still leave behind replayable failure data.
8
+
9
+ ### Reporting
10
+
11
+ - fix: correct reporter override output behavior.
12
+
13
+ ## 2026-03-30 - v1.0.5
14
+
15
+ ### CLI
16
+
17
+ - fix: preserve selectors passed after `--parallel` so commands like `ast test --parallel math` still target the requested suite.
18
+ - fix: treat uncaught runtime stderr / missing report payloads as normal failed test results instead of transport-level crashes, with cleaner default reporter output.
19
+ - perf: reuse build artifacts across modes when the resolved non-custom build invocation and build environment are identical, copying the first artifact instead of recompiling.
20
+
3
21
  ## 2026-03-27 - v1.0.4
4
22
 
5
23
  ### Build Command
package/README.md CHANGED
@@ -66,6 +66,23 @@ By default, `as-test` looks for:
66
66
 
67
67
  Generated files go into `.as-test/`.
68
68
 
69
+ Minimal `as-test.config.json`:
70
+
71
+ ```json
72
+ {
73
+ "input": ["assembly/__tests__/*.spec.ts"],
74
+ "output": ".as-test/",
75
+ "buildOptions": {
76
+ "target": "wasi"
77
+ },
78
+ "runOptions": {
79
+ "runtime": {
80
+ "cmd": "node .as-test/runners/default.wasi.js <file>"
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
69
86
  ## Writing Tests
70
87
 
71
88
  Tests usually live in `assembly/__tests__/*.spec.ts`.
@@ -214,6 +231,8 @@ If you used `npx ast init` with a fuzzer example, the config is already there. O
214
231
 
215
232
  `ast fuzz` runs fuzz files across the selected modes, reports one result per file, and keeps the final summary separate from the normal test totals. If you want one combined command, use `ast test --fuzz`.
216
233
 
234
+ When a fuzzer fails, `as-test` now prints the exact failing seeds and one-run repro commands such as `ast fuzz ... --seed <seed+n> --runs 1`. Crash records in `.as-test/crashes` also include the captured inputs passed to `run(...)`, which helps when the generator itself has side effects.
235
+
217
236
  Run only fuzzers:
218
237
 
219
238
  ```bash
@@ -1,4 +1,4 @@
1
- import { quote } from "../util/json";
1
+ import { quote, rawOrNull, stringifyValue } from "../util/json";
2
2
 
3
3
  export class StringOptions {
4
4
  charset: string = "ascii";
@@ -211,6 +211,7 @@ export class FuzzerResult {
211
211
  public failureLeft: string = "";
212
212
  public failureRight: string = "";
213
213
  public failureMessage: string = "";
214
+ public failures: FuzzFailure[] = [];
214
215
 
215
216
  serialize(): string {
216
217
  return (
@@ -238,7 +239,27 @@ export class FuzzerResult {
238
239
  quote(this.failureRight) +
239
240
  ',"message":' +
240
241
  quote(this.failureMessage) +
241
- "}}"
242
+ '},"failures":' +
243
+ serializeFuzzFailures(this.failures) +
244
+ "}"
245
+ );
246
+ }
247
+ }
248
+
249
+ export class FuzzFailure {
250
+ public run: i32 = 0;
251
+ public seed: u64 = 0;
252
+ public input: string = "";
253
+
254
+ serialize(): string {
255
+ return (
256
+ '{"run":' +
257
+ this.run.toString() +
258
+ ',"seed":' +
259
+ this.seed.toString() +
260
+ ',"input":' +
261
+ rawOrNull(this.input) +
262
+ "}"
242
263
  );
243
264
  }
244
265
  }
@@ -329,7 +350,7 @@ function createSkippedResult(name: string): FuzzerResult {
329
350
  return result;
330
351
  }
331
352
 
332
- function recordResult(result: FuzzerResult): void {
353
+ function recordResult(result: FuzzerResult, run: i32, seed: u64): void {
333
354
  if (__as_test_fuzz_failed) {
334
355
  result.failed++;
335
356
  if (!result.failureInstr.length && __as_test_fuzz_failure_instr.length) {
@@ -338,6 +359,11 @@ function recordResult(result: FuzzerResult): void {
338
359
  result.failureRight = __as_test_fuzz_failure_right;
339
360
  result.failureMessage = __as_test_fuzz_failure_message;
340
361
  }
362
+ const failure = new FuzzFailure();
363
+ failure.run = run;
364
+ failure.seed = seed;
365
+ failure.input = __as_test_fuzz_input;
366
+ result.failures.push(failure);
341
367
  } else {
342
368
  result.passed++;
343
369
  }
@@ -389,7 +415,7 @@ export class Fuzzer0<R> extends FuzzerBase {
389
415
  "fuzz generator must call run() exactly once",
390
416
  );
391
417
  }
392
- recordResult(result);
418
+ recordResult(result, i, seedBase + <u64>i);
393
419
  }
394
420
  __fuzz_callback0 = null;
395
421
  result.timeEnd = performance.now();
@@ -438,7 +464,10 @@ export class Fuzzer1<A, R> extends FuzzerBase {
438
464
  "fuzzers with arguments must call .generate(...)",
439
465
  );
440
466
  } else {
441
- this.generator(seed, changetype<(a: A) => R>(__fuzz_run1));
467
+ this.generator(seed, (a: A): R => {
468
+ __as_test_fuzz_input = "[" + stringifyValue<A>(a) + "]";
469
+ return changetype<(a: A) => R>(__fuzz_run1)(a);
470
+ });
442
471
  }
443
472
  if (__fuzz_calls != 1) {
444
473
  failFuzzIteration(
@@ -448,7 +477,7 @@ export class Fuzzer1<A, R> extends FuzzerBase {
448
477
  "fuzz generator must call run() exactly once",
449
478
  );
450
479
  }
451
- recordResult(result);
480
+ recordResult(result, i, seedBase + <u64>i);
452
481
  }
453
482
  __fuzz_callback1 = null;
454
483
  result.timeEnd = performance.now();
@@ -500,7 +529,11 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
500
529
  "fuzzers with arguments must call .generate(...)",
501
530
  );
502
531
  } else {
503
- this.generator(seed, changetype<(a: A, b: B) => R>(__fuzz_run2));
532
+ this.generator(seed, (a: A, b: B): R => {
533
+ __as_test_fuzz_input =
534
+ "[" + stringifyValue<A>(a) + "," + stringifyValue<B>(b) + "]";
535
+ return changetype<(a: A, b: B) => R>(__fuzz_run2)(a, b);
536
+ });
504
537
  }
505
538
  if (__fuzz_calls != 1) {
506
539
  failFuzzIteration(
@@ -510,7 +543,7 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
510
543
  "fuzz generator must call run() exactly once",
511
544
  );
512
545
  }
513
- recordResult(result);
546
+ recordResult(result, i, seedBase + <u64>i);
514
547
  }
515
548
  __fuzz_callback2 = null;
516
549
  result.timeEnd = performance.now();
@@ -564,7 +597,17 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
564
597
  } else {
565
598
  changetype<(seed: FuzzSeed, run: (a: A, b: B, c: C) => R) => void>(
566
599
  this.generator,
567
- )(seed, changetype<(a: A, b: B, c: C) => R>(__fuzz_run3));
600
+ )(seed, (a: A, b: B, c: C): R => {
601
+ __as_test_fuzz_input =
602
+ "[" +
603
+ stringifyValue<A>(a) +
604
+ "," +
605
+ stringifyValue<B>(b) +
606
+ "," +
607
+ stringifyValue<C>(c) +
608
+ "]";
609
+ return changetype<(a: A, b: B, c: C) => R>(__fuzz_run3)(a, b, c);
610
+ });
568
611
  }
569
612
  if (__fuzz_calls != 1) {
570
613
  failFuzzIteration(
@@ -574,7 +617,7 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
574
617
  "fuzz generator must call run() exactly once",
575
618
  );
576
619
  }
577
- recordResult(result);
620
+ recordResult(result, i, seedBase + <u64>i);
578
621
  }
579
622
  __fuzz_callback3 = null;
580
623
  result.timeEnd = performance.now();
@@ -694,6 +737,8 @@ function containsFloatValue<T>(values: T[], needle: T): bool {
694
737
  @global export let __as_test_fuzz_failure_right: string = "";
695
738
  // @ts-ignore
696
739
  @global export let __as_test_fuzz_failure_message: string = "";
740
+ // @ts-ignore
741
+ @global export let __as_test_fuzz_input: string = "";
697
742
 
698
743
  export function prepareFuzzIteration(): void {
699
744
  __as_test_fuzz_failed = false;
@@ -701,6 +746,7 @@ export function prepareFuzzIteration(): void {
701
746
  __as_test_fuzz_failure_left = "";
702
747
  __as_test_fuzz_failure_right = "";
703
748
  __as_test_fuzz_failure_message = "";
749
+ __as_test_fuzz_input = "[]";
704
750
  }
705
751
 
706
752
  export function failFuzzIteration(
@@ -721,3 +767,15 @@ export function failFuzzIteration(
721
767
  function panic(): void {
722
768
  unreachable();
723
769
  }
770
+
771
+ function serializeFuzzFailures(values: FuzzFailure[]): string {
772
+ if (!values.length) return "[]";
773
+
774
+ let out = "[";
775
+ for (let i = 0; i < values.length; i++) {
776
+ if (i) out += ",";
777
+ out += unchecked(values[i]).serialize();
778
+ }
779
+ out += "]";
780
+ return out;
781
+ }
@@ -124,6 +124,39 @@ export async function getBuildInvocationPreview(configPath = DEFAULT_CONFIG_PATH
124
124
  const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
125
125
  return getBuildCommand(config, getPkgRunner(), file, outFile, modeName, featureToggles);
126
126
  }
127
+ export async function getBuildReuseInfo(configPath = DEFAULT_CONFIG_PATH, file, modeName, featureToggles = {}, overrides = {}) {
128
+ const loadedConfig = loadConfig(configPath, false);
129
+ const mode = applyMode(loadedConfig, modeName);
130
+ const config = Object.assign(Object.create(Object.getPrototypeOf(mode.config)), mode.config);
131
+ config.buildOptions = Object.assign(Object.create(Object.getPrototypeOf(mode.config.buildOptions)), mode.config.buildOptions);
132
+ if (overrides.target) {
133
+ config.buildOptions.target = overrides.target;
134
+ }
135
+ if (overrides.args?.length) {
136
+ config.buildOptions.args = [...config.buildOptions.args, ...overrides.args];
137
+ }
138
+ if (hasCustomBuildCommand(config)) {
139
+ return null;
140
+ }
141
+ const duplicateSpecBasenames = resolveDuplicateBasenames([file]);
142
+ const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
143
+ const invocation = getBuildCommand(config, getPkgRunner(), file, outFile, modeName, featureToggles);
144
+ const coverageEnabled = resolveCoverageEnabled(config.coverage, featureToggles.coverage);
145
+ const buildEnv = {
146
+ ...mode.env,
147
+ ...config.buildOptions.env,
148
+ AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
149
+ };
150
+ return {
151
+ signature: JSON.stringify({
152
+ command: invocation.command,
153
+ args: stripOutputArgs(invocation.args),
154
+ apiArgs: invocation.apiArgs ? stripOutputArgs(invocation.apiArgs) : [],
155
+ env: sortRecord(buildEnv),
156
+ }),
157
+ outFile,
158
+ };
159
+ }
127
160
  function hasCustomBuildCommand(config) {
128
161
  return !!config.buildOptions.cmd.trim().length;
129
162
  }
@@ -176,6 +209,23 @@ function getUserBuildArgs(config) {
176
209
  }
177
210
  return args;
178
211
  }
212
+ function stripOutputArgs(args) {
213
+ const out = [];
214
+ for (let i = 0; i < args.length; i++) {
215
+ const arg = args[i];
216
+ if (arg == "-o" || arg == "--outFile") {
217
+ i++;
218
+ continue;
219
+ }
220
+ out.push(arg);
221
+ }
222
+ return out;
223
+ }
224
+ function sortRecord(record) {
225
+ return Object.fromEntries(Object.entries(record)
226
+ .filter((entry) => typeof entry[1] == "string")
227
+ .sort((a, b) => a[0].localeCompare(b[0])));
228
+ }
179
229
  function expandBuildCommand(template, file, outFile, target, modeName) {
180
230
  const name = path
181
231
  .basename(file)
@@ -1,6 +1,6 @@
1
1
  import { closeSerialBuildWorkerPool, } from "./build-core.js";
2
2
  export { build } from "./build-core.js";
3
- export { formatInvocation, getBuildInvocationPreview } from "./build-core.js";
3
+ export { formatInvocation, getBuildInvocationPreview, getBuildReuseInfo, } from "./build-core.js";
4
4
  export async function executeBuildCommand(rawArgs, configPath, selectedModes, deps) {
5
5
  const commandArgs = deps.resolveCommandArgs(rawArgs, "build");
6
6
  const listFlags = deps.resolveListFlags(rawArgs, "build");
@@ -68,6 +68,7 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
68
68
  const crash = persistCrashRecord(config.crashDir, {
69
69
  kind: "fuzz",
70
70
  file,
71
+ entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
71
72
  mode: modeName ?? "default",
72
73
  seed: config.seed,
73
74
  error: crashMessage,
@@ -94,6 +95,7 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
94
95
  const crash = persistCrashRecord(config.crashDir, {
95
96
  kind: "fuzz",
96
97
  file,
98
+ entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
97
99
  mode: modeName ?? "default",
98
100
  seed: config.seed,
99
101
  error: `missing fuzz report payload from ${path.basename(file)}`,
@@ -115,13 +117,37 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
115
117
  fuzzers: [],
116
118
  };
117
119
  }
120
+ const crashFiles = [];
121
+ for (const fuzzer of report.fuzzers) {
122
+ if (fuzzer.failed <= 0 && fuzzer.crashed <= 0)
123
+ continue;
124
+ const firstFailureSeed = typeof fuzzer.failures?.[0]?.seed == "number"
125
+ ? fuzzer.failures[0].seed
126
+ : config.seed;
127
+ const crash = persistCrashRecord(config.crashDir, {
128
+ kind: "fuzz",
129
+ file,
130
+ entryKey: buildFuzzFailureEntryKey(file, fuzzer.name, modeName ?? "default"),
131
+ mode: modeName ?? "default",
132
+ seed: firstFailureSeed,
133
+ reproCommand: buildFuzzReproCommand(file, firstFailureSeed, modeName ?? "default", 1),
134
+ error: fuzzer.failure?.message ||
135
+ `fuzz failure in ${fuzzer.name} after ${fuzzer.runs} runs`,
136
+ stdout: passthrough.stdout,
137
+ stderr: "",
138
+ failure: fuzzer.failure,
139
+ failures: fuzzer.failures,
140
+ });
141
+ crashFiles.push(crash.jsonPath);
142
+ fuzzer.crashFile = crash.jsonPath;
143
+ }
118
144
  return {
119
145
  file,
120
146
  target: path.basename(file),
121
147
  modeName: modeName ?? "default",
122
148
  runs: report.fuzzers.reduce((sum, item) => sum + item.runs, 0),
123
149
  crashes: report.fuzzers.reduce((sum, item) => sum + item.crashed, 0),
124
- crashFiles: [],
150
+ crashFiles,
125
151
  seed: config.seed,
126
152
  time: Date.now() - startedAt,
127
153
  buildTime,
@@ -130,6 +156,23 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
130
156
  fuzzers: report.fuzzers,
131
157
  };
132
158
  }
159
+ function buildFuzzReproCommand(file, seed, modeName, runs) {
160
+ const modeArg = modeName != "default" ? ` --mode ${modeName}` : "";
161
+ const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
162
+ return `ast fuzz ${file}${modeArg} --seed ${seed}${runsArg}`;
163
+ }
164
+ function buildFuzzFailureEntryKey(file, name, modeName) {
165
+ return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}.${sanitizeEntryName(name)}`;
166
+ }
167
+ function buildFuzzCrashEntryKey(file, modeName) {
168
+ return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}`;
169
+ }
170
+ function sanitizeEntryName(name) {
171
+ return name
172
+ .toLowerCase()
173
+ .replace(/[^a-z0-9]+/g, "-")
174
+ .replace(/^-+|-+$/g, "") || "fuzzer";
175
+ }
133
176
  function captureFrames(onFrame) {
134
177
  const originalWrite = process.stdout.write.bind(process.stdout);
135
178
  const originalRead = typeof process.stdin.read == "function"
@@ -1286,26 +1286,22 @@ async function runProcess(invocation, specFile, crashDir, modeName, snapshots, s
1286
1286
  let report = null;
1287
1287
  let parseError = null;
1288
1288
  let stderrBuffer = "";
1289
+ let stderrPendingLine = "";
1289
1290
  let stdoutBuffer = "";
1290
- let suppressTraceWarningLine = false;
1291
1291
  let spawnError = null;
1292
1292
  child.on("error", (error) => {
1293
1293
  spawnError = error;
1294
1294
  });
1295
1295
  child.stderr.on("data", (chunk) => {
1296
- stderrBuffer += chunk.toString("utf8");
1297
- let newline = stderrBuffer.indexOf("\n");
1296
+ stderrPendingLine += chunk.toString("utf8");
1297
+ let newline = stderrPendingLine.indexOf("\n");
1298
1298
  while (newline >= 0) {
1299
- const line = stderrBuffer.slice(0, newline + 1);
1300
- stderrBuffer = stderrBuffer.slice(newline + 1);
1301
- if (shouldSuppressWasiWarningLine(line, suppressTraceWarningLine)) {
1302
- suppressTraceWarningLine = true;
1299
+ const line = stderrPendingLine.slice(0, newline + 1);
1300
+ stderrPendingLine = stderrPendingLine.slice(newline + 1);
1301
+ if (!shouldSuppressWasiWarningLine(line)) {
1302
+ stderrBuffer += line;
1303
1303
  }
1304
- else {
1305
- suppressTraceWarningLine = false;
1306
- process.stderr.write(line);
1307
- }
1308
- newline = stderrBuffer.indexOf("\n");
1304
+ newline = stderrPendingLine.indexOf("\n");
1309
1305
  }
1310
1306
  });
1311
1307
  class TestChannel extends Channel {
@@ -1409,55 +1405,138 @@ async function runProcess(invocation, specFile, crashDir, modeName, snapshots, s
1409
1405
  const code = await new Promise((resolve) => {
1410
1406
  child.on("close", (exitCode) => resolve(exitCode ?? 1));
1411
1407
  });
1412
- if (stderrBuffer.length) {
1413
- if (!shouldSuppressWasiWarningLine(stderrBuffer, suppressTraceWarningLine)) {
1414
- process.stderr.write(stderrBuffer);
1415
- }
1408
+ if (stderrPendingLine.length && !shouldSuppressWasiWarningLine(stderrPendingLine)) {
1409
+ stderrBuffer += stderrPendingLine;
1416
1410
  }
1417
1411
  if (spawnError) {
1412
+ const errorText = spawnError.stack ?? spawnError.message;
1418
1413
  persistCrashRecord(crashDir, {
1419
1414
  kind: "test",
1420
1415
  file: specFile,
1421
1416
  mode: modeName ?? "default",
1422
- error: spawnError.stack ?? spawnError.message,
1417
+ error: errorText,
1423
1418
  stdout: stdoutBuffer,
1424
1419
  stderr: stderrBuffer,
1425
1420
  });
1426
- throw spawnError;
1421
+ return createRuntimeFailureReport(specFile, modeName, "failed to start test runtime", errorText, stdoutBuffer, stderrBuffer);
1427
1422
  }
1428
1423
  if (parseError) {
1424
+ const errorText = `could not parse report payload: ${parseError}`;
1429
1425
  persistCrashRecord(crashDir, {
1430
1426
  kind: "test",
1431
1427
  file: specFile,
1432
1428
  mode: modeName ?? "default",
1433
- error: `could not parse report payload: ${parseError}`,
1429
+ error: errorText,
1434
1430
  stdout: stdoutBuffer,
1435
1431
  stderr: stderrBuffer,
1436
1432
  });
1437
- throw new Error(`could not parse report payload: ${parseError}`);
1433
+ return createRuntimeFailureReport(specFile, modeName, "runtime returned an invalid report payload", errorText, stdoutBuffer, stderrBuffer);
1438
1434
  }
1439
1435
  if (!report) {
1436
+ const errorText = "missing report payload from test runtime";
1440
1437
  persistCrashRecord(crashDir, {
1441
1438
  kind: "test",
1442
1439
  file: specFile,
1443
1440
  mode: modeName ?? "default",
1444
- error: "missing report payload from test runtime",
1441
+ error: errorText,
1445
1442
  stdout: stdoutBuffer,
1446
1443
  stderr: stderrBuffer,
1447
1444
  });
1448
- throw new Error("missing report payload from test runtime");
1445
+ return createRuntimeFailureReport(specFile, modeName, "test runtime exited without sending a report", errorText, stdoutBuffer, stderrBuffer);
1449
1446
  }
1450
- if (code !== 0) {
1451
- // Let report determine failure counts, but keep non-zero child exits visible.
1452
- process.stderr.write(chalk.dim(`child process exited with code ${code}\n`));
1447
+ if (code !== 0 || hasMeaningfulRuntimeOutput(stderrBuffer)) {
1448
+ const errorParts = [];
1449
+ if (code !== 0) {
1450
+ errorParts.push(`child process exited with code ${code}`);
1451
+ }
1452
+ const stderrText = normalizeRuntimeOutput(stderrBuffer);
1453
+ if (stderrText.length) {
1454
+ errorParts.push(stderrText);
1455
+ }
1456
+ const errorText = errorParts.join("\n\n");
1457
+ persistCrashRecord(crashDir, {
1458
+ kind: "test",
1459
+ file: specFile,
1460
+ mode: modeName ?? "default",
1461
+ error: errorText || "runtime reported an unknown error",
1462
+ stdout: stdoutBuffer,
1463
+ stderr: stderrBuffer,
1464
+ });
1465
+ return appendRuntimeFailureReport(report, specFile, modeName, code !== 0
1466
+ ? `test runtime failed with exit code ${code}`
1467
+ : "test runtime wrote to stderr", errorText, stdoutBuffer, stderrBuffer);
1453
1468
  }
1454
1469
  return report;
1455
1470
  }
1456
- function shouldSuppressWasiWarningLine(line, suppressTraceWarningLine) {
1471
+ function createRuntimeFailureReport(specFile, modeName, title, details, stdout, stderr) {
1472
+ return appendRuntimeFailureReport({
1473
+ suites: [],
1474
+ coverage: {
1475
+ total: 0,
1476
+ covered: 0,
1477
+ uncovered: 0,
1478
+ percent: 100,
1479
+ points: [],
1480
+ },
1481
+ }, specFile, modeName, title, details, stdout, stderr);
1482
+ }
1483
+ function appendRuntimeFailureReport(report, specFile, modeName, title, details, stdout, stderr) {
1484
+ const suites = Array.isArray(report?.suites) ? report.suites : [];
1485
+ suites.push({
1486
+ file: specFile,
1487
+ description: path.basename(specFile),
1488
+ depth: 0,
1489
+ kind: "runtime-error",
1490
+ verdict: "fail",
1491
+ time: {
1492
+ start: 0,
1493
+ end: 0,
1494
+ },
1495
+ suites: [],
1496
+ logs: [],
1497
+ tests: [
1498
+ {
1499
+ order: 0,
1500
+ type: "runtime-error",
1501
+ verdict: "fail",
1502
+ left: null,
1503
+ right: null,
1504
+ instr: title,
1505
+ message: formatRuntimeFailureMessage(details, stdout, stderr),
1506
+ location: "",
1507
+ },
1508
+ ],
1509
+ modeName: modeName ?? "default",
1510
+ });
1511
+ report.suites = suites;
1512
+ return report;
1513
+ }
1514
+ function formatRuntimeFailureMessage(details, stdout, stderr) {
1515
+ const parts = [];
1516
+ const normalizedDetails = normalizeRuntimeOutput(details);
1517
+ const normalizedStderr = normalizeRuntimeOutput(stderr);
1518
+ const normalizedStdout = normalizeRuntimeOutput(stdout);
1519
+ if (normalizedDetails.length)
1520
+ parts.push(normalizedDetails);
1521
+ if (normalizedStderr.length && normalizedStderr !== normalizedDetails) {
1522
+ parts.push(`stderr:\n${normalizedStderr}`);
1523
+ }
1524
+ if (normalizedStdout.length) {
1525
+ parts.push(`stdout:\n${normalizedStdout}`);
1526
+ }
1527
+ return parts.join("\n\n");
1528
+ }
1529
+ function normalizeRuntimeOutput(value) {
1530
+ return value.replace(/\r\n/g, "\n").trim();
1531
+ }
1532
+ function hasMeaningfulRuntimeOutput(value) {
1533
+ return normalizeRuntimeOutput(value).length > 0;
1534
+ }
1535
+ function shouldSuppressWasiWarningLine(line) {
1457
1536
  if (line.includes("ExperimentalWarning: WASI is an experimental feature")) {
1458
1537
  return true;
1459
1538
  }
1460
- if (suppressTraceWarningLine && line.includes("--trace-warnings")) {
1539
+ if (line.includes("--trace-warnings")) {
1461
1540
  return true;
1462
1541
  }
1463
1542
  return false;
@@ -1506,6 +1585,7 @@ function readFileReport(stats, fileReport) {
1506
1585
  }
1507
1586
  function readSuite(stats, suite, file, modeName) {
1508
1587
  const suiteAny = suite;
1588
+ const kind = String(suiteAny.kind ?? "");
1509
1589
  let verdict = normalizeVerdict(suiteAny.verdict);
1510
1590
  const time = suiteAny.time;
1511
1591
  const start = Number(time?.start ?? 0);
@@ -1533,6 +1613,20 @@ function readSuite(stats, suite, file, modeName) {
1533
1613
  stats.skippedTests++;
1534
1614
  }
1535
1615
  }
1616
+ if (isTestCaseSuiteKind(kind)) {
1617
+ if (!subSuites.length && !tests.length) {
1618
+ if (verdict == "fail") {
1619
+ stats.failedTests++;
1620
+ }
1621
+ else if (verdict == "ok") {
1622
+ stats.passedTests++;
1623
+ }
1624
+ else if (verdict == "skip") {
1625
+ stats.skippedTests++;
1626
+ }
1627
+ }
1628
+ return verdict;
1629
+ }
1536
1630
  if (verdict == "fail") {
1537
1631
  stats.failedSuites++;
1538
1632
  stats.failedEntries.push({
@@ -1549,6 +1643,15 @@ function readSuite(stats, suite, file, modeName) {
1549
1643
  }
1550
1644
  return verdict;
1551
1645
  }
1646
+ function isTestCaseSuiteKind(kind) {
1647
+ return (kind == "test" ||
1648
+ kind == "it" ||
1649
+ kind == "only" ||
1650
+ kind == "xtest" ||
1651
+ kind == "xit" ||
1652
+ kind == "xonly" ||
1653
+ kind == "todo");
1654
+ }
1552
1655
  function normalizeVerdict(value) {
1553
1656
  const verdict = String(value ?? "none");
1554
1657
  if (verdict == "fail")
@@ -13,6 +13,7 @@ export async function executeRunCommand(rawArgs, flags, configPath, selectedMode
13
13
  ...deps.resolveParallelJobs(rawArgs, "run"),
14
14
  coverage: featureToggles.coverage,
15
15
  browser: deps.resolveBrowserOverride(rawArgs, "run"),
16
+ reporterPath: deps.resolveReporterOverride(rawArgs, "run"),
16
17
  };
17
18
  const modeTargets = deps.resolveExecutionModes(configPath, selectedModes);
18
19
  if (listFlags.list || listFlags.listModes) {
@@ -16,6 +16,7 @@ export async function executeTestCommand(rawArgs, flags, configPath, selectedMod
16
16
  ...deps.resolveParallelJobs(rawArgs, "test"),
17
17
  coverage: featureToggles.coverage,
18
18
  browser: deps.resolveBrowserOverride(rawArgs, "test"),
19
+ reporterPath: deps.resolveReporterOverride(rawArgs, "test"),
19
20
  };
20
21
  const fuzzEnabled = flags.includes("--fuzz");
21
22
  const fuzzOverrides = deps.resolveFuzzOverrides(rawArgs, "test");
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
2
  import * as path from "path";
3
3
  export function persistCrashRecord(rootDir, record) {
4
- const entry = crashEntryKey(record.file);
4
+ const entry = record.entryKey?.length ? record.entryKey : crashEntryKey(record.file);
5
5
  const dir = path.resolve(process.cwd(), rootDir);
6
6
  mkdirSync(dir, { recursive: true });
7
7
  const jsonPath = path.join(dir, `${entry}.json`);
@@ -53,6 +53,17 @@ function buildCrashLog(payload) {
53
53
  lines.push(`message: ${payload.failure.message}`);
54
54
  }
55
55
  }
56
+ if (payload.failures?.length) {
57
+ lines.push("");
58
+ lines.push("[fuzz-failures]");
59
+ for (const failure of payload.failures) {
60
+ lines.push(`run: ${failure.run}`);
61
+ lines.push(`seed: ${failure.seed}`);
62
+ if (failure.input)
63
+ lines.push(`input: ${JSON.stringify(failure.input)}`);
64
+ lines.push("");
65
+ }
66
+ }
56
67
  lines.push("");
57
68
  lines.push("[stdout]");
58
69
  lines.push(payload.stdout ?? "");
package/bin/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import chalk from "chalk";
3
- import { build, formatInvocation as formatBuildInvocation, getBuildInvocationPreview, } from "./commands/build.js";
3
+ import { build, formatInvocation as formatBuildInvocation, getBuildInvocationPreview, getBuildReuseInfo, } from "./commands/build.js";
4
4
  import { createRunReporter, run } from "./commands/run.js";
5
5
  import { executeBuildCommand } from "./commands/build.js";
6
6
  import { executeRunCommand } from "./commands/run.js";
@@ -14,7 +14,7 @@ import * as path from "path";
14
14
  import { spawnSync } from "child_process";
15
15
  import { glob } from "glob";
16
16
  import { createInterface } from "readline";
17
- import { existsSync } from "fs";
17
+ import { copyFileSync, existsSync, mkdirSync } from "fs";
18
18
  import { availableParallelism, cpus } from "os";
19
19
  import { BuildWorkerPool } from "./build-worker-pool.js";
20
20
  const _args = process.argv.slice(2);
@@ -66,6 +66,7 @@ else if (COMMANDS.includes(args[0])) {
66
66
  resolveFeatureToggles,
67
67
  resolveParallelJobs,
68
68
  resolveBrowserOverride,
69
+ resolveReporterOverride,
69
70
  resolveExecutionModes,
70
71
  listExecutionPlan,
71
72
  runRuntimeModes,
@@ -81,6 +82,7 @@ else if (COMMANDS.includes(args[0])) {
81
82
  resolveFeatureToggles,
82
83
  resolveParallelJobs,
83
84
  resolveBrowserOverride,
85
+ resolveReporterOverride,
84
86
  resolveFuzzOverrides,
85
87
  resolveExecutionModes,
86
88
  listExecutionPlan,
@@ -400,7 +402,6 @@ function resolveCommandArgs(rawArgs, command) {
400
402
  }
401
403
  if (arg == "--runs" ||
402
404
  arg == "--seed" ||
403
- arg == "--parallel" ||
404
405
  arg == "--jobs" ||
405
406
  arg == "--build-jobs" ||
406
407
  arg == "--run-jobs" ||
@@ -410,6 +411,9 @@ function resolveCommandArgs(rawArgs, command) {
410
411
  i++;
411
412
  continue;
412
413
  }
414
+ if (arg == "--parallel") {
415
+ continue;
416
+ }
413
417
  if (arg.startsWith("--runs=") ||
414
418
  arg.startsWith("--seed=") ||
415
419
  arg.startsWith("--jobs=") ||
@@ -577,6 +581,32 @@ function resolveBrowserOverride(rawArgs, command) {
577
581
  }
578
582
  return undefined;
579
583
  }
584
+ function resolveReporterOverride(rawArgs, command) {
585
+ let seenCommand = false;
586
+ for (let i = 0; i < rawArgs.length; i++) {
587
+ const arg = rawArgs[i];
588
+ if (!seenCommand) {
589
+ if (arg == command)
590
+ seenCommand = true;
591
+ continue;
592
+ }
593
+ if (arg == "--reporter") {
594
+ const next = rawArgs[i + 1];
595
+ if (next && !next.startsWith("-")) {
596
+ return next;
597
+ }
598
+ return undefined;
599
+ }
600
+ if (arg.startsWith("--reporter=")) {
601
+ const value = arg.slice("--reporter=".length);
602
+ return value.length ? value : undefined;
603
+ }
604
+ if (arg == "--tap") {
605
+ return "tap";
606
+ }
607
+ }
608
+ return undefined;
609
+ }
580
610
  function resolveJobs(rawArgs, command) {
581
611
  let seenCommand = false;
582
612
  let parallel = false;
@@ -731,9 +761,9 @@ function createBufferedStream() {
731
761
  },
732
762
  };
733
763
  }
734
- async function createBufferedReporter(configPath, modeName) {
764
+ async function createBufferedReporter(configPath, reporterPath, modeName) {
735
765
  const stream = createBufferedStream();
736
- const session = await createRunReporter(configPath, undefined, modeName, {
766
+ const session = await createRunReporter(configPath, reporterPath, modeName, {
737
767
  stdout: stream,
738
768
  stderr: stream,
739
769
  });
@@ -882,6 +912,32 @@ function resolveCommandTokens(rawArgs, command) {
882
912
  }
883
913
  return values;
884
914
  }
915
+ async function buildFileForMode(cache, args) {
916
+ const reuse = await getBuildReuseInfo(args.configPath, args.file, args.modeName, args.buildFeatureToggles);
917
+ if (reuse) {
918
+ const source = cache.get(reuse.signature);
919
+ if (source && source != reuse.outFile && existsSync(source)) {
920
+ mkdirSync(path.dirname(reuse.outFile), { recursive: true });
921
+ copyFileSync(source, reuse.outFile);
922
+ return true;
923
+ }
924
+ }
925
+ if (args.buildPool) {
926
+ await args.buildPool.buildFileMode({
927
+ configPath: args.configPath,
928
+ file: args.file,
929
+ modeName: args.modeName,
930
+ featureToggles: args.buildFeatureToggles,
931
+ });
932
+ }
933
+ else {
934
+ await build(args.configPath, [args.file], args.modeName, args.buildFeatureToggles);
935
+ }
936
+ if (reuse) {
937
+ cache.set(reuse.signature, reuse.outFile);
938
+ }
939
+ return false;
940
+ }
885
941
  async function runTestSequential(runFlags, configPath, selectors, buildFeatureToggles, modeSummaryTotal, fileSummaryTotal, allowNoSpecFiles = false, modeName, reporterOverride, emitRunComplete = true) {
886
942
  const files = await resolveSelectedFiles(configPath, selectors);
887
943
  if (!files.length) {
@@ -889,7 +945,7 @@ async function runTestSequential(runFlags, configPath, selectors, buildFeatureTo
889
945
  throw await buildNoTestFilesMatchedError(configPath, selectors);
890
946
  }
891
947
  }
892
- const reporterSession = await createRunReporter(configPath, undefined, modeName);
948
+ const reporterSession = await createRunReporter(configPath, runFlags.reporterPath, modeName);
893
949
  const reporter = reporterOverride ?? reporterSession.reporter;
894
950
  const snapshotEnabled = runFlags.snapshot !== false;
895
951
  reporter.onRunStart?.({
@@ -963,17 +1019,19 @@ async function runBuildModes(configPath, selectors, modes, buildFeatureToggles,
963
1019
  const loadedConfig = loadConfig(resolvedConfigPath, true);
964
1020
  const allStartedAt = Date.now();
965
1021
  let builtCount = 0;
1022
+ const buildReuseCache = new Map();
966
1023
  for (const modeName of modes) {
967
1024
  const startedAt = Date.now();
968
1025
  if (effective.buildJobs > 1) {
969
1026
  const pool = new BuildWorkerPool(effective.buildJobs);
970
1027
  try {
971
1028
  await runOrderedPool(files, effective.buildJobs, async (file) => {
972
- await pool.buildFileMode({
1029
+ await buildFileForMode(buildReuseCache, {
973
1030
  configPath,
974
1031
  file,
975
1032
  modeName,
976
- featureToggles: buildFeatureToggles,
1033
+ buildFeatureToggles,
1034
+ buildPool: pool,
977
1035
  });
978
1036
  });
979
1037
  }
@@ -982,7 +1040,14 @@ async function runBuildModes(configPath, selectors, modes, buildFeatureToggles,
982
1040
  }
983
1041
  }
984
1042
  else {
985
- await build(configPath, selectors, modeName, buildFeatureToggles);
1043
+ for (const file of files) {
1044
+ await buildFileForMode(buildReuseCache, {
1045
+ configPath,
1046
+ file,
1047
+ modeName,
1048
+ buildFeatureToggles,
1049
+ });
1050
+ }
986
1051
  }
987
1052
  builtCount += files.length;
988
1053
  const active = applyMode(loadedConfig, modeName).config;
@@ -1022,6 +1087,7 @@ async function runRuntimeModes(runFlags, configPath, selectors, modes) {
1022
1087
  const buildCommandsByFile = await previewBuildCommands(configPath, selectors, modes[0], {});
1023
1088
  for (const modeName of modes) {
1024
1089
  const result = await run(effectiveRunFlags, configPath, selectors, false, {
1090
+ reporterPath: effectiveRunFlags.reporterPath,
1025
1091
  modeName,
1026
1092
  modeSummaryTotal,
1027
1093
  modeSummaryExecuted: 1,
@@ -1063,11 +1129,13 @@ async function runRuntimeMatrix(runFlags, configPath, selectors, modes, modeSumm
1063
1129
  }));
1064
1130
  const duplicateSpecBasenames = resolveDuplicateSpecBasenames(files);
1065
1131
  const buildIntervals = [];
1132
+ const buildReuseCache = new Map();
1066
1133
  for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
1067
1134
  const file = files[fileIndex];
1068
1135
  const fileName = path.basename(file);
1069
1136
  const fileResults = [];
1070
1137
  const modeTimes = modes.map(() => "...");
1138
+ const buildReuseCache = new Map();
1071
1139
  if (liveMatrix) {
1072
1140
  renderMatrixLiveLine(fileName, modeLabels, modeTimes, showPerModeTimes);
1073
1141
  }
@@ -1104,7 +1172,9 @@ async function runRuntimeMatrix(runFlags, configPath, selectors, modes, modeSumm
1104
1172
  throw error;
1105
1173
  }
1106
1174
  }
1107
- renderMatrixFileResult(fileName, modeLabels, fileResults, modeTimes, liveMatrix, showPerModeTimes);
1175
+ if (reporterSession.reporterKind == "default") {
1176
+ renderMatrixFileResult(fileName, modeLabels, fileResults, modeTimes, liveMatrix, showPerModeTimes);
1177
+ }
1108
1178
  const verdict = resolveMatrixVerdict(fileResults);
1109
1179
  if (verdict == "fail") {
1110
1180
  fileState[fileIndex].failed = true;
@@ -1158,7 +1228,7 @@ async function runTestModes(runFlags, configPath, selectors, modes, buildFeature
1158
1228
  }
1159
1229
  let failed = false;
1160
1230
  for (const modeName of modes) {
1161
- const reporterSession = await createRunReporter(configPath, undefined, modeName);
1231
+ const reporterSession = await createRunReporter(configPath, effectiveRunFlags.reporterPath, modeName);
1162
1232
  const modeResult = await runTestSequential(effectiveRunFlags, configPath, selectors, buildFeatureToggles, modeSummaryTotal, fileSummaryTotal, fuzzEnabled, modeName, reporterSession.reporter, !fuzzEnabled);
1163
1233
  if (modeResult.failed)
1164
1234
  failed = true;
@@ -1228,6 +1298,7 @@ async function runTestMatrix(runFlags, configPath, selectors, modes, buildFeatur
1228
1298
  const fileName = path.basename(file);
1229
1299
  const fileResults = [];
1230
1300
  const modeTimes = modes.map(() => "...");
1301
+ const buildReuseCache = new Map();
1231
1302
  if (liveMatrix) {
1232
1303
  renderMatrixLiveLine(fileName, modeLabels, modeTimes, showPerModeTimes);
1233
1304
  }
@@ -1235,7 +1306,12 @@ async function runTestMatrix(runFlags, configPath, selectors, modes, buildFeatur
1235
1306
  const modeName = modes[i];
1236
1307
  try {
1237
1308
  const buildStartedAt = Date.now();
1238
- await build(configPath, [file], modeName, buildFeatureToggles);
1309
+ await buildFileForMode(buildReuseCache, {
1310
+ configPath,
1311
+ file,
1312
+ modeName,
1313
+ buildFeatureToggles,
1314
+ });
1239
1315
  buildIntervals.push({ start: buildStartedAt, end: Date.now() });
1240
1316
  const buildInvocation = await getBuildInvocationPreview(configPath, file, modeName, buildFeatureToggles);
1241
1317
  const artifactKey = resolvePerFileArtifactKey(file, duplicateSpecBasenames);
@@ -1267,7 +1343,9 @@ async function runTestMatrix(runFlags, configPath, selectors, modes, buildFeatur
1267
1343
  throw error;
1268
1344
  }
1269
1345
  }
1270
- renderMatrixFileResult(fileName, modeLabels, fileResults, modeTimes, liveMatrix, showPerModeTimes);
1346
+ if (reporterSession.reporterKind == "default") {
1347
+ renderMatrixFileResult(fileName, modeLabels, fileResults, modeTimes, liveMatrix, showPerModeTimes);
1348
+ }
1271
1349
  const verdict = resolveMatrixVerdict(fileResults);
1272
1350
  if (verdict == "fail") {
1273
1351
  fileState[fileIndex].failed = true;
@@ -1330,7 +1408,7 @@ async function runRuntimeSingleParallel(runFlags, configPath, selectors, modeNam
1330
1408
  if (!files.length) {
1331
1409
  throw await buildNoTestFilesMatchedError(configPath, selectors);
1332
1410
  }
1333
- const reporterSession = await createRunReporter(configPath, undefined, modeName);
1411
+ const reporterSession = await createRunReporter(configPath, runFlags.reporterPath, modeName);
1334
1412
  const reporter = reporterSession.reporter;
1335
1413
  const snapshotEnabled = runFlags.snapshot !== false;
1336
1414
  reporter.onRunStart?.({
@@ -1342,15 +1420,20 @@ async function runRuntimeSingleParallel(runFlags, configPath, selectors, modeNam
1342
1420
  });
1343
1421
  const buildCommandsByFile = await previewBuildCommands(configPath, selectors, modeName, {});
1344
1422
  const results = new Array(files.length);
1345
- const queueDisplay = new ParallelQueueDisplay(!runFlags.clean);
1423
+ const useQueueDisplay = reporterSession.reporterKind == "default";
1424
+ const queueDisplay = new ParallelQueueDisplay(useQueueDisplay && !runFlags.clean);
1346
1425
  const runLimit = createAsyncLimiter(runFlags.runJobs);
1347
1426
  const poolWidth = Math.max(runFlags.buildJobs, runFlags.runJobs);
1348
1427
  await runOrderedPool(files, poolWidth, async (file, index) => {
1349
- const token = renderQueuedFileStart(queueDisplay, path.basename(file));
1350
- const buffered = await createBufferedReporter(configPath, modeName);
1428
+ const token = useQueueDisplay
1429
+ ? renderQueuedFileStart(queueDisplay, path.basename(file))
1430
+ : null;
1431
+ const buffered = useQueueDisplay
1432
+ ? await createBufferedReporter(configPath, runFlags.reporterPath, modeName)
1433
+ : null;
1351
1434
  const result = await runLimit(() => run({ ...runFlags, clean: true }, configPath, [file], false, {
1352
- reporter: buffered.reporter,
1353
- reporterKind: buffered.reporterKind,
1435
+ reporter: buffered?.reporter,
1436
+ reporterKind: buffered?.reporterKind,
1354
1437
  modeName,
1355
1438
  emitRunComplete: false,
1356
1439
  fileSummaryTotal: 1,
@@ -1358,9 +1441,11 @@ async function runRuntimeSingleParallel(runFlags, configPath, selectors, modeNam
1358
1441
  modeSummaryExecuted: 1,
1359
1442
  buildCommandsByFile: { [file]: buildCommandsByFile[file] ?? "" },
1360
1443
  }));
1361
- buffered.reporter.flush?.();
1444
+ buffered?.reporter.flush?.();
1362
1445
  results[index] = result;
1363
- queueDisplay.complete(token, buffered.output());
1446
+ if (buffered && token != null) {
1447
+ queueDisplay.complete(token, buffered.output());
1448
+ }
1364
1449
  });
1365
1450
  queueDisplay.flush();
1366
1451
  const summary = aggregateRunResults(results);
@@ -1384,7 +1469,7 @@ async function runRuntimeMatrixParallel(runFlags, configPath, selectors, modes,
1384
1469
  if (!files.length) {
1385
1470
  throw await buildNoTestFilesMatchedError(configPath, selectors);
1386
1471
  }
1387
- const reporterSession = await createRunReporter(configPath);
1472
+ const reporterSession = await createRunReporter(configPath, runFlags.reporterPath);
1388
1473
  const reporter = reporterSession.reporter;
1389
1474
  const snapshotEnabled = runFlags.snapshot !== false;
1390
1475
  reporter.onRunStart?.({
@@ -1399,23 +1484,29 @@ async function runRuntimeMatrixParallel(runFlags, configPath, selectors, modes,
1399
1484
  const showPerModeTimes = Boolean(runFlags.verbose);
1400
1485
  const duplicateSpecBasenames = resolveDuplicateSpecBasenames(files);
1401
1486
  const ordered = new Array(files.length);
1402
- const queueDisplay = new ParallelQueueDisplay(!runFlags.clean);
1487
+ const useQueueDisplay = reporterSession.reporterKind == "default";
1488
+ const queueDisplay = new ParallelQueueDisplay(useQueueDisplay && !runFlags.clean);
1403
1489
  const poolWidth = Math.max(runFlags.jobs, runFlags.buildJobs, runFlags.runJobs);
1404
1490
  const buildPool = new BuildWorkerPool(runFlags.buildJobs);
1405
1491
  const buildIntervals = [];
1406
1492
  try {
1407
1493
  await runOrderedPool(files, poolWidth, async (file, fileIndex) => {
1408
1494
  const fileName = path.basename(file);
1409
- const token = renderQueuedFileStart(queueDisplay, fileName);
1495
+ const token = useQueueDisplay
1496
+ ? renderQueuedFileStart(queueDisplay, fileName)
1497
+ : null;
1410
1498
  const fileResults = [];
1411
1499
  const modeTimes = modes.map(() => "...");
1500
+ const buildReuseCache = new Map();
1412
1501
  for (let i = 0; i < modes.length; i++) {
1413
1502
  const modeName = modes[i];
1414
1503
  const buildStartedAt = Date.now();
1415
- await buildPool.buildFileMode({
1504
+ await buildFileForMode(buildReuseCache, {
1416
1505
  configPath,
1417
1506
  file,
1418
1507
  modeName,
1508
+ buildFeatureToggles: {},
1509
+ buildPool,
1419
1510
  });
1420
1511
  buildIntervals.push({ start: buildStartedAt, end: Date.now() });
1421
1512
  const buildInvocation = await getBuildInvocationPreview(configPath, file, modeName, {});
@@ -1434,7 +1525,9 @@ async function runRuntimeMatrixParallel(runFlags, configPath, selectors, modes,
1434
1525
  fileResults.push(result);
1435
1526
  }
1436
1527
  ordered[fileIndex] = { fileName, fileResults, modeTimes };
1437
- queueDisplay.complete(token, formatMatrixFileResultLine(fileName, modeLabels, fileResults, modeTimes, showPerModeTimes) + "\n");
1528
+ if (token != null) {
1529
+ queueDisplay.complete(token, formatMatrixFileResultLine(fileName, modeLabels, fileResults, modeTimes, showPerModeTimes) + "\n");
1530
+ }
1438
1531
  });
1439
1532
  }
1440
1533
  finally {
@@ -1481,7 +1574,7 @@ async function runTestSingleParallel(runFlags, configPath, selectors, buildFeatu
1481
1574
  if (!files.length && !fuzzEnabled) {
1482
1575
  throw await buildNoTestFilesMatchedError(configPath, selectors);
1483
1576
  }
1484
- const reporterSession = await createRunReporter(configPath, undefined, modeName);
1577
+ const reporterSession = await createRunReporter(configPath, runFlags.reporterPath, modeName);
1485
1578
  const reporter = reporterSession.reporter;
1486
1579
  const snapshotEnabled = runFlags.snapshot !== false;
1487
1580
  reporter.onRunStart?.({
@@ -1493,37 +1586,45 @@ async function runTestSingleParallel(runFlags, configPath, selectors, buildFeatu
1493
1586
  });
1494
1587
  const duplicateSpecBasenames = resolveDuplicateSpecBasenames(files);
1495
1588
  const results = new Array(files.length);
1496
- const queueDisplay = new ParallelQueueDisplay(!runFlags.clean);
1589
+ const useQueueDisplay = reporterSession.reporterKind == "default";
1590
+ const queueDisplay = new ParallelQueueDisplay(useQueueDisplay && !runFlags.clean);
1497
1591
  const poolWidth = Math.max(runFlags.jobs, runFlags.buildJobs, runFlags.runJobs);
1498
1592
  const buildIntervals = [];
1499
1593
  if (files.length) {
1500
1594
  const buildPool = new BuildWorkerPool(runFlags.buildJobs);
1501
1595
  try {
1502
1596
  await runOrderedPool(files, poolWidth, async (file, index) => {
1503
- const token = renderQueuedFileStart(queueDisplay, path.basename(file));
1597
+ const token = useQueueDisplay
1598
+ ? renderQueuedFileStart(queueDisplay, path.basename(file))
1599
+ : null;
1504
1600
  const buildStartedAt = Date.now();
1505
- await buildPool.buildFileMode({
1601
+ await buildFileForMode(new Map(), {
1506
1602
  configPath,
1507
1603
  file,
1508
1604
  modeName,
1509
- featureToggles: buildFeatureToggles,
1605
+ buildFeatureToggles,
1606
+ buildPool,
1510
1607
  });
1511
1608
  buildIntervals.push({ start: buildStartedAt, end: Date.now() });
1512
1609
  const buildInvocation = await getBuildInvocationPreview(configPath, file, modeName, buildFeatureToggles);
1513
1610
  const artifactKey = resolvePerFileArtifactKey(file, duplicateSpecBasenames);
1514
- const buffered = await createBufferedReporter(configPath, modeName);
1611
+ const buffered = useQueueDisplay
1612
+ ? await createBufferedReporter(configPath, runFlags.reporterPath, modeName)
1613
+ : null;
1515
1614
  const result = await run({ ...runFlags, clean: true }, configPath, [file], false, {
1516
- reporter: buffered.reporter,
1517
- reporterKind: buffered.reporterKind,
1615
+ reporter: buffered?.reporter,
1616
+ reporterKind: buffered?.reporterKind,
1518
1617
  emitRunComplete: false,
1519
1618
  logFileName: `test.${artifactKey}.log.json`,
1520
1619
  coverageFileName: `coverage.${artifactKey}.log.json`,
1521
1620
  buildCommand: formatBuildInvocation(buildInvocation),
1522
1621
  modeName,
1523
1622
  });
1524
- buffered.reporter.flush?.();
1623
+ buffered?.reporter.flush?.();
1525
1624
  results[index] = result;
1526
- queueDisplay.complete(token, buffered.output());
1625
+ if (buffered && token != null) {
1626
+ queueDisplay.complete(token, buffered.output());
1627
+ }
1527
1628
  });
1528
1629
  }
1529
1630
  finally {
@@ -1572,7 +1673,7 @@ async function runTestMatrixParallel(runFlags, configPath, selectors, modes, bui
1572
1673
  throw await buildNoTestFilesMatchedError(configPath, selectors, true);
1573
1674
  }
1574
1675
  }
1575
- const reporterSession = await createRunReporter(configPath);
1676
+ const reporterSession = await createRunReporter(configPath, runFlags.reporterPath);
1576
1677
  const reporter = reporterSession.reporter;
1577
1678
  const snapshotEnabled = runFlags.snapshot !== false;
1578
1679
  reporter.onRunStart?.({
@@ -1587,24 +1688,29 @@ async function runTestMatrixParallel(runFlags, configPath, selectors, modes, bui
1587
1688
  const showPerModeTimes = Boolean(runFlags.verbose);
1588
1689
  const duplicateSpecBasenames = resolveDuplicateSpecBasenames(files);
1589
1690
  const ordered = new Array(files.length);
1590
- const queueDisplay = new ParallelQueueDisplay(!runFlags.clean);
1691
+ const useQueueDisplay = reporterSession.reporterKind == "default";
1692
+ const queueDisplay = new ParallelQueueDisplay(useQueueDisplay && !runFlags.clean);
1591
1693
  const poolWidth = Math.max(runFlags.jobs, runFlags.buildJobs, runFlags.runJobs);
1592
1694
  const buildPool = new BuildWorkerPool(runFlags.buildJobs);
1593
1695
  const buildIntervals = [];
1594
1696
  try {
1595
1697
  await runOrderedPool(files, poolWidth, async (file, fileIndex) => {
1596
1698
  const fileName = path.basename(file);
1597
- const token = renderQueuedFileStart(queueDisplay, fileName);
1699
+ const token = useQueueDisplay
1700
+ ? renderQueuedFileStart(queueDisplay, fileName)
1701
+ : null;
1598
1702
  const fileResults = [];
1599
1703
  const modeTimes = modes.map(() => "...");
1704
+ const buildReuseCache = new Map();
1600
1705
  for (let i = 0; i < modes.length; i++) {
1601
1706
  const modeName = modes[i];
1602
1707
  const buildStartedAt = Date.now();
1603
- await buildPool.buildFileMode({
1708
+ await buildFileForMode(buildReuseCache, {
1604
1709
  configPath,
1605
1710
  file,
1606
1711
  modeName,
1607
- featureToggles: buildFeatureToggles,
1712
+ buildFeatureToggles,
1713
+ buildPool,
1608
1714
  });
1609
1715
  buildIntervals.push({ start: buildStartedAt, end: Date.now() });
1610
1716
  const buildInvocation = await getBuildInvocationPreview(configPath, file, modeName, buildFeatureToggles);
@@ -1623,7 +1729,9 @@ async function runTestMatrixParallel(runFlags, configPath, selectors, modes, bui
1623
1729
  fileResults.push(result);
1624
1730
  }
1625
1731
  ordered[fileIndex] = { fileName, fileResults, modeTimes };
1626
- queueDisplay.complete(token, formatMatrixFileResultLine(fileName, modeLabels, fileResults, modeTimes, showPerModeTimes) + "\n");
1732
+ if (token != null) {
1733
+ queueDisplay.complete(token, formatMatrixFileResultLine(fileName, modeLabels, fileResults, modeTimes, showPerModeTimes) + "\n");
1734
+ }
1627
1735
  });
1628
1736
  }
1629
1737
  finally {
@@ -360,7 +360,19 @@ function renderFailedFuzzers(results) {
360
360
  console.log(chalk.dim(`Runs: ${fuzzer.passed + fuzzer.failed + fuzzer.crashed} completed (${fuzzer.passed} passed, ${fuzzer.failed} failed, ${fuzzer.crashed} crashed)`));
361
361
  console.log(chalk.dim(`Repro: ${repro}`));
362
362
  console.log(chalk.dim(`Seed: ${modeResult.seed}`));
363
- if (modeResult.crashFiles.length) {
363
+ if (fuzzer.failures?.length) {
364
+ console.log(chalk.dim(`Failing seeds: ${formatFailingSeeds(fuzzer)}`));
365
+ for (const failure of fuzzer.failures) {
366
+ console.log(chalk.dim(`Repro ${failure.run + 1}: ${buildFuzzReproCommand(relativeFile, failure.seed, modeResult.modeName, 1)}`));
367
+ if (failure.input) {
368
+ console.log(chalk.dim(`Input ${failure.run + 1}: ${JSON.stringify(failure.input)}`));
369
+ }
370
+ }
371
+ }
372
+ if (fuzzer.crashFile?.length) {
373
+ console.log(chalk.dim(`Crash: ${fuzzer.crashFile}`));
374
+ }
375
+ else if (modeResult.crashFiles.length) {
364
376
  console.log(chalk.dim(`Crash: ${modeResult.crashFiles[0]}`));
365
377
  }
366
378
  console.log("");
@@ -392,9 +404,13 @@ function averageFuzzModeTime(results) {
392
404
  return 0;
393
405
  return results.reduce((sum, result) => sum + result.time, 0) / results.length;
394
406
  }
395
- function buildFuzzReproCommand(file, seed, modeName) {
407
+ function buildFuzzReproCommand(file, seed, modeName, runs) {
396
408
  const modeArg = modeName != "default" ? ` --mode ${modeName}` : "";
397
- return `ast fuzz ${file}${modeArg} --seed ${seed}`;
409
+ const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
410
+ return `ast fuzz ${file}${modeArg} --seed ${seed}${runsArg}`;
411
+ }
412
+ function formatFailingSeeds(fuzzer) {
413
+ return (fuzzer.failures ?? []).map((failure) => String(failure.seed)).join(", ");
398
414
  }
399
415
  function toRelativeResultPath(file) {
400
416
  const relative = path.relative(process.cwd(), path.resolve(process.cwd(), file));
@@ -465,12 +481,30 @@ function collectSuiteFailures(suite, file, path, printed) {
465
481
  const loc = String(test.location ?? "");
466
482
  const where = loc.length ? `${file}:${loc}` : file;
467
483
  const modeName = String(suiteAny.modeName ?? "");
468
- const dedupeKey = `${file}::${modeName}::${title}::${String(test.left)}::${String(test.right)}`;
484
+ const message = String(test.message ?? "");
485
+ const dedupeKey = `${file}::${modeName}::${title}::${String(test.left)}::${String(test.right)}::${message}`;
469
486
  if (printed.has(dedupeKey))
470
487
  continue;
471
488
  printed.add(dedupeKey);
472
489
  const left = JSON.stringify(test.left);
473
490
  const right = JSON.stringify(test.right);
491
+ if (left == "null" && right == "null") {
492
+ console.log(`${chalk.bgRed(" FAIL ")} ${chalk.dim(title)} ${chalk.dim("(" + where + ")")}`);
493
+ if (modeName.length) {
494
+ console.log(chalk.dim(`Mode: ${modeName}`));
495
+ }
496
+ const normalizedMessage = normalizeFailureMessage(message);
497
+ if (normalizedMessage.length) {
498
+ for (const line of normalizedMessage.split("\n")) {
499
+ console.log(chalk.dim(line));
500
+ }
501
+ }
502
+ else {
503
+ console.log(chalk.dim("runtime error"));
504
+ }
505
+ console.log("");
506
+ continue;
507
+ }
474
508
  const diffResult = diff(left, right);
475
509
  let expected = "";
476
510
  for (const res of diffResult.diff) {
@@ -506,6 +540,9 @@ function collectSuiteFailures(suite, file, path, printed) {
506
540
  collectSuiteFailures(sub, file, nextPath, printed);
507
541
  }
508
542
  }
543
+ function normalizeFailureMessage(message) {
544
+ return message.replace(/\r\n/g, "\n").trim();
545
+ }
509
546
  function renderSnapshotSummary(snapshotSummary, leadingGap = true) {
510
547
  if (leadingGap) {
511
548
  console.log("");
@@ -221,10 +221,13 @@ function buildFuzzerFailureMessage(result, fuzzer) {
221
221
  if (fuzzer.crashed > 0 || result.crashes > 0) {
222
222
  return buildFuzzMessage(result.runs, result.seed, result.crashFiles[0]);
223
223
  }
224
+ const failureSeeds = fuzzer.failures?.length
225
+ ? `, failing seeds ${fuzzer.failures.map((failure) => failure.seed).join(", ")}`
226
+ : "";
224
227
  if (fuzzer.failure?.message?.length) {
225
- return `${fuzzer.failure.message} (runs ${fuzzer.runs}, seed ${result.seed})`;
228
+ return `${fuzzer.failure.message} (runs ${fuzzer.runs}, seed ${result.seed}${failureSeeds})`;
226
229
  }
227
- return `fuzz failed after ${fuzzer.runs} runs (seed ${result.seed})`;
230
+ return `fuzz failed after ${fuzzer.runs} runs (seed ${result.seed}${failureSeeds})`;
228
231
  }
229
232
  function buildFuzzMessage(runs, seed, crashFile) {
230
233
  const crashSuffix = crashFile?.length ? `, crash ${crashFile}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "as-test",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "author": "Jairus Tanaka",
5
5
  "repository": {
6
6
  "type": "git",
@@ -64,9 +64,9 @@
64
64
  "access": "public"
65
65
  },
66
66
  "scripts": {
67
- "test": "node ./bin/index.js test",
67
+ "test": "node ./bin/index.js test --parallel",
68
+ "test:ci": "node ./bin/index.js test --parallel --tap --config ./as-test.ci.config.json",
68
69
  "fuzz": "node ./bin/index.js fuzz",
69
- "test:ci": "node ./bin/index.js test --tap --config ./as-test.ci.config.json",
70
70
  "test:examples": "npm --prefix ./examples run test",
71
71
  "ci:act": "bash ./tools/act.sh push",
72
72
  "ci:act:pr": "bash ./tools/act.sh pull_request",