Skip to main content

alef_e2e/codegen/typescript/
mod.rs

1//! TypeScript e2e test generator using vitest.
2
3mod assertions;
4mod config;
5mod json;
6mod test_file;
7mod visitors;
8
9use crate::config::E2eConfig;
10use crate::field_access::FieldResolver;
11use crate::fixture::FixtureGroup;
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use anyhow::Result;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18use config::{render_file_setup, render_global_setup, render_package_json, render_tsconfig, render_vitest_config};
19pub use test_file::render_test_file;
20use test_file::resolve_node_function_name;
21
22/// TypeScript e2e code generator.
23pub struct TypeScriptCodegen;
24
25impl E2eCodegen for TypeScriptCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        _config: &ResolvedCrateConfig,
31        type_defs: &[alef_core::ir::TypeDef],
32    ) -> Result<Vec<GeneratedFile>> {
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
34        let tests_base = output_base.join("tests");
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides — use "node" key (Language::Node).
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get("node");
41        let module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let function_name = overrides.and_then(|o| o.function.as_ref()).cloned().unwrap_or_else(|| {
46            let default_cc = e2e_config.resolve_call(None);
47            resolve_node_function_name(default_cc)
48        });
49        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
50
51        // Resolve package config.
52        let node_pkg = e2e_config.resolve_package("node");
53        let pkg_path = node_pkg
54            .as_ref()
55            .and_then(|p| p.path.as_ref())
56            .cloned()
57            .unwrap_or_else(|| "../../packages/typescript".to_string());
58        let pkg_name = node_pkg
59            .as_ref()
60            .and_then(|p| p.name.as_ref())
61            .cloned()
62            .unwrap_or_else(|| module_path.clone());
63        let pkg_version = node_pkg
64            .as_ref()
65            .and_then(|p| p.version.as_ref())
66            .cloned()
67            .unwrap_or_else(|| "0.1.0".to_string());
68
69        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
70
71        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
72            let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
73            cc.args
74                .iter()
75                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
76        });
77
78        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 {
91            path: output_base.join("tsconfig.json"),
92            content: render_tsconfig(),
93            generated_header: false,
94        });
95
96        // globalSetup spawns the mock-server binary and exposes its URL via
97        // MOCK_SERVER_URL. Required whenever any fixture's call uses the mock
98        // server — either via http blocks (real HTTP test fixtures), via
99        // mock_response/mock_responses (function-call tests that build their
100        // own URLs against MOCK_SERVER_URL), or because a client_factory is
101        // wired to point at the mock server's `/fixtures/<id>` prefix.
102        let any_needs_mock_server = groups
103            .iter()
104            .flat_map(|g| g.fixtures.iter())
105            .any(|f| f.needs_mock_server());
106        let needs_global_setup = client_factory.is_some() || has_http_fixtures || any_needs_mock_server;
107
108        files.push(GeneratedFile {
109            path: output_base.join("vitest.config.ts"),
110            content: render_vitest_config(needs_global_setup, has_file_fixtures),
111            generated_header: true,
112        });
113
114        if needs_global_setup {
115            files.push(GeneratedFile {
116                path: output_base.join("globalSetup.ts"),
117                content: render_global_setup(),
118                generated_header: true,
119            });
120        }
121
122        if has_file_fixtures {
123            files.push(GeneratedFile {
124                path: output_base.join("setup.ts"),
125                content: render_file_setup(&e2e_config.test_documents_dir),
126                generated_header: true,
127            });
128        }
129
130        let options_type = overrides.and_then(|o| o.options_type.clone());
131        let field_resolver = FieldResolver::new(
132            &e2e_config.fields,
133            &e2e_config.fields_optional,
134            &e2e_config.result_fields,
135            &e2e_config.fields_array,
136            &std::collections::HashSet::new(),
137        );
138
139        for group in groups {
140            let active: Vec<_> = group
141                .fixtures
142                .iter()
143                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip("node")))
144                .collect();
145
146            if active.is_empty() {
147                continue;
148            }
149
150            let filename = format!("{}.test.ts", crate::escape::sanitize_filename(&group.category));
151            let content = render_test_file(
152                "node",
153                &group.category,
154                &active,
155                &module_path,
156                &pkg_name,
157                &function_name,
158                &e2e_config.call.args,
159                options_type.as_deref(),
160                &field_resolver,
161                client_factory,
162                e2e_config,
163                type_defs,
164            );
165            files.push(GeneratedFile {
166                path: tests_base.join(filename),
167                content,
168                generated_header: true,
169            });
170        }
171
172        Ok(files)
173    }
174
175    fn language_name(&self) -> &'static str {
176        "node"
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn language_name_is_node() {
186        let codegen = TypeScriptCodegen;
187        assert_eq!(codegen.language_name(), "node");
188    }
189
190    #[test]
191    fn generate_empty_groups_produces_config_files_only() {
192        use alef_core::config::NewAlefConfig;
193        let cfg: NewAlefConfig = toml::from_str(
194            r#"
195[workspace]
196languages = ["node"]
197
198[[crates]]
199name = "my-lib"
200sources = ["src/lib.rs"]
201
202[crates.e2e]
203fixtures = "fixtures"
204output = "e2e"
205[crates.e2e.call]
206function = "process"
207module = "my-lib"
208result_var = "result"
209"#,
210        )
211        .unwrap();
212        let e2e = cfg.crates[0].e2e.clone().unwrap();
213        let resolved = cfg.resolve().unwrap().remove(0);
214        let codegen = TypeScriptCodegen;
215        let files = codegen.generate(&[], &e2e, &resolved, &[]).unwrap();
216        // package.json, tsconfig.json, vitest.config.ts
217        assert!(files.len() >= 3, "got {} files", files.len());
218    }
219}