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