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::AlefConfig;
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 alef_config: &AlefConfig,
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");
55 let pkg_path = wasm_pkg
56 .as_ref()
57 .and_then(|p| p.path.as_ref())
58 .cloned()
59 .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
60 let pkg_name = wasm_pkg
61 .as_ref()
62 .and_then(|p| p.name.as_ref())
63 .cloned()
64 .unwrap_or_else(|| module_path.clone());
65 let pkg_version = wasm_pkg
66 .as_ref()
67 .and_then(|p| p.version.as_ref())
68 .cloned()
69 .unwrap_or_else(|| "0.1.0".to_string());
70
71 let active_per_group: Vec<Vec<&Fixture>> = groups
75 .iter()
76 .map(|group| {
77 group
78 .fixtures
79 .iter()
80 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
81 .filter(|f| {
86 let cc = e2e_config.resolve_call(f.call.as_deref());
87 !cc.skip_languages.iter().any(|l| l == lang)
88 })
89 .filter(|f| {
90 f.http.as_ref().is_none_or(|h| {
94 !h.request
95 .headers
96 .iter()
97 .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
98 })
99 })
100 .filter(|f| {
101 f.http.as_ref().is_none_or(|h| {
104 let m = h.request.method.to_ascii_uppercase();
105 m != "TRACE" && m != "CONNECT"
106 })
107 })
108 .collect()
109 })
110 .collect();
111
112 let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
113 let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
114 let has_non_http_fixtures = any_fixtures
115 .clone()
116 .any(|f| !f.is_http_test() && !f.assertions.is_empty());
117 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
120 let cc = e2e_config.resolve_call(f.call.as_deref());
121 cc.args
122 .iter()
123 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
124 });
125
126 files.push(GeneratedFile {
131 path: output_base.join("package.json"),
132 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
133 generated_header: false,
134 });
135
136 files.push(GeneratedFile {
139 path: output_base.join("vitest.config.ts"),
140 content: render_vitest_config(has_http_fixtures, has_file_fixtures),
141 generated_header: true,
142 });
143
144 if has_http_fixtures {
147 files.push(GeneratedFile {
148 path: output_base.join("globalSetup.ts"),
149 content: render_global_setup(),
150 generated_header: true,
151 });
152 }
153
154 if has_file_fixtures {
157 files.push(GeneratedFile {
158 path: output_base.join("setup.ts"),
159 content: render_file_setup(),
160 generated_header: true,
161 });
162 }
163
164 files.push(GeneratedFile {
167 path: output_base.join("tsconfig.json"),
168 content: render_tsconfig(),
169 generated_header: false,
170 });
171
172 let _ = has_non_http_fixtures;
174
175 let options_type = overrides.and_then(|o| o.options_type.clone());
177 let field_resolver = FieldResolver::new(
178 &e2e_config.fields,
179 &e2e_config.fields_optional,
180 &e2e_config.result_fields,
181 &e2e_config.fields_array,
182 );
183
184 for (group, active) in groups.iter().zip(active_per_group.iter()) {
190 if active.is_empty() {
191 continue;
192 }
193 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
194 let content = super::typescript::render_test_file(
195 lang,
196 &group.category,
197 active,
198 &module_path,
199 &pkg_name,
200 &function_name,
201 &e2e_config.call.args,
202 options_type.as_deref(),
203 &field_resolver,
204 client_factory,
205 e2e_config,
206 );
207 files.push(GeneratedFile {
208 path: tests_base.join(filename),
209 content,
210 generated_header: true,
211 });
212 }
213
214 Ok(files)
215 }
216
217 fn language_name(&self) -> &'static str {
218 "wasm"
219 }
220}
221
222fn snake_to_camel(s: &str) -> String {
223 let mut out = String::with_capacity(s.len());
224 let mut upper_next = false;
225 for ch in s.chars() {
226 if ch == '_' {
227 upper_next = true;
228 } else if upper_next {
229 out.push(ch.to_ascii_uppercase());
230 upper_next = false;
231 } else {
232 out.push(ch);
233 }
234 }
235 out
236}
237
238fn render_package_json(
239 pkg_name: &str,
240 pkg_path: &str,
241 pkg_version: &str,
242 dep_mode: crate::config::DependencyMode,
243) -> String {
244 let dep_value = match dep_mode {
245 crate::config::DependencyMode::Registry => pkg_version.to_string(),
246 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
247 };
248 format!(
249 r#"{{
250 "name": "{pkg_name}-e2e-wasm",
251 "version": "0.1.0",
252 "private": true,
253 "type": "module",
254 "scripts": {{
255 "test": "vitest run"
256 }},
257 "devDependencies": {{
258 "{pkg_name}": "{dep_value}",
259 "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
260 "vite-plugin-wasm": "{vite_plugin_wasm}",
261 "vitest": "{vitest}"
262 }}
263}}
264"#,
265 vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
266 vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
267 vitest = tv::npm::VITEST,
268 )
269}
270
271fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
272 let header = hash::header(CommentStyle::DoubleSlash);
273 let setup_files_line = if with_file_setup {
274 " setupFiles: ['./setup.ts'],\n"
275 } else {
276 ""
277 };
278 let global_setup_line = if with_global_setup {
279 " globalSetup: './globalSetup.ts',\n"
280 } else {
281 ""
282 };
283 format!(
284 r#"{header}import {{ defineConfig }} from 'vitest/config';
285import wasm from 'vite-plugin-wasm';
286import topLevelAwait from 'vite-plugin-top-level-await';
287
288export default defineConfig({{
289 plugins: [wasm(), topLevelAwait()],
290 test: {{
291 include: ['tests/**/*.test.ts'],
292{global_setup_line}{setup_files_line} }},
293}});
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 format!(
318 r#"{header}import {{ spawn }} from 'child_process';
319import {{ resolve }} from 'path';
320
321let serverProcess: any;
322
323export async function setup() {{
324 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
325 serverProcess = spawn(
326 resolve(__dirname, '../rust/target/release/mock-server'),
327 [resolve(__dirname, '../../fixtures')],
328 {{ stdio: ['pipe', 'pipe', 'inherit'] }}
329 );
330
331 const url = await new Promise<string>((resolve, reject) => {{
332 serverProcess.stdout.on('data', (data: Buffer) => {{
333 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
334 if (match) resolve(match[1].trim());
335 }});
336 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
337 }});
338
339 process.env.MOCK_SERVER_URL = url;
340}}
341
342export async function teardown() {{
343 if (serverProcess) {{
344 serverProcess.stdin.end();
345 serverProcess.kill();
346 }}
347}}
348"#
349 )
350}
351
352fn render_tsconfig() -> String {
353 r#"{
354 "compilerOptions": {
355 "target": "ES2022",
356 "module": "ESNext",
357 "moduleResolution": "bundler",
358 "strict": true,
359 "strictNullChecks": false,
360 "esModuleInterop": true,
361 "skipLibCheck": true
362 },
363 "include": ["tests/**/*.ts", "vitest.config.ts"]
364}
365"#
366 .to_string()
367}