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        let has_non_http_fixtures = any_fixtures
126            .clone()
127            .any(|f| !f.is_http_test() && !f.assertions.is_empty());
128        // file_path / bytes args are read off disk by the generated code at runtime;
129        // we add a setup.ts chdir to test_documents so relative paths resolve.
130        let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
131            let cc = e2e_config.resolve_call(f.call.as_deref());
132            cc.args
133                .iter()
134                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
135        });
136
137        // Generate package.json — adds vite-plugin-wasm + top-level-await on top
138        // of the standard vitest dev deps so that `import init, { … } from
139        // '@kreuzberg/wasm'` resolves and instantiates the wasm module before tests
140        // run.
141        files.push(GeneratedFile {
142            path: output_base.join("package.json"),
143            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
144            generated_header: false,
145        });
146
147        // Generate vitest.config.ts — needs vite-plugin-wasm + topLevelAwait, plus
148        // optional globalSetup (for HTTP fixtures) and setupFiles (for chdir).
149        files.push(GeneratedFile {
150            path: output_base.join("vitest.config.ts"),
151            content: render_vitest_config(has_http_fixtures, has_file_fixtures),
152            generated_header: true,
153        });
154
155        // Generate globalSetup.ts only when at least one HTTP fixture is in scope —
156        // it spawns the rust mock-server.
157        if has_http_fixtures {
158            files.push(GeneratedFile {
159                path: output_base.join("globalSetup.ts"),
160                content: render_global_setup(),
161                generated_header: true,
162            });
163        }
164
165        // Generate setup.ts when any active fixture takes a file_path / bytes arg.
166        // This chdir's to test_documents/ so relative fixture paths resolve.
167        if has_file_fixtures {
168            files.push(GeneratedFile {
169                path: output_base.join("setup.ts"),
170                content: render_file_setup(),
171                generated_header: true,
172            });
173        }
174
175        // Generate tsconfig.json — prevents Vite from walking up to a project-level
176        // tsconfig and pulling in unrelated compiler options.
177        files.push(GeneratedFile {
178            path: output_base.join("tsconfig.json"),
179            content: render_tsconfig(),
180            generated_header: false,
181        });
182
183        // Suppress the unused-variable warning when no non-HTTP fixtures exist.
184        let _ = has_non_http_fixtures;
185
186        // Resolve options_type from override (e.g. `WasmExtractionConfig`).
187        let options_type = overrides.and_then(|o| o.options_type.clone());
188        let field_resolver = FieldResolver::new(
189            &e2e_config.fields,
190            &e2e_config.fields_optional,
191            &e2e_config.result_fields,
192            &e2e_config.fields_array,
193            &std::collections::HashSet::new(),
194        );
195
196        // Generate test files per category. We delegate the per-fixture rendering
197        // to the typescript codegen (`render_test_file`), which already handles
198        // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
199        // routes per-fixture override resolution and skip checks through the wasm
200        // language key. We then inject Node.js WASM initialization code to load
201        // the WASM binary from the pkg directory using fs.readFileSync.
202        for (group, active) in groups.iter().zip(active_per_group.iter()) {
203            if active.is_empty() {
204                continue;
205            }
206            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
207            let mut content = super::typescript::render_test_file(
208                lang,
209                &group.category,
210                active,
211                &module_path,
212                &pkg_name,
213                &function_name,
214                &e2e_config.call.args,
215                options_type.as_deref(),
216                &field_resolver,
217                client_factory,
218                e2e_config,
219            );
220
221            // Inject WASM initialization code for Node.js environments.
222            // Pass the WASM crate name (e.g., "html-to-markdown-wasm") instead of the core crate name.
223            let wasm_crate_name = format!("{}-wasm", config.name);
224            content = inject_wasm_init(&content, &pkg_name, &wasm_crate_name);
225
226            files.push(GeneratedFile {
227                path: tests_base.join(filename),
228                content,
229                generated_header: true,
230            });
231        }
232
233        Ok(files)
234    }
235
236    fn language_name(&self) -> &'static str {
237        "wasm"
238    }
239}
240
241fn snake_to_camel(s: &str) -> String {
242    let mut out = String::with_capacity(s.len());
243    let mut upper_next = false;
244    for ch in s.chars() {
245        if ch == '_' {
246            upper_next = true;
247        } else if upper_next {
248            out.push(ch.to_ascii_uppercase());
249            upper_next = false;
250        } else {
251            out.push(ch);
252        }
253    }
254    out
255}
256
257fn render_package_json(
258    pkg_name: &str,
259    pkg_path: &str,
260    pkg_version: &str,
261    dep_mode: crate::config::DependencyMode,
262) -> String {
263    let dep_value = match dep_mode {
264        crate::config::DependencyMode::Registry => pkg_version.to_string(),
265        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
266    };
267    crate::template_env::render(
268        "wasm/package.json.jinja",
269        minijinja::context! {
270            pkg_name => pkg_name,
271            dep_value => dep_value,
272            rollup => tv::npm::ROLLUP,
273            vite_plugin_wasm => tv::npm::VITE_PLUGIN_WASM,
274            vitest => tv::npm::VITEST,
275        },
276    )
277}
278
279fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
280    let header = hash::header(CommentStyle::DoubleSlash);
281    crate::template_env::render(
282        "wasm/vitest.config.ts.jinja",
283        minijinja::context! {
284            header => header,
285            with_global_setup => with_global_setup,
286            with_file_setup => with_file_setup,
287        },
288    )
289}
290
291fn render_file_setup() -> String {
292    let header = hash::header(CommentStyle::DoubleSlash);
293    header
294        + r#"import { fileURLToPath } from 'url';
295import { dirname, join } from 'path';
296
297// Change to the test_documents directory so that fixture file paths like
298// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
299// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
300// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
301const __filename = fileURLToPath(import.meta.url);
302const __dirname = dirname(__filename);
303const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
304process.chdir(testDocumentsDir);
305"#
306}
307
308fn render_global_setup() -> String {
309    let header = hash::header(CommentStyle::DoubleSlash);
310    crate::template_env::render(
311        "wasm/globalSetup.ts.jinja",
312        minijinja::context! {
313            header => header,
314        },
315    )
316}
317
318fn render_tsconfig() -> String {
319    crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
320}
321
322/// Inject WASM initialization code for Node.js environments.
323///
324/// Injects top-level await for the async init() function from wasm-pack.
325/// This allows the WASM module to be initialized before tests run.
326/// Also injects chdir to test_documents before init() so file paths resolve.
327///
328/// # Arguments
329/// * `content` — the generated TypeScript test file content
330/// * `pkg_name` — the npm package name (e.g., "kreuzberg" or "@org/kreuzberg")
331/// * `_crate_name` — the Rust crate name (unused in async init pattern)
332fn inject_wasm_init(content: &str, pkg_name: &str, _crate_name: &str) -> String {
333    // The TypeScript renderer generates single-quoted imports; match both styles for robustness.
334    let from_marker_sq = format!("}} from '{pkg_name}';");
335    let from_marker_dq = format!("}} from \"{pkg_name}\";");
336    let from_marker = if content.contains(&from_marker_sq) {
337        from_marker_sq
338    } else {
339        from_marker_dq
340    };
341
342    // Find the closing `} from "pkg_name";` marker, then search backward for the matching `import {`
343    // to avoid accidentally patching an earlier import statement (e.g. `import { ... } from "vitest"`).
344    if let Some(from_pos) = content.find(&from_marker) {
345        let full_from_pos = from_pos + from_marker.len();
346        // Search backward from from_pos to find the last `import {` or `import init, {` before it.
347        let before_from = &content[..from_pos];
348        if let Some(import_pos) = before_from
349            .rfind("import {")
350            .or_else(|| before_from.rfind("import init, {"))
351        {
352            let import_section = &content[import_pos..full_from_pos];
353
354            // Already patched (contains `import init`) — nothing to do.
355            if import_section.contains("import init,") {
356                return content.to_string();
357            }
358
359            // For Node.js test environments (vitest), use initSync with the bundled WASM
360            // binary. Use import.meta.resolve to locate the bundled WASM file reliably.
361            let init_code = format!("import {{ initSync }} from '{pkg_name}';\n", pkg_name = pkg_name);
362            let setup_code = format!(
363                "import {{ fileURLToPath }} from \"url\";\n\
364                import {{ dirname, join }} from \"path\";\n\
365                import {{ readFileSync }} from \"fs\";\n\
366                const __filename = fileURLToPath(import.meta.url);\n\
367                const __dirname = dirname(__filename);\n\
368                const testDocumentsDir = join(__dirname, \"..\", \"..\", \"..\", \"test_documents\");\n\
369                globalThis.process.chdir(testDocumentsDir);\n\
370                const wasmUrl = await import.meta.resolve('{pkg_name}/kreuzberg_wasm_bg.wasm');\n\
371                const wasmPath = fileURLToPath(wasmUrl);\n\
372                const wasmBuffer = readFileSync(wasmPath);\n\
373                initSync(wasmBuffer);\n",
374                pkg_name = pkg_name
375            );
376
377            return init_code + &content[..full_from_pos] + "\n" + &setup_code + &content[full_from_pos..];
378        }
379    }
380
381    content.to_string()
382}