1use 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
23pub 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 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 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 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 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 .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 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 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 let has_non_http_fixtures = any_fixtures
126 .clone()
127 .any(|f| !f.is_http_test() && !f.assertions.is_empty());
128 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
131 let cc = e2e_config.resolve_call(f.call.as_deref());
132 cc.args
133 .iter()
134 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
135 });
136
137 files.push(GeneratedFile {
142 path: output_base.join("package.json"),
143 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
144 generated_header: false,
145 });
146
147 files.push(GeneratedFile {
150 path: output_base.join("vitest.config.ts"),
151 content: render_vitest_config(has_http_fixtures, has_file_fixtures),
152 generated_header: true,
153 });
154
155 if has_http_fixtures {
158 files.push(GeneratedFile {
159 path: output_base.join("globalSetup.ts"),
160 content: render_global_setup(),
161 generated_header: true,
162 });
163 }
164
165 if has_file_fixtures {
168 files.push(GeneratedFile {
169 path: output_base.join("setup.ts"),
170 content: render_file_setup(),
171 generated_header: true,
172 });
173 }
174
175 files.push(GeneratedFile {
178 path: output_base.join("tsconfig.json"),
179 content: render_tsconfig(),
180 generated_header: false,
181 });
182
183 let _ = has_non_http_fixtures;
185
186 let options_type = overrides.and_then(|o| o.options_type.clone());
188 let field_resolver = FieldResolver::new(
189 &e2e_config.fields,
190 &e2e_config.fields_optional,
191 &e2e_config.result_fields,
192 &e2e_config.fields_array,
193 &std::collections::HashSet::new(),
194 );
195
196 for (group, active) in groups.iter().zip(active_per_group.iter()) {
203 if active.is_empty() {
204 continue;
205 }
206 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
207 let mut content = super::typescript::render_test_file(
208 lang,
209 &group.category,
210 active,
211 &module_path,
212 &pkg_name,
213 &function_name,
214 &e2e_config.call.args,
215 options_type.as_deref(),
216 &field_resolver,
217 client_factory,
218 e2e_config,
219 );
220
221 let wasm_crate_name = format!("{}-wasm", config.name);
224 content = inject_wasm_init(&content, &pkg_name, &wasm_crate_name);
225
226 files.push(GeneratedFile {
227 path: tests_base.join(filename),
228 content,
229 generated_header: true,
230 });
231 }
232
233 Ok(files)
234 }
235
236 fn language_name(&self) -> &'static str {
237 "wasm"
238 }
239}
240
241fn snake_to_camel(s: &str) -> String {
242 let mut out = String::with_capacity(s.len());
243 let mut upper_next = false;
244 for ch in s.chars() {
245 if ch == '_' {
246 upper_next = true;
247 } else if upper_next {
248 out.push(ch.to_ascii_uppercase());
249 upper_next = false;
250 } else {
251 out.push(ch);
252 }
253 }
254 out
255}
256
257fn render_package_json(
258 pkg_name: &str,
259 pkg_path: &str,
260 pkg_version: &str,
261 dep_mode: crate::config::DependencyMode,
262) -> String {
263 let dep_value = match dep_mode {
264 crate::config::DependencyMode::Registry => pkg_version.to_string(),
265 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
266 };
267 crate::template_env::render(
268 "wasm/package.json.jinja",
269 minijinja::context! {
270 pkg_name => pkg_name,
271 dep_value => dep_value,
272 rollup => tv::npm::ROLLUP,
273 vite_plugin_wasm => tv::npm::VITE_PLUGIN_WASM,
274 vitest => tv::npm::VITEST,
275 },
276 )
277}
278
279fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
280 let header = hash::header(CommentStyle::DoubleSlash);
281 crate::template_env::render(
282 "wasm/vitest.config.ts.jinja",
283 minijinja::context! {
284 header => header,
285 with_global_setup => with_global_setup,
286 with_file_setup => with_file_setup,
287 },
288 )
289}
290
291fn render_file_setup() -> String {
292 let header = hash::header(CommentStyle::DoubleSlash);
293 header
294 + r#"import { fileURLToPath } from 'url';
295import { dirname, join } from 'path';
296
297// Change to the test_documents directory so that fixture file paths like
298// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
299// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
300// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
301const __filename = fileURLToPath(import.meta.url);
302const __dirname = dirname(__filename);
303const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
304process.chdir(testDocumentsDir);
305"#
306}
307
308fn render_global_setup() -> String {
309 let header = hash::header(CommentStyle::DoubleSlash);
310 crate::template_env::render(
311 "wasm/globalSetup.ts.jinja",
312 minijinja::context! {
313 header => header,
314 },
315 )
316}
317
318fn render_tsconfig() -> String {
319 crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
320}
321
322fn inject_wasm_init(content: &str, pkg_name: &str, _crate_name: &str) -> String {
333 let from_marker_sq = format!("}} from '{pkg_name}';");
335 let from_marker_dq = format!("}} from \"{pkg_name}\";");
336 let from_marker = if content.contains(&from_marker_sq) {
337 from_marker_sq
338 } else {
339 from_marker_dq
340 };
341
342 if let Some(from_pos) = content.find(&from_marker) {
345 let full_from_pos = from_pos + from_marker.len();
346 let before_from = &content[..from_pos];
348 if let Some(import_pos) = before_from
349 .rfind("import {")
350 .or_else(|| before_from.rfind("import init, {"))
351 {
352 let import_section = &content[import_pos..full_from_pos];
353
354 if import_section.contains("import init,") {
356 return content.to_string();
357 }
358
359 let init_code = format!("import {{ initSync }} from '{pkg_name}';\n", pkg_name = pkg_name);
362 let setup_code = format!(
363 "import {{ fileURLToPath }} from \"url\";\n\
364 import {{ dirname, join }} from \"path\";\n\
365 import {{ readFileSync }} from \"fs\";\n\
366 const __filename = fileURLToPath(import.meta.url);\n\
367 const __dirname = dirname(__filename);\n\
368 const testDocumentsDir = join(__dirname, \"..\", \"..\", \"..\", \"test_documents\");\n\
369 globalThis.process.chdir(testDocumentsDir);\n\
370 const wasmUrl = await import.meta.resolve('{pkg_name}/kreuzberg_wasm_bg.wasm');\n\
371 const wasmPath = fileURLToPath(wasmUrl);\n\
372 const wasmBuffer = readFileSync(wasmPath);\n\
373 initSync(wasmBuffer);\n",
374 pkg_name = pkg_name
375 );
376
377 return init_code + &content[..full_from_pos] + "\n" + &setup_code + &content[full_from_pos..];
378 }
379 }
380
381 content.to_string()
382}