1use crate::config::E2eConfig;
7use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
8use crate::fixture::{Fixture, FixtureGroup};
9use alef_core::backend::GeneratedFile;
10use alef_core::config::AlefConfig;
11use alef_core::hash::{self, CommentStyle};
12use alef_core::template_versions as tv;
13use anyhow::Result;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19pub struct WasmCodegen;
21
22impl E2eCodegen for WasmCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let lang = self.language_name();
30 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
31 let tests_base = output_base.join("tests");
32
33 let mut files = Vec::new();
34
35 let call = &e2e_config.call;
37 let overrides = call.overrides.get(lang);
38 let module_path = overrides
39 .and_then(|o| o.module.as_ref())
40 .cloned()
41 .unwrap_or_else(|| call.module.clone());
42
43 let wasm_pkg = e2e_config.resolve_package("wasm");
45 let pkg_path = wasm_pkg
46 .as_ref()
47 .and_then(|p| p.path.as_ref())
48 .cloned()
49 .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
50 let pkg_name = wasm_pkg
51 .as_ref()
52 .and_then(|p| p.name.as_ref())
53 .cloned()
54 .unwrap_or_else(|| module_path.clone());
55 let pkg_version = wasm_pkg
56 .as_ref()
57 .and_then(|p| p.version.as_ref())
58 .cloned()
59 .unwrap_or_else(|| "0.1.0".to_string());
60
61 files.push(GeneratedFile {
63 path: output_base.join("package.json"),
64 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
65 generated_header: false,
66 });
67
68 files.push(GeneratedFile {
70 path: output_base.join("vitest.config.ts"),
71 content: render_vitest_config(),
72 generated_header: true,
73 });
74
75 files.push(GeneratedFile {
77 path: output_base.join("globalSetup.ts"),
78 content: render_global_setup(),
79 generated_header: true,
80 });
81
82 files.push(GeneratedFile {
84 path: output_base.join("tsconfig.json"),
85 content: render_tsconfig(),
86 generated_header: false,
87 });
88
89 for group in groups {
91 let active: Vec<&Fixture> = group
92 .fixtures
93 .iter()
94 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
95 .filter(|f| f.http.is_some())
100 .filter(|f| {
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| {
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 if active.is_empty() {
122 continue;
123 }
124
125 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
126 let content = render_test_file(&group.category, &active);
127 files.push(GeneratedFile {
128 path: tests_base.join(filename),
129 content,
130 generated_header: true,
131 });
132 }
133
134 Ok(files)
135 }
136
137 fn language_name(&self) -> &'static str {
138 "wasm"
139 }
140}
141
142fn render_package_json(
143 pkg_name: &str,
144 pkg_path: &str,
145 pkg_version: &str,
146 dep_mode: crate::config::DependencyMode,
147) -> String {
148 let dep_value = match dep_mode {
149 crate::config::DependencyMode::Registry => pkg_version.to_string(),
150 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
151 };
152 format!(
153 r#"{{
154 "name": "{pkg_name}-e2e-wasm",
155 "version": "0.1.0",
156 "private": true,
157 "type": "module",
158 "scripts": {{
159 "test": "vitest run"
160 }},
161 "devDependencies": {{
162 "{pkg_name}": "{dep_value}",
163 "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
164 "vite-plugin-wasm": "{vite_plugin_wasm}",
165 "vitest": "{vitest}"
166 }}
167}}
168"#,
169 vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
170 vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
171 vitest = tv::npm::VITEST,
172 )
173}
174
175fn render_vitest_config() -> String {
176 let header = hash::header(CommentStyle::DoubleSlash);
177 format!(
178 r#"{header}import {{ defineConfig }} from 'vitest/config';
179import wasm from 'vite-plugin-wasm';
180import topLevelAwait from 'vite-plugin-top-level-await';
181
182export default defineConfig({{
183 plugins: [wasm(), topLevelAwait()],
184 test: {{
185 include: ['tests/**/*.test.ts'],
186 globalSetup: './globalSetup.ts',
187 }},
188}});
189"#
190 )
191}
192
193fn render_global_setup() -> String {
194 let header = hash::header(CommentStyle::DoubleSlash);
195 format!(
196 r#"{header}import {{ spawn }} from 'child_process';
197import {{ resolve }} from 'path';
198
199let serverProcess;
200
201export async function setup() {{
202 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
203 serverProcess = spawn(
204 resolve(__dirname, '../rust/target/release/mock-server'),
205 [resolve(__dirname, '../../fixtures')],
206 {{ stdio: ['pipe', 'pipe', 'inherit'] }}
207 );
208
209 const url = await new Promise((resolve, reject) => {{
210 serverProcess.stdout.on('data', (data) => {{
211 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
212 if (match) resolve(match[1].trim());
213 }});
214 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
215 }});
216
217 process.env.MOCK_SERVER_URL = url;
218}}
219
220export async function teardown() {{
221 if (serverProcess) {{
222 serverProcess.stdin.end();
223 serverProcess.kill();
224 }}
225}}
226"#
227 )
228}
229
230fn render_tsconfig() -> String {
231 r#"{
232 "compilerOptions": {
233 "target": "ES2022",
234 "module": "ESNext",
235 "moduleResolution": "bundler",
236 "strict": true,
237 "strictNullChecks": false,
238 "esModuleInterop": true,
239 "skipLibCheck": true
240 },
241 "include": ["tests/**/*.ts", "vitest.config.ts"]
242}
243"#
244 .to_string()
245}
246
247fn render_test_file(category: &str, fixtures: &[&Fixture]) -> String {
248 let mut out = String::new();
249 out.push_str(&hash::header(CommentStyle::DoubleSlash));
250 let _ = writeln!(out, "import {{ describe, expect, it }} from 'vitest';");
251 let _ = writeln!(out);
252 let _ = writeln!(out, "describe('{category}', () => {{");
253
254 for (i, fixture) in fixtures.iter().enumerate() {
255 render_http_test_case(&mut out, fixture);
256 if i + 1 < fixtures.len() {
257 let _ = writeln!(out);
258 }
259 }
260
261 let _ = writeln!(out, "}});");
262 out
263}
264
265fn render_http_test_case(out: &mut String, fixture: &Fixture) {
269 let Some(http) = &fixture.http else {
270 return;
271 };
272
273 let test_name = sanitize_ident(&fixture.id);
274 let description = fixture.description.replace('\'', "\\'");
275
276 if http.expected_response.status_code == 101 {
278 let _ = writeln!(out, " it.skip('{test_name}: {description}', async () => {{");
279 let _ = writeln!(out, " // HTTP 101 WebSocket upgrade cannot be tested via fetch");
280 let _ = writeln!(out, " }});");
281 return;
282 }
283
284 let method = http.request.method.to_uppercase();
285
286 let mut init_entries: Vec<String> = Vec::new();
288 init_entries.push(format!("method: '{method}'"));
289 init_entries.push("redirect: 'manual'".to_string());
291
292 if !http.request.headers.is_empty() {
294 let entries: Vec<String> = http
295 .request
296 .headers
297 .iter()
298 .map(|(k, v)| {
299 let expanded_v = v.clone();
300 format!(" \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
301 })
302 .collect();
303 init_entries.push(format!("headers: {{\n{},\n }}", entries.join(",\n")));
304 }
305
306 if let Some(body) = &http.request.body {
308 let js_body = json_to_js(body);
309 init_entries.push(format!("body: JSON.stringify({js_body})"));
310 }
311
312 let fixture_id = escape_js(&fixture.id);
313 let _ = writeln!(out, " it('{test_name}: {description}', async () => {{");
314 let _ = writeln!(
315 out,
316 " const baseUrl = process.env.MOCK_SERVER_URL ?? \"http://localhost:8080\";"
317 );
318 let _ = writeln!(out, " const mockUrl = `${{baseUrl}}/fixtures/{fixture_id}`;");
319
320 let init_str = init_entries.join(", ");
321 let _ = writeln!(out, " const response = await fetch(mockUrl, {{ {init_str} }});");
322
323 let status = http.expected_response.status_code;
325 let _ = writeln!(out, " expect(response.status).toBe({status});");
326
327 if let Some(expected_body) = &http.expected_response.body {
329 if !(expected_body.is_null() || expected_body.is_string() && expected_body.as_str() == Some("")) {
331 if let serde_json::Value::String(s) = expected_body {
332 let escaped = escape_js(s);
334 let _ = writeln!(out, " const text = await response.text();");
335 let _ = writeln!(out, " expect(text).toBe('{escaped}');");
336 } else {
337 let js_val = json_to_js(expected_body);
338 let _ = writeln!(out, " const data = await response.json();");
339 let _ = writeln!(out, " expect(data).toEqual({js_val});");
340 }
341 }
342 } else if let Some(partial) = &http.expected_response.body_partial {
343 let _ = writeln!(out, " const data = await response.json();");
344 if let Some(obj) = partial.as_object() {
345 for (key, val) in obj {
346 let js_key = escape_js(key);
347 let js_val = json_to_js(val);
348 let _ = writeln!(
349 out,
350 " expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
351 );
352 }
353 }
354 }
355
356 for (header_name, header_value) in &http.expected_response.headers {
358 let lower_name = header_name.to_lowercase();
359 if lower_name == "content-encoding" {
361 continue;
362 }
363 let escaped_name = escape_js(&lower_name);
364 match header_value.as_str() {
365 "<<present>>" => {
366 let _ = writeln!(
367 out,
368 " expect(response.headers.get('{escaped_name}')).not.toBeNull();"
369 );
370 }
371 "<<absent>>" => {
372 let _ = writeln!(out, " expect(response.headers.get('{escaped_name}')).toBeNull();");
373 }
374 "<<uuid>>" => {
375 let _ = writeln!(
376 out,
377 " expect(response.headers.get('{escaped_name}')).toMatch(/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/);"
378 );
379 }
380 exact => {
381 let escaped_val = escape_js(exact);
382 let _ = writeln!(
383 out,
384 " expect(response.headers.get('{escaped_name}')).toBe('{escaped_val}');"
385 );
386 }
387 }
388 }
389
390 let body_has_content = matches!(&http.expected_response.body, Some(v)
393 if !(v.is_null() || (v.is_string() && v.as_str() == Some(""))));
394 if let Some(validation_errors) = &http.expected_response.validation_errors {
395 if !validation_errors.is_empty() && !body_has_content {
396 let _ = writeln!(
397 out,
398 " const body = await response.json() as {{ errors?: unknown[] }};"
399 );
400 let _ = writeln!(out, " const errors = body.errors ?? [];");
401 for ve in validation_errors {
402 let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
403 let loc_str = loc_js.join(", ");
404 let escaped_msg = escape_js(&ve.msg);
405 let _ = writeln!(
406 out,
407 " expect((errors as Array<Record<string, unknown>>).some((e) => JSON.stringify(e[\"loc\"]) === JSON.stringify([{loc_str}]) && String(e[\"msg\"]).includes(\"{escaped_msg}\"))).toBe(true);"
408 );
409 }
410 }
411 }
412
413 let _ = writeln!(out, " }});");
414}
415
416fn json_to_js(value: &serde_json::Value) -> String {
418 match value {
419 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
420 serde_json::Value::Bool(b) => b.to_string(),
421 serde_json::Value::Number(n) => n.to_string(),
422 serde_json::Value::Null => "null".to_string(),
423 serde_json::Value::Array(arr) => {
424 let items: Vec<String> = arr.iter().map(json_to_js).collect();
425 format!("[{}]", items.join(", "))
426 }
427 serde_json::Value::Object(map) => {
428 let entries: Vec<String> = map
429 .iter()
430 .map(|(k, v)| {
431 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
432 && !k.starts_with(|c: char| c.is_ascii_digit())
433 {
434 k.clone()
435 } else {
436 format!("\"{}\"", escape_js(k))
437 };
438 format!("{key}: {}", json_to_js(v))
439 })
440 .collect();
441 format!("{{ {} }}", entries.join(", "))
442 }
443 }
444}