as-test 1.0.12 → 1.0.13

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,10 @@
1
1
  # Change Log
2
2
 
3
+ ## 2025-05-03 - v1.0.13
4
+
5
+ - feat: add `--fuzzer` / `--fuzzers` filtering for `ast fuzz` and `ast test --fuzz`, accept `--suite` / `--suites` as fuzz aliases, and include target-specific repro commands in fuzz failure output.
6
+ - feat: add `--suite` / `--suites` filtering for `ast run` and `ast test`, and print suite-specific repro commands on failing test assertions.
7
+
3
8
  ## 2026-04-28 - v1.0.12
4
9
 
5
10
  - perf: faster seed generation
package/README.md CHANGED
@@ -134,6 +134,13 @@ Run one matching file:
134
134
  npx ast test math
135
135
  ```
136
136
 
137
+ Re-run one suite inside a matching file:
138
+
139
+ ```bash
140
+ npx ast run math --suite array-check
141
+ npx ast run math --suite array-manipulation/array-check
142
+ ```
143
+
137
144
  You do not need to learn every CLI flag to get started. Most projects can begin with `npx ast test`, then add more configuration only when they need it.
138
145
 
139
146
  ## Mocking
@@ -286,6 +293,12 @@ Run only fuzzers:
286
293
  npx ast fuzz
287
294
  ```
288
295
 
296
+ Run one matching fuzz target:
297
+
298
+ ```bash
299
+ npx ast fuzz string --fuzzer ascii-strings-survive-concatenation-boundaries
300
+ ```
301
+
289
302
  Run tests and fuzzers together:
290
303
 
