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::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18/// WebAssembly e2e code generator.
19pub struct WasmCodegen;
20
21impl E2eCodegen for WasmCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        _alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let lang = self.language_name();
29        let output_base = PathBuf::from(&e2e_config.output).join(lang);
30        let tests_base = output_base.join("tests");
31
32        let mut files = Vec::new();
33
34        // Resolve call config with overrides.
35        let call = &e2e_config.call;
36        let overrides = call.overrides.get(lang);
37        let module_path = overrides
38            .and_then(|o| o.module.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.module.clone());
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.function.clone());
45        let result_var = &call.result_var;
46        let is_async = call.r#async;
47
48        // Resolve package config.
49        let wasm_pkg = e2e_config.packages.get("wasm");
50        let pkg_path = wasm_pkg
51            .and_then(|p| p.path.as_ref())
52            .cloned()
53            .unwrap_or_else(|| "../../packages/wasm".to_string());
54        let pkg_name = wasm_pkg
55            .and_then(|p| p.name.as_ref())
56            .cloned()
57            .unwrap_or_else(|| module_path.clone());
58
59        // Generate package.json.
60        files.push(GeneratedFile {
61            path: output_base.join("package.json"),
62            content: render_package_json(&pkg_name, &pkg_path),
63            generated_header: false,
64        });
65
66        // Generate vitest.config.ts.
67        files.push(GeneratedFile {
68            path: output_base.join("vitest.config.ts"),
69            content: render_vitest_config(),
70            generated_header: true,
71        });
72
73        // Generate test files per category.
74        for group in groups {
75            let active: Vec<&Fixture> = group
76                .fixtures
77                .iter()
78                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
79                .collect();
80
81            if active.is_empty() {
82                continue;
83            }
84
85            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
86            let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
87            let content = render_test_file(
88                &group.category,
89                &active,
90                &pkg_name,
91                &function_name,
92                result_var,
93                is_async,
94                &e2e_config.call.args,
95                &field_resolver,
96            );
97            files.push(GeneratedFile {
98                path: tests_base.join(filename),
99                content,
100                generated_header: true,
101            });
102        }
103
104        Ok(files)
105    }
106
107    fn language_name(&self) -> &'static str {
108        "wasm"
109    }
110}
111
112fn render_package_json(pkg_name: &str, pkg_path: &str) -> String {
113    format!(
114        r#"{{
115  "name": "{pkg_name}-e2e-wasm",
116  "version": "0.1.0",
117  "private": true,
118  "type": "module",
119  "scripts": {{
120    "test": "vitest run"
121  }},
122  "devDependencies": {{
123    "{pkg_name}": "file:{pkg_path}",
124    "vitest": "^3.0.0"
125  }}
126}}
127"#
128    )
129}
130
131fn render_vitest_config() -> String {
132    r#"import { defineConfig } from 'vitest/config';
133
134export default defineConfig({
135  test: {
136    include: ['tests/**/*.test.ts'],
137  },
138});
139"#
140    .to_string()
141}
142
143fn render_test_file(
144    category: &str,
145    fixtures: &[&Fixture],
146    pkg_name: &str,
147    function_name: &str,
148    result_var: &str,
149    is_async: bool,
150    args: &[crate::config::ArgMapping],
151    field_resolver: &FieldResolver,
152) -> String {
153    let mut out = String::new();
154    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
155    let _ = writeln!(out, "import {{ {function_name} }} from '{pkg_name}';");
156    let _ = writeln!(out);
157    let _ = writeln!(out, "describe('{category}', () => {{");
158
159    for (i, fixture) in fixtures.iter().enumerate() {
160        render_test_case(
161            &mut out,
162            fixture,
163            function_name,
164            result_var,
165            is_async,
166            args,
167            field_resolver,
168        );
169        if i + 1 < fixtures.len() {
170            let _ = writeln!(out);
171        }
172    }
173
174    let _ = writeln!(out, "}});");
175    out
176}
177
178fn render_test_case(
179    out: &mut String,
180    fixture: &Fixture,
181    function_name: &str,
182    result_var: &str,
183    is_async: bool,
184    args: &[crate::config::ArgMapping],
185    field_resolver: &FieldResolver,
186) {
187    let test_name = sanitize_ident(&fixture.id);
188    let description = fixture.description.replace('\'', "\\'");
189    let async_kw = if is_async { "async " } else { "" };
190    let await_kw = if is_async { "await " } else { "" };
191
192    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
193
194    if expects_error {
195        let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
196        let args_str = build_args_string(&fixture.input, args);
197        if is_async {
198            let _ = writeln!(
199                out,
200                "    await expect({async_kw}() => {await_kw}{function_name}({args_str})).rejects.toThrow();"
201            );
202        } else {
203            let _ = writeln!(out, "    expect(() => {function_name}({args_str})).toThrow();");
204        }
205        let _ = writeln!(out, "  }});");
206        return;
207    }
208
209    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
210
211    let args_str = build_args_string(&fixture.input, args);
212    let _ = writeln!(out, "    const {result_var} = {await_kw}{function_name}({args_str});");
213
214    for assertion in &fixture.assertions {
215        render_assertion(out, assertion, result_var, field_resolver);
216    }
217
218    let _ = writeln!(out, "  }});");
219}
220
221fn build_args_string(input: &serde_json::Value, args: &[crate::config::ArgMapping]) -> String {
222    if args.is_empty() {
223        return json_to_js(input);
224    }
225
226    let parts: Vec<String> = args
227        .iter()
228        .filter_map(|arg| {
229            let val = input.get(&arg.field)?;
230            if val.is_null() && arg.optional {
231                return None;
232            }
233            Some(json_to_js(val))
234        })
235        .collect();
236
237    parts.join(", ")
238}
239
240fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
241    let field_expr = match &assertion.field {
242        Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
243        _ => result_var.to_string(),
244    };
245
246    match assertion.assertion_type.as_str() {
247        "equals" => {
248            if let Some(expected) = &assertion.value {
249                let js_val = json_to_js(expected);
250                let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
251            }
252        }
253        "contains" => {
254            if let Some(expected) = &assertion.value {
255                let js_val = json_to_js(expected);
256                let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
257            }
258        }
259        "contains_all" => {
260            if let Some(values) = &assertion.values {
261                for val in values {
262                    let js_val = json_to_js(val);
263                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
264                }
265            }
266        }
267        "not_contains" => {
268            if let Some(expected) = &assertion.value {
269                let js_val = json_to_js(expected);
270                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
271            }
272        }
273        "not_empty" => {
274            let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
275        }
276        "starts_with" => {
277            if let Some(expected) = &assertion.value {
278                let js_val = json_to_js(expected);
279                let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
280            }
281        }
282        "not_error" => {
283            // No-op — if we got here, the call succeeded.
284        }
285        "error" => {
286            // Handled at the test level.
287        }
288        other => {
289            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
290        }
291    }
292}
293
294/// Convert a `serde_json::Value` to a JavaScript literal string.
295fn json_to_js(value: &serde_json::Value) -> String {
296    match value {
297        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
298        serde_json::Value::Bool(b) => b.to_string(),
299        serde_json::Value::Number(n) => n.to_string(),
300        serde_json::Value::Null => "null".to_string(),
301        serde_json::Value::Array(arr) => {
302            let items: Vec<String> = arr.iter().map(json_to_js).collect();
303            format!("[{}]", items.join(", "))
304        }
305        serde_json::Value::Object(map) => {
306            let entries: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, json_to_js(v))).collect();
307            format!("{{ {} }}", entries.join(", "))
308        }
309    }
310}