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