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