as-test 0.5.3 → 1.0.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,27 @@
1
1
  # Change Log
2
2
 
3
+ ## 2026-03-11 - v1.0.0
4
+
5
+ ### Docs
6
+
7
+ - docs: add README guidance for strict config validation behavior and example error output.
8
+
9
+ ### CLI & Config Validation
10
+
11
+ - feat: validate config shape and field types before applying defaults for all CLI entry points.
12
+ - feat: reject unknown config keys with nearest-key suggestions.
13
+ - feat: show structured validation diagnostics with JSON paths and fix hints.
14
+ - feat: fail fast on invalid config JSON with parser error details.
15
+ -
16
+ ### Release Readiness
17
+
18
+ - fix: resolve `@assemblyscript/wasi-shim` and `try-as` with package resolution instead of assuming a local `./node_modules` folder, so nested example projects and other valid installs run correctly.
19
+ - fix: pass the WASI shim config to `asc` as a cwd-relative path, which avoids nested-project WASI build failures with standalone examples.
20
+ - feat: add root `test:examples` coverage and include full standalone example validation in `release:check`.
21
+ - feat: validate all examples in both `wasi` and `bindings` modes as part of release readiness.
22
+ - docs: update README and examples docs for the standalone example layout and root-level validation flow.
23
+ - chore: promote package version to `1.0.0` and switch `publishConfig` to public npm publishing.
24
+
3
25
  ## 2026-02-25 - v0.5.3
4
26
 
5
27
  ### CLI, Modes & Matrix
