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    // Collect handle constructor imports.
222    let handle_constructors: Vec<String> = args
223        .iter()
224        .filter(|arg| arg.arg_type == "handle")
225        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
226        .collect();
227
228    {
229        let mut imports = vec![function_name.to_string()];
230        imports.extend(handle_constructors);
231        if let (true, Some(opts_type)) = (needs_options_import, options_type) {
232            imports.push(opts_type.to_string());
233            imports.extend(enum_imports.iter().map(|s| s.to_string()));
234        }
235        let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
236    }
237    let _ = writeln!(out);
238    let _ = writeln!(out, "describe('{category}', () => {{");
239
240    for (i, fixture) in fixtures.iter().enumerate() {
241        render_test_case(
242            &mut out,
243            fixture,
244            function_name,
245            result_var,
246            is_async,
247            args,
248            field_resolver,
249            options_type,
250            enum_fields,
251        );
252        if i + 1 < fixtures.len() {
253            let _ = writeln!(out);
254        }
255    }
256
257    let _ = writeln!(out, "}});");
258    out
259}
260
261#[allow(clippy::too_many_arguments)]
262fn render_test_case(
263    out: &mut String,
264    fixture: &Fixture,
265    function_name: &str,
266    result_var: &str,
267    is_async: bool,
268    args: &[crate::config::ArgMapping],
269    field_resolver: &FieldResolver,
270    options_type: Option<&str>,
271    enum_fields: &HashMap<String, String>,
272) {
273    let test_name = sanitize_ident(&fixture.id);
274    let description = fixture.description.replace('\'', "\\'");
275    let async_kw = if is_async { "async " } else { "" };
276    let await_kw = if is_async { "await " } else { "" };
277
278    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
279    let (setup_lines, arg_parts) = build_args_and_setup(&fixture.input, args, options_type, enum_fields, &fixture.id);
280    let args_str = arg_parts.join(", ");
281
282    if expects_error {
283        let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
284        for line in &setup_lines {
285            let _ = writeln!(out, "    {line}");
286        }
287        if is_async {
288            let _ = writeln!(
289                out,
290                "    await expect({async_kw}() => {await_kw}{function_name}({args_str})).rejects.toThrow();"
291            );
292        } else {
293            let _ = writeln!(out, "    expect(() => {function_name}({args_str})).toThrow();");
294        }
295        let _ = writeln!(out, "  }});");
296        return;
297    }
298
299    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
300    for line in &setup_lines {
301        let _ = writeln!(out, "    {line}");
302    }
303    let _ = writeln!(out, "    const {result_var} = {await_kw}{function_name}({args_str});");
304
305    for assertion in &fixture.assertions {
306        render_assertion(out, assertion, result_var, field_resolver);
307    }
308
309    let _ = writeln!(out, "  }});");
310}
311
312/// Build setup lines and argument parts for a function call.
313///
314/// Returns `(setup_lines, args_parts)`. Setup lines are emitted before the
315/// function call; args parts are joined with `, ` to form the argument list.
316fn build_args_and_setup(
317    input: &serde_json::Value,
318    args: &[crate::config::ArgMapping],
319    options_type: Option<&str>,
320    enum_fields: &HashMap<String, String>,
321    fixture_id: &str,
322) -> (Vec<String>, Vec<String>) {
323    let mut setup_lines = Vec::new();
324    let mut parts = Vec::new();
325
326    if args.is_empty() {
327        parts.push(json_to_js(input));
328        return (setup_lines, parts);
329    }
330
331    for arg in args {
332        if arg.arg_type == "mock_url" {
333            setup_lines.push(format!(
334                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
335                arg.name,
336            ));
337            parts.push(arg.name.clone());
338            continue;
339        }
340
341        if arg.arg_type == "handle" {
342            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
343            let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
344            if config_value.is_null()
345                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
346            {
347                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
348            } else {
349                let js_val = json_to_js(config_value);
350                setup_lines.push(format!("const {} = {constructor_name}({js_val});", arg.name));
351            }
352            parts.push(arg.name.clone());
353            continue;
354        }
355
356        let val = input.get(&arg.field);
357        match val {
358            None | Some(serde_json::Value::Null) if arg.optional => continue,
359            None | Some(serde_json::Value::Null) => {
360                let default_val = match arg.arg_type.as_str() {
361                    "string" => "''".to_string(),
362                    "int" | "integer" => "0".to_string(),
363                    "float" | "number" => "0.0".to_string(),
364                    "bool" | "boolean" => "false".to_string(),
365                    _ => "null".to_string(),
366                };
367                parts.push(default_val);
368            }
369            Some(v) => {
370                if arg.arg_type == "json_object" && !v.is_null() {
371                    if let Some(opts_type) = options_type {
372                        if let Some(obj) = v.as_object() {
373                            setup_lines.push(format!("const options = {opts_type}.default();"));
374                            for (k, field_val) in obj {
375                                let camel_key = k.to_lower_camel_case();
376                                let js_val = if let Some(enum_type) = enum_fields.get(k) {
377                                    if let Some(s) = field_val.as_str() {
378                                        let pascal_val = s.to_upper_camel_case();
379                                        format!("{enum_type}.{pascal_val}")
380                                    } else {
381                                        json_to_js(field_val)
382                                    }
383                                } else {
384                                    json_to_js(field_val)
385                                };
386                                setup_lines.push(format!("options.{camel_key} = {js_val};"));
387                            }
388                            parts.push("options".to_string());
389                            continue;
390                        }
391                    }
392                }
393                parts.push(json_to_js(v));
394            }
395        }
396    }
397
398    (setup_lines, parts)
399}
400
401fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
402    // Skip assertions on fields that don't exist on the result type.
403    if let Some(f) = &assertion.field {
404        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
405            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
406            return;
407        }
408    }
409
410    let field_expr = match &assertion.field {
411        Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
412        _ => result_var.to_string(),
413    };
414
415    match assertion.assertion_type.as_str() {
416        "equals" => {
417            if let Some(expected) = &assertion.value {
418                let js_val = json_to_js(expected);
419                if expected.is_string() {
420                    let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
421                } else {
422                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
423                }
424            }
425        }
426        "contains" => {
427            if let Some(expected) = &assertion.value {
428                let js_val = json_to_js(expected);
429                let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
430            }
431        }
432        "contains_all" => {
433            if let Some(values) = &assertion.values {
434                for val in values {
435                    let js_val = json_to_js(val);
436                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
437                }
438            }
439        }
440        "not_contains" => {
441            if let Some(expected) = &assertion.value {
442                let js_val = json_to_js(expected);
443                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
444            }
445        }
446        "not_empty" => {
447            let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
448        }
449        "is_empty" => {
450            let _ = writeln!(out, "    expect({field_expr}.trim()).toHaveLength(0);");
451        }
452        "contains_any" => {
453            if let Some(values) = &assertion.values {
454                let items: Vec<String> = values.iter().map(json_to_js).collect();
455                let arr_str = items.join(", ");
456                let _ = writeln!(
457                    out,
458                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
459                );
460            }
461        }
462        "greater_than" => {
463            if let Some(val) = &assertion.value {
464                let js_val = json_to_js(val);
465                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
466            }
467        }
468        "less_than" => {
469            if let Some(val) = &assertion.value {
470                let js_val = json_to_js(val);
471                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
472            }
473        }
474        "greater_than_or_equal" => {
475            if let Some(val) = &assertion.value {
476                let js_val = json_to_js(val);
477                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
478            }
479        }
480        "less_than_or_equal" => {
481            if let Some(val) = &assertion.value {
482                let js_val = json_to_js(val);
483                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
484            }
485        }
486        "starts_with" => {
487            if let Some(expected) = &assertion.value {
488                let js_val = json_to_js(expected);
489                let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
490            }
491        }
492        "count_min" => {
493            if let Some(val) = &assertion.value {
494                if let Some(n) = val.as_u64() {
495                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
496                }
497            }
498        }
499        "not_error" => {
500            // No-op — if we got here, the call succeeded.
501        }
502        "error" => {
503            // Handled at the test level.
504        }
505        other => {
506            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
507        }
508    }
509}
510
511/// Convert a `serde_json::Value` to a JavaScript literal string.
512fn json_to_js(value: &serde_json::Value) -> String {
513    match value {
514        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
515        serde_json::Value::Bool(b) => b.to_string(),
516        serde_json::Value::Number(n) => n.to_string(),
517        serde_json::Value::Null => "null".to_string(),
518        serde_json::Value::Array(arr) => {
519            let items: Vec<String> = arr.iter().map(json_to_js).collect();
520            format!("[{}]", items.join(", "))
521        }
522        serde_json::Value::Object(map) => {
523            let entries: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, json_to_js(v))).collect();
524            format!("{{ {} }}", entries.join(", "))
525        }
526    }
527}