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(|| call.function.clone());
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 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 files.push(GeneratedFile {
82 path: output_base.join("tsconfig.json"),
83 content: render_tsconfig(),
84 generated_header: false,
85 });
86
87 let needs_global_setup = client_factory.is_some() || has_http_fixtures;
89
90 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 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 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 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; 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 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test() && !f.assertions.is_empty());
299
300 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 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 if has_non_http_fixtures {
323 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 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 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 let _ = module_path; 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 let _ = has_http_fixtures;
399
400 let _ = writeln!(out, "}});");
401 out
402}
403
404fn 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
413fn 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 _ => None,
426 }
427}
428
429fn 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 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 let mut init_entries: Vec<String> = Vec::new();
459 init_entries.push(format!("method: '{method}'"));
460 init_entries.push("redirect: 'manual'".to_string());
462
463 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 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 let status = http.expected_response.status_code;
495 let _ = writeln!(out, " expect(response.status).toBe({status});");
496
497 if let Some(expected_body) = &http.expected_response.body {
499 if !(expected_body.is_null() || expected_body.is_string() && expected_body.as_str() == Some("")) {
501 if let serde_json::Value::String(s) = expected_body {
502 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 for (header_name, header_value) in &http.expected_response.headers {
528 let lower_name = header_name.to_lowercase();
529 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 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#[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 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 let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
614
615 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 let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
637
638 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
640
641 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 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 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 for assertion in &fixture.assertions {
695 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
705fn 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 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 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 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 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 continue;
767 }
768 None | Some(serde_json::Value::Null) => {
769 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 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 if let Some(f) = &assertion.field {
799 match f.as_str() {
800 "chunks_have_content" => {
801 let pred = format!("({result_var}.chunks ?? []).every((c: {{ content?: string }}) => !!c.content)");
802 match assertion.assertion_type.as_str() {
803 "is_true" => {
804 let _ = writeln!(out, " expect({pred}).toBe(true);");
805 }
806 "is_false" => {
807 let _ = writeln!(out, " expect({pred}).toBe(false);");
808 }
809 _ => {
810 let _ = writeln!(
811 out,
812 " // skipped: unsupported assertion type on synthetic field '{f}'"
813 );
814 }
815 }
816 return;
817 }
818 "chunks_have_embeddings" => {
819 let pred = format!(
820 "({result_var}.chunks ?? []).every((c: {{ embedding?: number[] }}) => c.embedding != null && c.embedding.length > 0)"
821 );
822 match assertion.assertion_type.as_str() {
823 "is_true" => {
824 let _ = writeln!(out, " expect({pred}).toBe(true);");
825 }
826 "is_false" => {
827 let _ = writeln!(out, " expect({pred}).toBe(false);");
828 }
829 _ => {
830 let _ = writeln!(
831 out,
832 " // skipped: unsupported assertion type on synthetic field '{f}'"
833 );
834 }
835 }
836 return;
837 }
838 "embeddings" => {
842 match assertion.assertion_type.as_str() {
843 "count_equals" => {
844 if let Some(val) = &assertion.value {
845 let js_val = json_to_js(val);
846 let _ = writeln!(out, " expect({result_var}.length).toBe({js_val});");
847 }
848 }
849 "count_min" => {
850 if let Some(val) = &assertion.value {
851 let js_val = json_to_js(val);
852 let _ = writeln!(out, " expect({result_var}.length).toBeGreaterThanOrEqual({js_val});");
853 }
854 }
855 "not_empty" => {
856 let _ = writeln!(out, " expect({result_var}.length).toBeGreaterThan(0);");
857 }
858 "is_empty" => {
859 let _ = writeln!(out, " expect({result_var}.length).toBe(0);");
860 }
861 _ => {
862 let _ = writeln!(
863 out,
864 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
865 );
866 }
867 }
868 return;
869 }
870 "embedding_dimensions" => {
871 let expr = format!("({result_var}.length > 0 ? {result_var}[0].length : 0)");
872 match assertion.assertion_type.as_str() {
873 "equals" => {
874 if let Some(val) = &assertion.value {
875 let js_val = json_to_js(val);
876 let _ = writeln!(out, " expect({expr}).toBe({js_val});");
877 }
878 }
879 "greater_than" => {
880 if let Some(val) = &assertion.value {
881 let js_val = json_to_js(val);
882 let _ = writeln!(out, " expect({expr}).toBeGreaterThan({js_val});");
883 }
884 }
885 _ => {
886 let _ = writeln!(
887 out,
888 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
889 );
890 }
891 }
892 return;
893 }
894 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
895 let pred = match f.as_str() {
896 "embeddings_valid" => {
897 format!("{result_var}.every((e: number[]) => e.length > 0)")
898 }
899 "embeddings_finite" => {
900 format!("{result_var}.every((e: number[]) => e.every((v: number) => isFinite(v)))")
901 }
902 "embeddings_non_zero" => {
903 format!("{result_var}.every((e: number[]) => e.some((v: number) => v !== 0))")
904 }
905 "embeddings_normalized" => {
906 format!(
907 "{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; }})"
908 )
909 }
910 _ => unreachable!(),
911 };
912 match assertion.assertion_type.as_str() {
913 "is_true" => {
914 let _ = writeln!(out, " expect({pred}).toBe(true);");
915 }
916 "is_false" => {
917 let _ = writeln!(out, " expect({pred}).toBe(false);");
918 }
919 _ => {
920 let _ = writeln!(
921 out,
922 " // skipped: unsupported assertion type on synthetic field '{f}'"
923 );
924 }
925 }
926 return;
927 }
928 "keywords" | "keywords_count" => {
931 let _ = writeln!(
932 out,
933 " // skipped: field '{f}' not available on Node JsExtractionResult"
934 );
935 return;
936 }
937 _ => {}
938 }
939 }
940
941 if let Some(f) = &assertion.field {
943 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
944 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
945 return;
946 }
947 }
948
949 let field_expr = match &assertion.field {
950 Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
951 _ => result_var.to_string(),
952 };
953
954 match assertion.assertion_type.as_str() {
955 "equals" => {
956 if let Some(expected) = &assertion.value {
957 let js_val = json_to_js(expected);
958 if expected.is_string() {
961 let resolved = assertion.field.as_deref().unwrap_or("");
962 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
963 let _ = writeln!(out, " expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
964 } else {
965 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
966 }
967 } else {
968 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
969 }
970 }
971 }
972 "contains" => {
973 if let Some(expected) = &assertion.value {
974 let js_val = json_to_js(expected);
975 let resolved = assertion.field.as_deref().unwrap_or("");
977 if !resolved.is_empty()
978 && expected.is_string()
979 && field_resolver.is_optional(field_resolver.resolve(resolved))
980 {
981 let _ = writeln!(out, " expect({field_expr} ?? \"\").toContain({js_val});");
982 } else {
983 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
984 }
985 }
986 }
987 "contains_all" => {
988 if let Some(values) = &assertion.values {
989 for val in values {
990 let js_val = json_to_js(val);
991 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
992 }
993 }
994 }
995 "not_contains" => {
996 if let Some(expected) = &assertion.value {
997 let js_val = json_to_js(expected);
998 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
999 }
1000 }
1001 "not_empty" => {
1002 let resolved = assertion.field.as_deref().unwrap_or("");
1004 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1005 let _ = writeln!(out, " expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
1006 } else {
1007 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
1008 }
1009 }
1010 "is_empty" => {
1011 let resolved = assertion.field.as_deref().unwrap_or("");
1013 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1014 let _ = writeln!(out, " expect({field_expr} ?? \"\").toHaveLength(0);");
1015 } else {
1016 let _ = writeln!(out, " expect({field_expr}).toHaveLength(0);");
1017 }
1018 }
1019 "contains_any" => {
1020 if let Some(values) = &assertion.values {
1021 let items: Vec<String> = values.iter().map(json_to_js).collect();
1022 let arr_str = items.join(", ");
1023 let _ = writeln!(
1024 out,
1025 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
1026 );
1027 }
1028 }
1029 "greater_than" => {
1030 if let Some(val) = &assertion.value {
1031 let js_val = json_to_js(val);
1032 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
1033 }
1034 }
1035 "less_than" => {
1036 if let Some(val) = &assertion.value {
1037 let js_val = json_to_js(val);
1038 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
1039 }
1040 }
1041 "greater_than_or_equal" => {
1042 if let Some(val) = &assertion.value {
1043 let js_val = json_to_js(val);
1044 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
1045 }
1046 }
1047 "less_than_or_equal" => {
1048 if let Some(val) = &assertion.value {
1049 let js_val = json_to_js(val);
1050 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
1051 }
1052 }
1053 "starts_with" => {
1054 if let Some(expected) = &assertion.value {
1055 let js_val = json_to_js(expected);
1056 let resolved = assertion.field.as_deref().unwrap_or("");
1058 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1059 let _ = writeln!(
1060 out,
1061 " expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
1062 );
1063 } else {
1064 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
1065 }
1066 }
1067 }
1068 "count_min" => {
1069 if let Some(val) = &assertion.value {
1070 if let Some(n) = val.as_u64() {
1071 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1072 }
1073 }
1074 }
1075 "count_equals" => {
1076 if let Some(val) = &assertion.value {
1077 if let Some(n) = val.as_u64() {
1078 let _ = writeln!(out, " expect({field_expr}.length).toBe({n});");
1079 }
1080 }
1081 }
1082 "is_true" => {
1083 let _ = writeln!(out, " expect({field_expr}).toBe(true);");
1084 }
1085 "is_false" => {
1086 let _ = writeln!(out, " expect({field_expr}).toBe(false);");
1087 }
1088 "method_result" => {
1089 if let Some(method_name) = &assertion.method {
1090 let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
1091 let check = assertion.check.as_deref().unwrap_or("is_true");
1092 match check {
1093 "equals" => {
1094 if let Some(val) = &assertion.value {
1095 let js_val = json_to_js(val);
1096 let _ = writeln!(out, " expect({call_expr}).toBe({js_val});");
1097 }
1098 }
1099 "is_true" => {
1100 let _ = writeln!(out, " expect({call_expr}).toBe(true);");
1101 }
1102 "is_false" => {
1103 let _ = writeln!(out, " expect({call_expr}).toBe(false);");
1104 }
1105 "greater_than_or_equal" => {
1106 if let Some(val) = &assertion.value {
1107 let n = val.as_u64().unwrap_or(0);
1108 let _ = writeln!(out, " expect({call_expr}).toBeGreaterThanOrEqual({n});");
1109 }
1110 }
1111 "count_min" => {
1112 if let Some(val) = &assertion.value {
1113 let n = val.as_u64().unwrap_or(0);
1114 let _ = writeln!(out, " expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
1115 }
1116 }
1117 "contains" => {
1118 if let Some(val) = &assertion.value {
1119 let js_val = json_to_js(val);
1120 let _ = writeln!(out, " expect({call_expr}).toContain({js_val});");
1121 }
1122 }
1123 "is_error" => {
1124 let _ = writeln!(out, " expect(() => {{ {call_expr}; }}).toThrow();");
1125 }
1126 other_check => {
1127 panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
1128 }
1129 }
1130 } else {
1131 panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
1132 }
1133 }
1134 "min_length" => {
1135 if let Some(val) = &assertion.value {
1136 if let Some(n) = val.as_u64() {
1137 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1138 }
1139 }
1140 }
1141 "max_length" => {
1142 if let Some(val) = &assertion.value {
1143 if let Some(n) = val.as_u64() {
1144 let _ = writeln!(out, " expect({field_expr}.length).toBeLessThanOrEqual({n});");
1145 }
1146 }
1147 }
1148 "ends_with" => {
1149 if let Some(expected) = &assertion.value {
1150 let js_val = json_to_js(expected);
1151 let _ = writeln!(out, " expect({field_expr}.endsWith({js_val})).toBe(true);");
1152 }
1153 }
1154 "matches_regex" => {
1155 if let Some(expected) = &assertion.value {
1156 if let Some(pattern) = expected.as_str() {
1157 let _ = writeln!(out, " expect({field_expr}).toMatch(/{pattern}/);");
1158 }
1159 }
1160 }
1161 "not_error" => {
1162 }
1164 "error" => {
1165 }
1167 other => {
1168 panic!("TypeScript e2e generator: unsupported assertion type: {other}");
1169 }
1170 }
1171}
1172
1173fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1176 match method_name {
1177 "root_child_count" => format!("{result_var}.rootNode.childCount"),
1178 "root_node_type" => format!("{result_var}.rootNode.type"),
1179 "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
1180 "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
1181 "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
1182 "tree_to_sexp" => format!("treeToSexp({result_var})"),
1183 "contains_node_type" => {
1184 let node_type = args
1185 .and_then(|a| a.get("node_type"))
1186 .and_then(|v| v.as_str())
1187 .unwrap_or("");
1188 format!("treeContainsNodeType({result_var}, \"{node_type}\")")
1189 }
1190 "find_nodes_by_type" => {
1191 let node_type = args
1192 .and_then(|a| a.get("node_type"))
1193 .and_then(|v| v.as_str())
1194 .unwrap_or("");
1195 format!("findNodesByType({result_var}, \"{node_type}\")")
1196 }
1197 "run_query" => {
1198 let query_source = args
1199 .and_then(|a| a.get("query_source"))
1200 .and_then(|v| v.as_str())
1201 .unwrap_or("");
1202 let language = args
1203 .and_then(|a| a.get("language"))
1204 .and_then(|v| v.as_str())
1205 .unwrap_or("");
1206 format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1207 }
1208 _ => {
1209 if let Some(args_val) = args {
1210 let arg_str = args_val
1211 .as_object()
1212 .map(|obj| {
1213 obj.iter()
1214 .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1215 .collect::<Vec<_>>()
1216 .join(", ")
1217 })
1218 .unwrap_or_default();
1219 format!("{result_var}.{method_name}({arg_str})")
1220 } else {
1221 format!("{result_var}.{method_name}()")
1222 }
1223 }
1224 }
1225}
1226
1227fn json_to_js_camel(value: &serde_json::Value) -> String {
1233 match value {
1234 serde_json::Value::Object(map) => {
1235 let entries: Vec<String> = map
1236 .iter()
1237 .map(|(k, v)| {
1238 let camel_key = snake_to_camel(k);
1239 let key = if camel_key
1241 .chars()
1242 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1243 && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1244 {
1245 camel_key.clone()
1246 } else {
1247 format!("\"{}\"", escape_js(&camel_key))
1248 };
1249 format!("{key}: {}", json_to_js_camel(v))
1250 })
1251 .collect();
1252 format!("{{ {} }}", entries.join(", "))
1253 }
1254 serde_json::Value::Array(arr) => {
1255 let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1256 format!("[{}]", items.join(", "))
1257 }
1258 other => json_to_js(other),
1260 }
1261}
1262
1263fn snake_to_camel(s: &str) -> String {
1265 let mut result = String::with_capacity(s.len());
1266 let mut capitalize_next = false;
1267 for ch in s.chars() {
1268 if ch == '_' {
1269 capitalize_next = true;
1270 } else if capitalize_next {
1271 result.extend(ch.to_uppercase());
1272 capitalize_next = false;
1273 } else {
1274 result.push(ch);
1275 }
1276 }
1277 result
1278}
1279
1280fn json_to_js(value: &serde_json::Value) -> String {
1282 match value {
1283 serde_json::Value::String(s) => {
1284 let expanded = expand_fixture_templates(s);
1285 format!("\"{}\"", escape_js(&expanded))
1286 }
1287 serde_json::Value::Bool(b) => b.to_string(),
1288 serde_json::Value::Number(n) => {
1289 if let Some(i) = n.as_i64() {
1291 if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&i) {
1292 return format!("Number(\"{i}\")");
1293 }
1294 }
1295 if let Some(u) = n.as_u64() {
1296 if u > 9_007_199_254_740_991 {
1297 return format!("Number(\"{u}\")");
1298 }
1299 }
1300 n.to_string()
1301 }
1302 serde_json::Value::Null => "null".to_string(),
1303 serde_json::Value::Array(arr) => {
1304 let items: Vec<String> = arr.iter().map(json_to_js).collect();
1305 format!("[{}]", items.join(", "))
1306 }
1307 serde_json::Value::Object(map) => {
1308 let entries: Vec<String> = map
1309 .iter()
1310 .map(|(k, v)| {
1311 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1313 && !k.starts_with(|c: char| c.is_ascii_digit())
1314 {
1315 k.clone()
1316 } else {
1317 format!("\"{}\"", escape_js(k))
1318 };
1319 format!("{key}: {}", json_to_js(v))
1320 })
1321 .collect();
1322 format!("{{ {} }}", entries.join(", "))
1323 }
1324 }
1325}
1326
1327fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1333 use std::fmt::Write as FmtWrite;
1334 let mut visitor_obj = String::new();
1335 let _ = writeln!(visitor_obj, "{{");
1336 for (method_name, action) in &visitor_spec.callbacks {
1337 emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1338 }
1339 let _ = writeln!(visitor_obj, " }}");
1340
1341 setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1342 "_testVisitor".to_string()
1343}
1344
1345fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1347 use std::fmt::Write as FmtWrite;
1348
1349 let camel_method = to_camel_case(method_name);
1350 let params = match method_name {
1351 "visit_link" => "ctx, href, text, title",
1352 "visit_image" => "ctx, src, alt, title",
1353 "visit_heading" => "ctx, level, text, id",
1354 "visit_code_block" => "ctx, lang, code",
1355 "visit_code_inline"
1356 | "visit_strong"
1357 | "visit_emphasis"
1358 | "visit_strikethrough"
1359 | "visit_underline"
1360 | "visit_subscript"
1361 | "visit_superscript"
1362 | "visit_mark"
1363 | "visit_button"
1364 | "visit_summary"
1365 | "visit_figcaption"
1366 | "visit_definition_term"
1367 | "visit_definition_description" => "ctx, text",
1368 "visit_text" => "ctx, text",
1369 "visit_list_item" => "ctx, ordered, marker, text",
1370 "visit_blockquote" => "ctx, content, depth",
1371 "visit_table_row" => "ctx, cells, isHeader",
1372 "visit_custom_element" => "ctx, tagName, html",
1373 "visit_form" => "ctx, actionUrl, method",
1374 "visit_input" => "ctx, inputType, name, value",
1375 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1376 "visit_details" => "ctx, isOpen",
1377 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1378 "visit_list_start" => "ctx, ordered",
1379 "visit_list_end" => "ctx, ordered, output",
1380 _ => "ctx",
1381 };
1382
1383 let _ = writeln!(
1384 out,
1385 " {camel_method}({params}): string | {{{{ custom: string }}}} {{"
1386 );
1387 match action {
1388 CallbackAction::Skip => {
1389 let _ = writeln!(out, " return \"skip\";");
1390 }
1391 CallbackAction::Continue => {
1392 let _ = writeln!(out, " return \"continue\";");
1393 }
1394 CallbackAction::PreserveHtml => {
1395 let _ = writeln!(out, " return \"preserve_html\";");
1396 }
1397 CallbackAction::Custom { output } => {
1398 let escaped = escape_js(output);
1399 let _ = writeln!(out, " return {{ custom: {escaped} }};");
1400 }
1401 CallbackAction::CustomTemplate { template } => {
1402 let _ = writeln!(out, " return {{ custom: `{template}` }};");
1403 }
1404 }
1405 let _ = writeln!(out, " }},");
1406}
1407
1408fn to_camel_case(snake: &str) -> String {
1410 use heck::ToLowerCamelCase;
1411 snake.to_lower_camel_case()
1412}