as-test 1.5.0 → 1.5.2
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 +37 -0
- package/bin/commands/build-core.js +59 -66
- package/bin/commands/init-core.js +371 -36
- package/bin/commands/run-core.js +58 -77
- package/bin/index.js +10 -26
- package/bin/selectors.js +208 -0
- package/lib/build/index.js +1 -0
- package/lib/src/index.ts +4 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 2026-06-01 - v1.5.2
|
|
4
|
+
|
|
5
|
+
### Selectors resolve folders, files, and globs consistently
|
|
6
|
+
|
|
7
|
+
- feat: positional selectors for `ast test`/`ast run`/`ast build` now resolve through a single shared resolver (`cli/selectors.ts:resolveSpecFiles`), replacing three drifting private copies of `resolveInputPatterns` (in `build-core`, `run-core`, and `index`). Three input shapes are supported:
|
|
8
|
+
- **Bare folders/files/globs** (no leading `./`) resolve against the configured input root(s) — the static prefix of each `input` glob, e.g. `assembly/__tests__` — searched recursively, and fall back to the cwd only if nothing matched there: `ast test rfc/` → `<root>/**/rfc/**/*.spec.ts`; `ast test foo` → `<root>/**/foo.spec.ts`; `ast test 'rfc/*.spec.ts'` → `<root>/**/rfc/*.spec.ts` (the user's glob appended verbatim). A bare path shorthand like `nested/array` is tried as a cwd path first, then anchored to the test folder.
|
|
9
|
+
- **`./`-prefixed** selectors (and absolute / `~` paths) are cwd-relative only; on a miss we emit a `did you mean "rfc/*.spec.ts"` hint pointing at the test-folder form when that would have matched.
|
|
10
|
+
- feat: a bare selector that matches under more than one configured input root is flagged with a `WARN` (it still runs everything that matched), and a selector that matches nothing emits a `WARN` naming where it looked. Warnings are deduped by text across the orchestrator + per-file build/run passes (`emitSelectorWarnings`), so each prints once per invocation. Folder selectors (`rfc/`) and `,`-joined bare names (`a,b`) are recognized; selectors with an internal path separator (e.g. the orchestrator's own `assembly/__tests__/foo.spec.ts`) are still treated as direct cwd paths, preserving existing per-file dispatch.
|
|
11
|
+
|
|
12
|
+
## 2026-05-30 - v1.5.1
|
|
13
|
+
|
|
14
|
+
### An early-exiting runtime now fails instead of warning
|
|
15
|
+
|
|
16
|
+
- fix: when the runtime never delivers its final report payload, the CLI reconstructs a result from the streamed lifecycle events (`synthesizeReportFromRuntimeEvents`). Previously this always emitted a `WARN` and returned the reconstruction, only escalating to a `FAIL` if the child exited non-zero or wrote to stderr — so a spec that trapped/exited early with exit code `0` and no stderr came back as a passing reconstruction (the `runtime report payload missing; reconstructed result from streamed lifecycle events` warning storm). Both `runProcess` (WASI/bindings) and `runWebSessionProcess` (web) now treat `!runtimeEvents.sawFileEnd` — the runtime never emitted `event:file-end`, i.e. it exited before the file finished — as a failure: `appendRuntimeFailureReport` with a persisted crash record and a `test runtime exited before completing the test file` message, no misleading `WARN`. A run that _did_ reach `file-end` but simply failed to flush the final report frame is still the recoverable case (`WARN` + reconstructed result). This also closes a gap in the web path, which previously never escalated to a failure in the synthesized branch — it only warned, even on a non-zero exit.
|
|
17
|
+
|
|
18
|
+
### esm bindings now run
|
|
19
|
+
|
|
20
|
+
- fix: `instantiateEsmInstance` (`lib/src/index.ts`) now calls `patchNodeIo()` before importing the bindings helper. An esm helper auto-instantiates at import time and writes the WIPC report by calling the global `process.stdout.write(ArrayBuffer)` directly. `patchNodeIo()` — which teaches `process.stdout.write`/`process.stdin.read` to accept a raw `ArrayBuffer` and route it through `fs.writeSync` — was only wired into the raw path (via `withNodeIo`), never the esm path. So under esm bindings Node threw `ERR_INVALID_ARG_TYPE` ("chunk must be of type string or Buffer…, received an instance of ArrayBuffer") before any report was emitted, and the run crashed with `missing report payload from test runtime`. The patch is now in place by the time the helper instantiates.
|
|
21
|
+
|
|
22
|
+
### `--bindings` is respected instead of overridden
|
|
23
|
+
|
|
24
|
+
- feat: as-test no longer forces `--bindings raw` when you've already declared bindings yourself. `getDefaultBuildArgs` (`cli/commands/build-core.ts`) now takes a `bindingsAlreadyConfigured` flag and only appends `--bindings raw` when neither `buildOptions.args` nor a referenced asconfig declares `--bindings`. The other bindings flags (`AS_TEST_BINDINGS=1`, `--exportRuntime`, `--exportStart _start`) are still always injected. Two new detectors back this: `argsDeclareBindings(args)` (scans for `--bindings`/`--bindings=`) and `asconfigDeclaresBindings(configPath)` (reads `options.bindings`, follows `extends`), mirroring the existing try-as detection. Previously `--bindings esm` in `buildOptions.args` was combined with the forced `--bindings raw`, so `asc` emitted glue for **both** styles into one file; the runtime then mis-detected the kind and crashed. With this, `--bindings esm` produces esm-only glue and runs.
|
|
25
|
+
|
|
26
|
+
### Mocking works on every runtime
|
|
27
|
+
|
|
28
|
+
- change: `mock.spec.ts` is split into `mock.spec.ts` (mocking + `unmockFn` only) and `unmock.spec.ts` (the `unmockImport` cases). The split tracks the real esm/standalone-WASI boundary: the transform removes a `@external` import from the wasm when it is **only ever mocked**, but keeps it (for fall-back) when it is `unmockImport`'d anywhere. A pure-mock spec therefore imports nothing virtual and runs on **every** runtime — verified via `WebAssembly.Module.imports()`: the pure-mock wasi build imports only `wasi_snapshot_preview1`, while the unmock build imports `mock.foo`. `unmockFn` (function mocks) does not retain an import; only `unmockImport` does.
|
|
29
|
+
- feat: pure `mockImport` specs now run under **esm bindings** and the standalone WASI runtimes (`wasmtime`, `wasmer`, `wazero`) — `mock.spec.ts` is no longer excluded from those modes (it was in v1.5.0). Only `unmock.spec.ts`, which retains a real host binding the host can't supply under those runtimes, is excluded (`!**/unmock.spec.ts`).
|
|
30
|
+
- feat: two new modes in `as-test.config.json` — `node:bindings:raw` and `node:bindings:esm` (both `default: false`) — exercise each bindings style explicitly, and both are added to the `test:modes` matrix so `test:all` covers them.
|
|
31
|
+
|
|
32
|
+
### Watch mode exits with the last verdict
|
|
33
|
+
|
|
34
|
+
- feat: quitting `--watch` (ctrl+c, both the raw-mode `0x03` path and the `SIGINT` handler in `runWatchLoop`) now exits `1` when the most recent run left any spec failing **or a run is still in flight**, instead of always exiting `0`. The watch loop already tracks currently-failing `(spec, mode)` pairs in its sticky `failingSpecs` map and an `isRunning` flag, so the exit code is `isRunning || failingSpecs.size ? 1 : 0` — an interrupted run counts as a failure. This lets a red watch session fail CI and shell pipelines (`ast test --watch && deploy`) instead of masking the failure on quit.
|
|
35
|
+
|
|
36
|
+
### CI uses the main config
|
|
37
|
+
|
|
38
|
+
- chore: removed `as-test.ci.config.json`. `test:ci` now runs against the main `as-test.config.json` (`npm run test -- --mode node:bindings,node:wasi,wasmtime`), so CI uses the same modes, `features` (`try-as`), and per-mode spec exclusions as everything else — `try-as` no longer needs an explicit `--enable`, and the stale CI-only `wasmtime` exclusion that still ran `unmock.spec.ts` (and failed on the missing `mock::foo` host import) is gone.
|
|
39
|
+
|
|
3
40
|
## 2026-05-28 - v1.5.0
|
|
4
41
|
|
|
5
42
|
### `mockFn` and `mockImport` now work anywhere
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../util.js";
|
|
23
23
|
import { persistCrashRecord } from "../crash-store.js";
|
|
24
24
|
import { BuildWorkerPool } from "../build-worker-pool.js";
|
|
25
|
+
import { resolveSpecFiles, emitSelectorWarnings } from "../selectors.js";
|
|
25
26
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
26
27
|
export const buildRecorderStorage = new AsyncLocalStorage();
|
|
27
28
|
export class BuildFailureError extends Error {
|
|
@@ -67,14 +68,9 @@ export async function build(
|
|
|
67
68
|
const pkgRunner = getPkgRunner();
|
|
68
69
|
const sourceInputPatterns =
|
|
69
70
|
overrides.kind === "fuzz" ? config.fuzz.input : config.input;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.filter((p) => p.startsWith("!"))
|
|
74
|
-
.map((p) => p.slice(1));
|
|
75
|
-
const inputFiles = (
|
|
76
|
-
await glob(includePatterns, { ignore: ignorePatterns })
|
|
77
|
-
).sort((a, b) => a.localeCompare(b));
|
|
71
|
+
const { files: inputFiles, warnings: selectorWarnings } =
|
|
72
|
+
await resolveSpecFiles(sourceInputPatterns, selectors);
|
|
73
|
+
emitSelectorWarnings(selectorWarnings);
|
|
78
74
|
await assertNoArtifactCollisions(sourceInputPatterns);
|
|
79
75
|
warnOnUnknownModeReferences(inputFiles, loadedConfig.modes ?? {});
|
|
80
76
|
const coverageEnabled = resolveCoverageEnabled(
|
|
@@ -438,10 +434,13 @@ function getBuildCommand(
|
|
|
438
434
|
}
|
|
439
435
|
const tryAsAlreadyConfigured =
|
|
440
436
|
argsDeclareTryAs(userArgs) || asconfigDeclaresTryAs(config.config);
|
|
437
|
+
const bindingsAlreadyConfigured =
|
|
438
|
+
argsDeclareBindings(userArgs) || asconfigDeclaresBindings(config.config);
|
|
441
439
|
const defaultArgs = getDefaultBuildArgs(
|
|
442
440
|
config,
|
|
443
441
|
featureToggles,
|
|
444
442
|
tryAsAlreadyConfigured,
|
|
443
|
+
bindingsAlreadyConfigured,
|
|
445
444
|
);
|
|
446
445
|
const ascInvocation = resolveAscInvocation(pkgRunner);
|
|
447
446
|
// as-test's own transform goes first so CoverageTransform sees the
|
|
@@ -537,61 +536,6 @@ async function assertNoArtifactCollisions(configured) {
|
|
|
537
536
|
seen.set(artifact, file);
|
|
538
537
|
}
|
|
539
538
|
}
|
|
540
|
-
function resolveInputPatterns(configured, selectors) {
|
|
541
|
-
const configuredInputs = Array.isArray(configured)
|
|
542
|
-
? configured
|
|
543
|
-
: [configured];
|
|
544
|
-
if (!selectors.length) return configuredInputs;
|
|
545
|
-
const patterns = new Set();
|
|
546
|
-
for (const selector of expandSelectors(selectors)) {
|
|
547
|
-
if (!selector) continue;
|
|
548
|
-
if (isBareSuiteSelector(selector)) {
|
|
549
|
-
const base = stripSuiteSuffix(selector);
|
|
550
|
-
for (const configuredInput of configuredInputs) {
|
|
551
|
-
patterns.add(
|
|
552
|
-
path.join(path.dirname(configuredInput), `${base}.spec.ts`),
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
continue;
|
|
556
|
-
}
|
|
557
|
-
patterns.add(selector);
|
|
558
|
-
}
|
|
559
|
-
return [...patterns];
|
|
560
|
-
}
|
|
561
|
-
function expandSelectors(selectors) {
|
|
562
|
-
const expanded = [];
|
|
563
|
-
for (const selector of selectors) {
|
|
564
|
-
if (!selector) continue;
|
|
565
|
-
if (!shouldSplitSelector(selector)) {
|
|
566
|
-
expanded.push(selector);
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
for (const token of selector.split(",")) {
|
|
570
|
-
const trimmed = token.trim();
|
|
571
|
-
if (!trimmed.length) continue;
|
|
572
|
-
expanded.push(trimmed);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
return expanded;
|
|
576
|
-
}
|
|
577
|
-
function shouldSplitSelector(selector) {
|
|
578
|
-
return (
|
|
579
|
-
selector.includes(",") &&
|
|
580
|
-
!selector.includes("/") &&
|
|
581
|
-
!selector.includes("\\") &&
|
|
582
|
-
!/[*?[\]{}]/.test(selector)
|
|
583
|
-
);
|
|
584
|
-
}
|
|
585
|
-
function isBareSuiteSelector(selector) {
|
|
586
|
-
return (
|
|
587
|
-
!selector.includes("/") &&
|
|
588
|
-
!selector.includes("\\") &&
|
|
589
|
-
!/[*?[\]{}]/.test(selector)
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
function stripSuiteSuffix(selector) {
|
|
593
|
-
return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
|
|
594
|
-
}
|
|
595
539
|
function ensureDeps(config) {
|
|
596
540
|
if (config.buildOptions.target == "wasi") {
|
|
597
541
|
if (!resolveWasiShim()) {
|
|
@@ -769,6 +713,7 @@ function getDefaultBuildArgs(
|
|
|
769
713
|
config,
|
|
770
714
|
featureToggles,
|
|
771
715
|
tryAsAlreadyConfigured = false,
|
|
716
|
+
bindingsAlreadyConfigured = false,
|
|
772
717
|
) {
|
|
773
718
|
const buildArgs = [];
|
|
774
719
|
const effectiveFeatures = resolveEffectiveFeatures(config, featureToggles);
|
|
@@ -795,7 +740,6 @@ function getDefaultBuildArgs(
|
|
|
795
740
|
if (INTERNAL_FEATURE_NAMES.has(feature)) continue;
|
|
796
741
|
buildArgs.push("--enable", feature);
|
|
797
742
|
}
|
|
798
|
-
// Should also strip any bindings-enabling from asconfig
|
|
799
743
|
if (
|
|
800
744
|
config.buildOptions.target == "bindings" ||
|
|
801
745
|
config.buildOptions.target == "web"
|
|
@@ -803,12 +747,18 @@ function getDefaultBuildArgs(
|
|
|
803
747
|
buildArgs.push(
|
|
804
748
|
"--use",
|
|
805
749
|
"AS_TEST_BINDINGS=1",
|
|
806
|
-
"--bindings",
|
|
807
|
-
"raw",
|
|
808
750
|
"--exportRuntime",
|
|
809
751
|
"--exportStart",
|
|
810
752
|
"_start",
|
|
811
753
|
);
|
|
754
|
+
// `raw` bindings are the default the runtime host knows how to drive.
|
|
755
|
+
// If the user already declared `--bindings` (via buildOptions.args or
|
|
756
|
+
// an asconfig), respect their choice — the runtime supports both `raw`
|
|
757
|
+
// and `esm` — and don't force `raw` on top of it (asc would otherwise
|
|
758
|
+
// emit glue for both styles, confusing kind detection).
|
|
759
|
+
if (!bindingsAlreadyConfigured) {
|
|
760
|
+
buildArgs.push("--bindings", "raw");
|
|
761
|
+
}
|
|
812
762
|
} else if (config.buildOptions.target == "wasi") {
|
|
813
763
|
const wasiShim = resolveWasiShim();
|
|
814
764
|
if (!wasiShim) {
|
|
@@ -879,6 +829,49 @@ function argsDeclareTryAs(args) {
|
|
|
879
829
|
}
|
|
880
830
|
return false;
|
|
881
831
|
}
|
|
832
|
+
function argsDeclareBindings(args) {
|
|
833
|
+
for (const arg of args) {
|
|
834
|
+
if (arg === "--bindings" || arg.startsWith("--bindings=")) return true;
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
function asconfigDeclaresBindings(configPath, seen = new Set()) {
|
|
839
|
+
if (!configPath || configPath === "none") return false;
|
|
840
|
+
const resolved = path.isAbsolute(configPath)
|
|
841
|
+
? configPath
|
|
842
|
+
: path.resolve(process.cwd(), configPath);
|
|
843
|
+
if (seen.has(resolved)) return false;
|
|
844
|
+
seen.add(resolved);
|
|
845
|
+
if (!existsSync(resolved)) return false;
|
|
846
|
+
let parsed;
|
|
847
|
+
try {
|
|
848
|
+
parsed = JSON.parse(readFileSync(resolved, "utf8"));
|
|
849
|
+
} catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
853
|
+
const obj = parsed;
|
|
854
|
+
const options = obj.options;
|
|
855
|
+
if (options && typeof options === "object") {
|
|
856
|
+
const bindings = options.bindings;
|
|
857
|
+
if (typeof bindings === "string" && bindings.length) return true;
|
|
858
|
+
if (Array.isArray(bindings) && bindings.length) return true;
|
|
859
|
+
}
|
|
860
|
+
const extendsField = obj.extends;
|
|
861
|
+
const extendsList = Array.isArray(extendsField)
|
|
862
|
+
? extendsField
|
|
863
|
+
: typeof extendsField === "string"
|
|
864
|
+
? [extendsField]
|
|
865
|
+
: [];
|
|
866
|
+
for (const ext of extendsList) {
|
|
867
|
+
if (typeof ext !== "string") continue;
|
|
868
|
+
const extPath = path.isAbsolute(ext)
|
|
869
|
+
? ext
|
|
870
|
+
: path.resolve(path.dirname(resolved), ext);
|
|
871
|
+
if (asconfigDeclaresBindings(extPath, seen)) return true;
|
|
872
|
+
}
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
882
875
|
function asconfigDeclaresTryAs(configPath, seen = new Set()) {
|
|
883
876
|
if (!configPath || configPath === "none") return false;
|
|
884
877
|
const resolved = path.isAbsolute(configPath)
|
|
@@ -3,9 +3,169 @@ import { spawnSync } from "child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { createInterface } from "readline";
|
|
6
|
-
import { getCliVersion } from "../util.js";
|
|
6
|
+
import { getCliVersion, getExec } from "../util.js";
|
|
7
7
|
import { buildWebRunnerSource } from "./web-runner-source.js";
|
|
8
8
|
const TARGETS = ["wasi", "bindings", "web"];
|
|
9
|
+
// Popular runtimes offered by the interactive picker. Availability is probed
|
|
10
|
+
// against the machine/project (PATH for native binaries, node_modules for
|
|
11
|
+
// Playwright) so unavailable entries can be dimmed rather than hidden.
|
|
12
|
+
const RUNTIMES = [
|
|
13
|
+
{
|
|
14
|
+
value: "node:wasi",
|
|
15
|
+
label: "Node.js",
|
|
16
|
+
target: "wasi",
|
|
17
|
+
cmd: "node .as-test/runners/default.wasi.js",
|
|
18
|
+
browser: "",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: "node:bindings",
|
|
22
|
+
label: "Node.js",
|
|
23
|
+
target: "bindings",
|
|
24
|
+
cmd: "node .as-test/runners/default.bindings.js",
|
|
25
|
+
browser: "",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
value: "wasmtime",
|
|
29
|
+
label: "Wasmtime",
|
|
30
|
+
target: "wasi",
|
|
31
|
+
cmd: "wasmtime run <file>",
|
|
32
|
+
browser: "",
|
|
33
|
+
bin: "wasmtime",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
value: "wasmer",
|
|
37
|
+
label: "Wasmer",
|
|
38
|
+
target: "wasi",
|
|
39
|
+
cmd: "wasmer run <file>",
|
|
40
|
+
browser: "",
|
|
41
|
+
bin: "wasmer",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
value: "wazero",
|
|
45
|
+
label: "wazero",
|
|
46
|
+
target: "wasi",
|
|
47
|
+
cmd: "wazero run <file>",
|
|
48
|
+
browser: "",
|
|
49
|
+
bin: "wazero",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
value: "chromium",
|
|
53
|
+
label: "Chromium",
|
|
54
|
+
target: "web",
|
|
55
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
56
|
+
browser: "chromium",
|
|
57
|
+
needsPlaywright: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: "firefox",
|
|
61
|
+
label: "Firefox",
|
|
62
|
+
target: "web",
|
|
63
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
64
|
+
browser: "firefox",
|
|
65
|
+
needsPlaywright: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
value: "webkit",
|
|
69
|
+
label: "WebKit",
|
|
70
|
+
target: "web",
|
|
71
|
+
cmd: "node .as-test/runners/default.web.js",
|
|
72
|
+
browser: "webkit",
|
|
73
|
+
needsPlaywright: true,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
// Probe whether a runtime is usable on this machine/project. Node-based
|
|
77
|
+
// runtimes are always available (we are running under Node); native binaries
|
|
78
|
+
// must resolve on PATH; browser runtimes require Playwright to be installed.
|
|
79
|
+
function probeRuntime(runtime, root) {
|
|
80
|
+
if (runtime.bin) {
|
|
81
|
+
if (getExec(runtime.bin)) return { available: true };
|
|
82
|
+
return { available: false, hint: `${runtime.bin} not on PATH` };
|
|
83
|
+
}
|
|
84
|
+
if (runtime.needsPlaywright) {
|
|
85
|
+
const pkg = path.join(root, "node_modules", "playwright", "package.json");
|
|
86
|
+
if (existsSync(pkg)) return { available: true };
|
|
87
|
+
return { available: false, hint: "playwright not installed" };
|
|
88
|
+
}
|
|
89
|
+
return { available: true };
|
|
90
|
+
}
|
|
91
|
+
// The default runtime command for a build target, matching the long-standing
|
|
92
|
+
// `--target`/`--yes` behaviour (plain Node runner, no browser).
|
|
93
|
+
function runtimeForTarget(target) {
|
|
94
|
+
const cmd =
|
|
95
|
+
target == "wasi"
|
|
96
|
+
? "node .as-test/runners/default.wasi.js"
|
|
97
|
+
: target == "bindings"
|
|
98
|
+
? "node .as-test/runners/default.bindings.js"
|
|
99
|
+
: "node .as-test/runners/default.web.js";
|
|
100
|
+
return { cmd, browser: "" };
|
|
101
|
+
}
|
|
102
|
+
function defaultRuntimeLabel(target) {
|
|
103
|
+
if (target == "wasi") return "node:wasi";
|
|
104
|
+
if (target == "bindings") return "node:bindings";
|
|
105
|
+
return "web";
|
|
106
|
+
}
|
|
107
|
+
// Ensure a mode key is unique against names already in use, suffixing -2, -3…
|
|
108
|
+
function uniqueModeName(name, taken) {
|
|
109
|
+
if (!taken.includes(name)) return name;
|
|
110
|
+
for (let i = 2; ; i++) {
|
|
111
|
+
const candidate = `${name}-${i}`;
|
|
112
|
+
if (!taken.includes(candidate)) return candidate;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Prompt for a user-defined runtime: a command (with optional <file>
|
|
116
|
+
// placeholder), a mode name, and — for the web target — an optional browser.
|
|
117
|
+
// Returns null if no command is entered or interactive input is unavailable.
|
|
118
|
+
async function askCustomRuntime(target, face, taken) {
|
|
119
|
+
if (!face) return null;
|
|
120
|
+
const cmd = (
|
|
121
|
+
await ask(
|
|
122
|
+
`${chalk.bold.blue("◇ Custom runtime command")}\n` +
|
|
123
|
+
`${chalk.dim("│ <file> is replaced with the generated .wasm — e.g. wasmtime <file>")}\n│ `,
|
|
124
|
+
face,
|
|
125
|
+
"",
|
|
126
|
+
)
|
|
127
|
+
).trim();
|
|
128
|
+
if (!cmd.length) return null;
|
|
129
|
+
const rawName = (
|
|
130
|
+
await ask(
|
|
131
|
+
`${chalk.bold.blue("◇ Name for this runtime (used as the mode key, default: custom)")}\n│ `,
|
|
132
|
+
face,
|
|
133
|
+
"",
|
|
134
|
+
)
|
|
135
|
+
).trim();
|
|
136
|
+
const name = uniqueModeName(rawName.length ? rawName : "custom", taken);
|
|
137
|
+
let browser = "";
|
|
138
|
+
if (target == "web") {
|
|
139
|
+
browser = (
|
|
140
|
+
await ask(
|
|
141
|
+
`${chalk.bold.blue("◇ Browser (optional: chromium / firefox / webkit)")}\n│ `,
|
|
142
|
+
face,
|
|
143
|
+
"",
|
|
144
|
+
)
|
|
145
|
+
).trim();
|
|
146
|
+
}
|
|
147
|
+
printSelectionLine(`${name}: ${cmd}${browser ? ` (${browser})` : ""}`);
|
|
148
|
+
return { value: name, target, cmd, browser };
|
|
149
|
+
}
|
|
150
|
+
// Turn the selected runtimes into config `modes` — one named mode per runtime,
|
|
151
|
+
// each carrying its build target and runtime command. Every mode runs by
|
|
152
|
+
// default (the absent `default` flag defaults to true), so `ast test` executes
|
|
153
|
+
// them all.
|
|
154
|
+
function buildRuntimeModes(selected) {
|
|
155
|
+
const modes = {};
|
|
156
|
+
for (const rt of selected) {
|
|
157
|
+
modes[rt.value] = {
|
|
158
|
+
buildOptions: { target: rt.target },
|
|
159
|
+
runOptions: {
|
|
160
|
+
runtime: {
|
|
161
|
+
cmd: rt.cmd,
|
|
162
|
+
...(rt.browser ? { browser: rt.browser } : {}),
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return modes;
|
|
168
|
+
}
|
|
9
169
|
const EXAMPLE_MODES = ["minimal", "full", "none"];
|
|
10
170
|
const FEATURE_KEYS = ["coverage", "tryAs"];
|
|
11
171
|
const FEATURE_LABELS = {
|
|
@@ -27,6 +187,9 @@ export async function init(rawArgs) {
|
|
|
27
187
|
? {
|
|
28
188
|
root: path.resolve(process.cwd(), options.dir),
|
|
29
189
|
target: options.target ?? "wasi",
|
|
190
|
+
runtime: runtimeForTarget(options.target ?? "wasi"),
|
|
191
|
+
modes: {},
|
|
192
|
+
runtimeLabel: defaultRuntimeLabel(options.target ?? "wasi"),
|
|
30
193
|
example: options.example ?? "minimal",
|
|
31
194
|
fuzzExample: options.fuzzExample ?? false,
|
|
32
195
|
features: resolveFeatures(options.features, {
|
|
@@ -43,6 +206,8 @@ export async function init(rawArgs) {
|
|
|
43
206
|
printPlan(
|
|
44
207
|
answers.root,
|
|
45
208
|
answers.target,
|
|
209
|
+
answers.runtimeLabel,
|
|
210
|
+
Object.keys(answers.modes),
|
|
46
211
|
answers.example,
|
|
47
212
|
answers.fuzzExample,
|
|
48
213
|
answers.features,
|
|
@@ -58,6 +223,8 @@ export async function init(rawArgs) {
|
|
|
58
223
|
const summary = applyInit(
|
|
59
224
|
answers.root,
|
|
60
225
|
answers.target,
|
|
226
|
+
answers.runtime,
|
|
227
|
+
answers.modes,
|
|
61
228
|
answers.example,
|
|
62
229
|
answers.fuzzExample,
|
|
63
230
|
answers.features,
|
|
@@ -265,6 +432,8 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
265
432
|
} else {
|
|
266
433
|
printSelectionLine(resolvedRoot);
|
|
267
434
|
}
|
|
435
|
+
// Step 1: pick the build target (mode). The runtime list is then filtered to
|
|
436
|
+
// the runtimes that support this target.
|
|
268
437
|
const target =
|
|
269
438
|
options.target ??
|
|
270
439
|
(onboardingMode == "quick"
|
|
@@ -272,21 +441,9 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
272
441
|
: await askMenuChoice(
|
|
273
442
|
"Build target",
|
|
274
443
|
[
|
|
275
|
-
{
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"wasi (default runner: node .as-test/runners/default.wasi.js)",
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
value: "bindings",
|
|
282
|
-
label:
|
|
283
|
-
"bindings (default runner: node .as-test/runners/default.bindings.js)",
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
value: "web",
|
|
287
|
-
label:
|
|
288
|
-
"web (default runner: node .as-test/runners/default.web.js)",
|
|
289
|
-
},
|
|
444
|
+
{ value: "wasi", label: "wasi (WebAssembly System Interface)" },
|
|
445
|
+
{ value: "bindings", label: "bindings (Node.js host bindings)" },
|
|
446
|
+
{ value: "web", label: "web (browser via Playwright)" },
|
|
290
447
|
],
|
|
291
448
|
face,
|
|
292
449
|
"wasi",
|
|
@@ -294,6 +451,72 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
294
451
|
if (options.target || onboardingMode == "quick") {
|
|
295
452
|
printPromptAndSelectionLine("Build target", target);
|
|
296
453
|
}
|
|
454
|
+
// Step 2: pick one or more runtimes, scoped to the chosen target. Unavailable
|
|
455
|
+
// runtimes (native binary missing from PATH, or Playwright not installed) are
|
|
456
|
+
// dimmed; "Custom…" lets the user define their own. Each chosen runtime
|
|
457
|
+
// becomes a config mode so `ast test` runs the whole matrix.
|
|
458
|
+
let runtime;
|
|
459
|
+
let runtimeLabel;
|
|
460
|
+
let modes = {};
|
|
461
|
+
const targetRuntimes = RUNTIMES.filter((rt) => rt.target == target);
|
|
462
|
+
if (options.target || onboardingMode == "quick") {
|
|
463
|
+
// Flag/quick runs are non-interactive: keep the historical Node default.
|
|
464
|
+
runtime = runtimeForTarget(target);
|
|
465
|
+
runtimeLabel = defaultRuntimeLabel(target);
|
|
466
|
+
printPromptAndSelectionLine("Runtime", runtimeLabel);
|
|
467
|
+
} else {
|
|
468
|
+
const toggleChoices = targetRuntimes.map((rt) => {
|
|
469
|
+
const status = probeRuntime(rt, resolvedRoot);
|
|
470
|
+
return {
|
|
471
|
+
value: rt.value,
|
|
472
|
+
label: rt.label,
|
|
473
|
+
disabled: !status.available,
|
|
474
|
+
hint: status.hint,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
toggleChoices.push({
|
|
478
|
+
value: "custom",
|
|
479
|
+
label: "Custom…",
|
|
480
|
+
alwaysSelectable: true,
|
|
481
|
+
});
|
|
482
|
+
// Pre-select the first available built-in runtime so confirming immediately
|
|
483
|
+
// yields a sensible single choice.
|
|
484
|
+
const firstAvailable =
|
|
485
|
+
targetRuntimes.find((rt) => probeRuntime(rt, resolvedRoot).available)
|
|
486
|
+
?.value ?? targetRuntimes[0].value;
|
|
487
|
+
const initial = {};
|
|
488
|
+
for (const choice of toggleChoices) {
|
|
489
|
+
initial[choice.value] = choice.value == firstAvailable;
|
|
490
|
+
}
|
|
491
|
+
const result = await askMultiToggle(
|
|
492
|
+
"Runtimes (↑/↓ move, space toggle, enter confirm — dimmed = not detected)",
|
|
493
|
+
toggleChoices,
|
|
494
|
+
face,
|
|
495
|
+
initial,
|
|
496
|
+
);
|
|
497
|
+
const selected = targetRuntimes.filter((rt) => result[rt.value]);
|
|
498
|
+
if (result["custom"]) {
|
|
499
|
+
const custom = await askCustomRuntime(
|
|
500
|
+
target,
|
|
501
|
+
face,
|
|
502
|
+
selected.map((rt) => rt.value),
|
|
503
|
+
);
|
|
504
|
+
if (custom) selected.push(custom);
|
|
505
|
+
}
|
|
506
|
+
if (!selected.length) {
|
|
507
|
+
// Confirming with nothing selected falls back to the default runtime.
|
|
508
|
+
const fallback =
|
|
509
|
+
targetRuntimes.find((rt) => rt.value == firstAvailable) ??
|
|
510
|
+
targetRuntimes[0];
|
|
511
|
+
selected.push(fallback);
|
|
512
|
+
}
|
|
513
|
+
const primary = selected[0];
|
|
514
|
+
runtime = { cmd: primary.cmd, browser: primary.browser };
|
|
515
|
+
runtimeLabel = selected.map((rt) => rt.value).join(", ");
|
|
516
|
+
// The picker is authoritative: write a mode per chosen runtime so the
|
|
517
|
+
// exact selection (single, matrix, or custom) is what `ast test` runs.
|
|
518
|
+
modes = buildRuntimeModes(selected);
|
|
519
|
+
}
|
|
297
520
|
const featureDefaults = { coverage: false, tryAs: false };
|
|
298
521
|
const explicitFeatures =
|
|
299
522
|
options.features.coverage !== undefined ||
|
|
@@ -355,6 +578,9 @@ async function runInteractiveOnboarding(options, face) {
|
|
|
355
578
|
return {
|
|
356
579
|
root: resolvedRoot,
|
|
357
580
|
target,
|
|
581
|
+
runtime,
|
|
582
|
+
modes,
|
|
583
|
+
runtimeLabel,
|
|
358
584
|
example,
|
|
359
585
|
fuzzExample,
|
|
360
586
|
features,
|
|
@@ -464,7 +690,16 @@ function isTarget(value) {
|
|
|
464
690
|
function isExampleMode(value) {
|
|
465
691
|
return EXAMPLE_MODES.includes(value);
|
|
466
692
|
}
|
|
467
|
-
function printPlan(
|
|
693
|
+
function printPlan(
|
|
694
|
+
root,
|
|
695
|
+
target,
|
|
696
|
+
runtimeLabel,
|
|
697
|
+
modeNames,
|
|
698
|
+
example,
|
|
699
|
+
fuzzExample,
|
|
700
|
+
features,
|
|
701
|
+
install,
|
|
702
|
+
) {
|
|
468
703
|
const displayRoot = () => {
|
|
469
704
|
const rel = path.relative(process.cwd(), root).split(path.sep).join("/");
|
|
470
705
|
if (!rel || rel == ".") return "./";
|
|
@@ -568,6 +803,15 @@ function printPlan(root, target, example, fuzzExample, features, install) {
|
|
|
568
803
|
const treeRoot = buildTree(fileEntries);
|
|
569
804
|
console.log(chalk.bold.blue("◇ Planned Changes"));
|
|
570
805
|
console.log("│" + chalk.dim(` - Target: ${target}`));
|
|
806
|
+
console.log(
|
|
807
|
+
"│" +
|
|
808
|
+
chalk.dim(
|
|
809
|
+
` - Runtime${runtimeLabel.includes(",") ? "s" : ""}: ${runtimeLabel}`,
|
|
810
|
+
),
|
|
811
|
+
);
|
|
812
|
+
if (modeNames.length) {
|
|
813
|
+
console.log("│" + chalk.dim(` - Modes: ${modeNames.join(", ")}`));
|
|
814
|
+
}
|
|
571
815
|
console.log("│" + chalk.dim(` - Example: ${example}`));
|
|
572
816
|
console.log(
|
|
573
817
|
"│" + chalk.dim(` - Fuzzer example: ${fuzzExample ? "yes" : "no"}`),
|
|
@@ -586,7 +830,16 @@ function printPlan(root, target, example, fuzzExample, features, install) {
|
|
|
586
830
|
}
|
|
587
831
|
console.log("│");
|
|
588
832
|
}
|
|
589
|
-
function applyInit(
|
|
833
|
+
function applyInit(
|
|
834
|
+
root,
|
|
835
|
+
target,
|
|
836
|
+
runtime,
|
|
837
|
+
modes,
|
|
838
|
+
example,
|
|
839
|
+
fuzzExample,
|
|
840
|
+
features,
|
|
841
|
+
force,
|
|
842
|
+
) {
|
|
590
843
|
const summary = {
|
|
591
844
|
created: [],
|
|
592
845
|
updated: [],
|
|
@@ -637,17 +890,17 @@ function applyInit(root, target, example, fuzzExample, features, force) {
|
|
|
637
890
|
},
|
|
638
891
|
runOptions: {
|
|
639
892
|
runtime: {
|
|
640
|
-
cmd:
|
|
641
|
-
|
|
642
|
-
? "node .as-test/runners/default.wasi.js"
|
|
643
|
-
: target == "bindings"
|
|
644
|
-
? "node .as-test/runners/default.bindings.js"
|
|
645
|
-
: "node .as-test/runners/default.web.js",
|
|
893
|
+
cmd: runtime.cmd,
|
|
894
|
+
...(runtime.browser ? { browser: runtime.browser } : {}),
|
|
646
895
|
},
|
|
647
896
|
reporter: "default",
|
|
648
897
|
},
|
|
649
|
-
|
|
650
|
-
|
|
898
|
+
// The interactive picker supplies one mode per selected runtime. The
|
|
899
|
+
// non-interactive paths (--yes/--target/quick) leave `modes` empty, so the
|
|
900
|
+
// historical web convenience modes are scaffolded for the web target.
|
|
901
|
+
modes: Object.keys(modes).length
|
|
902
|
+
? modes
|
|
903
|
+
: target == "web"
|
|
651
904
|
? {
|
|
652
905
|
web: {
|
|
653
906
|
default: false,
|
|
@@ -899,12 +1152,14 @@ async function askChoice(label, choices, face, fallback) {
|
|
|
899
1152
|
throw new Error(`Invalid choice "${answer}" for ${label}`);
|
|
900
1153
|
}
|
|
901
1154
|
async function askMenuChoice(label, choices, face, fallback) {
|
|
902
|
-
const
|
|
1155
|
+
const enabled = choices.filter((choice) => !choice.disabled);
|
|
1156
|
+
const pool = enabled.length ? enabled : choices;
|
|
1157
|
+
const fallbackValue = pool.some((choice) => choice.value == fallback)
|
|
903
1158
|
? fallback
|
|
904
|
-
:
|
|
1159
|
+
: pool[0].value;
|
|
905
1160
|
if (!face) return fallbackValue;
|
|
906
1161
|
if (!canUseArrowMenu(face)) {
|
|
907
|
-
const values =
|
|
1162
|
+
const values = pool.map((choice) => choice.value);
|
|
908
1163
|
return askChoice(label, values, face, fallbackValue);
|
|
909
1164
|
}
|
|
910
1165
|
return askMenuChoiceWithArrows(label, choices, face, fallbackValue);
|
|
@@ -915,7 +1170,16 @@ async function askMultiToggle(label, choices, face, initial) {
|
|
|
915
1170
|
return askMultiToggleWithArrows(label, choices, face, initial);
|
|
916
1171
|
}
|
|
917
1172
|
const result = { ...initial };
|
|
1173
|
+
const anySelectable = choices.some(
|
|
1174
|
+
(choice) => !choice.disabled && !choice.alwaysSelectable,
|
|
1175
|
+
);
|
|
918
1176
|
for (const choice of choices) {
|
|
1177
|
+
const selectable =
|
|
1178
|
+
Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled;
|
|
1179
|
+
if (!selectable) {
|
|
1180
|
+
result[choice.value] = false;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
919
1183
|
result[choice.value] = await askYesNo(
|
|
920
1184
|
`${label} — enable ${choice.label}?`,
|
|
921
1185
|
face,
|
|
@@ -961,8 +1225,32 @@ function canUseArrowMenu(face) {
|
|
|
961
1225
|
async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
962
1226
|
const stdin = process.stdin;
|
|
963
1227
|
const stdout = process.stdout;
|
|
964
|
-
|
|
965
|
-
|
|
1228
|
+
// When nothing is selectable (e.g. every runtime for a target is dimmed
|
|
1229
|
+
// because its tooling isn't installed yet), fall back to letting the user
|
|
1230
|
+
// pick anyway — the dimming stays as an informational warning.
|
|
1231
|
+
const anySelectable = choices.some((choice) => !choice.disabled);
|
|
1232
|
+
const isSelectable = (index) => !anySelectable || !choices[index].disabled;
|
|
1233
|
+
const firstSelectable = choices.findIndex((_, i) => isSelectable(i));
|
|
1234
|
+
const fallbackIndex = choices.findIndex(
|
|
1235
|
+
(choice) => choice.value == fallback && !choice.disabled,
|
|
1236
|
+
);
|
|
1237
|
+
let selectedIndex =
|
|
1238
|
+
fallbackIndex != -1
|
|
1239
|
+
? fallbackIndex
|
|
1240
|
+
: firstSelectable != -1
|
|
1241
|
+
? firstSelectable
|
|
1242
|
+
: 0;
|
|
1243
|
+
// Step from `selectedIndex` in `step` direction (wrapping) to the next
|
|
1244
|
+
// selectable option, ignoring disabled entries. Returns the current index
|
|
1245
|
+
// unchanged if nothing else is selectable.
|
|
1246
|
+
const stepSelection = (step) => {
|
|
1247
|
+
for (let i = 1; i <= choices.length; i++) {
|
|
1248
|
+
const candidate =
|
|
1249
|
+
(selectedIndex + step * i + choices.length * i) % choices.length;
|
|
1250
|
+
if (isSelectable(candidate)) return candidate;
|
|
1251
|
+
}
|
|
1252
|
+
return selectedIndex;
|
|
1253
|
+
};
|
|
966
1254
|
let renderedLineCount = 0;
|
|
967
1255
|
const previousRawMode = Boolean(stdin.isRaw);
|
|
968
1256
|
const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
|
|
@@ -977,6 +1265,15 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
977
1265
|
const lines = [titleLine()];
|
|
978
1266
|
for (let i = 0; i < choices.length; i++) {
|
|
979
1267
|
const choice = choices[i];
|
|
1268
|
+
if (choice.disabled) {
|
|
1269
|
+
const text = choice.hint
|
|
1270
|
+
? `${choice.label} (${choice.hint})`
|
|
1271
|
+
: choice.label;
|
|
1272
|
+
lines.push(
|
|
1273
|
+
`│ ${chalk.dim("✕")} ${chalk.dim(clamp(text, Math.max(8, lineWidth - 6)))}`,
|
|
1274
|
+
);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
980
1277
|
const marker = i == selectedIndex ? chalk.blue("●") : chalk.dim("○");
|
|
981
1278
|
lines.push(
|
|
982
1279
|
`│ ${marker} ${clamp(choice.label, Math.max(8, lineWidth - 6))}`,
|
|
@@ -1069,7 +1366,7 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
1069
1366
|
input == "\x1b[D" ||
|
|
1070
1367
|
input == "\x1bOD"
|
|
1071
1368
|
) {
|
|
1072
|
-
selectedIndex = (
|
|
1369
|
+
selectedIndex = stepSelection(-1);
|
|
1073
1370
|
writeLines(menuLines());
|
|
1074
1371
|
return;
|
|
1075
1372
|
}
|
|
@@ -1079,11 +1376,12 @@ async function askMenuChoiceWithArrows(label, choices, face, fallback) {
|
|
|
1079
1376
|
input == "\x1b[C" ||
|
|
1080
1377
|
input == "\x1bOC"
|
|
1081
1378
|
) {
|
|
1082
|
-
selectedIndex = (
|
|
1379
|
+
selectedIndex = stepSelection(1);
|
|
1083
1380
|
writeLines(menuLines());
|
|
1084
1381
|
return;
|
|
1085
1382
|
}
|
|
1086
1383
|
if (input == "\r" || input == "\n") {
|
|
1384
|
+
if (!isSelectable(selectedIndex)) return;
|
|
1087
1385
|
finish(choices[selectedIndex].value);
|
|
1088
1386
|
return;
|
|
1089
1387
|
}
|
|
@@ -1101,7 +1399,29 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1101
1399
|
const stdin = process.stdin;
|
|
1102
1400
|
const stdout = process.stdout;
|
|
1103
1401
|
const selected = { ...initial };
|
|
1104
|
-
|
|
1402
|
+
// When no "real" option is selectable (e.g. every browser is dimmed because
|
|
1403
|
+
// Playwright isn't installed yet — which init can fix), allow toggling the
|
|
1404
|
+
// dimmed entries anyway; the dimming stays as an informational warning. The
|
|
1405
|
+
// always-selectable Custom… entry is excluded from this decision.
|
|
1406
|
+
const anySelectable = choices.some(
|
|
1407
|
+
(choice) => !choice.disabled && !choice.alwaysSelectable,
|
|
1408
|
+
);
|
|
1409
|
+
const isSelectable = (index) => {
|
|
1410
|
+
const choice = choices[index];
|
|
1411
|
+
return (
|
|
1412
|
+
Boolean(choice.alwaysSelectable) || !anySelectable || !choice.disabled
|
|
1413
|
+
);
|
|
1414
|
+
};
|
|
1415
|
+
const stepCursor = (step) => {
|
|
1416
|
+
for (let i = 1; i <= choices.length; i++) {
|
|
1417
|
+
const candidate =
|
|
1418
|
+
(cursorIndex + step * i + choices.length * i) % choices.length;
|
|
1419
|
+
if (isSelectable(candidate)) return candidate;
|
|
1420
|
+
}
|
|
1421
|
+
return cursorIndex;
|
|
1422
|
+
};
|
|
1423
|
+
let cursorIndex = choices.findIndex((_, i) => isSelectable(i));
|
|
1424
|
+
if (cursorIndex == -1) cursorIndex = 0;
|
|
1105
1425
|
let renderedLineCount = 0;
|
|
1106
1426
|
const previousRawMode = Boolean(stdin.isRaw);
|
|
1107
1427
|
const lineWidth = Math.max(20, (stdout.columns ?? 80) - 2);
|
|
@@ -1118,6 +1438,20 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1118
1438
|
const choice = choices[i];
|
|
1119
1439
|
const isOn = Boolean(selected[choice.value]);
|
|
1120
1440
|
const cursor = i == cursorIndex ? chalk.blue("›") : " ";
|
|
1441
|
+
if (choice.disabled) {
|
|
1442
|
+
const text = choice.hint
|
|
1443
|
+
? `${choice.label} (${choice.hint})`
|
|
1444
|
+
: choice.label;
|
|
1445
|
+
const dimmed = chalk.dim(clamp(text, Math.max(8, lineWidth - 8)));
|
|
1446
|
+
// Selectable-but-dimmed (installable) keeps its ●/○; hard-blocked uses ✕.
|
|
1447
|
+
const marker = isSelectable(i)
|
|
1448
|
+
? isOn
|
|
1449
|
+
? chalk.blue("●")
|
|
1450
|
+
: chalk.dim("○")
|
|
1451
|
+
: chalk.dim("✕");
|
|
1452
|
+
lines.push(`│ ${cursor} ${marker} ${dimmed}`);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1121
1455
|
const marker = isOn ? chalk.blue("●") : chalk.dim("○");
|
|
1122
1456
|
const text = clamp(choice.label, Math.max(8, lineWidth - 6));
|
|
1123
1457
|
const painted = i == cursorIndex ? chalk.bold(text) : text;
|
|
@@ -1203,16 +1537,17 @@ async function askMultiToggleWithArrows(label, choices, face, initial) {
|
|
|
1203
1537
|
return;
|
|
1204
1538
|
}
|
|
1205
1539
|
if (input == "\x1b[A" || input == "\x1bOA") {
|
|
1206
|
-
cursorIndex = (
|
|
1540
|
+
cursorIndex = stepCursor(-1);
|
|
1207
1541
|
writeLines(menuLines());
|
|
1208
1542
|
return;
|
|
1209
1543
|
}
|
|
1210
1544
|
if (input == "\x1b[B" || input == "\x1bOB") {
|
|
1211
|
-
cursorIndex = (
|
|
1545
|
+
cursorIndex = stepCursor(1);
|
|
1212
1546
|
writeLines(menuLines());
|
|
1213
1547
|
return;
|
|
1214
1548
|
}
|
|
1215
1549
|
if (input == " ") {
|
|
1550
|
+
if (!isSelectable(cursorIndex)) return;
|
|
1216
1551
|
const key = choices[cursorIndex].value;
|
|
1217
1552
|
selected[key] = !selected[key];
|
|
1218
1553
|
writeLines(menuLines());
|
package/bin/commands/run-core.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { glob } from "glob";
|
|
4
3
|
import { minimatch } from "minimatch";
|
|
5
4
|
import { Channel, MessageType } from "../wipc.js";
|
|
6
5
|
import {
|
|
@@ -21,6 +20,7 @@ import { PassThrough } from "stream";
|
|
|
21
20
|
import { buildWebRunnerSource } from "./web-runner-source.js";
|
|
22
21
|
import { PersistentWebSessionHost } from "./web-session.js";
|
|
23
22
|
import { build } from "./build-core.js";
|
|
23
|
+
import { resolveSpecFiles, emitSelectorWarnings } from "../selectors.js";
|
|
24
24
|
import { createReporter as createDefaultReporter } from "../reporters/default.js";
|
|
25
25
|
import { createTapReporter } from "../reporters/tap.js";
|
|
26
26
|
import { persistCrashRecord } from "../crash-store.js";
|
|
@@ -733,14 +733,9 @@ export async function run(
|
|
|
733
733
|
}
|
|
734
734
|
}
|
|
735
735
|
}
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
.filter((p) => p.startsWith("!"))
|
|
740
|
-
.map((p) => p.slice(1));
|
|
741
|
-
const inputFiles = (
|
|
742
|
-
await glob(includePatterns, { ignore: ignorePatterns })
|
|
743
|
-
).sort((a, b) => a.localeCompare(b));
|
|
736
|
+
const { files: inputFiles, warnings: selectorWarnings } =
|
|
737
|
+
await resolveSpecFiles(config.input, selectors);
|
|
738
|
+
emitSelectorWarnings(selectorWarnings);
|
|
744
739
|
const snapshotEnabled = flags.snapshot !== false;
|
|
745
740
|
const createSnapshots = Boolean(flags.createSnapshots);
|
|
746
741
|
const overwriteSnapshots = Boolean(flags.overwriteSnapshots);
|
|
@@ -1364,61 +1359,6 @@ function runtimeNameFromCommand(command) {
|
|
|
1364
1359
|
const token = command.trim().split(/\s+/)[0];
|
|
1365
1360
|
return token && token.length ? token : "runtime";
|
|
1366
1361
|
}
|
|
1367
|
-
function resolveInputPatterns(configured, selectors) {
|
|
1368
|
-
const configuredInputs = Array.isArray(configured)
|
|
1369
|
-
? configured
|
|
1370
|
-
: [configured];
|
|
1371
|
-
if (!selectors.length) return configuredInputs;
|
|
1372
|
-
const patterns = new Set();
|
|
1373
|
-
for (const selector of expandSelectors(selectors)) {
|
|
1374
|
-
if (!selector) continue;
|
|
1375
|
-
if (isBareSuiteSelector(selector)) {
|
|
1376
|
-
const base = stripSuiteSuffix(selector);
|
|
1377
|
-
for (const configuredInput of configuredInputs) {
|
|
1378
|
-
patterns.add(
|
|
1379
|
-
path.join(path.dirname(configuredInput), `${base}.spec.ts`),
|
|
1380
|
-
);
|
|
1381
|
-
}
|
|
1382
|
-
continue;
|
|
1383
|
-
}
|
|
1384
|
-
patterns.add(selector);
|
|
1385
|
-
}
|
|
1386
|
-
return [...patterns];
|
|
1387
|
-
}
|
|
1388
|
-
function expandSelectors(selectors) {
|
|
1389
|
-
const expanded = [];
|
|
1390
|
-
for (const selector of selectors) {
|
|
1391
|
-
if (!selector) continue;
|
|
1392
|
-
if (!shouldSplitSelector(selector)) {
|
|
1393
|
-
expanded.push(selector);
|
|
1394
|
-
continue;
|
|
1395
|
-
}
|
|
1396
|
-
for (const token of selector.split(",")) {
|
|
1397
|
-
const trimmed = token.trim();
|
|
1398
|
-
if (!trimmed.length) continue;
|
|
1399
|
-
expanded.push(trimmed);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
return expanded;
|
|
1403
|
-
}
|
|
1404
|
-
function shouldSplitSelector(selector) {
|
|
1405
|
-
return (
|
|
1406
|
-
selector.includes(",") &&
|
|
1407
|
-
!selector.includes("/") &&
|
|
1408
|
-
!selector.includes("\\") &&
|
|
1409
|
-
!/[*?[\]{}]/.test(selector)
|
|
1410
|
-
);
|
|
1411
|
-
}
|
|
1412
|
-
function isBareSuiteSelector(selector) {
|
|
1413
|
-
return (
|
|
1414
|
-
!selector.includes("/") &&
|
|
1415
|
-
!selector.includes("\\") &&
|
|
1416
|
-
!/[*?[\]{}]/.test(selector)
|
|
1417
|
-
);
|
|
1418
|
-
}
|
|
1419
|
-
function stripSuiteSuffix(selector) {
|
|
1420
|
-
return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
|
|
1421
|
-
}
|
|
1422
1362
|
function normalizeReport(raw) {
|
|
1423
1363
|
if (Array.isArray(raw)) {
|
|
1424
1364
|
return {
|
|
@@ -2165,14 +2105,19 @@ async function runProcess(
|
|
|
2165
2105
|
runtimeEvents,
|
|
2166
2106
|
);
|
|
2167
2107
|
if (synthesized) {
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2108
|
+
const exitedEarly = !runtimeEvents.sawFileEnd;
|
|
2109
|
+
if (
|
|
2110
|
+
exitedEarly ||
|
|
2111
|
+
code !== 0 ||
|
|
2112
|
+
hasMeaningfulRuntimeOutput(stderrBuffer)
|
|
2113
|
+
) {
|
|
2173
2114
|
const errorParts = [];
|
|
2174
2115
|
if (code !== 0) {
|
|
2175
2116
|
errorParts.push(`child process exited with code ${code}`);
|
|
2117
|
+
} else if (exitedEarly) {
|
|
2118
|
+
errorParts.push(
|
|
2119
|
+
"test runtime exited before reporting file completion",
|
|
2120
|
+
);
|
|
2176
2121
|
}
|
|
2177
2122
|
const stderrText = normalizeRuntimeOutput(stderrBuffer);
|
|
2178
2123
|
if (stderrText.length) {
|
|
@@ -2201,12 +2146,18 @@ async function runProcess(
|
|
|
2201
2146
|
modeName,
|
|
2202
2147
|
code !== 0
|
|
2203
2148
|
? `test runtime failed with exit code ${code}`
|
|
2204
|
-
:
|
|
2149
|
+
: exitedEarly
|
|
2150
|
+
? "test runtime exited before completing the test file"
|
|
2151
|
+
: "test runtime wrote to stderr",
|
|
2205
2152
|
errorText,
|
|
2206
2153
|
stdoutBuffer,
|
|
2207
2154
|
stderrBuffer,
|
|
2208
2155
|
);
|
|
2209
2156
|
}
|
|
2157
|
+
reporter.onWarning?.({
|
|
2158
|
+
message:
|
|
2159
|
+
"runtime report payload missing; reconstructed result from streamed lifecycle events",
|
|
2160
|
+
});
|
|
2210
2161
|
return synthesized;
|
|
2211
2162
|
}
|
|
2212
2163
|
const errorText = "missing report payload from test runtime";
|
|
@@ -2548,14 +2499,19 @@ async function runWebSessionProcess(
|
|
|
2548
2499
|
runtimeEvents,
|
|
2549
2500
|
);
|
|
2550
2501
|
if (synthesized) {
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2502
|
+
const exitedEarly = !runtimeEvents.sawFileEnd;
|
|
2503
|
+
if (
|
|
2504
|
+
exitedEarly ||
|
|
2505
|
+
code !== 0 ||
|
|
2506
|
+
hasMeaningfulRuntimeOutput(stderrBuffer)
|
|
2507
|
+
) {
|
|
2556
2508
|
const errorParts = [];
|
|
2557
2509
|
if (code !== 0) {
|
|
2558
2510
|
errorParts.push(`child process exited with code ${code}`);
|
|
2511
|
+
} else if (exitedEarly) {
|
|
2512
|
+
errorParts.push(
|
|
2513
|
+
"test runtime exited before reporting file completion",
|
|
2514
|
+
);
|
|
2559
2515
|
}
|
|
2560
2516
|
const stderrText = normalizeRuntimeOutput(stderrBuffer);
|
|
2561
2517
|
if (stderrText.length) {
|
|
@@ -2567,10 +2523,35 @@ async function runWebSessionProcess(
|
|
|
2567
2523
|
reportStream,
|
|
2568
2524
|
runtimeEvents,
|
|
2569
2525
|
);
|
|
2570
|
-
|
|
2571
|
-
|
|
2526
|
+
errorParts.push(diagnostics);
|
|
2527
|
+
const errorText = errorParts.join("\n\n");
|
|
2528
|
+
persistCrashRecord(crashDir, {
|
|
2529
|
+
kind: "test",
|
|
2530
|
+
file: specFile,
|
|
2531
|
+
entryKey: crashEntryKey,
|
|
2532
|
+
mode: modeName ?? "default",
|
|
2533
|
+
error: errorText || "runtime reported an unknown error",
|
|
2534
|
+
stdout: stdoutBuffer,
|
|
2535
|
+
stderr: stderrBuffer,
|
|
2572
2536
|
});
|
|
2537
|
+
return appendRuntimeFailureReport(
|
|
2538
|
+
synthesized,
|
|
2539
|
+
specFile,
|
|
2540
|
+
modeName,
|
|
2541
|
+
code !== 0
|
|
2542
|
+
? `test runtime failed with exit code ${code}`
|
|
2543
|
+
: exitedEarly
|
|
2544
|
+
? "test runtime exited before completing the test file"
|
|
2545
|
+
: "test runtime wrote to stderr",
|
|
2546
|
+
errorText,
|
|
2547
|
+
stdoutBuffer,
|
|
2548
|
+
stderrBuffer,
|
|
2549
|
+
);
|
|
2573
2550
|
}
|
|
2551
|
+
reporter.onWarning?.({
|
|
2552
|
+
message:
|
|
2553
|
+
"runtime report payload missing; reconstructed result from streamed lifecycle events",
|
|
2554
|
+
});
|
|
2574
2555
|
return synthesized;
|
|
2575
2556
|
}
|
|
2576
2557
|
const diagnostics = buildRuntimeReportDiagnostics(
|
package/bin/index.js
CHANGED
|
@@ -41,6 +41,7 @@ import { BuildWorkerPool } from "./build-worker-pool.js";
|
|
|
41
41
|
import { PersistentWebSessionHost } from "./commands/web-session.js";
|
|
42
42
|
import { buildRecorderStorage } from "./commands/build-core.js";
|
|
43
43
|
import { DependencyGraph } from "./dependency-graph.js";
|
|
44
|
+
import { resolveSpecFiles, emitSelectorWarnings } from "./selectors.js";
|
|
44
45
|
const _args = process.argv.slice(2);
|
|
45
46
|
const flags = [];
|
|
46
47
|
const args = [];
|
|
@@ -2344,7 +2345,10 @@ async function runWatchLoop(
|
|
|
2344
2345
|
if (byte === 0x03) {
|
|
2345
2346
|
if (rawModeEnabled) stdin.setRawMode(false);
|
|
2346
2347
|
closeAllWatchers();
|
|
2347
|
-
|
|
2348
|
+
// Exit non-zero if the last run left anything failing, or if a run
|
|
2349
|
+
// is still in flight (an interrupted run counts as a failure), so
|
|
2350
|
+
// quitting a red watch session still fails CI / shell pipelines.
|
|
2351
|
+
process.exit(isRunning || failingSpecs.size ? 1 : 0);
|
|
2348
2352
|
}
|
|
2349
2353
|
if (isRunning) break;
|
|
2350
2354
|
if (byte === 0x77 || byte === 0x57) {
|
|
@@ -2394,7 +2398,8 @@ async function runWatchLoop(
|
|
|
2394
2398
|
process.on("SIGINT", () => {
|
|
2395
2399
|
if (rawModeEnabled) stdin.setRawMode(false);
|
|
2396
2400
|
closeAllWatchers();
|
|
2397
|
-
|
|
2401
|
+
// Mirror the raw-mode ctrl+c path: a failing or in-flight run exits 1.
|
|
2402
|
+
process.exit(isRunning || failingSpecs.size ? 1 : 0);
|
|
2398
2403
|
});
|
|
2399
2404
|
// Keep the process alive
|
|
2400
2405
|
await new Promise(() => {});
|
|
@@ -3851,9 +3856,9 @@ async function resolveSelectedFiles(configPath, selectors, warn = true) {
|
|
|
3851
3856
|
const resolvedConfigPath =
|
|
3852
3857
|
configPath ?? path.join(process.cwd(), "./as-test.config.json");
|
|
3853
3858
|
const config = loadConfig(resolvedConfigPath, warn);
|
|
3854
|
-
const
|
|
3855
|
-
|
|
3856
|
-
const specs =
|
|
3859
|
+
const { files, warnings } = await resolveSpecFiles(config.input, selectors);
|
|
3860
|
+
if (warn) emitSelectorWarnings(warnings);
|
|
3861
|
+
const specs = files.filter((file) => file.endsWith(".spec.ts"));
|
|
3857
3862
|
return [...new Set(specs)].sort((a, b) => a.localeCompare(b));
|
|
3858
3863
|
}
|
|
3859
3864
|
async function resolveSelectedFuzzFiles(
|
|
@@ -3992,27 +3997,6 @@ function levenshteinDistance(left, right) {
|
|
|
3992
3997
|
}
|
|
3993
3998
|
return matrix[left.length][right.length];
|
|
3994
3999
|
}
|
|
3995
|
-
function resolveInputPatterns(configured, selectors) {
|
|
3996
|
-
const configuredInputs = Array.isArray(configured)
|
|
3997
|
-
? configured
|
|
3998
|
-
: [configured];
|
|
3999
|
-
if (!selectors.length) return configuredInputs;
|
|
4000
|
-
const patterns = new Set();
|
|
4001
|
-
for (const selector of expandSelectors(selectors)) {
|
|
4002
|
-
if (!selector) continue;
|
|
4003
|
-
if (isBareSuiteSelector(selector)) {
|
|
4004
|
-
const base = stripSuiteSuffix(selector);
|
|
4005
|
-
for (const configuredInput of configuredInputs) {
|
|
4006
|
-
patterns.add(
|
|
4007
|
-
path.join(path.dirname(configuredInput), `${base}.spec.ts`),
|
|
4008
|
-
);
|
|
4009
|
-
}
|
|
4010
|
-
continue;
|
|
4011
|
-
}
|
|
4012
|
-
patterns.add(selector);
|
|
4013
|
-
}
|
|
4014
|
-
return [...patterns];
|
|
4015
|
-
}
|
|
4016
4000
|
function resolveFuzzPatterns(configured, selectors) {
|
|
4017
4001
|
const configuredInputs = Array.isArray(configured)
|
|
4018
4002
|
? configured
|
package/bin/selectors.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { glob } from "glob";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
// Selector resolution runs in several places per command (the orchestrator,
|
|
5
|
+
// then build/run cores per file); dedupe by text so a warning prints once per
|
|
6
|
+
// process regardless of how many resolvers see the same selector.
|
|
7
|
+
const reportedSelectorWarnings = new Set();
|
|
8
|
+
export function emitSelectorWarnings(warnings) {
|
|
9
|
+
for (const warning of warnings) {
|
|
10
|
+
if (reportedSelectorWarnings.has(warning)) continue;
|
|
11
|
+
reportedSelectorWarnings.add(warning);
|
|
12
|
+
process.stderr.write(`${chalk.yellow.bold("WARN")} ${warning}\n`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const GLOB_MAGIC = /[*?[\]{}]/;
|
|
16
|
+
function hasGlobMagic(selector) {
|
|
17
|
+
return GLOB_MAGIC.test(selector);
|
|
18
|
+
}
|
|
19
|
+
function endsWithSlash(selector) {
|
|
20
|
+
return /[\\/]$/.test(selector);
|
|
21
|
+
}
|
|
22
|
+
function stripTrailingSlash(selector) {
|
|
23
|
+
return selector.replace(/[\\/]+$/, "");
|
|
24
|
+
}
|
|
25
|
+
function stripSuiteSuffix(selector) {
|
|
26
|
+
return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
|
|
27
|
+
}
|
|
28
|
+
function isCwdRelative(selector) {
|
|
29
|
+
return (
|
|
30
|
+
selector.startsWith("./") ||
|
|
31
|
+
selector.startsWith("../") ||
|
|
32
|
+
selector.startsWith(".\\") ||
|
|
33
|
+
selector.startsWith("..\\") ||
|
|
34
|
+
selector.startsWith("/") ||
|
|
35
|
+
selector.startsWith("~") ||
|
|
36
|
+
path.isAbsolute(selector)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// A selector with a path separator that is not merely a single trailing slash
|
|
40
|
+
// (e.g. `assembly/__tests__/foo.spec.ts`, passed verbatim by the orchestrator)
|
|
41
|
+
// is treated as a direct cwd-relative path rather than a test-folder alias.
|
|
42
|
+
function hasInternalSlash(selector) {
|
|
43
|
+
return /[\\/]/.test(stripTrailingSlash(selector));
|
|
44
|
+
}
|
|
45
|
+
// The longest leading run of path segments containing no glob magic — the
|
|
46
|
+
// static "test folder" of an input pattern (`assembly/__tests__/**/*.spec.ts`
|
|
47
|
+
// -> `assembly/__tests__`).
|
|
48
|
+
function globBase(pattern) {
|
|
49
|
+
const segments = pattern.split("/");
|
|
50
|
+
const base = [];
|
|
51
|
+
for (const segment of segments) {
|
|
52
|
+
if (hasGlobMagic(segment)) break;
|
|
53
|
+
base.push(segment);
|
|
54
|
+
}
|
|
55
|
+
return base.join("/") || ".";
|
|
56
|
+
}
|
|
57
|
+
function uniqueInputRoots(configuredInputs) {
|
|
58
|
+
const roots = new Set();
|
|
59
|
+
for (const pattern of configuredInputs) {
|
|
60
|
+
if (pattern.startsWith("!")) continue;
|
|
61
|
+
roots.add(globBase(pattern));
|
|
62
|
+
}
|
|
63
|
+
return [...roots];
|
|
64
|
+
}
|
|
65
|
+
// Turn a cwd-relative selector into the spec glob(s) it stands for.
|
|
66
|
+
function cwdPatterns(selector) {
|
|
67
|
+
if (endsWithSlash(selector)) {
|
|
68
|
+
return [`${stripTrailingSlash(selector)}/**/*.spec.ts`];
|
|
69
|
+
}
|
|
70
|
+
if (/\.ts$/.test(selector)) return [selector];
|
|
71
|
+
return [`${stripSuiteSuffix(selector)}.spec.ts`];
|
|
72
|
+
}
|
|
73
|
+
// Turn a bare selector into the spec glob(s) it stands for, anchored to a
|
|
74
|
+
// configured input root and searched recursively beneath it. A selector that
|
|
75
|
+
// already carries glob magic (`rfc/*.spec.ts`, `*.spec.ts`) is appended
|
|
76
|
+
// verbatim so the user's pattern controls the match; a plain folder/name has
|
|
77
|
+
// the spec suffix supplied.
|
|
78
|
+
function barePatterns(root, selector) {
|
|
79
|
+
if (hasGlobMagic(selector)) {
|
|
80
|
+
return [`${root}/**/${selector}`];
|
|
81
|
+
}
|
|
82
|
+
if (endsWithSlash(selector)) {
|
|
83
|
+
return [`${root}/**/${stripTrailingSlash(selector)}/**/*.spec.ts`];
|
|
84
|
+
}
|
|
85
|
+
return [`${root}/**/${stripSuiteSuffix(selector)}.spec.ts`];
|
|
86
|
+
}
|
|
87
|
+
// Split comma-joined bare selectors (`a,b,c`) while leaving paths and globs
|
|
88
|
+
// (which can legitimately contain commas, e.g. `{a,b}`) intact.
|
|
89
|
+
function expandSelectors(selectors) {
|
|
90
|
+
const expanded = [];
|
|
91
|
+
for (const selector of selectors) {
|
|
92
|
+
if (!selector) continue;
|
|
93
|
+
if (
|
|
94
|
+
selector.includes(",") &&
|
|
95
|
+
!hasInternalSlash(selector) &&
|
|
96
|
+
!endsWithSlash(selector) &&
|
|
97
|
+
!hasGlobMagic(selector)
|
|
98
|
+
) {
|
|
99
|
+
for (const token of selector.split(",")) {
|
|
100
|
+
const trimmed = token.trim();
|
|
101
|
+
if (trimmed.length) expanded.push(trimmed);
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
expanded.push(selector);
|
|
106
|
+
}
|
|
107
|
+
return expanded;
|
|
108
|
+
}
|
|
109
|
+
async function globFiles(patterns) {
|
|
110
|
+
return glob(patterns);
|
|
111
|
+
}
|
|
112
|
+
async function resolveSelector(selector, inputRoots) {
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const isGlob = hasGlobMagic(selector);
|
|
115
|
+
// Explicit cwd-relative selector (`./`, `../`, absolute, `~`) — resolve from
|
|
116
|
+
// the cwd only. A glob is matched verbatim; a plain path gets the spec suffix.
|
|
117
|
+
if (isCwdRelative(selector)) {
|
|
118
|
+
const files = await globFiles(isGlob ? [selector] : cwdPatterns(selector));
|
|
119
|
+
if (!files.length) {
|
|
120
|
+
const bare = selector.replace(/^\.[\\/]/, "");
|
|
121
|
+
let suggestion = null;
|
|
122
|
+
for (const root of inputRoots) {
|
|
123
|
+
const inRoot = await globFiles(barePatterns(root, bare));
|
|
124
|
+
if (inRoot.length) {
|
|
125
|
+
suggestion = bare;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
warnings.push(
|
|
130
|
+
suggestion
|
|
131
|
+
? `"${selector}" not found relative to the current directory — did you mean "${suggestion}" (searches the configured test folder)?`
|
|
132
|
+
: `"${selector}" not found relative to the current directory`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return { files, warnings };
|
|
136
|
+
}
|
|
137
|
+
// A plain path with an internal separator (e.g. the orchestrator's own
|
|
138
|
+
// `assembly/__tests__/foo.spec.ts`) resolves from the cwd verbatim. Globs
|
|
139
|
+
// skip this and fall through to test-folder anchoring below.
|
|
140
|
+
if (!isGlob && hasInternalSlash(selector)) {
|
|
141
|
+
const direct = await globFiles(cwdPatterns(selector));
|
|
142
|
+
if (direct.length) return { files: direct, warnings };
|
|
143
|
+
// Fall through to test-folder resolution for user shorthands like
|
|
144
|
+
// `nested/array` that aren't a real cwd path.
|
|
145
|
+
}
|
|
146
|
+
// Bare name/folder or relative glob — configured input root(s) first.
|
|
147
|
+
const perRoot = [];
|
|
148
|
+
for (const root of inputRoots) {
|
|
149
|
+
const files = await globFiles(barePatterns(root, selector));
|
|
150
|
+
if (files.length) perRoot.push({ root, files });
|
|
151
|
+
}
|
|
152
|
+
if (perRoot.length) {
|
|
153
|
+
if (perRoot.length > 1) {
|
|
154
|
+
warnings.push(
|
|
155
|
+
`selector "${selector}" matched specs under ${perRoot.length} input roots (${perRoot
|
|
156
|
+
.map((entry) => entry.root)
|
|
157
|
+
.join(", ")}) — running all of them`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return { files: perRoot.flatMap((entry) => entry.files), warnings };
|
|
161
|
+
}
|
|
162
|
+
// Fall back to the cwd before giving up.
|
|
163
|
+
const cwdFiles = await globFiles(
|
|
164
|
+
isGlob ? [selector] : cwdPatterns(`./${selector}`),
|
|
165
|
+
);
|
|
166
|
+
if (cwdFiles.length) {
|
|
167
|
+
return { files: cwdFiles, warnings };
|
|
168
|
+
}
|
|
169
|
+
warnings.push(
|
|
170
|
+
inputRoots.length
|
|
171
|
+
? `no spec files matched "${selector}" in ${inputRoots.join(", ")} or the current directory`
|
|
172
|
+
: `no spec files matched "${selector}"`,
|
|
173
|
+
);
|
|
174
|
+
return { files: [], warnings };
|
|
175
|
+
}
|
|
176
|
+
// Resolve configured input patterns + positional selectors into the concrete
|
|
177
|
+
// set of spec files to act on, along with any human-readable warnings. With no
|
|
178
|
+
// selectors this is just the configured globs (honoring `!`-negations); with
|
|
179
|
+
// selectors the per-selector rules above apply and config negations are
|
|
180
|
+
// intentionally bypassed so an explicit pick always wins.
|
|
181
|
+
export async function resolveSpecFiles(configured, selectors) {
|
|
182
|
+
const configuredInputs = Array.isArray(configured)
|
|
183
|
+
? configured
|
|
184
|
+
: [configured];
|
|
185
|
+
if (!selectors.length) {
|
|
186
|
+
const include = configuredInputs.filter((p) => !p.startsWith("!"));
|
|
187
|
+
const ignore = configuredInputs
|
|
188
|
+
.filter((p) => p.startsWith("!"))
|
|
189
|
+
.map((p) => p.slice(1));
|
|
190
|
+
const files = (await glob(include, { ignore })).sort((a, b) =>
|
|
191
|
+
a.localeCompare(b),
|
|
192
|
+
);
|
|
193
|
+
return { files, warnings: [] };
|
|
194
|
+
}
|
|
195
|
+
const inputRoots = uniqueInputRoots(configuredInputs);
|
|
196
|
+
const files = new Set();
|
|
197
|
+
const warnings = [];
|
|
198
|
+
for (const selector of expandSelectors(selectors)) {
|
|
199
|
+
if (!selector) continue;
|
|
200
|
+
const resolved = await resolveSelector(selector, inputRoots);
|
|
201
|
+
for (const file of resolved.files) files.add(file);
|
|
202
|
+
warnings.push(...resolved.warnings);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
files: [...files].sort((a, b) => a.localeCompare(b)),
|
|
206
|
+
warnings,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/lib/build/index.js
CHANGED
|
@@ -220,6 +220,7 @@ async function instantiateEsmInstance(wasmPath, helperPath, imports) {
|
|
|
220
220
|
"esm bindings do not support custom imports in as-test/lib; pass {} or switch to raw bindings",
|
|
221
221
|
);
|
|
222
222
|
}
|
|
223
|
+
patchNodeIo();
|
|
223
224
|
const instance = await captureHelperInstance(async () => {
|
|
224
225
|
await import(`${pathToFileURL(helperPath).href}?t=${Date.now()}`);
|
|
225
226
|
});
|
package/lib/src/index.ts
CHANGED
|
@@ -272,6 +272,10 @@ async function instantiateEsmInstance(
|
|
|
272
272
|
"esm bindings do not support custom imports in as-test/lib; pass {} or switch to raw bindings",
|
|
273
273
|
);
|
|
274
274
|
}
|
|
275
|
+
// The esm helper auto-instantiates at import time and writes the WIPC report
|
|
276
|
+
// by calling the global process.stdout.write with an ArrayBuffer. Patch node
|
|
277
|
+
// IO before importing so that write (and stdin.read) accept the raw buffer.
|
|
278
|
+
patchNodeIo();
|
|
275
279
|
const instance = await captureHelperInstance(async () => {
|
|
276
280
|
await import(`${pathToFileURL(helperPath).href}?t=${Date.now()}`);
|
|
277
281
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "as-test",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"author": "Jairus Tanaka",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -80,10 +80,10 @@
|
|
|
80
80
|
"build:transform": "rm -rf ./transform/lib && tsc -p ./transform && prettier -w ./transform/",
|
|
81
81
|
"test": "node ./bin/index.js test --parallel",
|
|
82
82
|
"test:integration": "node --test tests/*.test.mjs",
|
|
83
|
-
"test:modes": "npm run test -- --mode node:bindings,node:wasi,wasmtime,wasmer,wazero,firefox,chromium,webkit,firefox:headless,chromium:headless,webkit:headless",
|
|
83
|
+
"test:modes": "npm run test -- --mode node:bindings,node:bindings:raw,node:bindings:esm,node:wasi,wasmtime,wasmer,wazero,firefox,chromium,webkit,firefox:headless,chromium:headless,webkit:headless",
|
|
84
84
|
"test:examples": "npm --prefix ./examples run test",
|
|
85
85
|
"test:all": "npm run build && npm run test:modes && npm run test:integration && npm run test:examples",
|
|
86
|
-
"test:ci": "npm run test -- --
|
|
86
|
+
"test:ci": "npm run test -- --mode node:bindings,node:wasi,wasmtime",
|
|
87
87
|
"fuzz": "node ./bin/index.js fuzz",
|
|
88
88
|
"bench:seed": "node ./bin/index.js fuzz --config ./as-test.bench.config.json --clean",
|
|
89
89
|
"bench:seed:compare": "bash ./tools/bench-seed-compare.sh 7",
|