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