291
304
  ```bash
@@ -9,7 +9,7 @@ const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
9
9
  const MAGIC = Buffer.from("WIPC");
10
10
  const HEADER_SIZE = 9;
11
11
  const MAX_DEFAULT_SEED = 0x7fffffff;
12
- export async function fuzz(configPath = DEFAULT_CONFIG_PATH, selectors = [], modeName, overrides = {}) {
12
+ export async function fuzz(configPath = DEFAULT_CONFIG_PATH, selectors = [], modeName, overrides = {}, fuzzerSelectors = []) {
13
13
  const loadedConfig = loadConfig(configPath, false);
14
14
  const mode = applyMode(loadedConfig, modeName);
15
15
  const config = resolveFuzzConfig(loadedConfig.fuzz, overrides);
@@ -25,7 +25,7 @@ export async function fuzz(configPath = DEFAULT_CONFIG_PATH, selectors = [], mod
25
25
  await build(configPath, [file], modeName, { coverage: false }, { target: "bindings", args: ["--use", "AS_TEST_FUZZ=1"], kind: "fuzz" });
26
26
  const buildFinishedAt = Date.now();
27
27
  const buildTime = buildFinishedAt - buildStartedAt;
28
- results.push(await runFuzzTarget(file, mode.config.outDir, duplicateBasenames, config, buildStartedAt, buildFinishedAt, buildTime, modeName));
28
+ results.push(await runFuzzTarget(file, mode.config.outDir, duplicateBasenames, config, fuzzerSelectors, buildStartedAt, buildFinishedAt, buildTime, modeName));
29
29
  }
30
30
  return results;
31
31
  }
@@ -69,7 +69,7 @@ function encodeRunsOverrideKind(kind) {
69
69
  return 4;
70
70
  }
71
71
  }
72
- async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStartedAt, buildFinishedAt, buildTime, modeName) {
72
+ async function runFuzzTarget(file, outDir, duplicateBasenames, config, fuzzerSelectors, buildStartedAt, buildFinishedAt, buildTime, modeName) {
73
73
  const startedAt = Date.now();
74
74
  const artifact = resolveArtifactFileName(file, duplicateBasenames, modeName);
75
75
  const wasmPath = path.resolve(process.cwd(), outDir, artifact);
@@ -210,7 +210,10 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
210
210
  };
211
211
  }
212
212
  const crashFiles = [];
213
- for (const fuzzer of report.fuzzers) {
213
+ const selectedFuzzers = fuzzerSelectors.length
214
+ ? filterSelectedFuzzers(report.fuzzers, fuzzerSelectors, file)
215
+ : report.fuzzers;
216
+ for (const fuzzer of selectedFuzzers) {
214
217
  if (fuzzer.failed <= 0 && fuzzer.crashed <= 0)
215
218
  continue;
216
219
  const firstFailureSeed = typeof fuzzer.failures?.[0]?.seed == "number"
@@ -222,7 +225,7 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
222
225
  entryKey: buildFuzzFailureEntryKey(file, fuzzer.name, modeName ?? "default"),
223
226
  mode: modeName ?? "default",
224
227
  seed: firstFailureSeed,
225
- reproCommand: buildFuzzReproCommand(file, firstFailureSeed, modeName ?? "default", 1),
228
+ reproCommand: buildFuzzReproCommand(file, firstFailureSeed, modeName ?? "default", fuzzer.selector, 1),
226
229
  error: fuzzer.failure?.message ||
227
230
  `fuzz failure in ${fuzzer.name} after ${fuzzer.runs} runs`,
228
231
  stdout: passthrough.stdout,
@@ -237,21 +240,49 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
237
240
  file,
238
241
  target: path.basename(file),
239
242
  modeName: modeName ?? "default",
240
- runs: report.fuzzers.reduce((sum, item) => sum + item.runs, 0),
241
- crashes: report.fuzzers.reduce((sum, item) => sum + item.crashed, 0),
243
+ runs: selectedFuzzers.reduce((sum, item) => sum + item.runs, 0),
244
+ crashes: selectedFuzzers.reduce((sum, item) => sum + item.crashed, 0),
242
245
  crashFiles,
243
246
  seed: config.seed,
244
247
  time: Date.now() - startedAt,
245
248
  buildTime,
246
249
  buildStartedAt,
247
250
  buildFinishedAt,
248
- fuzzers: report.fuzzers,
251
+ fuzzers: selectedFuzzers,
249
252
  };
250
253
  }
251
- function buildFuzzReproCommand(file, seed, modeName, runs) {
254
+ function filterSelectedFuzzers(fuzzers, selectors, file) {
255
+ const annotated = fuzzers.map((fuzzer) => ({
256
+ ...fuzzer,
257
+ selector: slugifyFuzzerSelector(fuzzer.name),
258
+ }));
259
+ const selected = new Set();
260
+ for (const selector of selectors) {
261
+ const slug = slugifyFuzzerSelector(selector);
262
+ if (!slug.length)
263
+ continue;
264
+ const matches = annotated.filter((fuzzer) => fuzzer.selector == slug);
265
+ if (!matches.length) {
266
+ throw new Error(`No fuzz targets matched "${selector}" in ${path.basename(file)}.`);
267
+ }
268
+ for (const match of matches) {
269
+ selected.add(match.selector);
270
+ }
271
+ }
272
+ return annotated.filter((fuzzer) => selected.has(fuzzer.selector ?? ""));
273
+ }
274
+ function slugifyFuzzerSelector(value) {
275
+ return value
276
+ .trim()
277
+ .toLowerCase()
278
+ .replace(/[^a-z0-9]+/g, "-")
279
+ .replace(/^-+|-+$/g, "");
280
+ }
281
+ function buildFuzzReproCommand(file, seed, modeName, fuzzer, runs) {
252
282
  const modeArg = modeName != "default" ? ` --mode ${modeName}` : "";
283
+ const fuzzerArg = fuzzer?.length ? ` --fuzzer ${fuzzer}` : "";
253
284
  const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
254
- return `ast fuzz ${file}${modeArg} --seed ${seed}${runsArg}`;
285
+ return `ast fuzz ${file}${modeArg}${fuzzerArg} --seed ${seed}${runsArg}`;
255
286
  }
256
287
  function buildFuzzFailureEntryKey(file, name, modeName) {
257
288
  return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}.${sanitizeEntryName(name)}`;
@@ -324,12 +355,12 @@ function captureFrames(onFrame) {
324
355
  }
325
356
  });
