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                vm.set_harness(harn_vm::Harness::real());
156                let result = match vm.execute(&chunk).await {
157                    Ok(val) => Ok(val),
158                    Err(e) => {
159                        let formatted = vm.format_runtime_error(&e);
160                        Err(formatted)
161                    }
162                };
163                harn_vm::egress::reset_egress_policy_for_host();
164                result
165            }),
166        )
167        .await;
168
169        let duration = start.elapsed().as_millis() as u64;
170
171        match result {
172            Ok(Ok(_)) => {
173                results.push(TestResult {
174                    name: test_name.clone(),
175                    file: path.display().to_string(),
176                    passed: true,
177                    error: None,
178                    duration_ms: duration,
179                });
180            }
181            Ok(Err(e)) => {
182                results.push(TestResult {
183                    name: test_name.clone(),
184                    file: path.display().to_string(),
185                    passed: false,
186                    error: Some(e),
187                    duration_ms: duration,
188                });
189            }
190            Err(_) => {
191                results.push(TestResult {
192                    name: test_name.clone(),
193                    file: path.display().to_string(),
194                    passed: false,
195                    error: Some(format!("timed out after {timeout_ms}ms")),
196                    duration_ms: timeout_ms,
197                });
198            }
199        }
200    }
201
202    Ok(results)
203}
204
205/// Discover and run tests in a file or directory.
206pub async fn run_tests(
207    path: &Path,
208    filter: Option<&str>,
209    timeout_ms: u64,
210    parallel: bool,
211    cli_skill_dirs: &[PathBuf],
212) -> TestSummary {
213    // Default LLM provider to "mock" in test mode unless caller overrides.
214    let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
215    let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
216
217    let start = Instant::now();
218    let mut all_results = Vec::new();
219
220    let canonical_target = canonicalize_existing_path(path);
221    let files = if canonical_target.is_dir() {
222        discover_test_files(&canonical_target)
223    } else {
224        vec![canonical_target]
225    };
226
227    if parallel {
228        let mut handles = Vec::new();
229        for file in files {
230            let filter = filter.map(|s| s.to_string());
231            let cli_skill_dirs = cli_skill_dirs.to_vec();
232            handles.push(tokio::task::spawn_blocking(move || {
233                let execution_cwd = file
234                    .parent()
235                    .filter(|parent| !parent.as_os_str().is_empty())
236                    .map(Path::to_path_buf);
237                run_test_file_on_isolated_thread(
238                    &file,
239                    filter.as_deref(),
240                    timeout_ms,
241                    execution_cwd.as_deref(),
242                    &cli_skill_dirs,
243                )
244            }));
245        }
246        for handle in handles {
247            match handle.await {
248                Ok(Ok(r)) => all_results.extend(r),
249                Ok(Err(e)) => all_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) => all_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    } else {
266        for file in &files {
267            let execution_cwd = file
268                .parent()
269                .filter(|parent| !parent.as_os_str().is_empty());
270            match run_test_file(file, filter, timeout_ms, execution_cwd, cli_skill_dirs).await {
271                Ok(results) => all_results.extend(results),
272                Err(e) => {
273                    all_results.push(TestResult {
274                        name: "<file error>".to_string(),
275                        file: file.display().to_string(),
276                        passed: false,
277                        error: Some(e),
278                        duration_ms: 0,
279                    });
280                }
281            }
282        }
283    }
284
285    let total = all_results.len();
286    let passed = all_results.iter().filter(|r| r.passed).count();
287    let failed = total - passed;
288
289    TestSummary {
290        results: all_results,
291        passed,
292        failed,
293        total,
294        duration_ms: start.elapsed().as_millis() as u64,
295    }
296}
297
298fn run_test_file_on_isolated_thread(
299    file: &Path,
300    filter: Option<&str>,
301    timeout_ms: u64,
302    execution_cwd: Option<&Path>,
303    cli_skill_dirs: &[PathBuf],
304) -> Result<Vec<TestResult>, String> {
305    let runtime = tokio::runtime::Builder::new_current_thread()
306        .enable_all()
307        .build()
308        .map_err(|error| format!("failed to start test runtime: {error}"))?;
309    runtime.block_on(run_test_file(
310        file,
311        filter,
312        timeout_ms,
313        execution_cwd,
314        cli_skill_dirs,
315    ))
316}
317
318fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
319    let mut files = Vec::new();
320    if let Ok(entries) = std::fs::read_dir(dir) {
321        for entry in entries.flatten() {
322            let path = entry.path();
323            if path.is_dir() {
324                files.extend(discover_test_files(&path));
325            } else if path.extension().is_some_and(|e| e == "harn") {
326                if let Ok(content) = std::fs::read_to_string(&path) {
327                    if content.contains("test_") || content.contains("@test") {
328                        files.push(canonicalize_existing_path(&path));
329                    }
330                }
331            }
332        }
333    }
334    files.sort();
335    files
336}
337
338#[cfg(test)]
339mod tests {
340    use super::{discover_test_files, run_tests};
341    use std::fs;
342    use std::path::{Path, PathBuf};
343    use std::time::{SystemTime, UNIX_EPOCH};
344
345    struct TempTestDir {
346        path: PathBuf,
347    }
348
349    impl TempTestDir {
350        fn new() -> Self {
351            let unique = format!(
352                "harn-test-runner-{}-{}",
353                std::process::id(),
354                SystemTime::now()
355                    .duration_since(UNIX_EPOCH)
356                    .unwrap()
357                    .as_nanos()
358            );
359            let path = std::env::temp_dir().join(unique);
360            fs::create_dir_all(&path).unwrap();
361            Self { path }
362        }
363
364        fn write(&self, relative: &str, contents: &str) {
365            let path = self.path.join(relative);
366            if let Some(parent) = path.parent() {
367                fs::create_dir_all(parent).unwrap();
368            }
369            fs::write(path, contents).unwrap();
370        }
371
372        fn path(&self) -> &Path {
373            &self.path
374        }
375    }
376
377    impl Drop for TempTestDir {
378        fn drop(&mut self) {
379            let _ = fs::remove_dir_all(&self.path);
380        }
381    }
382
383    #[test]
384    fn discover_test_files_returns_canonical_absolute_paths() {
385        let temp = TempTestDir::new();
386        temp.write("suite/test_alpha.harn", "pipeline test_alpha(task) {}");
387        temp.write("suite/nested/test_beta.harn", "pipeline test_beta(task) {}");
388        temp.write("suite/annotated.harn", "@test\npipeline annotated(task) {}");
389        temp.write("suite/ignore.harn", "pipeline build(task) {}");
390
391        // Pass an absolute path rather than mutating process-wide cwd — the
392        // other test_runner test asserts cwd preservation, and mutating it
393        // from two tests concurrently causes cross-test flakiness.
394        let files = discover_test_files(&temp.path().join("suite"));
395
396        assert_eq!(files.len(), 3);
397        assert!(files.iter().all(|path| path.is_absolute()));
398        assert!(files
399            .iter()
400            .any(|path| path.ends_with("suite/test_alpha.harn")));
401        assert!(files
402            .iter()
403            .any(|path| path.ends_with("suite/nested/test_beta.harn")));
404        assert!(files
405            .iter()
406            .any(|path| path.ends_with("suite/annotated.harn")));
407    }
408
409    #[tokio::test]
410    async fn run_tests_uses_file_parent_as_execution_cwd_and_restores_shell_cwd() {
411        let _cwd_guard = crate::tests::common::cwd_lock::lock_cwd_async().await;
412        let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
413        let temp = TempTestDir::new();
414        temp.write(
415            "suite/test_cwd.harn",
416            r#"
417pipeline test_current_dir(task) {
418  assert_eq(cwd(), source_dir())
419}
420"#,
421        );
422
423        let original_cwd = std::env::current_dir().unwrap();
424        let summary = run_tests(&temp.path().join("suite"), None, 1_000, false, &[]).await;
425        let restored_cwd = std::env::current_dir().unwrap();
426
427        assert_eq!(summary.failed, 0);
428        assert_eq!(summary.passed, 1);
429        assert_eq!(
430            fs::canonicalize(restored_cwd).unwrap(),
431            fs::canonicalize(original_cwd).unwrap()
432        );
433    }
434
435    #[tokio::test]
436    async fn parallel_run_tests_uses_each_file_parent_as_execution_cwd() {
437        let _cwd_guard = crate::tests::common::cwd_lock::lock_cwd_async().await;
438        let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
439        let temp = TempTestDir::new();
440        temp.write(
441            "suite/a/test_one.harn",
442            r#"
443pipeline test_one(task) {
444  assert_eq(cwd(), source_dir())
445}
446"#,
447        );
448        temp.write(
449            "suite/b/test_two.harn",
450            r#"
451pipeline test_two(task) {
452  assert_eq(cwd(), source_dir())
453}
454"#,
455        );
456
457        let summary = run_tests(&temp.path().join("suite"), None, 1_000, true, &[]).await;
458        assert_eq!(summary.failed, 0);
459        assert_eq!(summary.passed, 2);
460    }
461
462    #[tokio::test]
463    async fn run_tests_loads_cli_skill_dirs() {
464        let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
465        let temp = TempTestDir::new();
466        temp.write(
467            "skills/review/SKILL.md",
468            r#"---
469name: review
470short: Review PRs
471description: Review pull requests
472---
473
474Review instructions.
475"#,
476        );
477        temp.write(
478            "suite/test_skills.harn",
479            r#"
480pipeline test_cli_skills(task) {
481  assert_eq(skill_count(skills), 1)
482  let found = skill_find(skills, "review")
483  assert_eq(found.name, "review")
484}
485"#,
486        );
487
488        let summary = run_tests(
489            &temp.path().join("suite"),
490            None,
491            1_000,
492            false,
493            &[temp.path().join("skills")],
494        )
495        .await;
496
497        assert_eq!(summary.failed, 0, "{:?}", summary.results[0].error);
498        assert_eq!(summary.passed, 1);
499    }
500}