Skip to main content

alef_e2e/codegen/
wasm.rs

1//! WebAssembly e2e test generator using vitest.
2//!
3//! Reuses the TypeScript test renderer for both HTTP and non-HTTP fixtures,
4//! configured with the `@kreuzberg/wasm` (or equivalent) package as the import
5//! path and `wasm` as the language key for skip/override resolution. Adds
6//! wasm-specific scaffolding: a `setup.ts` chdir to `test_documents/` so
7//! file_path fixtures resolve, and a `globalSetup.ts` that spawns the
8//! mock-server for HTTP fixtures. The wasm-pack `--target nodejs` CJS bundle
9//! initializes synchronously and does not require vite-plugin-wasm.
10
11use crate::config::E2eConfig;
12use crate::escape::sanitize_filename;
13
14use crate::fixture::{Fixture, FixtureGroup};
15use alef_core::backend::GeneratedFile;
16use alef_core::config::ResolvedCrateConfig;
17use alef_core::hash::{self, CommentStyle};
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23use super::E2eCodegen;
24
25/// WebAssembly e2e code generator.
26pub struct WasmCodegen;
27
28impl E2eCodegen for WasmCodegen {
29    fn generate(
30        &self,
31        groups: &[FixtureGroup],
32        e2e_config: &E2eConfig,
33        config: &ResolvedCrateConfig,
34        type_defs: &[alef_core::ir::TypeDef],
35        enums: &[alef_core::ir::EnumDef],
36    ) -> Result<Vec<GeneratedFile>> {
37        let lang = self.language_name();
38        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39        let tests_base = output_base.join("tests");
40
41        let mut files = Vec::new();
42
43        // Resolve call config with wasm-specific overrides.
44        let call = &e2e_config.call;
45        let overrides = call.overrides.get(lang);
46        let module_path = overrides
47            .and_then(|o| o.module.as_ref())
48            .cloned()
49            .unwrap_or_else(|| call.module.clone());
50        let function_name = overrides
51            .and_then(|o| o.function.as_ref())
52            .cloned()
53            .unwrap_or_else(|| snake_to_camel(&call.function));
54        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
55
56        // Resolve package config — defaults to a co-located pkg/ directory shipped
57        // by `wasm-pack build` next to the wasm crate.
58        // When `[crates.output] wasm` is set explicitly, derive the pkg path from
59        // that value so that renamed WASM crates resolve correctly without any
60        // hardcoded special cases.
61        let wasm_pkg = e2e_config.resolve_package("wasm");
62        // `pkg_path_is_explicit` distinguishes "user told us exactly where the
63        // npm-consumable package lives" from "fall back to the default
64        // wasm-pack output directory". The render below appends `/nodejs` only
65        // for the fallback case (`wasm_crate_path()` returns the crate's
66        // `pkg/` dir, whose npm-consumable subpackage is at `pkg/nodejs/`).
67        // For an explicit path the user is responsible for pointing at a
68        // directory that already has a valid `package.json`.
69        let (pkg_path, pkg_path_is_explicit) = match wasm_pkg.as_ref().and_then(|p| p.path.as_ref()) {
70            Some(p) => (p.clone(), true),
71            None => (config.wasm_crate_path(), false),
72        };
73        let pkg_name = wasm_pkg
74            .as_ref()
75            .and_then(|p| p.name.as_ref())
76            .cloned()
77            .unwrap_or_else(|| {
78                // Default: derive from WASM crate name (config.name + "-wasm")
79                // wasm-pack transforms the crate name to the package name by replacing
80                // dashes with the crate separator in Cargo (e.g., kreuzberg-wasm -> kreuzberg_wasm).
81                // However, the published npm package might use the module name, which is typically
82                // the crate name without "-wasm". Fall back to the module path.
83                module_path.clone()
84            });
85        let pkg_version = wasm_pkg
86            .as_ref()
87            .and_then(|p| p.version.as_ref())
88            .cloned()
89            .or_else(|| config.resolved_version())
90            .unwrap_or_else(|| "0.1.0".to_string());
91
92        // Determine which auxiliary scaffolding files we need based on the active
93        // fixture set. Doing this once up front lets us emit a self-contained vitest
94        // config that wires only the setup files we'll actually generate.
95        //
96        // WASM language filtering: when `[crates.wasm].languages` is set, auto-skip
97        // fixtures for languages not in that static-compiled list. This bridges the gap
98        // between the full language pack and WASM's 8-language static build.
99        let wasm_languages = config.wasm.as_ref().and_then(|w| {
100            if w.languages.is_empty() {
101                None
102            } else {
103                Some(w.languages.clone())
104            }
105        });
106
107        // Build active fixtures per group. For WASM, when the backend declares a static
108        // language set via `[crates.wasm].languages`, include fixtures for languages
109        // not in that set but mark them with auto-skip directives so they render as
110        // `it.skip()` tests instead of being omitted entirely.
111        let active_per_group: Vec<Vec<Fixture>> = groups
112            .iter()
113            .map(|group| {
114                let mut result = Vec::new();
115                for fixture in &group.fixtures {
116                    // Determine if this fixture should be included.
117                    // Start with the base should_include_fixture check.
118                    let mut base_include = super::should_include_fixture(fixture, lang, e2e_config);
119
120                    // When `[crates.wasm].languages` is set, force `base_include = false` for
121                    // any fixture whose `input.language` falls outside that static-compiled set.
122                    // The else-branch below will then attach an auto-skip directive so the test
123                    // renders as `it.skip(...)` rather than running against a missing grammar.
124                    // `should_include_fixture` does not inspect `input.language`, so without this
125                    // override fixtures like `{ input: { language: "abl" } }` (where "abl" is not
126                    // in the wasm bundle) would be emitted normally and fail at runtime.
127                    if base_include {
128                        if let Some(ref wasm_langs) = wasm_languages {
129                            // Look for the target grammar in either of the two shapes
130                            // alef fixtures use: top-level `input.language` (function-call
131                            // shape) or nested `input.config.language` (config-object shape
132                            // used by smoke fixtures and anything taking a typed config DTO).
133                            let fix_lang = fixture.input.get("language").and_then(|v| v.as_str()).or_else(|| {
134                                fixture
135                                    .input
136                                    .get("config")
137                                    .and_then(|c| c.get("language"))
138                                    .and_then(|v| v.as_str())
139                            });
140                            if let Some(fix_lang) = fix_lang {
141                                if !wasm_langs.iter().any(|l| l == fix_lang) {
142                                    base_include = false;
143                                }
144                            }
145                        }
146                    }
147
148                    // Check per-call skip_languages (fixture is completely unsupported)
149                    let cc = e2e_config.resolve_call_for_fixture(
150                        fixture.call.as_deref(),
151                        &fixture.id,
152                        &fixture.resolved_category(),
153                        &fixture.tags,
154                        &fixture.input,
155                    );
156                    if cc.skip_languages.iter().any(|l| l == lang) {
157                        // Per-call skip — drop entirely, never include
158                        continue;
159                    }
160
161                    if base_include {
162                        // Check node fetch compatibility
163                        if let Some(http) = &fixture.http {
164                            if http
165                                .request
166                                .headers
167                                .iter()
168                                .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
169                            {
170                                // Node fetch rejects mismatched Content-Length — skip fixture
171                                continue;
172                            }
173                            let m = http.request.method.to_ascii_uppercase();
174                            if m == "TRACE" || m == "CONNECT" {
175                                // Node fetch doesn't support these methods — skip fixture
176                                continue;
177                            }
178                        }
179
180                        // Include the fixture normally
181                        result.push(fixture.clone());
182                    } else {
183                        // Fixture failed should_include_fixture or language not in wasm set.
184                        // Omit entirely — do not emit as it.skip().
185                        continue;
186                    }
187                }
188                result
189            })
190            .collect();
191
192        let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
193        // The wasm globalSetup spawns the mock server. It must run for any fixture
194        // that interpolates `${process.env.MOCK_SERVER_URL}` into a base URL —
195        // i.e. anything with `mock_response` (liter-llm shape) or `http`
196        // (kreuzberg/kreuzcrawl shape), not just raw `is_http_test`. The
197        // comment block below this line states the same intent; the previous
198        // condition (`f.is_http_test()`) only detected the consumer-style
199        // `http: { ... }` shape and missed the entire liter-llm fixture set.
200        let has_http_fixtures = any_fixtures.clone().any(|f| f.needs_mock_server());
201        // file_path / bytes args are read off disk by the generated code at runtime;
202        // we add a setup.ts chdir to test_documents so relative paths resolve.
203        let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
204            let cc = e2e_config.resolve_call_for_fixture(
205                f.call.as_deref(),
206                &f.id,
207                &f.resolved_category(),
208                &f.tags,
209                &f.input,
210            );
211            cc.args
212                .iter()
213                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
214        });
215
216        // Generate package.json — adds vitest + rollup dev deps so that the test
217        // suite can import the wasm-pack nodejs CJS bundle by package name.
218        files.push(GeneratedFile {
219            path: output_base.join("package.json"),
220            content: render_package_json(
221                &pkg_name,
222                &pkg_path,
223                pkg_path_is_explicit,
224                &pkg_version,
225                e2e_config.dep_mode,
226                e2e_config.harness_extras.get("wasm"),
227            ),
228            generated_header: false,
229        });
230
231        // Generate vitest.config.ts — optional globalSetup (for HTTP fixtures and
232        // any function-call test that hits the mock server via MOCK_SERVER_URL)
233        // and setupFiles (for chdir). Function-call e2e tests construct request URLs via
234        // `${process.env.MOCK_SERVER_URL}/fixtures/<id>`, so the mock server must
235        // be running and the env var set even when no raw HTTP fixtures exist.
236        let needs_global_setup = has_http_fixtures;
237        files.push(GeneratedFile {
238            path: output_base.join("vitest.config.ts"),
239            content: render_vitest_config(needs_global_setup, has_file_fixtures),
240            generated_header: true,
241        });
242
243        // Generate globalSetup.ts when any fixture requires the mock server —
244        // either an HTTP fixture (the original consumer) or any function-call
245        // fixture that interpolates `${process.env.MOCK_SERVER_URL}` into a
246        // base URL. It spawns the rust mock-server binary.
247        if needs_global_setup {
248            files.push(GeneratedFile {
249                path: output_base.join("globalSetup.ts"),
250                content: render_global_setup(),
251                generated_header: true,
252            });
253        }
254
255        // Generate setup.ts when any active fixture takes a file_path / bytes arg.
256        // This chdir's to test_documents/ so relative fixture paths resolve.
257        if has_file_fixtures {
258            files.push(GeneratedFile {
259                path: output_base.join("setup.ts"),
260                content: render_file_setup(&e2e_config.test_documents_dir),
261                generated_header: true,
262            });
263        }
264
265        // Generate tsconfig.json — prevents Vite from walking up to a project-level
266        // tsconfig and pulling in unrelated compiler options.
267        files.push(GeneratedFile {
268            path: output_base.join("tsconfig.json"),
269            content: render_tsconfig(),
270            generated_header: false,
271        });
272
273        // Emit a local `pnpm-workspace.yaml` declaring `e2e/wasm/` as its own
274        // pnpm workspace root. Without this, `pnpm install` walks up to the
275        // repo-root `pnpm-workspace.yaml`, where polyglot repos commonly
276        // exclude `e2e/wasm` (it depends on a `wasm-pack build` artifact that
277        // is absent on fresh checkouts). The CLI flag `--ignore-workspace`
278        // would also work, but it forces every caller (Taskfile, CI step) to
279        // pass it; making `e2e/wasm/` self-rooted keeps the generated suite
280        // self-contained.
281        files.push(GeneratedFile {
282            path: output_base.join("pnpm-workspace.yaml"),
283            content: "packages:\n  - \".\"\n".to_string(),
284            generated_header: false,
285        });
286
287        // Resolve options_type from override (e.g. `WasmExtractionConfig`).
288        let options_type = overrides.and_then(|o| o.options_type.clone());
289
290        // Generate test files per category. We delegate the per-fixture rendering
291        // to the typescript codegen (`render_test_file`), which already handles
292        // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
293        // routes per-fixture override resolution and skip checks through the wasm
294        // language key. We then inject Node.js WASM initialization code to load
295        // the WASM binary from the pkg directory using fs.readFileSync.
296        let wasm_type_prefix = config.wasm_type_prefix();
297        for (group, active) in groups.iter().zip(active_per_group.iter()) {
298            if active.is_empty() {
299                continue;
300            }
301            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
302            // Convert Vec<Fixture> to Vec<&Fixture> for render_test_file
303            let active_refs: Vec<&Fixture> = active.iter().collect();
304            let content = super::typescript::render_test_file(
305                lang,
306                &group.category,
307                &active_refs,
308                &module_path,
309                &pkg_name,
310                &function_name,
311                &e2e_config.call.args,
312                options_type.as_deref(),
313                client_factory,
314                e2e_config,
315                type_defs,
316                enums,
317                &wasm_type_prefix,
318            );
319
320            // The local `pkg/` directory produced by `wasm-pack build --target nodejs`
321            // is already a Node-friendly self-initializing CJS module — `pkg/package.json`
322            // sets `"main"` to the JS entry, so test files can import the package by name
323            // (`from "<pkg_name>"`) with no subpath. The historical `dist-node` rewrite
324            // assumed a multi-distribution layout (`dist/`, `dist-node/`, `dist-web/`)
325            // that the alef-managed `wasm-pack build` does not produce; it is therefore
326            // intentionally absent here.
327            let _ = (&pkg_path, &config.name); // keep variables alive for future use
328
329            files.push(GeneratedFile {
330                path: tests_base.join(filename),
331                content,
332                generated_header: true,
333            });
334        }
335
336        Ok(files)
337    }
338
339    fn language_name(&self) -> &'static str {
340        "wasm"
341    }
342}
343
344fn snake_to_camel(s: &str) -> String {
345    let mut out = String::with_capacity(s.len());
346    let mut upper_next = false;
347    for ch in s.chars() {
348        if ch == '_' {
349            upper_next = true;
350        } else if upper_next {
351            out.push(ch.to_ascii_uppercase());
352            upper_next = false;
353        } else {
354            out.push(ch);
355        }
356    }
357    out
358}
359
360fn render_package_json(
361    pkg_name: &str,
362    pkg_path: &str,
363    pkg_path_is_explicit: bool,
364    pkg_version: &str,
365    dep_mode: crate::config::DependencyMode,
366    extras: Option<&alef_core::config::manifest_extras::ManifestExtras>,
367) -> String {
368    let dep_value = match dep_mode {
369        crate::config::DependencyMode::Registry => pkg_version.to_string(),
370        // Fallback path: `wasm-pack build --target nodejs --out-dir pkg/nodejs` writes
371        // the npm-consumable package (its own package.json with `main`/`types` etc.)
372        // to `pkg/nodejs/`, not to `pkg/` directly. The fallback `wasm_crate_path()`
373        // points at `pkg/`, so we descend into `nodejs/` to find a valid
374        // package.json. When the user has set `[e2e.packages.wasm].path` explicitly,
375        // we trust they have already pointed at a directory with a valid package.json
376        // (the crate root, the wasm-pack out-dir, or another distribution layout) and
377        // do not mutate it.
378        crate::config::DependencyMode::Local => {
379            if pkg_path_is_explicit {
380                format!("file:{pkg_path}")
381            } else {
382                format!("file:{pkg_path}/nodejs")
383            }
384        }
385    };
386    let rendered = crate::template_env::render(
387        "wasm/package.json.jinja",
388        minijinja::context! {
389            pkg_name => pkg_name,
390            dep_value => dep_value,
391            rollup => tv::npm::ROLLUP,
392            vitest => tv::npm::VITEST,
393        },
394    );
395    match extras {
396        Some(e) if !e.is_empty() => crate::codegen::typescript::config::inject_package_json_extras(&rendered, e),
397        _ => rendered,
398    }
399}
400
401fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
402    let header = hash::header(CommentStyle::DoubleSlash);
403    crate::template_env::render(
404        "wasm/vitest.config.ts.jinja",
405        minijinja::context! {
406            header => header,
407            with_global_setup => with_global_setup,
408            with_file_setup => with_file_setup,
409        },
410    )
411}
412
413fn render_file_setup(test_documents_dir: &str) -> String {
414    let header = hash::header(CommentStyle::DoubleSlash);
415    let mut out = header;
416    out.push_str("import { createRequire } from 'module';\n");
417    out.push_str("import { fileURLToPath } from 'url';\n");
418    out.push_str("import { dirname, join } from 'path';\n\n");
419    out.push_str("// Patch CommonJS `require('env')` and `require('wasi_snapshot_preview1')` to\n");
420    out.push_str("// return shim objects. wasm-pack `--target nodejs` emits bare `require()`\n");
421    out.push_str("// calls for these from getrandom/wasi transitives, but they are not real\n");
422    out.push_str("// Node modules — the WASM module imports them by name and the host is\n");
423    out.push_str("// expected to satisfy them. Patch Module._load BEFORE the wasm bundle is\n");
424    out.push_str("// imported by any test file.\n");
425    out.push_str("// Note: setupFiles run per-test-worker; vitest imports the test files\n");
426    out.push_str("// AFTER setupFiles complete, so this hook installs in time.\n");
427    out.push_str("{\n");
428    out.push_str("  const _require = createRequire(import.meta.url);\n");
429    out.push_str("  const Module = _require('module');\n");
430    out.push_str("  // env.system / env.mkstemp come from C-runtime calls embedded in some\n");
431    out.push_str("  // WASM-compiled deps (e.g. tesseract-wasm). Tests that don't exercise\n");
432    out.push_str("  // those paths only need the imports to be callable for module instantiation.\n");
433    out.push_str("  const env = {\n");
434    out.push_str("    system: (_cmd: number) => -1,\n");
435    out.push_str("    mkstemp: (_template: number) => -1,\n");
436    out.push_str("  };\n");
437    out.push_str("  // WASI shims. Critical: clock_time_get and random_get must produce realistic\n");
438    out.push_str("  // values — returning 0 for all clock calls causes WASM-side timing loops to\n");
439    out.push_str("  // spin forever (e.g. getrandom's spin-until-elapsed retry), and zero-filled\n");
440    out.push_str("  // random buffers can cause init loops in deps expecting non-zero entropy.\n");
441    out.push_str("  const _wasiMemoryView = (): DataView | null => {\n");
442    out.push_str("    // Imports are wired before the WASM is instantiated; the bundle stashes\n");
443    out.push_str("    // its instance on a runtime-known global once available. We try to grab\n");
444    out.push_str("    // it lazily so writes to wasm memory go to the right place.\n");
445    out.push_str("    const g = globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory };\n");
446    out.push_str("    return g.__alef_wasm_memory__ ? new DataView(g.__alef_wasm_memory__.buffer) : null;\n");
447    out.push_str("  };\n");
448    out.push_str("  const _cryptoFill = (buf: Uint8Array) => {\n");
449    out.push_str("    const c = globalThis.crypto;\n");
450    out.push_str("    if (c && typeof c.getRandomValues === 'function') c.getRandomValues(buf);\n");
451    out.push_str("    else for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256);\n");
452    out.push_str("  };\n");
453    out.push_str("  const wasi_snapshot_preview1 = {\n");
454    out.push_str("    proc_exit: () => {},\n");
455    out.push_str("    environ_get: () => 0,\n");
456    out.push_str("    environ_sizes_get: (countOut: number, _sizeOut: number) => {\n");
457    out.push_str("      const v = _wasiMemoryView();\n");
458    out.push_str("      if (v) v.setUint32(countOut, 0, true);\n");
459    out.push_str("      return 0;\n");
460    out.push_str("    },\n");
461    out.push_str("    // WASI fd_write must update `nwritten_ptr` with the total bytes consumed,\n");
462    out.push_str("    // otherwise libc-style callers (e.g. tesseract-compiled-to-wasm fputs)\n");
463    out.push_str("    // see 0 of N bytes written and retry forever, hanging the host.\n");
464    out.push_str("    fd_write: (_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number) => {\n");
465    out.push_str("      const v = _wasiMemoryView();\n");
466    out.push_str("      if (!v) return 0;\n");
467    out.push_str("      let total = 0;\n");
468    out.push_str("      for (let i = 0; i < iovsLen; i++) {\n");
469    out.push_str("        const off = iovsPtr + i * 8;\n");
470    out.push_str("        total += v.getUint32(off + 4, true);\n");
471    out.push_str("      }\n");
472    out.push_str("      v.setUint32(nwrittenPtr, total, true);\n");
473    out.push_str("      return 0;\n");
474    out.push_str("    },\n");
475    out.push_str("    // Mirror fd_write: callers retry on partial reads. Reporting 0 bytes\n");
476    out.push_str("    // read (EOF) is fine; just make sure `nread_ptr` is written.\n");
477    out.push_str("    fd_read: (_fd: number, _iovsPtr: number, _iovsLen: number, nreadPtr: number) => {\n");
478    out.push_str("      const v = _wasiMemoryView();\n");
479    out.push_str("      if (v) v.setUint32(nreadPtr, 0, true);\n");
480    out.push_str("      return 0;\n");
481    out.push_str("    },\n");
482    out.push_str("    fd_seek: () => 0,\n");
483    out.push_str("    fd_close: () => 0,\n");
484    out.push_str("    fd_prestat_get: () => 8, // EBADF — no preopens.\n");
485    out.push_str("    fd_prestat_dir_name: () => 0,\n");
486    out.push_str("    fd_fdstat_get: () => 0,\n");
487    out.push_str("    fd_fdstat_set_flags: () => 0,\n");
488    out.push_str("    path_open: () => 44, // ENOENT.\n");
489    out.push_str("    path_create_directory: () => 0,\n");
490    out.push_str("    path_remove_directory: () => 0,\n");
491    out.push_str("    path_unlink_file: () => 0,\n");
492    out.push_str("    path_filestat_get: () => 44, // ENOENT.\n");
493    out.push_str("    path_rename: () => 0,\n");
494    out.push_str("    clock_time_get: (_clockId: number, _precision: bigint, timeOut: number) => {\n");
495    out.push_str("      const ns = BigInt(Date.now()) * 1_000_000n + BigInt(performance.now() | 0) % 1_000_000n;\n");
496    out.push_str("      const v = _wasiMemoryView();\n");
497    out.push_str("      if (v) v.setBigUint64(timeOut, ns, true);\n");
498    out.push_str("      return 0;\n");
499    out.push_str("    },\n");
500    out.push_str("    clock_res_get: (_clockId: number, resOut: number) => {\n");
501    out.push_str("      const v = _wasiMemoryView();\n");
502    out.push_str("      if (v) v.setBigUint64(resOut, 1_000n, true);\n");
503    out.push_str("      return 0;\n");
504    out.push_str("    },\n");
505    out.push_str("    random_get: (bufPtr: number, bufLen: number) => {\n");
506    out.push_str("      const g = globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory };\n");
507    out.push_str("      if (!g.__alef_wasm_memory__) return 0;\n");
508    out.push_str("      _cryptoFill(new Uint8Array(g.__alef_wasm_memory__.buffer, bufPtr, bufLen));\n");
509    out.push_str("      return 0;\n");
510    out.push_str("    },\n");
511    out.push_str("    args_get: () => 0,\n");
512    out.push_str("    args_sizes_get: (countOut: number, _sizeOut: number) => {\n");
513    out.push_str("      const v = _wasiMemoryView();\n");
514    out.push_str("      if (v) v.setUint32(countOut, 0, true);\n");
515    out.push_str("      return 0;\n");
516    out.push_str("    },\n");
517    out.push_str("    poll_oneoff: () => 0,\n");
518    out.push_str("    sched_yield: () => 0,\n");
519    out.push_str("  };\n");
520    out.push_str("  const _origResolve = Module._resolveFilename;\n");
521    out.push_str("  Module._resolveFilename = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
522    out.push_str("    if (request === 'env' || request === 'wasi_snapshot_preview1') return request;\n");
523    out.push_str("    return _origResolve.call(this, request, parent, ...rest);\n");
524    out.push_str("  };\n");
525    out.push_str("  const _origLoad = Module._load;\n");
526    out.push_str("  Module._load = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
527    out.push_str("    if (request === 'env') return env;\n");
528    out.push_str("    if (request === 'wasi_snapshot_preview1') return wasi_snapshot_preview1;\n");
529    out.push_str("    return _origLoad.call(this, request, parent, ...rest);\n");
530    out.push_str("  };\n");
531    out.push_str("  // Capture the WASM linear memory at instantiation time so the WASI shims\n");
532    out.push_str("  // can read/write into it. Without this, every shim that needs memory\n");
533    out.push_str("  // (fd_write nwritten, clock_time_get, random_get, etc.) silently no-ops\n");
534    out.push_str("  // and the host-side C runtime hangs in a retry loop.\n");
535    out.push_str("  const _OrigInstance = WebAssembly.Instance;\n");
536    out.push_str("  const PatchedInstance = function(this: WebAssembly.Instance, mod: WebAssembly.Module, imports?: WebAssembly.Imports) {\n");
537    out.push_str("    const inst = new _OrigInstance(mod, imports);\n");
538    out.push_str("    const exportsMem = (inst.exports as Record<string, unknown>).memory;\n");
539    out.push_str("    if (exportsMem instanceof WebAssembly.Memory) {\n");
540    out.push_str("      (globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory }).__alef_wasm_memory__ = exportsMem;\n");
541    out.push_str("    }\n");
542    out.push_str("    return inst;\n");
543    out.push_str("  } as unknown as typeof WebAssembly.Instance;\n");
544    out.push_str("  PatchedInstance.prototype = _OrigInstance.prototype;\n");
545    out.push_str(
546        "  (WebAssembly as unknown as { Instance: typeof WebAssembly.Instance }).Instance = PatchedInstance;\n",
547    );
548    out.push_str("}\n\n");
549    out.push_str("// Change to the configured test-documents directory so that fixture file paths like\n");
550    out.push_str("// \"pdf/fake_memo.pdf\" resolve correctly when vitest runs from e2e/wasm/.\n");
551    out.push_str("// setup.ts lives in e2e/wasm/; the fixtures dir lives at the repository root,\n");
552    out.push_str("// two directories up: e2e/wasm/ -> e2e/ -> repo root.\n");
553    out.push_str("const __filename = fileURLToPath(import.meta.url);\n");
554    out.push_str("const __dirname = dirname(__filename);\n");
555    let _ = writeln!(
556        out,
557        "const testDocumentsDir = join(__dirname, '..', '..', '{test_documents_dir}');"
558    );
559    out.push_str("process.chdir(testDocumentsDir);\n");
560    out
561}
562
563fn render_global_setup() -> String {
564    let header = hash::header(CommentStyle::DoubleSlash);
565    crate::template_env::render(
566        "wasm/globalSetup.ts.jinja",
567        minijinja::context! {
568            header => header,
569        },
570    )
571}
572
573fn render_tsconfig() -> String {
574    crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
575}
576
577// The historical `inject_wasm_init` post-processor rewrote test imports to a
578// `<pkg>/dist-node` subpath. It was removed because the alef-managed
579// `wasm-pack build --target nodejs` artifact is a flat self-initializing CJS
580// module — its `package.json` already sets `"main"` to the JS entry, so the
581// emitted `import … from "<pkg>"` resolves directly.