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