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
33pub 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 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
205pub 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 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 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}