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) -> 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 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
203pub async fn run_tests(
205 path: &Path,
206 filter: Option<&str>,
207 timeout_ms: u64,
208 parallel: bool,
209) -> TestSummary {
210 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 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}