1use crate::config::E2eConfig;
4use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, 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 result_var = &call.result_var;
43 let is_async = call.r#async;
44
45 let node_pkg = e2e_config.resolve_package("node");
47 let pkg_path = node_pkg
48 .as_ref()
49 .and_then(|p| p.path.as_ref())
50 .cloned()
51 .unwrap_or_else(|| "../../packages/typescript".to_string());
52 let pkg_name = node_pkg
53 .as_ref()
54 .and_then(|p| p.name.as_ref())
55 .cloned()
56 .unwrap_or_else(|| module_path.clone());
57 let pkg_version = node_pkg
58 .as_ref()
59 .and_then(|p| p.version.as_ref())
60 .cloned()
61 .unwrap_or_else(|| "0.1.0".to_string());
62
63 files.push(GeneratedFile {
65 path: output_base.join("package.json"),
66 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
67 generated_header: false,
68 });
69
70 files.push(GeneratedFile {
72 path: output_base.join("tsconfig.json"),
73 content: render_tsconfig(),
74 generated_header: false,
75 });
76
77 files.push(GeneratedFile {
79 path: output_base.join("vitest.config.ts"),
80 content: render_vitest_config(),
81 generated_header: true,
82 });
83
84 let options_type = overrides.and_then(|o| o.options_type.clone());
86 let field_resolver = FieldResolver::new(
87 &e2e_config.fields,
88 &e2e_config.fields_optional,
89 &e2e_config.result_fields,
90 &e2e_config.fields_array,
91 );
92
93 for group in groups {
95 let active: Vec<&Fixture> = group
96 .fixtures
97 .iter()
98 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip("node")))
99 .collect();
100
101 if active.is_empty() {
102 continue;
103 }
104
105 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
106 let content = render_test_file(
107 &group.category,
108 &active,
109 &module_path,
110 &pkg_name,
111 &function_name,
112 result_var,
113 is_async,
114 &e2e_config.call.args,
115 options_type.as_deref(),
116 &field_resolver,
117 );
118 files.push(GeneratedFile {
119 path: tests_base.join(filename),
120 content,
121 generated_header: true,
122 });
123 }
124
125 Ok(files)
126 }
127
128 fn language_name(&self) -> &'static str {
129 "node"
130 }
131}
132
133fn render_package_json(
134 pkg_name: &str,
135 pkg_path: &str,
136 pkg_version: &str,
137 dep_mode: crate::config::DependencyMode,
138) -> String {
139 let dep_value = match dep_mode {
140 crate::config::DependencyMode::Registry => pkg_version.to_string(),
141 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
142 };
143 format!(
144 r#"{{
145 "name": "{pkg_name}-e2e-typescript",
146 "version": "0.1.0",
147 "private": true,
148 "type": "module",
149 "scripts": {{
150 "test": "vitest run"
151 }},
152 "devDependencies": {{
153 "{pkg_name}": "{dep_value}",
154 "vitest": "^3.0.0"
155 }}
156}}
157"#
158 )
159}
160
161fn render_tsconfig() -> String {
162 r#"{
163 "compilerOptions": {
164 "target": "ES2022",
165 "module": "ESNext",
166 "moduleResolution": "bundler",
167 "strict": true,
168 "strictNullChecks": false,
169 "esModuleInterop": true,
170 "skipLibCheck": true
171 },
172 "include": ["tests/**/*.ts", "vitest.config.ts"]
173}
174"#
175 .to_string()
176}
177
178fn render_vitest_config() -> String {
179 r#"// This file is auto-generated by alef. DO NOT EDIT.
180import { defineConfig } from 'vitest/config';
181
182export default defineConfig({
183 test: {
184 include: ['tests/**/*.test.ts'],
185 },
186});
187"#
188 .to_string()
189}
190
191#[allow(clippy::too_many_arguments)]
192fn render_test_file(
193 category: &str,
194 fixtures: &[&Fixture],
195 module_path: &str,
196 pkg_name: &str,
197 function_name: &str,
198 result_var: &str,
199 is_async: bool,
200 args: &[crate::config::ArgMapping],
201 options_type: Option<&str>,
202 field_resolver: &FieldResolver,
203) -> String {
204 let mut out = String::new();
205 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
206 let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
207
208 let needs_options_import = options_type.is_some()
210 && fixtures.iter().any(|f| {
211 args.iter()
212 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
213 });
214
215 let handle_constructors: Vec<String> = args
217 .iter()
218 .filter(|arg| arg.arg_type == "handle")
219 .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
220 .collect();
221
222 let mut imports: Vec<String> = vec![function_name.to_string()];
223 for ctor in &handle_constructors {
224 if !imports.contains(ctor) {
225 imports.push(ctor.clone());
226 }
227 }
228
229 let _ = module_path; if let (true, Some(opts_type)) = (needs_options_import, options_type) {
234 imports.push(format!("type {opts_type}"));
235 let imports_str = imports.join(", ");
236 let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
237 } else {
238 let imports_str = imports.join(", ");
239 let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
240 }
241 let _ = writeln!(out);
242 let _ = writeln!(out, "describe('{category}', () => {{");
243
244 for (i, fixture) in fixtures.iter().enumerate() {
245 render_test_case(
246 &mut out,
247 fixture,
248 function_name,
249 result_var,
250 is_async,
251 args,
252 options_type,
253 field_resolver,
254 );
255 if i + 1 < fixtures.len() {
256 let _ = writeln!(out);
257 }
258 }
259
260 let _ = writeln!(out, "}});");
261 out
262}
263
264#[allow(clippy::too_many_arguments)]
265fn render_test_case(
266 out: &mut String,
267 fixture: &Fixture,
268 function_name: &str,
269 result_var: &str,
270 is_async: bool,
271 args: &[crate::config::ArgMapping],
272 options_type: Option<&str>,
273 field_resolver: &FieldResolver,
274) {
275 let test_name = sanitize_ident(&fixture.id);
276 let description = fixture.description.replace('\'', "\\'");
277 let async_kw = if is_async { "async " } else { "" };
278 let await_kw = if is_async { "await " } else { "" };
279
280 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
282
283 if expects_error {
284 let _ = writeln!(out, " it('{test_name}: {description}', async () => {{");
285 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
286 let _ = writeln!(out, " await expect(async () => {{");
289 for line in &setup_lines {
290 let _ = writeln!(out, " {line}");
291 }
292 let _ = writeln!(out, " await {function_name}({args_str});");
293 let _ = writeln!(out, " }}).rejects.toThrow();");
294 let _ = writeln!(out, " }});");
295 return;
296 }
297
298 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
299
300 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
302
303 for line in &setup_lines {
304 let _ = writeln!(out, " {line}");
305 }
306
307 let has_usable_assertion = fixture.assertions.iter().any(|a| {
310 if a.assertion_type == "not_error" || a.assertion_type == "error" {
311 return false;
312 }
313 match &a.field {
314 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
315 _ => true,
316 }
317 });
318
319 if has_usable_assertion {
320 let _ = writeln!(out, " const {result_var} = {await_kw}{function_name}({args_str});");
322 } else {
323 let _ = writeln!(out, " {await_kw}{function_name}({args_str});");
325 }
326
327 for assertion in &fixture.assertions {
329 render_assertion(out, assertion, result_var, field_resolver);
330 }
331
332 let _ = writeln!(out, " }});");
333}
334
335fn build_args_and_setup(
339 input: &serde_json::Value,
340 args: &[crate::config::ArgMapping],
341 options_type: Option<&str>,
342 fixture_id: &str,
343) -> (Vec<String>, String) {
344 if args.is_empty() {
345 return (Vec::new(), json_to_js(input));
347 }
348
349 let mut setup_lines: Vec<String> = Vec::new();
350 let mut parts: Vec<String> = Vec::new();
351
352 for arg in args {
353 if arg.arg_type == "mock_url" {
354 setup_lines.push(format!(
355 "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
356 arg.name,
357 ));
358 parts.push(arg.name.clone());
359 continue;
360 }
361
362 if arg.arg_type == "handle" {
363 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
365 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
366 if config_value.is_null()
367 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
368 {
369 setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
370 } else {
371 let literal = json_to_js_camel(config_value);
374 setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
375 setup_lines.push(format!(
376 "const {} = {constructor_name}({name}Config);",
377 arg.name,
378 name = arg.name,
379 ));
380 }
381 parts.push(arg.name.clone());
382 continue;
383 }
384
385 let val = input.get(&arg.field);
386 match val {
387 None | Some(serde_json::Value::Null) if arg.optional => {
388 continue;
390 }
391 None | Some(serde_json::Value::Null) => {
392 let default_val = match arg.arg_type.as_str() {
394 "string" => "\"\"".to_string(),
395 "int" | "integer" => "0".to_string(),
396 "float" | "number" => "0.0".to_string(),
397 "bool" | "boolean" => "false".to_string(),
398 _ => "null".to_string(),
399 };
400 parts.push(default_val);
401 }
402 Some(v) => {
403 if arg.arg_type == "json_object" {
405 if let Some(opts_type) = options_type {
406 parts.push(format!("{} as {opts_type}", json_to_js(v)));
407 continue;
408 }
409 }
410 parts.push(json_to_js(v));
411 }
412 }
413 }
414
415 (setup_lines, parts.join(", "))
416}
417
418fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
419 if let Some(f) = &assertion.field {
421 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
422 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
423 return;
424 }
425 }
426
427 let field_expr = match &assertion.field {
428 Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
429 _ => result_var.to_string(),
430 };
431
432 match assertion.assertion_type.as_str() {
433 "equals" => {
434 if let Some(expected) = &assertion.value {
435 let js_val = json_to_js(expected);
436 if expected.is_string() {
439 let resolved = assertion.field.as_deref().unwrap_or("");
440 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
441 let _ = writeln!(out, " expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
442 } else {
443 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
444 }
445 } else {
446 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
447 }
448 }
449 }
450 "contains" => {
451 if let Some(expected) = &assertion.value {
452 let js_val = json_to_js(expected);
453 let resolved = assertion.field.as_deref().unwrap_or("");
455 if !resolved.is_empty()
456 && expected.is_string()
457 && field_resolver.is_optional(field_resolver.resolve(resolved))
458 {
459 let _ = writeln!(out, " expect({field_expr} ?? \"\").toContain({js_val});");
460 } else {
461 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
462 }
463 }
464 }
465 "contains_all" => {
466 if let Some(values) = &assertion.values {
467 for val in values {
468 let js_val = json_to_js(val);
469 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
470 }
471 }
472 }
473 "not_contains" => {
474 if let Some(expected) = &assertion.value {
475 let js_val = json_to_js(expected);
476 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
477 }
478 }
479 "not_empty" => {
480 let resolved = assertion.field.as_deref().unwrap_or("");
482 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
483 let _ = writeln!(out, " expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
484 } else {
485 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
486 }
487 }
488 "is_empty" => {
489 let resolved = assertion.field.as_deref().unwrap_or("");
491 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
492 let _ = writeln!(out, " expect({field_expr} ?? \"\").toHaveLength(0);");
493 } else {
494 let _ = writeln!(out, " expect({field_expr}).toHaveLength(0);");
495 }
496 }
497 "contains_any" => {
498 if let Some(values) = &assertion.values {
499 let items: Vec<String> = values.iter().map(json_to_js).collect();
500 let arr_str = items.join(", ");
501 let _ = writeln!(
502 out,
503 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
504 );
505 }
506 }
507 "greater_than" => {
508 if let Some(val) = &assertion.value {
509 let js_val = json_to_js(val);
510 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
511 }
512 }
513 "less_than" => {
514 if let Some(val) = &assertion.value {
515 let js_val = json_to_js(val);
516 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
517 }
518 }
519 "greater_than_or_equal" => {
520 if let Some(val) = &assertion.value {
521 let js_val = json_to_js(val);
522 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
523 }
524 }
525 "less_than_or_equal" => {
526 if let Some(val) = &assertion.value {
527 let js_val = json_to_js(val);
528 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
529 }
530 }
531 "starts_with" => {
532 if let Some(expected) = &assertion.value {
533 let js_val = json_to_js(expected);
534 let resolved = assertion.field.as_deref().unwrap_or("");
536 if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
537 let _ = writeln!(
538 out,
539 " expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
540 );
541 } else {
542 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
543 }
544 }
545 }
546 "count_min" => {
547 if let Some(val) = &assertion.value {
548 if let Some(n) = val.as_u64() {
549 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
550 }
551 }
552 }
553 "not_error" => {
554 }
556 "error" => {
557 }
559 other => {
560 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
561 }
562 }
563}
564
565fn json_to_js_camel(value: &serde_json::Value) -> String {
571 match value {
572 serde_json::Value::Object(map) => {
573 let entries: Vec<String> = map
574 .iter()
575 .map(|(k, v)| {
576 let camel_key = snake_to_camel(k);
577 let key = if camel_key
579 .chars()
580 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
581 && !camel_key.starts_with(|c: char| c.is_ascii_digit())
582 {
583 camel_key.clone()
584 } else {
585 format!("\"{}\"", escape_js(&camel_key))
586 };
587 format!("{key}: {}", json_to_js_camel(v))
588 })
589 .collect();
590 format!("{{ {} }}", entries.join(", "))
591 }
592 serde_json::Value::Array(arr) => {
593 let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
594 format!("[{}]", items.join(", "))
595 }
596 other => json_to_js(other),
598 }
599}
600
601fn snake_to_camel(s: &str) -> String {
603 let mut result = String::with_capacity(s.len());
604 let mut capitalize_next = false;
605 for ch in s.chars() {
606 if ch == '_' {
607 capitalize_next = true;
608 } else if capitalize_next {
609 result.extend(ch.to_uppercase());
610 capitalize_next = false;
611 } else {
612 result.push(ch);
613 }
614 }
615 result
616}
617
618fn json_to_js(value: &serde_json::Value) -> String {
620 match value {
621 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
622 serde_json::Value::Bool(b) => b.to_string(),
623 serde_json::Value::Number(n) => n.to_string(),
624 serde_json::Value::Null => "null".to_string(),
625 serde_json::Value::Array(arr) => {
626 let items: Vec<String> = arr.iter().map(json_to_js).collect();
627 format!("[{}]", items.join(", "))
628 }
629 serde_json::Value::Object(map) => {
630 let entries: Vec<String> = map
631 .iter()
632 .map(|(k, v)| {
633 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
635 && !k.starts_with(|c: char| c.is_ascii_digit())
636 {
637 k.clone()
638 } else {
639 format!("\"{}\"", escape_js(k))
640 };
641 format!("{key}: {}", json_to_js(v))
642 })
643 .collect();
644 format!("{{ {} }}", entries.join(", "))
645 }
646 }
647}