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, ValidationErrorExpectation};
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;
17use super::client;
18
19pub struct TypeScriptCodegen;
21
22impl E2eCodegen for TypeScriptCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 _alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
30 let tests_base = output_base.join("tests");
31
32 let mut files = Vec::new();
33
34 let call = &e2e_config.call;
36 let overrides = call.overrides.get("node");
37 let module_path = overrides
38 .and_then(|o| o.module.as_ref())
39 .cloned()
40 .unwrap_or_else(|| call.module.clone());
41 let function_name = overrides
42 .and_then(|o| o.function.as_ref())
43 .cloned()
44 .unwrap_or_else(|| snake_to_camel(&call.function));
45 let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
46
47 let node_pkg = e2e_config.resolve_package("node");
49 let pkg_path = node_pkg
50 .as_ref()
51 .and_then(|p| p.path.as_ref())
52 .cloned()
53 .unwrap_or_else(|| "../../packages/typescript".to_string());
54 let pkg_name = node_pkg
55 .as_ref()
56 .and_then(|p| p.name.as_ref())
57 .cloned()
58 .unwrap_or_else(|| module_path.clone());
59 let pkg_version = node_pkg
60 .as_ref()
61 .and_then(|p| p.version.as_ref())
62 .cloned()
63 .unwrap_or_else(|| "0.1.0".to_string());
64
65 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
67
68 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
71 let cc = e2e_config.resolve_call(f.call.as_deref());
72 cc.args
73 .iter()
74 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
75 });
76
77 files.push(GeneratedFile {
79 path: output_base.join("package.json"),
80 content: render_package_json(
81 &pkg_name,
82 &pkg_path,
83 &pkg_version,
84 e2e_config.dep_mode,
85 has_http_fixtures,
86 ),
87 generated_header: false,
88 });
89
90 files.push(GeneratedFile {
92 path: output_base.join("tsconfig.json"),
93 content: render_tsconfig(),
94 generated_header: false,
95 });
96
97 let needs_global_setup = client_factory.is_some() || has_http_fixtures;
99
100 files.push(GeneratedFile {
102 path: output_base.join("vitest.config.ts"),
103 content: render_vitest_config(needs_global_setup, has_file_fixtures),
104 generated_header: true,
105 });
106
107 if needs_global_setup {
109 files.push(GeneratedFile {
110 path: output_base.join("globalSetup.ts"),
111 content: render_global_setup(),
112 generated_header: true,
113 });
114 }
115
116 if has_file_fixtures {
118 files.push(GeneratedFile {
119 path: output_base.join("setup.ts"),
120 content: render_file_setup(),
121 generated_header: true,
122 });
123 }
124
125 let options_type = overrides.and_then(|o| o.options_type.clone());
127 let field_resolver = FieldResolver::new(
128 &e2e_config.fields,
129 &e2e_config.fields_optional,
130 &e2e_config.result_fields,
131 &e2e_config.fields_array,
132 );
133
134 for group in groups {
136 let lang_filter = self.language_name();
137 let active: Vec<&Fixture> = group
138 .fixtures
139 .iter()
140 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang_filter)))
141 .filter(|f| {
146 let cc = e2e_config.resolve_call(f.call.as_deref());
147 !cc.skip_languages.iter().any(|l| l == lang_filter)
148 })
149 .collect();
150
151 if active.is_empty() {
152 continue;
153 }
154
155 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
156 let content = render_test_file(
157 self.language_name(),
158 &group.category,
159 &active,
160 &module_path,
161 &pkg_name,
162 &function_name,
163 &e2e_config.call.args,
164 options_type.as_deref(),
165 &field_resolver,
166 client_factory,
167 e2e_config,
168 );
169 files.push(GeneratedFile {
170 path: tests_base.join(filename),
171 content,
172 generated_header: true,
173 });
174 }
175
176 Ok(files)
177 }
178
179 fn language_name(&self) -> &'static str {
180 "node"
181 }
182}
183
184fn render_package_json(
185 pkg_name: &str,
186 _pkg_path: &str,
187 pkg_version: &str,
188 dep_mode: crate::config::DependencyMode,
189 has_http_fixtures: bool,
190) -> String {
191 let dep_value = match dep_mode {
192 crate::config::DependencyMode::Registry => pkg_version.to_string(),
193 crate::config::DependencyMode::Local => "workspace:*".to_string(),
194 };
195 let _ = has_http_fixtures; format!(
197 r#"{{
198 "name": "{pkg_name}-e2e-typescript",
199 "version": "0.1.0",
200 "private": true,
201 "type": "module",
202 "scripts": {{
203 "test": "vitest run"
204 }},
205 "devDependencies": {{
206 "{pkg_name}": "{dep_value}",
207 "vitest": "{vitest}"
208 }}
209}}
210"#,
211 vitest = tv::npm::VITEST,
212 )
213}
214
215fn render_tsconfig() -> String {
216 r#"{
217 "compilerOptions": {
218 "target": "ES2022",
219 "module": "ESNext",
220 "moduleResolution": "bundler",
221 "strict": true,
222 "strictNullChecks": false,
223 "esModuleInterop": true,
224 "skipLibCheck": true
225 },
226 "include": ["tests/**/*.ts", "vitest.config.ts"]
227}
228"#
229 .to_string()
230}
231
232fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
233 let header = hash::header(CommentStyle::DoubleSlash);
234 let setup_files_line = if with_file_setup {
235 " setupFiles: ['./setup.ts'],\n"
236 } else {
237 ""
238 };
239 if with_global_setup {
240 format!(
241 r#"{header}import {{ defineConfig }} from 'vitest/config';
242
243export default defineConfig({{
244 test: {{
245 include: ['tests/**/*.test.ts'],
246 globalSetup: './globalSetup.ts',
247{setup_files_line} }},
248}});
249"#
250 )
251 } else {
252 format!(
253 r#"{header}import {{ defineConfig }} from 'vitest/config';
254
255export default defineConfig({{
256 test: {{
257 include: ['tests/**/*.test.ts'],
258{setup_files_line} }},
259}});
260"#
261 )
262 }
263}
264
265fn render_file_setup() -> String {
266 let header = hash::header(CommentStyle::DoubleSlash);
267 header
268 + r#"import { fileURLToPath } from 'url';
269import { dirname, join } from 'path';
270
271// Change to the test_documents directory so that fixture file paths like
272// "pdf/fake_memo.pdf" resolve correctly when running vitest from e2e/node/.
273// setup.ts lives in e2e/node/; test_documents lives at the repository root,
274// two directories up: e2e/node/ -> e2e/ -> repo root -> test_documents/.
275const __filename = fileURLToPath(import.meta.url);
276const __dirname = dirname(__filename);
277const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
278process.chdir(testDocumentsDir);
279"#
280}
281
282fn render_global_setup() -> String {
283 let header = hash::header(CommentStyle::DoubleSlash);
284 header
285 + r#"import { spawn } from 'child_process';
286import { resolve } from 'path';
287
288let serverProcess: any;
289
290// HTTP client wrapper for making requests to mock server
291const createApp = (baseUrl: string) => ({
292 async request(path: string, init?: RequestInit): Promise<Response> {
293 const url = new URL(path, baseUrl);
294 return fetch(url.toString(), init);
295 },
296});
297
298export async function setup() {
299 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
300 serverProcess = spawn(
301 resolve(__dirname, '../rust/target/release/mock-server'),
302 [resolve(__dirname, '../../fixtures')],
303 { stdio: ['pipe', 'pipe', 'inherit'] }
304 );
305
306 const url = await new Promise<string>((resolve, reject) => {
307 serverProcess.stdout.on('data', (data: any) => {
308 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
309 if (match) resolve(match[1].trim());
310 });
311 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
312 });
313
314 process.env.MOCK_SERVER_URL = url;
315
316 // Make app available globally to all tests
317 (globalThis as any).app = createApp(url);
318}
319
320export async function teardown() {
321 if (serverProcess) {
322 serverProcess.stdin.end();
323 serverProcess.kill();
324 }
325}
326"#
327}
328
329#[allow(clippy::too_many_arguments)]
330pub(super) fn render_test_file(
331 lang: &str,
332 category: &str,
333 fixtures: &[&Fixture],
334 module_path: &str,
335 pkg_name: &str,
336 function_name: &str,
337 args: &[crate::config::ArgMapping],
338 options_type: Option<&str>,
339 field_resolver: &FieldResolver,
340 client_factory: Option<&str>,
341 e2e_config: &E2eConfig,
342) -> String {
343 let mut out = String::new();
344 out.push_str(&hash::header(CommentStyle::DoubleSlash));
345 let _ = writeln!(out, "import {{ describe, expect, it }} from 'vitest';");
346
347 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
348 let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test() && !f.assertions.is_empty());
351
352 let mut needed_options_types: Vec<String> = Vec::new();
361 let push_unique = |v: &mut Vec<String>, name: String| {
362 if !v.contains(&name) {
363 v.push(name);
364 }
365 };
366 for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
367 let resolved = e2e_config.resolve_call(fixture.call.as_deref());
368 let call_args = if fixture.call.is_some() { &resolved.args } else { args };
369 let per_call_options_type = resolved.overrides.get(lang).and_then(|o| o.options_type.clone());
374 let fixture_options_type: Option<String> =
375 per_call_options_type.or_else(|| options_type.map(|s| s.to_string()));
376 let Some(opts_type) = fixture_options_type else {
377 continue;
378 };
379 let any_object_or_missing_optional = call_args.iter().any(|arg| {
383 if arg.arg_type != "json_object" {
384 return false;
385 }
386 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
387 let val = if field == "input" {
388 Some(&fixture.input)
389 } else {
390 fixture.input.get(field)
391 };
392 match val {
393 Some(v) if v.is_object() => true,
394 None | Some(serde_json::Value::Null) => arg.optional,
395 _ => false,
396 }
397 });
398 if any_object_or_missing_optional {
399 push_unique(&mut needed_options_types, opts_type);
400 }
401 }
402
403 let handle_constructors: Vec<String> = args
405 .iter()
406 .filter(|arg| arg.arg_type == "handle")
407 .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
408 .collect();
409
410 let needs_fs_import = fixtures.iter().filter(|f| !f.is_http_test()).any(|f| {
413 let resolved = e2e_config.resolve_call(f.call.as_deref());
414 let call_args = if f.call.is_some() { &resolved.args } else { args };
415 call_args.iter().any(|arg| {
416 if arg.arg_type != "bytes" {
417 return false;
418 }
419 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
420 let val = if field == "input" {
421 Some(&f.input)
422 } else {
423 f.input.get(field)
424 };
425 val.and_then(|v| v.as_str())
426 .is_some_and(|s| matches!(classify_bytes_value(s), BytesKind::FilePath))
427 })
428 });
429
430 if needs_fs_import {
431 let _ = writeln!(out, "import {{ readFileSync }} from 'node:fs';");
432 }
433
434 if has_non_http_fixtures {
436 let mut imports: Vec<String> = if let Some(factory) = client_factory {
438 vec![factory.to_string()]
439 } else {
440 vec![function_name.to_string()]
441 };
442
443 for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
445 if fixture.call.is_some() {
446 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
447 let fixture_fn = resolve_node_function_name(call_config, lang);
448 if client_factory.is_none() && !imports.contains(&fixture_fn) {
449 imports.push(fixture_fn);
450 }
451 }
452 }
453
454 for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
456 for assertion in &fixture.assertions {
457 if assertion.assertion_type == "method_result" {
458 if let Some(method_name) = &assertion.method {
459 let helper = ts_method_helper_import(method_name);
460 if let Some(helper_fn) = helper {
461 if !imports.contains(&helper_fn) {
462 imports.push(helper_fn);
463 }
464 }
465 }
466 }
467 }
468 }
469
470 for ctor in &handle_constructors {
471 if !imports.contains(ctor) {
472 imports.push(ctor.clone());
473 }
474 }
475
476 let _ = module_path; for opts_type in &needed_options_types {
480 imports.push(format!("type {opts_type}"));
481 }
482 let imports_str = imports.join(", ");
483 let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
484 }
485
486 let _ = writeln!(out);
487 let _ = writeln!(out, "describe('{category}', () => {{");
488
489 for (i, fixture) in fixtures.iter().enumerate() {
490 if fixture.is_http_test() {
491 render_http_test_case(&mut out, fixture);
492 } else {
493 render_test_case(
494 &mut out,
495 lang,
496 fixture,
497 client_factory,
498 options_type,
499 field_resolver,
500 e2e_config,
501 );
502 }
503 if i + 1 < fixtures.len() {
504 let _ = writeln!(out);
505 }
506 }
507
508 let _ = has_http_fixtures;
510
511 let _ = writeln!(out, "}});");
512 out
513}
514
515fn resolve_node_function_name(call_config: &crate::config::CallConfig, lang: &str) -> String {
522 call_config
523 .overrides
524 .get(lang)
525 .and_then(|o| o.function.clone())
526 .unwrap_or_else(|| snake_to_camel(&call_config.function))
527}
528
529fn ts_method_helper_import(method_name: &str) -> Option<String> {
532 match method_name {
533 "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
534 "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
535 "tree_to_sexp" => Some("treeToSexp".to_string()),
536 "contains_node_type" => Some("treeContainsNodeType".to_string()),
537 "find_nodes_by_type" => Some("findNodesByType".to_string()),
538 "run_query" => Some("runQuery".to_string()),
539 _ => None,
542 }
543}
544
545pub(super) struct TypeScriptTestClientRenderer;
552
553impl client::TestClientRenderer for TypeScriptTestClientRenderer {
554 fn language_name(&self) -> &'static str {
555 "node"
556 }
557
558 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
559 let escaped_desc = description.replace('\'', "\\'");
560 if let Some(reason) = skip_reason {
561 let escaped_reason = reason.replace('\'', "\\'");
562 let _ = writeln!(out, " it.skip('{fn_name}: {escaped_desc}', async () => {{");
563 let _ = writeln!(out, " // skipped: {escaped_reason}");
564 } else {
565 let _ = writeln!(out, " it('{fn_name}: {escaped_desc}', async () => {{");
566 }
567 }
568
569 fn render_test_close(&self, out: &mut String) {
570 let _ = writeln!(out, " }});");
571 }
572
573 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
574 let method = ctx.method.to_uppercase();
575 let fixture_id = escape_js(ctx.path.trim_start_matches("/fixtures/"));
576
577 let mut init_entries: Vec<String> = Vec::new();
579 init_entries.push(format!("method: '{method}'"));
580 init_entries.push("redirect: 'manual'".to_string());
582
583 if !ctx.headers.is_empty() {
585 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
586 header_pairs.sort_by_key(|(k, _)| k.as_str());
587 let entries: Vec<String> = header_pairs
588 .iter()
589 .map(|(k, v)| {
590 let expanded_v = expand_fixture_templates(v);
591 format!(" \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
592 })
593 .collect();
594 init_entries.push(format!("headers: {{\n{},\n }}", entries.join(",\n")));
595 }
596
597 if let Some(body) = ctx.body {
599 let js_body = json_to_js(body);
600 init_entries.push(format!("body: JSON.stringify({js_body})"));
601 }
602
603 let _ = writeln!(
604 out,
605 " const mockUrl = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;"
606 );
607 let init_str = init_entries.join(", ");
608 let _ = writeln!(
609 out,
610 " const {} = await fetch(mockUrl, {{ {init_str} }});",
611 ctx.response_var
612 );
613 }
614
615 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
616 let _ = writeln!(out, " expect({response_var}.status).toBe({status});");
617 }
618
619 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
620 let escaped_name = escape_js(&name.to_lowercase());
621 match expected {
622 "<<present>>" => {
623 let _ = writeln!(
624 out,
625 " expect({response_var}.headers.get('{escaped_name}')).not.toBeNull();"
626 );
627 }
628 "<<absent>>" => {
629 let _ = writeln!(
630 out,
631 " expect({response_var}.headers.get('{escaped_name}')).toBeNull();"
632 );
633 }
634 "<<uuid>>" => {
635 let _ = writeln!(
636 out,
637 " expect({response_var}.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}}$/);"
638 );
639 }
640 exact => {
641 let escaped_val = escape_js(exact);
642 let _ = writeln!(
643 out,
644 " expect({response_var}.headers.get('{escaped_name}')).toBe('{escaped_val}');"
645 );
646 }
647 }
648 }
649
650 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
651 if let serde_json::Value::String(s) = expected {
652 let escaped = escape_js(s);
654 let _ = writeln!(out, " const text = await {response_var}.text();");
655 let _ = writeln!(out, " expect(text).toBe('{escaped}');");
656 } else {
657 let js_val = json_to_js(expected);
658 let _ = writeln!(out, " const data = await {response_var}.json();");
659 let _ = writeln!(out, " expect(data).toEqual({js_val});");
660 }
661 }
662
663 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
664 let _ = writeln!(out, " const data = await {response_var}.json();");
665 if let Some(obj) = expected.as_object() {
666 for (key, val) in obj {
667 let js_key = escape_js(key);
668 let js_val = json_to_js(val);
669 let _ = writeln!(
670 out,
671 " expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
672 );
673 }
674 }
675 }
676
677 fn render_assert_validation_errors(
678 &self,
679 out: &mut String,
680 response_var: &str,
681 errors: &[ValidationErrorExpectation],
682 ) {
683 let _ = writeln!(
684 out,
685 " const body = await {response_var}.json() as {{ errors?: unknown[] }};"
686 );
687 let _ = writeln!(out, " const errors = body.errors ?? [];");
688 for ve in errors {
689 let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
690 let loc_str = loc_js.join(", ");
691 let expanded_msg = expand_fixture_templates(&ve.msg);
692 let escaped_msg = escape_js(&expanded_msg);
693 let _ = writeln!(
694 out,
695 " 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);"
696 );
697 }
698 }
699}
700
701fn render_http_test_case(out: &mut String, fixture: &Fixture) {
708 let Some(http) = &fixture.http else {
709 return;
710 };
711
712 if http.expected_response.status_code == 101 {
714 let test_name = sanitize_ident(&fixture.id);
715 let description = fixture.description.replace('\'', "\\'");
716 let _ = writeln!(out, " it.skip('{test_name}: {description}', async () => {{");
717 let _ = writeln!(out, " // HTTP 101 WebSocket upgrade cannot be tested via fetch");
718 let _ = writeln!(out, " }});");
719 return;
720 }
721
722 client::http_call::render_http_test(out, &TypeScriptTestClientRenderer, fixture);
723}
724
725#[allow(clippy::too_many_arguments)]
730fn render_test_case(
731 out: &mut String,
732 lang: &str,
733 fixture: &Fixture,
734 client_factory: Option<&str>,
735 options_type: Option<&str>,
736 field_resolver: &FieldResolver,
737 e2e_config: &E2eConfig,
738) {
739 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
741 let function_name = resolve_node_function_name(call_config, lang);
742 let result_var = &call_config.result_var;
743 let is_async = call_config.r#async;
744 let args = &call_config.args;
745
746 let per_call_options_type = call_config.overrides.get(lang).and_then(|o| o.options_type.clone());
754 let fixture_options_type: Option<String> = per_call_options_type.or_else(|| options_type.map(|s| s.to_string()));
755 let options_type = fixture_options_type.as_deref();
756
757 let result_is_simple =
762 call_config.result_is_simple || call_config.overrides.get(lang).is_some_and(|o| o.result_is_simple);
763 let result_is_vec = call_config.result_is_vec || call_config.overrides.get(lang).is_some_and(|o| o.result_is_vec);
764
765 let arg_order = call_config
770 .overrides
771 .get(lang)
772 .map(|o| o.arg_order.as_slice())
773 .unwrap_or(&[]);
774 let reordered_args: Vec<crate::config::ArgMapping>;
775 let args: &[crate::config::ArgMapping] = if arg_order.is_empty() {
776 args
777 } else {
778 reordered_args = arg_order
779 .iter()
780 .filter_map(|name| args.iter().find(|a| &a.name == name).cloned())
781 .collect();
782 &reordered_args
783 };
784
785 let test_name = sanitize_ident(&fixture.id);
786 let description = fixture.description.replace('\'', "\\'");
787 let async_kw = if is_async { "async " } else { "" };
788 let await_kw = if is_async { "await " } else { "" };
789
790 let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
792
793 let mut visitor_arg = String::new();
795 if let Some(visitor_spec) = &fixture.visitor {
796 visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
797 }
798
799 let final_args = if visitor_arg.is_empty() {
800 args_str
801 } else if args_str.is_empty() {
802 format!("{{ visitor: {visitor_arg} }}")
803 } else {
804 format!("{args_str}, {{ visitor: {visitor_arg} }}")
805 };
806
807 let call_expr = if client_factory.is_some() {
808 format!("client.{function_name}({final_args})")
809 } else {
810 format!("{function_name}({final_args})")
811 };
812
813 let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
815
816 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
818
819 if fixture.assertions.is_empty() {
821 let _ = writeln!(out, " it.skip('{test_name}: {description}', async () => {{");
822 let _ = writeln!(out, " // no assertions configured for this fixture in node e2e");
823 let _ = writeln!(out, " }});");
824 return;
825 }
826
827 if expects_error {
828 let _ = writeln!(out, " it('{test_name}: {description}', async () => {{");
829 if let Some(factory) = client_factory {
830 let _ = writeln!(out, " const client = {factory}('test-key', {base_url_expr});");
831 }
832 let _ = writeln!(out, " await expect(async () => {{");
835 for line in &setup_lines {
836 let _ = writeln!(out, " {line}");
837 }
838 let _ = writeln!(out, " await {call_expr};");
839 let _ = writeln!(out, " }}).rejects.toThrow();");
840 let _ = writeln!(out, " }});");
841 return;
842 }
843
844 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
845
846 if let Some(factory) = client_factory {
847 let _ = writeln!(out, " const client = {factory}('test-key', {base_url_expr});");
848 }
849
850 for line in &setup_lines {
851 let _ = writeln!(out, " {line}");
852 }
853
854 let has_usable_assertion = fixture.assertions.iter().any(|a| {
856 if a.assertion_type == "not_error" || a.assertion_type == "error" {
857 return false;
858 }
859 match &a.field {
860 Some(f) if !f.is_empty() => {
861 if result_is_simple {
865 f == "result"
866 } else {
867 field_resolver.is_valid_for_result(f)
868 }
869 }
870 _ => true,
871 }
872 });
873
874 if has_usable_assertion {
875 let _ = writeln!(out, " const {result_var} = {await_kw}{call_expr};");
876 } else {
877 let _ = writeln!(out, " {await_kw}{call_expr};");
878 }
879
880 for assertion in &fixture.assertions {
882 if assertion.assertion_type == "not_error" && !call_config.returns_result {
884 continue;
885 }
886 render_assertion(
887 out,
888 assertion,
889 result_var,
890 field_resolver,
891 result_is_simple,
892 result_is_vec,
893 );
894 }
895
896 let _ = writeln!(out, " }});");
897}
898
899fn has_later_arg_value(
908 args: &[crate::config::ArgMapping],
909 from_idx: usize,
910 input: &serde_json::Value,
911 options_type: Option<&str>,
912) -> bool {
913 args[from_idx..].iter().any(|arg| {
914 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
915 let val = if field == "input" {
916 Some(input)
917 } else {
918 input.get(field)
919 };
920 let has_value = !matches!(val, None | Some(serde_json::Value::Null));
921 if has_value {
922 return true;
923 }
924 arg.optional && arg.arg_type == "json_object" && options_type.is_some()
927 })
928}
929
930fn build_args_and_setup(
934 input: &serde_json::Value,
935 args: &[crate::config::ArgMapping],
936 options_type: Option<&str>,
937 fixture_id: &str,
938) -> (Vec<String>, String) {
939 if args.is_empty() {
940 let no_input =
945 matches!(input, serde_json::Value::Null) || input.as_object().map(|o| o.is_empty()).unwrap_or(false);
946 if no_input {
947 return (Vec::new(), String::new());
948 }
949 return (Vec::new(), json_to_js(input));
950 }
951
952 let mut setup_lines: Vec<String> = Vec::new();
953 let mut parts: Vec<String> = Vec::new();
954
955 for (idx, arg) in args.iter().enumerate() {
956 if arg.arg_type == "mock_url" {
957 setup_lines.push(format!(
958 "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
959 arg.name,
960 ));
961 parts.push(arg.name.clone());
962 continue;
963 }
964
965 if arg.arg_type == "handle" {
966 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
968 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
969 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
970 if config_value.is_null()
971 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
972 {
973 setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
974 } else {
975 let literal = json_to_js_camel(config_value);
978 setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
979 setup_lines.push(format!(
980 "const {} = {constructor_name}({name}Config);",
981 arg.name,
982 name = arg.name,
983 ));
984 }
985 parts.push(arg.name.clone());
986 continue;
987 }
988
989 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
990 let val = if field == "input" {
992 Some(input)
993 } else {
994 input.get(field)
995 };
996 match val {
997 None | Some(serde_json::Value::Null) if arg.optional => {
998 if arg.arg_type == "json_object" {
1008 if let Some(opts_type) = options_type {
1009 parts.push(format!("{{}} as unknown as {opts_type}"));
1010 }
1011 } else if has_later_arg_value(args, idx + 1, input, options_type) {
1012 parts.push("undefined".to_string());
1013 }
1014 }
1016 None | Some(serde_json::Value::Null) => {
1017 let default_val = match arg.arg_type.as_str() {
1019 "string" => "\"\"".to_string(),
1020 "int" | "integer" => "0".to_string(),
1021 "float" | "number" => "0.0".to_string(),
1022 "bool" | "boolean" => "false".to_string(),
1023 _ => "null".to_string(),
1024 };
1025 parts.push(default_val);
1026 }
1027 Some(v) => {
1028 if arg.arg_type == "bytes" {
1029 if let Some(raw) = v.as_str() {
1030 let var = format!("{}Bytes", arg.name);
1031 match classify_bytes_value(raw) {
1032 BytesKind::FilePath => {
1033 let escaped = escape_js(raw);
1034 setup_lines.push(format!("const {var} = readFileSync(\"{escaped}\");"));
1035 }
1036 BytesKind::InlineText => {
1037 let escaped = escape_js(raw);
1038 setup_lines.push(format!("const {var} = Buffer.from(\"{escaped}\", \"utf-8\");"));
1039 }
1040 BytesKind::Base64 => {
1041 let escaped = escape_js(raw);
1042 setup_lines.push(format!("const {var} = Buffer.from(\"{escaped}\", \"base64\");"));
1043 }
1044 }
1045 parts.push(var);
1046 continue;
1047 }
1048 }
1049 if arg.arg_type == "json_object" {
1055 if v.is_object() {
1056 if let Some(opts_type) = options_type {
1057 parts.push(format!("{} as unknown as {opts_type}", json_to_js_camel(v)));
1063 } else {
1064 parts.push(json_to_js_camel(v));
1065 }
1066 } else {
1067 parts.push(json_to_js_camel(v));
1068 }
1069 continue;
1070 }
1071 parts.push(json_to_js(v));
1072 }
1073 }
1074 }
1075
1076 (setup_lines, parts.join(", "))
1077}
1078
1079fn render_assertion(
1080 out: &mut String,
1081 assertion: &Assertion,
1082 result_var: &str,
1083 field_resolver: &FieldResolver,
1084 result_is_simple: bool,
1085 result_is_vec: bool,
1086) {
1087 if let Some(f) = &assertion.field {
1090 match f.as_str() {
1091 "chunks_have_content" => {
1092 let pred = format!("({result_var}.chunks ?? []).every((c: {{ content?: string }}) => !!c.content)");
1093 match assertion.assertion_type.as_str() {
1094 "is_true" => {
1095 let _ = writeln!(out, " expect({pred}).toBe(true);");
1096 }
1097 "is_false" => {
1098 let _ = writeln!(out, " expect({pred}).toBe(false);");
1099 }
1100 _ => {
1101 let _ = writeln!(
1102 out,
1103 " // skipped: unsupported assertion type on synthetic field '{f}'"
1104 );
1105 }
1106 }
1107 return;
1108 }
1109 "chunks_have_embeddings" => {
1110 let pred = format!(
1111 "({result_var}.chunks ?? []).every((c: {{ embedding?: number[] }}) => c.embedding != null && c.embedding.length > 0)"
1112 );
1113 match assertion.assertion_type.as_str() {
1114 "is_true" => {
1115 let _ = writeln!(out, " expect({pred}).toBe(true);");
1116 }
1117 "is_false" => {
1118 let _ = writeln!(out, " expect({pred}).toBe(false);");
1119 }
1120 _ => {
1121 let _ = writeln!(
1122 out,
1123 " // skipped: unsupported assertion type on synthetic field '{f}'"
1124 );
1125 }
1126 }
1127 return;
1128 }
1129 "embeddings" => {
1133 match assertion.assertion_type.as_str() {
1134 "count_equals" => {
1135 if let Some(val) = &assertion.value {
1136 let js_val = json_to_js(val);
1137 let _ = writeln!(out, " expect({result_var}.length).toBe({js_val});");
1138 }
1139 }
1140 "count_min" => {
1141 if let Some(val) = &assertion.value {
1142 let js_val = json_to_js(val);
1143 let _ = writeln!(out, " expect({result_var}.length).toBeGreaterThanOrEqual({js_val});");
1144 }
1145 }
1146 "not_empty" => {
1147 let _ = writeln!(out, " expect({result_var}.length).toBeGreaterThan(0);");
1148 }
1149 "is_empty" => {
1150 let _ = writeln!(out, " expect({result_var}.length).toBe(0);");
1151 }
1152 _ => {
1153 let _ = writeln!(
1154 out,
1155 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
1156 );
1157 }
1158 }
1159 return;
1160 }
1161 "embedding_dimensions" => {
1162 let expr = format!("({result_var}.length > 0 ? {result_var}[0].length : 0)");
1163 match assertion.assertion_type.as_str() {
1164 "equals" => {
1165 if let Some(val) = &assertion.value {
1166 let js_val = json_to_js(val);
1167 let _ = writeln!(out, " expect({expr}).toBe({js_val});");
1168 }
1169 }
1170 "greater_than" => {
1171 if let Some(val) = &assertion.value {
1172 let js_val = json_to_js(val);
1173 let _ = writeln!(out, " expect({expr}).toBeGreaterThan({js_val});");
1174 }
1175 }
1176 _ => {
1177 let _ = writeln!(
1178 out,
1179 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1180 );
1181 }
1182 }
1183 return;
1184 }
1185 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1186 let pred = match f.as_str() {
1187 "embeddings_valid" => {
1188 format!("{result_var}.every((e: number[]) => e.length > 0)")
1189 }
1190 "embeddings_finite" => {
1191 format!("{result_var}.every((e: number[]) => e.every((v: number) => isFinite(v)))")
1192 }
1193 "embeddings_non_zero" => {
1194 format!("{result_var}.every((e: number[]) => e.some((v: number) => v !== 0))")
1195 }
1196 "embeddings_normalized" => {
1197 format!(
1198 "{result_var}.every((e: number[]) => {{ const n = e.reduce((s: number, v: number) => s + v * v, 0); return Math.abs(n - 1.0) < 1e-3; }})"
1199 )
1200 }
1201 _ => unreachable!(),
1202 };
1203 match assertion.assertion_type.as_str() {
1204 "is_true" => {
1205 let _ = writeln!(out, " expect({pred}).toBe(true);");
1206 }
1207 "is_false" => {
1208 let _ = writeln!(out, " expect({pred}).toBe(false);");
1209 }
1210 _ => {
1211 let _ = writeln!(
1212 out,
1213 " // skipped: unsupported assertion type on synthetic field '{f}'"
1214 );
1215 }
1216 }
1217 return;
1218 }
1219 "keywords" | "keywords_count" => {
1222 let _ = writeln!(
1223 out,
1224 " // skipped: field '{f}' not available on Node JsExtractionResult"
1225 );
1226 return;
1227 }
1228 _ => {}
1229 }
1230 }
1231
1232 if let Some(f) = &assertion.field {
1234 if !f.is_empty() && !result_is_simple && !field_resolver.is_valid_for_result(f) {
1235 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1236 return;
1237 }
1238 if !f.is_empty() && result_is_simple && f != "result" {
1242 let _ = writeln!(
1243 out,
1244 " // skipped: field '{f}' not available on simple/scalar result type"
1245 );
1246 return;
1247 }
1248 }
1249
1250 let effective_result_var = if result_is_vec {
1254 format!("{result_var}[0]")
1255 } else {
1256 result_var.to_string()
1257 };
1258 let field_expr = match &assertion.field {
1259 Some(f) if !f.is_empty() && !result_is_simple => {
1262 field_resolver.accessor(f, "typescript", &effective_result_var)
1263 }
1264 _ => effective_result_var.clone(),
1265 };
1266
1267 match assertion.assertion_type.as_str() {
1268 "equals" => {
1269 if let Some(expected) = &assertion.value {
1270 let js_val = json_to_js(expected);
1271 if expected.is_string() {
1274 let resolved = assertion.field.as_deref().unwrap_or("");
1275 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1276 let _ = writeln!(out, " expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
1277 } else {
1278 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
1279 }
1280 } else {
1281 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
1282 }
1283 }
1284 }
1285 "contains" => {
1286 if let Some(expected) = &assertion.value {
1287 let js_val = json_to_js(expected);
1288 let resolved = assertion.field.as_deref().unwrap_or("");
1290 if !resolved.is_empty()
1291 && expected.is_string()
1292 && field_resolver.is_optional(field_resolver.resolve(resolved))
1293 {
1294 let _ = writeln!(out, " expect({field_expr} ?? \"\").toContain({js_val});");
1295 } else {
1296 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
1297 }
1298 }
1299 }
1300 "contains_all" => {
1301 if let Some(values) = &assertion.values {
1302 for val in values {
1303 let js_val = json_to_js(val);
1304 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
1305 }
1306 }
1307 }
1308 "not_contains" => {
1309 if let Some(expected) = &assertion.value {
1310 let js_val = json_to_js(expected);
1311 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
1312 }
1313 }
1314 "not_empty" => {
1315 let resolved = assertion.field.as_deref().unwrap_or("");
1333 let resolved_path = field_resolver.resolve(resolved);
1334 let is_array = !resolved.is_empty() && field_resolver.is_array(resolved_path);
1335 let is_optional = !resolved.is_empty() && field_resolver.is_optional(resolved_path);
1336 if is_array {
1337 if is_optional {
1338 let _ = writeln!(out, " expect(({field_expr} ?? []).length).toBeGreaterThan(0);");
1339 } else {
1340 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
1341 }
1342 } else if resolved.is_empty() {
1343 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
1345 } else {
1346 let any_expr = format!("({field_expr} as unknown)");
1355 if is_optional {
1356 let _ = writeln!(
1357 out,
1358 " expect((() => {{ const v = {any_expr} ?? ''; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBeGreaterThan(0);"
1359 );
1360 } else {
1361 let _ = writeln!(
1362 out,
1363 " expect((() => {{ const v = {any_expr}; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBeGreaterThan(0);"
1364 );
1365 }
1366 }
1367 }
1368 "is_empty" => {
1369 let resolved = assertion.field.as_deref().unwrap_or("");
1370 let resolved_path = field_resolver.resolve(resolved);
1371 let is_array = !resolved.is_empty() && field_resolver.is_array(resolved_path);
1372 let is_optional = !resolved.is_empty() && field_resolver.is_optional(resolved_path);
1373 if is_array {
1374 if is_optional {
1375 let _ = writeln!(out, " expect(({field_expr} ?? []).length).toBe(0);");
1376 } else {
1377 let _ = writeln!(out, " expect({field_expr}).toHaveLength(0);");
1378 }
1379 } else if resolved.is_empty() {
1380 let _ = writeln!(out, " expect({field_expr}).toHaveLength(0);");
1381 } else {
1382 let any_expr = format!("({field_expr} as unknown)");
1383 if is_optional {
1384 let _ = writeln!(
1385 out,
1386 " expect((() => {{ const v = {any_expr} ?? ''; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBe(0);"
1387 );
1388 } else {
1389 let _ = writeln!(
1390 out,
1391 " expect((() => {{ const v = {any_expr}; return typeof v === 'string' || Array.isArray(v) ? (v as {{ length: number }}).length : Object.keys(v as object).length; }})()).toBe(0);"
1392 );
1393 }
1394 }
1395 }
1396 "contains_any" => {
1397 if let Some(values) = &assertion.values {
1398 let items: Vec<String> = values.iter().map(json_to_js).collect();
1399 let arr_str = items.join(", ");
1400 let _ = writeln!(
1401 out,
1402 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
1403 );
1404 }
1405 }
1406 "greater_than" => {
1407 if let Some(val) = &assertion.value {
1408 let js_val = json_to_js(val);
1409 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
1410 }
1411 }
1412 "less_than" => {
1413 if let Some(val) = &assertion.value {
1414 let js_val = json_to_js(val);
1415 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
1416 }
1417 }
1418 "greater_than_or_equal" => {
1419 if let Some(val) = &assertion.value {
1420 let js_val = json_to_js(val);
1421 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
1422 }
1423 }
1424 "less_than_or_equal" => {
1425 if let Some(val) = &assertion.value {
1426 let js_val = json_to_js(val);
1427 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
1428 }
1429 }
1430 "starts_with" => {
1431 if let Some(expected) = &assertion.value {
1432 let js_val = json_to_js(expected);
1433 let resolved = assertion.field.as_deref().unwrap_or("");
1435 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1436 let _ = writeln!(
1437 out,
1438 " expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
1439 );
1440 } else {
1441 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
1442 }
1443 }
1444 }
1445 "count_min" => {
1446 if let Some(val) = &assertion.value {
1447 if let Some(n) = val.as_u64() {
1448 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1449 }
1450 }
1451 }
1452 "count_equals" => {
1453 if let Some(val) = &assertion.value {
1454 if let Some(n) = val.as_u64() {
1455 let _ = writeln!(out, " expect({field_expr}.length).toBe({n});");
1456 }
1457 }
1458 }
1459 "is_true" => {
1460 let _ = writeln!(out, " expect({field_expr}).toBe(true);");
1461 }
1462 "is_false" => {
1463 let _ = writeln!(out, " expect({field_expr}).toBe(false);");
1464 }
1465 "method_result" => {
1466 if let Some(method_name) = &assertion.method {
1467 let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
1468 let check = assertion.check.as_deref().unwrap_or("is_true");
1469 match check {
1470 "equals" => {
1471 if let Some(val) = &assertion.value {
1472 let js_val = json_to_js(val);
1473 let _ = writeln!(out, " expect({call_expr}).toBe({js_val});");
1474 }
1475 }
1476 "is_true" => {
1477 let _ = writeln!(out, " expect({call_expr}).toBe(true);");
1478 }
1479 "is_false" => {
1480 let _ = writeln!(out, " expect({call_expr}).toBe(false);");
1481 }
1482 "greater_than_or_equal" => {
1483 if let Some(val) = &assertion.value {
1484 let n = val.as_u64().unwrap_or(0);
1485 let _ = writeln!(out, " expect({call_expr}).toBeGreaterThanOrEqual({n});");
1486 }
1487 }
1488 "count_min" => {
1489 if let Some(val) = &assertion.value {
1490 let n = val.as_u64().unwrap_or(0);
1491 let _ = writeln!(out, " expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
1492 }
1493 }
1494 "contains" => {
1495 if let Some(val) = &assertion.value {
1496 let js_val = json_to_js(val);
1497 let _ = writeln!(out, " expect({call_expr}).toContain({js_val});");
1498 }
1499 }
1500 "is_error" => {
1501 let _ = writeln!(out, " expect(() => {{ {call_expr}; }}).toThrow();");
1502 }
1503 other_check => {
1504 panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
1505 }
1506 }
1507 } else {
1508 panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
1509 }
1510 }
1511 "min_length" => {
1512 if let Some(val) = &assertion.value {
1513 if let Some(n) = val.as_u64() {
1514 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1515 }
1516 }
1517 }
1518 "max_length" => {
1519 if let Some(val) = &assertion.value {
1520 if let Some(n) = val.as_u64() {
1521 let _ = writeln!(out, " expect({field_expr}.length).toBeLessThanOrEqual({n});");
1522 }
1523 }
1524 }
1525 "ends_with" => {
1526 if let Some(expected) = &assertion.value {
1527 let js_val = json_to_js(expected);
1528 let _ = writeln!(out, " expect({field_expr}.endsWith({js_val})).toBe(true);");
1529 }
1530 }
1531 "matches_regex" => {
1532 if let Some(expected) = &assertion.value {
1533 if let Some(pattern) = expected.as_str() {
1534 let _ = writeln!(out, " expect({field_expr}).toMatch(/{pattern}/);");
1535 }
1536 }
1537 }
1538 "not_error" => {
1539 }
1541 "error" => {
1542 }
1544 other => {
1545 panic!("TypeScript e2e generator: unsupported assertion type: {other}");
1546 }
1547 }
1548}
1549
1550fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1553 match method_name {
1554 "root_child_count" => format!("{result_var}.rootNode.childCount"),
1555 "root_node_type" => format!("{result_var}.rootNode.type"),
1556 "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
1557 "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
1558 "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
1559 "tree_to_sexp" => format!("treeToSexp({result_var})"),
1560 "contains_node_type" => {
1561 let node_type = args
1562 .and_then(|a| a.get("node_type"))
1563 .and_then(|v| v.as_str())
1564 .unwrap_or("");
1565 format!("treeContainsNodeType({result_var}, \"{node_type}\")")
1566 }
1567 "find_nodes_by_type" => {
1568 let node_type = args
1569 .and_then(|a| a.get("node_type"))
1570 .and_then(|v| v.as_str())
1571 .unwrap_or("");
1572 format!("findNodesByType({result_var}, \"{node_type}\")")
1573 }
1574 "run_query" => {
1575 let query_source = args
1576 .and_then(|a| a.get("query_source"))
1577 .and_then(|v| v.as_str())
1578 .unwrap_or("");
1579 let language = args
1580 .and_then(|a| a.get("language"))
1581 .and_then(|v| v.as_str())
1582 .unwrap_or("");
1583 format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1584 }
1585 _ => {
1586 if let Some(args_val) = args {
1587 let arg_str = args_val
1588 .as_object()
1589 .map(|obj| {
1590 obj.iter()
1591 .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1592 .collect::<Vec<_>>()
1593 .join(", ")
1594 })
1595 .unwrap_or_default();
1596 format!("{result_var}.{method_name}({arg_str})")
1597 } else {
1598 format!("{result_var}.{method_name}()")
1599 }
1600 }
1601 }
1602}
1603
1604fn json_to_js_camel(value: &serde_json::Value) -> String {
1610 match value {
1611 serde_json::Value::Object(map) => {
1612 let entries: Vec<String> = map
1613 .iter()
1614 .map(|(k, v)| {
1615 let camel_key = snake_to_camel(k);
1616 let key = if camel_key
1618 .chars()
1619 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1620 && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1621 {
1622 camel_key.clone()
1623 } else {
1624 format!("\"{}\"", escape_js(&camel_key))
1625 };
1626 format!("{key}: {}", json_to_js_camel(v))
1627 })
1628 .collect();
1629 format!("{{ {} }}", entries.join(", "))
1630 }
1631 serde_json::Value::Array(arr) => {
1632 let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1633 format!("[{}]", items.join(", "))
1634 }
1635 other => json_to_js(other),
1637 }
1638}
1639
1640fn snake_to_camel(s: &str) -> String {
1642 let mut result = String::with_capacity(s.len());
1643 let mut capitalize_next = false;
1644 for ch in s.chars() {
1645 if ch == '_' {
1646 capitalize_next = true;
1647 } else if capitalize_next {
1648 result.extend(ch.to_uppercase());
1649 capitalize_next = false;
1650 } else {
1651 result.push(ch);
1652 }
1653 }
1654 result
1655}
1656
1657fn json_to_js(value: &serde_json::Value) -> String {
1659 match value {
1660 serde_json::Value::String(s) => {
1661 let expanded = expand_fixture_templates(s);
1662 format!("\"{}\"", escape_js(&expanded))
1663 }
1664 serde_json::Value::Bool(b) => b.to_string(),
1665 serde_json::Value::Number(n) => {
1666 if let Some(i) = n.as_i64() {
1668 if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&i) {
1669 return format!("Number(\"{i}\")");
1670 }
1671 }
1672 if let Some(u) = n.as_u64() {
1673 if u > 9_007_199_254_740_991 {
1674 return format!("Number(\"{u}\")");
1675 }
1676 }
1677 n.to_string()
1678 }
1679 serde_json::Value::Null => "null".to_string(),
1680 serde_json::Value::Array(arr) => {
1681 let items: Vec<String> = arr.iter().map(json_to_js).collect();
1682 format!("[{}]", items.join(", "))
1683 }
1684 serde_json::Value::Object(map) => {
1685 let entries: Vec<String> = map
1686 .iter()
1687 .map(|(k, v)| {
1688 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1690 && !k.starts_with(|c: char| c.is_ascii_digit())
1691 {
1692 k.clone()
1693 } else {
1694 format!("\"{}\"", escape_js(k))
1695 };
1696 format!("{key}: {}", json_to_js(v))
1697 })
1698 .collect();
1699 format!("{{ {} }}", entries.join(", "))
1700 }
1701 }
1702}
1703
1704fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1710 use std::fmt::Write as FmtWrite;
1711 let mut visitor_obj = String::new();
1712 let _ = writeln!(visitor_obj, "{{");
1713 for (method_name, action) in &visitor_spec.callbacks {
1714 emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1715 }
1716 let _ = writeln!(visitor_obj, " }}");
1717
1718 setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1719 "_testVisitor".to_string()
1720}
1721
1722fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1724 use std::fmt::Write as FmtWrite;
1725
1726 let camel_method = to_camel_case(method_name);
1727 let params = match method_name {
1728 "visit_link" => "ctx, href, text, title",
1729 "visit_image" => "ctx, src, alt, title",
1730 "visit_heading" => "ctx, level, text, id",
1731 "visit_code_block" => "ctx, lang, code",
1732 "visit_code_inline"
1733 | "visit_strong"
1734 | "visit_emphasis"
1735 | "visit_strikethrough"
1736 | "visit_underline"
1737 | "visit_subscript"
1738 | "visit_superscript"
1739 | "visit_mark"
1740 | "visit_button"
1741 | "visit_summary"
1742 | "visit_figcaption"
1743 | "visit_definition_term"
1744 | "visit_definition_description" => "ctx, text",
1745 "visit_text" => "ctx, text",
1746 "visit_list_item" => "ctx, ordered, marker, text",
1747 "visit_blockquote" => "ctx, content, depth",
1748 "visit_table_row" => "ctx, cells, isHeader",
1749 "visit_custom_element" => "ctx, tagName, html",
1750 "visit_form" => "ctx, actionUrl, method",
1751 "visit_input" => "ctx, inputType, name, value",
1752 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1753 "visit_details" => "ctx, isOpen",
1754 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1755 "visit_list_start" => "ctx, ordered",
1756 "visit_list_end" => "ctx, ordered, output",
1757 _ => "ctx",
1758 };
1759
1760 let _ = writeln!(out, " {camel_method}({params}): string | {{ custom: string }} {{");
1761 match action {
1762 CallbackAction::Skip => {
1763 let _ = writeln!(out, " return \"skip\";");
1764 }
1765 CallbackAction::Continue => {
1766 let _ = writeln!(out, " return \"continue\";");
1767 }
1768 CallbackAction::PreserveHtml => {
1769 let _ = writeln!(out, " return \"preserve_html\";");
1770 }
1771 CallbackAction::Custom { output } => {
1772 let escaped = escape_js(output);
1773 let _ = writeln!(out, " return {{ custom: \"{escaped}\" }};");
1774 }
1775 CallbackAction::CustomTemplate { template } => {
1776 let _ = writeln!(out, " return {{ custom: `{template}` }};");
1777 }
1778 }
1779 let _ = writeln!(out, " }},");
1780}
1781
1782fn to_camel_case(snake: &str) -> String {
1784 use heck::ToLowerCamelCase;
1785 snake.to_lower_camel_case()
1786}
1787
1788enum BytesKind {
1799 FilePath,
1800 InlineText,
1801 Base64,
1802}
1803
1804fn classify_bytes_value(s: &str) -> BytesKind {
1812 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1813 return BytesKind::InlineText;
1814 }
1815 let first = s.chars().next().unwrap_or('\0');
1816 if first.is_ascii_alphanumeric() || first == '_' {
1817 if let Some(slash_pos) = s.find('/') {
1818 if slash_pos > 0 {
1819 let after_slash = &s[slash_pos + 1..];
1820 if after_slash.contains('.') && !after_slash.is_empty() {
1821 return BytesKind::FilePath;
1822 }
1823 }
1824 }
1825 }
1826 BytesKind::Base64
1827}