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