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: vite-plugin-wasm + top-level-await for vitest,
7//! a `setup.ts` chdir to `test_documents/` so file_path fixtures resolve, and
8//! a `globalSetup.ts` that spawns the mock-server for HTTP fixtures.
9
10use crate::config::E2eConfig;
11use crate::escape::sanitize_filename;
12use crate::field_access::FieldResolver;
13use crate::fixture::{Fixture, FixtureGroup};
14use alef_core::backend::GeneratedFile;
15use alef_core::config::ResolvedCrateConfig;
16use alef_core::hash::{self, CommentStyle};
17use alef_core::template_versions as tv;
18use anyhow::Result;
19use std::path::PathBuf;
20
21use super::E2eCodegen;
22
23/// WebAssembly e2e code generator.
24pub struct WasmCodegen;
25
26impl E2eCodegen for WasmCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35        let tests_base = output_base.join("tests");
36
37        let mut files = Vec::new();
38
39        // Resolve call config with wasm-specific overrides.
40        let call = &e2e_config.call;
41        let overrides = call.overrides.get(lang);
42        let module_path = overrides
43            .and_then(|o| o.module.as_ref())
44            .cloned()
45            .unwrap_or_else(|| call.module.clone());
46        let function_name = overrides
47            .and_then(|o| o.function.as_ref())
48            .cloned()
49            .unwrap_or_else(|| snake_to_camel(&call.function));
50        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
51
52        // Resolve package config — defaults to a co-located pkg/ directory shipped
53        // by `wasm-pack build` next to the wasm crate.
54        // When `[crates.output] wasm` is set explicitly, derive the pkg path from
55        // that value so that renamed WASM crates resolve correctly without any
56        // hardcoded special cases.
57        let wasm_pkg = e2e_config.resolve_package("wasm");
58        let pkg_path = wasm_pkg
59            .as_ref()
60            .and_then(|p| p.path.as_ref())
61            .cloned()
62            .unwrap_or_else(|| config.wasm_crate_path());
63        let pkg_name = wasm_pkg
64            .as_ref()
65            .and_then(|p| p.name.as_ref())
66            .cloned()
67            .unwrap_or_else(|| {
68                // Default: derive from WASM crate name (config.name + "-wasm")
69                // wasm-pack transforms the crate name to the package name by replacing
70                // dashes with the crate separator in Cargo (e.g., kreuzberg-wasm -> kreuzberg_wasm).
71                // However, the published npm package might use the module name, which is typically
72                // the crate name without "-wasm". Fall back to the module path.
73                module_path.clone()
74            });
75        let pkg_version = wasm_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .or_else(|| config.resolved_version())
80            .unwrap_or_else(|| "0.1.0".to_string());
81
82        // Determine which auxiliary scaffolding files we need based on the active
83        // fixture set. Doing this once up front lets us emit a self-contained vitest
84        // config that wires only the setup files we'll actually generate.
85        let active_per_group: Vec<Vec<&Fixture>> = groups
86            .iter()
87            .map(|group| {
88                group
89                    .fixtures
90                    .iter()
91                    .filter(|f| super::should_include_fixture(f, lang, e2e_config))
92                    // Honor per-call `skip_languages`: when the resolved call's
93                    // `skip_languages` contains `wasm`, the wasm binding doesn't
94                    // export that function and any test file referencing it
95                    // would fail TS resolution. Drop the fixture entirely.
96                    .filter(|f| {
97                        let cc = e2e_config.resolve_call(f.call.as_deref());
98                        !cc.skip_languages.iter().any(|l| l == lang)
99                    })
100                    .filter(|f| {
101                        // Node fetch (undici) rejects pre-set Content-Length that
102                        // doesn't match the real body length — skip fixtures that
103                        // intentionally send a mismatched header.
104                        f.http.as_ref().is_none_or(|h| {
105                            !h.request
106                                .headers
107                                .iter()
108                                .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
109                        })
110                    })
111                    .filter(|f| {
112                        // Node fetch only supports a fixed set of HTTP methods;
113                        // TRACE and CONNECT throw before reaching the server.
114                        f.http.as_ref().is_none_or(|h| {
115                            let m = h.request.method.to_ascii_uppercase();
116                            m != "TRACE" && m != "CONNECT"
117                        })
118                    })
119                    .collect()
120            })
121            .collect();
122
123        let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
124        let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
125        // file_path / bytes args are read off disk by the generated code at runtime;
126        // we add a setup.ts chdir to test_documents so relative paths resolve.
127        let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
128            let cc = e2e_config.resolve_call(f.call.as_deref());
129            cc.args
130                .iter()
131                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
132        });
133
134        // Generate package.json — adds vite-plugin-wasm + top-level-await on top
135        // of the standard vitest dev deps so that `import init, { … } from
136        // '@kreuzberg/wasm'` resolves and instantiates the wasm module before tests
137        // run.
138        files.push(GeneratedFile {
139            path: output_base.join("package.json"),
140            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
141            generated_header: false,
142        });
143
144        // Generate vitest.config.ts — needs vite-plugin-wasm + topLevelAwait, plus
145        // optional globalSetup (for HTTP fixtures and any function-call test that
146        // hits the mock server via MOCK_SERVER_URL) and setupFiles (for chdir).
147        // Function-call e2e tests construct request URLs via
148        // `${process.env.MOCK_SERVER_URL}/fixtures/<id>`, so the mock server must
149        // be running and the env var set even when no raw HTTP fixtures exist.
150        let needs_global_setup = has_http_fixtures;
151        files.push(GeneratedFile {
152            path: output_base.join("vitest.config.ts"),
153            content: render_vitest_config(needs_global_setup, has_file_fixtures),
154            generated_header: true,
155        });
156
157        // Generate globalSetup.ts when any fixture requires the mock server —
158        // either an HTTP fixture (the original consumer) or any function-call
159        // fixture that interpolates `${process.env.MOCK_SERVER_URL}` into a
160        // base URL. It spawns the rust mock-server binary.
161        if needs_global_setup {
162            files.push(GeneratedFile {
163                path: output_base.join("globalSetup.ts"),
164                content: render_global_setup(),
165                generated_header: true,
166            });
167        }
168
169        // Generate setup.ts when any active fixture takes a file_path / bytes arg.
170        // This chdir's to test_documents/ so relative fixture paths resolve.
171        if has_file_fixtures {
172            files.push(GeneratedFile {
173                path: output_base.join("setup.ts"),
174                content: render_file_setup(),
175                generated_header: true,
176            });
177        }
178
179        // Generate tsconfig.json — prevents Vite from walking up to a project-level
180        // tsconfig and pulling in unrelated compiler options.
181        files.push(GeneratedFile {
182            path: output_base.join("tsconfig.json"),
183            content: render_tsconfig(),
184            generated_header: false,
185        });
186
187        // Resolve options_type from override (e.g. `WasmExtractionConfig`).
188        let options_type = overrides.and_then(|o| o.options_type.clone());
189        let field_resolver = FieldResolver::new(
190            &e2e_config.fields,
191            &e2e_config.fields_optional,
192            &e2e_config.result_fields,
193            &e2e_config.fields_array,
194            &std::collections::HashSet::new(),
195        );
196
197        // Generate test files per category. We delegate the per-fixture rendering
198        // to the typescript codegen (`render_test_file`), which already handles
199        // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
200        // routes per-fixture override resolution and skip checks through the wasm
201        // language key. We then inject Node.js WASM initialization code to load
202        // the WASM binary from the pkg directory using fs.readFileSync.
203        for (group, active) in groups.iter().zip(active_per_group.iter()) {
204            if active.is_empty() {
205                continue;
206            }
207            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
208            let content = super::typescript::render_test_file(
209                lang,
210                &group.category,
211                active,
212                &module_path,
213                &pkg_name,
214                &function_name,
215                &e2e_config.call.args,
216                options_type.as_deref(),
217                &field_resolver,
218                client_factory,
219                e2e_config,
220            );
221
222            // The local `pkg/` directory produced by `wasm-pack build --target nodejs`
223            // is already a Node-friendly self-initializing CJS module — `pkg/package.json`
224            // sets `"main"` to the JS entry, so test files can import the package by name
225            // (`from "<pkg_name>"`) with no subpath. The historical `dist-node` rewrite
226            // assumed a multi-distribution layout (`dist/`, `dist-node/`, `dist-web/`)
227            // that the alef-managed `wasm-pack build` does not produce; it is therefore
228            // intentionally absent here.
229            let _ = (&pkg_path, &config.name); // keep variables alive for future use
230
231            files.push(GeneratedFile {
232                path: tests_base.join(filename),
233                content,
234                generated_header: true,
235            });
236        }
237
238        Ok(files)
239    }
240
241    fn language_name(&self) -> &'static str {
242        "wasm"
243    }
244}
245
246fn snake_to_camel(s: &str) -> String {
247    let mut out = String::with_capacity(s.len());
248    let mut upper_next = false;
249    for ch in s.chars() {
250        if ch == '_' {
251            upper_next = true;
252        } else if upper_next {
253            out.push(ch.to_ascii_uppercase());
254            upper_next = false;
255        } else {
256            out.push(ch);
257        }
258    }
259    out
260}
261
262fn render_package_json(
263    pkg_name: &str,
264    pkg_path: &str,
265    pkg_version: &str,
266    dep_mode: crate::config::DependencyMode,
267) -> String {
268    let dep_value = match dep_mode {
269        crate::config::DependencyMode::Registry => pkg_version.to_string(),
270        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
271    };
272    crate::template_env::render(
273        "wasm/package.json.jinja",
274        minijinja::context! {
275            pkg_name => pkg_name,
276            dep_value => dep_value,
277            rollup => tv::npm::ROLLUP,
278            vite_plugin_wasm => tv::npm::VITE_PLUGIN_WASM,
279            vitest => tv::npm::VITEST,
280        },
281    )
282}
283
284fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
285    let header = hash::header(CommentStyle::DoubleSlash);
286    crate::template_env::render(
287        "wasm/vitest.config.ts.jinja",
288        minijinja::context! {
289            header => header,
290            with_global_setup => with_global_setup,
291            with_file_setup => with_file_setup,
292        },
293    )
294}
295
296fn render_file_setup() -> String {
297    let header = hash::header(CommentStyle::DoubleSlash);
298    header
299        + r#"import { fileURLToPath } from 'url';
300import { dirname, join } from 'path';
301
302// Change to the test_documents directory so that fixture file paths like
303// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
304// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
305// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
306const __filename = fileURLToPath(import.meta.url);
307const __dirname = dirname(__filename);
308const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
309process.chdir(testDocumentsDir);
310"#
311}
312
313fn render_global_setup() -> String {
314    let header = hash::header(CommentStyle::DoubleSlash);
315    crate::template_env::render(
316        "wasm/globalSetup.ts.jinja",
317        minijinja::context! {
318            header => header,
319        },
320    )
321}
322
323fn render_tsconfig() -> String {
324    crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
325}
326
327// The historical `inject_wasm_init` post-processor rewrote test imports to a
328// `<pkg>/dist-node` subpath. It was removed because the alef-managed
329// `wasm-pack build --target nodejs` artifact is a flat self-initializing CJS
330// module — its `package.json` already sets `"main"` to the JS entry, so the
331// emitted `import … from "<pkg>"` resolves directly.