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, ValidationErrorExpectation};
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;
17use super::client;
18
19/// TypeScript e2e code generator.
20pub struct TypeScriptCodegen;
21
22impl E2eCodegen for TypeScriptCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        _alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
30        let tests_base = output_base.join("tests");
31
32        let mut files = Vec::new();
33
34        // Resolve call config with overrides — use "node" key (Language::Node).
35        let call = &e2e_config.call;
36        let overrides = call.overrides.get("node");
37        let module_path = overrides
38            .and_then(|o| o.module.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.module.clone());
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| snake_to_camel(&call.function));
45        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
46
47        // Resolve package config.
48        let node_pkg = e2e_config.resolve_package("node");
49        let pkg_path = node_pkg
50            .as_ref()
51            .and_then(|p| p.path.as_ref())
52            .cloned()
53            .unwrap_or_else(|| "../../packages/typescript".to_string());
54        let pkg_name = node_pkg
55            .as_ref()
56            .and_then(|p| p.name.as_ref())
57            .cloned()
58            .unwrap_or_else(|| module_path.clone());
59        let pkg_version = node_pkg
60            .as_ref()
61            .and_then(|p| p.version.as_ref())
62            .cloned()
63            .unwrap_or_else(|| "0.1.0".to_string());
64
65        // Determine whether any group has HTTP server test fixtures.
66        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
67
68        // Detect whether any fixture uses file_path or bytes args — if so we need to
69        // chdir to the test_documents directory so relative paths resolve correctly.
70        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
71            let cc = e2e_config.resolve_call(f.call.as_deref());
72            cc.args
73                .iter()
74                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
75        });
76
77        // Generate package.json.
78        files.push(GeneratedFile {
79            path: output_base.join("package.json"),
80            content: render_package_json(
81                &pkg_name,
82                &pkg_path,
83                &pkg_version,
84                e2e_config.dep_mode,
85                has_http_fixtures,
86            ),
87            generated_header: false,
88        });
89
90        // Generate tsconfig.json.
91        files.push(GeneratedFile {
92            path: output_base.join("tsconfig.json"),
93            content: render_tsconfig(),
94            generated_header: false,
95        });
96
97        // Check if we need global setup (either for client_factory or HTTP tests).
98        let needs_global_setup = client_factory.is_some() || has_http_fixtures;
99
100        // Generate vitest.config.ts — include globalSetup and/or setupFiles when needed.
101        files.push(GeneratedFile {
102            path: output_base.join("vitest.config.ts"),
103            content: render_vitest_config(needs_global_setup, has_file_fixtures),
104            generated_header: true,
105        });
106
107        // Generate globalSetup.ts when needed (for mock server or HTTP tests).
108        if needs_global_setup {
109            files.push(GeneratedFile {
110                path: output_base.join("globalSetup.ts"),
111                content: render_global_setup(),
112                generated_header: true,
113            });
114        }
115
116        // Generate setup.ts when file_path args are used, to chdir to test_documents.
117        if has_file_fixtures {
118            files.push(GeneratedFile {
119                path: output_base.join("setup.ts"),
120                content: render_file_setup(),
121                generated_header: true,
122            });
123        }
124
125        // Resolve options_type from override.
126        let options_type = overrides.and_then(|o| o.options_type.clone());
127        let field_resolver = FieldResolver::new(
128            &e2e_config.fields,
129            &e2e_config.fields_optional,
130            &e2e_config.result_fields,
131            &e2e_config.fields_array,
132        );
133
134        // Generate test files per category.
135        for group in groups {
136            let lang_filter = self.language_name();
137            let active: Vec<&Fixture> = group
138                .fixtures
139                .iter()
140                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang_filter)))
141                // Honor per-call `skip_languages`: a fixture that resolves to a
142                // call marked `skip_languages = ["wasm"]` should not appear in
143                // the wasm e2e (the call's symbol isn't exported by the wasm
144                // binding). Same for node when a call opts out.
145                .filter(|f| {
146                    let cc = e2e_config.resolve_call(f.call.as_deref());
147                    !cc.skip_languages.iter().any(|l| l == lang_filter)
148                })
149                .collect();
150
151            if active.is_empty() {
152                continue;
153            }
154
155            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
156            let content = render_test_file(
157                self.language_name(),
158                &group.category,
159                &active,
160                &module_path,
161                &pkg_name,
162                &function_name,
163                &e2e_config.call.args,
164                options_type.as_deref(),
165                &field_resolver,
166                client_factory,
167                e2e_config,
168            );
169            files.push(GeneratedFile {
170                path: tests_base.join(filename),
171                content,
172                generated_header: true,
173            });
174        }
175
176        Ok(files)
177    }
178
179    fn language_name(&self) -> &'static str {
180        "node"
181    }
182}
183
184fn render_package_json(
185    pkg_name: &str,
186    _pkg_path: &str,
187    pkg_version: &str,
188    dep_mode: crate::config::DependencyMode,
189    has_http_fixtures: bool,
190) -> String {
191    let dep_value = match dep_mode {
192        crate::config::DependencyMode::Registry => pkg_version.to_string(),
193        crate::config::DependencyMode::Local => "workspace:*".to_string(),
194    };
195    let _ = has_http_fixtures; // TODO: add HTTP test deps when http fixtures are present
196    format!(
197        r#"{{
198  "name": "{pkg_name}-e2e-typescript",
199  "version": "0.1.0",
200  "private": true,
201  "type": "module",
202  "scripts": {{
203    "test": "vitest run"
204  }},
205  "devDependencies": {{
206    "{pkg_name}": "{dep_value}",
207    "vitest": "{vitest}"
208  }}
209}}
210"#,
211        vitest = tv::npm::VITEST,
212    )
213}
214
215fn render_tsconfig() -> String {
216    r#"{
217  "compilerOptions": {
218    "target": "ES2022",
219    "module": "ESNext",
220    "moduleResolution": "bundler",
221    "strict": true,
222    "strictNullChecks": false,
223    "esModuleInterop": true,
224    "skipLibCheck": true
225  },
226  "include": ["tests/**/*.ts", "vitest.config.ts"]
227}
228"#
229    .to_string()
230}
231
232fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
233    let header = hash::header(CommentStyle::DoubleSlash);
234    let setup_files_line = if with_file_setup {
235        "    setupFiles: ['./setup.ts'],\n"
236    } else {
237        ""
238    };
239    if with_global_setup {
240        format!(
241            r#"{header}import {{ defineConfig }} from 'vitest/config';
242
243export default defineConfig({{
244  test: {{
245    include: ['tests/**/*.test.ts'],
246    globalSetup: './globalSetup.ts',
247{setup_files_line}  }},
248}});
249"#
250        )
251    } else {
252        format!(
253            r#"{header}import {{ defineConfig }} from 'vitest/config';
254
255export default defineConfig({{
256  test: {{
257    include: ['tests/**/*.test.ts'],
258{setup_files_line}  }},
259}});
260"#
261        )
262    }
263}
264
265fn render_file_setup() -> String {
266    let header = hash::header(CommentStyle::DoubleSlash);
267    header
268        + r#"import { fileURLToPath } from 'url';
269import { dirname, join } from 'path';
270
271// Change to the test_documents directory so that fixture file paths like
272// "pdf/fake_memo.pdf" resolve correctly when running vitest from e2e/node/.
273// setup.ts lives in e2e/node/; test_documents lives at the repository root,
274// two directories up: e2e/node/ -> e2e/ -> repo root -> test_documents/.
275const __filename = fileURLToPath(import.meta.url);
276const __dirname = dirname(__filename);
277const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
278process.chdir(testDocumentsDir);
279"#
280}
281
282fn render_global_setup() -> String {
283    let header = hash::header(CommentStyle::DoubleSlash);
284    header
285        + r#"import { spawn } from 'child_process';
286import { resolve } from 'path';
287
288let serverProcess: any;
289
290// HTTP client wrapper for making requests to mock server
291const createApp = (baseUrl: string) => ({
292  async request(path: string, init?: RequestInit): Promise<Response> {
293    const url = new URL(path, baseUrl);
294    return fetch(url.toString(), init);
295  },
296});
297
298export async function setup() {
299  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
300  serverProcess = spawn(
301    resolve(__dirname, '../rust/target/release/mock-server'),
302    [resolve(__dirname, '../../fixtures')],
303    { stdio: ['pipe', 'pipe', 'inherit'] }
304  );
305
306  const url = await new Promise<string>((resolve, reject) => {
307    serverProcess.stdout.on('data', (data: any) => {
308      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
309      if (match) resolve(match[1].trim());
310    });
311    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
312  });
313
314  process.env.MOCK_SERVER_URL = url;
315
316  // Make app available globally to all tests
317  (globalThis as any).app = createApp(url);
318}
319
320export async function teardown() {
321  if (serverProcess) {
322    serverProcess.stdin.end();
323    serverProcess.kill();
324  }
325}
326"#
327}
328
329#[allow(clippy::too_many_arguments)]
330pub(super) fn render_test_file(
331    lang: &str,
332    category: &str,
333    fixtures: &[&Fixture],
334    module_path: &str,
335    pkg_name: &str,
336    function_name: &str,
337    args: &[crate::config::ArgMapping],
338    options_type: Option<&str>,
339    field_resolver: &FieldResolver,
340    client_factory: Option<&str>,
341    e2e_config: &E2eConfig,
342) -> String {
343    let mut out = String::new();
344    out.push_str(&hash::header(CommentStyle::DoubleSlash));
345    let _ = writeln!(out, "import {{ describe, expect, it }} from 'vitest';");
346
347    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
348    // Only treat as "has non-HTTP fixtures" when at least one non-HTTP fixture has assertions
349    // (fixtures with no assertions are emitted as it.skip stubs that don't call any imports).
350    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test() && !f.assertions.is_empty());
351
352    // Collect the union of all `options_type` overrides referenced by the active
353    // fixtures. A single test file may exercise multiple `[e2e.calls.*]` entries
354    // (e.g. `extract_file` *and* `chunk_text`) — each can declare its own
355    // `options_type` for the language, so we import every distinct one.
356    //
357    // For fixtures using the default call (`fixture.call == None`), the top-level
358    // `options_type` argument applies; for fixtures with `fixture.call = "..."`,
359    // we look up the per-call override directly.
360    let mut needed_options_types: Vec<String> = Vec::new();
361    let push_unique = |v: &mut Vec<String>, name: String| {
362        if !v.contains(&name) {
363            v.push(name);
364        }
365    };
366    for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
367        let resolved = e2e_config.resolve_call(fixture.call.as_deref());
368        let call_args = if fixture.call.is_some() { &resolved.args } else { args };
369        // Resolve options_type using the same fallback as render_test_case:
370        // per-call language override first, top-level override second. Keeps
371        // the fixture compile-clean even when a `[e2e.calls.<n>]` entry omits
372        // `options_type` because it shares the default config shape.
373        let per_call_options_type = resolved.overrides.get(lang).and_then(|o| o.options_type.clone());
374        let fixture_options_type: Option<String> =
375            per_call_options_type.or_else(|| options_type.map(|s| s.to_string()));
376        let Some(opts_type) = fixture_options_type else {
377            continue;
378        };
379        // Whether any json_object arg in this fixture's call needs the options_type
380        // import: either the value is an object (cast applied to the literal), or
381        // the value is missing for an optional arg (we emit `{} as unknown as <T>`).
382        let any_object_or_missing_optional = call_args.iter().any(|arg| {
383            if arg.arg_type != "json_object" {
384                return false;
385            }
386            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
387            let val = if field == "input" {
388                Some(&fixture.input)
389            } else {
390                fixture.input.get(field)
391            };
392            match val {
393                Some(v) if v.is_object() => true,
394                None | Some(serde_json::Value::Null) => arg.optional,
395                _ => false,
396            }
397        });
398        if any_object_or_missing_optional {
399            push_unique(&mut needed_options_types, opts_type);
400        }
401    }
402
403    // Collect handle constructor function names that need to be imported.
404    let handle_constructors: Vec<String> = args
405        .iter()
406        .filter(|arg| arg.arg_type == "handle")
407        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
408        .collect();
409
410    // Detect whether any active fixture's resolved call uses a `bytes` arg with a
411    // file-path string value — those need `readFileSync` from `node:fs`.
412    let needs_fs_import = fixtures.iter().filter(|f| !f.is_http_test()).any(|f| {
413        let resolved = e2e_config.resolve_call(f.call.as_deref());
414        let call_args = if f.call.is_some() { &resolved.args } else { args };
415        call_args.iter().any(|arg| {
416            if arg.arg_type != "bytes" {
417                return false;
418            }
419            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
420            let val = if field == "input" {
421                Some(&f.input)
422            } else {
423                f.input.get(field)
424            };
425            val.and_then(|v| v.as_str())
426                .is_some_and(|s| matches!(classify_bytes_value(s), BytesKind::FilePath))
427        })
428    });
429
430    if needs_fs_import {
431        let _ = writeln!(out, "import {{ readFileSync }} from 'node:fs';");
432    }
433
434    // Build imports for non-HTTP fixtures.
435    if has_non_http_fixtures {
436        // When using client_factory, import the factory instead of the function.
437        let mut imports: Vec<String> = if let Some(factory) = client_factory {
438            vec![factory.to_string()]
439        } else {
440            vec![function_name.to_string()]
441        };
442
443        // Also import any additional function names used by per-fixture call overrides.
444        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
445            if fixture.call.is_some() {
446                let call_config = e2e_config.resolve_call(fixture.call.as_deref());
447                let fixture_fn = resolve_node_function_name(call_config, lang);
448                if client_factory.is_none() && !imports.contains(&fixture_fn) {
449                    imports.push(fixture_fn);
450                }
451            }
452        }
453
454        // Collect tree helper function names needed by method_result assertions.
455        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
456            for assertion in &fixture.assertions {
457                if assertion.assertion_type == "method_result" {
458                    if let Some(method_name) = &assertion.method {
459                        let helper = ts_method_helper_import(method_name);
460                        if let Some(helper_fn) = helper {
461                            if !imports.contains(&helper_fn) {
462                                imports.push(helper_fn);
463                            }
464                        }
465                    }
466                }
467            }
468        }
469
470        for ctor in &handle_constructors {
471            if !imports.contains(ctor) {
472                imports.push(ctor.clone());
473            }
474        }
475
476        // Use pkg_name (the npm package name, e.g. "@kreuzberg/liter-llm") for
477        // the import specifier so that registry builds resolve the published package name.
478        let _ = module_path; // retained in signature for potential future use
479        for opts_type in &needed_options_types {
480            imports.push(format!("type {opts_type}"));
481        }
482        let imports_str = imports.join(", ");
483        let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
484    }
485
486    let _ = writeln!(out);
487    let _ = writeln!(out, "describe('{category}', () => {{");
488
489    for (i, fixture) in fixtures.iter().enumerate() {
490        if fixture.is_http_test() {
491            render_http_test_case(&mut out, fixture);
492        } else {
493            render_test_case(
494                &mut out,
495                lang,
496                fixture,
497                client_factory,
498                options_type,
499                field_resolver,
500                e2e_config,
501            );
502        }
503        if i + 1 < fixtures.len() {
504            let _ = writeln!(out);
505        }
506    }
507
508    // Suppress unused variable warning when file has only HTTP fixtures.
509    let _ = has_http_fixtures;
510
511    let _ = writeln!(out, "}});");
512    out
513}
514
515/// Resolve the function name for a call config, applying language-specific overrides.
516///
517/// Both NAPI-RS (node) and wasm-bindgen (wasm) export Rust snake_case functions as
518/// camelCase in JavaScript. When no explicit `function` override is set for `lang`,
519/// auto-convert the call config's snake_case function name to camelCase so generated
520/// imports match the binding's exports.
521fn resolve_node_function_name(call_config: &crate::config::CallConfig, lang: &str) -> String {
522    call_config
523        .overrides
524        .get(lang)
525        .and_then(|o| o.function.clone())
526        .unwrap_or_else(|| snake_to_camel(&call_config.function))
527}
528
529/// Return the package-level helper function name to import for a method_result method,
530/// or `None` if the method maps to a property access (no import needed).
531fn ts_method_helper_import(method_name: &str) -> Option<String> {
532    match method_name {
533        "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
534        "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
535        "tree_to_sexp" => Some("treeToSexp".to_string()),
536        "contains_node_type" => Some("treeContainsNodeType".to_string()),
537        "find_nodes_by_type" => Some("findNodesByType".to_string()),
538        "run_query" => Some("runQuery".to_string()),
539        // Property accesses (root_child_count, root_node_type, named_children_count)
540        // and unknown methods that become `result.method()` don't need extra imports.
541        _ => None,
542    }
543}
544
545// ---------------------------------------------------------------------------
546// HTTP server test rendering — TestClientRenderer impl + thin driver wrapper
547// ---------------------------------------------------------------------------
548
549/// Renderer that emits vitest `it(...)` blocks using the Node.js `fetch` API
550/// against the mock server (`process.env.MOCK_SERVER_URL`).
551pub(super) struct TypeScriptTestClientRenderer;
552
553impl client::TestClientRenderer for TypeScriptTestClientRenderer {
554    fn language_name(&self) -> &'static str {
555        "node"
556    }
557
558    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
559        let escaped_desc = description.replace('\'', "\\'");
560        if let Some(reason) = skip_reason {
561            let escaped_reason = reason.replace('\'', "\\'");
562            let _ = writeln!(out, "  it.skip('{fn_name}: {escaped_desc}', async () => {{");
563            let _ = writeln!(out, "    // skipped: {escaped_reason}");
564        } else {
565            let _ = writeln!(out, "  it('{fn_name}: {escaped_desc}', async () => {{");
566        }
567    }
568
569    fn render_test_close(&self, out: &mut String) {
570        let _ = writeln!(out, "  }});");
571    }
572
573    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
574        let method = ctx.method.to_uppercase();
575        let fixture_id = escape_js(ctx.path.trim_start_matches("/fixtures/"));
576
577        // Build the init object for `fetch(url, init)`.
578        let mut init_entries: Vec<String> = Vec::new();
579        init_entries.push(format!("method: '{method}'"));
580        // Do not follow redirects — tests that assert on 3xx status codes need the original response.
581        init_entries.push("redirect: 'manual'".to_string());
582
583        // Headers
584        if !ctx.headers.is_empty() {
585            let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
586            header_pairs.sort_by_key(|(k, _)| k.as_str());
587            let entries: Vec<String> = header_pairs
588                .iter()
589                .map(|(k, v)| {
590                    let expanded_v = expand_fixture_templates(v);
591                    format!("      \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
592                })
593                .collect();
594            init_entries.push(format!("headers: {{\n{},\n    }}", entries.join(",\n")));
595        }
596
597        // Body
598        if let Some(body) = ctx.body {
599            let js_body = json_to_js(body);
600            init_entries.push(format!("body: JSON.stringify({js_body})"));
601        }
602
603        let _ = writeln!(
604            out,
605            "    const mockUrl = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;"
606        );
607        let init_str = init_entries.join(", ");
608        let _ = writeln!(
609            out,
610            "    const {} = await fetch(mockUrl, {{ {init_str} }});",
611            ctx.response_var
612        );
613    }
614
615    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
616        let _ = writeln!(out, "    expect({response_var}.status).toBe({status});");
617    }
618
619    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
620        let escaped_name = escape_js(&name.to_lowercase());
621        match expected {
622            "<<present>>" => {
623                let _ = writeln!(
624                    out,
625                    "    expect({response_var}.headers.get('{escaped_name}')).not.toBeNull();"
626                );
627            }
628            "<<absent>>" => {
629                let _ = writeln!(
630                    out,
631                    "    expect({response_var}.headers.get('{escaped_name}')).toBeNull();"
632                );
633            }
634            "<<uuid>>" => {
635                let _ = writeln!(
636                    out,
637                    "    expect({response_var}.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}}$/);"
638                );
639            }
640            exact => {
641                let escaped_val = escape_js(exact);
642                let _ = writeln!(
643                    out,
644                    "    expect({response_var}.headers.get('{escaped_name}')).toBe('{escaped_val}');"
645                );
646            }
647        }
648    }
649
650    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
651        if let serde_json::Value::String(s) = expected {
652            // Plain-string body: mock server returns raw text, compare as text.
653            let escaped = escape_js(s);
654            let _ = writeln!(out, "    const text = await {response_var}.text();");
655            let _ = writeln!(out, "    expect(text).toBe('{escaped}');");
656        } else {
657            let js_val = json_to_js(expected);
658            let _ = writeln!(out, "    const data = await {response_var}.json();");
659            let _ = writeln!(out, "    expect(data).toEqual({js_val});");
660        }
661    }
662
663    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
664        let _ = writeln!(out, "    const data = await {response_var}.json();");
665        if let Some(obj) = expected.as_object() {
666            for (key, val) in obj {
667                let js_key = escape_js(key);
668                let js_val = json_to_js(val);
669                let _ = writeln!(
670                    out,
671                    "    expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
672                );
673            }
674        }
675    }
676
677    fn render_assert_validation_errors(
678        &self,
679        out: &mut String,
680        response_var: &str,
681        errors: &[ValidationErrorExpectation],
682    ) {
683        let _ = writeln!(
684            out,
685            "    const body = await {response_var}.json() as {{ errors?: unknown[] }};"
686        );
687        let _ = writeln!(out, "    const errors = body.errors ?? [];");
688        for ve in errors {
689            let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
690            let loc_str = loc_js.join(", ");
691            let expanded_msg = expand_fixture_templates(&ve.msg);
692            let escaped_msg = escape_js(&expanded_msg);
693            let _ = writeln!(
694                out,
695                "    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);"
696            );
697        }
698    }
699}
700
701/// Render a vitest `it` block for an HTTP server fixture.
702///
703/// Delegates to the shared [`client::http_call::render_http_test`] driver via
704/// [`TypeScriptTestClientRenderer`]. HTTP 101 (WebSocket upgrade) fixtures are
705/// emitted as `it.skip` stubs before reaching the driver because `fetch` cannot
706/// handle upgrade responses.
707fn render_http_test_case(out: &mut String, fixture: &Fixture) {
708    let Some(http) = &fixture.http else {
709        return;
710    };
711
712    // HTTP 101 (WebSocket upgrade) — fetch cannot handle upgrade responses.
713    if http.expected_response.status_code == 101 {
714        let test_name = sanitize_ident(&fixture.id);
715        let description = fixture.description.replace('\'', "\\'");
716        let _ = writeln!(out, "  it.skip('{test_name}: {description}', async () => {{");
717        let _ = writeln!(out, "    // HTTP 101 WebSocket upgrade cannot be tested via fetch");
718        let _ = writeln!(out, "  }});");
719        return;
720    }
721
722    client::http_call::render_http_test(out, &TypeScriptTestClientRenderer, fixture);
723}
724
725// ---------------------------------------------------------------------------
726// Function-call test rendering
727// ---------------------------------------------------------------------------
728
729#[allow(clippy::too_many_arguments)]
730fn render_test_case(
731    out: &mut String,
732    lang: &str,
733    fixture: &Fixture,
734    client_factory: Option<&str>,
735    options_type: Option<&str>,
736    field_resolver: &FieldResolver,
737    e2e_config: &E2eConfig,
738) {
739    // Resolve per-fixture call config (supports `"call": "parse"` overrides in fixtures).
740    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
741    let function_name = resolve_node_function_name(call_config, lang);
742    let result_var = &call_config.result_var;
743    let is_async = call_config.r#async;
744    let args = &call_config.args;
745
746    // Resolve options_type per-fixture. Lookup order:
747    //   1. Per-call language override (`[e2e.calls.<name>.overrides.<lang>].options_type`)
748    //   2. Top-level call language override (`[e2e.call.overrides.<lang>].options_type`)
749    // When the fixture uses the default call (no `fixture.call`), only step 2
750    // applies. The fallback ensures a per-call entry that omits `options_type`
751    // (because the call shares the default `JsExtractionConfig` shape) still
752    // gets the cast/import emission needed for strict TypeScript.
753    let per_call_options_type = call_config.overrides.get(lang).and_then(|o| o.options_type.clone());
754    let fixture_options_type: Option<String> = per_call_options_type.or_else(|| options_type.map(|s| s.to_string()));
755    let options_type = fixture_options_type.as_deref();
756
757    // Result-shape flags describe the Rust core's return type and therefore apply
758    // to every binding; prefer the call-level value (`[e2e.calls.<name>]`) and
759    // fall back to a per-language override only for backwards compatibility with
760    // older alef.tomls that declared the flag under `overrides.<lang>`.
761    let result_is_simple =
762        call_config.result_is_simple || call_config.overrides.get(lang).is_some_and(|o| o.result_is_simple);
763    let result_is_vec = call_config.result_is_vec || call_config.overrides.get(lang).is_some_and(|o| o.result_is_vec);
764
765    // Apply per-language `arg_order` reordering. NAPI-RS often reshuffles
766    // parameters relative to the canonical Rust signature (e.g. extract_file
767    // takes `(path, mime_type, config)` in Rust but `(path, config, mime_type?)`
768    // via NAPI), and the alef.toml override declares the binding's order.
769    let arg_order = call_config
770        .overrides
771        .get(lang)
772        .map(|o| o.arg_order.as_slice())
773        .unwrap_or(&[]);
774    let reordered_args: Vec<crate::config::ArgMapping>;
775    let args: &[crate::config::ArgMapping] = if arg_order.is_empty() {
776        args
777    } else {
778        reordered_args = arg_order
779            .iter()
780            .filter_map(|name| args.iter().find(|a| &a.name == name).cloned())
781            .collect();
782        &reordered_args
783    };
784
785    let test_name = sanitize_ident(&fixture.id);
786    let description = fixture.description.replace('\'', "\\'");
787    let async_kw = if is_async { "async " } else { "" };
788    let await_kw = if is_async { "await " } else { "" };
789
790    // Build the call expression — either `client.method(args)` or `method(args)`
791    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
792
793    // Build visitor if present and add to setup
794    let mut visitor_arg = String::new();
795    if let Some(visitor_spec) = &fixture.visitor {
796        visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
797    }
798
799    let final_args = if visitor_arg.is_empty() {
800        args_str
801    } else if args_str.is_empty() {
802        format!("{{ visitor: {visitor_arg} }}")
803    } else {
804        format!("{args_str}, {{ visitor: {visitor_arg} }}")
805    };
806
807    let call_expr = if client_factory.is_some() {
808        format!("client.{function_name}({final_args})")
809    } else {
810        format!("{function_name}({final_args})")
811    };
812
813    // Build the base_url expression for mock server
814    let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
815
816    // Check if this is an error-expecting test.
817    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
818
819    // Skip tests with no assertions — they would call a stub function that may not exist.
820    if fixture.assertions.is_empty() {
821        let _ = writeln!(out, "  it.skip('{test_name}: {description}', async () => {{");
822        let _ = writeln!(out, "    // no assertions configured for this fixture in node e2e");
823        let _ = writeln!(out, "  }});");
824        return;
825    }
826
827    if expects_error {
828        let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
829        if let Some(factory) = client_factory {
830            let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
831        }
832        // Wrap ALL setup lines and the function call inside the expect block so that
833        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
834        let _ = writeln!(out, "    await expect(async () => {{");
835        for line in &setup_lines {
836            let _ = writeln!(out, "      {line}");
837        }
838        let _ = writeln!(out, "      await {call_expr};");
839        let _ = writeln!(out, "    }}).rejects.toThrow();");
840        let _ = writeln!(out, "  }});");
841        return;
842    }
843
844    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
845
846    if let Some(factory) = client_factory {
847        let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
848    }
849
850    for line in &setup_lines {
851        let _ = writeln!(out, "    {line}");
852    }
853
854    // Check if any assertion actually uses the result variable.
855    let has_usable_assertion = fixture.assertions.iter().any(|a| {
856        if a.assertion_type == "not_error" || a.assertion_type == "error" {
857            return false;
858        }
859        match &a.field {
860            Some(f) if !f.is_empty() => {
861                // When the result is a primitive, the only "valid" assertion is
862                // one with no field (whole-result) or one targeting the synthetic
863                // marker `"result"` — every other path would dereference a scalar.
864                if result_is_simple {
865                    f == "result"
866                } else {
867                    field_resolver.is_valid_for_result(f)
868                }
869            }
870            _ => true,
871        }
872    });
873
874    if has_usable_assertion {
875        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
876    } else {
877        let _ = writeln!(out, "    {await_kw}{call_expr};");
878    }
879
880    // Emit assertions.
881    for assertion in &fixture.assertions {
882        // A2: skip not_error assertions when returns_result=false (non-Result calls don't return errors).
883        if assertion.assertion_type == "not_error" && !call_config.returns_result {
884            continue;
885        }
886        render_assertion(
887            out,
888            assertion,
889            result_var,
890            field_resolver,
891            result_is_simple,
892            result_is_vec,
893        );
894    }
895
896    let _ = writeln!(out, "  }});");
897}
898
899/// Check whether any arg at index `idx` or later requires a positional placeholder.
900/// Returns `true` when:
901///   - the arg has a non-null fixture value, OR
902///   - the arg is a `json_object` with an `options_type` configured (the renderer
903///     emits `{} as unknown as <Type>` for missing-but-typed optional configs).
904///
905/// Used to decide whether earlier optional args need `undefined` placeholders to
906/// preserve positional argument order in the call site.
907fn has_later_arg_value(
908    args: &[crate::config::ArgMapping],
909    from_idx: usize,
910    input: &serde_json::Value,
911    options_type: Option<&str>,
912) -> bool {
913    args[from_idx..].iter().any(|arg| {
914        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
915        let val = if field == "input" {
916            Some(input)
917        } else {
918            input.get(field)
919        };
920        let has_value = !matches!(val, None | Some(serde_json::Value::Null));
921        if has_value {
922            return true;
923        }
924        // Optional json_object args with a configured options_type emit a typed
925        // empty default — they DO occupy a positional slot.
926        arg.optional && arg.arg_type == "json_object" && options_type.is_some()
927    })
928}
929
930/// Build setup lines (e.g. handle creation) and the argument list for the function call.
931///
932/// Returns `(setup_lines, args_string)`.
933fn build_args_and_setup(
934    input: &serde_json::Value,
935    args: &[crate::config::ArgMapping],
936    options_type: Option<&str>,
937    fixture_id: &str,
938) -> (Vec<String>, String) {
939    if args.is_empty() {
940        // No args mapping. If the fixture has no input either, the call takes no
941        // arguments — emit a bare call. Otherwise pass the whole input as a
942        // single argument (legacy behavior, used by HTTP-style fixtures whose
943        // input describes a request body).
944        let no_input =
945            matches!(input, serde_json::Value::Null) || input.as_object().map(|o| o.is_empty()).unwrap_or(false);
946        if no_input {
947            return (Vec::new(), String::new());
948        }
949        return (Vec::new(), json_to_js(input));
950    }
951
952    let mut setup_lines: Vec<String> = Vec::new();
953    let mut parts: Vec<String> = Vec::new();
954
955    for (idx, arg) in args.iter().enumerate() {
956        if arg.arg_type == "mock_url" {
957            setup_lines.push(format!(
958                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
959                arg.name,
960            ));
961            parts.push(arg.name.clone());
962            continue;
963        }
964
965        if arg.arg_type == "handle" {
966            // Generate a createEngine (or equivalent) call and pass the variable.
967            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
968            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
969            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
970            if config_value.is_null()
971                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
972            {
973                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
974            } else {
975                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
976                // config keys from the fixture JSON to camelCase before passing to the constructor.
977                let literal = json_to_js_camel(config_value);
978                setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
979                setup_lines.push(format!(
980                    "const {} = {constructor_name}({name}Config);",
981                    arg.name,
982                    name = arg.name,
983                ));
984            }
985            parts.push(arg.name.clone());
986            continue;
987        }
988
989        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
990        // When field == "input", the entire input object IS the value (not a nested key)
991        let val = if field == "input" {
992            Some(input)
993        } else {
994            input.get(field)
995        };
996        match val {
997            None | Some(serde_json::Value::Null) if arg.optional => {
998                // Optional arg with no fixture value. NAPI-RS marks Rust `Option<T>`
999                // params as `T | undefined | null` in TypeScript and bare `T` as required
1000                // — alef.toml's `optional` flag does not always match the binding's
1001                // strictness. To stay compile-safe under strict tsconfig, emit a typed
1002                // empty literal whenever an `options_type` cast is configured for
1003                // json_object args (the binding will accept `{}` since every field of
1004                // a config struct is itself optional in serde-deserialized form).
1005                // For other arg types, keep the legacy behavior: emit `undefined` only
1006                // when a later arg needs to be positioned.
1007                if arg.arg_type == "json_object" {
1008                    if let Some(opts_type) = options_type {
1009                        parts.push(format!("{{}} as unknown as {opts_type}"));
1010                    }
1011                } else if has_later_arg_value(args, idx + 1, input, options_type) {
1012                    parts.push("undefined".to_string());
1013                }
1014                // Otherwise skip entirely (trailing optional args need no placeholder).
1015            }
1016            None | Some(serde_json::Value::Null) => {
1017                // Required arg with no fixture value: pass a language-appropriate default.
1018                let default_val = match arg.arg_type.as_str() {
1019                    "string" => "\"\"".to_string(),
1020                    "int" | "integer" => "0".to_string(),
1021                    "float" | "number" => "0.0".to_string(),
1022                    "bool" | "boolean" => "false".to_string(),
1023                    _ => "null".to_string(),
1024                };
1025                parts.push(default_val);
1026            }
1027            Some(v) => {
1028                if arg.arg_type == "bytes" {
1029                    if let Some(raw) = v.as_str() {
1030                        let var = format!("{}Bytes", arg.name);
1031                        match classify_bytes_value(raw) {
1032                            BytesKind::FilePath => {
1033                                let escaped = escape_js(raw);
1034                                setup_lines.push(format!("const {var} = readFileSync(\"{escaped}\");"));
1035                            }
1036                            BytesKind::InlineText => {
1037                                let escaped = escape_js(raw);
1038                                setup_lines.push(format!("const {var} = Buffer.from(\"{escaped}\", \"utf-8\");"));
1039                            }
1040                            BytesKind::Base64 => {
1041                                let escaped = escape_js(raw);
1042                                setup_lines.push(format!("const {var} = Buffer.from(\"{escaped}\", \"base64\");"));
1043                            }
1044                        }
1045                        parts.push(var);
1046                        continue;
1047                    }
1048                }
1049                // For json_object args, NAPI-RS bindings use camelCase for JS field names,
1050                // so convert snake_case fixture keys to camelCase before passing.
1051                // Only apply the `options_type` cast to *object*-shaped values — array
1052                // arguments (e.g. `paths: ["a.pdf", "b.pdf"]`) are typed as `Array<T>`
1053                // in the binding signature and must not be cast to the config type.
1054                if arg.arg_type == "json_object" {
1055                    if v.is_object() {
1056                        if let Some(opts_type) = options_type {
1057                            // Cast through `unknown` so partial config literals are
1058                            // accepted by strict tsconfig — fixture configs almost
1059                            // never set every required field of the binding type, so
1060                            // `as <Type>` triggers TS2352 "neither type sufficiently
1061                            // overlaps". The runtime binding still validates fields.
1062                            parts.push(format!("{} as unknown as {opts_type}", json_to_js_camel(v)));
1063                        } else {
1064                            parts.push(json_to_js_camel(v));
1065                        }
1066                    } else {
1067                        parts.push(json_to_js_camel(v));
1068                    }
1069                    continue;
1070                }
1071                parts.push(json_to_js(v));
1072            }
1073        }
1074    }
1075
1076    (setup_lines, parts.join(", "))
1077}
1078
1079fn render_assertion(
1080    out: &mut String,
1081    assertion: &Assertion,
1082    result_var: &str,
1083    field_resolver: &FieldResolver,
1084    result_is_simple: bool,
1085    result_is_vec: bool,
1086) {
1087    // Handle synthetic / derived fields before the is_valid_for_result check
1088    // so they are never treated as struct property accesses on the result.
1089    if let Some(f) = &assertion.field {
1090        match f.as_str() {
1091            "chunks_have_content" => {
1092                let pred = format!("({result_var}.chunks ?? []).every((c: {{ content?: string }}) => !!c.content)");
1093                match assertion.assertion_type.as_str() {
1094                    "is_true" => {
1095                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
1096                    }
1097                    "is_false" => {
1098                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
1099                    }
1100                    _ => {
1101                        let _ = writeln!(
1102                            out,
1103                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
1104                        );
1105                    }
1106                }
1107                return;
1108            }
1109            "chunks_have_embeddings" => {
1110                let pred = format!(
1111                    "({result_var}.chunks ?? []).every((c: {{ embedding?: number[] }}) => c.embedding != null && c.embedding.length > 0)"
1112                );
1113                match assertion.assertion_type.as_str() {
1114                    "is_true" => {
1115                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
1116                    }
1117                    "is_false" => {
1118                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
1119                    }
1120                    _ => {
1121                        let _ = writeln!(
1122                            out,
1123                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
1124                        );
1125                    }
1126                }
1127                return;
1128            }
1129            // ---- EmbedResponse virtual fields ----
1130            // embed_texts returns number[][] in TypeScript — no wrapper object.
1131            // result_var is the embedding matrix; use it directly.
1132            "embeddings" => {
1133                match assertion.assertion_type.as_str() {
1134                    "count_equals" => {
1135                        if let Some(val) = &assertion.value {
1136                            let js_val = json_to_js(val);
1137                            let _ = writeln!(out, "    expect({result_var}.length).toBe({js_val});");
1138                        }
1139                    }
1140                    "count_min" => {
1141                        if let Some(val) = &assertion.value {
1142                            let js_val = json_to_js(val);
1143                            let _ = writeln!(out, "    expect({result_var}.length).toBeGreaterThanOrEqual({js_val});");
1144                        }
1145                    }
1146                    "not_empty" => {
1147                        let _ = writeln!(out, "    expect({result_var}.length).toBeGreaterThan(0);");
1148                    }
1149                    "is_empty" => {
1150                        let _ = writeln!(out, "    expect({result_var}.length).toBe(0);");
1151                    }
1152                    _ => {
1153                        let _ = writeln!(
1154                            out,
1155                            "    // skipped: unsupported assertion type on synthetic field 'embeddings'"
1156                        );
1157                    }
1158                }
1159                return;
1160            }
1161            "embedding_dimensions" => {
1162                let expr = format!("({result_var}.length > 0 ? {result_var}[0].length : 0)");
1163                match assertion.assertion_type.as_str() {
1164                    "equals" => {
1165                        if let Some(val) = &assertion.value {
1166                            let js_val = json_to_js(val);
1167                            let _ = writeln!(out, "    expect({expr}).toBe({js_val});");
1168                        }
1169                    }
1170                    "greater_than" => {
1171                        if let Some(val) = &assertion.value {
1172                            let js_val = json_to_js(val);
1173                            let _ = writeln!(out, "    expect({expr}).toBeGreaterThan({js_val});");
1174                        }
1175                    }
1176                    _ => {
1177                        let _ = writeln!(
1178                            out,
1179                            "    // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1180                        );
1181                    }
1182                }
1183                return;
1184            }
1185            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1186                let pred = match f.as_str() {
1187                    "embeddings_valid" => {
1188                        format!("{result_var}.every((e: number[]) => e.length > 0)")
1189                    }
1190                    "embeddings_finite" => {
1191                        format!("{result_var}.every((e: number[]) => e.every((v: number) => isFinite(v)))")
1192                    }
1193                    "embeddings_non_zero" => {
1194                        format!("{result_var}.every((e: number[]) => e.some((v: number) => v !== 0))")
1195                    }
1196                    "embeddings_normalized" => {
1197                        format!(
1198                            "{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; }})"
1199                        )
1200                    }
1201                    _ => unreachable!(),
1202                };
1203                match assertion.assertion_type.as_str() {
1204                    "is_true" => {
1205                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
1206                    }
1207                    "is_false" => {
1208                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
1209                    }
1210                    _ => {
1211                        let _ = writeln!(
1212                            out,
1213                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
1214                        );
1215                    }
1216                }
1217                return;
1218            }
1219            // ---- keywords / keywords_count ----
1220            // Node JsExtractionResult does not expose extracted_keywords; skip.
1221            "keywords" | "keywords_count" => {
1222                let _ = writeln!(
1223                    out,
1224                    "    // skipped: field '{f}' not available on Node JsExtractionResult"
1225                );
1226                return;
1227            }
1228            _ => {}
1229        }
1230    }
1231
1232    // Skip assertions on fields that don't exist on the result type.
1233    if let Some(f) = &assertion.field {
1234        if !f.is_empty() && !result_is_simple && !field_resolver.is_valid_for_result(f) {
1235            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1236            return;
1237        }
1238        // When result_is_simple is true, only the synthetic field `"result"` is
1239        // valid (whole-result assertion). Any other field would dereference a
1240        // primitive and TS would reject it.
1241        if !f.is_empty() && result_is_simple && f != "result" {
1242            let _ = writeln!(
1243                out,
1244                "    // skipped: field '{f}' not available on simple/scalar result type"
1245            );
1246            return;
1247        }
1248    }
1249
1250    // When the binding returns Vec<T>, index into [0] for single-element field
1251    // access. (Mirror the csharp codegen.) Most "batch" fixtures assert on the
1252    // first result's content/mime_type rather than enumerating every element.
1253    let effective_result_var = if result_is_vec {
1254        format!("{result_var}[0]")
1255    } else {
1256        result_var.to_string()
1257    };
1258    let field_expr = match &assertion.field {
1259        // When the binding returns a primitive, every assertion targets the bare
1260        // `result` variable — never `result.<field>`.
1261        Some(f) if !f.is_empty() && !result_is_simple => {
1262            field_resolver.accessor(f, "typescript", &effective_result_var)
1263        }
1264        _ => effective_result_var.clone(),
1265    };
1266
1267    match assertion.assertion_type.as_str() {
1268        "equals" => {
1269            if let Some(expected) = &assertion.value {
1270                let js_val = json_to_js(expected);
1271                // For string equality, trim trailing whitespace to handle trailing newlines
1272                // from the converter. Use null-coalescing for optional fields.
1273                if expected.is_string() {
1274                    let resolved = assertion.field.as_deref().unwrap_or("");
1275                    if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1276                        let _ = writeln!(out, "    expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
1277                    } else {
1278                        let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
1279                    }
1280                } else {
1281                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
1282                }
1283            }
1284        }
1285        "contains" => {
1286            if let Some(expected) = &assertion.value {
1287                let js_val = json_to_js(expected);
1288                // Use null-coalescing for optional string fields to handle null/undefined values.
1289                let resolved = assertion.field.as_deref().unwrap_or("");
1290                if !resolved.is_empty()
1291                    && expected.is_string()
1292                    && field_resolver.is_optional(field_resolver.resolve(resolved))
1293                {
1294                    let _ = writeln!(out, "    expect({field_expr} ?? \"\").toContain({js_val});");
1295                } else {
1296                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
1297                }
1298            }
1299        }
1300        "contains_all" => {
1301            if let Some(values) = &assertion.values {
1302                for val in values {
1303                    let js_val = json_to_js(val);
1304                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
1305                }
1306            }
1307        }
1308        "not_contains" => {
1309            if let Some(expected) = &assertion.value {
1310                let js_val = json_to_js(expected);
1311                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
1312            }
1313        }
1314        "not_empty" => {
1315            // `not_empty` semantics depend on the field's type:
1316            //   - Array/Vec   (in fields_array): assert `.length > 0`
1317            //   - String      (Rust `String`/`Option<String>`):    assert `.length > 0`
1318            //                 — strings are not in fields_array but the binding
1319            //                 emits `string`, so `.length` works
1320            //   - Struct      (e.g. `metadata`, `document`):       assert non-null
1321            //                 — structs have no `.length`, so emit a presence
1322            //                 check via `Object.keys(...).length > 0`. This
1323            //                 mirrors the kreuzberg core's `Metadata::is_empty`
1324            //                 semantics ("at least one populated field") without
1325            //                 hard-coding the field set.
1326            //
1327            // Detection: a field is treated as "array-like" when it is in
1328            // `fields_array` OR when the assertion expected value implies a
1329            // string (we can't see the value here for `not_empty`). Lacking
1330            // type info, we conservatively use the array path only when
1331            // `fields_array` claims the field; otherwise use the struct path.
1332            let resolved = assertion.field.as_deref().unwrap_or("");
1333            let resolved_path = field_resolver.resolve(resolved);
1334            let is_array = !resolved.is_empty() && field_resolver.is_array(resolved_path);
1335            let is_optional = !resolved.is_empty() && field_resolver.is_optional(resolved_path);
1336            if is_array {
1337                if is_optional {
1338                    let _ = writeln!(out, "    expect(({field_expr} ?? []).length).toBeGreaterThan(0);");
1339                } else {
1340                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
1341                }
1342            } else if resolved.is_empty() {
1343                // Whole-result `not_empty` — assume string-like length.
1344                let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
1345            } else {
1346                // Non-array, non-empty field path: the field could be a string
1347                // (`content`, `mimeType`, …) or a struct (`metadata`, `document`,
1348                // …). We can't infer the type at codegen time so we cast through
1349                // `unknown` and dispatch at runtime — strings/arrays use
1350                // `.length`, objects use `Object.keys(...).length`. The cast
1351                // avoids TS narrowing the conditional to `never` when the
1352                // declared type is a struct (string-typeof branch unreachable
1353                // statically) or vice-versa.
1354                let any_expr = format!("({field_expr} as unknown)");
1355                if is_optional {
1356                    let _ = writeln!(
1357                        out,
1358                        "    expect((() => {{ const v = {any_expr} ?? ''; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBeGreaterThan(0);"
1359                    );
1360                } else {
1361                    let _ = writeln!(
1362                        out,
1363                        "    expect((() => {{ const v = {any_expr}; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBeGreaterThan(0);"
1364                    );
1365                }
1366            }
1367        }
1368        "is_empty" => {
1369            let resolved = assertion.field.as_deref().unwrap_or("");
1370            let resolved_path = field_resolver.resolve(resolved);
1371            let is_array = !resolved.is_empty() && field_resolver.is_array(resolved_path);
1372            let is_optional = !resolved.is_empty() && field_resolver.is_optional(resolved_path);
1373            if is_array {
1374                if is_optional {
1375                    let _ = writeln!(out, "    expect(({field_expr} ?? []).length).toBe(0);");
1376                } else {
1377                    let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
1378                }
1379            } else if resolved.is_empty() {
1380                let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
1381            } else {
1382                let any_expr = format!("({field_expr} as unknown)");
1383                if is_optional {
1384                    let _ = writeln!(
1385                        out,
1386                        "    expect((() => {{ const v = {any_expr} ?? ''; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBe(0);"
1387                    );
1388                } else {
1389                    let _ = writeln!(
1390                        out,
1391                        "    expect((() => {{ const v = {any_expr}; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBe(0);"
1392                    );
1393                }
1394            }
1395        }
1396        "contains_any" => {
1397            if let Some(values) = &assertion.values {
1398                let items: Vec<String> = values.iter().map(json_to_js).collect();
1399                let arr_str = items.join(", ");
1400                let _ = writeln!(
1401                    out,
1402                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
1403                );
1404            }
1405        }
1406        "greater_than" => {
1407            if let Some(val) = &assertion.value {
1408                let js_val = json_to_js(val);
1409                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
1410            }
1411        }
1412        "less_than" => {
1413            if let Some(val) = &assertion.value {
1414                let js_val = json_to_js(val);
1415                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
1416            }
1417        }
1418        "greater_than_or_equal" => {
1419            if let Some(val) = &assertion.value {
1420                let js_val = json_to_js(val);
1421                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
1422            }
1423        }
1424        "less_than_or_equal" => {
1425            if let Some(val) = &assertion.value {
1426                let js_val = json_to_js(val);
1427                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
1428            }
1429        }
1430        "starts_with" => {
1431            if let Some(expected) = &assertion.value {
1432                let js_val = json_to_js(expected);
1433                // Use null-coalescing for optional fields to handle null/undefined values.
1434                let resolved = assertion.field.as_deref().unwrap_or("");
1435                if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1436                    let _ = writeln!(
1437                        out,
1438                        "    expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
1439                    );
1440                } else {
1441                    let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
1442                }
1443            }
1444        }
1445        "count_min" => {
1446            if let Some(val) = &assertion.value {
1447                if let Some(n) = val.as_u64() {
1448                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1449                }
1450            }
1451        }
1452        "count_equals" => {
1453            if let Some(val) = &assertion.value {
1454                if let Some(n) = val.as_u64() {
1455                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
1456                }
1457            }
1458        }
1459        "is_true" => {
1460            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
1461        }
1462        "is_false" => {
1463            let _ = writeln!(out, "    expect({field_expr}).toBe(false);");
1464        }
1465        "method_result" => {
1466            if let Some(method_name) = &assertion.method {
1467                let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
1468                let check = assertion.check.as_deref().unwrap_or("is_true");
1469                match check {
1470                    "equals" => {
1471                        if let Some(val) = &assertion.value {
1472                            let js_val = json_to_js(val);
1473                            let _ = writeln!(out, "    expect({call_expr}).toBe({js_val});");
1474                        }
1475                    }
1476                    "is_true" => {
1477                        let _ = writeln!(out, "    expect({call_expr}).toBe(true);");
1478                    }
1479                    "is_false" => {
1480                        let _ = writeln!(out, "    expect({call_expr}).toBe(false);");
1481                    }
1482                    "greater_than_or_equal" => {
1483                        if let Some(val) = &assertion.value {
1484                            let n = val.as_u64().unwrap_or(0);
1485                            let _ = writeln!(out, "    expect({call_expr}).toBeGreaterThanOrEqual({n});");
1486                        }
1487                    }
1488                    "count_min" => {
1489                        if let Some(val) = &assertion.value {
1490                            let n = val.as_u64().unwrap_or(0);
1491                            let _ = writeln!(out, "    expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
1492                        }
1493                    }
1494                    "contains" => {
1495                        if let Some(val) = &assertion.value {
1496                            let js_val = json_to_js(val);
1497                            let _ = writeln!(out, "    expect({call_expr}).toContain({js_val});");
1498                        }
1499                    }
1500                    "is_error" => {
1501                        let _ = writeln!(out, "    expect(() => {{ {call_expr}; }}).toThrow();");
1502                    }
1503                    other_check => {
1504                        panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
1505                    }
1506                }
1507            } else {
1508                panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
1509            }
1510        }
1511        "min_length" => {
1512            if let Some(val) = &assertion.value {
1513                if let Some(n) = val.as_u64() {
1514                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1515                }
1516            }
1517        }
1518        "max_length" => {
1519            if let Some(val) = &assertion.value {
1520                if let Some(n) = val.as_u64() {
1521                    let _ = writeln!(out, "    expect({field_expr}.length).toBeLessThanOrEqual({n});");
1522                }
1523            }
1524        }
1525        "ends_with" => {
1526            if let Some(expected) = &assertion.value {
1527                let js_val = json_to_js(expected);
1528                let _ = writeln!(out, "    expect({field_expr}.endsWith({js_val})).toBe(true);");
1529            }
1530        }
1531        "matches_regex" => {
1532            if let Some(expected) = &assertion.value {
1533                if let Some(pattern) = expected.as_str() {
1534                    let _ = writeln!(out, "    expect({field_expr}).toMatch(/{pattern}/);");
1535                }
1536            }
1537        }
1538        "not_error" => {
1539            // No-op — if we got here, the call succeeded (it would have thrown).
1540        }
1541        "error" => {
1542            // Handled at the test level (early return above).
1543        }
1544        other => {
1545            panic!("TypeScript e2e generator: unsupported assertion type: {other}");
1546        }
1547    }
1548}
1549
1550/// Build a TypeScript call expression for a method_result assertion on a tree-sitter Tree.
1551/// Maps method names to the appropriate TypeScript function calls or property accesses.
1552fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1553    match method_name {
1554        "root_child_count" => format!("{result_var}.rootNode.childCount"),
1555        "root_node_type" => format!("{result_var}.rootNode.type"),
1556        "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
1557        "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
1558        "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
1559        "tree_to_sexp" => format!("treeToSexp({result_var})"),
1560        "contains_node_type" => {
1561            let node_type = args
1562                .and_then(|a| a.get("node_type"))
1563                .and_then(|v| v.as_str())
1564                .unwrap_or("");
1565            format!("treeContainsNodeType({result_var}, \"{node_type}\")")
1566        }
1567        "find_nodes_by_type" => {
1568            let node_type = args
1569                .and_then(|a| a.get("node_type"))
1570                .and_then(|v| v.as_str())
1571                .unwrap_or("");
1572            format!("findNodesByType({result_var}, \"{node_type}\")")
1573        }
1574        "run_query" => {
1575            let query_source = args
1576                .and_then(|a| a.get("query_source"))
1577                .and_then(|v| v.as_str())
1578                .unwrap_or("");
1579            let language = args
1580                .and_then(|a| a.get("language"))
1581                .and_then(|v| v.as_str())
1582                .unwrap_or("");
1583            format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1584        }
1585        _ => {
1586            if let Some(args_val) = args {
1587                let arg_str = args_val
1588                    .as_object()
1589                    .map(|obj| {
1590                        obj.iter()
1591                            .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1592                            .collect::<Vec<_>>()
1593                            .join(", ")
1594                    })
1595                    .unwrap_or_default();
1596                format!("{result_var}.{method_name}({arg_str})")
1597            } else {
1598                format!("{result_var}.{method_name}()")
1599            }
1600        }
1601    }
1602}
1603
1604/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
1605///
1606/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
1607/// snake_case object keys (as written in fixture JSON) to camelCase so that the
1608/// generated config objects match the NAPI binding's expected field names.
1609fn json_to_js_camel(value: &serde_json::Value) -> String {
1610    match value {
1611        serde_json::Value::Object(map) => {
1612            let entries: Vec<String> = map
1613                .iter()
1614                .map(|(k, v)| {
1615                    let camel_key = snake_to_camel(k);
1616                    // Quote keys that aren't valid JS identifiers.
1617                    let key = if camel_key
1618                        .chars()
1619                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1620                        && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1621                    {
1622                        camel_key.clone()
1623                    } else {
1624                        format!("\"{}\"", escape_js(&camel_key))
1625                    };
1626                    format!("{key}: {}", json_to_js_camel(v))
1627                })
1628                .collect();
1629            format!("{{ {} }}", entries.join(", "))
1630        }
1631        serde_json::Value::Array(arr) => {
1632            let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1633            format!("[{}]", items.join(", "))
1634        }
1635        // Scalars and null delegate to the standard converter.
1636        other => json_to_js(other),
1637    }
1638}
1639
1640/// Convert a snake_case string to camelCase.
1641fn snake_to_camel(s: &str) -> String {
1642    let mut result = String::with_capacity(s.len());
1643    let mut capitalize_next = false;
1644    for ch in s.chars() {
1645        if ch == '_' {
1646            capitalize_next = true;
1647        } else if capitalize_next {
1648            result.extend(ch.to_uppercase());
1649            capitalize_next = false;
1650        } else {
1651            result.push(ch);
1652        }
1653    }
1654    result
1655}
1656
1657/// Convert a `serde_json::Value` to a JavaScript literal string.
1658fn json_to_js(value: &serde_json::Value) -> String {
1659    match value {
1660        serde_json::Value::String(s) => {
1661            let expanded = expand_fixture_templates(s);
1662            format!("\"{}\"", escape_js(&expanded))
1663        }
1664        serde_json::Value::Bool(b) => b.to_string(),
1665        serde_json::Value::Number(n) => {
1666            // For integers outside JS safe range, emit as string to avoid precision loss.
1667            if let Some(i) = n.as_i64() {
1668                if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&i) {
1669                    return format!("Number(\"{i}\")");
1670                }
1671            }
1672            if let Some(u) = n.as_u64() {
1673                if u > 9_007_199_254_740_991 {
1674                    return format!("Number(\"{u}\")");
1675                }
1676            }
1677            n.to_string()
1678        }
1679        serde_json::Value::Null => "null".to_string(),
1680        serde_json::Value::Array(arr) => {
1681            let items: Vec<String> = arr.iter().map(json_to_js).collect();
1682            format!("[{}]", items.join(", "))
1683        }
1684        serde_json::Value::Object(map) => {
1685            let entries: Vec<String> = map
1686                .iter()
1687                .map(|(k, v)| {
1688                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
1689                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1690                        && !k.starts_with(|c: char| c.is_ascii_digit())
1691                    {
1692                        k.clone()
1693                    } else {
1694                        format!("\"{}\"", escape_js(k))
1695                    };
1696                    format!("{key}: {}", json_to_js(v))
1697                })
1698                .collect();
1699            format!("{{ {} }}", entries.join(", "))
1700        }
1701    }
1702}
1703
1704// ---------------------------------------------------------------------------
1705// Visitor generation
1706// ---------------------------------------------------------------------------
1707
1708/// Build a TypeScript visitor object and add setup line. Returns the visitor variable name.
1709fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1710    use std::fmt::Write as FmtWrite;
1711    let mut visitor_obj = String::new();
1712    let _ = writeln!(visitor_obj, "{{");
1713    for (method_name, action) in &visitor_spec.callbacks {
1714        emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1715    }
1716    let _ = writeln!(visitor_obj, "    }}");
1717
1718    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1719    "_testVisitor".to_string()
1720}
1721
1722/// Emit a TypeScript visitor method for a callback action.
1723fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1724    use std::fmt::Write as FmtWrite;
1725
1726    let camel_method = to_camel_case(method_name);
1727    let params = match method_name {
1728        "visit_link" => "ctx, href, text, title",
1729        "visit_image" => "ctx, src, alt, title",
1730        "visit_heading" => "ctx, level, text, id",
1731        "visit_code_block" => "ctx, lang, code",
1732        "visit_code_inline"
1733        | "visit_strong"
1734        | "visit_emphasis"
1735        | "visit_strikethrough"
1736        | "visit_underline"
1737        | "visit_subscript"
1738        | "visit_superscript"
1739        | "visit_mark"
1740        | "visit_button"
1741        | "visit_summary"
1742        | "visit_figcaption"
1743        | "visit_definition_term"
1744        | "visit_definition_description" => "ctx, text",
1745        "visit_text" => "ctx, text",
1746        "visit_list_item" => "ctx, ordered, marker, text",
1747        "visit_blockquote" => "ctx, content, depth",
1748        "visit_table_row" => "ctx, cells, isHeader",
1749        "visit_custom_element" => "ctx, tagName, html",
1750        "visit_form" => "ctx, actionUrl, method",
1751        "visit_input" => "ctx, inputType, name, value",
1752        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1753        "visit_details" => "ctx, isOpen",
1754        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1755        "visit_list_start" => "ctx, ordered",
1756        "visit_list_end" => "ctx, ordered, output",
1757        _ => "ctx",
1758    };
1759
1760    let _ = writeln!(out, "    {camel_method}({params}): string | {{ custom: string }} {{");
1761    match action {
1762        CallbackAction::Skip => {
1763            let _ = writeln!(out, "        return \"skip\";");
1764        }
1765        CallbackAction::Continue => {
1766            let _ = writeln!(out, "        return \"continue\";");
1767        }
1768        CallbackAction::PreserveHtml => {
1769            let _ = writeln!(out, "        return \"preserve_html\";");
1770        }
1771        CallbackAction::Custom { output } => {
1772            let escaped = escape_js(output);
1773            let _ = writeln!(out, "        return {{ custom: \"{escaped}\" }};");
1774        }
1775        CallbackAction::CustomTemplate { template } => {
1776            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
1777        }
1778    }
1779    let _ = writeln!(out, "    }},");
1780}
1781
1782/// Convert snake_case to camelCase for method names.
1783fn to_camel_case(snake: &str) -> String {
1784    use heck::ToLowerCamelCase;
1785    snake.to_lower_camel_case()
1786}
1787
1788/// How to represent a fixture `type = "bytes"` string value in generated TypeScript.
1789///
1790/// Mirrors the classification in `python.rs` and `rust.rs`. Three patterns appear in
1791/// fixtures:
1792///   1. **File path** — `"pdf/fake_memo.pdf"`. Loaded with `readFileSync(path)` from
1793///      the working directory (`setup.ts` chdirs to `test_documents/`).
1794///   2. **Inline text** — `"<!DOCTYPE html>..."`, `"{...}"`, prose with whitespace.
1795///      Encoded with `Buffer.from(s, "utf-8")`.
1796///   3. **Base64** — `"/9j/4AAQ..."` and other opaque short strings.
1797///      Decoded with `Buffer.from(s, "base64")`.
1798enum BytesKind {
1799    FilePath,
1800    InlineText,
1801    Base64,
1802}
1803
1804/// Classify a fixture string value that maps to a `bytes` argument.
1805///
1806/// Rules (in order):
1807/// 1. Starts with `<`, `{`, `[`, or contains whitespace → inline text.
1808/// 2. First character is an ASCII word character AND value contains a `/` AND the
1809///    portion after the last `/` contains a `.` → file path.
1810/// 3. Everything else → base64.
1811fn classify_bytes_value(s: &str) -> BytesKind {
1812    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1813        return BytesKind::InlineText;
1814    }
1815    let first = s.chars().next().unwrap_or('\0');
1816    if first.is_ascii_alphanumeric() || first == '_' {
1817        if let Some(slash_pos) = s.find('/') {
1818            if slash_pos > 0 {
1819                let after_slash = &s[slash_pos + 1..];
1820                if after_slash.contains('.') && !after_slash.is_empty() {
1821                    return BytesKind::FilePath;
1822                }
1823            }
1824        }
1825    }
1826    BytesKind::Base64
1827}