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 .collect();
96
97 if active.is_empty() {
98 continue;
99 }
100
101 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
102 let content = render_test_file(&group.category, &active);
103 files.push(GeneratedFile {
104 path: tests_base.join(filename),
105 content,
106 generated_header: true,
107 });
108 }
109
110 Ok(files)
111 }
112
113 fn language_name(&self) -> &'static str {
114 "wasm"
115 }
116}
117
118fn render_package_json(
119 pkg_name: &str,
120 pkg_path: &str,
121 pkg_version: &str,
122 dep_mode: crate::config::DependencyMode,
123) -> String {
124 let dep_value = match dep_mode {
125 crate::config::DependencyMode::Registry => pkg_version.to_string(),
126 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
127 };
128 format!(
129 r#"{{
130 "name": "{pkg_name}-e2e-wasm",
131 "version": "0.1.0",
132 "private": true,
133 "type": "module",
134 "scripts": {{
135 "test": "vitest run"
136 }},
137 "devDependencies": {{
138 "{pkg_name}": "{dep_value}",
139 "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
140 "vite-plugin-wasm": "{vite_plugin_wasm}",
141 "vitest": "{vitest}"
142 }}
143}}
144"#,
145 vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
146 vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
147 vitest = tv::npm::VITEST,
148 )
149}
150
151fn render_vitest_config() -> String {
152 let header = hash::header(CommentStyle::DoubleSlash);
153 format!(
154 r#"{header}import {{ defineConfig }} from 'vitest/config';
155import wasm from 'vite-plugin-wasm';
156import topLevelAwait from 'vite-plugin-top-level-await';
157
158export default defineConfig({{
159 plugins: [wasm(), topLevelAwait()],
160 test: {{
161 include: ['tests/**/*.test.ts'],
162 globalSetup: './globalSetup.ts',
163 }},
164}});
165"#
166 )
167}
168
169fn render_global_setup() -> String {
170 let header = hash::header(CommentStyle::DoubleSlash);
171 format!(
172 r#"{header}import {{ spawn }} from 'child_process';
173import {{ resolve }} from 'path';
174
175let serverProcess;
176
177export async function setup() {{
178 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
179 serverProcess = spawn(
180 resolve(__dirname, '../rust/target/release/mock-server'),
181 [resolve(__dirname, '../../fixtures')],
182 {{ stdio: ['pipe', 'pipe', 'inherit'] }}
183 );
184
185 const url = await new Promise((resolve, reject) => {{
186 serverProcess.stdout.on('data', (data) => {{
187 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
188 if (match) resolve(match[1].trim());
189 }});
190 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
191 }});
192
193 process.env.MOCK_SERVER_URL = url;
194}}
195
196export async function teardown() {{
197 if (serverProcess) {{
198 serverProcess.stdin.end();
199 serverProcess.kill();
200 }}
201}}
202"#
203 )
204}
205
206fn render_tsconfig() -> String {
207 r#"{
208 "compilerOptions": {
209 "target": "ES2022",
210 "module": "ESNext",
211 "moduleResolution": "bundler",
212 "strict": true,
213 "strictNullChecks": false,
214 "esModuleInterop": true,
215 "skipLibCheck": true
216 },
217 "include": ["tests/**/*.ts", "vitest.config.ts"]
218}
219"#
220 .to_string()
221}
222
223fn render_test_file(category: &str, fixtures: &[&Fixture]) -> String {
224 let mut out = String::new();
225 out.push_str(&hash::header(CommentStyle::DoubleSlash));
226 let _ = writeln!(out, "import {{ describe, expect, it }} from 'vitest';");
227 let _ = writeln!(out);
228 let _ = writeln!(out, "describe('{category}', () => {{");
229
230 for (i, fixture) in fixtures.iter().enumerate() {
231 render_http_test_case(&mut out, fixture);
232 if i + 1 < fixtures.len() {
233 let _ = writeln!(out);
234 }
235 }
236
237 let _ = writeln!(out, "}});");
238 out
239}
240
241fn render_http_test_case(out: &mut String, fixture: &Fixture) {
245 let Some(http) = &fixture.http else {
246 return;
247 };
248
249 let test_name = sanitize_ident(&fixture.id);
250 let description = fixture.description.replace('\'', "\\'");
251
252 if http.expected_response.status_code == 101 {
254 let _ = writeln!(out, " it.skip('{test_name}: {description}', async () => {{");
255 let _ = writeln!(out, " // HTTP 101 WebSocket upgrade cannot be tested via fetch");
256 let _ = writeln!(out, " }});");
257 return;
258 }
259
260 let method = http.request.method.to_uppercase();
261
262 let mut init_entries: Vec<String> = Vec::new();
264 init_entries.push(format!("method: '{method}'"));
265 init_entries.push("redirect: 'manual'".to_string());
267
268 if !http.request.headers.is_empty() {
270 let entries: Vec<String> = http
271 .request
272 .headers
273 .iter()
274 .map(|(k, v)| {
275 let expanded_v = v.clone();
276 format!(" \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
277 })
278 .collect();
279 init_entries.push(format!("headers: {{\n{},\n }}", entries.join(",\n")));
280 }
281
282 if let Some(body) = &http.request.body {
284 let js_body = json_to_js(body);
285 init_entries.push(format!("body: JSON.stringify({js_body})"));
286 }
287
288 let fixture_id = escape_js(&fixture.id);
289 let _ = writeln!(out, " it('{test_name}: {description}', async () => {{");
290 let _ = writeln!(
291 out,
292 " const baseUrl = process.env.MOCK_SERVER_URL ?? \"http://localhost:8080\";"
293 );
294 let _ = writeln!(out, " const mockUrl = `${{baseUrl}}/fixtures/{fixture_id}`;");
295
296 let init_str = init_entries.join(", ");
297 let _ = writeln!(out, " const response = await fetch(mockUrl, {{ {init_str} }});");
298
299 let status = http.expected_response.status_code;
301 let _ = writeln!(out, " expect(response.status).toBe({status});");
302
303 if let Some(expected_body) = &http.expected_response.body {
305 if !(expected_body.is_null() || expected_body.is_string() && expected_body.as_str() == Some("")) {
307 if let serde_json::Value::String(s) = expected_body {
308 let escaped = escape_js(s);
310 let _ = writeln!(out, " const text = await response.text();");
311 let _ = writeln!(out, " expect(text).toBe('{escaped}');");
312 } else {
313 let js_val = json_to_js(expected_body);
314 let _ = writeln!(out, " const data = await response.json();");
315 let _ = writeln!(out, " expect(data).toEqual({js_val});");
316 }
317 }
318 } else if let Some(partial) = &http.expected_response.body_partial {
319 let _ = writeln!(out, " const data = await response.json();");
320 if let Some(obj) = partial.as_object() {
321 for (key, val) in obj {
322 let js_key = escape_js(key);
323 let js_val = json_to_js(val);
324 let _ = writeln!(
325 out,
326 " expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
327 );
328 }
329 }
330 }
331
332 for (header_name, header_value) in &http.expected_response.headers {
334 let lower_name = header_name.to_lowercase();
335 if lower_name == "content-encoding" {
337 continue;
338 }
339 let escaped_name = escape_js(&lower_name);
340 match header_value.as_str() {
341 "<<present>>" => {
342 let _ = writeln!(
343 out,
344 " expect(response.headers.get('{escaped_name}')).not.toBeNull();"
345 );
346 }
347 "<<absent>>" => {
348 let _ = writeln!(out, " expect(response.headers.get('{escaped_name}')).toBeNull();");
349 }
350 "<<uuid>>" => {
351 let _ = writeln!(
352 out,
353 " 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}}$/);"
354 );
355 }
356 exact => {
357 let escaped_val = escape_js(exact);
358 let _ = writeln!(
359 out,
360 " expect(response.headers.get('{escaped_name}')).toBe('{escaped_val}');"
361 );
362 }
363 }
364 }
365
366 let body_has_content = matches!(&http.expected_response.body, Some(v)
369 if !(v.is_null() || (v.is_string() && v.as_str() == Some(""))));
370 if let Some(validation_errors) = &http.expected_response.validation_errors {
371 if !validation_errors.is_empty() && !body_has_content {
372 let _ = writeln!(
373 out,
374 " const body = await response.json() as {{ errors?: unknown[] }};"
375 );
376 let _ = writeln!(out, " const errors = body.errors ?? [];");
377 for ve in validation_errors {
378 let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
379 let loc_str = loc_js.join(", ");
380 let escaped_msg = escape_js(&ve.msg);
381 let _ = writeln!(
382 out,
383 " 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);"
384 );
385 }
386 }
387 }
388
389 let _ = writeln!(out, " }});");
390}
391
392fn json_to_js(value: &serde_json::Value) -> String {
394 match value {
395 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
396 serde_json::Value::Bool(b) => b.to_string(),
397 serde_json::Value::Number(n) => n.to_string(),
398 serde_json::Value::Null => "null".to_string(),
399 serde_json::Value::Array(arr) => {
400 let items: Vec<String> = arr.iter().map(json_to_js).collect();
401 format!("[{}]", items.join(", "))
402 }
403 serde_json::Value::Object(map) => {
404 let entries: Vec<String> = map
405 .iter()
406 .map(|(k, v)| {
407 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
408 && !k.starts_with(|c: char| c.is_ascii_digit())
409 {
410 k.clone()
411 } else {
412 format!("\"{}\"", escape_js(k))
413 };
414 format!("{key}: {}", json_to_js(v))
415 })
416 .collect();
417 format!("{{ {} }}", entries.join(", "))
418 }
419 }
420}