Skip to main content

alef_e2e/codegen/
typescript.rs

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