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 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
204pub 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 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 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}