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