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