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
447    // Build the init object for `fetch(url, init)`.
448    let mut init_entries: Vec<String> = Vec::new();
449    init_entries.push(format!("method: '{method}'"));
450
451    // Headers
452    if !http.request.headers.is_empty() {
453        let entries: Vec<String> = http
454            .request
455            .headers
456            .iter()
457            .map(|(k, v)| {
458                let expanded_v = expand_fixture_templates(v);
459                format!("      \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
460            })
461            .collect();
462        init_entries.push(format!("headers: {{\n{},\n    }}", entries.join(",\n")));
463    }
464
465    // Body
466    if let Some(body) = &http.request.body {
467        let js_body = json_to_js(body);
468        init_entries.push(format!("body: JSON.stringify({js_body})"));
469    }
470
471    let fixture_id = escape_js(&fixture.id);
472    let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
473    let _ = writeln!(
474        out,
475        "    const mockUrl = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;"
476    );
477
478    let init_str = init_entries.join(", ");
479    let _ = writeln!(out, "    const response = await fetch(mockUrl, {{ {init_str} }});");
480
481    // Status code assertion.
482    let status = http.expected_response.status_code;
483    let _ = writeln!(out, "    expect(response.status).toBe({status});");
484
485    // Body assertions.
486    if let Some(expected_body) = &http.expected_response.body {
487        let js_val = json_to_js(expected_body);
488        let _ = writeln!(out, "    const data = await response.json();");
489        let _ = writeln!(out, "    expect(data).toEqual({js_val});");
490    } else if let Some(partial) = &http.expected_response.body_partial {
491        let _ = writeln!(out, "    const data = await response.json();");
492        if let Some(obj) = partial.as_object() {
493            for (key, val) in obj {
494                let js_key = escape_js(key);
495                let js_val = json_to_js(val);
496                let _ = writeln!(
497                    out,
498                    "    expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
499                );
500            }
501        }
502    }
503
504    // Header assertions.
505    for (header_name, header_value) in &http.expected_response.headers {
506        let lower_name = header_name.to_lowercase();
507        let escaped_name = escape_js(&lower_name);
508        match header_value.as_str() {
509            "<<present>>" => {
510                let _ = writeln!(
511                    out,
512                    "    expect(response.headers.get('{escaped_name}')).not.toBeNull();"
513                );
514            }
515            "<<absent>>" => {
516                let _ = writeln!(out, "    expect(response.headers.get('{escaped_name}')).toBeNull();");
517            }
518            "<<uuid>>" => {
519                let _ = writeln!(
520                    out,
521                    "    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}}$/);"
522                );
523            }
524            exact => {
525                let escaped_val = escape_js(exact);
526                let _ = writeln!(
527                    out,
528                    "    expect(response.headers.get('{escaped_name}')).toBe('{escaped_val}');"
529                );
530            }
531        }
532    }
533
534    // Validation error assertions.
535    if let Some(validation_errors) = &http.expected_response.validation_errors {
536        if !validation_errors.is_empty() {
537            let _ = writeln!(
538                out,
539                "    const body = await response.json() as {{ detail?: unknown[] }};"
540            );
541            let _ = writeln!(out, "    const errors = body.detail ?? [];");
542            for ve in validation_errors {
543                let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
544                let loc_str = loc_js.join(", ");
545                let expanded_msg = expand_fixture_templates(&ve.msg);
546                let escaped_msg = escape_js(&expanded_msg);
547                let _ = writeln!(
548                    out,
549                    "    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);"
550                );
551            }
552        }
553    }
554
555    let _ = writeln!(out, "  }});");
556}
557
558// ---------------------------------------------------------------------------
559// Function-call test rendering
560// ---------------------------------------------------------------------------
561
562#[allow(clippy::too_many_arguments)]
563fn render_test_case(
564    out: &mut String,
565    fixture: &Fixture,
566    client_factory: Option<&str>,
567    options_type: Option<&str>,
568    field_resolver: &FieldResolver,
569    e2e_config: &E2eConfig,
570) {
571    // Resolve per-fixture call config (supports `"call": "parse"` overrides in fixtures).
572    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
573    let function_name = resolve_node_function_name(call_config);
574    let result_var = &call_config.result_var;
575    let is_async = call_config.r#async;
576    let args = &call_config.args;
577
578    let test_name = sanitize_ident(&fixture.id);
579    let description = fixture.description.replace('\'', "\\'");
580    let async_kw = if is_async { "async " } else { "" };
581    let await_kw = if is_async { "await " } else { "" };
582
583    // Build the call expression — either `client.method(args)` or `method(args)`
584    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
585
586    // Build visitor if present and add to setup
587    let mut visitor_arg = String::new();
588    if let Some(visitor_spec) = &fixture.visitor {
589        visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
590    }
591
592    let final_args = if visitor_arg.is_empty() {
593        args_str
594    } else if args_str.is_empty() {
595        format!("{{ visitor: {visitor_arg} }}")
596    } else {
597        format!("{args_str}, {{ visitor: {visitor_arg} }}")
598    };
599
600    let call_expr = if client_factory.is_some() {
601        format!("client.{function_name}({final_args})")
602    } else {
603        format!("{function_name}({final_args})")
604    };
605
606    // Build the base_url expression for mock server
607    let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
608
609    // Check if this is an error-expecting test.
610    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
611
612    if expects_error {
613        let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
614        if let Some(factory) = client_factory {
615            let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
616        }
617        // Wrap ALL setup lines and the function call inside the expect block so that
618        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
619        let _ = writeln!(out, "    await expect(async () => {{");
620        for line in &setup_lines {
621            let _ = writeln!(out, "      {line}");
622        }
623        let _ = writeln!(out, "      await {call_expr};");
624        let _ = writeln!(out, "    }}).rejects.toThrow();");
625        let _ = writeln!(out, "  }});");
626        return;
627    }
628
629    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
630
631    if let Some(factory) = client_factory {
632        let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
633    }
634
635    for line in &setup_lines {
636        let _ = writeln!(out, "    {line}");
637    }
638
639    // Check if any assertion actually uses the result variable.
640    let has_usable_assertion = fixture.assertions.iter().any(|a| {
641        if a.assertion_type == "not_error" || a.assertion_type == "error" {
642            return false;
643        }
644        match &a.field {
645            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
646            _ => true,
647        }
648    });
649
650    if has_usable_assertion {
651        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
652    } else {
653        let _ = writeln!(out, "    {await_kw}{call_expr};");
654    }
655
656    // Emit assertions.
657    for assertion in &fixture.assertions {
658        // A2: skip not_error assertions when returns_result=false (non-Result calls don't return errors).
659        if assertion.assertion_type == "not_error" && !call_config.returns_result {
660            continue;
661        }
662        render_assertion(out, assertion, result_var, field_resolver);
663    }
664
665    let _ = writeln!(out, "  }});");
666}
667
668/// Build setup lines (e.g. handle creation) and the argument list for the function call.
669///
670/// Returns `(setup_lines, args_string)`.
671fn build_args_and_setup(
672    input: &serde_json::Value,
673    args: &[crate::config::ArgMapping],
674    options_type: Option<&str>,
675    fixture_id: &str,
676) -> (Vec<String>, String) {
677    if args.is_empty() {
678        // If no args mapping, pass the whole input as a single argument.
679        return (Vec::new(), json_to_js(input));
680    }
681
682    let mut setup_lines: Vec<String> = Vec::new();
683    let mut parts: Vec<String> = Vec::new();
684
685    for arg in args {
686        if arg.arg_type == "mock_url" {
687            setup_lines.push(format!(
688                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
689                arg.name,
690            ));
691            parts.push(arg.name.clone());
692            continue;
693        }
694
695        if arg.arg_type == "handle" {
696            // Generate a createEngine (or equivalent) call and pass the variable.
697            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
698            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
699            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
700            if config_value.is_null()
701                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
702            {
703                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
704            } else {
705                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
706                // config keys from the fixture JSON to camelCase before passing to the constructor.
707                let literal = json_to_js_camel(config_value);
708                setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
709                setup_lines.push(format!(
710                    "const {} = {constructor_name}({name}Config);",
711                    arg.name,
712                    name = arg.name,
713                ));
714            }
715            parts.push(arg.name.clone());
716            continue;
717        }
718
719        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
720        // When field == "input", the entire input object IS the value (not a nested key)
721        let val = if field == "input" {
722            Some(input)
723        } else {
724            input.get(field)
725        };
726        match val {
727            None | Some(serde_json::Value::Null) if arg.optional => {
728                // Optional arg with no fixture value: skip entirely.
729                continue;
730            }
731            None | Some(serde_json::Value::Null) => {
732                // Required arg with no fixture value: pass a language-appropriate default.
733                let default_val = match arg.arg_type.as_str() {
734                    "string" => "\"\"".to_string(),
735                    "int" | "integer" => "0".to_string(),
736                    "float" | "number" => "0.0".to_string(),
737                    "bool" | "boolean" => "false".to_string(),
738                    _ => "null".to_string(),
739                };
740                parts.push(default_val);
741            }
742            Some(v) => {
743                // For json_object args with options_type, cast the object literal.
744                if arg.arg_type == "json_object" {
745                    if let Some(opts_type) = options_type {
746                        parts.push(format!("{} as {opts_type}", json_to_js(v)));
747                        continue;
748                    }
749                }
750                parts.push(json_to_js(v));
751            }
752        }
753    }
754
755    (setup_lines, parts.join(", "))
756}
757
758fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
759    // Skip assertions on fields that don't exist on the result type.
760    if let Some(f) = &assertion.field {
761        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
762            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
763            return;
764        }
765    }
766
767    let field_expr = match &assertion.field {
768        Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
769        _ => result_var.to_string(),
770    };
771
772    match assertion.assertion_type.as_str() {
773        "equals" => {
774            if let Some(expected) = &assertion.value {
775                let js_val = json_to_js(expected);
776                // For string equality, trim trailing whitespace to handle trailing newlines
777                // from the converter. Use null-coalescing for optional fields.
778                if expected.is_string() {
779                    let resolved = assertion.field.as_deref().unwrap_or("");
780                    if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
781                        let _ = writeln!(out, "    expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
782                    } else {
783                        let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
784                    }
785                } else {
786                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
787                }
788            }
789        }
790        "contains" => {
791            if let Some(expected) = &assertion.value {
792                let js_val = json_to_js(expected);
793                // Use null-coalescing for optional string fields to handle null/undefined values.
794                let resolved = assertion.field.as_deref().unwrap_or("");
795                if !resolved.is_empty()
796                    && expected.is_string()
797                    && field_resolver.is_optional(field_resolver.resolve(resolved))
798                {
799                    let _ = writeln!(out, "    expect({field_expr} ?? \"\").toContain({js_val});");
800                } else {
801                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
802                }
803            }
804        }
805        "contains_all" => {
806            if let Some(values) = &assertion.values {
807                for val in values {
808                    let js_val = json_to_js(val);
809                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
810                }
811            }
812        }
813        "not_contains" => {
814            if let Some(expected) = &assertion.value {
815                let js_val = json_to_js(expected);
816                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
817            }
818        }
819        "not_empty" => {
820            // Use null-coalescing for optional fields to handle null/undefined values.
821            let resolved = assertion.field.as_deref().unwrap_or("");
822            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
823                let _ = writeln!(out, "    expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
824            } else {
825                let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
826            }
827        }
828        "is_empty" => {
829            // Use null-coalescing for optional string fields to handle null/undefined values.
830            let resolved = assertion.field.as_deref().unwrap_or("");
831            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
832                let _ = writeln!(out, "    expect({field_expr} ?? \"\").toHaveLength(0);");
833            } else {
834                let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
835            }
836        }
837        "contains_any" => {
838            if let Some(values) = &assertion.values {
839                let items: Vec<String> = values.iter().map(json_to_js).collect();
840                let arr_str = items.join(", ");
841                let _ = writeln!(
842                    out,
843                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
844                );
845            }
846        }
847        "greater_than" => {
848            if let Some(val) = &assertion.value {
849                let js_val = json_to_js(val);
850                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
851            }
852        }
853        "less_than" => {
854            if let Some(val) = &assertion.value {
855                let js_val = json_to_js(val);
856                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
857            }
858        }
859        "greater_than_or_equal" => {
860            if let Some(val) = &assertion.value {
861                let js_val = json_to_js(val);
862                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
863            }
864        }
865        "less_than_or_equal" => {
866            if let Some(val) = &assertion.value {
867                let js_val = json_to_js(val);
868                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
869            }
870        }
871        "starts_with" => {
872            if let Some(expected) = &assertion.value {
873                let js_val = json_to_js(expected);
874                // Use null-coalescing for optional fields to handle null/undefined values.
875                let resolved = assertion.field.as_deref().unwrap_or("");
876                if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
877                    let _ = writeln!(
878                        out,
879                        "    expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
880                    );
881                } else {
882                    let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
883                }
884            }
885        }
886        "count_min" => {
887            if let Some(val) = &assertion.value {
888                if let Some(n) = val.as_u64() {
889                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
890                }
891            }
892        }
893        "count_equals" => {
894            if let Some(val) = &assertion.value {
895                if let Some(n) = val.as_u64() {
896                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
897                }
898            }
899        }
900        "is_true" => {
901            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
902        }
903        "is_false" => {
904            let _ = writeln!(out, "    expect({field_expr}).toBe(false);");
905        }
906        "method_result" => {
907            if let Some(method_name) = &assertion.method {
908                let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
909                let check = assertion.check.as_deref().unwrap_or("is_true");
910                match check {
911                    "equals" => {
912                        if let Some(val) = &assertion.value {
913                            let js_val = json_to_js(val);
914                            let _ = writeln!(out, "    expect({call_expr}).toBe({js_val});");
915                        }
916                    }
917                    "is_true" => {
918                        let _ = writeln!(out, "    expect({call_expr}).toBe(true);");
919                    }
920                    "is_false" => {
921                        let _ = writeln!(out, "    expect({call_expr}).toBe(false);");
922                    }
923                    "greater_than_or_equal" => {
924                        if let Some(val) = &assertion.value {
925                            let n = val.as_u64().unwrap_or(0);
926                            let _ = writeln!(out, "    expect({call_expr}).toBeGreaterThanOrEqual({n});");
927                        }
928                    }
929                    "count_min" => {
930                        if let Some(val) = &assertion.value {
931                            let n = val.as_u64().unwrap_or(0);
932                            let _ = writeln!(out, "    expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
933                        }
934                    }
935                    "contains" => {
936                        if let Some(val) = &assertion.value {
937                            let js_val = json_to_js(val);
938                            let _ = writeln!(out, "    expect({call_expr}).toContain({js_val});");
939                        }
940                    }
941                    "is_error" => {
942                        let _ = writeln!(out, "    expect(() => {{ {call_expr}; }}).toThrow();");
943                    }
944                    other_check => {
945                        panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
946                    }
947                }
948            } else {
949                panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
950            }
951        }
952        "min_length" => {
953            if let Some(val) = &assertion.value {
954                if let Some(n) = val.as_u64() {
955                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
956                }
957            }
958        }
959        "max_length" => {
960            if let Some(val) = &assertion.value {
961                if let Some(n) = val.as_u64() {
962                    let _ = writeln!(out, "    expect({field_expr}.length).toBeLessThanOrEqual({n});");
963                }
964            }
965        }
966        "ends_with" => {
967            if let Some(expected) = &assertion.value {
968                let js_val = json_to_js(expected);
969                let _ = writeln!(out, "    expect({field_expr}.endsWith({js_val})).toBe(true);");
970            }
971        }
972        "matches_regex" => {
973            if let Some(expected) = &assertion.value {
974                if let Some(pattern) = expected.as_str() {
975                    let _ = writeln!(out, "    expect({field_expr}).toMatch(/{pattern}/);");
976                }
977            }
978        }
979        "not_error" => {
980            // No-op — if we got here, the call succeeded (it would have thrown).
981        }
982        "error" => {
983            // Handled at the test level (early return above).
984        }
985        other => {
986            panic!("TypeScript e2e generator: unsupported assertion type: {other}");
987        }
988    }
989}
990
991/// Build a TypeScript call expression for a method_result assertion on a tree-sitter Tree.
992/// Maps method names to the appropriate TypeScript function calls or property accesses.
993fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
994    match method_name {
995        "root_child_count" => format!("{result_var}.rootNode.childCount"),
996        "root_node_type" => format!("{result_var}.rootNode.type"),
997        "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
998        "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
999        "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
1000        "tree_to_sexp" => format!("treeToSexp({result_var})"),
1001        "contains_node_type" => {
1002            let node_type = args
1003                .and_then(|a| a.get("node_type"))
1004                .and_then(|v| v.as_str())
1005                .unwrap_or("");
1006            format!("treeContainsNodeType({result_var}, \"{node_type}\")")
1007        }
1008        "find_nodes_by_type" => {
1009            let node_type = args
1010                .and_then(|a| a.get("node_type"))
1011                .and_then(|v| v.as_str())
1012                .unwrap_or("");
1013            format!("findNodesByType({result_var}, \"{node_type}\")")
1014        }
1015        "run_query" => {
1016            let query_source = args
1017                .and_then(|a| a.get("query_source"))
1018                .and_then(|v| v.as_str())
1019                .unwrap_or("");
1020            let language = args
1021                .and_then(|a| a.get("language"))
1022                .and_then(|v| v.as_str())
1023                .unwrap_or("");
1024            format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1025        }
1026        _ => {
1027            if let Some(args_val) = args {
1028                let arg_str = args_val
1029                    .as_object()
1030                    .map(|obj| {
1031                        obj.iter()
1032                            .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1033                            .collect::<Vec<_>>()
1034                            .join(", ")
1035                    })
1036                    .unwrap_or_default();
1037                format!("{result_var}.{method_name}({arg_str})")
1038            } else {
1039                format!("{result_var}.{method_name}()")
1040            }
1041        }
1042    }
1043}
1044
1045/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
1046///
1047/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
1048/// snake_case object keys (as written in fixture JSON) to camelCase so that the
1049/// generated config objects match the NAPI binding's expected field names.
1050fn json_to_js_camel(value: &serde_json::Value) -> String {
1051    match value {
1052        serde_json::Value::Object(map) => {
1053            let entries: Vec<String> = map
1054                .iter()
1055                .map(|(k, v)| {
1056                    let camel_key = snake_to_camel(k);
1057                    // Quote keys that aren't valid JS identifiers.
1058                    let key = if camel_key
1059                        .chars()
1060                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1061                        && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1062                    {
1063                        camel_key.clone()
1064                    } else {
1065                        format!("\"{}\"", escape_js(&camel_key))
1066                    };
1067                    format!("{key}: {}", json_to_js_camel(v))
1068                })
1069                .collect();
1070            format!("{{ {} }}", entries.join(", "))
1071        }
1072        serde_json::Value::Array(arr) => {
1073            let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1074            format!("[{}]", items.join(", "))
1075        }
1076        // Scalars and null delegate to the standard converter.
1077        other => json_to_js(other),
1078    }
1079}
1080
1081/// Convert a snake_case string to camelCase.
1082fn snake_to_camel(s: &str) -> String {
1083    let mut result = String::with_capacity(s.len());
1084    let mut capitalize_next = false;
1085    for ch in s.chars() {
1086        if ch == '_' {
1087            capitalize_next = true;
1088        } else if capitalize_next {
1089            result.extend(ch.to_uppercase());
1090            capitalize_next = false;
1091        } else {
1092            result.push(ch);
1093        }
1094    }
1095    result
1096}
1097
1098/// Convert a `serde_json::Value` to a JavaScript literal string.
1099fn json_to_js(value: &serde_json::Value) -> String {
1100    match value {
1101        serde_json::Value::String(s) => {
1102            let expanded = expand_fixture_templates(s);
1103            format!("\"{}\"", escape_js(&expanded))
1104        }
1105        serde_json::Value::Bool(b) => b.to_string(),
1106        serde_json::Value::Number(n) => {
1107            // For integers outside JS safe range, emit as string to avoid precision loss.
1108            if let Some(i) = n.as_i64() {
1109                if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&i) {
1110                    return format!("Number(\"{i}\")");
1111                }
1112            }
1113            if let Some(u) = n.as_u64() {
1114                if u > 9_007_199_254_740_991 {
1115                    return format!("Number(\"{u}\")");
1116                }
1117            }
1118            n.to_string()
1119        }
1120        serde_json::Value::Null => "null".to_string(),
1121        serde_json::Value::Array(arr) => {
1122            let items: Vec<String> = arr.iter().map(json_to_js).collect();
1123            format!("[{}]", items.join(", "))
1124        }
1125        serde_json::Value::Object(map) => {
1126            let entries: Vec<String> = map
1127                .iter()
1128                .map(|(k, v)| {
1129                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
1130                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1131                        && !k.starts_with(|c: char| c.is_ascii_digit())
1132                    {
1133                        k.clone()
1134                    } else {
1135                        format!("\"{}\"", escape_js(k))
1136                    };
1137                    format!("{key}: {}", json_to_js(v))
1138                })
1139                .collect();
1140            format!("{{ {} }}", entries.join(", "))
1141        }
1142    }
1143}
1144
1145// ---------------------------------------------------------------------------
1146// Visitor generation
1147// ---------------------------------------------------------------------------
1148
1149/// Build a TypeScript visitor object and add setup line. Returns the visitor variable name.
1150fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1151    use std::fmt::Write as FmtWrite;
1152    let mut visitor_obj = String::new();
1153    let _ = writeln!(visitor_obj, "{{");
1154    for (method_name, action) in &visitor_spec.callbacks {
1155        emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1156    }
1157    let _ = writeln!(visitor_obj, "    }}");
1158
1159    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1160    "_testVisitor".to_string()
1161}
1162
1163/// Emit a TypeScript visitor method for a callback action.
1164fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1165    use std::fmt::Write as FmtWrite;
1166
1167    let camel_method = to_camel_case(method_name);
1168    let params = match method_name {
1169        "visit_link" => "ctx, href, text, title",
1170        "visit_image" => "ctx, src, alt, title",
1171        "visit_heading" => "ctx, level, text, id",
1172        "visit_code_block" => "ctx, lang, code",
1173        "visit_code_inline"
1174        | "visit_strong"
1175        | "visit_emphasis"
1176        | "visit_strikethrough"
1177        | "visit_underline"
1178        | "visit_subscript"
1179        | "visit_superscript"
1180        | "visit_mark"
1181        | "visit_button"
1182        | "visit_summary"
1183        | "visit_figcaption"
1184        | "visit_definition_term"
1185        | "visit_definition_description" => "ctx, text",
1186        "visit_text" => "ctx, text",
1187        "visit_list_item" => "ctx, ordered, marker, text",
1188        "visit_blockquote" => "ctx, content, depth",
1189        "visit_table_row" => "ctx, cells, isHeader",
1190        "visit_custom_element" => "ctx, tagName, html",
1191        "visit_form" => "ctx, actionUrl, method",
1192        "visit_input" => "ctx, inputType, name, value",
1193        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1194        "visit_details" => "ctx, isOpen",
1195        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1196        "visit_list_start" => "ctx, ordered",
1197        "visit_list_end" => "ctx, ordered, output",
1198        _ => "ctx",
1199    };
1200
1201    let _ = writeln!(
1202        out,
1203        "    {camel_method}({params}): string | {{{{ custom: string }}}} {{"
1204    );
1205    match action {
1206        CallbackAction::Skip => {
1207            let _ = writeln!(out, "        return \"skip\";");
1208        }
1209        CallbackAction::Continue => {
1210            let _ = writeln!(out, "        return \"continue\";");
1211        }
1212        CallbackAction::PreserveHtml => {
1213            let _ = writeln!(out, "        return \"preserve_html\";");
1214        }
1215        CallbackAction::Custom { output } => {
1216            let escaped = escape_js(output);
1217            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
1218        }
1219        CallbackAction::CustomTemplate { template } => {
1220            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
1221        }
1222    }
1223    let _ = writeln!(out, "    }},");
1224}
1225
1226/// Convert snake_case to camelCase for method names.
1227fn to_camel_case(snake: &str) -> String {
1228    use heck::ToLowerCamelCase;
1229    snake.to_lower_camel_case()
1230}