Skip to main content

alef_e2e/codegen/
wasm.rs

1//! WebAssembly e2e test generator using vitest.
2//!
3//! Similar to the TypeScript generator but imports from a wasm package
4//! and uses `language_name` "wasm".
5
6use 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
19/// WebAssembly e2e code generator.
20pub 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        // Resolve call config with overrides.
36        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        // Resolve package config.
44        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        // Generate package.json.
62        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        // Generate vitest.config.ts.
69        files.push(GeneratedFile {
70            path: output_base.join("vitest.config.ts"),
71            content: render_vitest_config(),
72            generated_header: true,
73        });
74
75        // Generate globalSetup.ts for spawning the mock server.
76        files.push(GeneratedFile {
77            path: output_base.join("globalSetup.ts"),
78            content: render_global_setup(),
79            generated_header: true,
80        });
81
82        // Generate tsconfig.json (prevents Vite from walking up to root tsconfig).
83        files.push(GeneratedFile {
84            path: output_base.join("tsconfig.json"),
85            content: render_tsconfig(),
86            generated_header: false,
87        });
88
89        // Generate test files per category.
90        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                // Wasm e2e is HTTP-only — drop non-HTTP fixtures entirely.
96                // Without this, render_http_test_case emits nothing per fixture
97                // and we end up with an empty `describe(...)` block, which vitest
98                // treats as a test-file failure.
99                .filter(|f| f.http.is_some())
100                // Node fetch (undici) rejects pre-set Content-Length that doesn't
101                // match the real body length — skip fixtures designed to send a
102                // mismatched header.
103                .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                // Node fetch only supports a fixed set of HTTP methods; TRACE and
112                // CONNECT throw at the runtime level before reaching the server.
113                .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
265/// Render a vitest `it` block for an HTTP server fixture via fetch.
266///
267/// Wasm e2e tests run under vitest+node, so they can use global `fetch` to hit the mock server.
268fn 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    // HTTP 101 (WebSocket upgrade) — fetch cannot handle upgrade responses.
277    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    // Build the init object for `fetch(url, init)`.
287    let mut init_entries: Vec<String> = Vec::new();
288    init_entries.push(format!("method: '{method}'"));
289    // Do not follow redirects — tests that assert on 3xx status codes need the original response.
290    init_entries.push("redirect: 'manual'".to_string());
291
292    // Headers
293    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    // Body
307    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    // Status code assertion.
324    let status = http.expected_response.status_code;
325    let _ = writeln!(out, "    expect(response.status).toBe({status});");
326
327    // Body assertions.
328    if let Some(expected_body) = &http.expected_response.body {
329        // Empty-string sentinel ("") and null mean no body — skip assertion.
330        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                // Plain-string body: mock server returns raw text, compare as text.
333                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    // Header assertions.
357    for (header_name, header_value) in &http.expected_response.headers {
358        let lower_name = header_name.to_lowercase();
359        // The mock server strips content-encoding headers because it returns uncompressed bodies.
360        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    // Validation error assertions — skip when a full body assertion is already generated
391    // (redundant, and response.json() can only be called once per response).
392    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
416/// Convert a `serde_json::Value` to a JavaScript literal string.
417fn 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}