as-test 1.1.10 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Change Log
2
2
 
3
+ ## 2026-05-20 - v1.2.0
4
+
5
+ ### Directory-preserving artifact layout
6
+
7
+ - feat: build artifacts, fuzz artifacts, snapshots, readable logs, coverage logs, and crash records now mirror the source tree under the configured input globs instead of being flattened into a single directory with a `____`-mangled disambiguator suffix. For `assembly/__tests__/nested/array.spec.ts` the artifact is `outDir/<mode>/nested/array.spec.wasm` (previously `outDir/<mode>/array.<mode>.<target>.assembly____tests____nested.wasm`).
8
+ - feat: filename simplified to `<stem>.wasm` — the `.<mode>.<target>` suffix has been dropped since the mode is already a directory level and the target is implied by the mode config.
9
+ - feat: add `resolveGlobBase`, `resolveSpecRelativePath`, and `resolveArtifactPath` in `cli/util.ts` as the shared path helpers used by every code path that writes or looks up a per-spec artifact. Glob bases are computed component-wise (so `assembly/__tests` is not a prefix of `assembly/__tests__/foo.spec.ts`) and the longest matching base wins when multiple configured input patterns overlap.
10
+ - feat: add an up-front collision check in `build()` that throws a clear error naming both source files when two configured inputs would resolve to the same artifact path.
11
+ - fix: `ast test <one-spec>`, `ast run <one-spec>`, and `ast fuzz <one-spec>` no longer drop the disambiguator when only one of two same-basename files is being built. The build side and the runner side now compute the same path from the same configured input set.
12
+ - fix: build sites now `mkdir -p` the artifact's parent directory before invoking `asc` — pinned `assemblyscript@0.28.17`'s `-o` flag does not create parents and would otherwise ENOENT for any new nested directory.
13
+ - fix: `persistCrashRecord` now `mkdir -p`'s the entry's parent directory, supporting `/` in entry keys so nested specs and fuzz failures get their own crash files instead of clobbering by basename.
14
+ - fix: replace the hardcoded `/__tests__/` and `/__fuzz__/` markers in snapshot and readable-log path resolution with proper glob-base computation, so projects with custom input layouts now nest correctly instead of falling back to basename-only paths.
15
+
16
+ ### `.toThrow()` is a real matcher
17
+
18
+ - feat: `expect((): void => { throw new Error("boom"); }).toThrow()` now invokes the wrapped callback and asserts it threw, using try-as's `__ExceptionState.Failures` counter to detect the throw. Calling `.toThrow()` on a non-function value reports a clear "needs a function" failure.
19
+ - feat: requires `--enable try-as`. Without the feature flag, `.toThrow()` warns once and is a no-op (existing behavior preserved).
20
+ - chore: the bundled try-as integration spec lives at `assembly/__tests__/try-as.spec.ts` and is run by `npm test`, which now passes `--enable try-as`.
21
+
22
+ ### Breaking
23
+
24
+ - chore: clean break on snapshot file layout — the legacy `${base}.snap.json` and `${base}.${disambiguator}.snap.json` fallbacks have been removed. After upgrading, run `--create-snapshots` or `--overwrite-snapshots` once so snapshots are written at their new relative-path locations.
25
+ - chore: artifact filenames no longer carry the `<mode>.<target>` suffix; tooling that grepped the old shape needs adjusting. `ast clean` removes any orphan artifacts.
26
+ - chore: `.toThrow()` no longer accepts a bare value — it now requires a `() => void` callback and the try-as feature flag.
27
+
28
+ ### Tooling
29
+
30
+ - chore: husky pre-commit / commit-msg / pre-push hooks (build → format → typecheck → lint on commit; conventional-commits enforcement; full test gate only on push to `main` of `JairusSW/as-test`).
31
+
3
32
  ## 2026-05-19 - v1.1.10
4
33
 
5
34
  - feat: when the user already declares `--transform json-as/...` in `buildOptions.args` or in their referenced `asconfig.json` (top-level `options.transform`, any `targets.*.transform`, or via a single level of `extends`), as-test no longer adds its own auto-include — letting users bring their own json-as version or load path. Detection matches bare specifiers (`json-as`, `json-as/transform`), absolute paths, and `./node_modules/...` paths.
@@ -457,15 +457,21 @@ export class Expectation<T> extends Tests {
457
457
  }
458
458
 
459
459
  /**
460
- * Delegates throw assertions to try-as when available.
461
- * If try-as is unavailable, this matcher is disabled and warns once.
460
+ * Invokes the wrapped function inside a try/catch and asserts it threw.
461
+ * Requires the try-as feature (`--enable try-as`).
462
+ *
463
+ * expect((): void => { throw new Error("boom"); }).toThrow();
464
+ *
465
+ * The value passed to `expect()` must be a `() => void` callback — calling
466
+ * `.toThrow()` on a non-function value records a failure that explains the
467
+ * usage.
462
468
  */
