as-test 1.5.1 → 1.6.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 +32 -0
- package/as-test.config.schema.json +40 -0
- package/bin/build-cache.js +278 -0
- package/bin/commands/build-core.js +84 -78
- package/bin/commands/clean-core.js +4 -0
- package/bin/commands/run-core.js +186 -66
- package/bin/commands/test.js +2 -0
- package/bin/index.js +257 -92
- package/bin/reporters/default.js +56 -5
- package/bin/selectors.js +208 -0
- package/bin/types.js +18 -0
- package/bin/util.js +94 -0
- package/package.json +2 -2
- package/transform/lib/index.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 2026-06-01 - v1.6.0
|
|
4
|
+
|
|
5
|
+
### Incremental test cache — only rebuild and rerun what changed
|
|
6
|
+
|
|
7
|
+
- feat: an opt-in incremental cache for `ast test` skips recompiling and rerunning specs whose inputs are unchanged since the last run, only acting on the ones that changed — themselves or via their dependency tree. Enable per run with `--cache`, or persistently with `"cache": true | "build" | "full"` in `as-test.config.json` (`--no-cache` overrides). Default is off. Because asc compilation is ~all of a run's wall-clock (a spec runs in microseconds but builds in seconds), skipping unchanged builds is the dominant win. New module `cli/build-cache.ts` persists a manifest under `.as-test/cache/`.
|
|
8
|
+
- feat: **Tier 1 (build cache)** — `build()` (`cli/commands/build-core.ts`) and the parallel `buildFileForMode` (`cli/index.ts`) skip the asc invocation when a spec, every file it imported, and the build signature (`getBuildReuseInfo`) are all unchanged and the `.wasm` still exists. Dependencies are captured from asc's actual file reads (reusing the existing `buildRecorderStorage`/`onReads` plumbing that previously only fed `--watch`), so editing a shared helper correctly rebuilds just its dependents. The cache context is threaded once via an `AsyncLocalStorage` (`cacheStorage`) set in `runTestModes`, so no per-variant plumbing was needed and `--watch` (which keeps its own in-memory graph) is untouched.
|
|
9
|
+
- feat: **Tier 2 (run replay)** — under `"full"`/`--cache`, `run()` (`cli/commands/run-core.ts`) replays a stored passing report instead of spawning the runtime, so cached specs still appear in the live output and in the summary/coverage. Replayed specs keep the coloured verdict badge (white text) but render the **filename dimmed with a `(cache)` tag in place of the timing**, so freshly-run specs stand out — in both the default reporter (`cli/reporters/default.ts`) and the multi-mode matrix display (`formatMatrixFileResultLine`). Only passing reports are replayed; a failing spec re-runs for fresh output. A rebuild clears the stored report, so a changed spec never replays a stale result.
|
|
10
|
+
- feat: change detection uses a content hash (sha256) with an mtime+size fast-path — correct across `git checkout`/`clone`/`touch` (no false rebuilds) yet cheap when nothing changed. The whole cache is invalidated when the as-test version changes (transform output may differ); replay additionally keys on the runtime command and snapshot file contents. The build signature hashes only as-test's _declared_ env (config + `buildOptions.env` + the `AS_TEST_*` flags), not the inherited `process.env`, so volatile ambient vars like `FORCE_COLOR`/`TERM`/`CI` can't spuriously invalidate the cache between runs.
|
|
11
|
+
- note: a reachability-based dep-pruning mode (`cache: "reachable"`) was prototyped but **not shipped** — it was unsound for AssemblyScript's compile-time inlining (inlined `const`s, `static readonly` fields, `@inline` bodies, and re-export barrels can change a spec's output without being "reachable"), so it could serve a stale pass. `"reachable"` is accepted as a deprecated alias for `"full"`, which tracks the complete dependency set and is always correct.
|
|
12
|
+
- feat: the run summary gains a **`Cache:` line** (between `Modes:` and `Time:`), rendered in the same aligned three-column layout as the other totals — e.g. `Cache: 46 cached, 0 skipped, 46 total` (counted per file/mode execution; `skipped` = ran fresh). It only appears when the cache is active — each report carries a `cached` flag (true = replayed, false = freshly run) that the default reporter tallies in `onRunComplete`.
|
|
13
|
+
- feat: **time-based expiry** via the object config form — `"cache": { "type": "full", "maxTime": "1h" }`. `maxTime` accepts `ms`/`s`/`m`/`h`/`d` durations; an entry built longer ago than that is treated as stale and rebuilt+rerun (which resets its timer). `type` selects the tier (`build`/`full`); the bare boolean/string forms still work. Parsed by `parseDurationMs`, resolved by `resolveCacheSettings` (`cli/util.ts`), enforced per-entry in `BuildCache.isBuildFresh` against a `builtAt` timestamp.
|
|
14
|
+
- fix: the cache is now honored under `--watch` — previously `--cache --watch` silently ignored the cache (watch only set up its own in-memory dependency graph), so every watch run rebuilt from scratch. Each watch iteration now runs inside the cache context (a fresh load per run so `maxTime` stays current), making the initial watch run and `a` (re-run all) replay unchanged specs. `build()`'s read-recorder forwards to the watch graph recorder so both stay correct together; entry-pruning is skipped under watch (scoped re-runs resolve only a subset). A new **`c` keybinding toggles the cache** live; toggling `c` (and `w`) now rewrites the watch footer in place — `c = cache (on/off)` — instead of appending a message + a fresh footer each time.
|
|
15
|
+
- feat: the cache is bypassed automatically when it cannot be trusted — `--fuzz` (non-deterministic), a custom build command, and (for replay only) `--create-snapshots`/`--overwrite-snapshots`. `ast clean` removes `.as-test/cache`.
|
|
16
|
+
- fix: under `--parallel`, per-file result lines are now emitted in resolved order (the `localeCompare` sort `resolveSpecFiles` applies after globbing) instead of completion order. Previously a cache replay finished instantly and printed ahead of still-running fresh specs, scrambling the list; `ParallelQueueDisplay` now buffers completed output and flushes the contiguous prefix in start order, so cached and freshly-run specs stay interleaved in their original order.
|
|
17
|
+
- chore: the cache manifest and per-spec reports under `.as-test/cache/` are gzipped (`manifest.json.gz`, `reports/*.json.gz`) — they're large, repetitive JSON dominated by coverage points, so this shrinks the cache directory ~10× (≈8 MB → ≈0.8 MB on as-test's own suite) at negligible read/write cost.
|
|
18
|
+
|
|
19
|
+
## 2026-06-01 - v1.5.3
|
|
20
|
+
|
|
21
|
+
### A file with no runnable tests is skipped, not crashed
|
|
22
|
+
|
|
23
|
+
- fix: a spec file whose only suites are skip variants (`xdescribe`/`xtest`/`xit`) is now reported as **SKIP** instead of failing with `missing report payload from test runtime`. The transform's auto-`run()` injection keys off `analyzeSourceText` → `hasSuiteCalls` (`transform/src/index.ts`), whose regex was `\b(?:describe|test|it|only|xonly|todo|fuzz|xfuzz)`. `\bdescribe` can't match `xdescribe(` — `x` and `d` are both word chars, so there's no word boundary — and likewise `\btest`/`\bit` miss `xtest`/`xit`. A file whose only suites were skipped therefore reported `hasSuiteCalls = false`, `run()` was never injected, the wasm emitted no lifecycle frames and exited `0`, and the CLI read that silent-but-clean exit as a runtime crash. The regex now matches the `x?` variants (`x?describe|x?test|x?it|x?only|todo|x?fuzz`), so `run()` is injected and the file reports itself skipped (with the suite shown and counted). `looksLikeAsTestImport` got the same `x?` treatment so a file importing _only_ an x-variant still resolves its `run` import path.
|
|
24
|
+
- feat: a spec file with no suites at all (empty, or only imports/comments) is now reported as a skipped file with a `… contains no tests; marked as skipped` warning, instead of `missing report payload`. Such a file never injects `run()` either, so `runProcess` (`cli/commands/run-core.ts`) now treats a clean exit (`code 0`) with zero data frames, no `file-start`/`file-end` events, no suite starts, and no stderr as an empty test file — returning a skip report (`createEmptyFileSkipReport`, `suites: []` so it counts as one skipped file and zero skipped suites) rather than a crash. A file that _does_ have suites always emits `file-start` before anything can go wrong, so this only ever fires for genuinely empty files.
|
|
25
|
+
|
|
26
|
+
## 2026-06-01 - v1.5.2
|
|
27
|
+
|
|
28
|
+
### Selectors resolve folders, files, and globs consistently
|
|
29
|
+
|
|
30
|
+
- 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:
|
|
31
|
+
- **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.
|
|
32
|
+
- **`./`-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.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
3
35
|
## 2026-05-30 - v1.5.1
|
|
4
36
|
|
|
5
37
|
### An early-exiting runtime now fails instead of warning
|
|
@@ -87,6 +87,28 @@
|
|
|
87
87
|
"description": "Reserved config field used by existing projects.",
|
|
88
88
|
"default": "none"
|
|
89
89
|
},
|
|
90
|
+
"cache": {
|
|
91
|
+
"description": "Incremental test cache (opt-in). false = off; \"build\" = skip recompiling unchanged specs; \"full\"/true = also replay passing run results; \"reachable\" = full + reachability-pruned deps. The object form adds \"maxTime\" to expire entries older than that. Override per run with --cache / --no-cache.",
|
|
92
|
+
"oneOf": [
|
|
93
|
+
{ "type": "boolean" },
|
|
94
|
+
{ "type": "string", "enum": ["build", "full", "reachable"] },
|
|
95
|
+
{
|
|
96
|
+
"type": "object",
|
|
97
|
+
"additionalProperties": false,
|
|
98
|
+
"properties": {
|
|
99
|
+
"type": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"enum": ["build", "full", "reachable"]
|
|
102
|
+
},
|
|
103
|
+
"maxTime": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"description": "Entry expiry, e.g. \"1h\", \"30m\", \"90s\", \"7d\"."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"default": false
|
|
111
|
+
},
|
|
90
112
|
"coverage": {
|
|
91
113
|
"description": "Coverage settings. Use a boolean for quick toggle or object for options.",
|
|
92
114
|
"oneOf": [
|
|
@@ -378,6 +400,24 @@
|
|
|
378
400
|
"type": "string",
|
|
379
401
|
"description": "Mode-specific asconfig path or \"none\"."
|
|
380
402
|
},
|
|
403
|
+
"cache": {
|
|
404
|
+
"description": "Mode-specific incremental cache setting.",
|
|
405
|
+
"oneOf": [
|
|
406
|
+
{ "type": "boolean" },
|
|
407
|
+
{ "type": "string", "enum": ["build", "full", "reachable"] },
|
|
408
|
+
{
|
|
409
|
+
"type": "object",
|
|
410
|
+
"additionalProperties": false,
|
|
411
|
+
"properties": {
|
|
412
|
+
"type": {
|
|
413
|
+
"type": "string",
|
|
414
|
+
"enum": ["build", "full", "reachable"]
|
|
415
|
+
},
|
|
416
|
+
"maxTime": { "type": "string" }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
},
|
|
381
421
|
"coverage": {
|
|
382
422
|
"description": "Mode-specific coverage settings.",
|
|
383
423
|
"oneOf": [
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { gunzipSync, gzipSync } from "zlib";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
14
|
+
// Persisted incremental cache for `ast test`. Two tiers:
|
|
15
|
+
// Tier 1 (build): skip the asc compile when a spec + every file it imported is
|
|
16
|
+
// byte-for-byte unchanged and the build signature still matches.
|
|
17
|
+
// Tier 2 (replay): skip running and replay the stored per-file report when the
|
|
18
|
+
// spec is build-fresh AND the runtime command + snapshot file are unchanged.
|
|
19
|
+
// Both read the run-scoped context in `cacheStorage` (set once by the test
|
|
20
|
+
// orchestrator), mirroring how `buildRecorderStorage` threads the watch
|
|
21
|
+
// recorder down without plumbing a param through every call site.
|
|
22
|
+
const CACHE_FORMAT_VERSION = 1;
|
|
23
|
+
const MODE_KEY_DEFAULT = "__default__";
|
|
24
|
+
// Manifest and reports are gzipped (repetitive JSON, large coverage payloads).
|
|
25
|
+
const MANIFEST_FILE = "manifest.json.gz";
|
|
26
|
+
// Mirror dependency-graph.ts specKey: NUL cannot appear in a mode name or path,
|
|
27
|
+
// so the (mode, path) key can never collide.
|
|
28
|
+
function specKey(mode, spec) {
|
|
29
|
+
return `${mode ?? MODE_KEY_DEFAULT}\u0000${path.resolve(spec)}`;
|
|
30
|
+
}
|
|
31
|
+
// asc reads its own bundled stdlib/toolchain on every build; those never change
|
|
32
|
+
// between runs and would balloon every entry's dep set, so drop them — same
|
|
33
|
+
// filter dependency-graph.ts applies for watch.
|
|
34
|
+
function isUninterestingDep(absPath) {
|
|
35
|
+
const normalized = absPath.replace(/\\/g, "/");
|
|
36
|
+
if (normalized.includes("/node_modules/assemblyscript/")) return true;
|
|
37
|
+
if (normalized.includes("/node_modules/binaryen/")) return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
// `.as-test/cache` — sibling of build/logs/coverage, derived from the build root
|
|
41
|
+
// so a custom outDir keeps everything together. Always the un-mode-qualified
|
|
42
|
+
// base dir: the manifest is mode-scoped internally via its keys.
|
|
43
|
+
export function resolveCacheDir(baseOutDir) {
|
|
44
|
+
return path.join(path.dirname(baseOutDir), "cache");
|
|
45
|
+
}
|
|
46
|
+
export function sha256OfFile(absPath) {
|
|
47
|
+
try {
|
|
48
|
+
return createHash("sha256").update(readFileSync(absPath)).digest("hex");
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class BuildCache {
|
|
54
|
+
constructor(
|
|
55
|
+
cacheDir,
|
|
56
|
+
asTestVersion,
|
|
57
|
+
manifest,
|
|
58
|
+
// Entry expiry window in ms (null = none) and the run's reference time.
|
|
59
|
+
maxTimeMs,
|
|
60
|
+
now,
|
|
61
|
+
) {
|
|
62
|
+
this.cacheDir = cacheDir;
|
|
63
|
+
this.asTestVersion = asTestVersion;
|
|
64
|
+
this.manifest = manifest;
|
|
65
|
+
this.maxTimeMs = maxTimeMs;
|
|
66
|
+
this.now = now;
|
|
67
|
+
this.dirty = false;
|
|
68
|
+
}
|
|
69
|
+
// Loads the manifest, self-healing to an empty cache on a missing/corrupt
|
|
70
|
+
// file or a format/version mismatch (a new as-test may emit different
|
|
71
|
+
// transform output, so the whole cache is invalidated on version bump).
|
|
72
|
+
static load(cacheDir, asTestVersion, opts = {}) {
|
|
73
|
+
const maxTimeMs = opts.maxTimeMs ?? null;
|
|
74
|
+
const now = opts.now ?? Date.now();
|
|
75
|
+
const empty = {
|
|
76
|
+
version: CACHE_FORMAT_VERSION,
|
|
77
|
+
asTestVersion,
|
|
78
|
+
entries: {},
|
|
79
|
+
};
|
|
80
|
+
const manifestPath = path.join(cacheDir, MANIFEST_FILE);
|
|
81
|
+
if (!existsSync(manifestPath)) {
|
|
82
|
+
return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(
|
|
86
|
+
gunzipSync(readFileSync(manifestPath)).toString("utf8"),
|
|
87
|
+
);
|
|
88
|
+
if (
|
|
89
|
+
parsed.version !== CACHE_FORMAT_VERSION ||
|
|
90
|
+
parsed.asTestVersion !== asTestVersion ||
|
|
91
|
+
typeof parsed.entries !== "object" ||
|
|
92
|
+
parsed.entries === null
|
|
93
|
+
) {
|
|
94
|
+
return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
|
|
95
|
+
}
|
|
96
|
+
return new BuildCache(cacheDir, asTestVersion, parsed, maxTimeMs, now);
|
|
97
|
+
} catch {
|
|
98
|
+
return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
isBuildFresh(mode, spec, ctx) {
|
|
102
|
+
const entry = this.manifest.entries[specKey(mode, spec)];
|
|
103
|
+
if (!entry) return false;
|
|
104
|
+
if (entry.buildSignature !== ctx.signature) return false;
|
|
105
|
+
if (entry.coverageEnabled !== ctx.coverageEnabled) return false;
|
|
106
|
+
// Time-based expiry: an entry built longer ago than maxTime is stale.
|
|
107
|
+
// Entries from before this field existed (builtAt undefined) count as 0,
|
|
108
|
+
// so they expire on the first run with maxTime set.
|
|
109
|
+
if (
|
|
110
|
+
this.maxTimeMs != null &&
|
|
111
|
+
this.now - (entry.builtAt ?? 0) > this.maxTimeMs
|
|
112
|
+
) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (!existsSync(entry.outFile)) return false;
|
|
116
|
+
for (const [dep, fp] of Object.entries(entry.deps)) {
|
|
117
|
+
if (!this.depUnchanged(dep, fp)) return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// Tier 2: can we replay the stored report instead of running? A non-null
|
|
122
|
+
// reportPath means the build was fresh this session (recordBuild clears it on
|
|
123
|
+
// any rebuild), so the build signature + deps were already validated by
|
|
124
|
+
// build() before run() — we only re-check the run-specific inputs here
|
|
125
|
+
// (runtime command + snapshot file).
|
|
126
|
+
canReplay(mode, spec, ctx) {
|
|
127
|
+
const entry = this.manifest.entries[specKey(mode, spec)];
|
|
128
|
+
if (!entry?.reportPath) return false;
|
|
129
|
+
if ((entry.runtimeCmd ?? null) !== (ctx.runtimeCmd ?? null)) return false;
|
|
130
|
+
if ((entry.snapshotSha ?? null) !== (ctx.snapshotSha ?? null)) return false;
|
|
131
|
+
if (!existsSync(entry.outFile)) return false;
|
|
132
|
+
return existsSync(path.join(this.cacheDir, entry.reportPath));
|
|
133
|
+
}
|
|
134
|
+
recordBuild(mode, spec, args) {
|
|
135
|
+
const absSpec = path.resolve(spec);
|
|
136
|
+
const deps = {};
|
|
137
|
+
const record = (file) => {
|
|
138
|
+
const abs = path.resolve(file);
|
|
139
|
+
if (isUninterestingDep(abs)) return;
|
|
140
|
+
if (deps[abs]) return;
|
|
141
|
+
const fp = this.fingerprint(abs);
|
|
142
|
+
if (fp) deps[abs] = fp;
|
|
143
|
+
};
|
|
144
|
+
record(absSpec); // a spec is always its own dependency
|
|
145
|
+
for (const file of args.deps) record(file);
|
|
146
|
+
const key = specKey(mode, spec);
|
|
147
|
+
const prior = this.manifest.entries[key];
|
|
148
|
+
// A rebuild means any stored report is stale: drop it so isReplayFresh
|
|
149
|
+
// fails until the fresh run records a new one.
|
|
150
|
+
this.removeReportFile(prior?.reportPath ?? null);
|
|
151
|
+
this.manifest.entries[key] = {
|
|
152
|
+
spec: absSpec,
|
|
153
|
+
mode: mode ?? null,
|
|
154
|
+
buildSignature: args.signature,
|
|
155
|
+
outFile: path.resolve(args.outFile),
|
|
156
|
+
coverageEnabled: args.coverageEnabled,
|
|
157
|
+
deps,
|
|
158
|
+
builtAt: this.now,
|
|
159
|
+
runtimeCmd: null,
|
|
160
|
+
snapshotSha: null,
|
|
161
|
+
reportPath: null,
|
|
162
|
+
};
|
|
163
|
+
this.dirty = true;
|
|
164
|
+
}
|
|
165
|
+
recordReport(mode, spec, args) {
|
|
166
|
+
const key = specKey(mode, spec);
|
|
167
|
+
const entry = this.manifest.entries[key];
|
|
168
|
+
if (!entry) return; // recordBuild must have run first
|
|
169
|
+
const relPath = path.join("reports", `${sha1(key)}.json.gz`);
|
|
170
|
+
const absPath = path.join(this.cacheDir, relPath);
|
|
171
|
+
mkdirSync(path.dirname(absPath), { recursive: true });
|
|
172
|
+
// Reports are large (coverage points) and highly compressible repetitive
|
|
173
|
+
// JSON — gzip keeps .as-test/cache small (~15x on coverage-heavy specs).
|
|
174
|
+
writeFileSync(absPath, gzipSync(Buffer.from(JSON.stringify(args.report))));
|
|
175
|
+
entry.reportPath = relPath;
|
|
176
|
+
entry.snapshotSha = args.snapshotSha;
|
|
177
|
+
entry.runtimeCmd = args.runtimeCmd;
|
|
178
|
+
this.dirty = true;
|
|
179
|
+
}
|
|
180
|
+
getReport(mode, spec) {
|
|
181
|
+
const entry = this.manifest.entries[specKey(mode, spec)];
|
|
182
|
+
if (!entry?.reportPath) return undefined;
|
|
183
|
+
try {
|
|
184
|
+
return JSON.parse(
|
|
185
|
+
gunzipSync(
|
|
186
|
+
readFileSync(path.join(this.cacheDir, entry.reportPath)),
|
|
187
|
+
).toString("utf8"),
|
|
188
|
+
);
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Drop entries whose spec is no longer produced by the current input glob
|
|
194
|
+
// (deleted/renamed specs), along with their stored report files. liveKeys are
|
|
195
|
+
// specKey() strings for every (mode, spec) in this run.
|
|
196
|
+
prune(liveKeys) {
|
|
197
|
+
for (const [key, entry] of Object.entries(this.manifest.entries)) {
|
|
198
|
+
if (liveKeys.has(key)) continue;
|
|
199
|
+
this.removeReportFile(entry.reportPath ?? null);
|
|
200
|
+
delete this.manifest.entries[key];
|
|
201
|
+
this.dirty = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
keyFor(mode, spec) {
|
|
205
|
+
return specKey(mode, spec);
|
|
206
|
+
}
|
|
207
|
+
save() {
|
|
208
|
+
if (!this.dirty) return;
|
|
209
|
+
mkdirSync(this.cacheDir, { recursive: true });
|
|
210
|
+
const manifestPath = path.join(this.cacheDir, MANIFEST_FILE);
|
|
211
|
+
const tmp = `${manifestPath}.tmp`;
|
|
212
|
+
writeFileSync(tmp, gzipSync(Buffer.from(JSON.stringify(this.manifest))));
|
|
213
|
+
renameSync(tmp, manifestPath);
|
|
214
|
+
this.dirty = false;
|
|
215
|
+
}
|
|
216
|
+
fingerprint(absPath) {
|
|
217
|
+
try {
|
|
218
|
+
const st = statSync(absPath);
|
|
219
|
+
const sha = sha256OfFile(absPath);
|
|
220
|
+
if (sha === null) return null;
|
|
221
|
+
return { sha, mtimeMs: st.mtimeMs, size: st.size };
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Fast-path: trust mtime+size when both match (cheap stat, no read). Only when
|
|
227
|
+
// they differ do we hash — and if the hash still matches (e.g. git checkout
|
|
228
|
+
// rewrote mtime), refresh the stored stat so we don't re-hash next run.
|
|
229
|
+
depUnchanged(absPath, fp) {
|
|
230
|
+
let st;
|
|
231
|
+
try {
|
|
232
|
+
st = statSync(absPath);
|
|
233
|
+
} catch {
|
|
234
|
+
return false; // dep deleted
|
|
235
|
+
}
|
|
236
|
+
if (st.mtimeMs === fp.mtimeMs && st.size === fp.size) return true;
|
|
237
|
+
const sha = sha256OfFile(absPath);
|
|
238
|
+
if (sha === null) return false;
|
|
239
|
+
if (sha === fp.sha) {
|
|
240
|
+
fp.mtimeMs = st.mtimeMs;
|
|
241
|
+
fp.size = st.size;
|
|
242
|
+
this.dirty = true;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
removeReportFile(relPath) {
|
|
248
|
+
if (!relPath) return;
|
|
249
|
+
try {
|
|
250
|
+
rmSync(path.join(this.cacheDir, relPath), { force: true });
|
|
251
|
+
} catch {
|
|
252
|
+
// best effort
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function sha1(value) {
|
|
257
|
+
return createHash("sha1").update(value).digest("hex");
|
|
258
|
+
}
|
|
259
|
+
// True if any suite or test in a file report carries a "fail" verdict. We only
|
|
260
|
+
// replay passing reports — a failing spec is cheap to re-run and gives fresh
|
|
261
|
+
// failure output rather than a confusing replay.
|
|
262
|
+
export function reportHasFailure(report) {
|
|
263
|
+
const suites = report?.suites;
|
|
264
|
+
if (!Array.isArray(suites)) return false;
|
|
265
|
+
return suites.some(suiteHasFailure);
|
|
266
|
+
}
|
|
267
|
+
function suiteHasFailure(suite) {
|
|
268
|
+
const s = suite;
|
|
269
|
+
if (s?.verdict === "fail") return true;
|
|
270
|
+
if (Array.isArray(s?.tests) && s.tests.some((t) => t?.verdict === "fail")) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(s?.suites) && s.suites.some(suiteHasFailure)) return true;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
// Run-scoped cache context, set once by the test orchestrator. `replay` gates
|
|
277
|
+
// Tier 2 (off under snapshot-write flags, on for `cache: "full"`).
|
|
278
|
+
export const cacheStorage = new AsyncLocalStorage();
|
|
@@ -22,8 +22,34 @@ 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";
|
|
26
|
+
import { cacheStorage } from "../build-cache.js";
|
|
25
27
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
26
28
|
export const buildRecorderStorage = new AsyncLocalStorage();
|
|
29
|
+
// The canonical build signature: identical inputs (command, args sans -o, env)
|
|
30
|
+
// yield an identical string. Used by both the cache freshness check inside
|
|
31
|
+
// build() and getBuildReuseInfo() so the two never drift.
|
|
32
|
+
function computeBuildSignature(invocation, signatureEnv) {
|
|
33
|
+
return JSON.stringify({
|
|
34
|
+
command: invocation.command,
|
|
35
|
+
args: stripOutputArgs(invocation.args),
|
|
36
|
+
apiArgs: invocation.apiArgs ? stripOutputArgs(invocation.apiArgs) : [],
|
|
37
|
+
env: sortRecord(signatureEnv),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// The env that actually affects build output, for the cache signature — only
|
|
41
|
+
// as-test's *declared* env (config + buildOptions env + the AS_TEST_* flags),
|
|
42
|
+
// NOT the inherited process.env. The real build env passed to asc is a superset
|
|
43
|
+
// (it spreads process.env for PATH etc.), but hashing that would let volatile
|
|
44
|
+
// vars like FORCE_COLOR/TERM spuriously invalidate the cache every run.
|
|
45
|
+
function buildSignatureEnv(config, coverageEnabled, modeName) {
|
|
46
|
+
return {
|
|
47
|
+
...config.env,
|
|
48
|
+
...config.buildOptions.env,
|
|
49
|
+
AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
|
|
50
|
+
AS_TEST_MODE_NAME: modeName ?? "default",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
27
53
|
export class BuildFailureError extends Error {
|
|
28
54
|
constructor(args) {
|
|
29
55
|
super(args.message);
|
|
@@ -67,14 +93,9 @@ export async function build(
|
|
|
67
93
|
const pkgRunner = getPkgRunner();
|
|
68
94
|
const sourceInputPatterns =
|
|
69
95
|
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));
|
|
96
|
+
const { files: inputFiles, warnings: selectorWarnings } =
|
|
97
|
+
await resolveSpecFiles(sourceInputPatterns, selectors);
|
|
98
|
+
emitSelectorWarnings(selectorWarnings);
|
|
78
99
|
await assertNoArtifactCollisions(sourceInputPatterns);
|
|
79
100
|
warnOnUnknownModeReferences(inputFiles, loadedConfig.modes ?? {});
|
|
80
101
|
const coverageEnabled = resolveCoverageEnabled(
|
|
@@ -87,11 +108,15 @@ export async function build(
|
|
|
87
108
|
AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
|
|
88
109
|
AS_TEST_MODE_NAME: modeName ?? "default",
|
|
89
110
|
};
|
|
111
|
+
// The cache needs the in-process API build path (only it can record asc's
|
|
112
|
+
// file reads), so when a cache context is active fall through to the direct
|
|
113
|
+
// loop below rather than the child-process serial pool.
|
|
90
114
|
if (
|
|
91
115
|
!resolvedConfig &&
|
|
92
116
|
!process.env.AS_TEST_BUILD_API &&
|
|
93
117
|
!hasCustomBuildCommand(config) &&
|
|
94
|
-
!buildRecorderStorage.getStore()
|
|
118
|
+
!buildRecorderStorage.getStore() &&
|
|
119
|
+
!cacheStorage.getStore()
|
|
95
120
|
) {
|
|
96
121
|
const pool = getSerialBuildWorkerPool();
|
|
97
122
|
for (const file of inputFiles) {
|
|
@@ -118,12 +143,13 @@ export async function build(
|
|
|
118
143
|
}
|
|
119
144
|
return;
|
|
120
145
|
}
|
|
146
|
+
const cacheCtx = cacheStorage.getStore();
|
|
147
|
+
const canCache = !!cacheCtx && !hasCustomBuildCommand(config);
|
|
121
148
|
for (const file of inputFiles) {
|
|
122
149
|
const outFile = path.join(
|
|
123
150
|
config.outDir,
|
|
124
151
|
resolveArtifactPath(file, sourceInputPatterns),
|
|
125
152
|
);
|
|
126
|
-
mkdirSync(path.dirname(outFile), { recursive: true });
|
|
127
153
|
const invocation = getBuildCommand(
|
|
128
154
|
config,
|
|
129
155
|
pkgRunner,
|
|
@@ -132,8 +158,50 @@ export async function build(
|
|
|
132
158
|
modeName,
|
|
133
159
|
featureToggles,
|
|
134
160
|
);
|
|
161
|
+
const signature = canCache
|
|
162
|
+
? computeBuildSignature(
|
|
163
|
+
invocation,
|
|
164
|
+
buildSignatureEnv(config, coverageEnabled, modeName),
|
|
165
|
+
)
|
|
166
|
+
: "";
|
|
167
|
+
// Cache hit: spec + every recorded dependency unchanged and the build
|
|
168
|
+
// signature still matches, so the existing .wasm is current — skip asc.
|
|
169
|
+
if (
|
|
170
|
+
canCache &&
|
|
171
|
+
cacheCtx.cache.isBuildFresh(modeName, file, {
|
|
172
|
+
signature,
|
|
173
|
+
coverageEnabled,
|
|
174
|
+
})
|
|
175
|
+
) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(path.dirname(outFile), { recursive: true });
|
|
179
|
+
const reads = new Set();
|
|
135
180
|
try {
|
|
136
|
-
|
|
181
|
+
if (canCache) {
|
|
182
|
+
// Capture asc's file reads (the spec's transitive deps) so the next
|
|
183
|
+
// run can tell when any of them changed. Forward to any outer recorder
|
|
184
|
+
// (e.g. the watch loop's dependency-graph recorder) so it still sees
|
|
185
|
+
// reads while the cache is active.
|
|
186
|
+
const outer = buildRecorderStorage.getStore();
|
|
187
|
+
await buildRecorderStorage.run(
|
|
188
|
+
{
|
|
189
|
+
record: (mode, spec, abs) => {
|
|
190
|
+
reads.add(abs);
|
|
191
|
+
outer?.record(mode, spec, abs);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
() => buildFile(invocation, buildEnv),
|
|
195
|
+
);
|
|
196
|
+
cacheCtx.cache.recordBuild(modeName, file, {
|
|
197
|
+
signature,
|
|
198
|
+
outFile,
|
|
199
|
+
deps: reads,
|
|
200
|
+
coverageEnabled,
|
|
201
|
+
});
|
|
202
|
+
} else {
|
|
203
|
+
await buildFile(invocation, buildEnv);
|
|
204
|
+
}
|
|
137
205
|
} catch (error) {
|
|
138
206
|
const modeLabel = modeName ?? "default";
|
|
139
207
|
const stdout = getBuildStdout(error);
|
|
@@ -267,20 +335,13 @@ export async function getBuildReuseInfo(
|
|
|
267
335
|
config.coverage,
|
|
268
336
|
featureToggles.coverage,
|
|
269
337
|
);
|
|
270
|
-
const buildEnv = {
|
|
271
|
-
...mode.env,
|
|
272
|
-
...config.buildOptions.env,
|
|
273
|
-
AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
|
|
274
|
-
AS_TEST_MODE_NAME: modeName ?? "default",
|
|
275
|
-
};
|
|
276
338
|
return {
|
|
277
|
-
signature:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
env: sortRecord(buildEnv),
|
|
282
|
-
}),
|
|
339
|
+
signature: computeBuildSignature(
|
|
340
|
+
invocation,
|
|
341
|
+
buildSignatureEnv(config, coverageEnabled, modeName),
|
|
342
|
+
),
|
|
283
343
|
outFile,
|
|
344
|
+
coverageEnabled,
|
|
284
345
|
};
|
|
285
346
|
}
|
|
286
347
|
function hasCustomBuildCommand(config) {
|
|
@@ -540,61 +601,6 @@ async function assertNoArtifactCollisions(configured) {
|
|
|
540
601
|
seen.set(artifact, file);
|
|
541
602
|
}
|
|
542
603
|
}
|
|
543
|
-
function resolveInputPatterns(configured, selectors) {
|
|
544
|
-
const configuredInputs = Array.isArray(configured)
|
|
545
|
-
? configured
|
|
546
|
-
: [configured];
|
|
547
|
-
if (!selectors.length) return configuredInputs;
|
|
548
|
-
const patterns = new Set();
|
|
549
|
-
for (const selector of expandSelectors(selectors)) {
|
|
550
|
-
if (!selector) continue;
|
|
551
|
-
if (isBareSuiteSelector(selector)) {
|
|
552
|
-
const base = stripSuiteSuffix(selector);
|
|
553
|
-
for (const configuredInput of configuredInputs) {
|
|
554
|
-
patterns.add(
|
|
555
|
-
path.join(path.dirname(configuredInput), `${base}.spec.ts`),
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
patterns.add(selector);
|
|
561
|
-
}
|
|
562
|
-
return [...patterns];
|
|
563
|
-
}
|
|
564
|
-
function expandSelectors(selectors) {
|
|
565
|
-
const expanded = [];
|
|
566
|
-
for (const selector of selectors) {
|
|
567
|
-
if (!selector) continue;
|
|
568
|
-
if (!shouldSplitSelector(selector)) {
|
|
569
|
-
expanded.push(selector);
|
|
570
|
-
continue;
|
|
571
|
-
}
|
|
572
|
-
for (const token of selector.split(",")) {
|
|
573
|
-
const trimmed = token.trim();
|
|
574
|
-
if (!trimmed.length) continue;
|
|
575
|
-
expanded.push(trimmed);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
return expanded;
|
|
579
|
-
}
|
|
580
|
-
function shouldSplitSelector(selector) {
|
|
581
|
-
return (
|
|
582
|
-
selector.includes(",") &&
|
|
583
|
-
!selector.includes("/") &&
|
|
584
|
-
!selector.includes("\\") &&
|
|
585
|
-
!/[*?[\]{}]/.test(selector)
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
function isBareSuiteSelector(selector) {
|
|
589
|
-
return (
|
|
590
|
-
!selector.includes("/") &&
|
|
591
|
-
!selector.includes("\\") &&
|
|
592
|
-
!/[*?[\]{}]/.test(selector)
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
function stripSuiteSuffix(selector) {
|
|
596
|
-
return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
|
|
597
|
-
}
|
|
598
604
|
function ensureDeps(config) {
|
|
599
605
|
if (config.buildOptions.target == "wasi") {
|
|
600
606
|
if (!resolveWasiShim()) {
|
|
@@ -2,6 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { existsSync, rmSync } from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { applyMode, loadConfig } from "../util.js";
|
|
5
|
+
import { resolveCacheDir } from "../build-cache.js";
|
|
5
6
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
6
7
|
export async function clean(
|
|
7
8
|
configPath = DEFAULT_CONFIG_PATH,
|
|
@@ -11,6 +12,9 @@ export async function clean(
|
|
|
11
12
|
const loadedConfig = loadConfig(configPath, true);
|
|
12
13
|
const targets = new Map();
|
|
13
14
|
const ownership = buildOwnershipMap(loadedConfig);
|
|
15
|
+
// The incremental cache (.as-test/cache) is a single global dir, not
|
|
16
|
+
// per-mode, so it is always cleaned whenever `ast clean` runs.
|
|
17
|
+
collectRootTarget(targets, resolveCacheDir(loadedConfig.outDir), "cache");
|
|
14
18
|
if (fullClean) {
|
|
15
19
|
collectRootTarget(targets, loadedConfig.outDir, "build");
|
|
16
20
|
collectRootTarget(targets, loadedConfig.fuzz.crashDir, "crashes");
|