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, CallbackAction, 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 handle_config_type = overrides.and_then(|o| o.handle_config_type.clone());
49        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
50        let empty_enum_fields = HashMap::new();
51        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
52        let empty_bigint_fields: Vec<String> = Vec::new();
53        let bigint_fields = overrides.map(|o| &o.bigint_fields).unwrap_or(&empty_bigint_fields);
54        let result_var = &call.result_var;
55        let is_async = call.r#async;
56
57        // Resolve package config.
58        let wasm_pkg = e2e_config.resolve_package("wasm");
59        let pkg_path = wasm_pkg
60            .as_ref()
61            .and_then(|p| p.path.as_ref())
62            .cloned()
63            .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
64        let pkg_name = wasm_pkg
65            .as_ref()
66            .and_then(|p| p.name.as_ref())
67            .cloned()
68            .unwrap_or_else(|| module_path.clone());
69        let pkg_version = wasm_pkg
70            .as_ref()
71            .and_then(|p| p.version.as_ref())
72            .cloned()
73            .unwrap_or_else(|| "0.1.0".to_string());
74
75        // Generate package.json.
76        files.push(GeneratedFile {
77            path: output_base.join("package.json"),
78            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
79            generated_header: false,
80        });
81
82        // Generate vitest.config.ts.
83        files.push(GeneratedFile {
84            path: output_base.join("vitest.config.ts"),
85            content: render_vitest_config(),
86            generated_header: true,
87        });
88
89        // Generate globalSetup.ts for spawning the mock server.
90        files.push(GeneratedFile {
91            path: output_base.join("globalSetup.ts"),
92            content: render_global_setup(),
93            generated_header: true,
94        });
95
96        // Generate tsconfig.json (prevents Vite from walking up to root tsconfig).
97        files.push(GeneratedFile {
98            path: output_base.join("tsconfig.json"),
99            content: render_tsconfig(),
100            generated_header: false,
101        });
102
103        // Generate test files per category.
104        for group in groups {
105            let active: Vec<&Fixture> = group
106                .fixtures
107                .iter()
108                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
109                .collect();
110
111            if active.is_empty() {
112                continue;
113            }
114
115            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
116            let field_resolver = FieldResolver::new(
117                &e2e_config.fields,
118                &e2e_config.fields_optional,
119                &e2e_config.result_fields,
120                &e2e_config.fields_array,
121            );
122            let content = render_test_file(
123                &group.category,
124                &active,
125                &pkg_name,
126                &function_name,
127                result_var,
128                is_async,
129                &e2e_config.call.args,
130                &field_resolver,
131                options_type.as_deref(),
132                enum_fields,
133                handle_config_type.as_deref(),
134                client_factory,
135                bigint_fields,
136            );
137            files.push(GeneratedFile {
138                path: tests_base.join(filename),
139                content,
140                generated_header: true,
141            });
142        }
143
144        Ok(files)
145    }
146
147    fn language_name(&self) -> &'static str {
148        "wasm"
149    }
150}
151
152fn render_package_json(
153    pkg_name: &str,
154    pkg_path: &str,
155    pkg_version: &str,
156    dep_mode: crate::config::DependencyMode,
157) -> String {
158    let dep_value = match dep_mode {
159        crate::config::DependencyMode::Registry => pkg_version.to_string(),
160        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
161    };
162    format!(
163        r#"{{
164  "name": "{pkg_name}-e2e-wasm",
165  "version": "0.1.0",
166  "private": true,
167  "type": "module",
168  "scripts": {{
169    "test": "vitest run"
170  }},
171  "devDependencies": {{
172    "{pkg_name}": "{dep_value}",
173    "vite-plugin-top-level-await": "^1.4.0",
174    "vite-plugin-wasm": "^3.4.0",
175    "vitest": "^3.0.0"
176  }}
177}}
178"#
179    )
180}
181
182fn render_vitest_config() -> String {
183    r#"// This file is auto-generated by alef. DO NOT EDIT.
184import { defineConfig } from 'vitest/config';
185import wasm from 'vite-plugin-wasm';
186import topLevelAwait from 'vite-plugin-top-level-await';
187
188export default defineConfig({
189  plugins: [wasm(), topLevelAwait()],
190  test: {
191    include: ['tests/**/*.test.ts'],
192    globalSetup: './globalSetup.ts',
193  },
194});
195"#
196    .to_string()
197}
198
199fn render_global_setup() -> String {
200    r#"// This file is auto-generated by alef. DO NOT EDIT.
201import { spawn } from 'child_process';
202import { resolve } from 'path';
203
204let serverProcess;
205
206export async function setup() {
207  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
208  serverProcess = spawn(
209    resolve(__dirname, '../rust/target/release/mock-server'),
210    [resolve(__dirname, '../../fixtures')],
211    { stdio: ['pipe', 'pipe', 'inherit'] }
212  );
213
214  const url = await new Promise((resolve, reject) => {
215    serverProcess.stdout.on('data', (data) => {
216      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
217      if (match) resolve(match[1].trim());
218    });
219    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
220  });
221
222  process.env.MOCK_SERVER_URL = url;
223}
224
225export async function teardown() {
226  if (serverProcess) {
227    serverProcess.stdin.end();
228    serverProcess.kill();
229  }
230}
231"#
232    .to_string()
233}
234
235fn render_tsconfig() -> String {
236    r#"{
237  "compilerOptions": {
238    "target": "ES2022",
239    "module": "ESNext",
240    "moduleResolution": "bundler",
241    "strict": true,
242    "strictNullChecks": false,
243    "esModuleInterop": true,
244    "skipLibCheck": true
245  },
246  "include": ["tests/**/*.ts", "vitest.config.ts"]
247}
248"#
249    .to_string()
250}
251
252#[allow(clippy::too_many_arguments)]
253fn render_test_file(
254    category: &str,
255    fixtures: &[&Fixture],
256    pkg_name: &str,
257    function_name: &str,
258    result_var: &str,
259    is_async: bool,
260    args: &[crate::config::ArgMapping],
261    field_resolver: &FieldResolver,
262    options_type: Option<&str>,
263    enum_fields: &HashMap<String, String>,
264    handle_config_type: Option<&str>,
265    client_factory: Option<&str>,
266    bigint_fields: &[String],
267) -> String {
268    let mut out = String::new();
269    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
270    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
271
272    // Check if any fixture uses a json_object arg that needs the options type import.
273    let needs_options_import = options_type.is_some()
274        && fixtures.iter().any(|f| {
275            args.iter().any(|arg| {
276                if arg.arg_type != "json_object" {
277                    return false;
278                }
279                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
280                let val = if field == "input" {
281                    Some(&f.input)
282                } else {
283                    f.input.get(field)
284                };
285                val.is_some_and(|v| !v.is_null())
286            })
287        });
288
289    // Collect all enum types that need to be imported.
290    let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
291    if needs_options_import {
292        for fixture in fixtures {
293            for arg in args {
294                if arg.arg_type == "json_object" {
295                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
296                    let val = if field == "input" {
297                        Some(&fixture.input)
298                    } else {
299                        fixture.input.get(field)
300                    };
301                    if let Some(val) = val {
302                        if let Some(obj) = val.as_object() {
303                            for k in obj.keys() {
304                                if let Some(enum_type) = enum_fields.get(k) {
305                                    enum_imports.insert(enum_type);
306                                }
307                            }
308                        }
309                    }
310                }
311            }
312        }
313    }
314
315    // Collect handle constructor imports.
316    let handle_constructors: Vec<String> = args
317        .iter()
318        .filter(|arg| arg.arg_type == "handle")
319        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
320        .collect();
321
322    {
323        let mut imports: Vec<String> = if client_factory.is_some() {
324            // When using client_factory, import the factory instead of the function
325            vec![]
326        } else {
327            vec![function_name.to_string()]
328        };
329        if let Some(factory) = client_factory {
330            let camel = factory.to_lower_camel_case();
331            if !imports.contains(&camel) {
332                imports.push(camel);
333            }
334        }
335        imports.extend(handle_constructors);
336        if let (true, Some(opts_type)) = (needs_options_import, options_type) {
337            imports.push(opts_type.to_string());
338            imports.extend(enum_imports.iter().map(|s| s.to_string()));
339        }
340        // Import the handle config class when configured.
341        if let Some(hct) = handle_config_type {
342            if !imports.contains(&hct.to_string()) {
343                imports.push(hct.to_string());
344            }
345        }
346        let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
347    }
348    let _ = writeln!(out);
349    let _ = writeln!(out, "describe('{category}', () => {{");
350
351    for (i, fixture) in fixtures.iter().enumerate() {
352        render_test_case(
353            &mut out,
354            fixture,
355            function_name,
356            result_var,
357            is_async,
358            args,
359            field_resolver,
360            options_type,
361            enum_fields,
362            handle_config_type,
363            client_factory,
364            bigint_fields,
365        );
366        if i + 1 < fixtures.len() {
367            let _ = writeln!(out);
368        }
369    }
370
371    let _ = writeln!(out, "}});");
372    out
373}
374
375#[allow(clippy::too_many_arguments)]
376fn render_test_case(
377    out: &mut String,
378    fixture: &Fixture,
379    function_name: &str,
380    result_var: &str,
381    is_async: bool,
382    args: &[crate::config::ArgMapping],
383    field_resolver: &FieldResolver,
384    options_type: Option<&str>,
385    enum_fields: &HashMap<String, String>,
386    handle_config_type: Option<&str>,
387    client_factory: Option<&str>,
388    bigint_fields: &[String],
389) {
390    let test_name = sanitize_ident(&fixture.id);
391    let description = fixture.description.replace('\'', "\\'");
392    let async_kw = if is_async { "async " } else { "" };
393    let await_kw = if is_async { "await " } else { "" };
394
395    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
396    let (mut setup_lines, arg_parts) = build_args_and_setup(
397        &fixture.input,
398        args,
399        options_type,
400        enum_fields,
401        &fixture.id,
402        handle_config_type,
403        bigint_fields,
404    );
405    let args_str = arg_parts.join(", ");
406
407    // Build visitor if present and add to setup
408    let mut visitor_arg = String::new();
409    if let Some(visitor_spec) = &fixture.visitor {
410        visitor_arg = build_wasm_visitor(&mut setup_lines, visitor_spec);
411    }
412
413    let final_args = if visitor_arg.is_empty() {
414        args_str
415    } else if args_str.is_empty() {
416        format!("{{ visitor: {visitor_arg} }}")
417    } else {
418        format!("{args_str}, {{ visitor: {visitor_arg} }}")
419    };
420
421    // Build the call expression — either `client.method(args)` or `method(args)`
422    let call_expr = if client_factory.is_some() {
423        format!("client.{function_name}({final_args})")
424    } else {
425        format!("{function_name}({final_args})")
426    };
427
428    // Check if any arg is a base_url to determine if we need fixture path
429    let has_base_url_arg = args.iter().any(|arg| arg.arg_type == "base_url");
430    let base_url_expr = if has_base_url_arg {
431        format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id)
432    } else {
433        "process.env.MOCK_SERVER_URL".to_string()
434    };
435
436    if expects_error {
437        let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
438        if let Some(factory) = client_factory {
439            let factory_camel = factory.to_lower_camel_case();
440            let _ = writeln!(
441                out,
442                "    const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
443            );
444        }
445        for line in &setup_lines {
446            let _ = writeln!(out, "    {line}");
447        }
448        if is_async {
449            let _ = writeln!(
450                out,
451                "    await expect({async_kw}() => {await_kw}{call_expr}).rejects.toThrow();"
452            );
453        } else {
454            let _ = writeln!(out, "    expect(() => {call_expr}).toThrow();");
455        }
456        let _ = writeln!(out, "  }});");
457        return;
458    }
459
460    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
461    if let Some(factory) = client_factory {
462        let factory_camel = factory.to_lower_camel_case();
463        let _ = writeln!(
464            out,
465            "    const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
466        );
467    }
468    for line in &setup_lines {
469        let _ = writeln!(out, "    {line}");
470    }
471    let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
472
473    for assertion in &fixture.assertions {
474        render_assertion(out, assertion, result_var, field_resolver);
475    }
476
477    let _ = writeln!(out, "  }});");
478}
479
480/// Build setup lines and argument parts for a function call.
481///
482/// Returns `(setup_lines, args_parts)`. Setup lines are emitted before the
483/// function call; args parts are joined with `, ` to form the argument list.
484fn build_args_and_setup(
485    input: &serde_json::Value,
486    args: &[crate::config::ArgMapping],
487    options_type: Option<&str>,
488    enum_fields: &HashMap<String, String>,
489    fixture_id: &str,
490    handle_config_type: Option<&str>,
491    bigint_fields: &[String],
492) -> (Vec<String>, Vec<String>) {
493    let mut setup_lines = Vec::new();
494    let mut parts = Vec::new();
495
496    if args.is_empty() {
497        parts.push(json_to_js(input));
498        return (setup_lines, parts);
499    }
500
501    for arg in args {
502        if arg.arg_type == "mock_url" {
503            setup_lines.push(format!(
504                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
505                arg.name,
506            ));
507            parts.push(arg.name.clone());
508            continue;
509        }
510
511        if arg.arg_type == "base_url" {
512            // When mock server is in use, set base_url to include the fixture path
513            // so that client requests like /v1/chat/completions become
514            // /fixtures/{fixture_id}/v1/chat/completions which match the prefix
515            setup_lines.push(format!(
516                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
517                arg.name,
518            ));
519            parts.push(arg.name.clone());
520            continue;
521        }
522
523        if arg.arg_type == "handle" {
524            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
525            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
526            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
527            if config_value.is_null()
528                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
529            {
530                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
531            } else if let (Some(hct), Some(obj)) = (handle_config_type, config_value.as_object()) {
532                // WASM bindings use _assertClass validation, so we must construct
533                // a proper class instance instead of passing a plain JS object.
534                let config_var = format!("{}Config", arg.name);
535                setup_lines.push(format!("const {config_var} = new {hct}();"));
536                for (k, field_val) in obj {
537                    let camel_key = k.to_lower_camel_case();
538                    let js_val = json_to_js(field_val);
539                    setup_lines.push(format!("{config_var}.{camel_key} = {js_val};"));
540                }
541                setup_lines.push(format!("const {} = {constructor_name}({config_var});", arg.name));
542            } else {
543                let js_val = json_to_js(config_value);
544                setup_lines.push(format!("const {} = {constructor_name}({js_val});", arg.name));
545            }
546            parts.push(arg.name.clone());
547            continue;
548        }
549
550        // When field == "input", the entire input object IS the value (not a nested key)
551        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
552        let val = if field == "input" {
553            Some(input)
554        } else {
555            input.get(field)
556        };
557        match val {
558            None | Some(serde_json::Value::Null) if arg.optional => continue,
559            None | Some(serde_json::Value::Null) => {
560                let default_val = match arg.arg_type.as_str() {
561                    "string" => "''".to_string(),
562                    "int" | "integer" => "0".to_string(),
563                    "float" | "number" => "0.0".to_string(),
564                    "bool" | "boolean" => "false".to_string(),
565                    _ => "null".to_string(),
566                };
567                parts.push(default_val);
568            }
569            Some(v) => {
570                if arg.arg_type == "json_object" && !v.is_null() {
571                    if let Some(opts_type) = options_type {
572                        if let Some(obj) = v.as_object() {
573                            setup_lines.push(format!("const options = new {opts_type}();"));
574                            for (k, field_val) in obj {
575                                let camel_key = k.to_lower_camel_case();
576                                let js_val = if let Some(enum_type) = enum_fields.get(k) {
577                                    if let Some(s) = field_val.as_str() {
578                                        let pascal_val = s.to_upper_camel_case();
579                                        format!("{enum_type}.{pascal_val}")
580                                    } else {
581                                        json_to_js(field_val)
582                                    }
583                                } else if bigint_fields.iter().any(|f| f == &camel_key) && field_val.is_number() {
584                                    format!("BigInt({})", json_to_js(field_val))
585                                } else {
586                                    json_to_js(field_val)
587                                };
588                                setup_lines.push(format!("options.{camel_key} = {js_val};"));
589                            }
590                            parts.push("options".to_string());
591                            continue;
592                        }
593                    }
594                }
595                parts.push(json_to_js(v));
596            }
597        }
598    }
599
600    (setup_lines, parts)
601}
602
603fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
604    // Skip assertions on fields that don't exist on the result type.
605    if let Some(f) = &assertion.field {
606        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
607            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
608            return;
609        }
610    }
611
612    let field_expr = match &assertion.field {
613        Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
614        _ => result_var.to_string(),
615    };
616
617    match assertion.assertion_type.as_str() {
618        "equals" => {
619            if let Some(expected) = &assertion.value {
620                let js_val = json_to_js(expected);
621                if expected.is_string() {
622                    let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
623                } else {
624                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
625                }
626            }
627        }
628        "contains" => {
629            if let Some(expected) = &assertion.value {
630                let js_val = json_to_js(expected);
631                let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
632            }
633        }
634        "contains_all" => {
635            if let Some(values) = &assertion.values {
636                for val in values {
637                    let js_val = json_to_js(val);
638                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
639                }
640            }
641        }
642        "not_contains" => {
643            if let Some(expected) = &assertion.value {
644                let js_val = json_to_js(expected);
645                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
646            }
647        }
648        "not_empty" => {
649            let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
650        }
651        "is_empty" => {
652            let _ = writeln!(out, "    expect({field_expr}.trim()).toHaveLength(0);");
653        }
654        "contains_any" => {
655            if let Some(values) = &assertion.values {
656                let items: Vec<String> = values.iter().map(json_to_js).collect();
657                let arr_str = items.join(", ");
658                let _ = writeln!(
659                    out,
660                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
661                );
662            }
663        }
664        "greater_than" => {
665            if let Some(val) = &assertion.value {
666                let js_val = json_to_js(val);
667                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
668            }
669        }
670        "less_than" => {
671            if let Some(val) = &assertion.value {
672                let js_val = json_to_js(val);
673                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
674            }
675        }
676        "greater_than_or_equal" => {
677            if let Some(val) = &assertion.value {
678                let js_val = json_to_js(val);
679                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
680            }
681        }
682        "less_than_or_equal" => {
683            if let Some(val) = &assertion.value {
684                let js_val = json_to_js(val);
685                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
686            }
687        }
688        "starts_with" => {
689            if let Some(expected) = &assertion.value {
690                let js_val = json_to_js(expected);
691                let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
692            }
693        }
694        "count_min" => {
695            if let Some(val) = &assertion.value {
696                if let Some(n) = val.as_u64() {
697                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
698                }
699            }
700        }
701        "count_equals" => {
702            if let Some(val) = &assertion.value {
703                if let Some(n) = val.as_u64() {
704                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
705                }
706            }
707        }
708        "is_true" => {
709            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
710        }
711        "not_error" => {
712            // No-op — if we got here, the call succeeded.
713        }
714        "error" => {
715            // Handled at the test level.
716        }
717        other => {
718            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
719        }
720    }
721}
722
723/// Convert a `serde_json::Value` to a JavaScript literal string.
724fn json_to_js(value: &serde_json::Value) -> String {
725    match value {
726        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
727        serde_json::Value::Bool(b) => b.to_string(),
728        serde_json::Value::Number(n) => n.to_string(),
729        serde_json::Value::Null => "null".to_string(),
730        serde_json::Value::Array(arr) => {
731            let items: Vec<String> = arr.iter().map(json_to_js).collect();
732            format!("[{}]", items.join(", "))
733        }
734        serde_json::Value::Object(map) => {
735            let entries: Vec<String> = map
736                .iter()
737                .map(|(k, v)| {
738                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
739                        && !k.starts_with(|c: char| c.is_ascii_digit())
740                    {
741                        k.clone()
742                    } else {
743                        format!("\"{}\"", escape_js(k))
744                    };
745                    format!("{key}: {}", json_to_js(v))
746                })
747                .collect();
748            format!("{{ {} }}", entries.join(", "))
749        }
750    }
751}
752
753/// Build a WASM/JS visitor object and add setup line. Returns the visitor variable name.
754fn build_wasm_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
755    use std::fmt::Write as FmtWrite;
756    let mut visitor_obj = String::new();
757    let _ = writeln!(visitor_obj, "{{");
758    for (method_name, action) in &visitor_spec.callbacks {
759        emit_wasm_visitor_method(&mut visitor_obj, method_name, action);
760    }
761    let _ = writeln!(visitor_obj, "    }}");
762
763    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
764    "_testVisitor".to_string()
765}
766
767/// Emit a WASM/JS visitor method for a callback action.
768fn emit_wasm_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
769    use std::fmt::Write as FmtWrite;
770
771    let camel_method = to_camel_case_wasm(method_name);
772    let params = match method_name {
773        "visit_link" => "ctx, href, text, title",
774        "visit_image" => "ctx, src, alt, title",
775        "visit_heading" => "ctx, level, text, id",
776        "visit_code_block" => "ctx, lang, code",
777        "visit_code_inline"
778        | "visit_strong"
779        | "visit_emphasis"
780        | "visit_strikethrough"
781        | "visit_underline"
782        | "visit_subscript"
783        | "visit_superscript"
784        | "visit_mark"
785        | "visit_button"
786        | "visit_summary"
787        | "visit_figcaption"
788        | "visit_definition_term"
789        | "visit_definition_description" => "ctx, text",
790        "visit_text" => "ctx, text",
791        "visit_list_item" => "ctx, ordered, marker, text",
792        "visit_blockquote" => "ctx, content, depth",
793        "visit_table_row" => "ctx, cells, isHeader",
794        "visit_custom_element" => "ctx, tagName, html",
795        "visit_form" => "ctx, actionUrl, method",
796        "visit_input" => "ctx, inputType, name, value",
797        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
798        "visit_details" => "ctx, isOpen",
799        _ => "ctx",
800    };
801
802    let _ = writeln!(out, "    {camel_method}({params}): string | {{ custom: string }} {{");
803    match action {
804        CallbackAction::Skip => {
805            let _ = writeln!(out, "        return \"skip\";");
806        }
807        CallbackAction::Continue => {
808            let _ = writeln!(out, "        return \"continue\";");
809        }
810        CallbackAction::PreserveHtml => {
811            let _ = writeln!(out, "        return \"preserve_html\";");
812        }
813        CallbackAction::Custom { output } => {
814            let escaped = escape_js(output);
815            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
816        }
817        CallbackAction::CustomTemplate { template } => {
818            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
819        }
820    }
821    let _ = writeln!(out, "    }},");
822}
823
824/// Convert snake_case to camelCase for method names.
825fn to_camel_case_wasm(snake: &str) -> String {
826    use heck::ToLowerCamelCase;
827    snake.to_lower_camel_case()
828}