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