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 // file_path / bytes args are read off disk by the generated code at runtime;
126 // we add a setup.ts chdir to test_documents so relative paths resolve.
127 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
128 let cc = e2e_config.resolve_call(f.call.as_deref());
129 cc.args
130 .iter()
131 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
132 });
133
134 // Generate package.json — adds vite-plugin-wasm + top-level-await on top
135 // of the standard vitest dev deps so that `import init, { … } from
136 // '@kreuzberg/wasm'` resolves and instantiates the wasm module before tests
137 // run.
138 files.push(GeneratedFile {
139 path: output_base.join("package.json"),
140 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
141 generated_header: false,
142 });
143
144 // Generate vitest.config.ts — needs vite-plugin-wasm + topLevelAwait, plus
145 // optional globalSetup (for HTTP fixtures and any function-call test that
146 // hits the mock server via MOCK_SERVER_URL) and setupFiles (for chdir).
147 // Function-call e2e tests construct request URLs via
148 // `${process.env.MOCK_SERVER_URL}/fixtures/<id>`, so the mock server must
149 // be running and the env var set even when no raw HTTP fixtures exist.
150 let needs_global_setup = has_http_fixtures;
151 files.push(GeneratedFile {
152 path: output_base.join("vitest.config.ts"),
153 content: render_vitest_config(needs_global_setup, has_file_fixtures),
154 generated_header: true,
155 });
156
157 // Generate globalSetup.ts when any fixture requires the mock server —
158 // either an HTTP fixture (the original consumer) or any function-call
159 // fixture that interpolates `${process.env.MOCK_SERVER_URL}` into a
160 // base URL. It spawns the rust mock-server binary.
161 if needs_global_setup {
162 files.push(GeneratedFile {
163 path: output_base.join("globalSetup.ts"),
164 content: render_global_setup(),
165 generated_header: true,
166 });
167 }
168
169 // Generate setup.ts when any active fixture takes a file_path / bytes arg.
170 // This chdir's to test_documents/ so relative fixture paths resolve.
171 if has_file_fixtures {
172 files.push(GeneratedFile {
173 path: output_base.join("setup.ts"),
174 content: render_file_setup(),
175 generated_header: true,
176 });
177 }
178
179 // Generate tsconfig.json — prevents Vite from walking up to a project-level
180 // tsconfig and pulling in unrelated compiler options.
181 files.push(GeneratedFile {
182 path: output_base.join("tsconfig.json"),
183 content: render_tsconfig(),
184 generated_header: false,
185 });
186
187 // Resolve options_type from override (e.g. `WasmExtractionConfig`).
188 let options_type = overrides.and_then(|o| o.options_type.clone());
189 let field_resolver = FieldResolver::new(
190 &e2e_config.fields,
191 &e2e_config.fields_optional,
192 &e2e_config.result_fields,
193 &e2e_config.fields_array,
194 &std::collections::HashSet::new(),
195 );
196
197 // Generate test files per category. We delegate the per-fixture rendering
198 // to the typescript codegen (`render_test_file`), which already handles
199 // both HTTP and function-call fixtures correctly. Passing `lang = "wasm"`
200 // routes per-fixture override resolution and skip checks through the wasm
201 // language key. We then inject Node.js WASM initialization code to load
202 // the WASM binary from the pkg directory using fs.readFileSync.
203 for (group, active) in groups.iter().zip(active_per_group.iter()) {
204 if active.is_empty() {
205 continue;
206 }
207 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
208 let content = super::typescript::render_test_file(
209 lang,
210 &group.category,
211 active,
212 &module_path,
213 &pkg_name,
214 &function_name,
215 &e2e_config.call.args,
216 options_type.as_deref(),
217 &field_resolver,
218 client_factory,
219 e2e_config,
220 );
221
222 // The local `pkg/` directory produced by `wasm-pack build --target nodejs`
223 // is already a Node-friendly self-initializing CJS module — `pkg/package.json`
224 // sets `"main"` to the JS entry, so test files can import the package by name
225 // (`from "<pkg_name>"`) with no subpath. The historical `dist-node` rewrite
226 // assumed a multi-distribution layout (`dist/`, `dist-node/`, `dist-web/`)
227 // that the alef-managed `wasm-pack build` does not produce; it is therefore
228 // intentionally absent here.
229 let _ = (&pkg_path, &config.name); // keep variables alive for future use
230
231 files.push(GeneratedFile {
232 path: tests_base.join(filename),
233 content,
234 generated_header: true,
235 });
236 }
237
238 Ok(files)
239 }
240
241 fn language_name(&self) -> &'static str {
242 "wasm"
243 }
244}
245
246fn snake_to_camel(s: &str) -> String {
247 let mut out = String::with_capacity(s.len());
248 let mut upper_next = false;
249 for ch in s.chars() {
250 if ch == '_' {
251 upper_next = true;
252 } else if upper_next {
253 out.push(ch.to_ascii_uppercase());
254 upper_next = false;
255 } else {
256 out.push(ch);
257 }
258 }
259 out
260}
261
262fn render_package_json(
263 pkg_name: &str,
264 pkg_path: &str,
265 pkg_version: &str,
266 dep_mode: crate::config::DependencyMode,
267) -> String {
268 let dep_value = match dep_mode {
269 crate::config::DependencyMode::Registry => pkg_version.to_string(),
270 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
271 };
272 crate::template_env::render(
273 "wasm/package.json.jinja",
274 minijinja::context! {
275 pkg_name => pkg_name,
276 dep_value => dep_value,
277 rollup => tv::npm::ROLLUP,
278 vite_plugin_wasm => tv::npm::VITE_PLUGIN_WASM,
279 vitest => tv::npm::VITEST,
280 },
281 )
282}
283
284fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
285 let header = hash::header(CommentStyle::DoubleSlash);
286 crate::template_env::render(
287 "wasm/vitest.config.ts.jinja",
288 minijinja::context! {
289 header => header,
290 with_global_setup => with_global_setup,
291 with_file_setup => with_file_setup,
292 },
293 )
294}
295
296fn render_file_setup() -> String {
297 let header = hash::header(CommentStyle::DoubleSlash);
298 header
299 + r#"import { fileURLToPath } from 'url';
300import { dirname, join } from 'path';
301
302// Change to the test_documents directory so that fixture file paths like
303// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
304// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
305// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
306const __filename = fileURLToPath(import.meta.url);
307const __dirname = dirname(__filename);
308const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
309process.chdir(testDocumentsDir);
310"#
311}
312
313fn render_global_setup() -> String {
314 let header = hash::header(CommentStyle::DoubleSlash);
315 crate::template_env::render(
316 "wasm/globalSetup.ts.jinja",
317 minijinja::context! {
318 header => header,
319 },
320 )
321}
322
323fn render_tsconfig() -> String {
324 crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
325}
326
327// The historical `inject_wasm_init` post-processor rewrote test imports to a
328// `<pkg>/dist-node` subpath. It was removed because the alef-managed
329// `wasm-pack build --target nodejs` artifact is a flat self-initializing CJS
330// module — its `package.json` already sets `"main"` to the JS entry, so the
331// emitted `import … from "<pkg>"` resolves directly.