Skip to main content

harn_cli/
test_runner.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use harn_lexer::Lexer;
5use harn_parser::{Node, Parser};
6
7use crate::env_guard::ScopedEnvVar;
8
9pub struct TestResult {
10    pub name: String,
11    pub file: String,
12    pub passed: bool,
13    pub error: Option<String>,
14    pub duration_ms: u64,
15}
16
17pub struct TestSummary {
18    pub results: Vec<TestResult>,
19    pub passed: usize,
20    pub failed: usize,
21    pub total: usize,
22    pub duration_ms: u64,
23}
24
25fn canonicalize_existing_path(path: &Path) -> PathBuf {
26    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
27}
28
29fn test_execution_cwd() -> PathBuf {
30    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
31}
32
33/// Run all test_* pipelines in a single source file using the VM.
34pub async fn run_test_file(
35    path: &Path,
36    filter: Option<&str>,
37    timeout_ms: u64,
38    execution_cwd: Option<&Path>,
39) -> Result<Vec<TestResult>, String> {
40    let source = std::fs::read_to_string(path)
41        .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
42
43    let mut lexer = Lexer::new(&source);
44    let tokens = lexer.tokenize().map_err(|e| format!("{e}"))?;
45    let mut parser = Parser::new(tokens);
46    let program = parser.parse().map_err(|e| format!("{e}"))?;
47
48    let test_names: Vec<String> = program
49        .iter()
50        .filter_map(|snode| {
51            // Recognize either:
52            //  - the legacy naming convention: `pipeline test_*`
53            //  - the explicit `@test` attribute on a Pipeline (declarative)
54            let (has_test_attr, decl_node) = match &snode.node {
55                Node::AttributedDecl { attributes, inner } => {
56                    (attributes.iter().any(|a| a.name == "test"), inner.as_ref())
57                }
58                _ => (false, snode),
59            };
60            let name = match &decl_node.node {
61                Node::Pipeline { name, .. } => name.clone(),
62                _ => return None,
63            };
64            if !(has_test_attr || name.starts_with("test_")) {
65                return None;
66            }
67            if let Some(pattern) = filter {
68                if !name.contains(pattern) {
69                    return None;
70                }
71            }
72            Some(name)
73        })
74        .collect();
75
76    let mut results = Vec::new();
77
78    for test_name in &test_names {
79        harn_vm::reset_thread_local_state();
80
81        let start = Instant::now();
82
83        let chunk = match harn_vm::Compiler::new().compile_named(&program, test_name) {
84            Ok(c) => c,
85            Err(e) => {
86                results.push(TestResult {
87                    name: test_name.clone(),
88                    file: path.display().to_string(),
89                    passed: false,
90                    error: Some(format!("Compile error: {e}")),
91                    duration_ms: 0,
92                });
93                continue;
94            }
95        };
96
97        let local = tokio::task::LocalSet::new();
98        let path_str = path.display().to_string();
99        let timeout = std::time::Duration::from_millis(timeout_ms);
100        let execution_cwd = execution_cwd
101            .map(Path::to_path_buf)
102            .unwrap_or_else(test_execution_cwd);
103        let result = tokio::time::timeout(
104            timeout,
105            local.run_until(async {
106                let mut vm = harn_vm::Vm::new();
107                harn_vm::register_vm_stdlib(&mut vm);
108                crate::install_default_hostlib(&mut vm);
109                let source_parent = path.parent().unwrap_or(std::path::Path::new("."));
110                let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
111                let store_base = project_root.as_deref().unwrap_or(source_parent);
112                let source_dir = source_parent.to_string_lossy().into_owned();
113                harn_vm::register_store_builtins(&mut vm, store_base);
114                harn_vm::register_metadata_builtins(&mut vm, store_base);
115                let pipeline_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("test");
116                harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
117                vm.set_source_info(&path_str, &source);
118                harn_vm::stdlib::process::set_thread_execution_context(Some(
119                    harn_vm::orchestration::RunExecutionRecord {
120                        cwd: Some(execution_cwd.to_string_lossy().into_owned()),
121                        source_dir: Some(source_dir),
122                        env: std::collections::BTreeMap::new(),
123                        adapter: None,
124                        repo_path: None,
125                        worktree_path: None,
126                        branch: None,
127                        base_ref: None,
128                        cleanup: None,
129                    },
130                ));
131                if let Some(ref root) = project_root {
132                    vm.set_project_root(root);
133                }
134                if let Some(parent) = path.parent() {
135                    if !parent.as_os_str().is_empty() {
136                        vm.set_source_dir(parent);
137                    }
138                }
139                let loaded =
140                    crate::skill_loader::load_skills(&crate::skill_loader::SkillLoaderInputs {
141                        cli_dirs: Vec::new(),
142                        source_path: Some(path.to_path_buf()),
143                    });
144                crate::skill_loader::emit_loader_warnings(&loaded.loader_warnings);
145                crate::skill_loader::install_skills_global(&mut vm, &loaded);
146                let extensions = crate::package::load_runtime_extensions(path);
147                crate::package::install_runtime_extensions(&extensions);
148                crate::package::install_manifest_triggers(&mut vm, &extensions)
149                    .await
150                    .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
151                crate::package::install_manifest_hooks(&mut vm, &extensions)
152                    .await
153                    .map_err(|error| format!("failed to install manifest hooks: {error}"))?;
154                let result = match vm.execute(&chunk).await {
155                    Ok(val) => Ok(val),
156                    Err(e) => {
157                        let formatted = vm.format_runtime_error(&e);
158                        Err(formatted)
159                    }
160                };
161                harn_vm::egress::reset_egress_policy_for_host();
162                result
163            }),
164        )
165        .await;
166
167        let duration = start.elapsed().as_millis() as u64;
168
169        match result {
170            Ok(Ok(_)) => {
171                results.push(TestResult {
172                    name: test_name.clone(),
173                    file: path.display().to_string(),
174                    passed: true,
175                    error: None,
176                    duration_ms: duration,
177                });
178            }
179            Ok(Err(e)) => {
180                results.push(TestResult {
181                    name: test_name.clone(),
182                    file: path.display().to_string(),
183                    passed: false,
184                    error: Some(e),
185                    duration_ms: duration,
186                });
187            }
188            Err(_) => {
189                results.push(TestResult {
190                    name: test_name.clone(),
191                    file: path.display().to_string(),
192                    passed: false,
193                    error: Some(format!("timed out after {timeout_ms}ms")),
194                    duration_ms: timeout_ms,
195                });
196            }
197        }
198    }
199
200    Ok(results)
201}
202
203/// Discover and run tests in a file or directory.
204pub async fn run_tests(
205    path: &Path,
206    filter: Option<&str>,
207    timeout_ms: u64,
208    parallel: bool,
209) -> TestSummary {
210    // Default LLM provider to "mock" in test mode unless caller overrides.
211    let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
212    let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
213
214    let start = Instant::now();
215    let mut all_results = Vec::new();
216
217    let canonical_target = canonicalize_existing_path(path);
218    let files = if canonical_target.is_dir() {
219        discover_test_files(&canonical_target)
220    } else {
221        vec![canonical_target]
222    };
223
224    if parallel {
225        let local = tokio::task::LocalSet::new();
226        let results = local
227            .run_until(async {
228                let mut handles = Vec::new();
229                for file in files {
230                    let filter = filter.map(|s| s.to_string());
231                    handles.push(tokio::task::spawn_local(async move {
232                        let execution_cwd = file
233                            .parent()
234                            .filter(|parent| !parent.as_os_str().is_empty())
235                            .map(Path::to_path_buf);
236                        run_test_file(
237                            &file,
238                            filter.as_deref(),
239                            timeout_ms,
240                            execution_cwd.as_deref(),
241                        )
242                        .await
243                    }));
244                }
245                let mut results = Vec::new();
246                for handle in handles {
247                    match handle.await {
248                        Ok(Ok(r)) => results.extend(r),
249                        Ok(Err(e)) => results.push(TestResult {
250                            name: "<file error>".to_string(),
251                            file: String::new(),
252                            passed: false,
253                            error: Some(e),
254                            duration_ms: 0,
255                        }),
256                        Err(e) => results.push(TestResult {
257                            name: "<join error>".to_string(),
258                            file: String::new(),
259                            passed: false,
260                            error: Some(format!("{e}")),
261                            duration_ms: 0,
262                        }),
263                    }
264                }
265                results
266            })
267            .await;
268        all_results = results;
269    } else {
270        for file in &files {
271            let execution_cwd = file
272                .parent()
273                .filter(|parent| !parent.as_os_str().is_empty());
274            match run_test_file(file, filter, timeout_ms, execution_cwd).await {
275                Ok(results) => all_results.extend(results),
276                Err(e) => {
277                    all_results.push(TestResult {
278                        name: "<file error>".to_string(),
279                        file: file.display().to_string(),
280                        passed: false,
281                        error: Some(e),
282                        duration_ms: 0,
283                    });
284                }
285            }
286        }
287    }
288
289    let passed = all_results.iter().filter(|r| r.passed).count();
290    let failed = all_results.iter().filter(|r| !r.passed).count();
291    let total = all_results.len();
292
293    TestSummary {
294        results: all_results,
295        passed,
296        failed,
297        total,
298        duration_ms: start.elapsed().as_millis() as u64,
299    }
300}
301
302fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
303    let mut files = Vec::new();
304    if let Ok(entries) = std::fs::read_dir(dir) {
305        for entry in entries.flatten() {
306            let path = entry.path();
307            if path.is_dir() {
308                files.extend(discover_test_files(&path));
309            } else if path.extension().is_some_and(|e| e == "harn") {
310                if let Ok(content) = std::fs::read_to_string(&path) {
311                    if content.contains("test_") || content.contains("@test") {
312                        files.push(canonicalize_existing_path(&path));
313                    }
314                }
315            }
316        }
317    }
318    files.sort();
319    files
320}
321
322#[cfg(test)]
323mod tests {
324    use super::{discover_test_files, run_tests};
325    use std::fs;
326    use std::path::{Path, PathBuf};
327    use std::time::{SystemTime, UNIX_EPOCH};
328
329    struct TempTestDir {
330        path: PathBuf,
331    }
332
333    impl TempTestDir {
334        fn new() -> Self {
335            let unique = format!(
336                "harn-test-runner-{}-{}",
337                std::process::id(),
338                SystemTime::now()
339                    .duration_since(UNIX_EPOCH)
340                    .unwrap()
341                    .as_nanos()
342            );
343            let path = std::env::temp_dir().join(unique);
344            fs::create_dir_all(&path).unwrap();
345            Self { path }
346        }
347
348        fn write(&self, relative: &str, contents: &str) {
349            let path = self.path.join(relative);
350            if let Some(parent) = path.parent() {
351                fs::create_dir_all(parent).unwrap();
352            }
353            fs::write(path, contents).unwrap();
354        }
355
356        fn path(&self) -> &Path {
357            &self.path
358        }
359    }
360
361    impl Drop for TempTestDir {
362        fn drop(&mut self) {
363            let _ = fs::remove_dir_all(&self.path);
364        }
365    }
366
367    #[test]
368    fn discover_test_files_returns_canonical_absolute_paths() {
369        let temp = TempTestDir::new();
370        temp.write("suite/test_alpha.harn", "pipeline test_alpha(task) {}");
371        temp.write("suite/nested/test_beta.harn", "pipeline test_beta(task) {}");
372        temp.write("suite/annotated.harn", "@test\npipeline annotated(task) {}");
373        temp.write("suite/ignore.harn", "pipeline build(task) {}");
374
375        // Pass an absolute path rather than mutating process-wide cwd — the
376        // other test_runner test asserts cwd preservation, and mutating it
377        // from two tests concurrently causes cross-test flakiness.
378        let files = discover_test_files(&temp.path().join("suite"));
379
380        assert_eq!(files.len(), 3);
381        assert!(files.iter().all(|path| path.is_absolute()));
382        assert!(files
383            .iter()
384            .any(|path| path.ends_with("suite/test_alpha.harn")));
385        assert!(files
386            .iter()
387            .any(|path| path.ends_with("suite/nested/test_beta.harn")));
388        assert!(files
389            .iter()
390            .any(|path| path.ends_with("suite/annotated.harn")));
391    }
392
393    #[tokio::test]
394    async fn run_tests_uses_file_parent_as_execution_cwd_and_restores_shell_cwd() {
395        let _cwd_guard = crate::tests::common::cwd_lock::lock_cwd_async().await;
396        let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
397        let temp = TempTestDir::new();
398        temp.write(
399            "suite/test_cwd.harn",
400            r#"
401pipeline test_current_dir(task) {
402  assert_eq(cwd(), source_dir())
403}
404"#,
405        );
406
407        let original_cwd = std::env::current_dir().unwrap();
408        let summary = run_tests(&temp.path().join("suite"), None, 1_000, false).await;
409        let restored_cwd = std::env::current_dir().unwrap();
410
411        assert_eq!(summary.failed, 0);
412        assert_eq!(summary.passed, 1);
413        assert_eq!(
414            fs::canonicalize(restored_cwd).unwrap(),
415            fs::canonicalize(original_cwd).unwrap()
416        );
417    }
418
419    #[tokio::test]
420    async fn parallel_run_tests_uses_each_file_parent_as_execution_cwd() {
421        let _cwd_guard = crate::tests::common::cwd_lock::lock_cwd_async().await;
422        let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
423        let temp = TempTestDir::new();
424        temp.write(
425            "suite/a/test_one.harn",
426            r#"
427pipeline test_one(task) {
428  assert_eq(cwd(), source_dir())
429}
430"#,
431        );
432        temp.write(
433            "suite/b/test_two.harn",
434            r#"
435pipeline test_two(task) {
436  assert_eq(cwd(), source_dir())
437}
438"#,
439        );
440
441        let summary = run_tests(&temp.path().join("suite"), None, 1_000, true).await;
442        assert_eq!(summary.failed, 0);
443        assert_eq!(summary.passed, 2);
444    }
445}