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 heck::{ToLowerCamelCase, ToUpperCamelCase};
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// WebAssembly e2e code generator.
21pub struct WasmCodegen;
22
23impl E2eCodegen for WasmCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        _alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(&e2e_config.output).join(lang);
32        let tests_base = output_base.join("tests");
33
34        let mut files = Vec::new();
35
36        // Resolve call config with overrides.
37        let call = &e2e_config.call;
38        let overrides = call.overrides.get(lang);
39        let module_path = overrides
40            .and_then(|o| o.module.as_ref())
41            .cloned()
42            .unwrap_or_else(|| call.module.clone());
43        let function_name = overrides
44            .and_then(|o| o.function.as_ref())
45            .cloned()
46            .unwrap_or_else(|| call.function.clone());
47        let options_type = overrides.and_then(|o| o.options_type.clone());
48        let empty_enum_fields = HashMap::new();
49        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
50        let result_var = &call.result_var;
51        let is_async = call.r#async;
52
53        // Resolve package config.
54        let wasm_pkg = e2e_config.packages.get("wasm");
55        let pkg_path = wasm_pkg
56            .and_then(|p| p.path.as_ref())
57            .cloned()
58            .unwrap_or_else(|| "../../crates/html-to-markdown-wasm/pkg".to_string());
59        let pkg_name = wasm_pkg
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| module_path.clone());
63
64        // Generate package.json.
65        files.push(GeneratedFile {
66            path: output_base.join("package.json"),
67            content: render_package_json(&pkg_name, &pkg_path),
68            generated_header: false,
69        });
70
71        // Generate vitest.config.ts.
72        files.push(GeneratedFile {
73            path: output_base.join("vitest.config.ts"),
74            content: render_vitest_config(),
75            generated_header: true,
76        });
77
78        // Generate test files per category.
79        for group in groups {
80            let active: Vec<&Fixture> = group
81                .fixtures
82                .iter()
83                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
84                .collect();
85
86            if active.is_empty() {
87                continue;
88            }
89
90            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
91            let field_resolver = FieldResolver::new(
92                &e2e_config.fields,
93                &e2e_config.fields_optional,
94                &e2e_config.result_fields,
95                &e2e_config.fields_array,
96            );
97            let content = render_test_file(
98                &group.category,
99                &active,
100                &pkg_name,
101                &function_name,
102                result_var,
103                is_async,
104                &e2e_config.call.args,
105                &field_resolver,
106                options_type.as_deref(),
107                enum_fields,
108            );
109            files.push(GeneratedFile {
110                path: tests_base.join(filename),
111                content,
112                generated_header: true,
113            });
114        }
115
116        Ok(files)
117    }
118
119    fn language_name(&self) -> &'static str {
120        "wasm"
121    }
122}
123
124fn render_package_json(pkg_name: &str, pkg_path: &str) -> String {
125    format!(
126        r#"{{
127  "name": "{pkg_name}-e2e-wasm",
128  "version": "0.1.0",
129  "private": true,
130  "type": "module",
131  "scripts": {{
132    "test": "vitest run"
133  }},
134  "devDependencies": {{
135    "{pkg_name}": "file:{pkg_path}",
136    "vite-plugin-top-level-await": "^1.4.0",
137    "vite-plugin-wasm": "^3.4.0",
138    "vitest": "^3.0.0"
139  }}
140}}
141"#
142    )
143}
144
145fn render_vitest_config() -> String {
146    r#"// This file is auto-generated by alef. DO NOT EDIT.
147import { defineConfig } from 'vitest/config';
148import wasm from 'vite-plugin-wasm';
149import topLevelAwait from 'vite-plugin-top-level-await';
150
151export default defineConfig({
152  plugins: [wasm(), topLevelAwait()],
153  test: {
154    include: ['tests/**/*.test.ts'],
155  },
156});
157"#
158    .to_string()
159}
160
161#[allow(clippy::too_many_arguments)]
162fn render_test_file(
163    category: &str,
164    fixtures: &[&Fixture],
165    pkg_name: &str,
166    function_name: &str,
167    result_var: &str,
168    is_async: bool,
169    args: &[crate::config::ArgMapping],
170    field_resolver: &FieldResolver,
171    options_type: Option<&str>,
172    enum_fields: &HashMap<String, String>,
173) -> String {
174    let mut out = String::new();
175    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
176    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
177
178    // Check if any fixture uses a json_object arg that needs the options type import.
179    let needs_options_import = options_type.is_some()
180        && fixtures.iter().any(|f| {
181            args.iter()
182                .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
183        });
184
185    // Collect all enum types that need to be imported.
186    let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
187    if needs_options_import {
188        for fixture in fixtures {
189            for arg in args {
190                if arg.arg_type == "json_object" {
191                    if let Some(val) = fixture.input.get(&arg.field) {
192                        if let Some(obj) = val.as_object() {
193                            for k in obj.keys() {
194                                if let Some(enum_type) = enum_fields.get(k) {
195                                    enum_imports.insert(enum_type);
196                                }
197                            }
198                        }
199                    }
200                }
201            }
202        }
203    }
204
205    if let (true, Some(opts_type)) = (needs_options_import, options_type) {
206        let mut imports = vec![function_name.to_string(), opts_type.to_string()];
207        imports.extend(enum_imports.iter().map(|s| s.to_string()));
208        let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
209    } else {
210        let _ = writeln!(out, "import {{ {function_name} }} from '{pkg_name}';");
211    }
212    let _ = writeln!(out);
213    let _ = writeln!(out, "describe('{category}', () => {{");
214
215    for (i, fixture) in fixtures.iter().enumerate() {
216        render_test_case(
217            &mut out,
218            fixture,
219            function_name,
220            result_var,
221            is_async,
222            args,
223            field_resolver,
224            options_type,
225            enum_fields,
226        );
227        if i + 1 < fixtures.len() {
228            let _ = writeln!(out);
229        }
230    }
231
232    let _ = writeln!(out, "}});");
233    out
234}
235
236#[allow(clippy::too_many_arguments)]
237fn render_test_case(
238    out: &mut String,
239    fixture: &Fixture,
240    function_name: &str,
241    result_var: &str,
242    is_async: bool,
243    args: &[crate::config::ArgMapping],
244    field_resolver: &FieldResolver,
245    options_type: Option<&str>,
246    enum_fields: &HashMap<String, String>,
247) {
248    let test_name = sanitize_ident(&fixture.id);
249    let description = fixture.description.replace('\'', "\\'");
250    let async_kw = if is_async { "async " } else { "" };
251    let await_kw = if is_async { "await " } else { "" };
252
253    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
254
255    if expects_error {
256        let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
257        let args_str = build_args_string(&fixture.input, args, options_type, enum_fields);
258        if is_async {
259            let _ = writeln!(
260                out,
261                "    await expect({async_kw}() => {await_kw}{function_name}({args_str})).rejects.toThrow();"
262            );
263        } else {
264            let _ = writeln!(out, "    expect(() => {function_name}({args_str})).toThrow();");
265        }
266        let _ = writeln!(out, "  }});");
267        return;
268    }
269
270    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
271
272    // Check if we need to emit options setup code.
273    let has_options_setup = options_type.is_some()
274        && args
275            .iter()
276            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
277
278    if has_options_setup {
279        // Emit options construction via default + setter pattern.
280        if let Some(opts_type) = options_type {
281            for arg in args {
282                if arg.arg_type == "json_object" {
283                    if let Some(val) = fixture.input.get(&arg.field) {
284                        if !val.is_null() {
285                            if let Some(obj) = val.as_object() {
286                                let _ = writeln!(out, "    const options = {opts_type}.default();");
287                                for (k, v) in obj {
288                                    let camel_key = k.to_lower_camel_case();
289                                    // Check if this field maps to an enum type.
290                                    let js_val = if let Some(enum_type) = enum_fields.get(k) {
291                                        // Map string value to enum constant (PascalCase).
292                                        if let Some(s) = v.as_str() {
293                                            let pascal_val = s.to_upper_camel_case();
294                                            format!("{enum_type}.{pascal_val}")
295                                        } else {
296                                            json_to_js(v)
297                                        }
298                                    } else {
299                                        json_to_js(v)
300                                    };
301                                    let _ = writeln!(out, "    options.{camel_key} = {js_val};");
302                                }
303                            }
304                        }
305                    }
306                }
307            }
308        }
309        // Build call args, replacing the json_object arg with the `options` variable.
310        let call_args: Vec<String> = args
311            .iter()
312            .filter_map(|arg| {
313                let val = fixture.input.get(&arg.field)?;
314                if val.is_null() && arg.optional {
315                    return None;
316                }
317                if arg.arg_type == "json_object" && !val.is_null() && options_type.is_some() {
318                    return Some("options".to_string());
319                }
320                Some(json_to_js(val))
321            })
322            .collect();
323        let args_str = call_args.join(", ");
324        let _ = writeln!(out, "    const {result_var} = {await_kw}{function_name}({args_str});");
325    } else {
326        let args_str = build_args_string(&fixture.input, args, options_type, enum_fields);
327        let _ = writeln!(out, "    const {result_var} = {await_kw}{function_name}({args_str});");
328    }
329
330    for assertion in &fixture.assertions {
331        render_assertion(out, assertion, result_var, field_resolver);
332    }
333
334    let _ = writeln!(out, "  }});");
335}
336
337fn build_args_string(
338    input: &serde_json::Value,
339    args: &[crate::config::ArgMapping],
340    _options_type: Option<&str>,
341    _enum_fields: &HashMap<String, String>,
342) -> String {
343    if args.is_empty() {
344        return json_to_js(input);
345    }
346
347    let parts: Vec<String> = args
348        .iter()
349        .filter_map(|arg| {
350            let val = input.get(&arg.field)?;
351            if val.is_null() && arg.optional {
352                return None;
353            }
354            Some(json_to_js(val))
355        })
356        .collect();
357
358    parts.join(", ")
359}
360
361fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
362    // Skip assertions on fields that don't exist on the result type.
363    if let Some(f) = &assertion.field {
364        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
365            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
366            return;
367        }
368    }
369
370    let field_expr = match &assertion.field {
371        Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
372        _ => result_var.to_string(),
373    };
374
375    match assertion.assertion_type.as_str() {
376        "equals" => {
377            if let Some(expected) = &assertion.value {
378                let js_val = json_to_js(expected);
379                if expected.is_string() {
380                    let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
381                } else {
382                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
383                }
384            }
385        }
386        "contains" => {
387            if let Some(expected) = &assertion.value {
388                let js_val = json_to_js(expected);
389                let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
390            }
391        }
392        "contains_all" => {
393            if let Some(values) = &assertion.values {
394                for val in values {
395                    let js_val = json_to_js(val);
396                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
397                }
398            }
399        }
400        "not_contains" => {
401            if let Some(expected) = &assertion.value {
402                let js_val = json_to_js(expected);
403                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
404            }
405        }
406        "not_empty" => {
407            let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
408        }
409        "is_empty" => {
410            let _ = writeln!(out, "    expect({field_expr}.trim()).toHaveLength(0);");
411        }
412        "contains_any" => {
413            if let Some(values) = &assertion.values {
414                let items: Vec<String> = values.iter().map(json_to_js).collect();
415                let arr_str = items.join(", ");
416                let _ = writeln!(
417                    out,
418                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
419                );
420            }
421        }
422        "greater_than" => {
423            if let Some(val) = &assertion.value {
424                let js_val = json_to_js(val);
425                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
426            }
427        }
428        "less_than" => {
429            if let Some(val) = &assertion.value {
430                let js_val = json_to_js(val);
431                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
432            }
433        }
434        "greater_than_or_equal" => {
435            if let Some(val) = &assertion.value {
436                let js_val = json_to_js(val);
437                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
438            }
439        }
440        "less_than_or_equal" => {
441            if let Some(val) = &assertion.value {
442                let js_val = json_to_js(val);
443                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
444            }
445        }
446        "starts_with" => {
447            if let Some(expected) = &assertion.value {
448                let js_val = json_to_js(expected);
449                let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
450            }
451        }
452        "count_min" => {
453            if let Some(val) = &assertion.value {
454                if let Some(n) = val.as_u64() {
455                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
456                }
457            }
458        }
459        "not_error" => {
460            // No-op — if we got here, the call succeeded.
461        }
462        "error" => {
463            // Handled at the test level.
464        }
465        other => {
466            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
467        }
468    }
469}
470
471/// Convert a `serde_json::Value` to a JavaScript literal string.
472fn json_to_js(value: &serde_json::Value) -> String {
473    match value {
474        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
475        serde_json::Value::Bool(b) => b.to_string(),
476        serde_json::Value::Number(n) => n.to_string(),
477        serde_json::Value::Null => "null".to_string(),
478        serde_json::Value::Array(arr) => {
479            let items: Vec<String> = arr.iter().map(json_to_js).collect();
480            format!("[{}]", items.join(", "))
481        }
482        serde_json::Value::Object(map) => {
483            let entries: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, json_to_js(v))).collect();
484            format!("{{ {} }}", entries.join(", "))
485        }
486    }
487}