as-test 1.4.0 → 1.5.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 +43 -0
- package/assembly/src/expectation.ts +2 -2
- package/assembly/src/stringify.ts +29 -4
- package/bin/commands/build-core.js +7 -3
- package/bin/commands/fuzz-core.js +22 -1
- package/bin/commands/run-core.js +76 -8
- package/bin/commands/web-session.js +50 -3
- package/bin/dependency-graph.js +0 -0
- package/bin/wipc.js +46 -7
- package/lib/build/index.js +44 -15
- package/lib/build/web-runner/worker.js +27 -0
- package/lib/src/index.ts +62 -15
- package/package.json +19 -17
- package/transform/lib/equals.js +3 -3
- package/transform/lib/index.js +10 -1
- package/transform/lib/mock.js +63 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 2026-05-28 - v1.5.0
|
|
4
|
+
|
|
5
|
+
### `mockFn` and `mockImport` now work anywhere
|
|
6
|
+
|
|
7
|
+
- feat: `mockFn(target, callback)` and `unmockFn(target)` can be called from **anywhere** in the source — including inside a `test()` / `it()` callback — not just module top-level. The MockTransform now reads the visitor's enclosing-statement `ref` so it locates and removes the directive wherever it lives, defers list mutations to the end of `visitSource` so traversal isn't disturbed, and `unshift`s the generated mock fn to module scope so every rewritten call site can reach it. Previously, a nested `mockFn` silently no-op'd because the transform's top-level-statement scan never found the directive — calls remained un-rewritten and the test passed only because trivial assertions passed too.
|
|
8
|
+
- feat: `mockImport(path, callback)` + `unmockImport(path)` now retains the real import as a fallback (call this "route 2"). When a `mockImport`'d path is **never unmocked**, the `@external` import is removed and the wrapper always dispatches through `__mock_import` — same as before. When a path **is** unmocked somewhere, the transform keeps the real import (renamed `__as_test_real_<name>`, de-exported), and the wrapper becomes `if (__mock_import.has(path)) call_indirect(...) else __as_test_real_<name>(...)`. So a call after `unmockImport` falls back to the host binding instead of trapping on a missing `__mock_import` entry.
|
|
9
|
+
- fix: the runtime import-stub now covers any module the wasm declares (not just `env`), so a retained `mockImport` target with no host implementation (e.g. an artificial test import) gets a `() => 0` no-op stub before `WebAssembly.instantiate`. Without this, the wasm `LinkError`s on instantiation. `env` is no longer skipped — asc raw bindings put their `abort`/`trace`/`seed` defaults on the inner object and user env imports on the prototype, so the stub fills missing `env.*` user imports without clobbering asc's defaults.
|
|
10
|
+
- fix: in raw bindings the helper reads `imports.<module>` while building its import object, so the stub had to be applied **before** `helper.instantiate(module, imports)` is called — adding it inside the `WebAssembly.instantiate` wrapper alone was too late and surfaced as `instantiate@.../<spec>.js:...` failures (this was the chromium-headful + watch + full-suite crash where one bad job poisoned the persistent session).
|
|
11
|
+
|
|
12
|
+
### Class serializer rename — `__AS_TEST_TO_JSON()`
|
|
13
|
+
|
|
14
|
+
- change: the transform-injected class serializer is now emitted under an internal name `__AS_TEST_TO_JSON(): string` instead of `toJSON(): string`. Users can't accidentally call the generated method, and a hand-written `toJSON()` no longer collides with the generated one.
|
|
15
|
+
- feat: at runtime, `stringify<T>` for a managed class calls a user `toJSON()` only when its return type is a string (probed via a generic-parameter helper so the dead branch is pruned at compile time); a non-string `toJSON` cleanly falls back to the generated `__AS_TEST_TO_JSON()` structural serializer instead of being a compile error. The transform now generates `__AS_TEST_TO_JSON` for every eligible class so the fallback always exists.
|
|
16
|
+
|
|
17
|
+
### Chromium fix — fragmented WebSocket frames
|
|
18
|
+
|
|
19
|
+
- fix: chromium fragments outbound WebSocket binary messages at ~128KB. The two web server frame parsers (standalone runner in `lib/src/index.ts` and managed `PersistentWebSessionHost` in `cli/commands/web-session.ts`) read the opcode but **never checked the FIN bit or handled continuation frames (`0x0`)** — so chromium's continuation frame was silently dropped, the wipc byte stream desynced, and frame-header bytes bled into report payloads as `Bad control character in JSON`. Both parsers now reassemble fragments (buffer `0x1`/`0x2` with FIN=0, append `0x0` until FIN=1). firefox and webkit don't fragment at that size, which is why they always worked.
|
|
20
|
+
- fix: the CLI reassembled chunked report payloads by `data.toString("utf8")` per-chunk then joining. The producer (`assembly/util/wipc.ts`) chunks raw UTF-8 bytes, so a multibyte character straddling a chunk boundary was getting mis-decoded into replacement chars (or worse) on any transport, not just chromium. Chunks are now buffered as `Buffer`s and decoded UTF-8 once after `Buffer.concat`.
|
|
21
|
+
|
|
22
|
+
### Web mode — repro command + browser test infrastructure
|
|
23
|
+
|
|
24
|
+
- fix: when a managed web run (`ast test --mode firefox/chromium/webkit`) fails, the printed repro now points at the **managed CLI command** (`node ./bin/index.js run <spec> --mode <browser>`) instead of `node .as-test/runners/default.web.js <wasm>`. The managed path uses `PersistentWebSessionHost`'s panel UI; the standalone runner uses `lib/src/web-runner/*`'s terminal UI — completely different stacks. So the old repro never actually reproduced the failing run, and showed a different UI to boot. Top-level negations in `runCommandForLog` are now mode-aware via `webSession`.
|
|
25
|
+
- change: removed `tests/fixtures/fake-browser.mjs`. The integration suite's web tests now use real headless chromium (auto-detected from PATH or the Playwright cache), and `AS_TEST_SKIP_WEB=1` cleanly skips them in CI. CI workflows (`.github/workflows/as-test.yml`, `release.yml`) already set `AS_TEST_SKIP_WEB=1`. Without this, the shim's flow hung indefinitely in some local environments, eating ~hours of integration time.
|
|
26
|
+
- feat: a small "browser-closes-early" test now ships a `process.exit(0)` shebanged executable as `--browser` to verify the framework reports a clean disconnect error rather than hanging.
|
|
27
|
+
|
|
28
|
+
### Per-mode spec exclusion
|
|
29
|
+
|
|
30
|
+
- feat: mode-level `input` overrides can use `!`-prefixed patterns to exclude specific specs from that mode. The orchestrator's per-mode `run()` call now checks the **mode-only additions** (negations in the mode's `input` array that aren't in the top-level `input`) against the selector and returns an empty result for excluded files. Top-level negations remain ignored when a file is explicitly selected, so `test __tmp_foo` still works for paths the top-level config would otherwise filter.
|
|
31
|
+
- chore: `mock.spec` is excluded from `wasmtime`, `wasmer`, and `wazero` modes in `as-test.config.json`. Those CLI-only WASI runtimes can't accept arbitrary imports at instantiation, so the retained `env.*` `mockImport` target has no JS host to satisfy it.
|
|
32
|
+
|
|
33
|
+
### Scripts and CI standardization
|
|
34
|
+
|
|
35
|
+
- feat: new `build` script aliases `build:cli && build:lib && build:transform`. `test:all`, `release:check`, and `prepublishOnly` now compose with it instead of repeating the three sub-builds. `test:all` runs `build && test:modes && test:integration && test:examples`.
|
|
36
|
+
- chore: `test:modes` drops the duplicate `--parallel` (the base `test` script already has it), and `test:ci` composes with `npm run test --` instead of hard-coding the bin path.
|
|
37
|
+
- chore: `.github/workflows/as-test.yml` and `release.yml` now share identical test-job setup (checkout@v4, `actions/setup-node@v4`, `oven-sh/setup-bun@v1`, install Wasmtime, lint, typecheck, build, `test:ci`, `test:integration`). `as-test.yml` keeps the Test Summary action for PR feedback; the manual `curl`/`tar` Node bootstrap and the inline Bun installer are gone. `examples.yml` uses the `build` alias.
|
|
38
|
+
|
|
39
|
+
## 2026-05-27 - v1.4.1
|
|
40
|
+
|
|
41
|
+
### Dependency hygiene
|
|
42
|
+
|
|
43
|
+
- fix: declare `minimatch` (`^10.2.5`) as a direct dependency. The watch-mode exclusion matcher (`matchesAnyExclusion` in `cli/index.ts`) imported `minimatch` but relied on it resolving transitively through `glob` — it would have broken if `glob` ever dropped or changed that dependency. The pinned range matches what `glob` already uses, so the same resolved copy is reused.
|
|
44
|
+
- chore: `cli/dependency-graph.ts` no longer embeds raw NUL bytes. The `(mode, spec)` dependency-graph key delimiter is now written as a `\u0000` escape rather than a literal NUL. Behavior is identical (NUL is still the delimiter — it can't appear in a mode name or filesystem path, so keys stay collision-proof), but the source and its compiled `bin/` output are now plain text instead of being flagged binary by `git`/`grep`.
|
|
45
|
+
|
|
3
46
|
## 2026-05-26 - v1.4.0
|
|
4
47
|
|
|
5
48
|
### Dependency-free value serialization (json-as removed)
|
|
@@ -20,8 +20,8 @@ function safeStringify<T>(value: T): string {
|
|
|
20
20
|
!isString<T>() &&
|
|
21
21
|
!isArray<T>()
|
|
22
22
|
) {
|
|
23
|
-
// @ts-expect-error:
|
|
24
|
-
if (!isDefined(value.toJSON)) {
|
|
23
|
+
// @ts-expect-error: warn only when nothing serializes it — no user `toJSON` and no transform-generated `__AS_TEST_TO_JSON` → renders as a `<TypeName>` placeholder.
|
|
24
|
+
if (!isDefined(value.toJSON) && !isDefined(value.__AS_TEST_TO_JSON)) {
|
|
25
25
|
sendWarning(
|
|
26
26
|
"Class " +
|
|
27
27
|
nameof<T>() +
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
// * Array / StaticArray → element-wise recursion
|
|
16
16
|
// * Set → array of values
|
|
17
17
|
// * Map → object with stringified keys
|
|
18
|
-
// * managed
|
|
19
|
-
// * managed
|
|
18
|
+
// * managed, user `toJSON()` returns string → call it
|
|
19
|
+
// * managed otherwise → `__AS_TEST_TO_JSON()`
|
|
20
|
+
// (transform-generated structural serializer; the fallback when a
|
|
21
|
+
// user `toJSON` returns a non-string, or when there's no `toJSON`)
|
|
22
|
+
// * managed with neither → "<TypeName>" placeholder
|
|
20
23
|
//
|
|
21
24
|
// The Date/ArrayBuffer/typed-array/StaticArray/Set/Map branches use
|
|
22
25
|
// `value instanceof X` guards. In a generic function AssemblyScript resolves
|
|
@@ -134,8 +137,16 @@ export function stringify<T>(value: T): string {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
if (isManaged<T>()) {
|
|
137
|
-
//
|
|
138
|
-
|
|
140
|
+
// A user-supplied `toJSON` wins, but only when it returns a string.
|
|
141
|
+
// `preferToJSONString` decides that on the return type and otherwise
|
|
142
|
+
// falls back to the transform-generated structural serializer.
|
|
143
|
+
// @ts-ignore: optional user-supplied serializer
|
|
144
|
+
if (isDefined(value.toJSON)) {
|
|
145
|
+
// @ts-ignore: optional user-supplied serializer
|
|
146
|
+
return preferToJSONString(value, value.toJSON());
|
|
147
|
+
}
|
|
148
|
+
// @ts-ignore: transform-generated structural serializer
|
|
149
|
+
if (isDefined(value.__AS_TEST_TO_JSON)) return value.__AS_TEST_TO_JSON();
|
|
139
150
|
return escape("<" + nameof<T>() + ">");
|
|
140
151
|
}
|
|
141
152
|
|
|
@@ -144,6 +155,20 @@ export function stringify<T>(value: T): string {
|
|
|
144
155
|
return escape("<" + nameof<T>() + ">");
|
|
145
156
|
}
|
|
146
157
|
|
|
158
|
+
// Given a managed value and the result of calling its `toJSON()`, return
|
|
159
|
+
// that result when it's a string, otherwise the transform-generated
|
|
160
|
+
// `__AS_TEST_TO_JSON` structural serializer (or a `<TypeName>` placeholder
|
|
161
|
+
// if neither applies). `R` is the `toJSON` return type, so the
|
|
162
|
+
// `result`-returning branch is pruned — and never type-checked — for any
|
|
163
|
+
// non-string return type. That's what lets a `toJSON` returning, say,
|
|
164
|
+
// `i32` fall back here instead of being a hard compile error.
|
|
165
|
+
function preferToJSONString<T, R>(value: T, result: R): string {
|
|
166
|
+
if (isString<R>()) return result as string;
|
|
167
|
+
// @ts-ignore: transform-generated structural serializer
|
|
168
|
+
if (isDefined(value.__AS_TEST_TO_JSON)) return value.__AS_TEST_TO_JSON();
|
|
169
|
+
return escape("<" + nameof<T>() + ">");
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
// JSON string escape per RFC 8259, with explicit handling for UTF-16
|
|
148
173
|
// surrogates (matches json-as's serializeString behaviour).
|
|
149
174
|
//
|
|
@@ -68,9 +68,13 @@ export async function build(
|
|
|
68
68
|
const sourceInputPatterns =
|
|
69
69
|
overrides.kind === "fuzz" ? config.fuzz.input : config.input;
|
|
70
70
|
const inputPatterns = resolveInputPatterns(sourceInputPatterns, selectors);
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
const includePatterns = inputPatterns.filter((p) => !p.startsWith("!"));
|
|
72
|
+
const ignorePatterns = inputPatterns
|
|
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));
|
|
74
78
|
await assertNoArtifactCollisions(sourceInputPatterns);
|
|
75
79
|
warnOnUnknownModeReferences(inputFiles, loadedConfig.modes ?? {});
|
|
76
80
|
const coverageEnabled = resolveCoverageEnabled(
|
|
@@ -13,6 +13,10 @@ import { persistCrashRecord } from "../crash-store.js";
|
|
|
13
13
|
const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
|
|
14
14
|
const MAGIC = Buffer.from("WIPC");
|
|
15
15
|
const HEADER_SIZE = 9;
|
|
16
|
+
// See cli/wipc.ts: the magic can occur by chance in passthrough output, so a
|
|
17
|
+
// declared length above this bound means the match is coincidental.
|
|
18
|
+
const MAX_FRAME_SIZE = 16 * 1024 * 1024;
|
|
19
|
+
const KNOWN_FRAME_TYPES = new Set([0x00, 0x01, 0x02, 0x03]);
|
|
16
20
|
const MAX_DEFAULT_SEED = 0x7fffffff;
|
|
17
21
|
export async function fuzz(
|
|
18
22
|
configPath = DEFAULT_CONFIG_PATH,
|
|
@@ -135,7 +139,14 @@ async function runFuzzTarget(
|
|
|
135
139
|
};
|
|
136
140
|
const captured = captureFrames((type, payload, respond) => {
|
|
137
141
|
if (type == 0x02) {
|
|
138
|
-
|
|
142
|
+
// A coincidental magic + CALL match whose payload is not JSON must not
|
|
143
|
+
// crash the run (it would be misreported as a fuzz crash); drop it.
|
|
144
|
+
let event;
|
|
145
|
+
try {
|
|
146
|
+
event = JSON.parse(payload.toString("utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
139
150
|
const kind = String(event.kind ?? "");
|
|
140
151
|
if (kind == "fuzz:config") {
|
|
141
152
|
const resolved = config;
|
|
@@ -419,6 +430,16 @@ function captureFrames(onFrame) {
|
|
|
419
430
|
if (buffer.length < HEADER_SIZE) return true;
|
|
420
431
|
const type = buffer.readUInt8(4);
|
|
421
432
|
const length = buffer.readUInt32LE(5);
|
|
433
|
+
// A coincidental magic match in passthrough output: an unknown type or
|
|
434
|
+
// an implausible length means these 4 bytes are data, not a frame.
|
|
435
|
+
// Emit them and resync past the magic so we don't stall or misparse.
|
|
436
|
+
if (!KNOWN_FRAME_TYPES.has(type) || length > MAX_FRAME_SIZE) {
|
|
437
|
+
const raw = buffer.subarray(0, MAGIC.length);
|
|
438
|
+
passthrough = Buffer.concat([passthrough, raw]);
|
|
439
|
+
originalWrite(raw);
|
|
440
|
+
buffer = buffer.subarray(MAGIC.length);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
422
443
|
const frameSize = HEADER_SIZE + length;
|
|
423
444
|
if (buffer.length < frameSize) return true;
|
|
424
445
|
const payload = buffer.subarray(HEADER_SIZE, frameSize);
|
package/bin/commands/run-core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
3
|
import { glob } from "glob";
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
4
5
|
import { Channel, MessageType } from "../wipc.js";
|
|
5
6
|
import {
|
|
6
7
|
applyMode,
|
|
@@ -686,10 +687,60 @@ export async function run(
|
|
|
686
687
|
const loadedConfig = loadConfig(resolvedConfigPath);
|
|
687
688
|
const mode = applyMode(loadedConfig, options.modeName);
|
|
688
689
|
const config = mode.config;
|
|
690
|
+
// Mode-level exclusion: when a mode override adds `!`-prefixed negations
|
|
691
|
+
// beyond the top-level config, honor them even when the orchestrator passes
|
|
692
|
+
// a single-file selector (`resolveInputPatterns` drops the configured input
|
|
693
|
+
// in that case). Top-level negations are intentionally not enforced here so
|
|
694
|
+
// explicit selectors can still override them (e.g. picking a __tmp_* spec).
|
|
695
|
+
if (options.modeName && selectors.length > 0) {
|
|
696
|
+
const topInput = Array.isArray(loadedConfig.input)
|
|
697
|
+
? loadedConfig.input
|
|
698
|
+
: [loadedConfig.input];
|
|
699
|
+
const modeInput = Array.isArray(config.input)
|
|
700
|
+
? config.input
|
|
701
|
+
: [config.input];
|
|
702
|
+
const topNegations = new Set(topInput.filter((p) => p.startsWith("!")));
|
|
703
|
+
const modeOnlyNegations = modeInput
|
|
704
|
+
.filter((p) => p.startsWith("!") && !topNegations.has(p))
|
|
705
|
+
.map((p) => p.slice(1));
|
|
706
|
+
if (modeOnlyNegations.length > 0) {
|
|
707
|
+
const cwd = process.cwd();
|
|
708
|
+
const allExcluded = selectors.every((sel) => {
|
|
709
|
+
const abs = path.isAbsolute(sel) ? sel : path.resolve(cwd, sel);
|
|
710
|
+
const rel = path.relative(cwd, abs).split(path.sep).join("/");
|
|
711
|
+
return modeOnlyNegations.some((pat) =>
|
|
712
|
+
minimatch(rel, pat, { dot: true, matchBase: true }),
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
if (allExcluded) {
|
|
716
|
+
return {
|
|
717
|
+
failed: false,
|
|
718
|
+
stats: collectRunStats([]),
|
|
719
|
+
buildTime: 0,
|
|
720
|
+
snapshotSummary: { matched: 0, created: 0, updated: 0, failed: 0 },
|
|
721
|
+
coverageSummary: {
|
|
722
|
+
enabled: false,
|
|
723
|
+
showPoints: false,
|
|
724
|
+
total: 0,
|
|
725
|
+
covered: 0,
|
|
726
|
+
uncovered: 0,
|
|
727
|
+
percent: 100,
|
|
728
|
+
files: [],
|
|
729
|
+
},
|
|
730
|
+
reports: [],
|
|
731
|
+
logSummary: { count: 0, file: null, groups: [], text: "" },
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
689
736
|
const inputPatterns = resolveInputPatterns(config.input, selectors);
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
737
|
+
const includePatterns = inputPatterns.filter((p) => !p.startsWith("!"));
|
|
738
|
+
const ignorePatterns = inputPatterns
|
|
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));
|
|
693
744
|
const snapshotEnabled = flags.snapshot !== false;
|
|
694
745
|
const createSnapshots = Boolean(flags.createSnapshots);
|
|
695
746
|
const overwriteSnapshots = Boolean(flags.overwriteSnapshots);
|
|
@@ -798,7 +849,20 @@ export async function run(
|
|
|
798
849
|
token.replace(/<name>/g, fileBase).replace(/<file>/g, fileToken),
|
|
799
850
|
),
|
|
800
851
|
};
|
|
801
|
-
|
|
852
|
+
// A managed web-session run never spawns the standalone runner, so its
|
|
853
|
+
// repro must re-run the managed CLI command (which renders the same panel
|
|
854
|
+
// UI) rather than `default.web.js <wasm>`, a different web stack.
|
|
855
|
+
const runCommandForLog = webSession
|
|
856
|
+
? formatInvocation({
|
|
857
|
+
command: execPath,
|
|
858
|
+
args: [
|
|
859
|
+
process.argv[1] ?? "",
|
|
860
|
+
"run",
|
|
861
|
+
resolveSpecRelativePath(file, config.input),
|
|
862
|
+
...(options.modeName ? ["--mode", options.modeName] : []),
|
|
863
|
+
],
|
|
864
|
+
})
|
|
865
|
+
: formatInvocation(invocation);
|
|
802
866
|
const snapshotStore = new SnapshotStore(
|
|
803
867
|
file,
|
|
804
868
|
config.snapshotDir,
|
|
@@ -1991,7 +2055,7 @@ async function runProcess(
|
|
|
1991
2055
|
if (reportStream.sawChunkStart && !reportStream.sawChunkEnd) {
|
|
1992
2056
|
reportStream.chunkFramesReceived++;
|
|
1993
2057
|
reportStream.chunkBytesReceived += data.length;
|
|
1994
|
-
reportStream.chunks.push(
|
|
2058
|
+
reportStream.chunks.push(Buffer.from(data));
|
|
1995
2059
|
return;
|
|
1996
2060
|
}
|
|
1997
2061
|
try {
|
|
@@ -2041,7 +2105,9 @@ async function runProcess(
|
|
|
2041
2105
|
parseError =
|
|
2042
2106
|
parseError ?? "missing report:end marker for chunked report payload";
|
|
2043
2107
|
} else {
|
|
2044
|
-
const chunkedPayload = reportStream.chunks.
|
|
2108
|
+
const chunkedPayload = Buffer.concat(reportStream.chunks).toString(
|
|
2109
|
+
"utf8",
|
|
2110
|
+
);
|
|
2045
2111
|
try {
|
|
2046
2112
|
report = JSON.parse(chunkedPayload);
|
|
2047
2113
|
parseError = null;
|
|
@@ -2375,7 +2441,7 @@ async function runWebSessionProcess(
|
|
|
2375
2441
|
if (reportStream.sawChunkStart && !reportStream.sawChunkEnd) {
|
|
2376
2442
|
reportStream.chunkFramesReceived++;
|
|
2377
2443
|
reportStream.chunkBytesReceived += data.length;
|
|
2378
|
-
reportStream.chunks.push(
|
|
2444
|
+
reportStream.chunks.push(Buffer.from(data));
|
|
2379
2445
|
return;
|
|
2380
2446
|
}
|
|
2381
2447
|
try {
|
|
@@ -2422,7 +2488,9 @@ async function runWebSessionProcess(
|
|
|
2422
2488
|
parseError =
|
|
2423
2489
|
parseError ?? "missing report:end marker for chunked report payload";
|
|
2424
2490
|
} else {
|
|
2425
|
-
const chunkedPayload = reportStream.chunks.
|
|
2491
|
+
const chunkedPayload = Buffer.concat(reportStream.chunks).toString(
|
|
2492
|
+
"utf8",
|
|
2493
|
+
);
|
|
2426
2494
|
try {
|
|
2427
2495
|
report = JSON.parse(chunkedPayload);
|
|
2428
2496
|
parseError = null;
|
|
@@ -16,6 +16,8 @@ export class PersistentWebSessionHost {
|
|
|
16
16
|
this.serverSockets = new Set();
|
|
17
17
|
this.wsSocket = null;
|
|
18
18
|
this.wsBuffer = Buffer.alloc(0);
|
|
19
|
+
this.wsFragmentOpcode = 0;
|
|
20
|
+
this.wsFragments = [];
|
|
19
21
|
this.ready = false;
|
|
20
22
|
this.closed = false;
|
|
21
23
|
this.currentJob = null;
|
|
@@ -334,14 +336,32 @@ export class PersistentWebSessionHost {
|
|
|
334
336
|
payload = Buffer.from(payload);
|
|
335
337
|
}
|
|
336
338
|
this.wsBuffer = this.wsBuffer.subarray(offset + maskLength + length);
|
|
337
|
-
|
|
339
|
+
// Reassemble fragmented messages: a non-final data frame (0x1/0x2 with
|
|
340
|
+
// FIN=0) starts a sequence continued by 0x0 frames until FIN=1. Chromium
|
|
341
|
+
// fragments large (64KB) frames, so without this the wipc stream desyncs
|
|
342
|
+
// and the report payload is corrupted.
|
|
343
|
+
const fin = (first & 0x80) !== 0;
|
|
344
|
+
let effectiveOpcode = opcode;
|
|
345
|
+
if (opcode == 0x0) {
|
|
346
|
+
this.wsFragments.push(payload);
|
|
347
|
+
if (!fin) continue;
|
|
348
|
+
payload = Buffer.concat(this.wsFragments);
|
|
349
|
+
this.wsFragments = [];
|
|
350
|
+
effectiveOpcode = this.wsFragmentOpcode;
|
|
351
|
+
this.wsFragmentOpcode = 0;
|
|
352
|
+
} else if ((opcode == 0x1 || opcode == 0x2) && !fin) {
|
|
353
|
+
this.wsFragmentOpcode = opcode;
|
|
354
|
+
this.wsFragments = [payload];
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (effectiveOpcode == 0x8) {
|
|
338
358
|
return;
|
|
339
359
|
}
|
|
340
|
-
if (
|
|
360
|
+
if (effectiveOpcode == 0x1) {
|
|
341
361
|
this.onControl(payload.toString("utf8"));
|
|
342
362
|
continue;
|
|
343
363
|
}
|
|
344
|
-
if (
|
|
364
|
+
if (effectiveOpcode == 0x2 && this.currentJob) {
|
|
345
365
|
this.currentJob.onBinary(Buffer.from(payload));
|
|
346
366
|
}
|
|
347
367
|
}
|
|
@@ -1012,6 +1032,10 @@ async function instantiate(imports) {
|
|
|
1012
1032
|
if (typeof helper.instantiate != "function") {
|
|
1013
1033
|
throw new Error("bindings helper missing instantiate export");
|
|
1014
1034
|
}
|
|
1035
|
+
// Stub before the helper runs: it reads imports.<module> while building its
|
|
1036
|
+
// import object, so a missing import must be filled here, not just in the
|
|
1037
|
+
// WebAssembly.instantiate wrapper (which runs too late for raw bindings).
|
|
1038
|
+
stubMissingImports(module, imports, ["wasi_snapshot_preview1"]);
|
|
1015
1039
|
const instance = await captureInstantiateInstance(async () => {
|
|
1016
1040
|
await helper.instantiate(module, imports);
|
|
1017
1041
|
});
|
|
@@ -1026,11 +1050,31 @@ async function instantiate(imports) {
|
|
|
1026
1050
|
}
|
|
1027
1051
|
const binary = await fetchWasmBinary(wasmUrl);
|
|
1028
1052
|
const module = new WebAssembly.Module(binary);
|
|
1053
|
+
stubMissingImports(module, imports, ["wasi_snapshot_preview1"]);
|
|
1029
1054
|
const result = await WebAssembly.instantiate(module, imports);
|
|
1030
1055
|
const wasmInstance = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
1031
1056
|
return decorateInstance(wasmInstance);
|
|
1032
1057
|
}
|
|
1033
1058
|
|
|
1059
|
+
// Fills function imports the module declares but nothing provided with a no-op
|
|
1060
|
+
// stub, so instantiation never fails on a missing import (e.g. an unmocked
|
|
1061
|
+
// mockImport target with no host implementation). Mirrors the node runner.
|
|
1062
|
+
function stubMissingImports(module, importObject, skipModules) {
|
|
1063
|
+
const skip = new Set(skipModules || []);
|
|
1064
|
+
for (const entry of WebAssembly.Module.imports(module)) {
|
|
1065
|
+
if (entry.kind !== "function" || skip.has(entry.module)) continue;
|
|
1066
|
+
let mod = importObject[entry.module];
|
|
1067
|
+
if (!mod || typeof mod !== "object") {
|
|
1068
|
+
mod = {};
|
|
1069
|
+
importObject[entry.module] = mod;
|
|
1070
|
+
}
|
|
1071
|
+
if (!(entry.name in mod)) {
|
|
1072
|
+
mod[entry.name] = () => 0;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return importObject;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1034
1078
|
async function fetchWasmBinary(wasmUrl) {
|
|
1035
1079
|
const response = await fetch(wasmUrl);
|
|
1036
1080
|
if (!response.ok) throw new Error("failed to fetch wasm artifact: " + response.status);
|
|
@@ -1041,6 +1085,9 @@ async function captureInstantiateInstance(run) {
|
|
|
1041
1085
|
const originalInstantiate = WebAssembly.instantiate.bind(WebAssembly);
|
|
1042
1086
|
let captured = null;
|
|
1043
1087
|
WebAssembly.instantiate = async (source, importObject) => {
|
|
1088
|
+
if (source instanceof WebAssembly.Module && importObject) {
|
|
1089
|
+
stubMissingImports(source, importObject, ["wasi_snapshot_preview1"]);
|
|
1090
|
+
}
|
|
1044
1091
|
const result = await originalInstantiate(source, importObject);
|
|
1045
1092
|
captured = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
1046
1093
|
return result;
|
package/bin/dependency-graph.js
CHANGED
|
Binary file
|
package/bin/wipc.js
CHANGED
|
@@ -45,14 +45,52 @@ export class Channel {
|
|
|
45
45
|
if (this.buffer.length < Channel.HEADER_SIZE) return;
|
|
46
46
|
const type = this.buffer.readUInt8(4);
|
|
47
47
|
const length = this.buffer.readUInt32LE(5);
|
|
48
|
+
// The magic can occur by chance inside ordinary stdout output (e.g. a
|
|
49
|
+
// test printing binary data or the literal string "WIPC"). A genuine
|
|
50
|
+
// frame always carries a known type and a bounded length; if either
|
|
51
|
+
// check fails the match is coincidental, so surface the magic bytes as
|
|
52
|
+
// passthrough and resume scanning past them rather than crash or stall.
|
|
53
|
+
if (!Channel.isKnownType(type) || length > Channel.MAX_FRAME_SIZE) {
|
|
54
|
+
this.resyncPastMagic();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
48
57
|
const frameSize = Channel.HEADER_SIZE + length;
|
|
49
58
|
if (this.buffer.length < frameSize) return;
|
|
50
59
|
const payload = this.buffer.subarray(Channel.HEADER_SIZE, frameSize);
|
|
60
|
+
if (type === MessageType.CALL) {
|
|
61
|
+
// CALL payloads are always JSON. If the bytes do not parse the magic
|
|
62
|
+
// was coincidental — do NOT consume the frame; treat the magic as
|
|
63
|
+
// output and resync so the rest flushes as passthrough.
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(payload.toString("utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
this.resyncPastMagic();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this.buffer = this.buffer.subarray(frameSize);
|
|
72
|
+
this.onCall(parsed);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
51
75
|
this.buffer = this.buffer.subarray(frameSize);
|
|
52
|
-
this.
|
|
76
|
+
this.dispatchFrame(type, payload);
|
|
53
77
|
}
|
|
54
78
|
}
|
|
55
|
-
|
|
79
|
+
static isKnownType(type) {
|
|
80
|
+
return (
|
|
81
|
+
type === MessageType.OPEN ||
|
|
82
|
+
type === MessageType.CLOSE ||
|
|
83
|
+
type === MessageType.CALL ||
|
|
84
|
+
type === MessageType.DATA
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
// A coincidental magic match: emit the magic bytes as ordinary output and
|
|
88
|
+
// advance past them so scanning can continue from the next byte.
|
|
89
|
+
resyncPastMagic() {
|
|
90
|
+
this.onPassthrough(this.buffer.subarray(0, Channel.MAGIC.length));
|
|
91
|
+
this.buffer = this.buffer.subarray(Channel.MAGIC.length);
|
|
92
|
+
}
|
|
93
|
+
dispatchFrame(type, payload) {
|
|
56
94
|
switch (type) {
|
|
57
95
|
case MessageType.OPEN:
|
|
58
96
|
this.onOpen();
|
|
@@ -60,14 +98,9 @@ export class Channel {
|
|
|
60
98
|
case MessageType.CLOSE:
|
|
61
99
|
this.onClose();
|
|
62
100
|
break;
|
|
63
|
-
case MessageType.CALL:
|
|
64
|
-
this.onCall(JSON.parse(payload.toString("utf8")));
|
|
65
|
-
break;
|
|
66
101
|
case MessageType.DATA:
|
|
67
102
|
this.onDataMessage(payload);
|
|
68
103
|
break;
|
|
69
|
-
default:
|
|
70
|
-
throw new Error(`Unknown frame type: ${type}`);
|
|
71
104
|
}
|
|
72
105
|
}
|
|
73
106
|
onPassthrough(_data) {}
|
|
@@ -79,3 +112,9 @@ export class Channel {
|
|
|
79
112
|
Channel.MAGIC = Buffer.from("WIPC");
|
|
80
113
|
Channel.HEADER_SIZE = 9;
|
|
81
114
|
Channel.MAGIC_PREFIX_MAX = Channel.MAGIC.length - 1;
|
|
115
|
+
// Upper bound on a single frame's declared payload length. Real frames are
|
|
116
|
+
// small JSON events or report chunks (<= 64 KiB); anything larger means the
|
|
117
|
+
// 4 magic bytes appeared by coincidence inside passthrough output. Without
|
|
118
|
+
// this bound a forged length would make us buffer (and swallow real frames)
|
|
119
|
+
// indefinitely. The margin over 64 KiB future-proofs larger report chunks.
|
|
120
|
+
Channel.MAX_FRAME_SIZE = 16 * 1024 * 1024;
|
package/lib/build/index.js
CHANGED
|
@@ -200,7 +200,11 @@ async function instantiateRawInstance(wasmPath, helperPath, imports) {
|
|
|
200
200
|
if (typeof helper.instantiate != "function") {
|
|
201
201
|
throw new Error("bindings helper missing instantiate export");
|
|
202
202
|
}
|
|
203
|
-
const mergedImports =
|
|
203
|
+
const mergedImports = stubMissingImports(
|
|
204
|
+
module,
|
|
205
|
+
mergeImports(withNodeIo({}), imports),
|
|
206
|
+
["wasi_snapshot_preview1"],
|
|
207
|
+
);
|
|
204
208
|
const instance = await captureHelperInstance(async () => {
|
|
205
209
|
await helper.instantiate(module, mergedImports);
|
|
206
210
|
});
|
|
@@ -279,6 +283,8 @@ async function instantiateWebInstance(wasmPath, imports) {
|
|
|
279
283
|
let ready = false;
|
|
280
284
|
let wsSocket = null;
|
|
281
285
|
let wsBuffer = Buffer.alloc(0);
|
|
286
|
+
let wsFragmentOpcode = 0;
|
|
287
|
+
let wsFragments = [];
|
|
282
288
|
let stdinBuffer = Buffer.alloc(0);
|
|
283
289
|
let browserProcess = null;
|
|
284
290
|
let browserStderr = "";
|
|
@@ -420,15 +426,29 @@ async function instantiateWebInstance(wasmPath, imports) {
|
|
|
420
426
|
payload = Buffer.from(payload);
|
|
421
427
|
}
|
|
422
428
|
wsBuffer = wsBuffer.subarray(offset + maskLength + length);
|
|
423
|
-
|
|
429
|
+
const fin = (first & 0x80) !== 0;
|
|
430
|
+
let effectiveOpcode = opcode;
|
|
431
|
+
if (opcode == 0x0) {
|
|
432
|
+
wsFragments.push(payload);
|
|
433
|
+
if (!fin) continue;
|
|
434
|
+
payload = Buffer.concat(wsFragments);
|
|
435
|
+
wsFragments = [];
|
|
436
|
+
effectiveOpcode = wsFragmentOpcode;
|
|
437
|
+
wsFragmentOpcode = 0;
|
|
438
|
+
} else if ((opcode == 0x1 || opcode == 0x2) && !fin) {
|
|
439
|
+
wsFragmentOpcode = opcode;
|
|
440
|
+
wsFragments = [payload];
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (effectiveOpcode == 0x8) {
|
|
424
444
|
finish(0);
|
|
425
445
|
return;
|
|
426
446
|
}
|
|
427
|
-
if (
|
|
447
|
+
if (effectiveOpcode == 0x1) {
|
|
428
448
|
onControl(payload.toString("utf8"));
|
|
429
449
|
continue;
|
|
430
450
|
}
|
|
431
|
-
if (
|
|
451
|
+
if (effectiveOpcode == 0x2) {
|
|
432
452
|
process.stdout.write(payload);
|
|
433
453
|
}
|
|
434
454
|
}
|
|
@@ -626,20 +646,23 @@ function hasUserImports(imports) {
|
|
|
626
646
|
return Object.keys(imports).length > 0;
|
|
627
647
|
}
|
|
628
648
|
function createWasmImports(module, imports) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
649
|
+
return stubMissingImports(module, mergeImports(withNodeIo({}), imports));
|
|
650
|
+
}
|
|
651
|
+
function stubMissingImports(module, imports, skipModules = []) {
|
|
652
|
+
const skip = new Set(skipModules);
|
|
653
|
+
const bag = imports;
|
|
633
654
|
for (const entry of WebAssembly.Module.imports(module)) {
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
655
|
+
if (entry.kind != "function" || skip.has(entry.module)) continue;
|
|
656
|
+
let mod = bag[entry.module];
|
|
657
|
+
if (!mod || typeof mod != "object") {
|
|
658
|
+
mod = {};
|
|
659
|
+
bag[entry.module] = mod;
|
|
660
|
+
}
|
|
661
|
+
if (!(entry.name in mod)) {
|
|
662
|
+
mod[entry.name] = () => 0;
|
|
640
663
|
}
|
|
641
664
|
}
|
|
642
|
-
return
|
|
665
|
+
return imports;
|
|
643
666
|
}
|
|
644
667
|
let patchedWasiWarning = false;
|
|
645
668
|
function suppressExperimentalWasiWarning() {
|
|
@@ -1153,6 +1176,12 @@ async function captureHelperInstance(runHelper) {
|
|
|
1153
1176
|
const originalInstantiate = WebAssembly.instantiate.bind(WebAssembly);
|
|
1154
1177
|
let instance = null;
|
|
1155
1178
|
WebAssembly.instantiate = async (source, importObject) => {
|
|
1179
|
+
if (source instanceof WebAssembly.Module && importObject) {
|
|
1180
|
+
stubMissingImports(source, importObject, [
|
|
1181
|
+
"env",
|
|
1182
|
+
"wasi_snapshot_preview1",
|
|
1183
|
+
]);
|
|
1184
|
+
}
|
|
1156
1185
|
const result = await originalInstantiate(source, importObject);
|
|
1157
1186
|
if (result instanceof WebAssembly.Instance) {
|
|
1158
1187
|
instance = result;
|
|
@@ -109,6 +109,10 @@ async function instantiate(imports) {
|
|
|
109
109
|
if (typeof helper.instantiate != "function") {
|
|
110
110
|
throw new Error("bindings helper missing instantiate export");
|
|
111
111
|
}
|
|
112
|
+
// Stub before the helper runs: it reads imports.<module> while building its
|
|
113
|
+
// import object, so a missing import must be filled here, not just in the
|
|
114
|
+
// WebAssembly.instantiate wrapper (which runs too late for raw bindings).
|
|
115
|
+
stubMissingImports(module, imports, ["wasi_snapshot_preview1"]);
|
|
112
116
|
const instance = await captureInstantiateInstance(async () => {
|
|
113
117
|
await helper.instantiate(module, imports);
|
|
114
118
|
});
|
|
@@ -125,11 +129,31 @@ async function instantiate(imports) {
|
|
|
125
129
|
}
|
|
126
130
|
const binary = await fetchWasmBinary(wasmUrl);
|
|
127
131
|
const module = new WebAssembly.Module(binary);
|
|
132
|
+
stubMissingImports(module, imports, ["wasi_snapshot_preview1"]);
|
|
128
133
|
const result = await WebAssembly.instantiate(module, imports);
|
|
129
134
|
const instance = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
130
135
|
return decorateInstance(instance);
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
// Fills function imports the module declares but nothing provided with a no-op
|
|
139
|
+
// stub, so instantiation never fails on a missing import (e.g. an unmocked
|
|
140
|
+
// mockImport target with no host implementation). Mirrors the node runner.
|
|
141
|
+
function stubMissingImports(module, importObject, skipModules) {
|
|
142
|
+
const skip = new Set(skipModules || []);
|
|
143
|
+
for (const entry of WebAssembly.Module.imports(module)) {
|
|
144
|
+
if (entry.kind !== "function" || skip.has(entry.module)) continue;
|
|
145
|
+
let mod = importObject[entry.module];
|
|
146
|
+
if (!mod || typeof mod !== "object") {
|
|
147
|
+
mod = {};
|
|
148
|
+
importObject[entry.module] = mod;
|
|
149
|
+
}
|
|
150
|
+
if (!(entry.name in mod)) {
|
|
151
|
+
mod[entry.name] = () => 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return importObject;
|
|
155
|
+
}
|
|
156
|
+
|
|
133
157
|
async function fetchWasmBinary(wasmUrl) {
|
|
134
158
|
const response = await fetch(wasmUrl);
|
|
135
159
|
if (!response.ok) {
|
|
@@ -142,6 +166,9 @@ async function captureInstantiateInstance(run) {
|
|
|
142
166
|
const originalInstantiate = WebAssembly.instantiate.bind(WebAssembly);
|
|
143
167
|
let captured = null;
|
|
144
168
|
WebAssembly.instantiate = async (source, importObject) => {
|
|
169
|
+
if (source instanceof WebAssembly.Module && importObject) {
|
|
170
|
+
stubMissingImports(source, importObject, ["wasi_snapshot_preview1"]);
|
|
171
|
+
}
|
|
145
172
|
const result = await originalInstantiate(source, importObject);
|
|
146
173
|
if (result instanceof WebAssembly.Instance) {
|
|
147
174
|
captured = result;
|
package/lib/src/index.ts
CHANGED
|
@@ -247,7 +247,11 @@ async function instantiateRawInstance(
|
|
|
247
247
|
if (typeof helper.instantiate != "function") {
|
|
248
248
|
throw new Error("bindings helper missing instantiate export");
|
|
249
249
|
}
|
|
250
|
-
const mergedImports =
|
|
250
|
+
const mergedImports = stubMissingImports(
|
|
251
|
+
module,
|
|
252
|
+
mergeImports(withNodeIo({}), imports),
|
|
253
|
+
["wasi_snapshot_preview1"],
|
|
254
|
+
);
|
|
251
255
|
const instance = await captureHelperInstance(async () => {
|
|
252
256
|
await helper.instantiate!(module, mergedImports);
|
|
253
257
|
});
|
|
@@ -347,6 +351,8 @@ async function instantiateWebInstance(
|
|
|
347
351
|
let ready = false;
|
|
348
352
|
let wsSocket: Duplex | null = null;
|
|
349
353
|
let wsBuffer = Buffer.alloc(0);
|
|
354
|
+
let wsFragmentOpcode = 0;
|
|
355
|
+
let wsFragments: Buffer[] = [];
|
|
350
356
|
let stdinBuffer = Buffer.alloc(0);
|
|
351
357
|
let browserProcess: ChildProcess | null = null;
|
|
352
358
|
let browserStderr = "";
|
|
@@ -488,15 +494,33 @@ async function instantiateWebInstance(
|
|
|
488
494
|
payload = Buffer.from(payload);
|
|
489
495
|
}
|
|
490
496
|
wsBuffer = wsBuffer.subarray(offset + maskLength + length);
|
|
491
|
-
|
|
497
|
+
// Reassemble fragmented messages: a non-final data frame (0x1/0x2 with
|
|
498
|
+
// FIN=0) starts a sequence continued by 0x0 frames until FIN=1. Chromium
|
|
499
|
+
// fragments large (64KB) frames, so without this the wipc stream
|
|
500
|
+
// desyncs and the report payload is corrupted.
|
|
501
|
+
const fin = (first & 0x80) !== 0;
|
|
502
|
+
let effectiveOpcode = opcode;
|
|
503
|
+
if (opcode == 0x0) {
|
|
504
|
+
wsFragments.push(payload);
|
|
505
|
+
if (!fin) continue;
|
|
506
|
+
payload = Buffer.concat(wsFragments);
|
|
507
|
+
wsFragments = [];
|
|
508
|
+
effectiveOpcode = wsFragmentOpcode;
|
|
509
|
+
wsFragmentOpcode = 0;
|
|
510
|
+
} else if ((opcode == 0x1 || opcode == 0x2) && !fin) {
|
|
511
|
+
wsFragmentOpcode = opcode;
|
|
512
|
+
wsFragments = [payload];
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (effectiveOpcode == 0x8) {
|
|
492
516
|
finish(0);
|
|
493
517
|
return;
|
|
494
518
|
}
|
|
495
|
-
if (
|
|
519
|
+
if (effectiveOpcode == 0x1) {
|
|
496
520
|
onControl(payload.toString("utf8"));
|
|
497
521
|
continue;
|
|
498
522
|
}
|
|
499
|
-
if (
|
|
523
|
+
if (effectiveOpcode == 0x2) {
|
|
500
524
|
process.stdout.write(payload);
|
|
501
525
|
}
|
|
502
526
|
}
|
|
@@ -703,20 +727,33 @@ function createWasmImports(
|
|
|
703
727
|
module: WebAssembly.Module,
|
|
704
728
|
imports: AnyImports,
|
|
705
729
|
): AnyImports {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
730
|
+
return stubMissingImports(module, mergeImports(withNodeIo({}), imports));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Fills any function import the module declares but the caller didn't provide
|
|
734
|
+
// with a no-op stub, so instantiation never fails on a missing import. This
|
|
735
|
+
// covers imports an unmocked `mockImport` target reintroduces (which keep
|
|
736
|
+
// their real binding but have no host implementation in tests). `skipModules`
|
|
737
|
+
// leaves bindings-helper-owned modules (env / wasi) for the helper to fill.
|
|
738
|
+
function stubMissingImports(
|
|
739
|
+
module: WebAssembly.Module,
|
|
740
|
+
imports: AnyImports,
|
|
741
|
+
skipModules: readonly string[] = [],
|
|
742
|
+
): AnyImports {
|
|
743
|
+
const skip = new Set(skipModules);
|
|
744
|
+
const bag = imports as Record<string, Record<string, unknown>>;
|
|
710
745
|
for (const entry of WebAssembly.Module.imports(module)) {
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
746
|
+
if (entry.kind != "function" || skip.has(entry.module)) continue;
|
|
747
|
+
let mod = bag[entry.module];
|
|
748
|
+
if (!mod || typeof mod != "object") {
|
|
749
|
+
mod = {};
|
|
750
|
+
bag[entry.module] = mod;
|
|
751
|
+
}
|
|
752
|
+
if (!(entry.name in mod)) {
|
|
753
|
+
mod[entry.name] = () => 0;
|
|
717
754
|
}
|
|
718
755
|
}
|
|
719
|
-
return
|
|
756
|
+
return imports;
|
|
720
757
|
}
|
|
721
758
|
|
|
722
759
|
let patchedWasiWarning = false;
|
|
@@ -1306,6 +1343,16 @@ async function captureHelperInstance(
|
|
|
1306
1343
|
source: BufferSource | WebAssembly.Module,
|
|
1307
1344
|
importObject?: WebAssembly.Imports,
|
|
1308
1345
|
) => {
|
|
1346
|
+
// Stub any import the bindings helper left unprovided (e.g. an unmocked
|
|
1347
|
+
// mockImport target with no host implementation), so esm/raw bindings
|
|
1348
|
+
// instantiate instead of failing with a LinkError. env/wasi stay with the
|
|
1349
|
+
// helper.
|
|
1350
|
+
if (source instanceof WebAssembly.Module && importObject) {
|
|
1351
|
+
stubMissingImports(source, importObject as AnyImports, [
|
|
1352
|
+
"env",
|
|
1353
|
+
"wasi_snapshot_preview1",
|
|
1354
|
+
]);
|
|
1355
|
+
}
|
|
1309
1356
|
const result = await originalInstantiate(source, importObject);
|
|
1310
1357
|
if (result instanceof WebAssembly.Instance) {
|
|
1311
1358
|
instance = result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "as-test",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"author": "Jairus Tanaka",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"chalk": "^5.6.2",
|
|
11
11
|
"glob": "^13.0.6",
|
|
12
|
+
"minimatch": "^10.2.5",
|
|
12
13
|
"typer-diff": "^1.1.1",
|
|
13
14
|
"wipc-js": "^0.1.1"
|
|
14
15
|
},
|
|
@@ -73,30 +74,31 @@
|
|
|
73
74
|
"access": "public"
|
|
74
75
|
},
|
|
75
76
|
"scripts": {
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
77
|
+
"build": "npm run build:cli && npm run build:lib && npm run build:transform",
|
|
78
|
+
"build:cli": "rm -rf ./bin/ && tsc -p cli && prettier -w ./cli/ && chmod +x ./bin/index.js",
|
|
79
|
+
"build:lib": "tsc -p ./tsconfig.lib.json",
|
|
80
|
+
"build:transform": "rm -rf ./transform/lib && tsc -p ./transform && prettier -w ./transform/",
|
|
81
|
+
"test": "node ./bin/index.js test --parallel",
|
|
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",
|
|
84
|
+
"test:examples": "npm --prefix ./examples run test",
|
|
85
|
+
"test:all": "npm run build && npm run test:modes && npm run test:integration && npm run test:examples",
|
|
86
|
+
"test:ci": "npm run test -- --enable try-as --config ./as-test.ci.config.json",
|
|
80
87
|
"fuzz": "node ./bin/index.js fuzz",
|
|
81
88
|
"bench:seed": "node ./bin/index.js fuzz --config ./as-test.bench.config.json --clean",
|
|
82
89
|
"bench:seed:compare": "bash ./tools/bench-seed-compare.sh 7",
|
|
83
|
-
"test:examples": "npm --prefix ./examples run test",
|
|
84
|
-
"ci:act": "bash ./tools/act.sh push",
|
|
85
|
-
"ci:act:pr": "bash ./tools/act.sh pull_request",
|
|
86
|
-
"ci:act:tests": "act push -W .github/workflows/as-test.yml",
|
|
87
|
-
"ci:act:examples": "act push -W .github/workflows/examples.yml",
|
|
88
90
|
"typecheck": "tsc -p cli --noEmit && tsc -p tsconfig.lib.json --noEmit && tsc -p transform --noEmit",
|
|
89
91
|
"lint": "eslint transform/src/**/*.ts tools/**/*.js eslint.config.js",
|
|
90
|
-
"
|
|
91
|
-
"build:transform": "rm -rf ./transform/lib && tsc -p ./transform && prettier -w ./transform/",
|
|
92
|
-
"build:cli": "rm -rf ./bin/ && tsc -p cli && prettier -w ./cli/ && chmod +x ./bin/index.js",
|
|
93
|
-
"build:run": "npm run build:cli",
|
|
92
|
+
"format": "prettier -w .",
|
|
94
93
|
"docs:dev": "vitepress dev docs",
|
|
95
94
|
"docs:build": "vitepress build docs",
|
|
96
95
|
"docs:preview": "vitepress preview docs",
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
96
|
+
"release:check": "npm run build && npm run test && npm run test:examples && npm pack --dry-run --cache /tmp/as-test-npm-cache",
|
|
97
|
+
"prepublishOnly": "npm run build && npm run test && npm run format",
|
|
98
|
+
"ci:act": "bash ./tools/act.sh push",
|
|
99
|
+
"ci:act:pr": "bash ./tools/act.sh pull_request",
|
|
100
|
+
"ci:act:tests": "act push -W .github/workflows/as-test.yml",
|
|
101
|
+
"ci:act:examples": "act push -W .github/workflows/examples.yml",
|
|
100
102
|
"commitmsg:verify": "bash ./scripts/commit-msg.sh",
|
|
101
103
|
"precommit:verify": "bash ./scripts/pre-commit.sh",
|
|
102
104
|
"prepare": "husky"
|
package/transform/lib/equals.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { SimpleParser, isStdlib } from "./util.js";
|
|
6
6
|
const EQUALS_METHOD = "__AS_TEST_EQUALS";
|
|
7
|
-
const
|
|
7
|
+
const GENERATED_TOJSON_METHOD = "__AS_TEST_TO_JSON";
|
|
8
8
|
const REFLECT_LOCAL = "__AS_TEST_REFLECT_EQUALS_INTERNAL";
|
|
9
9
|
const STRINGIFY_LOCAL = "__AS_TEST_STRINGIFY_INTERNAL";
|
|
10
10
|
const ALREADY_INJECTED_EQUALS = new WeakSet();
|
|
@@ -165,7 +165,7 @@ export class EqualsTransform {
|
|
|
165
165
|
injectToJSONMethod(klass) {
|
|
166
166
|
if (ALREADY_INJECTED_TOJSON.has(klass))
|
|
167
167
|
return false;
|
|
168
|
-
if (declaresMethod(klass,
|
|
168
|
+
if (declaresMethod(klass, GENERATED_TOJSON_METHOD))
|
|
169
169
|
return false;
|
|
170
170
|
if (hasAnyDecorator(klass, JSON_DECORATORS))
|
|
171
171
|
return false;
|
|
@@ -189,7 +189,7 @@ export class EqualsTransform {
|
|
|
189
189
|
const body = parts.length
|
|
190
190
|
? `return "{" + ${parts.join(" + ")} + "}";`
|
|
191
191
|
: `return "{}";`;
|
|
192
|
-
const code =
|
|
192
|
+
const code = `${GENERATED_TOJSON_METHOD}(): string { ${body} }`;
|
|
193
193
|
try {
|
|
194
194
|
const method = SimpleParser.parseClassMember(code, klass);
|
|
195
195
|
klass.members.push(method);
|
package/transform/lib/index.js
CHANGED
|
@@ -37,6 +37,10 @@ export default class Transformer extends Transform {
|
|
|
37
37
|
for (const target of mockedImportTargets) {
|
|
38
38
|
mock.importMocked.add(target);
|
|
39
39
|
}
|
|
40
|
+
const unmockedImportTargets = collectUnmockImportTargets(sources);
|
|
41
|
+
for (const target of unmockedImportTargets) {
|
|
42
|
+
mock.importUnmocked.add(target);
|
|
43
|
+
}
|
|
40
44
|
for (const source of sources) {
|
|
41
45
|
const sourceInfo = analyzeSourceText(source.text);
|
|
42
46
|
const shouldInjectRunCall = source.sourceKind == 1 &&
|
|
@@ -110,8 +114,13 @@ function patchModeName(parser, modeName) {
|
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
116
|
function collectMockImportTargets(sources) {
|
|
117
|
+
return collectImportTargets(sources, /\bmockImport\s*\(\s*["']([^"']+)["']/g);
|
|
118
|
+
}
|
|
119
|
+
function collectUnmockImportTargets(sources) {
|
|
120
|
+
return collectImportTargets(sources, /\bunmockImport\s*\(\s*["']([^"']+)["']/g);
|
|
121
|
+
}
|
|
122
|
+
function collectImportTargets(sources, pattern) {
|
|
113
123
|
const out = new Set();
|
|
114
|
-
const pattern = /\bmockImport\s*\(\s*["']([^"']+)["']/g;
|
|
115
124
|
for (const source of sources) {
|
|
116
125
|
const text = stripComments(source.text);
|
|
117
126
|
for (const match of text.matchAll(pattern)) {
|
package/transform/lib/mock.js
CHANGED
|
@@ -6,8 +6,11 @@ export class MockTransform extends Visitor {
|
|
|
6
6
|
globalStatements = [];
|
|
7
7
|
mocked = new Set();
|
|
8
8
|
importMocked = new Set();
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
importUnmocked = new Set();
|
|
10
|
+
pendingHoist = [];
|
|
11
|
+
pendingRemoval = [];
|
|
12
|
+
visitCallExpression(node, ref = null) {
|
|
13
|
+
super.visitCallExpression(node, ref);
|
|
11
14
|
const name = normalizeName(expressionName(node.expression));
|
|
12
15
|
if (this.mocked.has(name + "_mock")) {
|
|
13
16
|
node.expression = Node.createIdentifierExpression(name + "_mock", node.expression.range);
|
|
@@ -25,6 +28,7 @@ export class MockTransform extends Visitor {
|
|
|
25
28
|
if (!oldFn)
|
|
26
29
|
return;
|
|
27
30
|
this.mocked.delete(normalizeName(expressionName(oldFn)) + "_mock");
|
|
31
|
+
this.scheduleRemoval(node, ref);
|
|
28
32
|
return;
|
|
29
33
|
}
|
|
30
34
|
if (name == "unmockImport") {
|
|
@@ -34,26 +38,21 @@ export class MockTransform extends Visitor {
|
|
|
34
38
|
return;
|
|
35
39
|
const oldValue = node.args[0];
|
|
36
40
|
const callback = node.args[1];
|
|
37
|
-
if (!oldValue || !callback)
|
|
41
|
+
if (!oldValue || !callback || !callback.declaration)
|
|
38
42
|
return;
|
|
39
|
-
const
|
|
40
|
-
const newFn = Node.createFunctionDeclaration(Node.createIdentifierExpression(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (stmt.range.start != node.range.start)
|
|
49
|
-
continue;
|
|
50
|
-
index = i;
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
if (index === -1)
|
|
43
|
+
const mockName = normalizeName(expressionName(oldValue)) + "_mock";
|
|
44
|
+
const newFn = Node.createFunctionDeclaration(Node.createIdentifierExpression(mockName, callback.range), callback.declaration.decorators, 0, callback.declaration.typeParameters, callback.declaration.signature, callback.declaration.body, callback.declaration.arrowKind, callback.range);
|
|
45
|
+
this.pendingHoist.push(newFn);
|
|
46
|
+
this.mocked.add(mockName);
|
|
47
|
+
this.scheduleRemoval(node, ref);
|
|
48
|
+
}
|
|
49
|
+
scheduleRemoval(node, ref) {
|
|
50
|
+
const container = asStatementContainer(ref);
|
|
51
|
+
if (!container)
|
|
54
52
|
return;
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const stmt = container.statements.find((s) => s.expression === node);
|
|
54
|
+
if (stmt)
|
|
55
|
+
this.pendingRemoval.push({ container, stmt });
|
|
57
56
|
}
|
|
58
57
|
visitFunctionDeclaration(node, isDefault) {
|
|
59
58
|
if (this.mocked.has(node.name.text))
|
|
@@ -62,11 +61,23 @@ export class MockTransform extends Visitor {
|
|
|
62
61
|
}
|
|
63
62
|
visitSource(node) {
|
|
64
63
|
this.mocked = new Set();
|
|
64
|
+
this.pendingHoist = [];
|
|
65
|
+
this.pendingRemoval = [];
|
|
65
66
|
this.srcCurrent = node;
|
|
66
67
|
super.visitSource(node);
|
|
67
68
|
const currentSource = this.srcCurrent;
|
|
68
69
|
if (!currentSource)
|
|
69
70
|
return;
|
|
71
|
+
for (const { container, stmt } of this.pendingRemoval) {
|
|
72
|
+
const i = container.statements.indexOf(stmt);
|
|
73
|
+
if (i !== -1)
|
|
74
|
+
container.statements.splice(i, 1);
|
|
75
|
+
}
|
|
76
|
+
this.pendingRemoval = [];
|
|
77
|
+
if (this.pendingHoist.length) {
|
|
78
|
+
currentSource.statements.unshift(...this.pendingHoist);
|
|
79
|
+
this.pendingHoist = [];
|
|
80
|
+
}
|
|
70
81
|
const stmts = currentSource.statements;
|
|
71
82
|
for (let index = 0; index < stmts.length; index++) {
|
|
72
83
|
const node = stmts[index];
|
|
@@ -98,6 +109,17 @@ export class MockTransform extends Visitor {
|
|
|
98
109
|
index++;
|
|
99
110
|
continue;
|
|
100
111
|
}
|
|
112
|
+
if (this.importUnmocked.has(path) && node.signature.returnType) {
|
|
113
|
+
const realName = "__as_test_real_" + node.name.text;
|
|
114
|
+
const r = node.range;
|
|
115
|
+
const wrapper = buildFallbackWrapper(node, path, realName, r);
|
|
116
|
+
const mutable = node;
|
|
117
|
+
mutable.name = Node.createIdentifierExpression(realName, r);
|
|
118
|
+
mutable.flags &= ~2;
|
|
119
|
+
stmts.splice(index + 1, 0, wrapper);
|
|
120
|
+
index++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
101
123
|
const args = [
|
|
102
124
|
Node.createCallExpression(Node.createPropertyAccessExpression(Node.createIdentifierExpression("__mock_import", node.range), Node.createIdentifierExpression("get", node.range), node.range), null, [Node.createStringLiteralExpression(path, node.range)], node.range),
|
|
103
125
|
];
|
|
@@ -120,6 +142,27 @@ function isBodylessTopLevelFunction(node) {
|
|
|
120
142
|
"signature" in candidate &&
|
|
121
143
|
candidate.body == null);
|
|
122
144
|
}
|
|
145
|
+
function buildFallbackWrapper(node, path, realName, range) {
|
|
146
|
+
const mockImportGet = Node.createCallExpression(Node.createPropertyAccessExpression(Node.createIdentifierExpression("__mock_import", range), Node.createIdentifierExpression("get", range), range), null, [Node.createStringLiteralExpression(path, range)], range);
|
|
147
|
+
const indirectArgs = [mockImportGet];
|
|
148
|
+
const forwardArgs = [];
|
|
149
|
+
for (const param of node.signature.parameters) {
|
|
150
|
+
indirectArgs.push(Node.createIdentifierExpression(param.name.text, range));
|
|
151
|
+
forwardArgs.push(Node.createIdentifierExpression(param.name.text, range));
|
|
152
|
+
}
|
|
153
|
+
const ifMocked = Node.createIfStatement(Node.createCallExpression(Node.createPropertyAccessExpression(Node.createIdentifierExpression("__mock_import", range), Node.createIdentifierExpression("has", range), range), null, [Node.createStringLiteralExpression(path, range)], range), Node.createReturnStatement(Node.createCallExpression(Node.createIdentifierExpression("call_indirect", range), null, indirectArgs, range), range), null, range);
|
|
154
|
+
const callReal = Node.createReturnStatement(Node.createCallExpression(Node.createIdentifierExpression(realName, range), null, forwardArgs, range), range);
|
|
155
|
+
const signature = Node.createFunctionType(node.signature.parameters, node.signature.returnType, node.signature.explicitThisType, node.signature.isNullable, range);
|
|
156
|
+
const exported = (node.flags & 2) != 0;
|
|
157
|
+
return Node.createFunctionDeclaration(Node.createIdentifierExpression(node.name.text, range), null, exported ? 2 : 0, node.typeParameters, signature, Node.createBlockStatement([ifMocked, callReal], range), 0, range);
|
|
158
|
+
}
|
|
159
|
+
function asStatementContainer(ref) {
|
|
160
|
+
const candidate = ref;
|
|
161
|
+
if (candidate && Array.isArray(candidate.statements)) {
|
|
162
|
+
return candidate;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
123
166
|
function normalizeName(value) {
|
|
124
167
|
return value
|
|
125
168
|
.replaceAll(".", "_")
|