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