as-test 0.5.3 → 0.5.4

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,18 @@
1
1
  # Change Log
2
2
 
3
+ ## 2026-03-04
4
+
5
+ ### CLI & Config Validation
6
+
7
+ - feat: validate config shape and field types before applying defaults for all CLI entry points.
8
+ - feat: reject unknown config keys with nearest-key suggestions.
9
+ - feat: show structured validation diagnostics with JSON paths and fix hints.
10
+ - feat: fail fast on invalid config JSON with parser error details.
11
+
12
+ ### Docs
13
+
14
+ - docs: add README guidance for strict config validation behavior and example error output.
15
+
3
16
  ## 2026-02-25 - v0.5.3
4
17
 
5
18
  ### 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
@@ -130,6 +137,9 @@ No test files matched: ...
130
137
  - `--disable <feature>`: disable as-test feature (`coverage`, `try-as`)
131
138
  - `--verbose`: keep expanded suite/test lines and update running `....` statuses in place
132
139
  - `--clean`: disable in-place TTY updates and print only final per-file verdict lines. Useful for CI/CD.
140
+ - `--list`: show resolved files, per-mode artifacts, and runtime command without executing
141
+ - `--list-modes`: show configured and selected modes without executing
142
+ - `--help` / `-h`: show command-specific help (`ast test --help`, `ast init --help`, etc.)
133
143
 
134
144
  Example:
135
145
 
@@ -138,6 +148,39 @@ ast build --enable try-as
138
148
  ast test --disable coverage
139
149
  ```
140
150
 
151
+ Preview execution plan:
152
+
153
+ ```bash
154
+ ast test --list
155
+ ast test --list-modes
156
+ ast run sleep --list --mode wasi
157
+ ast build --list --mode wasi,bindings
158
+ ```
159
+
160
+ ## Setup Diagnostics
161
+
162
+ Use `ast doctor` to validate local setup before running tests.
163
+
164
+ ```bash
165
+ ast doctor
166
+ ```
167
+
168
+ You can also target specific modes and config files:
169
+
170
+ ```bash
171
+ ast doctor --config ./as-test.config.json --mode wasi,bindings
172
+ ```
173
+
174
+ `doctor` checks:
175
+
176
+ - config file loading and mode resolution
177
+ - required dependencies (for example `assemblyscript`, `@assemblyscript/wasi-shim` for WASI targets)
178
+ - runtime command parsing and executable availability
179
+ - runtime script path existence (for script-host runtimes)
180
+ - test spec file discovery from configured input patterns
181
+
182
+ If any `ERROR` checks are found, `ast doctor` exits non-zero.
183
+
141
184
  ## Mocking
142
185
 
143
186
  Use these helpers when you need to replace behavior during tests:
@@ -248,10 +291,7 @@ Example:
248
291
  {
249
292
  "$schema": "./as-test.config.schema.json",
250
293
  "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",
294
+ "output": "./.as-test/",
255
295
  "config": "none",
256
296
  "coverage": true,
257
297
  "env": {},
@@ -273,10 +313,12 @@ Example:
273
313
  Key fields:
274
314
 
275
315
  - `input`: glob list of spec files
316
+ - `output`: output alias. Use a root string (`"./.as-test/"`) or object (`{ "build": "...", "logs": "...", "coverage": "...", "snapshots": "..." }`)
276
317
  - `outDir`: compiled wasm output dir
277
318
  - `logs`: log output dir or `"none"`
278
319
  - `coverageDir`: coverage output dir or `"none"`
279
320
  - `snapshotDir`: snapshot storage dir
321
+ - `outDir`, `logs`, `coverageDir`, and `snapshotDir` still work; when both are set, these explicit fields override `output`
280
322
  - `env`: environment variables injected into build and runtime processes
281
323
  - `buildOptions.cmd`: optional custom build command template; when set it replaces default build command and flags. Supports `<file>`, `<name>`, `<outFile>`, `<target>`, `<mode>`
282
324
  - `buildOptions.target`: `wasi` or `bindings`
@@ -284,6 +326,25 @@ Key fields:
284
326
  - `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
327
  - `runOptions.reporter`: reporter selection as a string or object
286
328
 
329
+ Validation behavior:
330
+
331
+ - Config parsing is strict for `ast build`, `ast run`, `ast test`, and `ast doctor`.
332
+ - Invalid JSON fails early with parser details (`line`/`column` when provided by Node).
333
+ - Unknown properties are rejected and include a nearest-key suggestion when possible.
334
+ - Invalid property types are reported with their JSON path and a short fix hint.
335
+ - On validation failure, the command exits non-zero and prints `run "ast doctor" to check your setup.`
336
+
337
+ Example validation error:
338
+
339
+ ```text
340
+ invalid config at ./as-test.config.json
341
+ 1. $.inpoot: unknown property
342
+ fix: use "input" if that was intended, otherwise remove this property
343
+ 2. $.runOptions.runtime.cmd: must be a string
344
+ fix: set to a runtime command including "<file>"
345
+ run "ast doctor" to check your setup.
346
+ ```
347
+
287
348
  Example multi-runtime matrix:
288
349
 
289
350
  ```json
@@ -501,7 +562,7 @@ Available matchers:
501
562
  - `toStartWith(prefix)`
502
563
  - `toEndWith(suffix)`
503
564
  - `toHaveLength(length)`
504
- - `toContain(item)`
565
+ - `toContain(itemOrSubstring)` (`toContains` alias supported)
505
566
  - `toThrow()` (with `try-as`)
506
567
  - `toMatchSnapshot(name?)`
507
568
 
@@ -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, } 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;
90
+ }
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);
76
107
  }
77
- return `${base}.${modeName}.${target}.wasm`;
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)
@@ -137,12 +177,27 @@ function ensureDeps(config) {
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,24 @@ 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
+ buildArgs.push("--use", "AS_TEST_WASI=1", "--config", "./node_modules/@assemblyscript/wasi-shim/asconfig.json");
185
237
  }
186
238
  else {
187
239
  console.log(`${chalk.bgRed(" ERROR ")}${chalk.dim(":")} could not determine target in config! Set target to 'bindings' or 'wasi'`);
@@ -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
+ }