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