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