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