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 alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// WebAssembly e2e code generator.
23pub struct WasmCodegen;
24
25impl E2eCodegen for WasmCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34        let tests_base = output_base.join("tests");
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let function_name = overrides
46            .and_then(|o| o.function.as_ref())
47            .cloned()
48            .unwrap_or_else(|| call.function.clone());
49        let options_type = overrides.and_then(|o| o.options_type.clone());
50        let handle_config_type = overrides.and_then(|o| o.handle_config_type.clone());
51        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
52        let empty_enum_fields = HashMap::new();
53        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
54        let empty_bigint_fields: Vec<String> = Vec::new();
55        let bigint_fields = overrides.map(|o| &o.bigint_fields).unwrap_or(&empty_bigint_fields);
56        let result_var = &call.result_var;
57        let is_async = call.r#async;
58
59        // Resolve package config.
60        let wasm_pkg = e2e_config.resolve_package("wasm");
61        let pkg_path = wasm_pkg
62            .as_ref()
63            .and_then(|p| p.path.as_ref())
64            .cloned()
65            .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
66        let pkg_name = wasm_pkg
67            .as_ref()
68            .and_then(|p| p.name.as_ref())
69            .cloned()
70            .unwrap_or_else(|| module_path.clone());
71        let pkg_version = wasm_pkg
72            .as_ref()
73            .and_then(|p| p.version.as_ref())
74            .cloned()
75            .unwrap_or_else(|| "0.1.0".to_string());
76
77        // Generate package.json.
78        files.push(GeneratedFile {
79            path: output_base.join("package.json"),
80            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
81            generated_header: false,
82        });
83
84        // Generate vitest.config.ts.
85        files.push(GeneratedFile {
86            path: output_base.join("vitest.config.ts"),
87            content: render_vitest_config(),
88            generated_header: true,
89        });
90
91        // Generate globalSetup.ts for spawning the mock server.
92        files.push(GeneratedFile {
93            path: output_base.join("globalSetup.ts"),
94            content: render_global_setup(),
95            generated_header: true,
96        });
97
98        // Generate tsconfig.json (prevents Vite from walking up to root tsconfig).
99        files.push(GeneratedFile {
100            path: output_base.join("tsconfig.json"),
101            content: render_tsconfig(),
102            generated_header: false,
103        });
104
105        // Generate test files per category.
106        for group in groups {
107            let active: Vec<&Fixture> = group
108                .fixtures
109                .iter()
110                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
111                .collect();
112
113            if active.is_empty() {
114                continue;
115            }
116
117            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
118            let field_resolver = FieldResolver::new(
119                &e2e_config.fields,
120                &e2e_config.fields_optional,
121                &e2e_config.result_fields,
122                &e2e_config.fields_array,
123            );
124            let content = render_test_file(
125                &group.category,
126                &active,
127                &pkg_name,
128                &function_name,
129                result_var,
130                is_async,
131                &e2e_config.call.args,
132                &field_resolver,
133                options_type.as_deref(),
134                enum_fields,
135                handle_config_type.as_deref(),
136                client_factory,
137                bigint_fields,
138                e2e_config,
139            );
140            files.push(GeneratedFile {
141                path: tests_base.join(filename),
142                content,
143                generated_header: true,
144            });
145        }
146
147        Ok(files)
148    }
149
150    fn language_name(&self) -> &'static str {
151        "wasm"
152    }
153}
154
155fn render_package_json(
156    pkg_name: &str,
157    pkg_path: &str,
158    pkg_version: &str,
159    dep_mode: crate::config::DependencyMode,
160) -> String {
161    let dep_value = match dep_mode {
162        crate::config::DependencyMode::Registry => pkg_version.to_string(),
163        crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
164    };
165    format!(
166        r#"{{
167  "name": "{pkg_name}-e2e-wasm",
168  "version": "0.1.0",
169  "private": true,
170  "type": "module",
171  "scripts": {{
172    "test": "vitest run"
173  }},
174  "devDependencies": {{
175    "{pkg_name}": "{dep_value}",
176    "vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
177    "vite-plugin-wasm": "{vite_plugin_wasm}",
178    "vitest": "{vitest}"
179  }}
180}}
181"#,
182        vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
183        vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
184        vitest = tv::npm::VITEST,
185    )
186}
187
188fn render_vitest_config() -> String {
189    let header = hash::header(CommentStyle::DoubleSlash);
190    format!(
191        r#"{header}import {{ defineConfig }} from 'vitest/config';
192import wasm from 'vite-plugin-wasm';
193import topLevelAwait from 'vite-plugin-top-level-await';
194
195export default defineConfig({{
196  plugins: [wasm(), topLevelAwait()],
197  test: {{
198    include: ['tests/**/*.test.ts'],
199    globalSetup: './globalSetup.ts',
200  }},
201}});
202"#
203    )
204}
205
206fn render_global_setup() -> String {
207    let header = hash::header(CommentStyle::DoubleSlash);
208    format!(
209        r#"{header}import {{ spawn }} from 'child_process';
210import {{ resolve }} from 'path';
211
212let serverProcess;
213
214export async function setup() {{
215  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
216  serverProcess = spawn(
217    resolve(__dirname, '../rust/target/release/mock-server'),
218    [resolve(__dirname, '../../fixtures')],
219    {{ stdio: ['pipe', 'pipe', 'inherit'] }}
220  );
221
222  const url = await new Promise((resolve, reject) => {{
223    serverProcess.stdout.on('data', (data) => {{
224      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
225      if (match) resolve(match[1].trim());
226    }});
227    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
228  }});
229
230  process.env.MOCK_SERVER_URL = url;
231}}
232
233export async function teardown() {{
234  if (serverProcess) {{
235    serverProcess.stdin.end();
236    serverProcess.kill();
237  }}
238}}
239"#
240    )
241}
242
243fn render_tsconfig() -> String {
244    r#"{
245  "compilerOptions": {
246    "target": "ES2022",
247    "module": "ESNext",
248    "moduleResolution": "bundler",
249    "strict": true,
250    "strictNullChecks": false,
251    "esModuleInterop": true,
252    "skipLibCheck": true
253  },
254  "include": ["tests/**/*.ts", "vitest.config.ts"]
255}
256"#
257    .to_string()
258}
259
260#[allow(clippy::too_many_arguments)]
261fn render_test_file(
262    category: &str,
263    fixtures: &[&Fixture],
264    pkg_name: &str,
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    handle_config_type: Option<&str>,
273    client_factory: Option<&str>,
274    bigint_fields: &[String],
275    e2e_config: &E2eConfig,
276) -> String {
277    let mut out = String::new();
278    out.push_str(&hash::header(CommentStyle::DoubleSlash));
279    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
280
281    // Check if any fixture uses a json_object arg that needs the options type import.
282    let needs_options_import = options_type.is_some()
283        && fixtures.iter().any(|f| {
284            args.iter().any(|arg| {
285                if arg.arg_type != "json_object" {
286                    return false;
287                }
288                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
289                let val = if field == "input" {
290                    Some(&f.input)
291                } else {
292                    f.input.get(field)
293                };
294                val.is_some_and(|v| !v.is_null())
295            })
296        });
297
298    // Collect all enum types that need to be imported.
299    let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
300    if needs_options_import {
301        for fixture in fixtures {
302            for arg in args {
303                if arg.arg_type == "json_object" {
304                    let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
305                    let val = if field == "input" {
306                        Some(&fixture.input)
307                    } else {
308                        fixture.input.get(field)
309                    };
310                    if let Some(val) = val {
311                        if let Some(obj) = val.as_object() {
312                            for k in obj.keys() {
313                                if let Some(enum_type) = enum_fields.get(k) {
314                                    enum_imports.insert(enum_type);
315                                }
316                            }
317                        }
318                    }
319                }
320            }
321        }
322    }
323
324    // Collect handle constructor imports.
325    let handle_constructors: Vec<String> = args
326        .iter()
327        .filter(|arg| arg.arg_type == "handle")
328        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
329        .collect();
330
331    {
332        let mut imports: Vec<String> = if client_factory.is_some() {
333            // When using client_factory, import the factory instead of the function
334            vec![]
335        } else {
336            vec![function_name.to_string()]
337        };
338
339        // Also import any additional function names used by per-fixture call overrides.
340        for fixture in fixtures {
341            if fixture.call.is_some() {
342                let call_config = e2e_config.resolve_call(fixture.call.as_deref());
343                let fixture_fn = resolve_wasm_function_name(call_config);
344                if client_factory.is_none() && !imports.contains(&fixture_fn) {
345                    imports.push(fixture_fn);
346                }
347            }
348        }
349
350        // Collect tree helper function names needed by method_result assertions.
351        for fixture in fixtures {
352            for assertion in &fixture.assertions {
353                if assertion.assertion_type == "method_result" {
354                    if let Some(method_name) = &assertion.method {
355                        if let Some(helper_fn) = wasm_method_helper_import(method_name) {
356                            if !imports.contains(&helper_fn) {
357                                imports.push(helper_fn);
358                            }
359                        }
360                    }
361                }
362            }
363        }
364
365        if let Some(factory) = client_factory {
366            let camel = factory.to_lower_camel_case();
367            if !imports.contains(&camel) {
368                imports.push(camel);
369            }
370        }
371        imports.extend(handle_constructors);
372        if let (true, Some(opts_type)) = (needs_options_import, options_type) {
373            imports.push(opts_type.to_string());
374            imports.extend(enum_imports.iter().map(|s| s.to_string()));
375        }
376        // Import the handle config class when configured.
377        if let Some(hct) = handle_config_type {
378            if !imports.contains(&hct.to_string()) {
379                imports.push(hct.to_string());
380            }
381        }
382        let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
383    }
384    let _ = writeln!(out);
385    let _ = writeln!(out, "describe('{category}', () => {{");
386
387    for (i, fixture) in fixtures.iter().enumerate() {
388        render_test_case(
389            &mut out,
390            fixture,
391            field_resolver,
392            options_type,
393            enum_fields,
394            handle_config_type,
395            client_factory,
396            bigint_fields,
397            e2e_config,
398        );
399        if i + 1 < fixtures.len() {
400            let _ = writeln!(out);
401        }
402    }
403
404    let _ = writeln!(out, "}});");
405    out
406}
407
408/// Resolve the function name for a call config, applying wasm-specific overrides.
409fn resolve_wasm_function_name(call_config: &crate::config::CallConfig) -> String {
410    call_config
411        .overrides
412        .get("wasm")
413        .and_then(|o| o.function.clone())
414        .unwrap_or_else(|| call_config.function.clone())
415}
416
417/// Return the package-level helper function name to import for a method_result method,
418/// or `None` if the method maps to a property access (no import needed).
419fn wasm_method_helper_import(method_name: &str) -> Option<String> {
420    match method_name {
421        "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
422        "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
423        "tree_to_sexp" => Some("treeToSexp".to_string()),
424        "contains_node_type" => Some("treeContainsNodeType".to_string()),
425        "find_nodes_by_type" => Some("findNodesByType".to_string()),
426        "run_query" => Some("runQuery".to_string()),
427        // Property accesses (root_child_count, root_node_type, named_children_count)
428        // and unknown methods that become `result.method()` don't need extra imports.
429        _ => None,
430    }
431}
432
433#[allow(clippy::too_many_arguments)]
434fn render_test_case(
435    out: &mut String,
436    fixture: &Fixture,
437    field_resolver: &FieldResolver,
438    options_type: Option<&str>,
439    enum_fields: &HashMap<String, String>,
440    handle_config_type: Option<&str>,
441    client_factory: Option<&str>,
442    bigint_fields: &[String],
443    e2e_config: &E2eConfig,
444) {
445    // Resolve per-fixture call config (supports `"call": "parse"` overrides in fixtures).
446    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
447    let resolved_function_name = resolve_wasm_function_name(call_config);
448    let function_name = resolved_function_name.as_str();
449    let resolved_result_var = call_config.result_var.clone();
450    let result_var = resolved_result_var.as_str();
451    let is_async = call_config.r#async;
452    let args = &call_config.args;
453
454    let test_name = sanitize_ident(&fixture.id);
455    let description = fixture.description.replace('\'', "\\'");
456    let async_kw = if is_async { "async " } else { "" };
457    let await_kw = if is_async { "await " } else { "" };
458
459    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
460    let (mut setup_lines, arg_parts) = build_args_and_setup(
461        &fixture.input,
462        args,
463        options_type,
464        enum_fields,
465        &fixture.id,
466        handle_config_type,
467        bigint_fields,
468    );
469    let args_str = arg_parts.join(", ");
470
471    // Build visitor if present and add to setup
472    let mut visitor_arg = String::new();
473    if let Some(visitor_spec) = &fixture.visitor {
474        visitor_arg = build_wasm_visitor(&mut setup_lines, visitor_spec);
475    }
476
477    let final_args = if visitor_arg.is_empty() {
478        args_str
479    } else if args_str.is_empty() {
480        format!("{{ visitor: {visitor_arg} }}")
481    } else {
482        format!("{args_str}, {{ visitor: {visitor_arg} }}")
483    };
484
485    // Build the call expression — either `client.method(args)` or `method(args)`
486    let call_expr = if client_factory.is_some() {
487        format!("client.{function_name}({final_args})")
488    } else {
489        format!("{function_name}({final_args})")
490    };
491
492    // Check if any arg is a base_url to determine if we need fixture path
493    let has_base_url_arg = args.iter().any(|arg| arg.arg_type == "base_url");
494    let base_url_expr = if has_base_url_arg {
495        format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id)
496    } else {
497        "process.env.MOCK_SERVER_URL".to_string()
498    };
499
500    if expects_error {
501        let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
502        if let Some(factory) = client_factory {
503            let factory_camel = factory.to_lower_camel_case();
504            let _ = writeln!(
505                out,
506                "    const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
507            );
508        }
509        for line in &setup_lines {
510            let _ = writeln!(out, "    {line}");
511        }
512        if is_async {
513            let _ = writeln!(
514                out,
515                "    await expect({async_kw}() => {await_kw}{call_expr}).rejects.toThrow();"
516            );
517        } else {
518            let _ = writeln!(out, "    expect(() => {call_expr}).toThrow();");
519        }
520        let _ = writeln!(out, "  }});");
521        return;
522    }
523
524    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
525    if let Some(factory) = client_factory {
526        let factory_camel = factory.to_lower_camel_case();
527        let _ = writeln!(
528            out,
529            "    const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
530        );
531    }
532    for line in &setup_lines {
533        let _ = writeln!(out, "    {line}");
534    }
535
536    let has_usable_assertion = fixture.assertions.iter().any(|a| {
537        if a.assertion_type == "not_error" || a.assertion_type == "error" {
538            return false;
539        }
540        match &a.field {
541            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
542            _ => true,
543        }
544    });
545
546    if has_usable_assertion {
547        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
548    } else {
549        let _ = writeln!(out, "    {await_kw}{call_expr};");
550    }
551
552    for assertion in &fixture.assertions {
553        render_assertion(out, assertion, result_var, field_resolver);
554    }
555
556    let _ = writeln!(out, "  }});");
557}
558
559/// Build setup lines and argument parts for a function call.
560///
561/// Returns `(setup_lines, args_parts)`. Setup lines are emitted before the
562/// function call; args parts are joined with `, ` to form the argument list.
563fn build_args_and_setup(
564    input: &serde_json::Value,
565    args: &[crate::config::ArgMapping],
566    options_type: Option<&str>,
567    enum_fields: &HashMap<String, String>,
568    fixture_id: &str,
569    handle_config_type: Option<&str>,
570    bigint_fields: &[String],
571) -> (Vec<String>, Vec<String>) {
572    let mut setup_lines = Vec::new();
573    let mut parts = Vec::new();
574
575    if args.is_empty() {
576        parts.push(json_to_js(input));
577        return (setup_lines, parts);
578    }
579
580    for arg in args {
581        if arg.arg_type == "mock_url" {
582            setup_lines.push(format!(
583                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
584                arg.name,
585            ));
586            parts.push(arg.name.clone());
587            continue;
588        }
589
590        if arg.arg_type == "base_url" {
591            // When mock server is in use, set base_url to include the fixture path
592            // so that client requests like /v1/chat/completions become
593            // /fixtures/{fixture_id}/v1/chat/completions which match the prefix
594            setup_lines.push(format!(
595                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
596                arg.name,
597            ));
598            parts.push(arg.name.clone());
599            continue;
600        }
601
602        if arg.arg_type == "handle" {
603            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
604            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
605            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
606            if config_value.is_null()
607                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
608            {
609                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
610            } else if let (Some(hct), Some(obj)) = (handle_config_type, config_value.as_object()) {
611                // WASM bindings use _assertClass validation, so we must construct
612                // a proper class instance instead of passing a plain JS object.
613                let config_var = format!("{}Config", arg.name);
614                setup_lines.push(format!("const {config_var} = new {hct}();"));
615                for (k, field_val) in obj {
616                    let camel_key = k.to_lower_camel_case();
617                    let js_val = json_to_js(field_val);
618                    setup_lines.push(format!("{config_var}.{camel_key} = {js_val};"));
619                }
620                setup_lines.push(format!("const {} = {constructor_name}({config_var});", arg.name));
621            } else {
622                let js_val = json_to_js(config_value);
623                setup_lines.push(format!("const {} = {constructor_name}({js_val});", arg.name));
624            }
625            parts.push(arg.name.clone());
626            continue;
627        }
628
629        // When field == "input", the entire input object IS the value (not a nested key)
630        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
631        let val = if field == "input" {
632            Some(input)
633        } else {
634            input.get(field)
635        };
636        match val {
637            None | Some(serde_json::Value::Null) if arg.optional => continue,
638            None | Some(serde_json::Value::Null) => {
639                let default_val = match arg.arg_type.as_str() {
640                    "string" => "''".to_string(),
641                    "int" | "integer" => "0".to_string(),
642                    "float" | "number" => "0.0".to_string(),
643                    "bool" | "boolean" => "false".to_string(),
644                    _ => "null".to_string(),
645                };
646                parts.push(default_val);
647            }
648            Some(v) => {
649                if arg.arg_type == "json_object" && !v.is_null() {
650                    if let Some(opts_type) = options_type {
651                        if let Some(obj) = v.as_object() {
652                            setup_lines.push(format!("const options = new {opts_type}();"));
653                            for (k, field_val) in obj {
654                                let camel_key = k.to_lower_camel_case();
655                                let js_val = if let Some(enum_type) = enum_fields.get(k) {
656                                    if let Some(s) = field_val.as_str() {
657                                        let pascal_val = s.to_upper_camel_case();
658                                        format!("{enum_type}.{pascal_val}")
659                                    } else {
660                                        json_to_js(field_val)
661                                    }
662                                } else if bigint_fields.iter().any(|f| f == &camel_key) && field_val.is_number() {
663                                    format!("BigInt({})", json_to_js(field_val))
664                                } else {
665                                    json_to_js(field_val)
666                                };
667                                setup_lines.push(format!("options.{camel_key} = {js_val};"));
668                            }
669                            parts.push("options".to_string());
670                            continue;
671                        }
672                    }
673                }
674                parts.push(json_to_js(v));
675            }
676        }
677    }
678
679    (setup_lines, parts)
680}
681
682fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
683    // Skip assertions on fields that don't exist on the result type.
684    if let Some(f) = &assertion.field {
685        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
686            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
687            return;
688        }
689    }
690
691    let field_expr = match &assertion.field {
692        Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
693        _ => result_var.to_string(),
694    };
695
696    match assertion.assertion_type.as_str() {
697        "equals" => {
698            if let Some(expected) = &assertion.value {
699                let js_val = json_to_js(expected);
700                if expected.is_string() {
701                    let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
702                } else {
703                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
704                }
705            }
706        }
707        "contains" => {
708            if let Some(expected) = &assertion.value {
709                let js_val = json_to_js(expected);
710                let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
711            }
712        }
713        "contains_all" => {
714            if let Some(values) = &assertion.values {
715                for val in values {
716                    let js_val = json_to_js(val);
717                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
718                }
719            }
720        }
721        "not_contains" => {
722            if let Some(expected) = &assertion.value {
723                let js_val = json_to_js(expected);
724                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
725            }
726        }
727        "not_empty" => {
728            let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
729        }
730        "is_empty" => {
731            let _ = writeln!(out, "    expect({field_expr}.trim()).toHaveLength(0);");
732        }
733        "contains_any" => {
734            if let Some(values) = &assertion.values {
735                let items: Vec<String> = values.iter().map(json_to_js).collect();
736                let arr_str = items.join(", ");
737                let _ = writeln!(
738                    out,
739                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
740                );
741            }
742        }
743        "greater_than" => {
744            if let Some(val) = &assertion.value {
745                let js_val = json_to_js(val);
746                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
747            }
748        }
749        "less_than" => {
750            if let Some(val) = &assertion.value {
751                let js_val = json_to_js(val);
752                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
753            }
754        }
755        "greater_than_or_equal" => {
756            if let Some(val) = &assertion.value {
757                let js_val = json_to_js(val);
758                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
759            }
760        }
761        "less_than_or_equal" => {
762            if let Some(val) = &assertion.value {
763                let js_val = json_to_js(val);
764                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
765            }
766        }
767        "starts_with" => {
768            if let Some(expected) = &assertion.value {
769                let js_val = json_to_js(expected);
770                let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
771            }
772        }
773        "count_min" => {
774            if let Some(val) = &assertion.value {
775                if let Some(n) = val.as_u64() {
776                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
777                }
778            }
779        }
780        "count_equals" => {
781            if let Some(val) = &assertion.value {
782                if let Some(n) = val.as_u64() {
783                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
784                }
785            }
786        }
787        "is_true" => {
788            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
789        }
790        "is_false" => {
791            let _ = writeln!(out, "    expect({field_expr}).toBe(false);");
792        }
793        "method_result" => {
794            if let Some(method_name) = &assertion.method {
795                let call_expr = build_wasm_method_call(result_var, method_name, assertion.args.as_ref());
796                let check = assertion.check.as_deref().unwrap_or("is_true");
797                match check {
798                    "equals" => {
799                        if let Some(val) = &assertion.value {
800                            let js_val = json_to_js(val);
801                            let _ = writeln!(out, "    expect({call_expr}).toBe({js_val});");
802                        }
803                    }
804                    "is_true" => {
805                        let _ = writeln!(out, "    expect({call_expr}).toBe(true);");
806                    }
807                    "is_false" => {
808                        let _ = writeln!(out, "    expect({call_expr}).toBe(false);");
809                    }
810                    "greater_than_or_equal" => {
811                        if let Some(val) = &assertion.value {
812                            let n = val.as_u64().unwrap_or(0);
813                            let _ = writeln!(out, "    expect({call_expr}).toBeGreaterThanOrEqual({n});");
814                        }
815                    }
816                    "count_min" => {
817                        if let Some(val) = &assertion.value {
818                            let n = val.as_u64().unwrap_or(0);
819                            let _ = writeln!(out, "    expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
820                        }
821                    }
822                    "contains" => {
823                        if let Some(val) = &assertion.value {
824                            let js_val = json_to_js(val);
825                            let _ = writeln!(out, "    expect({call_expr}).toContain({js_val});");
826                        }
827                    }
828                    "is_error" => {
829                        let _ = writeln!(out, "    expect(() => {{ {call_expr}; }}).toThrow();");
830                    }
831                    other_check => {
832                        panic!("WASM e2e generator: unsupported method_result check type: {other_check}");
833                    }
834                }
835            } else {
836                panic!("WASM e2e generator: method_result assertion missing 'method' field");
837            }
838        }
839        "min_length" => {
840            if let Some(val) = &assertion.value {
841                if let Some(n) = val.as_u64() {
842                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
843                }
844            }
845        }
846        "max_length" => {
847            if let Some(val) = &assertion.value {
848                if let Some(n) = val.as_u64() {
849                    let _ = writeln!(out, "    expect({field_expr}.length).toBeLessThanOrEqual({n});");
850                }
851            }
852        }
853        "ends_with" => {
854            if let Some(expected) = &assertion.value {
855                let js_val = json_to_js(expected);
856                let _ = writeln!(out, "    expect({field_expr}.endsWith({js_val})).toBe(true);");
857            }
858        }
859        "matches_regex" => {
860            if let Some(expected) = &assertion.value {
861                if let Some(pattern) = expected.as_str() {
862                    let _ = writeln!(out, "    expect({field_expr}).toMatch(/{pattern}/);");
863                }
864            }
865        }
866        "not_error" => {
867            // No-op — if we got here, the call succeeded.
868        }
869        "error" => {
870            // Handled at the test level.
871        }
872        other => {
873            panic!("WASM e2e generator: unsupported assertion type: {other}");
874        }
875    }
876}
877
878/// Build a WASM/JS call expression for a method_result assertion on a tree-sitter Tree.
879/// Maps method names to the appropriate JavaScript function calls or property accesses.
880fn build_wasm_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
881    match method_name {
882        "root_child_count" => format!("{result_var}.rootNode.childCount"),
883        "root_node_type" => format!("{result_var}.rootNode.type"),
884        "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
885        "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
886        "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
887        "tree_to_sexp" => format!("treeToSexp({result_var})"),
888        "contains_node_type" => {
889            let node_type = args
890                .and_then(|a| a.get("node_type"))
891                .and_then(|v| v.as_str())
892                .unwrap_or("");
893            format!("treeContainsNodeType({result_var}, \"{node_type}\")")
894        }
895        "find_nodes_by_type" => {
896            let node_type = args
897                .and_then(|a| a.get("node_type"))
898                .and_then(|v| v.as_str())
899                .unwrap_or("");
900            format!("findNodesByType({result_var}, \"{node_type}\")")
901        }
902        "run_query" => {
903            let query_source = args
904                .and_then(|a| a.get("query_source"))
905                .and_then(|v| v.as_str())
906                .unwrap_or("");
907            let language = args
908                .and_then(|a| a.get("language"))
909                .and_then(|v| v.as_str())
910                .unwrap_or("");
911            format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
912        }
913        other => {
914            let camel_method = other.to_lower_camel_case();
915            if let Some(args_val) = args {
916                let arg_str = args_val
917                    .as_object()
918                    .map(|obj| {
919                        obj.iter()
920                            .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
921                            .collect::<Vec<_>>()
922                            .join(", ")
923                    })
924                    .unwrap_or_default();
925                format!("{result_var}.{camel_method}({arg_str})")
926            } else {
927                format!("{result_var}.{camel_method}()")
928            }
929        }
930    }
931}
932
933/// Convert a `serde_json::Value` to a JavaScript literal string.
934fn json_to_js(value: &serde_json::Value) -> String {
935    match value {
936        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
937        serde_json::Value::Bool(b) => b.to_string(),
938        serde_json::Value::Number(n) => n.to_string(),
939        serde_json::Value::Null => "null".to_string(),
940        serde_json::Value::Array(arr) => {
941            let items: Vec<String> = arr.iter().map(json_to_js).collect();
942            format!("[{}]", items.join(", "))
943        }
944        serde_json::Value::Object(map) => {
945            let entries: Vec<String> = map
946                .iter()
947                .map(|(k, v)| {
948                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
949                        && !k.starts_with(|c: char| c.is_ascii_digit())
950                    {
951                        k.clone()
952                    } else {
953                        format!("\"{}\"", escape_js(k))
954                    };
955                    format!("{key}: {}", json_to_js(v))
956                })
957                .collect();
958            format!("{{ {} }}", entries.join(", "))
959        }
960    }
961}
962
963/// Build a WASM/JS visitor object and add setup line. Returns the visitor variable name.
964fn build_wasm_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
965    use std::fmt::Write as FmtWrite;
966    let mut visitor_obj = String::new();
967    let _ = writeln!(visitor_obj, "{{");
968    for (method_name, action) in &visitor_spec.callbacks {
969        emit_wasm_visitor_method(&mut visitor_obj, method_name, action);
970    }
971    let _ = writeln!(visitor_obj, "    }}");
972
973    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
974    "_testVisitor".to_string()
975}
976
977/// Emit a WASM/JS visitor method for a callback action.
978fn emit_wasm_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
979    use std::fmt::Write as FmtWrite;
980
981    let camel_method = to_camel_case_wasm(method_name);
982    let params = match method_name {
983        "visit_link" => "ctx, href, text, title",
984        "visit_image" => "ctx, src, alt, title",
985        "visit_heading" => "ctx, level, text, id",
986        "visit_code_block" => "ctx, lang, code",
987        "visit_code_inline"
988        | "visit_strong"
989        | "visit_emphasis"
990        | "visit_strikethrough"
991        | "visit_underline"
992        | "visit_subscript"
993        | "visit_superscript"
994        | "visit_mark"
995        | "visit_button"
996        | "visit_summary"
997        | "visit_figcaption"
998        | "visit_definition_term"
999        | "visit_definition_description" => "ctx, text",
1000        "visit_text" => "ctx, text",
1001        "visit_list_item" => "ctx, ordered, marker, text",
1002        "visit_blockquote" => "ctx, content, depth",
1003        "visit_table_row" => "ctx, cells, isHeader",
1004        "visit_custom_element" => "ctx, tagName, html",
1005        "visit_form" => "ctx, actionUrl, method",
1006        "visit_input" => "ctx, inputType, name, value",
1007        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1008        "visit_details" => "ctx, isOpen",
1009        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1010        "visit_list_start" => "ctx, ordered",
1011        "visit_list_end" => "ctx, ordered, output",
1012        _ => "ctx",
1013    };
1014
1015    let _ = writeln!(out, "    {camel_method}({params}): string | {{ custom: string }} {{");
1016    match action {
1017        CallbackAction::Skip => {
1018            let _ = writeln!(out, "        return \"skip\";");
1019        }
1020        CallbackAction::Continue => {
1021            let _ = writeln!(out, "        return \"continue\";");
1022        }
1023        CallbackAction::PreserveHtml => {
1024            let _ = writeln!(out, "        return \"preserve_html\";");
1025        }
1026        CallbackAction::Custom { output } => {
1027            let escaped = escape_js(output);
1028            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
1029        }
1030        CallbackAction::CustomTemplate { template } => {
1031            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
1032        }
1033    }
1034    let _ = writeln!(out, "    }},");
1035}
1036
1037/// Convert snake_case to camelCase for method names.
1038fn to_camel_case_wasm(snake: &str) -> String {
1039    use heck::ToLowerCamelCase;
1040    snake.to_lower_camel_case()
1041}