pipeline_service/testing/
parser.rs1use crate::testing::{PipelineTest, TestDefaults, TestSuite};
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub struct TestFileParser;
11
12#[derive(Debug)]
14pub enum TestParseError {
15 NotFound(PathBuf),
17 IoError(std::io::Error),
19 YamlError(serde_yaml::Error),
21 ValidationError(String),
23}
24
25impl std::fmt::Display for TestParseError {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 TestParseError::NotFound(path) => {
29 write!(f, "Test file not found: {}", path.display())
30 }
31 TestParseError::IoError(e) => write!(f, "IO error reading test file: {}", e),
32 TestParseError::YamlError(e) => write!(f, "YAML parse error in test file: {}", e),
33 TestParseError::ValidationError(msg) => {
34 write!(f, "Test file validation error: {}", msg)
35 }
36 }
37 }
38}
39
40impl std::error::Error for TestParseError {}
41
42impl From<std::io::Error> for TestParseError {
43 fn from(err: std::io::Error) -> Self {
44 TestParseError::IoError(err)
45 }
46}
47
48impl From<serde_yaml::Error> for TestParseError {
49 fn from(err: serde_yaml::Error) -> Self {
50 TestParseError::YamlError(err)
51 }
52}
53
54impl TestFileParser {
55 pub fn parse(content: &str) -> Result<TestSuite, TestParseError> {
57 let suite: TestSuite = serde_yaml::from_str(content)?;
58 Self::validate(&suite)?;
59 Ok(suite)
60 }
61
62 pub fn parse_file(path: &Path) -> Result<TestSuite, TestParseError> {
64 if !path.exists() {
65 return Err(TestParseError::NotFound(path.to_path_buf()));
66 }
67
68 let content = fs::read_to_string(path)?;
69 let mut suite = Self::parse(&content)?;
70
71 let base_dir = path
73 .parent()
74 .unwrap_or_else(|| Path::new("."))
75 .to_path_buf();
76
77 for test in &mut suite.tests {
78 if test.pipeline.is_relative() {
79 test.pipeline = base_dir.join(&test.pipeline);
80 }
81 }
82
83 Ok(suite)
84 }
85
86 pub fn discover(dir: &Path) -> Vec<PathBuf> {
91 let mut test_files = Vec::new();
92
93 for name in &[
95 "roxid-test.yml",
96 "roxid-test.yaml",
97 ".roxid-test.yml",
98 ".roxid-test.yaml",
99 ] {
100 let path = dir.join(name);
101 if path.exists() {
102 test_files.push(path);
103 }
104 }
105
106 let tests_dir = dir.join("tests");
108 if tests_dir.is_dir() {
109 if let Ok(entries) = fs::read_dir(&tests_dir) {
110 for entry in entries.flatten() {
111 let path = entry.path();
112 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
113 if (name.ends_with(".roxid-test.yml")
114 || name.ends_with(".roxid-test.yaml")
115 || name == "roxid-test.yml"
116 || name == "roxid-test.yaml")
117 && path.is_file()
118 {
119 test_files.push(path);
120 }
121 }
122 }
123 }
124 }
125
126 test_files.sort();
127 test_files
128 }
129
130 pub fn apply_defaults(test: &mut PipelineTest, defaults: &TestDefaults) {
132 for (key, value) in &defaults.variables {
134 test.variables.entry(key.clone()).or_insert(value.clone());
135 }
136
137 for (key, value) in &defaults.parameters {
139 test.parameters.entry(key.clone()).or_insert(value.clone());
140 }
141
142 if test.working_dir.is_none() {
144 test.working_dir.clone_from(&defaults.working_dir);
145 }
146 }
147
148 fn validate(suite: &TestSuite) -> Result<(), TestParseError> {
150 if suite.tests.is_empty() {
151 return Err(TestParseError::ValidationError(
152 "Test suite must contain at least one test".to_string(),
153 ));
154 }
155
156 for (i, test) in suite.tests.iter().enumerate() {
157 if test.name.is_empty() {
158 return Err(TestParseError::ValidationError(format!(
159 "Test at index {} must have a non-empty name",
160 i
161 )));
162 }
163
164 if test.pipeline.as_os_str().is_empty() {
165 return Err(TestParseError::ValidationError(format!(
166 "Test '{}' must specify a pipeline file",
167 test.name
168 )));
169 }
170 }
171
172 let mut names = std::collections::HashSet::new();
174 for test in &suite.tests {
175 if !names.insert(&test.name) {
176 return Err(TestParseError::ValidationError(format!(
177 "Duplicate test name: '{}'",
178 test.name
179 )));
180 }
181 }
182
183 Ok(())
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_parse_basic_suite() {
193 let yaml = r#"
194tests:
195 - name: "Basic test"
196 pipeline: pipeline.yml
197 assertions:
198 - pipeline_succeeded
199"#;
200 let suite = TestFileParser::parse(yaml).unwrap();
201 assert_eq!(suite.tests.len(), 1);
202 assert_eq!(suite.tests[0].name, "Basic test");
203 }
204
205 #[test]
206 fn test_parse_full_suite() {
207 let yaml = r#"
208name: "My test suite"
209defaults:
210 variables:
211 ENV: test
212 working_dir: /tmp
213tests:
214 - name: "Build test"
215 pipeline: azure-pipelines.yml
216 variables:
217 BUILD_CONFIG: Release
218 assertions:
219 - step_succeeded: Build
220 - step_output_contains:
221 step: Build
222 pattern: "Build succeeded"
223
224 - name: "Deploy skipped on PR"
225 pipeline: azure-pipelines.yml
226 variables:
227 BUILD_REASON: PullRequest
228 assertions:
229 - step_skipped: Deploy
230 - pipeline_succeeded
231"#;
232 let suite = TestFileParser::parse(yaml).unwrap();
233 assert_eq!(suite.name, Some("My test suite".to_string()));
234 assert!(suite.defaults.is_some());
235 assert_eq!(suite.tests.len(), 2);
236 }
237
238 #[test]
239 fn test_parse_empty_tests_fails() {
240 let yaml = r#"
241tests: []
242"#;
243 let result = TestFileParser::parse(yaml);
244 assert!(result.is_err());
245 assert!(matches!(
246 result.unwrap_err(),
247 TestParseError::ValidationError(_)
248 ));
249 }
250
251 #[test]
252 fn test_parse_duplicate_names_fails() {
253 let yaml = r#"
254tests:
255 - name: "Test A"
256 pipeline: pipeline.yml
257 assertions: []
258 - name: "Test A"
259 pipeline: pipeline.yml
260 assertions: []
261"#;
262 let result = TestFileParser::parse(yaml);
263 assert!(result.is_err());
264 }
265
266 #[test]
267 fn test_apply_defaults() {
268 let defaults = TestDefaults {
269 variables: {
270 let mut m = std::collections::HashMap::new();
271 m.insert("ENV".to_string(), "test".to_string());
272 m.insert("DEBUG".to_string(), "false".to_string());
273 m
274 },
275 parameters: std::collections::HashMap::new(),
276 working_dir: Some("/tmp".to_string()),
277 };
278
279 let mut test = PipelineTest {
280 name: "test".to_string(),
281 pipeline: PathBuf::from("pipeline.yml"),
282 variables: {
283 let mut m = std::collections::HashMap::new();
284 m.insert("ENV".to_string(), "prod".to_string()); m
286 },
287 parameters: std::collections::HashMap::new(),
288 working_dir: None,
289 assertions: vec![],
290 };
291
292 TestFileParser::apply_defaults(&mut test, &defaults);
293 assert_eq!(test.variables.get("ENV").unwrap(), "prod"); assert_eq!(test.variables.get("DEBUG").unwrap(), "false"); assert_eq!(test.working_dir, Some("/tmp".to_string())); }
297
298 #[test]
299 fn test_parse_file_not_found() {
300 let result = TestFileParser::parse_file(Path::new("/nonexistent/roxid-test.yml"));
301 assert!(matches!(result.unwrap_err(), TestParseError::NotFound(_)));
302 }
303
304 #[test]
305 fn test_discover_no_test_files() {
306 let dir = tempfile::tempdir().unwrap();
307 let files = TestFileParser::discover(dir.path());
308 assert!(files.is_empty());
309 }
310
311 #[test]
312 fn test_discover_standard_names() {
313 let dir = tempfile::tempdir().unwrap();
314 fs::write(dir.path().join("roxid-test.yml"), "tests: []").unwrap();
315 let files = TestFileParser::discover(dir.path());
316 assert_eq!(files.len(), 1);
317 }
318}