326
357
  process.stdin.read = ((size) => {
327
- const max = Number(size ?? 0);
358
+ const max = size == null ? 0 : Number(size);
328
359
  if (max > 0 && replies.length) {
329
360
  return dequeueReply(max);
330
361
  }
331
362
  if (originalRead) {
332
- return originalRead(size);
363
+ return originalRead(size === null ? undefined : size);
333
364
  }
334
365
  return null;
335
366
  });
@@ -1,10 +1,11 @@
1
1
  export async function executeFuzzCommand(rawArgs, configPath, selectedModes, deps) {
2
2
  const commandArgs = deps.resolveCommandArgs(rawArgs, "fuzz");
3
+ const fuzzerSelectors = deps.resolveFuzzerSelectors(rawArgs, "fuzz");
3
4
  const listFlags = deps.resolveListFlags(rawArgs, "fuzz");
4
5
  const modeTargets = deps.resolveExecutionModes(configPath, selectedModes);
5
6
  if (listFlags.list || listFlags.listModes) {
6
7
  await deps.listExecutionPlan("fuzz", configPath, commandArgs, modeTargets, listFlags);
7
8
  return;
8
9
  }
9
- await deps.runFuzzModes(configPath, commandArgs, modeTargets, rawArgs);
10
+ await deps.runFuzzModes(configPath, commandArgs, fuzzerSelectors, modeTargets, rawArgs);
10
11
  }
