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