Skip to main content

alef_e2e/codegen/
typescript.rs

1//! TypeScript e2e test generator using vitest.
2
3use crate::config::E2eConfig;
4use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToUpperCamelCase;
11use std::fmt::Write as FmtWrite;
12use std::path::PathBuf;
13
14use super::E2eCodegen;
15
16/// TypeScript e2e code generator.
17pub struct TypeScriptCodegen;
18
19impl E2eCodegen for TypeScriptCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        _alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
27        let tests_base = output_base.join("tests");
28
29        let mut files = Vec::new();
30
31        // Resolve call config with overrides — use "node" key (Language::Node).
32        let call = &e2e_config.call;
33        let overrides = call.overrides.get("node");
34        let module_path = overrides
35            .and_then(|o| o.module.as_ref())
36            .cloned()
37            .unwrap_or_else(|| call.module.clone());
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.clone());
42        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
43
44        // Resolve package config.
45        let node_pkg = e2e_config.resolve_package("node");
46        let pkg_path = node_pkg
47            .as_ref()
48            .and_then(|p| p.path.as_ref())
49            .cloned()
50            .unwrap_or_else(|| "../../packages/typescript".to_string());
51        let pkg_name = node_pkg
52            .as_ref()
53            .and_then(|p| p.name.as_ref())
54            .cloned()
55            .unwrap_or_else(|| module_path.clone());
56        let pkg_version = node_pkg
57            .as_ref()
58            .and_then(|p| p.version.as_ref())
59            .cloned()
60            .unwrap_or_else(|| "0.1.0".to_string());
61
62        // Determine whether any group has HTTP server test fixtures.
63        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
64
65        // Generate package.json.
66        files.push(GeneratedFile {
67            path: output_base.join("package.json"),
68            content: render_package_json(
69                &pkg_name,
70                &pkg_path,
71                &pkg_version,
72                e2e_config.dep_mode,
73                has_http_fixtures,
74            ),
75            generated_header: false,
76        });
77
78        // Generate tsconfig.json.
79        files.push(GeneratedFile {
80            path: output_base.join("tsconfig.json"),
81            content: render_tsconfig(),
82            generated_header: false,
83        });
84
85        // Generate vitest.config.ts — include globalSetup when client_factory is used.
86        files.push(GeneratedFile {
87            path: output_base.join("vitest.config.ts"),
88            content: render_vitest_config(client_factory.is_some()),
89            generated_header: true,
90        });
91
92        // Generate globalSetup.ts when client_factory is used (needs mock server).
93        if client_factory.is_some() {
94            files.push(GeneratedFile {
95                path: output_base.join("globalSetup.ts"),
96                content: render_global_setup(),
97                generated_header: true,
98            });
99        }
100
101        // Resolve options_type from override.
102        let options_type = overrides.and_then(|o| o.options_type.clone());
103        let field_resolver = FieldResolver::new(
104            &e2e_config.fields,
105            &e2e_config.fields_optional,
106            &e2e_config.result_fields,
107            &e2e_config.fields_array,
108        );
109
110        // Generate test files per category.
111        for group in groups {
112            let active: Vec<&Fixture> = group
113                .fixtures
114                .iter()
115                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip("node")))
116                .collect();
117
118            if active.is_empty() {
119                continue;
120            }
121
122            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
123            let content = render_test_file(
124                &group.category,
125                &active,
126                &module_path,
127                &pkg_name,
128                &function_name,
129                &e2e_config.call.args,
130                options_type.as_deref(),
131                &field_resolver,
132                client_factory,
133                e2e_config,
134            );
135            files.push(GeneratedFile {
136                path: tests_base.join(filename),
137                content,
138                generated_header: true,
139            });
140        }
141
142        Ok(files)
143    }
144
145    fn language_name(&self) -> &'static str {
146        "node"
147    }
148}
149
150fn render_package_json(
151    pkg_name: &str,
152    _pkg_path: &str,
153    pkg_version: &str,
154    dep_mode: crate::config::DependencyMode,
155    has_http_fixtures: bool,
156) -> String {
157    let dep_value = match dep_mode {
158        crate::config::DependencyMode::Registry => pkg_version.to_string(),
159        crate::config::DependencyMode::Local => "workspace:*".to_string(),
160    };
161    let _ = has_http_fixtures; // TODO: add HTTP test deps when http fixtures are present
162    format!(
163        r#"{{
164  "name": "{pkg_name}-e2e-typescript",
165  "version": "0.1.0",
166  "private": true,
167  "type": "module",
168  "scripts": {{
169    "test": "vitest run"
170  }},
171  "devDependencies": {{
172    "{pkg_name}": "{dep_value}",
173    "vitest": "^3.0.0"
174  }}
175}}
176"#
177    )
178}
179
180fn render_tsconfig() -> String {
181    r#"{
182  "compilerOptions": {
183    "target": "ES2022",
184    "module": "ESNext",
185    "moduleResolution": "bundler",
186    "strict": true,
187    "strictNullChecks": false,
188    "esModuleInterop": true,
189    "skipLibCheck": true
190  },
191  "include": ["tests/**/*.ts", "vitest.config.ts"]
192}
193"#
194    .to_string()
195}
196
197fn render_vitest_config(with_global_setup: bool) -> String {
198    if with_global_setup {
199        r#"// This file is auto-generated by alef. DO NOT EDIT.
200import { defineConfig } from 'vitest/config';
201
202export default defineConfig({
203  test: {
204    include: ['tests/**/*.test.ts'],
205    globalSetup: './globalSetup.ts',
206  },
207});
208"#
209        .to_string()
210    } else {
211        r#"// This file is auto-generated by alef. DO NOT EDIT.
212import { defineConfig } from 'vitest/config';
213
214export default defineConfig({
215  test: {
216    include: ['tests/**/*.test.ts'],
217  },
218});
219"#
220        .to_string()
221    }
222}
223
224fn render_global_setup() -> String {
225    r#"// This file is auto-generated by alef. DO NOT EDIT.
226import { spawn } from 'child_process';
227import { resolve } from 'path';
228
229let serverProcess;
230
231export async function setup() {
232  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
233  serverProcess = spawn(
234    resolve(__dirname, '../rust/target/release/mock-server'),
235    [resolve(__dirname, '../../fixtures')],
236    { stdio: ['pipe', 'pipe', 'inherit'] }
237  );
238
239  const url = await new Promise((resolve, reject) => {
240    serverProcess.stdout.on('data', (data) => {
241      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
242      if (match) resolve(match[1].trim());
243    });
244    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
245  });
246
247  process.env.MOCK_SERVER_URL = url;
248}
249
250export async function teardown() {
251  if (serverProcess) {
252    serverProcess.stdin.end();
253    serverProcess.kill();
254  }
255}
256"#
257    .to_string()
258}
259
260#[allow(clippy::too_many_arguments)]
261fn render_test_file(
262    category: &str,
263    fixtures: &[&Fixture],
264    module_path: &str,
265    pkg_name: &str,
266    function_name: &str,
267    args: &[crate::config::ArgMapping],
268    options_type: Option<&str>,
269    field_resolver: &FieldResolver,
270    client_factory: Option<&str>,
271    e2e_config: &E2eConfig,
272) -> String {
273    let mut out = String::new();
274    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
275    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
276
277    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
278    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
279
280    // Check if any fixture uses a json_object arg that needs the options type import.
281    let needs_options_import = options_type.is_some()
282        && fixtures.iter().any(|f| {
283            args.iter().any(|arg| {
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                arg.arg_type == "json_object" && val.is_some_and(|v| !v.is_null())
291            })
292        });
293
294    // Collect handle constructor function names that need to be imported.
295    let handle_constructors: Vec<String> = args
296        .iter()
297        .filter(|arg| arg.arg_type == "handle")
298        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
299        .collect();
300
301    // Build imports for non-HTTP fixtures.
302    if has_non_http_fixtures {
303        // When using client_factory, import the factory instead of the function.
304        let mut imports: Vec<String> = if let Some(factory) = client_factory {
305            vec![factory.to_string()]
306        } else {
307            vec![function_name.to_string()]
308        };
309
310        // Also import any additional function names used by per-fixture call overrides.
311        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
312            if fixture.call.is_some() {
313                let call_config = e2e_config.resolve_call(fixture.call.as_deref());
314                let fixture_fn = resolve_node_function_name(call_config);
315                if client_factory.is_none() && !imports.contains(&fixture_fn) {
316                    imports.push(fixture_fn);
317                }
318            }
319        }
320
321        // Collect tree helper function names needed by method_result assertions.
322        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
323            for assertion in &fixture.assertions {
324                if assertion.assertion_type == "method_result" {
325                    if let Some(method_name) = &assertion.method {
326                        let helper = ts_method_helper_import(method_name);
327                        if let Some(helper_fn) = helper {
328                            if !imports.contains(&helper_fn) {
329                                imports.push(helper_fn);
330                            }
331                        }
332                    }
333                }
334            }
335        }
336
337        for ctor in &handle_constructors {
338            if !imports.contains(ctor) {
339                imports.push(ctor.clone());
340            }
341        }
342
343        // Use pkg_name (the npm package name, e.g. "@kreuzberg/liter-llm") for
344        // the import specifier so that registry builds resolve the published package name.
345        let _ = module_path; // retained in signature for potential future use
346        if let (true, Some(opts_type)) = (needs_options_import, options_type) {
347            imports.push(format!("type {opts_type}"));
348            let imports_str = imports.join(", ");
349            let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
350        } else {
351            let imports_str = imports.join(", ");
352            let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
353        }
354    }
355
356    let _ = writeln!(out);
357    let _ = writeln!(out, "describe('{category}', () => {{");
358
359    for (i, fixture) in fixtures.iter().enumerate() {
360        if fixture.is_http_test() {
361            render_http_test_case(&mut out, fixture);
362        } else {
363            render_test_case(
364                &mut out,
365                fixture,
366                client_factory,
367                options_type,
368                field_resolver,
369                e2e_config,
370            );
371        }
372        if i + 1 < fixtures.len() {
373            let _ = writeln!(out);
374        }
375    }
376
377    // Suppress unused variable warning when file has only HTTP fixtures.
378    let _ = has_http_fixtures;
379
380    let _ = writeln!(out, "}});");
381    out
382}
383
384/// Resolve the function name for a call config, applying node-specific overrides.
385fn resolve_node_function_name(call_config: &crate::config::CallConfig) -> String {
386    call_config
387        .overrides
388        .get("node")
389        .and_then(|o| o.function.clone())
390        .unwrap_or_else(|| call_config.function.clone())
391}
392
393/// Return the package-level helper function name to import for a method_result method,
394/// or `None` if the method maps to a property access (no import needed).
395fn ts_method_helper_import(method_name: &str) -> Option<String> {
396    match method_name {
397        "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
398        "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
399        "tree_to_sexp" => Some("treeToSexp".to_string()),
400        "contains_node_type" => Some("treeContainsNodeType".to_string()),
401        "find_nodes_by_type" => Some("findNodesByType".to_string()),
402        "run_query" => Some("runQuery".to_string()),
403        // Property accesses (root_child_count, root_node_type, named_children_count)
404        // and unknown methods that become `result.method()` don't need extra imports.
405        _ => None,
406    }
407}
408
409// ---------------------------------------------------------------------------
410// HTTP server test rendering
411// ---------------------------------------------------------------------------
412
413/// Render a vitest `it` block for an HTTP server fixture.
414///
415/// The generated test uses the Hono/Fastify app's `.request()` method (or equivalent
416/// test helper) available as `app` from a module-level `beforeAll` setup. For now
417/// the test body stubs the `app` reference — callers that integrate this into a real
418/// framework supply the appropriate setup.
419fn render_http_test_case(out: &mut String, fixture: &Fixture) {
420    let Some(http) = &fixture.http else {
421        return;
422    };
423
424    let test_name = sanitize_ident(&fixture.id);
425    let description = fixture.description.replace('\'', "\\'");
426
427    let method = http.request.method.to_uppercase();
428    let path = &http.request.path;
429
430    // Build the init object for `app.request(path, init)`.
431    let mut init_entries: Vec<String> = Vec::new();
432    init_entries.push(format!("method: '{method}'"));
433
434    // Headers
435    if !http.request.headers.is_empty() {
436        let entries: Vec<String> = http
437            .request
438            .headers
439            .iter()
440            .map(|(k, v)| format!("      '{}': '{}'", escape_js(k), escape_js(v)))
441            .collect();
442        init_entries.push(format!("headers: {{\n{},\n    }}", entries.join(",\n")));
443    }
444
445    // Body
446    if let Some(body) = &http.request.body {
447        let js_body = json_to_js(body);
448        init_entries.push(format!("body: JSON.stringify({js_body})"));
449    }
450
451    let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
452
453    // Build query string if query params present.
454    let path_expr = if http.request.query_params.is_empty() {
455        format!("'{}'", escape_js(path))
456    } else {
457        let params: Vec<String> = http
458            .request
459            .query_params
460            .iter()
461            .map(|(k, v)| format!("{}={}", escape_js(k), escape_js(&json_value_to_query_string(v))))
462            .collect();
463        let qs = params.join("&");
464        format!("'{}?{}'", escape_js(path), qs)
465    };
466
467    let init_str = init_entries.join(", ");
468    let _ = writeln!(
469        out,
470        "    const response = await app.request({path_expr}, {{ {init_str} }});"
471    );
472
473    // Status code assertion.
474    let status = http.expected_response.status_code;
475    let _ = writeln!(out, "    expect(response.status).toBe({status});");
476
477    // Body assertions.
478    if let Some(expected_body) = &http.expected_response.body {
479        let js_val = json_to_js(expected_body);
480        let _ = writeln!(out, "    const data = await response.json();");
481        let _ = writeln!(out, "    expect(data).toEqual({js_val});");
482    } else if let Some(partial) = &http.expected_response.body_partial {
483        let _ = writeln!(out, "    const data = await response.json();");
484        if let Some(obj) = partial.as_object() {
485            for (key, val) in obj {
486                let js_key = escape_js(key);
487                let js_val = json_to_js(val);
488                let _ = writeln!(
489                    out,
490                    "    expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
491                );
492            }
493        }
494    }
495
496    // Header assertions.
497    for (header_name, header_value) in &http.expected_response.headers {
498        let lower_name = header_name.to_lowercase();
499        let escaped_name = escape_js(&lower_name);
500        match header_value.as_str() {
501            "<<present>>" => {
502                let _ = writeln!(
503                    out,
504                    "    expect(response.headers.get('{escaped_name}')).not.toBeNull();"
505                );
506            }
507            "<<absent>>" => {
508                let _ = writeln!(out, "    expect(response.headers.get('{escaped_name}')).toBeNull();");
509            }
510            "<<uuid>>" => {
511                let _ = writeln!(
512                    out,
513                    "    expect(response.headers.get('{escaped_name}')).toMatch(/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/);"
514                );
515            }
516            exact => {
517                let escaped_val = escape_js(exact);
518                let _ = writeln!(
519                    out,
520                    "    expect(response.headers.get('{escaped_name}')).toBe('{escaped_val}');"
521                );
522            }
523        }
524    }
525
526    // Validation error assertions.
527    if let Some(validation_errors) = &http.expected_response.validation_errors {
528        if !validation_errors.is_empty() {
529            let _ = writeln!(
530                out,
531                "    const body = await response.json() as {{ detail?: unknown[] }};"
532            );
533            let _ = writeln!(out, "    const errors = body.detail ?? [];");
534            for ve in validation_errors {
535                let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("'{}'", escape_js(s))).collect();
536                let loc_str = loc_js.join(", ");
537                let escaped_msg = escape_js(&ve.msg);
538                let _ = writeln!(
539                    out,
540                    "    expect((errors as Array<Record<string, unknown>>).some((e) => JSON.stringify(e['loc']) === JSON.stringify([{loc_str}]) && String(e['msg']).includes('{escaped_msg}'))).toBe(true);"
541                );
542            }
543        }
544    }
545
546    let _ = writeln!(out, "  }});");
547}
548
549/// Serialize a JSON value to a plain string for use in a URL query string.
550fn json_value_to_query_string(value: &serde_json::Value) -> String {
551    match value {
552        serde_json::Value::String(s) => s.clone(),
553        serde_json::Value::Bool(b) => b.to_string(),
554        serde_json::Value::Number(n) => n.to_string(),
555        serde_json::Value::Null => String::new(),
556        other => other.to_string(),
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Function-call test rendering
562// ---------------------------------------------------------------------------
563
564#[allow(clippy::too_many_arguments)]
565fn render_test_case(
566    out: &mut String,
567    fixture: &Fixture,
568    client_factory: Option<&str>,
569    options_type: Option<&str>,
570    field_resolver: &FieldResolver,
571    e2e_config: &E2eConfig,
572) {
573    // Resolve per-fixture call config (supports `"call": "parse"` overrides in fixtures).
574    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
575    let function_name = resolve_node_function_name(call_config);
576    let result_var = &call_config.result_var;
577    let is_async = call_config.r#async;
578    let args = &call_config.args;
579
580    let test_name = sanitize_ident(&fixture.id);
581    let description = fixture.description.replace('\'', "\\'");
582    let async_kw = if is_async { "async " } else { "" };
583    let await_kw = if is_async { "await " } else { "" };
584
585    // Build the call expression — either `client.method(args)` or `method(args)`
586    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
587
588    // Build visitor if present and add to setup
589    let mut visitor_arg = String::new();
590    if let Some(visitor_spec) = &fixture.visitor {
591        visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
592    }
593
594    let final_args = if visitor_arg.is_empty() {
595        args_str
596    } else if args_str.is_empty() {
597        format!("{{ visitor: {visitor_arg} }}")
598    } else {
599        format!("{args_str}, {{ visitor: {visitor_arg} }}")
600    };
601
602    let call_expr = if client_factory.is_some() {
603        format!("client.{function_name}({final_args})")
604    } else {
605        format!("{function_name}({final_args})")
606    };
607
608    // Build the base_url expression for mock server
609    let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
610
611    // Check if this is an error-expecting test.
612    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
613
614    if expects_error {
615        let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
616        if let Some(factory) = client_factory {
617            let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
618        }
619        // Wrap ALL setup lines and the function call inside the expect block so that
620        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
621        let _ = writeln!(out, "    await expect(async () => {{");
622        for line in &setup_lines {
623            let _ = writeln!(out, "      {line}");
624        }
625        let _ = writeln!(out, "      await {call_expr};");
626        let _ = writeln!(out, "    }}).rejects.toThrow();");
627        let _ = writeln!(out, "  }});");
628        return;
629    }
630
631    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
632
633    if let Some(factory) = client_factory {
634        let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
635    }
636
637    for line in &setup_lines {
638        let _ = writeln!(out, "    {line}");
639    }
640
641    // Check if any assertion actually uses the result variable.
642    let has_usable_assertion = fixture.assertions.iter().any(|a| {
643        if a.assertion_type == "not_error" || a.assertion_type == "error" {
644            return false;
645        }
646        match &a.field {
647            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
648            _ => true,
649        }
650    });
651
652    if has_usable_assertion {
653        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
654    } else {
655        let _ = writeln!(out, "    {await_kw}{call_expr};");
656    }
657
658    // Emit assertions.
659    for assertion in &fixture.assertions {
660        render_assertion(out, assertion, result_var, field_resolver);
661    }
662
663    let _ = writeln!(out, "  }});");
664}
665
666/// Build setup lines (e.g. handle creation) and the argument list for the function call.
667///
668/// Returns `(setup_lines, args_string)`.
669fn build_args_and_setup(
670    input: &serde_json::Value,
671    args: &[crate::config::ArgMapping],
672    options_type: Option<&str>,
673    fixture_id: &str,
674) -> (Vec<String>, String) {
675    if args.is_empty() {
676        // If no args mapping, pass the whole input as a single argument.
677        return (Vec::new(), json_to_js(input));
678    }
679
680    let mut setup_lines: Vec<String> = Vec::new();
681    let mut parts: Vec<String> = Vec::new();
682
683    for arg in args {
684        if arg.arg_type == "mock_url" {
685            setup_lines.push(format!(
686                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
687                arg.name,
688            ));
689            parts.push(arg.name.clone());
690            continue;
691        }
692
693        if arg.arg_type == "handle" {
694            // Generate a createEngine (or equivalent) call and pass the variable.
695            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
696            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
697            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
698            if config_value.is_null()
699                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
700            {
701                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
702            } else {
703                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
704                // config keys from the fixture JSON to camelCase before passing to the constructor.
705                let literal = json_to_js_camel(config_value);
706                setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
707                setup_lines.push(format!(
708                    "const {} = {constructor_name}({name}Config);",
709                    arg.name,
710                    name = arg.name,
711                ));
712            }
713            parts.push(arg.name.clone());
714            continue;
715        }
716
717        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
718        // When field == "input", the entire input object IS the value (not a nested key)
719        let val = if field == "input" {
720            Some(input)
721        } else {
722            input.get(field)
723        };
724        match val {
725            None | Some(serde_json::Value::Null) if arg.optional => {
726                // Optional arg with no fixture value: skip entirely.
727                continue;
728            }
729            None | Some(serde_json::Value::Null) => {
730                // Required arg with no fixture value: pass a language-appropriate default.
731                let default_val = match arg.arg_type.as_str() {
732                    "string" => "\"\"".to_string(),
733                    "int" | "integer" => "0".to_string(),
734                    "float" | "number" => "0.0".to_string(),
735                    "bool" | "boolean" => "false".to_string(),
736                    _ => "null".to_string(),
737                };
738                parts.push(default_val);
739            }
740            Some(v) => {
741                // For json_object args with options_type, cast the object literal.
742                if arg.arg_type == "json_object" {
743                    if let Some(opts_type) = options_type {
744                        parts.push(format!("{} as {opts_type}", json_to_js(v)));
745                        continue;
746                    }
747                }
748                parts.push(json_to_js(v));
749            }
750        }
751    }
752
753    (setup_lines, parts.join(", "))
754}
755
756fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
757    // Skip assertions on fields that don't exist on the result type.
758    if let Some(f) = &assertion.field {
759        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
760            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
761            return;
762        }
763    }
764
765    let field_expr = match &assertion.field {
766        Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
767        _ => result_var.to_string(),
768    };
769
770    match assertion.assertion_type.as_str() {
771        "equals" => {
772            if let Some(expected) = &assertion.value {
773                let js_val = json_to_js(expected);
774                // For string equality, trim trailing whitespace to handle trailing newlines
775                // from the converter. Use null-coalescing for optional fields.
776                if expected.is_string() {
777                    let resolved = assertion.field.as_deref().unwrap_or("");
778                    if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
779                        let _ = writeln!(out, "    expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
780                    } else {
781                        let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
782                    }
783                } else {
784                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
785                }
786            }
787        }
788        "contains" => {
789            if let Some(expected) = &assertion.value {
790                let js_val = json_to_js(expected);
791                // Use null-coalescing for optional string fields to handle null/undefined values.
792                let resolved = assertion.field.as_deref().unwrap_or("");
793                if !resolved.is_empty()
794                    && expected.is_string()
795                    && field_resolver.is_optional(field_resolver.resolve(resolved))
796                {
797                    let _ = writeln!(out, "    expect({field_expr} ?? \"\").toContain({js_val});");
798                } else {
799                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
800                }
801            }
802        }
803        "contains_all" => {
804            if let Some(values) = &assertion.values {
805                for val in values {
806                    let js_val = json_to_js(val);
807                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
808                }
809            }
810        }
811        "not_contains" => {
812            if let Some(expected) = &assertion.value {
813                let js_val = json_to_js(expected);
814                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
815            }
816        }
817        "not_empty" => {
818            // Use null-coalescing for optional fields to handle null/undefined values.
819            let resolved = assertion.field.as_deref().unwrap_or("");
820            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
821                let _ = writeln!(out, "    expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
822            } else {
823                let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
824            }
825        }
826        "is_empty" => {
827            // Use null-coalescing for optional string fields to handle null/undefined values.
828            let resolved = assertion.field.as_deref().unwrap_or("");
829            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
830                let _ = writeln!(out, "    expect({field_expr} ?? \"\").toHaveLength(0);");
831            } else {
832                let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
833            }
834        }
835        "contains_any" => {
836            if let Some(values) = &assertion.values {
837                let items: Vec<String> = values.iter().map(json_to_js).collect();
838                let arr_str = items.join(", ");
839                let _ = writeln!(
840                    out,
841                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
842                );
843            }
844        }
845        "greater_than" => {
846            if let Some(val) = &assertion.value {
847                let js_val = json_to_js(val);
848                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
849            }
850        }
851        "less_than" => {
852            if let Some(val) = &assertion.value {
853                let js_val = json_to_js(val);
854                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
855            }
856        }
857        "greater_than_or_equal" => {
858            if let Some(val) = &assertion.value {
859                let js_val = json_to_js(val);
860                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
861            }
862        }
863        "less_than_or_equal" => {
864            if let Some(val) = &assertion.value {
865                let js_val = json_to_js(val);
866                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
867            }
868        }
869        "starts_with" => {
870            if let Some(expected) = &assertion.value {
871                let js_val = json_to_js(expected);
872                // Use null-coalescing for optional fields to handle null/undefined values.
873                let resolved = assertion.field.as_deref().unwrap_or("");
874                if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
875                    let _ = writeln!(
876                        out,
877                        "    expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
878                    );
879                } else {
880                    let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
881                }
882            }
883        }
884        "count_min" => {
885            if let Some(val) = &assertion.value {
886                if let Some(n) = val.as_u64() {
887                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
888                }
889            }
890        }
891        "count_equals" => {
892            if let Some(val) = &assertion.value {
893                if let Some(n) = val.as_u64() {
894                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
895                }
896            }
897        }
898        "is_true" => {
899            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
900        }
901        "is_false" => {
902            let _ = writeln!(out, "    expect({field_expr}).toBe(false);");
903        }
904        "method_result" => {
905            if let Some(method_name) = &assertion.method {
906                let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
907                let check = assertion.check.as_deref().unwrap_or("is_true");
908                match check {
909                    "equals" => {
910                        if let Some(val) = &assertion.value {
911                            let js_val = json_to_js(val);
912                            let _ = writeln!(out, "    expect({call_expr}).toBe({js_val});");
913                        }
914                    }
915                    "is_true" => {
916                        let _ = writeln!(out, "    expect({call_expr}).toBe(true);");
917                    }
918                    "is_false" => {
919                        let _ = writeln!(out, "    expect({call_expr}).toBe(false);");
920                    }
921                    "greater_than_or_equal" => {
922                        if let Some(val) = &assertion.value {
923                            let n = val.as_u64().unwrap_or(0);
924                            let _ = writeln!(out, "    expect({call_expr}).toBeGreaterThanOrEqual({n});");
925                        }
926                    }
927                    "count_min" => {
928                        if let Some(val) = &assertion.value {
929                            let n = val.as_u64().unwrap_or(0);
930                            let _ = writeln!(out, "    expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
931                        }
932                    }
933                    "contains" => {
934                        if let Some(val) = &assertion.value {
935                            let js_val = json_to_js(val);
936                            let _ = writeln!(out, "    expect({call_expr}).toContain({js_val});");
937                        }
938                    }
939                    "is_error" => {
940                        let _ = writeln!(out, "    expect(() => {{ {call_expr}; }}).toThrow();");
941                    }
942                    other_check => {
943                        panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
944                    }
945                }
946            } else {
947                panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
948            }
949        }
950        "not_error" => {
951            // No-op — if we got here, the call succeeded (it would have thrown).
952        }
953        "error" => {
954            // Handled at the test level (early return above).
955        }
956        other => {
957            panic!("TypeScript e2e generator: unsupported assertion type: {other}");
958        }
959    }
960}
961
962/// Build a TypeScript call expression for a method_result assertion on a tree-sitter Tree.
963/// Maps method names to the appropriate TypeScript function calls or property accesses.
964fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
965    match method_name {
966        "root_child_count" => format!("{result_var}.rootNode.childCount"),
967        "root_node_type" => format!("{result_var}.rootNode.type"),
968        "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
969        "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
970        "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
971        "tree_to_sexp" => format!("treeToSexp({result_var})"),
972        "contains_node_type" => {
973            let node_type = args
974                .and_then(|a| a.get("node_type"))
975                .and_then(|v| v.as_str())
976                .unwrap_or("");
977            format!("treeContainsNodeType({result_var}, \"{node_type}\")")
978        }
979        "find_nodes_by_type" => {
980            let node_type = args
981                .and_then(|a| a.get("node_type"))
982                .and_then(|v| v.as_str())
983                .unwrap_or("");
984            format!("findNodesByType({result_var}, \"{node_type}\")")
985        }
986        "run_query" => {
987            let query_source = args
988                .and_then(|a| a.get("query_source"))
989                .and_then(|v| v.as_str())
990                .unwrap_or("");
991            let language = args
992                .and_then(|a| a.get("language"))
993                .and_then(|v| v.as_str())
994                .unwrap_or("");
995            format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
996        }
997        _ => {
998            if let Some(args_val) = args {
999                let arg_str = args_val
1000                    .as_object()
1001                    .map(|obj| {
1002                        obj.iter()
1003                            .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1004                            .collect::<Vec<_>>()
1005                            .join(", ")
1006                    })
1007                    .unwrap_or_default();
1008                format!("{result_var}.{method_name}({arg_str})")
1009            } else {
1010                format!("{result_var}.{method_name}()")
1011            }
1012        }
1013    }
1014}
1015
1016/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
1017///
1018/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
1019/// snake_case object keys (as written in fixture JSON) to camelCase so that the
1020/// generated config objects match the NAPI binding's expected field names.
1021fn json_to_js_camel(value: &serde_json::Value) -> String {
1022    match value {
1023        serde_json::Value::Object(map) => {
1024            let entries: Vec<String> = map
1025                .iter()
1026                .map(|(k, v)| {
1027                    let camel_key = snake_to_camel(k);
1028                    // Quote keys that aren't valid JS identifiers.
1029                    let key = if camel_key
1030                        .chars()
1031                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1032                        && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1033                    {
1034                        camel_key.clone()
1035                    } else {
1036                        format!("\"{}\"", escape_js(&camel_key))
1037                    };
1038                    format!("{key}: {}", json_to_js_camel(v))
1039                })
1040                .collect();
1041            format!("{{ {} }}", entries.join(", "))
1042        }
1043        serde_json::Value::Array(arr) => {
1044            let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1045            format!("[{}]", items.join(", "))
1046        }
1047        // Scalars and null delegate to the standard converter.
1048        other => json_to_js(other),
1049    }
1050}
1051
1052/// Convert a snake_case string to camelCase.
1053fn snake_to_camel(s: &str) -> String {
1054    let mut result = String::with_capacity(s.len());
1055    let mut capitalize_next = false;
1056    for ch in s.chars() {
1057        if ch == '_' {
1058            capitalize_next = true;
1059        } else if capitalize_next {
1060            result.extend(ch.to_uppercase());
1061            capitalize_next = false;
1062        } else {
1063            result.push(ch);
1064        }
1065    }
1066    result
1067}
1068
1069/// Convert a `serde_json::Value` to a JavaScript literal string.
1070fn json_to_js(value: &serde_json::Value) -> String {
1071    match value {
1072        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
1073        serde_json::Value::Bool(b) => b.to_string(),
1074        serde_json::Value::Number(n) => n.to_string(),
1075        serde_json::Value::Null => "null".to_string(),
1076        serde_json::Value::Array(arr) => {
1077            let items: Vec<String> = arr.iter().map(json_to_js).collect();
1078            format!("[{}]", items.join(", "))
1079        }
1080        serde_json::Value::Object(map) => {
1081            let entries: Vec<String> = map
1082                .iter()
1083                .map(|(k, v)| {
1084                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
1085                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1086                        && !k.starts_with(|c: char| c.is_ascii_digit())
1087                    {
1088                        k.clone()
1089                    } else {
1090                        format!("\"{}\"", escape_js(k))
1091                    };
1092                    format!("{key}: {}", json_to_js(v))
1093                })
1094                .collect();
1095            format!("{{ {} }}", entries.join(", "))
1096        }
1097    }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// Visitor generation
1102// ---------------------------------------------------------------------------
1103
1104/// Build a TypeScript visitor object and add setup line. Returns the visitor variable name.
1105fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1106    use std::fmt::Write as FmtWrite;
1107    let mut visitor_obj = String::new();
1108    let _ = writeln!(visitor_obj, "{{");
1109    for (method_name, action) in &visitor_spec.callbacks {
1110        emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1111    }
1112    let _ = writeln!(visitor_obj, "    }}");
1113
1114    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1115    "_testVisitor".to_string()
1116}
1117
1118/// Emit a TypeScript visitor method for a callback action.
1119fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1120    use std::fmt::Write as FmtWrite;
1121
1122    let camel_method = to_camel_case(method_name);
1123    let params = match method_name {
1124        "visit_link" => "ctx, href, text, title",
1125        "visit_image" => "ctx, src, alt, title",
1126        "visit_heading" => "ctx, level, text, id",
1127        "visit_code_block" => "ctx, lang, code",
1128        "visit_code_inline"
1129        | "visit_strong"
1130        | "visit_emphasis"
1131        | "visit_strikethrough"
1132        | "visit_underline"
1133        | "visit_subscript"
1134        | "visit_superscript"
1135        | "visit_mark"
1136        | "visit_button"
1137        | "visit_summary"
1138        | "visit_figcaption"
1139        | "visit_definition_term"
1140        | "visit_definition_description" => "ctx, text",
1141        "visit_text" => "ctx, text",
1142        "visit_list_item" => "ctx, ordered, marker, text",
1143        "visit_blockquote" => "ctx, content, depth",
1144        "visit_table_row" => "ctx, cells, isHeader",
1145        "visit_custom_element" => "ctx, tagName, html",
1146        "visit_form" => "ctx, actionUrl, method",
1147        "visit_input" => "ctx, inputType, name, value",
1148        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1149        "visit_details" => "ctx, isOpen",
1150        _ => "ctx",
1151    };
1152
1153    let _ = writeln!(
1154        out,
1155        "    {camel_method}({params}): string | {{{{ custom: string }}}} {{"
1156    );
1157    match action {
1158        CallbackAction::Skip => {
1159            let _ = writeln!(out, "        return \"skip\";");
1160        }
1161        CallbackAction::Continue => {
1162            let _ = writeln!(out, "        return \"continue\";");
1163        }
1164        CallbackAction::PreserveHtml => {
1165            let _ = writeln!(out, "        return \"preserve_html\";");
1166        }
1167        CallbackAction::Custom { output } => {
1168            let escaped = escape_js(output);
1169            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
1170        }
1171        CallbackAction::CustomTemplate { template } => {
1172            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
1173        }
1174    }
1175    let _ = writeln!(out, "    }},");
1176}
1177
1178/// Convert snake_case to camelCase for method names.
1179fn to_camel_case(snake: &str) -> String {
1180    use heck::ToLowerCamelCase;
1181    snake.to_lower_camel_case()
1182}