@@ -327,6 +327,9 @@ function formatReadableLog(file, suites, modeName, buildCommand, runCommand, sna
327
327
  lines.push("", "Failures:");
328
328
  for (const failure of failures) {
329
329
  lines.push(`FAIL ${failure.title}${failure.where.length ? ` (${failure.where})` : ""}`);
330
+ if (failure.suitePath.length) {
331
+ lines.push(`Repro: ${buildSuiteReproCommand(file, failure.suitePath, modeName)}`);
332
+ }
330
333
  if (failure.message.length)
331
334
  lines.push(`Message: ${failure.message}`);
332
335
  if (failure.left.length)
@@ -352,6 +355,139 @@ function formatInvocation(invocation) {
352
355
  .map((part) => (/[\s"'\\]/.test(part) ? JSON.stringify(part) : part))
353
356
  .join(" ");
354
357
  }
358
+ function filterSelectedSuites(suites, selectors, file, modeName) {
359
+ const annotated = annotateSuitePaths(suites, []);
360
+ const matches = resolveSuiteSelectionMatches(annotated, selectors, file);
361
+ const selected = new Set(matches.map((match) => match.resolvedPath));
362
+ return cloneSelectedSuites(annotated, selected, file, modeName);
363
+ }
364
+ function annotateSuitePaths(suites, pathParts) {
365
+ return suites.map((suite) => annotateSuiteNode(suite, pathParts));
366
+ }
367
+ function annotateSuiteNode(suite, pathParts) {
368
+ const description = String(suite?.description ?? "unknown");
369
+ const slug = slugifySelectorSegment(description);
370
+ const nextParts = [...pathParts, slug];
371
+ const nextSuites = Array.isArray(suite?.suites)
372
+ ? suite.suites
373
+ : [];
374
+ const annotatedSuites = annotateSuitePaths(nextSuites, nextParts);
375
+ return {
376
+ ...suite,
377
+ path: nextParts.join("/"),
378
+ suites: annotatedSuites,
379
+ };
380
+ }
381
+ function resolveSuiteSelectionMatches(suites, selectors, file) {
382
+ const matches = [];
383
+ for (const selector of selectors) {
384
+ const normalized = selector.trim();
385
+ if (!normalized.length)
386
+ continue;
387
+ if (normalized.includes("/")) {
388
+ const resolved = resolveExplicitSuitePath(suites, normalized);
389
+ if (!resolved) {
390
+ throw new Error(`No suites matched "${selector}" in ${path.basename(file)}.`);
391
+ }
392
+ matches.push({
393
+ kind: "path",
394
+ raw: selector,
395
+ resolvedPath: resolved.path,
396
+ depth: resolved.depth,
397
+ });
398
+ continue;
399
+ }
400
+ const resolved = resolveBareSuiteSelector(suites, normalized);
401
+ if (!resolved) {
402
+ throw new Error(`No suites matched "${selector}" in ${path.basename(file)}.`);
403
+ }
404
+ matches.push({
405
+ kind: "bare",
406
+ raw: selector,
407
+ resolvedPath: resolved.path,
408
+ depth: resolved.depth,
409
+ });
410
+ }
411
+ return matches;
412
+ }
413
+ function resolveExplicitSuitePath(suites, selector) {
414
+ const normalized = selector
415
+ .split("/")
416
+ .map((part) => slugifySelectorSegment(part))
417
+ .filter((part) => part.length)
418
+ .join("/");
419
+ if (!normalized.length)
420
+ return null;
421
+ let match = null;
422
+ walkSuites(suites, (suite, depth) => {
423
+ if (suite.path == normalized) {
424
+ match = { path: suite.path, depth };
425
+ return true;
426
+ }
427
+ return false;
428
+ });
429
+ return match;
430
+ }
431
+ function resolveBareSuiteSelector(suites, selector) {
432
+ const slug = slugifySelectorSegment(selector);
433
+ if (!slug.length)
434
+ return null;
435
+ const matches = [];
436
+ walkSuites(suites, (suite, depth) => {
437
+ const leaf = String(suite.path ?? "").split("/").pop() ?? "";
438
+ if (leaf == slug) {
439
+ matches.push({ path: String(suite.path), depth });
440
+ }
441
+ return false;
442
+ });
443
+ if (!matches.length)
444
+ return null;
445
+ matches.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
446
+ const shallowest = matches[0];
447
+ const ambiguous = matches.filter((match) => match.depth == shallowest.depth);
448
+ if (ambiguous.length > 1) {
449
+ throw new Error(`Suite selector "${selector}" is ambiguous. Matches: ${ambiguous.map((match) => match.path).join(", ")}`);
450
+ }
451
+ return shallowest;
452
+ }
453
+ function walkSuites(suites, visitor, depth = 0) {
454
+ for (const suite of suites) {
455
+ if (visitor(suite, depth))
456
+ return true;
457
+ const childSuites = Array.isArray(suite?.suites) ? suite.suites : [];
458
+ if (walkSuites(childSuites, visitor, depth + 1))
459
+ return true;
460
+ }
461
+ return false;
462
+ }
463
+ function cloneSelectedSuites(suites, selected, file, modeName) {
464
+ const out = [];
465
+ for (const suite of suites) {
466
+ const childSuites = Array.isArray(suite.suites) ? suite.suites : [];
467
+ const selectedChildren = cloneSelectedSuites(childSuites, selected, file, modeName);
468
+ const keep = selected.has(String(suite.path ?? "")) || selectedChildren.length > 0;
469
+ if (!keep)
470
+ continue;
471
+ out.push({
472
+ ...suite,
473
+ file,
474
+ modeName,
475
+ suites: selectedChildren,
476
+ });
477
+ }
478
+ return out;
479
+ }
480
+ function slugifySelectorSegment(value) {
481
+ return value
482
+ .trim()
483
+ .toLowerCase()
484
+ .replace(/[^a-z0-9]+/g, "-")
485
+ .replace(/^-+|-+$/g, "");
486
+ }
487
+ function buildSuiteReproCommand(file, suitePath, modeName) {
488
+ const modeArg = modeName && modeName != "default" ? ` --mode ${modeName}` : "";
489
+ return `ast run ${file}${modeArg} --suite ${suitePath}`;
490
+ }
355
491
  function collectReadableFailures(suites, file, pathParts) {
356
492
  const out = [];
357
493
  for (const suite of suites) {
@@ -372,6 +508,7 @@ function collectReadableFailures(suites, file, pathParts) {
372
508
  message: String(test.message ?? ""),
373
509
  left: JSON.stringify(test.left ?? ""),
374
510
  right: JSON.stringify(test.right ?? ""),
511
+ suitePath: String(suiteAny.path ?? ""),
375
512
  });
376
513
  }
377
514
  const childSuites = Array.isArray(suiteAny.suites)
@@ -492,6 +629,9 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
492
629
  throw new Error(`Failed to run ${path.basename(file)} in mode ${modeLabel} with ${details}`);
493
630
  }
494
631
  const normalized = normalizeReport(report);
632
+ const selectedSuites = options.suiteSelectors?.length
633
+ ? filterSelectedSuites(normalized.suites, options.suiteSelectors, file, options.modeName ?? "default")
634
+ : normalized.suites;
495
635
  snapshotStore.flush();
496
636
  snapshotSummary.matched += snapshotStore.matched;
497
637
  snapshotSummary.created += snapshotStore.created;
@@ -500,7 +640,7 @@ export async function run(flags = {}, configPath = DEFAULT_CONFIG_PATH, selector
500
640
  reports.push({
501
641
  file,
502
642
  modeName: options.modeName ?? "default",
503
- suites: normalized.suites,
643
+ suites: selectedSuites,
504
644
  coverage: normalized.coverage,
505
645
  runCommand: runCommandForLog,
506
646
  snapshotSummary: {
@@ -1665,8 +1805,9 @@ async function runProcess(invocation, specFile, crashDir, modeName, snapshots, s
1665
1805
  if (stderrPendingLine.length && !shouldSuppressWasiWarningLine(stderrPendingLine)) {
1666
1806
  stderrBuffer += stderrPendingLine;
1667
1807
  }
1668
- if (spawnError) {
1669
- const errorText = spawnError.stack ?? spawnError.message;
1808
+ const processSpawnError = spawnError;
1809
+ if (processSpawnError) {
1810
+ const errorText = processSpawnError.stack ?? processSpawnError.message;
1670
1811
  persistCrashRecord(crashDir, {
1671
1812
  kind: "test",
1672
1813
  file: specFile,
@@ -1,6 +1,7 @@
1
1
  export { createRunReporter, run } from "./run-core.js";
2
2
  export async function executeRunCommand(rawArgs, flags, configPath, selectedModes, deps) {
3
3
  const commandArgs = deps.resolveCommandArgs(rawArgs, "run");
4
+ const suiteSelectors = deps.resolveSuiteSelectors(rawArgs, "run");
4
5
  const listFlags = deps.resolveListFlags(rawArgs, "run");
5
6
  const featureToggles = deps.resolveFeatureToggles(rawArgs, "run");
6
7
  const runFlags = {
@@ -20,5 +21,5 @@ export async function executeRunCommand(rawArgs, flags, configPath, selectedMode
20
21
  await deps.listExecutionPlan("run", configPath, commandArgs, modeTargets, listFlags);
21
22
  return;
22
23
  }
23
- await deps.runRuntimeModes(runFlags, configPath, commandArgs, modeTargets);
24
+ await deps.runRuntimeModes(runFlags, configPath, commandArgs, suiteSelectors, modeTargets);
24
25
  }
@@ -1,5 +1,7 @@
1
1
  export async function executeTestCommand(rawArgs, flags, configPath, selectedModes, deps) {
2
2
  const commandArgs = deps.resolveCommandArgs(rawArgs, "test");
3
+ const suiteSelectors = deps.resolveSuiteSelectors(rawArgs, "test");
4
+ const fuzzerSelectors = deps.resolveFuzzerSelectors(rawArgs, "test");
3
5
  const listFlags = deps.resolveListFlags(rawArgs, "test");
4
6
  const featureToggles = deps.resolveFeatureToggles(rawArgs, "test");
5
7
  const buildFeatureToggles = {
@@ -25,5 +27,5 @@ export async function executeTestCommand(rawArgs, flags, configPath, selectedMod
25
27
  await deps.listExecutionPlan("test", configPath, commandArgs, modeTargets, listFlags, fuzzEnabled);
26
28
  return;
27
29
  }
28
- await deps.runTestModes(runFlags, configPath, commandArgs, modeTargets, buildFeatureToggles, fuzzEnabled, fuzzOverrides);
30
+ await deps.runTestModes(runFlags, configPath, commandArgs, suiteSelectors, fuzzerSelectors, modeTargets, buildFeatureToggles, fuzzEnabled, fuzzOverrides);
29
31
  }