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| {
82 f.http.as_ref().is_none_or(|h| {
86 !h.request
87 .headers
88 .iter()
89 .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
90 })
91 })
92 .filter(|f| {
93 f.http.as_ref().is_none_or(|h| {
96 let m = h.request.method.to_ascii_uppercase();
97 m != "TRACE" && m != "CONNECT"
98 })
99 })
100 .collect()
101 })
102 .collect();
103
104 let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
105 let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
106 let has_non_http_fixtures = any_fixtures
107 .clone()
108 .any(|f| !f.is_http_test() && !f.assertions.is_empty());
109 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
112 let cc = e2e_config.resolve_call(f.call.as_deref());
113 cc.args
114 .iter()
115 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
116 });
117
118 files.push(GeneratedFile {
123 path: output_base.join("package.json"),
124 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
125 generated_header: false,
126 });
127
128 files.push(GeneratedFile {
131 path: output_base.join("vitest.config.ts"),
132 content: render_vitest_config(has_http_fixtures, has_file_fixtures),
133 generated_header: true,
134 });
135
136 if has_http_fixtures {
139 files.push(GeneratedFile {
140 path: output_base.join("globalSetup.ts"),
141 content: render_global_setup(),
142 generated_header: true,
143 });
144 }
145
146 if has_file_fixtures {
149 files.push(GeneratedFile {
150 path: output_base.join("setup.ts"),
151 content: render_file_setup(),
152 generated_header: true,
153 });
154 }
155
156 files.push(GeneratedFile {
159 path: output_base.join("tsconfig.json"),
160 content: render_tsconfig(),
161 generated_header: false,
162 });
163
164 let _ = has_non_http_fixtures;
166
167 let options_type = overrides.and_then(|o| o.options_type.clone());
169 let field_resolver = FieldResolver::new(
170 &e2e_config.fields,
171 &e2e_config.fields_optional,
172 &e2e_config.result_fields,
173 &e2e_config.fields_array,
174 );
175
176 for (group, active) in groups.iter().zip(active_per_group.iter()) {
182 if active.is_empty() {
183 continue;
184 }
185 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
186 let content = super::typescript::render_test_file(
187 lang,
188 &group.category,
189 active,
190 &module_path,
191 &pkg_name,
192 &function_name,
193 &e2e_config.call.args,
194 options_type.as_deref(),
195 &field_resolver,
196 client_factory,
197 e2e_config,
198 );
199 files.push(GeneratedFile {
200 path: tests_base.join(filename),
201 content,
202 generated_header: true,
203 });
204 }
205
206 Ok(files)
207 }
208
209 fn language_name(&self) -> &'static str {
210 "wasm"
211 }
212}
213
214fn snake_to_camel(s: &str) -> String {
215 let mut out = String::with_capacity(s.len());
216 let mut upper_next = false;
217 for ch in s.chars() {
218 if ch == '_' {
219 upper_next = true;
220 } else if upper_next {
221 out.push(ch.to_ascii_uppercase());
222 upper_next = false;
223 } else {
224 out.push(ch);
225 }
226 }
227 out
228}
229
230fn render_package_json(
231 pkg_name: &str,
232 pkg_path: &str,
233 pkg_version: &str,
234 dep_mode: crate::config::DependencyMode,
235) -> String {
236 let dep_value = match dep_mode {
237 crate::config::DependencyMode::Registry => pkg_version.to_string(),
238 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
239 };
240 format!(
241 r#"{{
242 "name": "{pkg_name}-e2e-wasm",
243 "version": "0.1.0",
244 "private": true,
245 "type": "module",
246 "scripts": {{
247 "test": "vitest run"
248 }},
249 "devDependencies": {{
250 "{pkg_name}": "{dep_value}",
251 "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
252 "vite-plugin-wasm": "{vite_plugin_wasm}",
253 "vitest": "{vitest}"
254 }}
255}}
256"#,
257 vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
258 vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
259 vitest = tv::npm::VITEST,
260 )
261}
262
263fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
264 let header = hash::header(CommentStyle::DoubleSlash);
265 let setup_files_line = if with_file_setup {
266 " setupFiles: ['./setup.ts'],\n"
267 } else {
268 ""
269 };
270 let global_setup_line = if with_global_setup {
271 " globalSetup: './globalSetup.ts',\n"
272 } else {
273 ""
274 };
275 format!(
276 r#"{header}import {{ defineConfig }} from 'vitest/config';
277import wasm from 'vite-plugin-wasm';
278import topLevelAwait from 'vite-plugin-top-level-await';
279
280export default defineConfig({{
281 plugins: [wasm(), topLevelAwait()],
282 test: {{
283 include: ['tests/**/*.test.ts'],
284{global_setup_line}{setup_files_line} }},
285}});
286"#
287 )
288}
289
290fn render_file_setup() -> String {
291 let header = hash::header(CommentStyle::DoubleSlash);
292 header
293 + r#"import { fileURLToPath } from 'url';
294import { dirname, join } from 'path';
295
296// Change to the test_documents directory so that fixture file paths like
297// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
298// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
299// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
300const __filename = fileURLToPath(import.meta.url);
301const __dirname = dirname(__filename);
302const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
303process.chdir(testDocumentsDir);
304"#
305}
306
307fn render_global_setup() -> String {
308 let header = hash::header(CommentStyle::DoubleSlash);
309 format!(
310 r#"{header}import {{ spawn }} from 'child_process';
311import {{ resolve }} from 'path';
312
313let serverProcess: any;
314
315export async function setup() {{
316 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
317 serverProcess = spawn(
318 resolve(__dirname, '../rust/target/release/mock-server'),
319 [resolve(__dirname, '../../fixtures')],
320 {{ stdio: ['pipe', 'pipe', 'inherit'] }}
321 );
322
323 const url = await new Promise<string>((resolve, reject) => {{
324 serverProcess.stdout.on('data', (data: Buffer) => {{
325 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
326 if (match) resolve(match[1].trim());
327 }});
328 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
329 }});
330
331 process.env.MOCK_SERVER_URL = url;
332}}
333
334export async function teardown() {{
335 if (serverProcess) {{
336 serverProcess.stdin.end();
337 serverProcess.kill();
338 }}
339}}
340"#
341 )
342}
343
344fn render_tsconfig() -> String {
345 r#"{
346 "compilerOptions": {
347 "target": "ES2022",
348 "module": "ESNext",
349 "moduleResolution": "bundler",
350 "strict": true,
351 "strictNullChecks": false,
352 "esModuleInterop": true,
353 "skipLibCheck": true
354 },
355 "include": ["tests/**/*.ts", "vitest.config.ts"]
356}
357"#
358 .to_string()
359}