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");
57 let pkg_path = wasm_pkg
58 .as_ref()
59 .and_then(|p| p.path.as_ref())
60 .cloned()
61 .unwrap_or_else(|| {
62 let default_name = format!("../../crates/{}-wasm/pkg", config.name);
63 if config.name == "tree-sitter-language-pack" {
65 "../../crates/ts-pack-core-wasm/pkg".to_string()
66 } else {
67 default_name
68 }
69 });
70 let pkg_name = wasm_pkg
71 .as_ref()
72 .and_then(|p| p.name.as_ref())
73 .cloned()
74 .unwrap_or_else(|| {
75 module_path.clone()
81 });
82 let pkg_version = wasm_pkg
83 .as_ref()
84 .and_then(|p| p.version.as_ref())
85 .cloned()
86 .unwrap_or_else(|| "0.1.0".to_string());
87
88 let active_per_group: Vec<Vec<&Fixture>> = groups
92 .iter()
93 .map(|group| {
94 group
95 .fixtures
96 .iter()
97 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
98 .filter(|f| {
103 let cc = e2e_config.resolve_call(f.call.as_deref());
104 !cc.skip_languages.iter().any(|l| l == lang)
105 })
106 .filter(|f| {
107 f.http.as_ref().is_none_or(|h| {
111 !h.request
112 .headers
113 .iter()
114 .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
115 })
116 })
117 .filter(|f| {
118 f.http.as_ref().is_none_or(|h| {
121 let m = h.request.method.to_ascii_uppercase();
122 m != "TRACE" && m != "CONNECT"
123 })
124 })
125 .collect()
126 })
127 .collect();
128
129 let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
130 let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
131 let has_non_http_fixtures = any_fixtures
132 .clone()
133 .any(|f| !f.is_http_test() && !f.assertions.is_empty());
134 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
137 let cc = e2e_config.resolve_call(f.call.as_deref());
138 cc.args
139 .iter()
140 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
141 });
142
143 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 files.push(GeneratedFile {
156 path: output_base.join("vitest.config.ts"),
157 content: render_vitest_config(has_http_fixtures, has_file_fixtures),
158 generated_header: true,
159 });
160
161 if has_http_fixtures {
164 files.push(GeneratedFile {
165 path: output_base.join("globalSetup.ts"),
166 content: render_global_setup(),
167 generated_header: true,
168 });
169 }
170
171 if has_file_fixtures {
174 files.push(GeneratedFile {
175 path: output_base.join("setup.ts"),
176 content: render_file_setup(),
177 generated_header: true,
178 });
179 }
180
181 files.push(GeneratedFile {
184 path: output_base.join("tsconfig.json"),
185 content: render_tsconfig(),
186 generated_header: false,
187 });
188
189 let _ = has_non_http_fixtures;
191
192 let options_type = overrides.and_then(|o| o.options_type.clone());
194 let field_resolver = FieldResolver::new(
195 &e2e_config.fields,
196 &e2e_config.fields_optional,
197 &e2e_config.result_fields,
198 &e2e_config.fields_array,
199 &std::collections::HashSet::new(),
200 );
201
202 for (group, active) in groups.iter().zip(active_per_group.iter()) {
209 if active.is_empty() {
210 continue;
211 }
212 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
213 let mut content = super::typescript::render_test_file(
214 lang,
215 &group.category,
216 active,
217 &module_path,
218 &pkg_name,
219 &function_name,
220 &e2e_config.call.args,
221 options_type.as_deref(),
222 &field_resolver,
223 client_factory,
224 e2e_config,
225 );
226
227 let wasm_crate_name = format!("{}-wasm", config.name);
230 content = inject_wasm_init(&content, &pkg_name, &wasm_crate_name);
231
232 files.push(GeneratedFile {
233 path: tests_base.join(filename),
234 content,
235 generated_header: true,
236 });
237 }
238
239 Ok(files)
240 }
241
242 fn language_name(&self) -> &'static str {
243 "wasm"
244 }
245}
246
247fn snake_to_camel(s: &str) -> String {
248 let mut out = String::with_capacity(s.len());
249 let mut upper_next = false;
250 for ch in s.chars() {
251 if ch == '_' {
252 upper_next = true;
253 } else if upper_next {
254 out.push(ch.to_ascii_uppercase());
255 upper_next = false;
256 } else {
257 out.push(ch);
258 }
259 }
260 out
261}
262
263fn render_package_json(
264 pkg_name: &str,
265 pkg_path: &str,
266 pkg_version: &str,
267 dep_mode: crate::config::DependencyMode,
268) -> String {
269 let dep_value = match dep_mode {
270 crate::config::DependencyMode::Registry => pkg_version.to_string(),
271 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
272 };
273 format!(
274 r#"{{
275 "name": "{pkg_name}-e2e-wasm",
276 "version": "0.1.0",
277 "private": true,
278 "type": "module",
279 "scripts": {{
280 "test": "vitest run"
281 }},
282 "devDependencies": {{
283 "{pkg_name}": "{dep_value}",
284 "rollup": "{rollup}",
285 "vite-plugin-wasm": "{vite_plugin_wasm}",
286 "vitest": "{vitest}"
287 }}
288}}
289"#,
290 rollup = tv::npm::ROLLUP,
291 vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
292 vitest = tv::npm::VITEST,
293 )
294}
295
296fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
297 let header = hash::header(CommentStyle::DoubleSlash);
298 let setup_files_line = if with_file_setup {
299 " setupFiles: ['./setup.ts'],\n"
300 } else {
301 ""
302 };
303 let global_setup_line = if with_global_setup {
304 " globalSetup: './globalSetup.ts',\n"
305 } else {
306 ""
307 };
308 format!(
309 r#"{header}import {{ defineConfig }} from 'vitest/config';
310import wasm from 'vite-plugin-wasm';
311
312export default defineConfig({{
313 plugins: [wasm()],
314 test: {{
315 include: ['tests/**/*.test.ts'],
316{global_setup_line}{setup_files_line} }},
317}});
318"#
319 )
320}
321
322fn render_file_setup() -> String {
323 let header = hash::header(CommentStyle::DoubleSlash);
324 header
325 + r#"import { fileURLToPath } from 'url';
326import { dirname, join } from 'path';
327
328// Change to the test_documents directory so that fixture file paths like
329// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
330// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
331// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
332const __filename = fileURLToPath(import.meta.url);
333const __dirname = dirname(__filename);
334const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
335process.chdir(testDocumentsDir);
336"#
337}
338
339fn render_global_setup() -> String {
340 let header = hash::header(CommentStyle::DoubleSlash);
341 format!(
342 r#"{header}import {{ spawn }} from 'child_process';
343import {{ resolve }} from 'path';
344
345let serverProcess: any;
346
347export async function setup() {{
348 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
349 serverProcess = spawn(
350 resolve(__dirname, '../rust/target/release/mock-server'),
351 [resolve(__dirname, '../../fixtures')],
352 {{ stdio: ['pipe', 'pipe', 'inherit'] }}
353 );
354
355 const url = await new Promise<string>((resolve, reject) => {{
356 serverProcess.stdout.on('data', (data: Buffer) => {{
357 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
358 if (match) resolve(match[1].trim());
359 }});
360 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
361 }});
362
363 process.env.MOCK_SERVER_URL = url;
364}}
365
366export async function teardown() {{
367 if (serverProcess) {{
368 serverProcess.stdin.end();
369 serverProcess.kill();
370 }}
371}}
372"#
373 )
374}
375
376fn render_tsconfig() -> String {
377 r#"{
378 "compilerOptions": {
379 "target": "ES2022",
380 "module": "ESNext",
381 "moduleResolution": "bundler",
382 "strict": true,
383 "strictNullChecks": false,
384 "esModuleInterop": true,
385 "skipLibCheck": true
386 },
387 "include": ["tests/**/*.ts", "vitest.config.ts"]
388}
389"#
390 .to_string()
391}
392
393fn inject_wasm_init(content: &str, pkg_name: &str, _crate_name: &str) -> String {
404 let from_marker_sq = format!("}} from '{pkg_name}';");
406 let from_marker_dq = format!("}} from \"{pkg_name}\";");
407 let from_marker = if content.contains(&from_marker_sq) {
408 from_marker_sq
409 } else {
410 from_marker_dq
411 };
412
413 if let Some(from_pos) = content.find(&from_marker) {
416 let full_from_pos = from_pos + from_marker.len();
417 let before_from = &content[..from_pos];
419 if let Some(import_pos) = before_from
420 .rfind("import {")
421 .or_else(|| before_from.rfind("import init, {"))
422 {
423 let import_section = &content[import_pos..full_from_pos];
424
425 if import_section.contains("import init,") {
427 return content.to_string();
428 }
429
430 let _ = pkg_name;
438 let setup_code = concat!(
439 "import { fileURLToPath } from \"url\";\n",
440 "import { dirname, join } from \"path\";\n",
441 "const __filename = fileURLToPath(import.meta.url);\n",
442 "const __dirname = dirname(__filename);\n",
443 "const testDocumentsDir = join(__dirname, \"..\", \"..\", \"..\", \"test_documents\");\n",
444 "globalThis.process.chdir(testDocumentsDir);\n",
445 );
446
447 return content[..full_from_pos].to_string() + "\n" + setup_code + &content[full_from_pos..];
448 }
449 }
450
451 content.to_string()
452}