463
469
  toThrow(message: string = ""): void {
464
470
  // @ts-ignore
465
471
  if (!isDefined(AS_TEST_TRY_AS)) {
466
472
  if (!warnedToThrowDisabled) {
467
473
  sendWarning(
468
- 'toThrow() is disabled because try-as is not installed. Install and import "try-as" to enable it.',
474
+ "toThrow() requires the try-as feature. Enable with --enable try-as.",
469
475
  );
470
476
  warnedToThrowDisabled = true;
471
477
  }
@@ -473,13 +479,42 @@ export class Expectation<T> extends Tests {
473
479
  return;
474
480
  }
475
481
 
482
+ if (!isFunction<T>()) {
483
+ this._resolve(
484
+ false,
485
+ "toThrow",
486
+ q("non-function"),
487
+ q("() => void"),
488
+ message.length
489
+ ? message
490
+ : "toThrow() requires a function: expect((): void => { ... }).toThrow()",
491
+ );
492
+ return;
493
+ }
494
+
495
+ // try-as rewrites the throw inside the callback to bump
496
+ // __ExceptionState.Failures and return early from the arrow. We never
497
+ // wrap the call in try/catch here because try-as's source linker does not
498
+ // follow chained method calls (`expect(...).toThrow()`) and so it would
499
+ // not rewrite a `try` placed in this method body. Compare the failure
500
+ // counter before/after instead and consume any failure we observed.
501
+ // @ts-ignore: __ExceptionState is provided by the try-as transform
502
+ const beforeFailures = __ExceptionState.Failures;
503
+ // @ts-ignore: guarded by isFunction<T>() above
504
+ (this._left as () => void)();
476
505
  // @ts-ignore
477
- const passed = __ExceptionState.Failures > 0;
478
- if (passed) {
506
+ const threw = __ExceptionState.Failures > beforeFailures;
507
+ if (threw) {
479
508
  // @ts-ignore
480
- __ExceptionState.Failures--;
509
+ __ExceptionState.Failures = beforeFailures;
481
510
  }
482
- this._resolve(passed, "toThrow", q("throws"), q("throws"), message);
511
+ this._resolve(
512
+ threw,
513
+ "toThrow",
514
+ q(threw ? "threw" : "did not throw"),
515
+ q("throws"),
516
+ message,
517
+ );
483
518
  }
484
519
 
485
520
  /**
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { existsSync, mkdirSync, readFileSync } from "fs";
2
2
  import { glob } from "glob";
3
3
  import chalk from "chalk";
4
4
  import { spawn } from "child_process";
@@ -11,6 +11,8 @@ import {
11
11
  applyMode,
12
12
  getPkgRunner,
13
13
  loadConfig,
14
+ resolveArtifactPath,
15
+ resolveSpecRelativePath,
14
16
  tokenizeCommand,
15
17
  resolveProjectModule,
16
18
  } from "../util.js";
@@ -64,11 +66,7 @@ export async function build(
64
66
  const inputFiles = (await glob(inputPatterns)).sort((a, b) =>
65
67
  a.localeCompare(b),
66
68
  );
67
- // Disambiguation must consider the entire configured input set, not just the
68
- // selector-filtered subset, otherwise running `ast test <one-spec>` writes
69
- // an artifact name the runner won't look up (it always globs full input).
70
- const duplicateSpecBasenames =
71
- await resolveAllConfiguredDuplicateBasenames(sourceInputPatterns);
69
+ await assertNoArtifactCollisions(sourceInputPatterns);
72
70
  const coverageEnabled = resolveCoverageEnabled(
73
71
  config.coverage,
74
72
  featureToggles.coverage,
@@ -85,7 +83,10 @@ export async function build(
85
83
  ) {
86
84
  const pool = getSerialBuildWorkerPool();
87
85
  for (const file of inputFiles) {
88
- const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
86
+ const outFile = path.join(
87
+ config.outDir,
88
+ resolveArtifactPath(file, sourceInputPatterns),
89
+ );
89
90
  const invocation = getBuildCommand(
90
91
  config,
91
92
  pkgRunner,
@@ -106,7 +107,11 @@ export async function build(
106
107
  return;
107
108
  }
108
109
  for (const file of inputFiles) {
109
- const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
110
+ const outFile = path.join(
111
+ config.outDir,
112
+ resolveArtifactPath(file, sourceInputPatterns),
113
+ );
114
+ mkdirSync(path.dirname(outFile), { recursive: true });
110
115
  const invocation = getBuildCommand(
111
116
  config,
112
117
  pkgRunner,
@@ -127,6 +132,10 @@ export async function build(
127
132
  kind,
128
133
  stage: "build",
129
134
  file,
135
+ entryKey: resolveSpecRelativePath(file, sourceInputPatterns).replace(
136
+ /\.ts$/i,
137
+ "",
138
+ ),
130
139
  mode: modeLabel,
131
140
  cwd: process.cwd(),
132
141
  buildCommand,
@@ -189,9 +198,10 @@ export async function getBuildInvocationPreview(
189
198
  }
190
199
  const sourceInputPatterns =
191
200
  overrides.kind === "fuzz" ? config.fuzz.input : config.input;
192
- const duplicateSpecBasenames =
193
- await resolveAllConfiguredDuplicateBasenames(sourceInputPatterns);
194
- const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
201
+ const outFile = path.join(
202
+ config.outDir,
203
+ resolveArtifactPath(file, sourceInputPatterns),
204
+ );
195
205
  return getBuildCommand(
196
206
  config,
197
207
  getPkgRunner(),
@@ -229,9 +239,10 @@ export async function getBuildReuseInfo(
229
239
  }
230
240
  const sourceInputPatterns =
231
241
  overrides.kind === "fuzz" ? config.fuzz.input : config.input;
232
- const duplicateSpecBasenames =
233
- await resolveAllConfiguredDuplicateBasenames(sourceInputPatterns);
234
- const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
242
+ const outFile = path.join(
243
+ config.outDir,
244
+ resolveArtifactPath(file, sourceInputPatterns),
245
+ );
235
246
  const invocation = getBuildCommand(
236
247
  config,
237
248
  getPkgRunner(),
@@ -356,54 +367,23 @@ function expandBuildCommand(template, file, outFile, target, modeName) {
356
367
  .replace(/<target>/g, target)
357
368
  .replace(/<mode>/g, modeName ?? "");
358
369
  }
359
- function resolveArtifactFileName(
360
- file,
361
- target,
362
- modeName,
363
- duplicateSpecBasenames = new Set(),
364
- ) {
365
- const base = path
366
- .basename(file)
367
- .replace(/\.spec\.ts$/, "")
368
- .replace(/\.ts$/, "");
369
- const legacy = !modeName
370
- ? `${path.basename(file).replace(".ts", ".wasm")}`
371
- : `${base}.${modeName}.${target}.wasm`;
372
- if (!duplicateSpecBasenames.has(path.basename(file))) {
373
- return legacy;
374
- }
375
- const disambiguator = resolveDisambiguator(file);
376
- if (!disambiguator.length) {
377
- return legacy;
378
- }
379
- const ext = path.extname(legacy);
380
- const stem = ext.length ? legacy.slice(0, -ext.length) : legacy;
381
- return `${stem}.${disambiguator}${ext}`;
382
- }
383
- async function resolveAllConfiguredDuplicateBasenames(configured) {
370
+ async function assertNoArtifactCollisions(configured) {
384
371
  const patterns = Array.isArray(configured) ? configured : [configured];
385
372
  const files = await glob(patterns);
386
- return resolveDuplicateBasenames(files);
387
- }
388
- function resolveDuplicateBasenames(files) {
389
- const counts = new Map();
373
+ const seen = new Map();
390
374
  for (const file of files) {
391
- const base = path.basename(file);
392
- counts.set(base, (counts.get(base) ?? 0) + 1);
393
- }
394
- const duplicates = new Set();
395
- for (const [base, count] of counts) {
396
- if (count > 1) duplicates.add(base);
397
- }
398
- return duplicates;
399
- }
400
- function resolveDisambiguator(file) {
401
- const relDir = path.dirname(path.relative(process.cwd(), file));
402
- if (!relDir.length || relDir == ".") return "";
403
- return relDir
404
- .replace(/[\\/]+/g, "__")
405
- .replace(/[^A-Za-z0-9._-]/g, "_")
406
- .replace(/^_+|_+$/g, "");
375
+ const artifact = resolveArtifactPath(file, patterns);
376
+ const prev = seen.get(artifact);
377
+ if (prev != null && prev !== file) {
378
+ throw new Error(
379
+ `Two input files resolve to the same artifact path "${artifact}":\n` +
380
+ ` - ${prev}\n` +
381
+ ` - ${file}\n` +
382
+ `Rename one of them or narrow the input patterns to disambiguate.`,
383
+ );
384
+ }
385
+ seen.set(artifact, file);
386
+ }
407
387
  }
408
388
  function resolveInputPatterns(configured, selectors) {
409
389
  const configuredInputs = Array.isArray(configured)
@@ -3,7 +3,12 @@ import * as path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { glob } from "glob";
5
5
  import { build } from "./build-core.js";
6
- import { applyMode, loadConfig } from "../util.js";
6
+ import {
7
+ applyMode,
8
+ loadConfig,
9
+ resolveArtifactPath,
10
+ resolveSpecRelativePath,
11
+ } from "../util.js";
7
12
  import { persistCrashRecord } from "../crash-store.js";
8
13
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
9
14
  const MAGIC = Buffer.from("WIPC");
@@ -29,13 +34,6 @@ export async function fuzz(
29
34
  `No fuzz files matched: ${selectors.length ? selectors.join(", ") : "configured input patterns"}`,
30
35
  );
31
36
  }
32
- // Disambiguation must consider the full configured fuzz input set, not the
33
- // selector-filtered subset, so artifact names stay consistent across runs.
34
- const fullPatterns = Array.isArray(config.input)
35
- ? config.input
36
- : [config.input];
37
- const allFuzzFiles = await glob(fullPatterns);
38
- const duplicateBasenames = resolveDuplicateBasenames(allFuzzFiles);
39
37
  const results = [];
40
38
  for (const file of inputFiles) {
41
39
  const buildStartedAt = Date.now();
@@ -53,7 +51,6 @@ export async function fuzz(
53
51
  await runFuzzTarget(
54
52
  file,
55
53
  activeConfig.outDir,
56
- duplicateBasenames,
57
54
  config,
58
55
  fuzzerSelectors,
59
56
  buildStartedAt,
@@ -111,7 +108,6 @@ function encodeRunsOverrideKind(kind) {
111
108
  async function runFuzzTarget(
112
109
  file,
113
110
  outDir,
114
- duplicateBasenames,
115
111
  config,
116
112
  fuzzerSelectors,
117
113
  buildStartedAt,
@@ -120,7 +116,7 @@ async function runFuzzTarget(
120
116
  modeName,
121
117
  ) {
122
118
  const startedAt = Date.now();
123
- const artifact = resolveArtifactFileName(file, duplicateBasenames, modeName);
119
+ const artifact = resolveArtifactPath(file, config.input);
124
120
  const wasmPath = path.resolve(process.cwd(), outDir, artifact);
125
121
  const jsPath = resolveBindingsHelperPath(wasmPath);
126
122
  const helper = await import(pathToFileURL(jsPath).href + `?t=${Date.now()}`);
@@ -185,7 +181,11 @@ async function runFuzzTarget(
185
181
  const crash = persistCrashRecord(config.crashDir, {
186
182
  kind: "fuzz",
187
183
  file,
188
- entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
184
+ entryKey: buildFuzzCrashEntryKey(
185
+ file,
186
+ config.input,
187
+ modeName ?? "default",
188
+ ),
189
189
  mode: modeName ?? "default",
190
190
  seed: config.seed,
191
191
  error: crashMessage,
@@ -232,7 +232,11 @@ async function runFuzzTarget(
232
232
  const crash = persistCrashRecord(config.crashDir, {
233
233
  kind: "fuzz",
234
234
  file,
235
- entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
235
+ entryKey: buildFuzzCrashEntryKey(
236
+ file,
237
+ config.input,
238
+ modeName ?? "default",
239
+ ),
236
240
  mode: modeName ?? "default",
237
241
  seed: config.seed,
238
242
  error: `${reportParseError ? `invalid fuzz report payload: ${reportParseError}` : `missing fuzz report payload from ${path.basename(file)}`} (${diagnostics})`,
@@ -269,6 +273,7 @@ async function runFuzzTarget(
269
273
  file,
270
274
  entryKey: buildFuzzFailureEntryKey(
271
275
  file,
276
+ config.input,
272
277
  fuzzer.name,
273
278
  modeName ?? "default",
274
279
  ),
@@ -341,11 +346,19 @@ function buildFuzzReproCommand(file, seed, modeName, fuzzer, runs) {
341
346
  const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
342
347
  return `ast fuzz ${file}${modeArg}${fuzzerArg} --seed ${seed}${runsArg}`;
343
348
  }
344
- function buildFuzzFailureEntryKey(file, name, modeName) {
345
- return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}.${sanitizeEntryName(name)}`;
349
+ function buildFuzzFailureEntryKey(file, inputPatterns, name, modeName) {
350
+ const stem = resolveSpecRelativePath(file, inputPatterns).replace(
351
+ /\.ts$/i,
352
+ "",
353
+ );
354
+ return `${stem}.${sanitizeEntryName(modeName)}.${sanitizeEntryName(name)}`;
346
355
  }
347
- function buildFuzzCrashEntryKey(file, modeName) {
348
- return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}`;
356
+ function buildFuzzCrashEntryKey(file, inputPatterns, modeName) {
357
+ const stem = resolveSpecRelativePath(file, inputPatterns).replace(
358
+ /\.ts$/i,
359
+ "",
360
+ );
361
+ return `${stem}.${sanitizeEntryName(modeName)}`;
349
362
  }
350
363
  function sanitizeEntryName(name) {
351
364
  return (
@@ -458,45 +471,6 @@ function resolveFuzzInputPatterns(configured, selectors) {
458
471
  }
459
472
  return [...patterns];
460
473
  }
461
- function resolveArtifactFileName(file, duplicateBasenames, modeName) {
462
- const base = path
463
- .basename(file)
464
- .replace(/\.spec\.ts$/, "")
465
- .replace(/\.ts$/, "");
466
- const legacy = !modeName
467
- ? `${path.basename(file).replace(".ts", ".wasm")}`
468
- : `${base}.${modeName}.bindings.wasm`;
469
- if (!duplicateBasenames.has(path.basename(file))) {
470
- return legacy;
471
- }
472
- const disambiguator = resolveDisambiguator(file);
473
- if (!disambiguator.length) {
474
- return legacy;
475
- }
476
- const ext = path.extname(legacy);
477
- const stem = ext.length ? legacy.slice(0, -ext.length) : legacy;
478
- return `${stem}.${disambiguator}${ext}`;
479
- }
480
- function resolveDuplicateBasenames(files) {
481
- const counts = new Map();
482
- for (const file of files) {
483
- const base = path.basename(file);
484
- counts.set(base, (counts.get(base) ?? 0) + 1);
485
- }
486
- const duplicates = new Set();
487
- for (const [base, count] of counts) {
488
- if (count > 1) duplicates.add(base);
489
- }
490
- return duplicates;
491
- }
492
- function resolveDisambiguator(file) {
493
- const relDir = path.dirname(path.relative(process.cwd(), file));
494
- if (!relDir.length || relDir == ".") return "";
495
- return relDir
496
- .replace(/[\\/]+/g, "__")
497
- .replace(/[^A-Za-z0-9._-]/g, "_")
498
- .replace(/^_+|_+$/g, "");
499
- }
500
474
  function resolveBindingsHelperPath(wasmPath) {
501
475
  const bindingsPath = wasmPath.replace(/\.wasm$/, ".bindings.js");
502
476
  if (existsSync(bindingsPath)) return bindingsPath;
@@ -8,6 +8,8 @@ import {
8
8
  formatTime,
9
9
  getExec,
10
10
  loadConfig,
11
+ resolveArtifactPath,
12
+ resolveSpecRelativePath,
11
13
  tokenizeCommand,
12
14
  } from "../util.js";
13
15
  import * as path from "path";
@@ -23,7 +25,7 @@ import { persistCrashRecord } from "../crash-store.js";
23
25
  import { describeCoveragePoint } from "../coverage-points.js";
24
26
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
25
27
  class SnapshotStore {
26
- constructor(specFile, snapshotDir, duplicateSpecBasenames = new Set()) {
28
+ constructor(specFile, snapshotDir, inputPatterns) {
27
29
  this.dirty = false;
28
30
  this.created = 0;
29
31
  this.updated = 0;
@@ -32,27 +34,19 @@ class SnapshotStore {
32
34
  this.warnedMissing = new Set();
33
35
  this.specBasename = path.basename(specFile);
34
36
  const dir = path.join(process.cwd(), snapshotDir);
35
- const relative = resolveArtifactRelativePath(specFile, "__tests__").replace(
36
- /\.ts$/,
37
+ const relative = resolveSpecRelativePath(specFile, inputPatterns).replace(
38
+ /\.ts$/i,
37
39
  ".snap",
38
40
  );
39
41
  this.filePath = path.join(dir, relative);
40
- const sourcePath =
41
- resolveSnapshotSourcePath(
42
- specFile,
43
- dir,
44
- duplicateSpecBasenames,
45
- this.filePath,
46
- ) ?? null;
42
+ const sourcePath = existsSync(this.filePath) ? this.filePath : null;
47
43
  const loaded = sourcePath
48
44
  ? readSnapshotFile(sourcePath, specFile)
49
45
  : { data: {}, normalized: false, preamble: "" };
50
46
  this.data = loaded.data;
51
47
  this.preamble = loaded.preamble;
52
- this.existed = Boolean(sourcePath && existsSync(sourcePath));
53
- this.dirty = Boolean(
54
- (sourcePath && sourcePath != this.filePath) || loaded.normalized,
55
- );
48
+ this.existed = Boolean(sourcePath);
49
+ this.dirty = Boolean(loaded.normalized);
56
50
  }
57
51
  assert(key, actual, allowSnapshot, createSnapshots, overwriteSnapshots) {
58
52
  key = canonicalizeSnapshotKey(key);
@@ -103,26 +97,6 @@ class SnapshotStore {
103
97
  );
104
98
  }
105
99
  }
106
- function resolveSnapshotSourcePath(
107
- specFile,
108
- snapshotDir,
109
- duplicateSpecBasenames,
110
- preferredPath,
111
- ) {
112
- if (existsSync(preferredPath)) return preferredPath;
113
- const base = path.basename(specFile, ".ts");
114
- const legacyFlat = path.join(snapshotDir, `${base}.snap.json`);
115
- if (existsSync(legacyFlat)) return legacyFlat;
116
- const disambiguator = resolveDisambiguator(specFile, duplicateSpecBasenames);
117
- if (disambiguator.length) {
118
- const legacyDisambiguated = path.join(
119
- snapshotDir,
120
- `${base}.${disambiguator}.snap.json`,
121
- );
122
- if (existsSync(legacyDisambiguated)) return legacyDisambiguated;
123
- }
124
- return null;
125
- }
126
100
  function readSnapshotFile(filePath, specFile) {
127
101
  const raw = readFileSync(filePath, "utf8");
128
102
  if (filePath.endsWith(".json")) {
@@ -267,17 +241,11 @@ function trimSnapshotPreamble(lines) {
267
241
  while (end > 0 && !(lines[end - 1] ?? "").trim().length) end--;
268
242
  return lines.slice(0, end).join("\n");
269
243
  }
244
+ // Only the basename of the returned path matters — callers feed this into
245
+ // `path.basename(...)` to localize snapshot keys (strip the "${basename}::"
246
+ // prefix). The full path is therefore synthetic but stable.
270
247
  function resolveSnapshotSpecFile(filePath) {
271
- const normalized = filePath.replace(/\\/g, "/");
272
- const marker = "/snapshots/";
273
- const markerIndex = normalized.lastIndexOf(marker);
274
- const suffix =
275
- markerIndex >= 0
276
- ? normalized.slice(markerIndex + marker.length)
277
- : path.basename(normalized);
278
- const withoutMode = suffix.replace(/^default\//, "");
279
- const relative = withoutMode.replace(/\.snap$/, ".ts");
280
- return `assembly/__tests__/${relative}`;
248
+ return path.basename(filePath).replace(/\.snap$/, ".ts");
281
249
  }
282
250
  function localizeSnapshotKey(specFile, key) {
283
251
  const prefix = `${path.basename(specFile)}::`;
@@ -315,24 +283,18 @@ function canonicalizeSnapshotLocalKey(localKey) {
315
283
  }
316
284
  return localKey;
317
285
  }
318
- function resolveArtifactRelativePath(sourceFile, segment) {
319
- const normalized = sourceFile.replace(/\\/g, "/");
320
- const marker = `/${segment}/`;
321
- const index = normalized.lastIndexOf(marker);
322
- if (index >= 0) return normalized.slice(index + marker.length);
323
- return path.basename(normalized);
324
- }
325
286
  function writeReadableLog(
326
287
  logRoot,
327
288
  file,
289
+ inputPatterns,
328
290
  suites,
329
291
  modeName,
330
292
  buildCommand,
331
293
  runCommand,
332
294
  snapshotSummary,
333
295
  ) {
334
- const relative = resolveArtifactRelativePath(file, "__tests__").replace(
335
- /\.ts$/,
296
+ const relative = resolveSpecRelativePath(file, inputPatterns).replace(
297
+ /\.ts$/i,
336
298
  ".log",
337
299
  );
338
300
  const filePath = path.join(logRoot, relative);
@@ -605,9 +567,6 @@ export async function run(
605
567
  const inputFiles = (await glob(inputPatterns)).sort((a, b) =>
606
568
  a.localeCompare(b),
607
569
  );
608
- const duplicateSpecBasenames = await resolveDuplicateSpecBasenames(
609
- config.input,
610
- );
611
570
  const snapshotEnabled = flags.snapshot !== false;
612
571
  const createSnapshots = Boolean(flags.createSnapshots);
613
572
  const overwriteSnapshots = Boolean(flags.overwriteSnapshots);
@@ -685,12 +644,7 @@ export async function run(
685
644
  const file = inputFiles[i];
686
645
  const outFile = path.join(
687
646
  config.outDir,
688
- resolveArtifactFileName(
689
- file,
690
- config.buildOptions.target,
691
- options.modeName,
692
- duplicateSpecBasenames,
693
- ),
647
+ resolveArtifactPath(file, config.input),
694
648
  );
695
649
  if (!existsSync(outFile)) {
696
650
  const buildStartedAt = Date.now();
@@ -725,7 +679,7 @@ export async function run(
725
679
  const snapshotStore = new SnapshotStore(
726
680
  file,
727
681
  config.snapshotDir,
728
- duplicateSpecBasenames,
682
+ config.input,
729
683
  );
730
684
  let report;
731
685
  try {
@@ -739,11 +693,16 @@ export async function run(
739
693
  ? { BROWSER: config.runOptions.runtime.browser.trim() }
740
694
  : {}),
741
695
  };
696
+ const crashEntryKey = resolveSpecRelativePath(
697
+ file,
698
+ config.input,
699
+ ).replace(/\.ts$/i, "");
742
700
  report = webSession
743
701
  ? await runWebSessionProcess(
744
702
  webSession,
745
703
  file,
746
704
  config.fuzz.crashDir,
705
+ crashEntryKey,
747
706
  options.modeName,
748
707
  snapshotStore,
749
708
  snapshotEnabled,
@@ -757,6 +716,7 @@ export async function run(
757
716
  invocation,
758
717
  file,
759
718
  config.fuzz.crashDir,
719
+ crashEntryKey,
760
720
  options.modeName,
761
721
  snapshotStore,
762
722
  snapshotEnabled,
@@ -815,6 +775,7 @@ export async function run(
815
775
  writeReadableLog(
816
776
  logRoot,
817
777
  report.file,
778
+ config.input,
818
779
  report.suites,
819
780
  options.modeName,
820
781
  options.buildCommandsByFile?.[report.file] ??
@@ -842,16 +803,12 @@ export async function run(
842
803
  coverageSummary.files.length > 0
843
804
  ) {
844
805
  const resolvedCoverageDir = path.join(process.cwd(), coverageDir);
845
- if (!existsSync(resolvedCoverageDir)) {
846
- mkdirSync(resolvedCoverageDir, { recursive: true });
847
- }
848
- writeFileSync(
849
- path.join(
850
- resolvedCoverageDir,
851
- options.coverageFileName ?? "coverage.log.json",
852
- ),
853
- JSON.stringify(coverageSummary, null, 2),
806
+ const coverageFilePath = path.join(
807
+ resolvedCoverageDir,
808
+ options.coverageFileName ?? "coverage.log.json",
854
809
  );
810
+ mkdirSync(path.dirname(coverageFilePath), { recursive: true });
811
+ writeFileSync(coverageFilePath, JSON.stringify(coverageSummary, null, 2));
855
812
  }
856
813
  if (options.emitRunComplete !== false) {
857
814
  const totalModes = Math.max(options.modeSummaryTotal ?? 1, 1);
@@ -1091,53 +1048,6 @@ instantiate(imports)
1091
1048
  }
1092
1049
  return null;
1093
1050
  }
1094
- function resolveArtifactFileName(
1095
- file,
1096
- target,
1097
- modeName,
1098
- duplicateSpecBasenames = new Set(),
1099
- ) {
1100
- const base = path
1101
- .basename(file)
1102
- .replace(/\.spec\.ts$/, "")
1103
- .replace(/\.ts$/, "");
1104
- const legacy = !modeName
1105
- ? `${path.basename(file).replace(".ts", ".wasm")}`
1106
- : `${base}.${modeName}.${target}.wasm`;
1107
- if (!duplicateSpecBasenames.has(path.basename(file))) {
1108
- return legacy;
1109
- }
1110
- const disambiguator = resolveDisambiguator(file, duplicateSpecBasenames);
1111
- if (!disambiguator.length) {
1112
- return legacy;
1113
- }
1114
- const ext = path.extname(legacy);
1115
- const stem = ext.length ? legacy.slice(0, -ext.length) : legacy;
1116
- return `${stem}.${disambiguator}${ext}`;
1117
- }
1118
- async function resolveDuplicateSpecBasenames(configured) {
1119
- const patterns = Array.isArray(configured) ? configured : [configured];
1120
- const files = await glob(patterns);
1121
- const counts = new Map();
1122
- for (const file of files) {
1123
- const base = path.basename(file);
1124
- counts.set(base, (counts.get(base) ?? 0) + 1);
1125
- }
1126
- const duplicates = new Set();
1127
- for (const [base, count] of counts) {
1128
- if (count > 1) duplicates.add(base);
1129
- }
1130
- return duplicates;
1131
- }
1132
- function resolveDisambiguator(file, duplicateSpecBasenames) {
1133
- if (!duplicateSpecBasenames.has(path.basename(file))) return "";
1134
- const relDir = path.dirname(path.relative(process.cwd(), file));
1135
- if (!relDir.length || relDir == ".") return "";
1136
- return relDir
1137
- .replace(/[\\/]+/g, "__")
1138
- .replace(/[^A-Za-z0-9._-]/g, "_")
1139
- .replace(/^_+|_+$/g, "");
1140
- }
1141
1051
  function resolveRuntimeTargetEnv(target, wasmPath) {
1142
1052
  if (target == "bindings") {
1143
1053
  return resolveBindingsRuntimeEnv(wasmPath);
@@ -1757,6 +1667,7 @@ async function runProcess(
1757
1667
  invocation,
1758
1668
  specFile,
1759
1669
  crashDir,
1670
+ crashEntryKey,
1760
1671
  modeName,
1761
1672
  snapshots,
1762
1673
  snapshotEnabled,
@@ -1975,6 +1886,7 @@ async function runProcess(
1975
1886
  persistCrashRecord(crashDir, {
1976
1887
  kind: "test",
1977
1888
  file: specFile,
1889
+ entryKey: crashEntryKey,
1978
1890
  mode: modeName ?? "default",
1979
1891
  error: errorText,
1980
1892
  stdout: stdoutBuffer,
@@ -2031,6 +1943,7 @@ async function runProcess(
2031
1943
  persistCrashRecord(crashDir, {
2032
1944
  kind: "test",
2033
1945
  file: specFile,
1946
+ entryKey: crashEntryKey,
2034
1947
  mode: modeName ?? "default",
2035
1948
  error: fullError,
2036
1949
  stdout: stdoutBuffer,
@@ -2075,6 +1988,7 @@ async function runProcess(
2075
1988
  persistCrashRecord(crashDir, {
2076
1989
  kind: "test",
2077
1990
  file: specFile,
1991
+ entryKey: crashEntryKey,
2078
1992
  mode: modeName ?? "default",
2079
1993
  error: errorText || "runtime reported an unknown error",
2080
1994
  stdout: stdoutBuffer,
@@ -2105,6 +2019,7 @@ async function runProcess(
2105
2019
  persistCrashRecord(crashDir, {
2106
2020
  kind: "test",
2107
2021
  file: specFile,
2022
+ entryKey: crashEntryKey,
2108
2023
  mode: modeName ?? "default",
2109
2024
  error: fullError,
2110
2025
  stdout: stdoutBuffer,
@@ -2132,6 +2047,7 @@ async function runProcess(
2132
2047
  persistCrashRecord(crashDir, {
2133
2048
  kind: "test",
2134
2049
  file: specFile,
2050
+ entryKey: crashEntryKey,
2135
2051
  mode: modeName ?? "default",
2136
2052
  error: errorText || "runtime reported an unknown error",
2137
2053
  stdout: stdoutBuffer,
@@ -2155,6 +2071,7 @@ async function runWebSessionProcess(
2155
2071
  session,
2156
2072
  specFile,
2157
2073
  crashDir,
2074
+ crashEntryKey,
2158
2075
  modeName,
2159
2076
  snapshots,
2160
2077
  snapshotEnabled,
@@ -2407,6 +2324,7 @@ async function runWebSessionProcess(
2407
2324
  persistCrashRecord(crashDir, {
2408
2325
  kind: "test",
2409
2326
  file: specFile,
2327
+ entryKey: crashEntryKey,
2410
2328
  mode: modeName ?? "default",
2411
2329
  error: fullError,
2412
2330
  stdout: stdoutBuffer,
@@ -2462,6 +2380,7 @@ async function runWebSessionProcess(
2462
2380
  persistCrashRecord(crashDir, {
2463
2381
  kind: "test",
2464
2382
  file: specFile,
2383
+ entryKey: crashEntryKey,
2465
2384
  mode: modeName ?? "default",
2466
2385
  error: fullError,
2467
2386
  stdout: stdoutBuffer,
@@ -56,6 +56,10 @@ export class PersistentWebSessionHost {
56
56
  ? env.AS_TEST_HELPER_PATH
57
57
  : null;
58
58
  const jobId = String(this.nextJobId++);
59
+ // URL paths use only the basename — that is safe because each job has
60
+ // exactly one wasm and one helper, scoped by the jobId path component.
61
+ // Nested directory artifacts (e.g. nested/array.spec.wasm) are read
62
+ // from disk via `wasmPath`/`helperPath`, not via these URLs.
59
63
  const browserEnv = {
60
64
  ...env,
61
65
  AS_TEST_WASM_PATH: `/job/${jobId}/${path.basename(wasmPath)}`,
@@ -5,9 +5,9 @@ export function persistCrashRecord(rootDir, record) {
5
5
  ? record.entryKey
6
6
  : crashEntryKey(record.file);
7
7
  const dir = path.resolve(process.cwd(), rootDir);
8
- mkdirSync(dir, { recursive: true });
9
8
  const jsonPath = path.join(dir, `${entry}.json`);
10
9
  const logPath = path.join(dir, `${entry}.log`);
10
+ mkdirSync(path.dirname(jsonPath), { recursive: true });
11
11
  const payload = {
12
12
  timestamp: new Date().toISOString(),
13
13
  ...record,
package/bin/index.js CHANGED
@@ -22,7 +22,9 @@ import {
22
22
  getDefaultModeNames,
23
23
  getCliVersion,
24
24
  loadConfig,
25
+ resolveArtifactPath,
25
26
  resolveModeNames,
27
+ resolveSpecRelativePath,
26
28
  } from "./util.js";
27
29
  import * as path from "path";
28
30
  import { spawnSync } from "child_process";
@@ -1366,8 +1368,7 @@ async function runTestSequential(
1366
1368
  const results = [];
1367
1369
  let failed = false;
1368
1370
  const buildIntervals = [];
1369
- const duplicateSpecBasenames =
1370
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
1371
+ const inputPatterns = await loadInputPatterns(configPath);
1371
1372
  for (const file of files) {
1372
1373
  const buildStartedAt = Date.now();
1373
1374
  let result;
@@ -1380,10 +1381,7 @@ async function runTestSequential(
1380
1381
  modeName,
1381
1382
  buildFeatureToggles,
1382
1383
  );
1383
- const artifactKey = resolvePerFileArtifactKey(
1384
- file,
1385
- duplicateSpecBasenames,
1386
- );
1384
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
1387
1385
  result = await run(runFlags, configPath, [file], false, {
1388
1386
  reporter,
1389
1387
  webSession,
@@ -1391,7 +1389,7 @@ async function runTestSequential(
1391
1389
  emitRunStart: false,
1392
1390
  emitRunComplete: false,
1393
1391
  logFileName: `test.${artifactKey}.log.json`,
1394
- coverageFileName: `coverage.${artifactKey}.log.json`,
1392
+ coverageFileName: `${artifactKey}.log.json`,
1395
1393
  buildCommand: formatBuildInvocation(buildInvocation),
1396
1394
  modeName,
1397
1395
  });
@@ -1645,8 +1643,7 @@ async function runRuntimeMatrix(
1645
1643
  failed: false,
1646
1644
  passed: false,
1647
1645
  }));
1648
- const duplicateSpecBasenames =
1649
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
1646
+ const inputPatterns = await loadInputPatterns(configPath);
1650
1647
  const buildIntervals = [];
1651
1648
  for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
1652
1649
  const file = files[fileIndex];
@@ -1665,10 +1662,7 @@ async function runRuntimeMatrix(
1665
1662
  modeName,
1666
1663
  {},
1667
1664
  );
1668
- const artifactKey = resolvePerFileArtifactKey(
1669
- file,
1670
- duplicateSpecBasenames,
1671
- );
1665
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
1672
1666
  const result = await run(runFlags, configPath, [file], false, {
1673
1667
  reporter: silentReporter,
1674
1668
  reporterKind: "default",
@@ -1676,7 +1670,7 @@ async function runRuntimeMatrix(
1676
1670
  emitRunStart: false,
1677
1671
  emitRunComplete: false,
1678
1672
  logFileName: `run.${artifactKey}.log.json`,
1679
- coverageFileName: `coverage.${artifactKey}.log.json`,
1673
+ coverageFileName: `${artifactKey}.log.json`,
1680
1674
  buildCommand: formatBuildInvocation(buildInvocation),
1681
1675
  modeName,
1682
1676
  });
@@ -2106,8 +2100,7 @@ async function runTestMatrix(
2106
2100
  failed: false,
2107
2101
  passed: false,
2108
2102
  }));
2109
- const duplicateSpecBasenames =
2110
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
2103
+ const inputPatterns = await loadInputPatterns(configPath);
2111
2104
  const buildIntervals = [];
2112
2105
  for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
2113
2106
  const file = files[fileIndex];
@@ -2135,17 +2128,14 @@ async function runTestMatrix(
2135
2128
  modeName,
2136
2129
  buildFeatureToggles,
2137
2130
  );
2138
- const artifactKey = resolvePerFileArtifactKey(
2139
- file,
2140
- duplicateSpecBasenames,
2141
- );
2131
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
2142
2132
  result = await run(runFlags, configPath, [file], false, {
2143
2133
  reporter: silentReporter,
2144
2134
  reporterKind: "default",
2145
2135
  emitRunStart: false,
2146
2136
  emitRunComplete: false,
2147
2137
  logFileName: `test.${artifactKey}.log.json`,
2148
- coverageFileName: `coverage.${artifactKey}.log.json`,
2138
+ coverageFileName: `${artifactKey}.log.json`,
2149
2139
  buildCommand: formatBuildInvocation(buildInvocation),
2150
2140
  modeName,
2151
2141
  });
@@ -2413,8 +2403,7 @@ async function runRuntimeMatrixParallel(
2413
2403
  const silentReporter = {};
2414
2404
  const modeLabels = modes.map((modeName) => modeName ?? "default");
2415
2405
  const showPerModeTimes = Boolean(runFlags.verbose);
2416
- const duplicateSpecBasenames =
2417
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
2406
+ const inputPatterns = await loadInputPatterns(configPath);
2418
2407
  const ordered = new Array(files.length);
2419
2408
  const useQueueDisplay = reporterSession.reporterKind == "default";
2420
2409
  const queueDisplay = new ParallelQueueDisplay(
@@ -2454,10 +2443,7 @@ async function runRuntimeMatrixParallel(
2454
2443
  modeName,
2455
2444
  {},
2456
2445
  );
2457
- const artifactKey = resolvePerFileArtifactKey(
2458
- file,
2459
- duplicateSpecBasenames,
2460
- );
2446
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
2461
2447
  result = await run(runFlags, configPath, [file], false, {
2462
2448
  reporter: silentReporter,
2463
2449
  reporterKind: "default",
@@ -2465,7 +2451,7 @@ async function runRuntimeMatrixParallel(
2465
2451
  emitRunStart: false,
2466
2452
  emitRunComplete: false,
2467
2453
  logFileName: `run.${artifactKey}.log.json`,
2468
- coverageFileName: `coverage.${artifactKey}.log.json`,
2454
+ coverageFileName: `${artifactKey}.log.json`,
2469
2455
  buildCommand: formatBuildInvocation(buildInvocation),
2470
2456
  modeName,
2471
2457
  });
@@ -2563,8 +2549,7 @@ async function runTestSingleParallel(
2563
2549
  snapshotEnabled,
2564
2550
  createSnapshots: runFlags.createSnapshots,
2565
2551
  });
2566
- const duplicateSpecBasenames =
2567
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
2552
+ const inputPatterns = await loadInputPatterns(configPath);
2568
2553
  const results = new Array(files.length);
2569
2554
  const useQueueDisplay = reporterSession.reporterKind == "default";
2570
2555
  const queueDisplay = new ParallelQueueDisplay(
@@ -2607,10 +2592,7 @@ async function runTestSingleParallel(
2607
2592
  modeName,
2608
2593
  buildFeatureToggles,
2609
2594
  );
2610
- const artifactKey = resolvePerFileArtifactKey(
2611
- file,
2612
- duplicateSpecBasenames,
2613
- );
2595
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
2614
2596
  result = await run(
2615
2597
  { ...runFlags, clean: true },
2616
2598
  configPath,
@@ -2622,7 +2604,7 @@ async function runTestSingleParallel(
2622
2604
  suiteSelectors,
2623
2605
  emitRunComplete: false,
2624
2606
  logFileName: `test.${artifactKey}.log.json`,
2625
- coverageFileName: `coverage.${artifactKey}.log.json`,
2607
+ coverageFileName: `${artifactKey}.log.json`,
2626
2608
  buildCommand: formatBuildInvocation(buildInvocation),
2627
2609
  modeName,
2628
2610
  },
@@ -2734,8 +2716,7 @@ async function runTestMatrixParallel(
2734
2716
  const silentReporter = {};
2735
2717
  const modeLabels = modes.map((modeName) => modeName ?? "default");
2736
2718
  const showPerModeTimes = Boolean(runFlags.verbose);
2737
- const duplicateSpecBasenames =
2738
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
2719
+ const inputPatterns = await loadInputPatterns(configPath);
2739
2720
  const ordered = new Array(files.length);
2740
2721
  const useQueueDisplay = reporterSession.reporterKind == "default";
2741
2722
  const queueDisplay = new ParallelQueueDisplay(
@@ -2775,10 +2756,7 @@ async function runTestMatrixParallel(
2775
2756
  modeName,
2776
2757
  buildFeatureToggles,
2777
2758
  );
2778
- const artifactKey = resolvePerFileArtifactKey(
2779
- file,
2780
- duplicateSpecBasenames,
2781
- );
2759
+ const artifactKey = resolveArtifactStem(file, inputPatterns);
2782
2760
  result = await run(runFlags, configPath, [file], false, {
2783
2761
  reporter: silentReporter,
2784
2762
  reporterKind: "default",
@@ -2786,7 +2764,7 @@ async function runTestMatrixParallel(
2786
2764
  emitRunStart: false,
2787
2765
  emitRunComplete: false,
2788
2766
  logFileName: `test.${artifactKey}.log.json`,
2789
- coverageFileName: `coverage.${artifactKey}.log.json`,
2767
+ coverageFileName: `${artifactKey}.log.json`,
2790
2768
  buildCommand: formatBuildInvocation(buildInvocation),
2791
2769
  modeName,
2792
2770
  });
@@ -3601,80 +3579,21 @@ function isBareSuiteSelector(selector) {
3601
3579
  function stripSuiteSuffix(selector) {
3602
3580
  return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
3603
3581
  }
3604
- function resolveDuplicateSpecBasenames(files) {
3605
- const counts = new Map();
3606
- for (const file of files) {
3607
- const base = path.basename(file);
3608
- counts.set(base, (counts.get(base) ?? 0) + 1);
3609
- }
3610
- const duplicates = new Set();
3611
- for (const [base, count] of counts) {
3612
- if (count > 1) duplicates.add(base);
3613
- }
3614
- return duplicates;
3582
+ // Returns the spec relative path (under the configured input base) with the
3583
+ // trailing ".ts" stripped, suitable for use as a stable per-file key for
3584
+ // coverage and log filenames.
3585
+ function resolveArtifactStem(file, inputPatterns) {
3586
+ return resolveSpecRelativePath(file, inputPatterns).replace(/\.ts$/i, "");
3615
3587
  }
3616
- // Disambiguation must consider the full configured input set, not the
3617
- // selector-filtered subset, otherwise running a single spec writes/looks up an
3618
- // artifact name that the rest of the toolchain doesn't agree on.
3619
- async function resolveAllConfiguredDuplicateSpecBasenames(configPath) {
3588
+ async function loadInputPatterns(configPath) {
3620
3589
  const resolvedConfigPath =
3621
3590
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
3622
- const config = loadConfig(resolvedConfigPath, false);
3623
- return resolveDuplicateBasenamesForPatterns(config.input);
3591
+ return loadConfig(resolvedConfigPath, false).input;
3624
3592
  }
3625
- async function resolveAllConfiguredDuplicateFuzzBasenames(configPath) {
3593
+ async function loadFuzzInputPatterns(configPath) {
3626
3594
  const resolvedConfigPath =
3627
3595
  configPath ?? path.join(process.cwd(), "./as-test.config.json");
3628
- const config = loadConfig(resolvedConfigPath, false);
3629
- return resolveDuplicateBasenamesForPatterns(config.fuzz.input);
3630
- }
3631
- async function resolveDuplicateBasenamesForPatterns(configured) {
3632
- const patterns = Array.isArray(configured) ? configured : [configured];
3633
- const files = await glob(patterns);
3634
- return resolveDuplicateSpecBasenames(files);
3635
- }
3636
- function resolvePerFileArtifactKey(file, duplicateSpecBasenames) {
3637
- const base = path.basename(file);
3638
- let raw = base;
3639
- if (duplicateSpecBasenames.has(base)) {
3640
- const disambiguator = resolvePerFileDisambiguator(file);
3641
- if (disambiguator.length) {
3642
- raw = `${base}.${disambiguator}`;
3643
- }
3644
- }
3645
- return raw.replace(/[^a-zA-Z0-9._-]/g, "_");
3646
- }
3647
- function resolvePerFileDisambiguator(file) {
3648
- const relDir = path.dirname(path.relative(process.cwd(), file));
3649
- if (!relDir.length || relDir == ".") return "";
3650
- return relDir
3651
- .replace(/[\\/]+/g, "__")
3652
- .replace(/[^A-Za-z0-9._-]/g, "_")
3653
- .replace(/^_+|_+$/g, "");
3654
- }
3655
- function resolveArtifactFileNameForPreview(
3656
- file,
3657
- target,
3658
- modeName,
3659
- duplicateSpecBasenames,
3660
- ) {
3661
- const base = path
3662
- .basename(file)
3663
- .replace(/\.spec\.ts$/, "")
3664
- .replace(/\.ts$/, "");
3665
- const legacy = !modeName
3666
- ? `${path.basename(file).replace(".ts", ".wasm")}`
3667
- : `${base}.${modeName}.${target}.wasm`;
3668
- if (!duplicateSpecBasenames.has(path.basename(file))) {
3669
- return legacy;
3670
- }
3671
- const disambiguator = resolvePerFileDisambiguator(file);
3672
- if (!disambiguator.length) {
3673
- return legacy;
3674
- }
3675
- const ext = path.extname(legacy);
3676
- const stem = ext.length ? legacy.slice(0, -ext.length) : legacy;
3677
- return `${stem}.${disambiguator}${ext}`;
3596
+ return loadConfig(resolvedConfigPath, false).fuzz.input;
3678
3597
  }
3679
3598
  async function ensureWebBrowsersReady(configPath, modes, browserOverride) {
3680
3599
  const resolvedConfigPath =
@@ -4201,10 +4120,8 @@ async function listExecutionPlan(
4201
4120
  : `No test files matched: ${scope}`,
4202
4121
  );
4203
4122
  }
4204
- const duplicateSpecBasenames =
4205
- await resolveAllConfiguredDuplicateSpecBasenames(configPath);
4206
- const duplicateFuzzBasenames =
4207
- await resolveAllConfiguredDuplicateFuzzBasenames(configPath);
4123
+ const inputPatterns = await loadInputPatterns(configPath);
4124
+ const fuzzInputPatterns = await loadFuzzInputPatterns(configPath);
4208
4125
  if (specFiles.length) {
4209
4126
  process.stdout.write(chalk.bold("Resolved files:\n"));
4210
4127
  for (const file of specFiles) {
@@ -4258,12 +4175,7 @@ async function listExecutionPlan(
4258
4175
  if (specFiles.length) {
4259
4176
  process.stdout.write(" artifacts:\n");
4260
4177
  for (const file of specFiles) {
4261
- const artifactName = resolveArtifactFileNameForPreview(
4262
- file,
4263
- active.buildOptions.target,
4264
- modeName,
4265
- duplicateSpecBasenames,
4266
- );
4178
+ const artifactName = resolveArtifactPath(file, inputPatterns);
4267
4179
  process.stdout.write(
4268
4180
  ` - ${path.join(active.outDir, artifactName)}\n`,
4269
4181
  );
@@ -4272,12 +4184,7 @@ async function listExecutionPlan(
4272
4184
  if (fuzzFiles.length && command == "test") {
4273
4185
  process.stdout.write(" fuzz artifacts:\n");
4274
4186
  for (const file of fuzzFiles) {
4275
- const artifactName = resolveArtifactFileNameForPreview(
4276
- file,
4277
- "bindings",
4278
- modeName,
4279
- duplicateFuzzBasenames,
4280
- );
4187
+ const artifactName = resolveArtifactPath(file, fuzzInputPatterns);
4281
4188
  process.stdout.write(
4282
4189
  ` - ${path.join(active.outDir, artifactName)}\n`,
4283
4190
  );
@@ -4285,12 +4192,7 @@ async function listExecutionPlan(
4285
4192
  } else if (command == "fuzz") {
4286
4193
  process.stdout.write(" artifacts:\n");
4287
4194
  for (const file of fuzzFiles) {
4288
- const artifactName = resolveArtifactFileNameForPreview(
4289
- file,
4290
- "bindings",
4291
- modeName,
4292
- duplicateFuzzBasenames,
4293
- );
4195
+ const artifactName = resolveArtifactPath(file, fuzzInputPatterns);
4294
4196
  process.stdout.write(
4295
4197
  ` - ${path.join(active.outDir, artifactName)}\n`,
4296
4198
  );
package/bin/util.js CHANGED
@@ -1449,3 +1449,77 @@ export function resolveProjectModule(specifier) {
1449
1449
  }
1450
1450
  return null;
1451
1451
  }
1452
+ // picomatch-compatible glob metacharacters; first occurrence marks the start
1453
+ // of the dynamic part of a pattern and everything before it is the static base.
1454
+ const GLOB_META_RE = /[*?[\](){}!|+@]/;
1455
+ // Longest non-glob prefix of a single pattern, returned with native separators.
1456
+ // Examples:
1457
+ // "assembly/__tests__/**/*.spec.ts" -> "assembly/__tests__"
1458
+ // "**/*.spec.ts" -> ""
1459
+ // "assembly/foo.spec.ts" -> "assembly" (no glob → use dirname)
1460
+ // "/abs/path/**/*.ts" -> "/abs/path"
1461
+ export function resolveGlobBase(pattern) {
1462
+ const normalized = pattern.replace(/\\/g, "/");
1463
+ const metaIdx = normalized.search(GLOB_META_RE);
1464
+ let base;
1465
+ if (metaIdx < 0) {
1466
+ const dir = dirname(normalized);
1467
+ base = dir == "." ? "" : dir;
1468
+ } else {
1469
+ const slice = normalized.slice(0, metaIdx);
1470
+ const lastSlash = slice.lastIndexOf("/");
1471
+ base = lastSlash < 0 ? "" : slice.slice(0, lastSlash);
1472
+ }
1473
+ if (!base.length) return "";
1474
+ return base.split("/").join(sep);
1475
+ }
1476
+ // Strip the most-specific matching configured input base off `file`, returning
1477
+ // the path relative to that base (with native separators). If no base matches,
1478
+ // returns the basename of the file. Comparison is component-wise — so
1479
+ // "assembly/__tests" is not a prefix of "assembly/__tests__/foo.spec.ts".
1480
+ export function resolveSpecRelativePath(file, inputPatterns) {
1481
+ const patterns = Array.isArray(inputPatterns)
1482
+ ? inputPatterns
1483
+ : [inputPatterns];
1484
+ const absFile = resolve(process.cwd(), file);
1485
+ const fileComponents = toComponents(absFile);
1486
+ let bestBaseAbs = null;
1487
+ let bestLength = -1;
1488
+ for (const pattern of patterns) {
1489
+ const base = resolveGlobBase(pattern);
1490
+ const absBase = base.length
1491
+ ? resolve(process.cwd(), base)
1492
+ : resolve(process.cwd());
1493
+ const baseComponents = toComponents(absBase);
1494
+ if (!isComponentPrefix(baseComponents, fileComponents)) continue;
1495
+ if (baseComponents.length > bestLength) {
1496
+ bestBaseAbs = absBase;
1497
+ bestLength = baseComponents.length;
1498
+ }
1499
+ }
1500
+ if (bestBaseAbs == null) return basename(file);
1501
+ const rel = relative(bestBaseAbs, absFile);
1502
+ return rel.length ? rel : basename(file);
1503
+ }
1504
+ // Compute the artifact path (relative to outDir) for a given spec/fuzz source
1505
+ // file. Strips ".ts" only, keeping ".spec" / ".fuzz" suffixes so spec and
1506
+ // fuzz artifacts can coexist in the same outDir.
1507
+ // assembly/__tests__/array.spec.ts -> "array.spec.wasm"
1508
+ // assembly/__tests__/nested/array.spec.ts -> "nested/array.spec.wasm"
1509
+ // assembly/__fuzz__/nested/array.fuzz.ts -> "nested/array.fuzz.wasm"
1510
+ export function resolveArtifactPath(file, inputPatterns) {
1511
+ const rel = resolveSpecRelativePath(file, inputPatterns);
1512
+ return rel.replace(/\.ts$/i, ".wasm");
1513
+ }
1514
+ function toComponents(absPath) {
1515
+ return absPath
1516
+ .split(/[\\/]+/)
1517
+ .filter((segment, idx) => segment.length || idx == 0);
1518
+ }
1519
+ function isComponentPrefix(prefix, full) {
1520
+ if (prefix.length > full.length) return false;
1521
+ for (let i = 0; i < prefix.length; i++) {
1522
+ if (prefix[i] !== full[i]) return false;
1523
+ }
1524
+ return true;
1525
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "as-test",
3
- "version": "1.1.10",
3
+ "version": "1.2.0",
4
4
  "author": "Jairus Tanaka",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,16 +18,17 @@
18
18
  "devDependencies": {
19
19
  "@assemblyscript/wasi-shim": "^0.1.0",
20
20
  "@eslint/js": "^10.0.1",
21
- "@types/node": "^25.6.0",
21
+ "@types/node": "^25.9.1",
22
22
  "as-sleep": "^0.0.2",
23
23
  "as-test": "./",
24
24
  "assemblyscript": "^0.28.17",
25
25
  "assemblyscript-prettier": "^3.0.4",
26
- "json-as": "^1.3.6",
26
+ "husky": "^9.1.7",
27
+ "json-as": "^1.3.7",
27
28
  "prettier": "3.8.3",
28
- "try-as": "^1.0.1",
29
+ "try-as": "^1.1.0",
29
30
  "typescript": "^6.0.3",
30
- "typescript-eslint": "^8.59.1",
31
+ "typescript-eslint": "^8.59.4",
31
32
  "vitepress": "^1.6.4"
32
33
  },
33
34
  "bin": {
@@ -76,9 +77,9 @@
76
77
  },
77
78
  "scripts": {
78
79
  "test": "npm run test:as && npm run test:integration",
79
- "test:as": "node ./bin/index.js test --parallel",
80
+ "test:as": "node ./bin/index.js test --parallel --enable try-as",
80
81
  "test:integration": "npm run build:cli && npm run build:lib && node --test tests/*.test.mjs",
81
- "test:ci": "node ./bin/index.js test --parallel --tap --config ./as-test.ci.config.json",
82
+ "test:ci": "node ./bin/index.js test --parallel --tap --enable try-as --config ./as-test.ci.config.json",
82
83
  "fuzz": "node ./bin/index.js fuzz",
83
84
  "bench:seed": "node ./bin/index.js fuzz --config ./as-test.bench.config.json --clean",
84
85
  "bench:seed:compare": "bash ./tools/bench-seed-compare.sh 7",
@@ -98,7 +99,11 @@
98
99
  "docs:preview": "vitepress preview docs",
99
100
  "format": "prettier -w .",
100
101
  "release:check": "npm run build:cli && npm run build:lib && npm run build:transform && npm run test && npm run test:examples && npm pack --dry-run --cache /tmp/as-test-npm-cache",
101
- "prepublishOnly": "npm run build:cli && npm run build:lib && npm run build:transform && npm run test && npm run format"
102
+ "prepublishOnly": "npm run build:cli && npm run build:lib && npm run build:transform && npm run test && npm run format",
103
+ "commitmsg:verify": "bash ./scripts/commit-msg.sh",
104
+ "precommit:verify": "bash ./scripts/pre-commit.sh",
105
+ "prepush:verify": "bash ./scripts/pre-push.sh",
106
+ "prepare": "husky"
102
107
  },
103
108
  "type": "module"
104
109
  }