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