package/README.md CHANGED
@@ -9,11 +9,13 @@
9
9
  - [Installation](#installation)
10
10
  - [Examples](#examples)
11
11
  - [Writing Tests](#writing-tests)
12
+ - [Setup Diagnostics](#setup-diagnostics)
12
13
  - [Mocking](#mocking)
13
14
  - [Snapshots](#snapshots)
14
15
  - [Coverage](#coverage)
15
16
  - [Custom Reporters](#custom-reporters)
16
17
  - [Assertions](#assertions)
18
+ - [CLI Style Guide](#cli-style-guide)
17
19
  - [License](#license)
18
20
  - [Contact](#contact)
19
21
 
@@ -40,6 +42,11 @@ The installation script will set everything up for you:
40
42
  npx as-test init --dir ./path-to-install
41
43
  ```
42
44
 
45
+ To scaffold and install dependencies in one step:
46
+ ```bash
47
+ npx as-test init --dir ./path-to-install --install
48
+ ```
49
+
43
50
  Alternatively, you can install it manually:
44
51
  ```bash
45
52
  npm install as-test --save-dev
@@ -49,13 +56,19 @@ npm install as-test --save-dev
49
56
 
50
57
  Full runnable examples live in `examples/`, including:
51
58
 
59
+ - one standalone project per example (initialized with `ast init`)
52
60
  - complete spec files for core features
53
61
  - import mocking and import snapshot patterns
54
- - mode-based runtime matrix config in `examples/as-test.config.json`
55
- - a dedicated config you can run directly
56
62
 
57
63
  See `examples/README.md` for the walkthrough.
58
64
 
65
+ Quick validation from this repo:
66
+
67
+ ```bash
68
+ npm test
69
+ npm run test:examples
70
+ ```
71
+
59
72
  ## Writing Tests
60
73
 
61
74
  Create `assembly/__tests__/math.spec.ts`:
@@ -130,6 +143,9 @@ No test files matched: ...
130
143
  - `--disable <feature>`: disable as-test feature (`coverage`, `try-as`)
131
144
  - `--verbose`: keep expanded suite/test lines and update running `....` statuses in place
132
145
  - `--clean`: disable in-place TTY updates and print only final per-file verdict lines. Useful for CI/CD.
146
+ - `--list`: show resolved files, per-mode artifacts, and runtime command without executing
147
+ - `--list-modes`: show configured and selected modes without executing
148
+ - `--help` / `-h`: show command-specific help (`ast test --help`, `ast init --help`, etc.)
133
149
 
134
150
  Example:
135
151
 
@@ -138,6 +154,39 @@ ast build --enable try-as
138
154
  ast test --disable coverage
139
155
  ```
140
156
 
157
+ Preview execution plan:
158
+
159
+ ```bash
160
+ ast test --list
161
+ ast test --list-modes
162
+ ast run sleep --list --mode wasi
163
+ ast build --list --mode wasi,bindings
164
+ ```
165
+
166
+ ## Setup Diagnostics
167
+
168
+ Use `ast doctor` to validate local setup before running tests.
169
+
170
+ ```bash
171
+ ast doctor
172
+ ```
173
+
174
+ You can also target specific modes and config files:
175
+
176
+ ```bash
177
+ ast doctor --config ./as-test.config.json --mode wasi,bindings
178
+ ```
179
+
180
+ `doctor` checks:
181
+
182
+ - config file loading and mode resolution
183
+ - required dependencies (for example `assemblyscript`, `@assemblyscript/wasi-shim` for WASI targets)
184
+ - runtime command parsing and executable availability
185
+ - runtime script path existence (for script-host runtimes)
186
+ - test spec file discovery from configured input patterns
187
+
188
+ If any `ERROR` checks are found, `ast doctor` exits non-zero.
189
+
141
190
  ## Mocking
142
191
 
143
192
  Use these helpers when you need to replace behavior during tests:
@@ -248,10 +297,7 @@ Example:
248
297
  {
249
298
  "$schema": "./as-test.config.schema.json",
250
299
  "input": ["./assembly/__tests__/*.spec.ts"],
251
- "outDir": "./.as-test/build",
252
- "logs": "./.as-test/logs",
253
- "coverageDir": "./.as-test/coverage",
254
- "snapshotDir": "./.as-test/snapshots",
300
+ "output": "./.as-test/",
255
301
  "config": "none",
256
302
  "coverage": true,
257
303
  "env": {},
@@ -273,10 +319,12 @@ Example:
273
319
  Key fields:
274
320
 
275
321
  - `input`: glob list of spec files
322
+ - `output`: output alias. Use a root string (`"./.as-test/"`) or object (`{ "build": "...", "logs": "...", "coverage": "...", "snapshots": "..." }`)
276
323
  - `outDir`: compiled wasm output dir
277
324
  - `logs`: log output dir or `"none"`
278
325
  - `coverageDir`: coverage output dir or `"none"`
279
326
  - `snapshotDir`: snapshot storage dir
327
+ - `outDir`, `logs`, `coverageDir`, and `snapshotDir` still work; when both are set, these explicit fields override `output`
280
328
  - `env`: environment variables injected into build and runtime processes
281
329
  - `buildOptions.cmd`: optional custom build command template; when set it replaces default build command and flags. Supports `<file>`, `<name>`, `<outFile>`, `<target>`, `<mode>`
282
330
  - `buildOptions.target`: `wasi` or `bindings`
@@ -284,6 +332,25 @@ Key fields:
284
332
  - `runOptions.runtime.cmd`: runtime command, supports `<file>` and `<name>`; if its script path is missing, as-test falls back to the default runner for the selected target
285
333
  - `runOptions.reporter`: reporter selection as a string or object
286
334
 
335
+ Validation behavior:
336
+
337
+ - Config parsing is strict for `ast build`, `ast run`, `ast test`, and `ast doctor`.
338
+ - Invalid JSON fails early with parser details (`line`/`column` when provided by Node).
339
+ - Unknown properties are rejected and include a nearest-key suggestion when possible.
340
+ - Invalid property types are reported with their JSON path and a short fix hint.
341
+ - On validation failure, the command exits non-zero and prints `run "ast doctor" to check your setup.`
342
+
343
+ Example validation error:
344
+
345
+ ```text
346
+ invalid config at ./as-test.config.json
347
+ 1. $.inpoot: unknown property
348
+ fix: use "input" if that was intended, otherwise remove this property
349
+ 2. $.runOptions.runtime.cmd: must be a string
350
+ fix: set to a runtime command including "<file>"
351
+ run "ast doctor" to check your setup.
352
+ ```
353
+
287
354
  Example multi-runtime matrix:
288
355
 
289
356
  ```json
@@ -501,7 +568,7 @@ Available matchers:
501
568
  - `toStartWith(prefix)`
502
569
  - `toEndWith(suffix)`
503
570
  - `toHaveLength(length)`
504
- - `toContain(item)`
571
+ - `toContain(itemOrSubstring)` (`toContains` alias supported)
505
572
  - `toThrow()` (with `try-as`)
506
573
  - `toMatchSnapshot(name?)`
507
574
 
@@ -17,6 +17,37 @@
17
17
  },
18
18
  "default": ["./assembly/__tests__/*.spec.ts"]
19
19
  },
20
+ "output": {
21
+ "description": "Output alias. Use a root string or a per-artifact object. Legacy fields (outDir/logs/coverageDir/snapshotDir) still work and override this alias when both are set.",
22
+ "oneOf": [
23
+ {
24
+ "type": "string",
25
+ "description": "Root output directory. Expands to build/logs/coverage/snapshots subdirectories."
26
+ },
27
+ {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "build": {
32
+ "type": "string",
33
+ "description": "Build artifact output directory (maps to outDir)."
34
+ },
35
+ "logs": {
36
+ "type": "string",
37
+ "description": "Log artifact output directory (maps to logs). Use \"none\" to disable."
38
+ },
39
+ "coverage": {
40
+ "type": "string",
41
+ "description": "Coverage artifact output directory (maps to coverageDir). Use \"none\" to disable."
42
+ },
43
+ "snapshots": {
44
+ "type": "string",
45
+ "description": "Snapshot output directory (maps to snapshotDir)."
46
+ }
47
+ }
48
+ }
49
+ ]
50
+ },
20
51
  "outDir": {
21
52
  "type": "string",
22
53
  "description": "Directory where compiled artifacts are written.",
@@ -334,18 +334,41 @@ export class Expectation<T> extends Tests {
334
334
  }
335
335
 
336
336
  /**
337
- * Tests if an array contains an element
337
+ * Tests if an array or string contains a value
338
338
  */
339
339
  // @ts-ignore
340
340
  toContain(value: valueof<T>): void {
341
- // @ts-ignore
342
- const passed = isArray<T>() && this._left.includes(value);
343
- this._resolve(
344
- passed,
345
- "toContain",
346
- q("includes value"),
347
- q("does not include value"),
348
- );
341
+ if (isString<T>()) {
342
+ // @ts-ignore
343
+ const left = this._left as string;
344
+ // @ts-ignore
345
+ const needle = value as string;
346
+ const passed = left.indexOf(needle) >= 0;
347
+ this._resolve(passed, "toContain", q(left), q(needle));
348
+ return;
349
+ }
350
+
351
+ if (isArray<T>()) {
352
+ // @ts-ignore
353
+ const passed = this._left.includes(value);
354
+ this._resolve(
355
+ passed,
356
+ "toContain",
357
+ stringifyValue<T>(this._left),
358
+ stringifyValue<valueof<T>>(value),
359
+ );
360
+ return;
361
+ }
362
+
363
+ ERROR("toContain() can only be used on string and array types!");
364
+ }
365
+
366
+ /**
367
+ * Alias for toContain().
368
+ */
369
+ // @ts-ignore
370
+ toContains(value: valueof<T>): void {
371
+ this.toContain(value);
349
372
  }
350
373
 
351
374
  /**
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from "fs";
2
2
  import { glob } from "glob";
3
3
  import chalk from "chalk";
4
- import { execSync } from "child_process";
4
+ import { spawnSync } from "child_process";
5
5
  import * as path from "path";
6
- import { applyMode, getPkgRunner, loadConfig } from "./util.js";
6
+ import { applyMode, getPkgRunner, loadConfig, tokenizeCommand, resolveProjectModule, } from "../util.js";
7
7
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
8
8
  export async function build(configPath = DEFAULT_CONFIG_PATH, selectors = [], modeName, featureToggles = {}) {
9
9
  const loadedConfig = loadConfig(configPath, false);
@@ -15,20 +15,21 @@ export async function build(configPath = DEFAULT_CONFIG_PATH, selectors = [], mo
15
15
  const pkgRunner = getPkgRunner();
16
16
  const inputPatterns = resolveInputPatterns(config.input, selectors);
17
17
  const inputFiles = (await glob(inputPatterns)).sort((a, b) => a.localeCompare(b));
18
+ const duplicateSpecBasenames = await resolveDuplicateSpecBasenames(config.input);
18
19
  const coverageEnabled = resolveCoverageEnabled(config.coverage, featureToggles.coverage);
19
20
  const buildEnv = {
20
21
  ...mode.env,
21
22
  AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
22
23
  };
23
24
  for (const file of inputFiles) {
24
- const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName)}`;
25
- const cmd = getBuildCommand(config, pkgRunner, file, outFile, modeName, featureToggles);
25
+ const outFile = `${config.outDir}/${resolveArtifactFileName(file, config.buildOptions.target, modeName, duplicateSpecBasenames)}`;
26
+ const invocation = getBuildCommand(config, pkgRunner, file, outFile, modeName, featureToggles);
26
27
  try {
27
- buildFile(cmd, buildEnv);
28
+ buildFile(invocation, buildEnv);
28
29
  }
29
30
  catch (error) {
30
31
  const modeLabel = modeName ?? "default";
31
- throw new Error(`Failed to build ${path.basename(file)} in mode ${modeLabel} with ${getBuildStderr(error)}\nBuild command: ${cmd}`);
32
+ throw new Error(`Failed to build ${path.basename(file)} in mode ${modeLabel} with ${getBuildStderr(error)}\nBuild command: ${formatInvocation(invocation)}`);
32
33
  }
33
34
  }
34
35
  }
@@ -38,21 +39,27 @@ function hasCustomBuildCommand(config) {
38
39
  function getBuildCommand(config, pkgRunner, file, outFile, modeName, featureToggles = {}) {
39
40
  const userArgs = getUserBuildArgs(config);
40
41
  if (hasCustomBuildCommand(config)) {
41
- return `${expandBuildCommand(config.buildOptions.cmd, file, outFile, config.buildOptions.target, modeName)}${userArgs}`;
42
+ const tokens = tokenizeCommand(expandBuildCommand(config.buildOptions.cmd, file, outFile, config.buildOptions.target, modeName));
43
+ if (!tokens.length) {
44
+ throw new Error("custom build command is empty");
45
+ }
46
+ return {
47
+ command: tokens[0],
48
+ args: [...tokens.slice(1), ...userArgs],
49
+ };
42
50
  }
43
51
  const defaultArgs = getDefaultBuildArgs(config, featureToggles);
44
- let cmd = `${pkgRunner} asc ${file}${userArgs}${defaultArgs}`;
45
- if (config.outDir) {
46
- cmd += " -o " + outFile;
52
+ const args = ["asc", file, ...userArgs, ...defaultArgs];
53
+ if (config.outDir.length) {
54
+ args.push("-o", outFile);
47
55
  }
48
- return cmd;
56
+ return {
57
+ command: pkgRunner,
58
+ args,
59
+ };
49
60
  }
50
61
  function getUserBuildArgs(config) {
51
- const args = config.buildOptions.args.filter((value) => value.length > 0);
52
- if (args.length) {
53
- return " " + args.join(" ");
54
- }
55
- return "";
62
+ return config.buildOptions.args.filter((value) => value.length > 0);
56
63
  }
57
64
  function expandBuildCommand(template, file, outFile, target, modeName) {
58
65
  const name = path
@@ -66,15 +73,48 @@ function expandBuildCommand(template, file, outFile, target, modeName) {
66
73
  .replace(/<target>/g, target)
67
74
  .replace(/<mode>/g, modeName ?? "");
68
75
  }
69
- function resolveArtifactFileName(file, target, modeName) {
76
+ function resolveArtifactFileName(file, target, modeName, duplicateSpecBasenames = new Set()) {
70
77
  const base = path
71
78
  .basename(file)
72
79
  .replace(/\.spec\.ts$/, "")
73
80
  .replace(/\.ts$/, "");
74
- if (!modeName) {
75
- return `${path.basename(file).replace(".ts", ".wasm")}`;
81
+ const legacy = !modeName
82
+ ? `${path.basename(file).replace(".ts", ".wasm")}`
83
+ : `${base}.${modeName}.${target}.wasm`;
84
+ if (!duplicateSpecBasenames.has(path.basename(file))) {
85
+ return legacy;
86
+ }
87
+ const disambiguator = resolveDisambiguator(file);
88
+ if (!disambiguator.length) {
89
+ return legacy;
76
90
  }
77
- return `${base}.${modeName}.${target}.wasm`;
91
+ const ext = path.extname(legacy);
92
+ const stem = ext.length ? legacy.slice(0, -ext.length) : legacy;
93
+ return `${stem}.${disambiguator}${ext}`;
94
+ }
95
+ async function resolveDuplicateSpecBasenames(configured) {
96
+ const patterns = Array.isArray(configured) ? configured : [configured];
97
+ const files = await glob(patterns);
98
+ const counts = new Map();
99
+ for (const file of files) {
100
+ const base = path.basename(file);
101
+ counts.set(base, (counts.get(base) ?? 0) + 1);
102
+ }
103
+ const duplicates = new Set();
104
+ for (const [base, count] of counts) {
105
+ if (count > 1)
106
+ duplicates.add(base);
107
+ }
108
+ return duplicates;
109
+ }
110
+ function resolveDisambiguator(file) {
111
+ const relDir = path.dirname(path.relative(process.cwd(), file));
112
+ if (!relDir.length || relDir == ".")
113
+ return "";
114
+ return relDir
115
+ .replace(/[\\/]+/g, "__")
116
+ .replace(/[^A-Za-z0-9._-]/g, "_")
117
+ .replace(/^_+|_+$/g, "");
78
118
  }
79
119
  function resolveInputPatterns(configured, selectors) {
80
120
  const configuredInputs = Array.isArray(configured)
@@ -131,18 +171,33 @@ function stripSuiteSuffix(selector) {
131
171
  }
132
172
  function ensureDeps(config) {
133
173
  if (config.buildOptions.target == "wasi") {
134
- if (!existsSync("./node_modules/@assemblyscript/wasi-shim/asconfig.json")) {
174
+ if (!resolveWasiShim()) {
135
175
  console.log(`${chalk.bgRed(" ERROR ")}${chalk.dim(":")} could not find @assemblyscript/wasi-shim! Add it to your dependencies to run with WASI!`);
136
176
  process.exit(1);
137
177
  }
138
178
  }
139
179
  }
140
- function buildFile(command, env) {
141
- execSync(command, {
180
+ function buildFile(invocation, env) {
181
+ const result = spawnSync(invocation.command, invocation.args, {
142
182
  stdio: ["ignore", "pipe", "pipe"],
143
183
  encoding: "utf8",
144
184
  env,
185
+ shell: false,
145
186
  });
187
+ if (result.error)
188
+ throw result.error;
189
+ if (result.status !== 0) {
190
+ const error = new Error(result.stderr?.trim() ||
191
+ result.stdout?.trim() ||
192
+ `command exited with code ${result.status}`);
193
+ error.stderr = result.stderr ?? "";
194
+ throw error;
195
+ }
196
+ }
197
+ function formatInvocation(invocation) {
198
+ return [invocation.command, ...invocation.args]
199
+ .map((token) => (/\s/.test(token) ? JSON.stringify(token) : token))
200
+ .join(" ");
146
201
  }
147
202
  function getBuildStderr(error) {
148
203
  const err = error;
@@ -161,27 +216,28 @@ function getBuildStderr(error) {
161
216
  return message || "unknown error";
162
217
  }
163
218
  function getDefaultBuildArgs(config, featureToggles) {
164
- let buildArgs = "";
219
+ const buildArgs = [];
165
220
  const tryAsEnabled = resolveTryAsEnabled(featureToggles.tryAs);
166
- buildArgs += " --transform as-test/transform";
221
+ buildArgs.push("--transform", "as-test/transform");
167
222
  if (tryAsEnabled) {
168
- buildArgs += " --transform try-as/transform";
223
+ buildArgs.push("--transform", "try-as/transform");
169
224
  }
170
225
  if (config.config && config.config !== "none") {
171
- buildArgs += " --config " + config.config;
226
+ buildArgs.push("--config", config.config);
172
227
  }
173
228
  if (tryAsEnabled) {
174
- buildArgs += " --use AS_TEST_TRY_AS=1";
229
+ buildArgs.push("--use", "AS_TEST_TRY_AS=1");
175
230
  }
176
231
  // Should also strip any bindings-enabling from asconfig
177
232
  if (config.buildOptions.target == "bindings") {
178
- buildArgs += " --use AS_TEST_BINDINGS=1";
179
- buildArgs += " --bindings raw --exportRuntime --exportStart _start";
233
+ buildArgs.push("--use", "AS_TEST_BINDINGS=1", "--bindings", "raw", "--exportRuntime", "--exportStart", "_start");
180
234
  }
181
235
  else if (config.buildOptions.target == "wasi") {
182
- buildArgs += " --use AS_TEST_WASI=1";
183
- buildArgs +=
184
- " --config ./node_modules/@assemblyscript/wasi-shim/asconfig.json";
236
+ const wasiShim = resolveWasiShim();
237
+ if (!wasiShim) {
238
+ throw new Error('WASI target requires package "@assemblyscript/wasi-shim"');
239
+ }
240
+ buildArgs.push("--use", "AS_TEST_WASI=1", "--config", wasiShim.configPath);
185
241
  }
186
242
  else {
187
243
  console.log(`${chalk.bgRed(" ERROR ")}${chalk.dim(":")} could not determine target in config! Set target to 'bindings' or 'wasi'`);
@@ -211,6 +267,28 @@ function resolveCoverageEnabled(rawCoverage, override) {
211
267
  return true;
212
268
  }
213
269
  function hasTryAsRuntime() {
214
- return (existsSync(path.join(process.cwd(), "node_modules/try-as")) ||
215
- existsSync(path.join(process.cwd(), "node_modules/try-as/package.json")));
270
+ return resolveProjectModule("try-as/package.json") != null;
271
+ }
272
+ function resolveWasiShim() {
273
+ const resolved = resolveProjectModule("@assemblyscript/wasi-shim/asconfig.json");
274
+ if (!resolved)
275
+ return null;
276
+ if (!existsSync(resolved))
277
+ return null;
278
+ const relative = path.relative(process.cwd(), resolved).replace(/\\/g, "/");
279
+ return {
280
+ configPath: normalizeCliPath(relative),
281
+ };
282
+ }
283
+ function quoteCliArg(value) {
284
+ if (!/[\s"]/g.test(value))
285
+ return value;
286
+ return `"${value.replace(/"/g, '\\"')}"`;
287
+ }
288
+ function normalizeCliPath(value) {
289
+ if (!value.length)
290
+ return ".";
291
+ if (value.startsWith(".") || value.startsWith("/"))
292
+ return value;
293
+ return "./" + value;
216
294
  }
@@ -0,0 +1,16 @@
1
+ export { build } from "./build-core.js";
2
+ export async function executeBuildCommand(rawArgs, configPath, selectedModes, deps) {
3
+ const commandArgs = deps.resolveCommandArgs(rawArgs, "build");
4
+ const listFlags = deps.resolveListFlags(rawArgs, "build");
5
+ const featureToggles = deps.resolveFeatureToggles(rawArgs, "build");
6
+ const buildFeatureToggles = {
7
+ tryAs: featureToggles.tryAs,
8
+ coverage: featureToggles.coverage,
9
+ };
10
+ const modeTargets = deps.resolveExecutionModes(configPath, selectedModes);
11
+ if (listFlags.list || listFlags.listModes) {
12
+ await deps.listExecutionPlan("build", configPath, commandArgs, modeTargets, listFlags);
13
+ return;
14
+ }
15
+ await deps.runBuildModes(configPath, commandArgs, modeTargets, buildFeatureToggles);
16
+ }