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