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        // For projects with a core library name different from the package name,
55        // try both {config.name}-wasm and ts-pack-core-wasm (for tree-sitter-language-pack).
56        let wasm_pkg = e2e_config.resolve_package("wasm");
57        let pkg_path = wasm_pkg
58            .as_ref()
59            .and_then(|p| p.path.as_ref())
60            .cloned()
61            .unwrap_or_else(|| {
62                let default_name = format!("../../crates/{}-wasm/pkg", config.name);
63                // Special case: tree-sitter-language-pack uses ts-pack-core-wasm
64                if config.name == "tree-sitter-language-pack" {
65                    "../../crates/ts-pack-core-wasm/pkg".to_string()
66                } else {
67                    default_name
68                }
69            });
70        let pkg_name = wasm_pkg
71            .as_ref()
72            .and_then(|p| p.name.as_ref())
73            .cloned()
74            .unwrap_or_else(|| {
75                // Default: derive from WASM crate name (config.name + "-wasm")
76                // wasm-pack transforms the crate name to the package name by replacing
77                // dashes with the crate separator in Cargo (e.g., kreuzberg-wasm -> kreuzberg_wasm).
78                // However, the published npm package might use the module name, which is typically
79                // the crate name without "-wasm". Fall back to the module path.
80                module_path.clone()
81            });
82        let pkg_version = wasm_pkg
83            .as_ref()
84            .and_then(|p| p.version.as_ref())
85            .cloned()
86            .unwrap_or_else(|| "0.1.0".to_string());
87
88        // Determine which auxiliary scaffolding files we need based on the active
89        // fixture set. Doing this once up front lets us emit a self-contained vitest
90        // config that wires only the setup files we'll actually generate.
91        let active_per_group: Vec<Vec<&Fixture>> = groups
92            .iter()
93            .map(|group| {
94                group
95                    .fixtures
96                    .iter()
97                    .filter(|f| super::should_include_fixture(f, lang, e2e_config))
98                    // Honor per-call `skip_languages`: when the resolved call's
99                    // `skip_languages` contains `wasm`, the wasm binding doesn't
100                    // export that function and any test file referencing it
101                    // would fail TS resolution. Drop the fixture entirely.
102                    .filter(|f| {
103                        let cc = e2e_config.resolve_call(f.call.as_deref());
104                        !cc.skip_languages.iter().any(|l| l == lang)
105                    })
106                    .filter(|f| {
107                        // Node fetch (undici) rejects pre-set Content-Length that
108                        // doesn't match the real body length — skip fixtures that
109                        // intentionally send a mismatched header.
110                        f.http.as_ref().is_none_or(|h| {
111                            !h.request
112                                .headers
113                                .iter()
114                                .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
115                        })
116                    })
117                    .filter(|f| {
118                        // Node fetch only supports a fixed set of HTTP methods;
119                        // TRACE and CONNECT throw before reaching the server.
120                        f.http.as_ref().is_none_or(|h| {
121                            let m = h.request.method.to_ascii_uppercase();
122                            m != "TRACE" && m != "CONNECT"
123                        })
124                    })
125                    .collect()
126            })
127            .collect();
128
129        let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
130        let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
131        let has_non_http_fixtures = any_fixtures
132            .clone()
133            .any(|f| !f.is_http_test() && !f.assertions.is_empty());
134        // file_path / bytes args are read off disk by the generated code at runtime;
135        // we add a setup.ts chdir to test_documents so relative paths resolve.
136        let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
137            let cc = e2e_config.resolve_call(f.call.as_deref());
138            cc.args
139                .iter()
140                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
141        });
142
143        // Generate package.json — adds vite-plugin-wasm + top-level-await on top
144        // of the standard vitest dev deps so that `import init, { … } from
145        // '@kreuzberg/wasm'` resolves and instantiates the wasm module before tests
146        // run.
147        files.push(GeneratedFile {
148            path: output_base.join("package.json"),
149            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
150            generated_header: false,
151        });
152
153        // Generate vitest.config.ts — needs vite-plugin-wasm + topLevelAwait, plus
154        // optional globalSetup (for HTTP fixtures) and setupFiles (for chdir).
155        files.push(GeneratedFile {
156            path: output_base.join("vitest.config.ts"),
157            content: render_vitest_config(has_http_fixtures, has_file_fixtures),
158            generated_header: true,
159        });
160
161        // Generate globalSetup.ts only when at least one HTTP fixture is in scope —
162        // it spawns the rust mock-server.
163        if has_http_fixtures {
164            files.push(GeneratedFile {
165                path: output_base.join("globalSetup.ts"),
166                content: render_global_setup(),
167                generated_header: true,
168            });
169        }
170
171        // Generate setup.ts when any active fixture takes a file_path / bytes arg.
172        // This chdir's to test_documents/ so relative fixture paths resolve.
173        if has_file_fixtures {
174            files.push(GeneratedFile {
175                path: output_base.join("setup.ts"),
176                content: render_file_setup(),
177                generated_header: true,
178            });
179        }
180
181        // Generate tsconfig.json — prevents Vite from walking up to a project-level
182        // tsconfig and pulling in unrelated compiler options.
183        files.push(GeneratedFile {
184            path: output_base.join("tsconfig.json"),
185            content: render_tsconfig(),
186            generated_header: false,
187        });
188
189        // Suppress the unused-variable warning when no non-HTTP fixtures exist.
190        let _ = has_non_http_fixtures;
191
192        // Resolve options_type from override (e.g. `WasmExtractionConfig`).
193        let options_type = overrides.and_then(|o| o.options_type.clone());
194        let field_resolver = FieldResolver::new(
195            &e2e_config.fields,
196            &e2e_config.fields_optional,
197            &e2e_config.result_fields,
198            &e2e_config.fields_array,
199            &std::collections::HashSet::new(),
200        );
201
202        // Generate test files per category. We delegate the per-fixture rendering
203        // to the typescript codegen (`render_test_file`), which already handles
204        // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
205        // routes per-fixture override resolution and skip checks through the wasm
206        // language key. We then inject Node.js WASM initialization code to load
207        // the WASM binary from the pkg directory using fs.readFileSync.
208        for (group, active) in groups.iter().zip(active_per_group.iter()) {
209            if active.is_empty() {
210                continue;
211            }
212            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
213            let mut content = super::typescript::render_test_file(
214                lang,
215                &group.category,
216                active,
217                &module_path,
218                &pkg_name,
219                &function_name,
220                &e2e_config.call.args,
221                options_type.as_deref(),
222                &field_resolver,
223                client_factory,
224                e2e_config,
225            );
226
227            // Inject WASM initialization code for Node.js environments.
228            // Pass the WASM crate name (e.g., "html-to-markdown-wasm") instead of the core crate name.
229            let wasm_crate_name = format!("{}-wasm", config.name);
230            content = inject_wasm_init(&content, &pkg_name, &wasm_crate_name);
231
232            files.push(GeneratedFile {
233                path: tests_base.join(filename),
234                content,
235                generated_header: true,
236            });
237        }
238
239        Ok(files)
240    }
241
242    fn language_name(&self) -> &'static str {
243        "wasm"
244    }
245}
246
247fn snake_to_camel(s: &str) -> String {
248    let mut out = String::with_capacity(s.len());
249    let mut upper_next = false;
250    for ch in s.chars() {
251        if ch == '_' {
252            upper_next = true;
253        } else if upper_next {
254            out.push(ch.to_ascii_uppercase());
255            upper_next = false;
256        } else {
257            out.push(ch);
258        }
259    }
260    out
261}
262
263fn render_package_json(
264    pkg_name: &str,
265    pkg_path: &str,
266    pkg_version: &str,
267    dep_mode: crate::config::DependencyMode,
268) -> String {
269    let dep_value = match dep_mode {
270        crate::config::DependencyMode::Registry => pkg_version.to_string(),
271        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
272    };
273    format!(
274        r#"{{
275  "name": "{pkg_name}-e2e-wasm",
276  "version": "0.1.0",
277  "private": true,
278  "type": "module",
279  "scripts": {{
280    "test": "vitest run"
281  }},
282  "devDependencies": {{
283    "{pkg_name}": "{dep_value}",
284    "rollup": "{rollup}",
285    "vite-plugin-wasm": "{vite_plugin_wasm}",
286    "vitest": "{vitest}"
287  }}
288}}
289"#,
290        rollup = tv::npm::ROLLUP,
291        vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
292        vitest = tv::npm::VITEST,
293    )
294}
295
296fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
297    let header = hash::header(CommentStyle::DoubleSlash);
298    let setup_files_line = if with_file_setup {
299        "    setupFiles: ['./setup.ts'],\n"
300    } else {
301        ""
302    };
303    let global_setup_line = if with_global_setup {
304        "    globalSetup: './globalSetup.ts',\n"
305    } else {
306        ""
307    };
308    format!(
309        r#"{header}import {{ defineConfig }} from 'vitest/config';
310import wasm from 'vite-plugin-wasm';
311
312export default defineConfig({{
313  plugins: [wasm()],
314  test: {{
315    include: ['tests/**/*.test.ts'],
316{global_setup_line}{setup_files_line}  }},
317}});
318"#
319    )
320}
321
322fn render_file_setup() -> String {
323    let header = hash::header(CommentStyle::DoubleSlash);
324    header
325        + r#"import { fileURLToPath } from 'url';
326import { dirname, join } from 'path';
327
328// Change to the test_documents directory so that fixture file paths like
329// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
330// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
331// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
332const __filename = fileURLToPath(import.meta.url);
333const __dirname = dirname(__filename);
334const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
335process.chdir(testDocumentsDir);
336"#
337}
338
339fn render_global_setup() -> String {
340    let header = hash::header(CommentStyle::DoubleSlash);
341    format!(
342        r#"{header}import {{ spawn }} from 'child_process';
343import {{ resolve }} from 'path';
344
345let serverProcess: any;
346
347export async function setup() {{
348  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
349  serverProcess = spawn(
350    resolve(__dirname, '../rust/target/release/mock-server'),
351    [resolve(__dirname, '../../fixtures')],
352    {{ stdio: ['pipe', 'pipe', 'inherit'] }}
353  );
354
355  const url = await new Promise<string>((resolve, reject) => {{
356    serverProcess.stdout.on('data', (data: Buffer) => {{
357      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
358      if (match) resolve(match[1].trim());
359    }});
360    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
361  }});
362
363  process.env.MOCK_SERVER_URL = url;
364}}
365
366export async function teardown() {{
367  if (serverProcess) {{
368    serverProcess.stdin.end();
369    serverProcess.kill();
370  }}
371}}
372"#
373    )
374}
375
376fn render_tsconfig() -> String {
377    r#"{
378  "compilerOptions": {
379    "target": "ES2022",
380    "module": "ESNext",
381    "moduleResolution": "bundler",
382    "strict": true,
383    "strictNullChecks": false,
384    "esModuleInterop": true,
385    "skipLibCheck": true
386  },
387  "include": ["tests/**/*.ts", "vitest.config.ts"]
388}
389"#
390    .to_string()
391}
392
393/// Inject WASM initialization code for Node.js environments.
394///
395/// Injects top-level await for the async init() function from wasm-pack.
396/// This allows the WASM module to be initialized before tests run.
397/// Also injects chdir to test_documents before init() so file paths resolve.
398///
399/// # Arguments
400/// * `content` — the generated TypeScript test file content
401/// * `pkg_name` — the npm package name (e.g., "kreuzberg" or "@org/kreuzberg")
402/// * `_crate_name` — the Rust crate name (unused in async init pattern)
403fn inject_wasm_init(content: &str, pkg_name: &str, _crate_name: &str) -> String {
404    // The TypeScript renderer generates single-quoted imports; match both styles for robustness.
405    let from_marker_sq = format!("}} from '{pkg_name}';");
406    let from_marker_dq = format!("}} from \"{pkg_name}\";");
407    let from_marker = if content.contains(&from_marker_sq) {
408        from_marker_sq
409    } else {
410        from_marker_dq
411    };
412
413    // Find the closing `} from "pkg_name";` marker, then search backward for the matching `import {`
414    // to avoid accidentally patching an earlier import statement (e.g. `import { ... } from "vitest"`).
415    if let Some(from_pos) = content.find(&from_marker) {
416        let full_from_pos = from_pos + from_marker.len();
417        // Search backward from from_pos to find the last `import {` or `import init, {` before it.
418        let before_from = &content[..from_pos];
419        if let Some(import_pos) = before_from
420            .rfind("import {")
421            .or_else(|| before_from.rfind("import init, {"))
422        {
423            let import_section = &content[import_pos..full_from_pos];
424
425            // Already patched (contains `import init`) — nothing to do.
426            if import_section.contains("import init,") {
427                return content.to_string();
428            }
429
430            // For wasm-pack `--target bundler` (the default for projects bundled by
431            // Vite / vitest with vite-plugin-wasm), the wasm module is auto-initialized
432            // when it is imported — there is no `init` default export to call. Older
433            // alef releases injected `import init, { ... }` and `await init(...)`, which
434            // produced `TypeError: default is not a function` against modern wasm-bindgen
435            // packages.  We now only inject the chdir setup so relative-path fixtures
436            // resolve, and leave the import statement alone.
437            let _ = pkg_name;
438            let setup_code = concat!(
439                "import { fileURLToPath } from \"url\";\n",
440                "import { dirname, join } from \"path\";\n",
441                "const __filename = fileURLToPath(import.meta.url);\n",
442                "const __dirname = dirname(__filename);\n",
443                "const testDocumentsDir = join(__dirname, \"..\", \"..\", \"..\", \"test_documents\");\n",
444                "globalThis.process.chdir(testDocumentsDir);\n",
445            );
446
447            return content[..full_from_pos].to_string() + "\n" + setup_code + &content[full_from_pos..];
448        }
449    }
450
451    content.to_string()
452}