Skip to main content

alef_e2e/codegen/
typescript.rs

1//! TypeScript e2e test generator using vitest.
2
3use 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
16/// TypeScript e2e code generator.
17pub 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        // Resolve call config with overrides — use "node" key (Language::Node).
32        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        // Resolve package config.
46        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        // Generate package.json.
64        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        // Generate tsconfig.json.
71        files.push(GeneratedFile {
72            path: output_base.join("tsconfig.json"),
73            content: render_tsconfig(),
74            generated_header: false,
75        });
76
77        // Generate vitest.config.ts.
78        files.push(GeneratedFile {
79            path: output_base.join("vitest.config.ts"),
80            content: render_vitest_config(),
81            generated_header: true,
82        });
83
84        // Resolve options_type from override.
85        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        // Generate test files per category.
94        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    // Check if any fixture uses a json_object arg that needs the options type import.
209    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    // Collect handle constructor function names that need to be imported.
216    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    // Use pkg_name (the npm package name, e.g. "@kreuzberg/html-to-markdown-node") for
230    // the import specifier so that registry builds resolve the published package name.
231    // module_path is kept for internal/override use but is not the npm package name.
232    let _ = module_path; // retained in signature for potential future use
233    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    // Check if this is an error-expecting test.
281    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        // Wrap ALL setup lines and the function call inside the expect block so that
287        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
288        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    // Build function call arguments from input fields.
301    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    // Check if any assertion actually uses the result variable.
308    // If all assertions are skipped (field not on result type), skip the const assignment.
309    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        // Emit variable declarations for input args (for readability in complex cases).
321        let _ = writeln!(out, "    const {result_var} = {await_kw}{function_name}({args_str});");
322    } else {
323        // No usable assertions; just call the function without capturing the result.
324        let _ = writeln!(out, "    {await_kw}{function_name}({args_str});");
325    }
326
327    // Emit assertions.
328    for assertion in &fixture.assertions {
329        render_assertion(out, assertion, result_var, field_resolver);
330    }
331
332    let _ = writeln!(out, "  }});");
333}
334
335/// Build setup lines (e.g. handle creation) and the argument list for the function call.
336///
337/// Returns `(setup_lines, args_string)`.
338fn 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        // If no args mapping, pass the whole input as a single argument.
346        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            // Generate a createEngine (or equivalent) call and pass the variable.
364            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                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
372                // config keys from the fixture JSON to camelCase before passing to the constructor.
373                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                // Optional arg with no fixture value: skip entirely.
389                continue;
390            }
391            None | Some(serde_json::Value::Null) => {
392                // Required arg with no fixture value: pass a language-appropriate default.
393                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                // For json_object args with options_type, cast the object literal.
404                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    // Skip assertions on fields that don't exist on the result type.
420    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                // For string equality, trim trailing whitespace to handle trailing newlines
437                // from the converter. Use null-coalescing for optional fields.
438                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                // Use null-coalescing for optional string fields to handle null/undefined values.
454                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            // Use null-coalescing for optional fields to handle null/undefined values.
481            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            // Use null-coalescing for optional string fields to handle null/undefined values.
490            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                // Use null-coalescing for optional fields to handle null/undefined values.
535                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            // No-op — if we got here, the call succeeded (it would have thrown).
555        }
556        "error" => {
557            // Handled at the test level (early return above).
558        }
559        other => {
560            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
561        }
562    }
563}
564
565/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
566///
567/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
568/// snake_case object keys (as written in fixture JSON) to camelCase so that the
569/// generated config objects match the NAPI binding's expected field names.
570fn 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                    // Quote keys that aren't valid JS identifiers.
578                    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        // Scalars and null delegate to the standard converter.
597        other => json_to_js(other),
598    }
599}
600
601/// Convert a snake_case string to camelCase.
602fn 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
618/// Convert a `serde_json::Value` to a JavaScript literal string.
619fn 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                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
634                    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}