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