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