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