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 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: optional user-supplied serializer
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 with `toJSON(): string` → call it
19
- // * managed without `toJSON()` → "<TypeName>" placeholder
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
- // @ts-ignore: hand-written or transform-generated serializer
138
- if (isDefined(value.toJSON)) return value.toJSON();
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 inputFiles = (await glob(inputPatterns)).sort((a, b) =>
72
- a.localeCompare(b),
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
- const event = JSON.parse(payload.toString("utf8"));
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);
@@ -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 inputFiles = (await glob(inputPatterns)).sort((a, b) =>
691
- a.localeCompare(b),
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
- const runCommandForLog = formatInvocation(invocation);
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(data.toString("utf8"));
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.join("");
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(data.toString("utf8"));
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.join("");
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
- if (opcode == 0x8) {
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 (opcode == 0x1) {
360
+ if (effectiveOpcode == 0x1) {
341
361
  this.onControl(payload.toString("utf8"));
342
362
  continue;
343
363
  }
344
- if (opcode == 0x2 && this.currentJob) {
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;
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.handleFrame(type, payload);
76
+ this.dispatchFrame(type, payload);
53
77
  }
54
78
  }
55
- handleFrame(type, payload) {
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;
@@ -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 = mergeImports(withNodeIo({}), imports);
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
- if (opcode == 0x8) {
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 (opcode == 0x1) {
447
+ if (effectiveOpcode == 0x1) {
428
448
  onControl(payload.toString("utf8"));
429
449
  continue;
430
450
  }
431
- if (opcode == 0x2) {
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
- const mergedImports = mergeImports(withNodeIo({}), imports);
630
- if (!mergedImports.env || typeof mergedImports.env != "object") {
631
- mergedImports.env = {};
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
- entry.module == "env" &&
636
- entry.kind == "function" &&
637
- !(entry.name in mergedImports.env)
638
- ) {
639
- mergedImports.env[entry.name] = () => 0;
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 mergedImports;
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 = mergeImports(withNodeIo({}), imports);
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
- if (opcode == 0x8) {
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 (opcode == 0x1) {
519
+ if (effectiveOpcode == 0x1) {
496
520
  onControl(payload.toString("utf8"));
497
521
  continue;
498
522
  }
499
- if (opcode == 0x2) {
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
- const mergedImports = mergeImports(withNodeIo({}), imports);
707
- if (!mergedImports.env || typeof mergedImports.env != "object") {
708
- mergedImports.env = {};
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
- entry.module == "env" &&
713
- entry.kind == "function" &&
714
- !(entry.name in mergedImports.env)
715
- ) {
716
- mergedImports.env[entry.name] = () => 0;
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 mergedImports;
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.4.0",
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
- "test": "npm run test:as && npm run test:integration",
77
- "test:as": "node ./bin/index.js test --parallel --enable try-as",
78
- "test:integration": "npm run build:cli && npm run build:lib && node --test tests/*.test.mjs",
79
- "test:ci": "node ./bin/index.js test --parallel --tap --enable try-as --config ./as-test.ci.config.json",
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
- "build:lib": "tsc -p ./tsconfig.lib.json",
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
- "format": "prettier -w .",
98
- "release:check": "npm run build:cli && npm run build:lib && npm run build:transform && npm run test && npm run test:examples && npm pack --dry-run --cache /tmp/as-test-npm-cache",
99
- "prepublishOnly": "npm run build:cli && npm run build:lib && npm run build:transform && npm run test && npm run format",
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"
@@ -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 TOJSON_METHOD = "toJSON";
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, TOJSON_METHOD))
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 = `toJSON(): string { ${body} }`;
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);
@@ -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)) {
@@ -6,8 +6,11 @@ export class MockTransform extends Visitor {
6
6
  globalStatements = [];
7
7
  mocked = new Set();
8
8
  importMocked = new Set();
9
- visitCallExpression(node) {
10
- super.visitCallExpression(node);
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 newName = normalizeName(expressionName(oldValue));
40
- const newFn = Node.createFunctionDeclaration(Node.createIdentifierExpression(newName + "_mock", callback.range), callback.declaration.decorators, 0, callback.declaration.typeParameters, callback.declaration.signature, callback.declaration.body, callback.declaration.arrowKind, callback.range);
41
- const currentSource = this.srcCurrent;
42
- if (!currentSource)
43
- return;
44
- const stmts = currentSource.statements;
45
- let index = -1;
46
- for (let i = 0; i < stmts.length; i++) {
47
- const stmt = stmts[i];
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
- stmts.splice(index, 1, newFn);
56
- this.mocked.add(newFn.name.text);
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(".", "_")