1#[cfg(test)]
4mod assertion_wildcard_element_tests;
5mod assertions;
6pub(crate) mod config;
7#[cfg(test)]
8mod emit_test_backend_tests;
9mod field_refusal;
10#[cfg(test)]
11mod is_true_tests;
12mod json;
13#[cfg(test)]
14mod not_error_sibling_assertion_tests;
15#[cfg(test)]
16mod npmrc_marker_tests;
17pub(crate) mod test_file;
18#[cfg(test)]
19mod undeclared_key_refusal_tests;
20mod visitors;
21#[cfg(test)]
22mod wasm_trait_bridge_stub_tests;
23
24use crate::e2e::config::E2eConfig;
25
26use crate::core::backend::GeneratedFile;
27use crate::core::config::ResolvedCrateConfig;
28use crate::e2e::fixture::{Fixture, FixtureGroup};
29use anyhow::Result;
30use std::path::PathBuf;
31
32use super::E2eCodegen;
33use crate::codegen::generators::trait_bridge::to_camel_case;
34use config::{render_file_setup, render_global_setup, render_package_json, render_tsconfig, render_vitest_config};
35pub use test_file::render_test_file;
36use test_file::resolve_node_function_name;
37
38pub struct TypeScriptCodegen;
40
41impl E2eCodegen for TypeScriptCodegen {
42 fn generate(
43 &self,
44 groups: &[FixtureGroup],
45 e2e_config: &E2eConfig,
46 config: &ResolvedCrateConfig,
47 type_defs: &[crate::core::ir::TypeDef],
48 enums: &[crate::core::ir::EnumDef],
49 functions: &[crate::core::ir::FunctionDef],
50 errors: &[crate::core::ir::ErrorDef],
51 ) -> Result<Vec<GeneratedFile>> {
52 let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
53 let tests_base = output_base.join("tests");
54
55 let mut files = Vec::new();
56
57 let call = &e2e_config.call;
59 let overrides = call.overrides.get("node");
60 let module_path = overrides
61 .and_then(|o| o.module.as_ref())
62 .cloned()
63 .unwrap_or_else(|| call.module.clone());
64 let function_name = overrides.and_then(|o| o.function.as_ref()).cloned().unwrap_or_else(|| {
65 let default_cc = e2e_config.resolve_call(None);
66 resolve_node_function_name(default_cc)
67 });
68 let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
69
70 let node_pkg = e2e_config.resolve_package("node");
72 let pkg_path = node_pkg
73 .as_ref()
74 .and_then(|p| p.path.as_ref())
75 .cloned()
76 .unwrap_or_else(|| "../../packages/node".to_string());
77 let pkg_name = node_pkg
78 .as_ref()
79 .and_then(|p| p.name.as_ref())
80 .cloned()
81 .unwrap_or_else(|| module_path.clone());
82 let pkg_version = node_pkg
83 .as_ref()
84 .and_then(|p| p.version.as_ref())
85 .cloned()
86 .or_else(|| config.resolved_version())
87 .unwrap_or_else(|| "0.1.0".to_string());
88
89 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.http.is_some());
90
91 let file_input_scan = super::file_inputs::FileInputScan::new(type_defs, enums);
96 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
97 let cc = e2e_config.resolve_call_for_fixture(
98 f.call.as_deref(),
99 &f.id,
100 &f.resolved_category(),
101 &f.tags,
102 &f.input,
103 );
104 file_input_scan.fixture_uses_test_documents(f, cc)
105 });
106
107 files.push(GeneratedFile {
108 path: output_base.join("package.json"),
109 content: render_package_json(
110 &pkg_name,
111 &pkg_path,
112 &pkg_version,
113 e2e_config.dep_mode,
114 has_http_fixtures,
115 match e2e_config.dep_mode {
122 crate::e2e::config::DependencyMode::Local => e2e_config.harness_extras.get(self.language_name()),
123 crate::e2e::config::DependencyMode::Registry => None,
124 },
125 ),
126 generated_header: false,
127 });
128
129 files.push(GeneratedFile {
130 path: output_base.join("tsconfig.json"),
131 content: render_tsconfig(),
132 generated_header: false,
133 });
134
135 files.push(GeneratedFile {
146 path: output_base.join(".npmrc"),
147 content: format!("; {}\n; frozen-lockfile is disabled: the under-test napi package pins platform optionalDependencies to the unpublished release version.\nfrozen-lockfile=false\n", crate::core::hash::STANDARD_HEADER_LINE),
148 generated_header: false,
149 });
150
151 let workspace_yaml_path = output_base.join("pnpm-workspace.yaml");
166 if e2e_config.dep_mode == crate::e2e::config::DependencyMode::Registry {
167 let workspace_yaml_content = {
178 let mut content = String::from("packages: []\nallowBuilds:\n esbuild: true\n tree-sitter: true\n");
179 use std::fmt::Write as _;
180 let exclude_pattern = match pkg_name.split_once('/') {
190 Some((scope, _)) if scope.starts_with('@') => format!("{scope}/*"),
191 _ => pkg_name.clone(),
192 };
193 let _ = write!(content, "minimumReleaseAgeExclude:\n - '{exclude_pattern}'\n");
194 content
195 };
196 files.push(GeneratedFile {
197 path: workspace_yaml_path.clone(),
198 content: workspace_yaml_content,
199 generated_header: true,
200 });
201 } else {
202 if workspace_yaml_path.exists() {
206 std::fs::remove_file(&workspace_yaml_path)
207 .map_err(|e| anyhow::anyhow!("Failed to remove stale pnpm-workspace.yaml: {}", e))?;
208 }
209 }
210
211 let any_needs_mock_server = groups
218 .iter()
219 .flat_map(|g| g.fixtures.iter())
220 .any(|f| f.needs_mock_server());
221 let needs_global_setup = client_factory.is_some() || has_http_fixtures || any_needs_mock_server;
222
223 files.push(GeneratedFile {
224 path: output_base.join("vitest.config.ts"),
225 content: render_vitest_config(needs_global_setup, has_file_fixtures),
226 generated_header: true,
227 });
228
229 let use_server_pattern = has_http_fixtures && !e2e_config.harness.imports.is_empty();
234 if needs_global_setup && !use_server_pattern {
235 files.push(GeneratedFile {
236 path: output_base.join("globalSetup.ts"),
237 content: render_global_setup(false),
238 generated_header: true,
239 });
240 }
241
242 if has_file_fixtures {
243 files.push(GeneratedFile {
244 path: output_base.join("setup.ts"),
245 content: render_file_setup(&e2e_config.test_documents_dir),
246 generated_header: true,
247 });
248 }
249
250 let options_type = overrides.and_then(|o| o.options_type.clone());
251
252 for group in groups {
253 let active: Vec<_> = group
254 .fixtures
255 .iter()
256 .filter(|fixture| is_node_fixture_runnable(fixture, e2e_config))
257 .collect();
258
259 if active.is_empty() {
260 continue;
261 }
262
263 let filename = format!("{}.test.ts", crate::e2e::escape::sanitize_filename(&group.category));
264 let content = render_test_file(
265 "node",
266 &group.category,
267 &active,
268 &module_path,
269 &pkg_name,
270 &function_name,
271 &e2e_config.call.args,
272 options_type.as_deref(),
273 client_factory,
274 e2e_config,
275 type_defs,
276 enums,
277 functions,
278 "",
279 config,
280 errors,
281 );
282 files.push(GeneratedFile {
283 path: tests_base.join(filename),
284 content,
285 generated_header: true,
286 });
287 }
288
289 Ok(files)
290 }
291
292 fn render_snippet_body(
293 &self,
294 fixture: &Fixture,
295 e2e_config: &E2eConfig,
296 config: &ResolvedCrateConfig,
297 type_defs: &[crate::core::ir::TypeDef],
298 enums: &[crate::core::ir::EnumDef],
299 ) -> Result<String> {
300 self.render_snippet_body_with_functions(fixture, e2e_config, config, type_defs, enums, &[], &[])
301 }
302
303 fn render_snippet_body_with_functions(
304 &self,
305 fixture: &Fixture,
306 e2e_config: &E2eConfig,
307 config: &ResolvedCrateConfig,
308 type_defs: &[crate::core::ir::TypeDef],
309 enums: &[crate::core::ir::EnumDef],
310 functions: &[crate::core::ir::FunctionDef],
311 _errors: &[crate::core::ir::ErrorDef],
312 ) -> Result<String> {
313 Ok(test_file::render_node_snippet_body(
314 fixture, e2e_config, config, type_defs, enums, functions,
315 ))
316 }
317
318 fn language_name(&self) -> &'static str {
319 "node"
320 }
321}
322
323fn is_node_fixture_runnable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
324 if !super::fixture_inclusion(fixture, "node", e2e_config).is_included() {
325 return false;
326 }
327
328 if let Some(http) = &fixture.http {
329 return http.expected_response.status_code != 101;
330 }
331
332 !fixture.assertions.is_empty()
333}
334
335pub fn emit_test_backend(
359 trait_bridge: &crate::core::config::TraitBridgeConfig,
360 methods: &[&crate::core::ir::MethodDef],
361 fixture: &Fixture,
362 enums: &[crate::core::ir::EnumDef],
363 wasm_type_prefix: &str,
364) -> super::TestBackendEmission {
365 use crate::codegen::defaults::language_defaults;
366 use crate::e2e::escape::{escape_js, sanitize_ident};
367 use std::fmt::Write as FmtWrite;
368
369 let stub_name = format!("_TestStub_{}", sanitize_ident(&fixture.id));
370 let backend_name = extract_backend_name_from_input(&fixture.input, &fixture.id);
371 let defaults = language_defaults("typescript");
372
373 let backend_input = fixture.input.get("backend").and_then(|v| v.as_object());
376
377 let mut setup = String::new();
378 let mut type_imports: Vec<String> = Vec::new();
379
380 let _ = writeln!(setup, "class {stub_name} {{");
381
382 if trait_bridge.super_trait.is_some() {
384 let escaped = escape_js(&backend_name);
385 let _ = writeln!(setup, " name(): string {{ return \"{escaped}\"; }}");
386 }
387
388 for method in methods {
392 if trait_bridge.super_trait.is_some() && method.name == "name" {
394 continue;
395 }
396 emit_ts_stub_method(
397 &mut setup,
398 method,
399 &*defaults,
400 backend_input,
401 enums,
402 wasm_type_prefix,
403 &mut type_imports,
404 );
405 }
406
407 let _ = writeln!(setup, " async dispose(): Promise<void> {{ return undefined; }}");
410
411 let _ = writeln!(setup, "}}");
412
413 let arg_expr = format!("new {stub_name}()");
414
415 super::TestBackendEmission {
416 setup_block: setup,
417 arg_expr,
418 type_imports,
419 teardown_block: String::new(),
420 }
421}
422
423fn emit_ts_stub_method(
433 out: &mut String,
434 method: &crate::core::ir::MethodDef,
435 defaults: &dyn crate::codegen::defaults::LanguageDefaults,
436 backend_input: Option<&serde_json::Map<String, serde_json::Value>>,
437 enums: &[crate::core::ir::EnumDef],
438 wasm_type_prefix: &str,
439 type_imports: &mut Vec<String>,
440) {
441 use crate::e2e::escape::escape_js;
442 use std::fmt::Write as FmtWrite;
443
444 let params: Vec<String> = method
446 .params
447 .iter()
448 .enumerate()
449 .map(|(i, _)| format!("_p{i}?: any"))
450 .collect();
451 let params_str = params.join(", ");
452
453 let default_val = match &method.return_type {
471 crate::core::ir::TypeRef::Unit => "undefined".to_string(),
472 crate::core::ir::TypeRef::Named(name) if enums.iter().any(|e| &e.name == name) => {
473 let prefixed_name = format!("{wasm_type_prefix}{name}");
474 if !type_imports.contains(&prefixed_name) {
475 type_imports.push(prefixed_name.clone());
476 }
477 let enum_def = enums.iter().find(|e| &e.name == name).expect("checked above");
478 match enum_def.variants.first() {
479 Some(variant) => format!("\"\\\"{}\\\"\" as unknown as {prefixed_name}", variant.name),
480 None => format!("\"{{}}\" as unknown as {prefixed_name}"),
483 }
484 }
485 crate::core::ir::TypeRef::Named(name) => {
494 let prefixed_name = format!("{wasm_type_prefix}{name}");
495 if !type_imports.contains(&prefixed_name) {
496 type_imports.push(prefixed_name.clone());
497 }
498 format!("\"{{}}\" as unknown as {prefixed_name}")
499 }
500 crate::core::ir::TypeRef::String | crate::core::ir::TypeRef::Char | crate::core::ir::TypeRef::Path => {
501 fixture_backend_value(backend_input, &method.name)
502 .and_then(|val| val.as_str())
503 .map(|s| format!("\"{}\"", escape_js(s)))
504 .unwrap_or_else(|| defaults.emit_default(&method.return_type))
505 }
506 crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool) => {
507 fixture_backend_value(backend_input, &method.name)
508 .and_then(|val| val.as_bool())
509 .map(|b| b.to_string())
510 .unwrap_or_else(|| "false".to_string())
511 }
512 crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::F32)
513 | crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::F64) => {
514 fixture_backend_value(backend_input, &method.name)
515 .and_then(|val| val.as_f64())
516 .map(|n| n.to_string())
517 .unwrap_or_else(|| "0.0".to_string())
518 }
519 crate::core::ir::TypeRef::Primitive(_) => {
520 if let Some(val) = fixture_backend_value(backend_input, &method.name) {
522 match val {
524 serde_json::Value::Number(n) => n.to_string(),
525 serde_json::Value::Bool(b) => b.to_string(),
526 _ => {
527 "1".to_string()
529 }
530 }
531 } else {
532 "1".to_string()
534 }
535 }
536 other => defaults.emit_default(other),
537 };
538 let return_type = ts_stub_return_type(&method.return_type, enums, wasm_type_prefix);
539
540 if method.is_async {
541 let _ = writeln!(
542 out,
543 " async {name}({params_str}): Promise<{return_type}> {{ return {default_val}; }}",
544 name = to_camel_case(&method.name)
545 );
546 } else {
547 let _ = writeln!(
548 out,
549 " {name}({params_str}): {return_type} {{ return {default_val}; }}",
550 name = to_camel_case(&method.name)
551 );
552 }
553}
554
555fn fixture_backend_value<'a>(
556 backend_input: Option<&'a serde_json::Map<String, serde_json::Value>>,
557 method_name: &str,
558) -> Option<&'a serde_json::Value> {
559 backend_input
560 .and_then(|b| b.get(&method_name.to_lowercase()))
561 .or_else(|| backend_input.and_then(|b| b.get(&to_camel_case(method_name))))
562}
563
564fn ts_stub_return_type(
565 return_type: &crate::core::ir::TypeRef,
566 enums: &[crate::core::ir::EnumDef],
567 wasm_type_prefix: &str,
568) -> String {
569 match return_type {
570 crate::core::ir::TypeRef::Unit => "void".to_string(),
571 crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool) => "boolean".to_string(),
572 crate::core::ir::TypeRef::Primitive(_) => "number".to_string(),
573 crate::core::ir::TypeRef::Vec(inner) => {
580 format!("Array<{}>", ts_stub_return_type(inner, enums, wasm_type_prefix))
581 }
582 crate::core::ir::TypeRef::Optional(inner) => ts_stub_return_type(inner, enums, wasm_type_prefix),
583 crate::core::ir::TypeRef::Named(name) => format!("{wasm_type_prefix}{name}"),
592 _ => "string".to_string(),
593 }
594}
595
596fn extract_backend_name_from_input(input: &serde_json::Value, fallback: &str) -> String {
598 if let Some(obj) = input.as_object() {
599 if let Some(s) = obj.get("name").and_then(|v| v.as_str()) {
600 return s.to_string();
601 }
602 for v in obj.values() {
603 if let Some(inner) = v.as_object()
604 && let Some(s) = inner.get("name").and_then(|v| v.as_str())
605 {
606 return s.to_string();
607 }
608 }
609 for v in obj.values() {
610 if let Some(s) = v.as_str() {
611 return s.to_string();
612 }
613 }
614 }
615 fallback.to_string()
616}
617
618#[cfg(test)]
620fn test_method(
621 name: &str,
622 return_type: crate::core::ir::TypeRef,
623 is_async: bool,
624 has_default_impl: bool,
625) -> crate::core::ir::MethodDef {
626 crate::core::ir::MethodDef {
627 name: name.to_string(),
628 params: Vec::new(),
629 return_type,
630 is_async,
631 is_static: false,
632 error_type: None,
633 doc: String::new(),
634 receiver: Some(crate::core::ir::ReceiverKind::Ref),
635 cfg: None,
636 sanitized: false,
637 trait_source: None,
638 returns_ref: false,
639 returns_cow: false,
640 return_newtype_wrapper: None,
641 has_default_impl,
642 binding_excluded: false,
643 binding_exclusion_reason: None,
644 version: Default::default(),
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn language_name_is_node() {
654 let codegen = TypeScriptCodegen;
655 assert_eq!(codegen.language_name(), "node");
656 }
657
658 #[test]
659 fn generate_empty_groups_produces_config_files_only() {
660 use crate::core::config::NewAlefConfig;
661 let cfg: NewAlefConfig = toml::from_str(
662 r#"
663[workspace]
664languages = ["node"]
665
666[[crates]]
667name = "my-lib"
668sources = ["src/lib.rs"]
669
670[crates.e2e]
671fixtures = "fixtures"
672output = "e2e"
673[crates.e2e.call]
674function = "process"
675module = "my-lib"
676result_var = "result"
677"#,
678 )
679 .unwrap();
680 let mut e2e = cfg.crates[0].e2e.clone().unwrap();
681 e2e.dep_mode = crate::e2e::config::DependencyMode::Registry;
682 let resolved = cfg.resolve().unwrap().remove(0);
683 let codegen = TypeScriptCodegen;
684 let files = codegen.generate(&[], &e2e, &resolved, &[], &[], &[], &[]).unwrap();
685 assert!(files.len() >= 3, "got {} files", files.len());
687
688 let workspace = files
692 .iter()
693 .find(|f| f.path.ends_with("pnpm-workspace.yaml"))
694 .expect("node codegen in Registry mode must emit pnpm-workspace.yaml for install isolation");
695 assert!(
696 workspace.content.contains("packages:"),
697 "pnpm-workspace.yaml must declare an isolated workspace root, got: {}",
698 workspace.content
699 );
700
701 let npmrc = files
705 .iter()
706 .find(|f| f.path.ends_with(".npmrc"))
707 .expect("node codegen must emit .npmrc to disable frozen-lockfile for the e2e install");
708 assert!(
709 npmrc.content.contains("frozen-lockfile=false"),
710 "e2e .npmrc must set frozen-lockfile=false, got: {}",
711 npmrc.content
712 );
713 }
714
715 #[test]
716 fn local_mode_does_not_emit_pnpm_workspace_yaml() {
717 use crate::core::config::NewAlefConfig;
718 let cfg: NewAlefConfig = toml::from_str(
719 r#"
720[workspace]
721languages = ["node"]
722
723[[crates]]
724name = "my-lib"
725sources = ["src/lib.rs"]
726
727[crates.e2e]
728fixtures = "fixtures"
729output = "e2e"
730[crates.e2e.call]
731function = "process"
732module = "my-lib"
733result_var = "result"
734"#,
735 )
736 .unwrap();
737 let e2e = cfg.crates[0].e2e.clone().unwrap();
739 assert_eq!(e2e.dep_mode, crate::e2e::config::DependencyMode::Local);
740 let resolved = cfg.resolve().unwrap().remove(0);
741 let codegen = TypeScriptCodegen;
742 let files = codegen.generate(&[], &e2e, &resolved, &[], &[], &[], &[]).unwrap();
743
744 let workspace = files.iter().find(|f| f.path.ends_with("pnpm-workspace.yaml"));
748 assert!(
749 workspace.is_none(),
750 "Local mode must NOT emit pnpm-workspace.yaml; found: {:?}",
751 workspace
752 );
753 }
754}