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                .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
241/// Render a vitest `it` block for an HTTP server fixture via fetch.
242///
243/// Wasm e2e tests run under vitest+node, so they can use global `fetch` to hit the mock server.
244fn 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    // HTTP 101 (WebSocket upgrade) — fetch cannot handle upgrade responses.
253    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    // Build the init object for `fetch(url, init)`.
263    let mut init_entries: Vec<String> = Vec::new();
264    init_entries.push(format!("method: '{method}'"));
265    // Do not follow redirects — tests that assert on 3xx status codes need the original response.
266    init_entries.push("redirect: 'manual'".to_string());
267
268    // Headers
269    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    // Body
283    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    // Status code assertion.
300    let status = http.expected_response.status_code;
301    let _ = writeln!(out, "    expect(response.status).toBe({status});");
302
303    // Body assertions.
304    if let Some(expected_body) = &http.expected_response.body {
305        // Empty-string sentinel ("") and null mean no body — skip assertion.
306        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                // Plain-string body: mock server returns raw text, compare as text.
309                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    // Header assertions.
333    for (header_name, header_value) in &http.expected_response.headers {
334        let lower_name = header_name.to_lowercase();
335        // The mock server strips content-encoding headers because it returns uncompressed bodies.
336        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    // Validation error assertions — skip when a full body assertion is already generated
367    // (redundant, and response.json() can only be called once per response).
368    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
392/// Convert a `serde_json::Value` to a JavaScript literal string.
393fn 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}