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::AlefConfig;
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        alef_config: &AlefConfig,
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 kreuzberg-wasm crate.
54        let wasm_pkg = e2e_config.resolve_package("wasm");
55        let pkg_path = wasm_pkg
56            .as_ref()
57            .and_then(|p| p.path.as_ref())
58            .cloned()
59            .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
60        let pkg_name = wasm_pkg
61            .as_ref()
62            .and_then(|p| p.name.as_ref())
63            .cloned()
64            .unwrap_or_else(|| module_path.clone());
65        let pkg_version = wasm_pkg
66            .as_ref()
67            .and_then(|p| p.version.as_ref())
68            .cloned()
69            .unwrap_or_else(|| "0.1.0".to_string());
70
71        // Determine which auxiliary scaffolding files we need based on the active
72        // fixture set. Doing this once up front lets us emit a self-contained vitest
73        // config that wires only the setup files we'll actually generate.
74        let active_per_group: Vec<Vec<&Fixture>> = groups
75            .iter()
76            .map(|group| {
77                group
78                    .fixtures
79                    .iter()
80                    .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
81                    // Honor per-call `skip_languages`: when the resolved call's
82                    // `skip_languages` contains `wasm`, the wasm binding doesn't
83                    // export that function and any test file referencing it
84                    // would fail TS resolution. Drop the fixture entirely.
85                    .filter(|f| {
86                        let cc = e2e_config.resolve_call(f.call.as_deref());
87                        !cc.skip_languages.iter().any(|l| l == lang)
88                    })
89                    .filter(|f| {
90                        // Node fetch (undici) rejects pre-set Content-Length that
91                        // doesn't match the real body length — skip fixtures that
92                        // intentionally send a mismatched header.
93                        f.http.as_ref().is_none_or(|h| {
94                            !h.request
95                                .headers
96                                .iter()
97                                .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
98                        })
99                    })
100                    .filter(|f| {
101                        // Node fetch only supports a fixed set of HTTP methods;
102                        // TRACE and CONNECT throw before reaching the server.
103                        f.http.as_ref().is_none_or(|h| {
104                            let m = h.request.method.to_ascii_uppercase();
105                            m != "TRACE" && m != "CONNECT"
106                        })
107                    })
108                    .collect()
109            })
110            .collect();
111
112        let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
113        let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
114        let has_non_http_fixtures = any_fixtures
115            .clone()
116            .any(|f| !f.is_http_test() && !f.assertions.is_empty());
117        // file_path / bytes args are read off disk by the generated code at runtime;
118        // we add a setup.ts chdir to test_documents so relative paths resolve.
119        let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
120            let cc = e2e_config.resolve_call(f.call.as_deref());
121            cc.args
122                .iter()
123                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
124        });
125
126        // Generate package.json — adds vite-plugin-wasm + top-level-await on top
127        // of the standard vitest dev deps so that `import init, { … } from
128        // '@kreuzberg/wasm'` resolves and instantiates the wasm module before tests
129        // run.
130        files.push(GeneratedFile {
131            path: output_base.join("package.json"),
132            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
133            generated_header: false,
134        });
135
136        // Generate vitest.config.ts — needs vite-plugin-wasm + topLevelAwait, plus
137        // optional globalSetup (for HTTP fixtures) and setupFiles (for chdir).
138        files.push(GeneratedFile {
139            path: output_base.join("vitest.config.ts"),
140            content: render_vitest_config(has_http_fixtures, has_file_fixtures),
141            generated_header: true,
142        });
143
144        // Generate globalSetup.ts only when at least one HTTP fixture is in scope —
145        // it spawns the rust mock-server.
146        if has_http_fixtures {
147            files.push(GeneratedFile {
148                path: output_base.join("globalSetup.ts"),
149                content: render_global_setup(),
150                generated_header: true,
151            });
152        }
153
154        // Generate setup.ts when any active fixture takes a file_path / bytes arg.
155        // This chdir's to test_documents/ so relative fixture paths resolve.
156        if has_file_fixtures {
157            files.push(GeneratedFile {
158                path: output_base.join("setup.ts"),
159                content: render_file_setup(),
160                generated_header: true,
161            });
162        }
163
164        // Generate tsconfig.json — prevents Vite from walking up to a project-level
165        // tsconfig and pulling in unrelated compiler options.
166        files.push(GeneratedFile {
167            path: output_base.join("tsconfig.json"),
168            content: render_tsconfig(),
169            generated_header: false,
170        });
171
172        // Suppress the unused-variable warning when no non-HTTP fixtures exist.
173        let _ = has_non_http_fixtures;
174
175        // Resolve options_type from override (e.g. `WasmExtractionConfig`).
176        let options_type = overrides.and_then(|o| o.options_type.clone());
177        let field_resolver = FieldResolver::new(
178            &e2e_config.fields,
179            &e2e_config.fields_optional,
180            &e2e_config.result_fields,
181            &e2e_config.fields_array,
182        );
183
184        // Generate test files per category. We delegate the per-fixture rendering
185        // to the typescript codegen (`render_test_file`), which already handles
186        // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
187        // routes per-fixture override resolution and skip checks through the wasm
188        // language key.
189        for (group, active) in groups.iter().zip(active_per_group.iter()) {
190            if active.is_empty() {
191                continue;
192            }
193            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
194            let content = super::typescript::render_test_file(
195                lang,
196                &group.category,
197                active,
198                &module_path,
199                &pkg_name,
200                &function_name,
201                &e2e_config.call.args,
202                options_type.as_deref(),
203                &field_resolver,
204                client_factory,
205                e2e_config,
206            );
207            files.push(GeneratedFile {
208                path: tests_base.join(filename),
209                content,
210                generated_header: true,
211            });
212        }
213
214        Ok(files)
215    }
216
217    fn language_name(&self) -> &'static str {
218        "wasm"
219    }
220}
221
222fn snake_to_camel(s: &str) -> String {
223    let mut out = String::with_capacity(s.len());
224    let mut upper_next = false;
225    for ch in s.chars() {
226        if ch == '_' {
227            upper_next = true;
228        } else if upper_next {
229            out.push(ch.to_ascii_uppercase());
230            upper_next = false;
231        } else {
232            out.push(ch);
233        }
234    }
235    out
236}
237
238fn render_package_json(
239    pkg_name: &str,
240    pkg_path: &str,
241    pkg_version: &str,
242    dep_mode: crate::config::DependencyMode,
243) -> String {
244    let dep_value = match dep_mode {
245        crate::config::DependencyMode::Registry => pkg_version.to_string(),
246        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
247    };
248    format!(
249        r#"{{
250  "name": "{pkg_name}-e2e-wasm",
251  "version": "0.1.0",
252  "private": true,
253  "type": "module",
254  "scripts": {{
255    "test": "vitest run"
256  }},
257  "devDependencies": {{
258    "{pkg_name}": "{dep_value}",
259    "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
260    "vite-plugin-wasm": "{vite_plugin_wasm}",
261    "vitest": "{vitest}"
262  }}
263}}
264"#,
265        vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
266        vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
267        vitest = tv::npm::VITEST,
268    )
269}
270
271fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
272    let header = hash::header(CommentStyle::DoubleSlash);
273    let setup_files_line = if with_file_setup {
274        "    setupFiles: ['./setup.ts'],\n"
275    } else {
276        ""
277    };
278    let global_setup_line = if with_global_setup {
279        "    globalSetup: './globalSetup.ts',\n"
280    } else {
281        ""
282    };
283    format!(
284        r#"{header}import {{ defineConfig }} from 'vitest/config';
285import wasm from 'vite-plugin-wasm';
286import topLevelAwait from 'vite-plugin-top-level-await';
287
288export default defineConfig({{
289  plugins: [wasm(), topLevelAwait()],
290  test: {{
291    include: ['tests/**/*.test.ts'],
292{global_setup_line}{setup_files_line}  }},
293}});
294"#
295    )
296}
297
298fn render_file_setup() -> String {
299    let header = hash::header(CommentStyle::DoubleSlash);
300    header
301        + r#"import { fileURLToPath } from 'url';
302import { dirname, join } from 'path';
303
304// Change to the test_documents directory so that fixture file paths like
305// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
306// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
307// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
308const __filename = fileURLToPath(import.meta.url);
309const __dirname = dirname(__filename);
310const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
311process.chdir(testDocumentsDir);
312"#
313}
314
315fn render_global_setup() -> String {
316    let header = hash::header(CommentStyle::DoubleSlash);
317    format!(
318        r#"{header}import {{ spawn }} from 'child_process';
319import {{ resolve }} from 'path';
320
321let serverProcess: any;
322
323export async function setup() {{
324  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
325  serverProcess = spawn(
326    resolve(__dirname, '../rust/target/release/mock-server'),
327    [resolve(__dirname, '../../fixtures')],
328    {{ stdio: ['pipe', 'pipe', 'inherit'] }}
329  );
330
331  const url = await new Promise<string>((resolve, reject) => {{
332    serverProcess.stdout.on('data', (data: Buffer) => {{
333      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
334      if (match) resolve(match[1].trim());
335    }});
336    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
337  }});
338
339  process.env.MOCK_SERVER_URL = url;
340}}
341
342export async function teardown() {{
343  if (serverProcess) {{
344    serverProcess.stdin.end();
345    serverProcess.kill();
346  }}
347}}
348"#
349    )
350}
351
352fn render_tsconfig() -> String {
353    r#"{
354  "compilerOptions": {
355    "target": "ES2022",
356    "module": "ESNext",
357    "moduleResolution": "bundler",
358    "strict": true,
359    "strictNullChecks": false,
360    "esModuleInterop": true,
361    "skipLibCheck": true
362  },
363  "include": ["tests/**/*.ts", "vitest.config.ts"]
364}
365"#
366    .to_string()
367}