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