1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::fs;
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::{Arc, Mutex};
7use std::thread;
8use std::time::Instant;
9
10use crate::env_guard::ScopedEnvVar;
11use crate::package;
12use crate::test_timing::DurationSummary;
13use crate::CLI_RUNTIME_STACK_SIZE;
14use harn_vm::IsolateValue;
15
16mod execution;
17#[cfg(test)]
18mod fixture_tests;
19mod skill_context;
20#[cfg(test)]
21mod tests;
22
23use execution::{execute_case, execute_file_fixture};
24use harn_test_runner::{
25 extract_cases_from_program, parse_program, prepare_callable_entries,
26 seed_imported_enum_candidates, FixtureScope, TestCase, TestFixture,
27};
28pub use harn_test_runner::{
29 AggregateTimings, PhaseTimings, SuiteCallablePreparation, TestPhase, TestResult, TestSummary,
30 TestTimeout,
31};
32pub use harn_test_runner::{TestRunSession, TestRunSessionStats};
33use skill_context::PreparedSkillContexts;
34
35pub use harn_test_runner::{TestRunEvent, TestRunProgress};
36
37const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
38const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
39const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
40const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
41const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
42const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
43const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
44
45const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
52const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
53
54const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
62
63#[derive(Clone, Default)]
69pub struct RunOptions {
70 pub filter: Option<String>,
71 pub timeout_ms: u64,
72 pub max_test_ms: Option<u64>,
76 pub max_execute_ms: Option<u64>,
80 pub parallel: bool,
84 pub fail_fast: bool,
87 pub jobs: Option<usize>,
91 pub shard: Option<TestShard>,
94 pub cli_skill_dirs: Vec<PathBuf>,
95 pub progress: Option<TestRunProgress>,
98 pub diagnose: bool,
102 pub trusted_host_dispatch: bool,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct TestShard {
109 index: usize,
110 total: usize,
111}
112
113impl TestShard {
114 pub fn new(index: usize, total: usize) -> Result<Self, String> {
115 if total == 0 {
116 return Err("test shard total must be at least 1".to_string());
117 }
118 if index == 0 {
119 return Err("test shard index must be at least 1".to_string());
120 }
121 if index > total {
122 return Err(format!(
123 "test shard index {index} exceeds shard total {total}"
124 ));
125 }
126 Ok(Self { index, total })
127 }
128
129 pub fn index(self) -> usize {
130 self.index
131 }
132
133 pub fn total(self) -> usize {
134 self.total
135 }
136}
137
138impl RunOptions {
139 pub fn new(timeout_ms: u64) -> Self {
140 Self {
141 timeout_ms,
142 ..Default::default()
143 }
144 }
145}
146
147fn canonicalize_existing_path(path: &Path) -> PathBuf {
148 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
149}
150
151fn test_execution_cwd() -> PathBuf {
152 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
153}
154
155fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
156 if let Some(callback) = progress {
157 callback(event);
158 }
159}
160
161fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
162 total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
163}
164
165pub async fn run_tests(
167 path: &Path,
168 filter: Option<&str>,
169 timeout_ms: u64,
170 parallel: bool,
171 cli_skill_dirs: &[PathBuf],
172) -> TestSummary {
173 let options = RunOptions {
174 filter: filter.map(str::to_owned),
175 timeout_ms,
176 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
177 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
178 parallel,
179 fail_fast: false,
180 jobs: None,
181 shard: None,
182 cli_skill_dirs: cli_skill_dirs.to_vec(),
183 progress: None,
184 diagnose: diagnose_enabled_via_env(),
185 trusted_host_dispatch: false,
186 };
187 run_tests_with_options(path, &options).await
188}
189
190pub async fn run_tests_with_progress(
192 path: &Path,
193 filter: Option<&str>,
194 timeout_ms: u64,
195 parallel: bool,
196 cli_skill_dirs: &[PathBuf],
197 progress: Option<TestRunProgress>,
198) -> TestSummary {
199 let options = RunOptions {
200 filter: filter.map(str::to_owned),
201 timeout_ms,
202 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
203 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
204 parallel,
205 fail_fast: false,
206 jobs: None,
207 shard: None,
208 cli_skill_dirs: cli_skill_dirs.to_vec(),
209 progress,
210 diagnose: diagnose_enabled_via_env(),
211 trusted_host_dispatch: false,
212 };
213 run_tests_with_options(path, &options).await
214}
215
216fn diagnose_enabled_via_env() -> bool {
217 let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
218 return false;
219 };
220 matches!(
221 raw.to_ascii_lowercase().as_str(),
222 "1" | "true" | "yes" | "on"
223 )
224}
225
226fn test_budget_ms_via_env(name: &str) -> Option<u64> {
227 std::env::var(name)
228 .ok()
229 .and_then(|raw| raw.trim().parse::<u64>().ok())
230 .filter(|&value| value >= 1)
231}
232
233pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
238 run_tests_with_session(path, options, &TestRunSession::default()).await
239}
240
241pub fn run_tests_with_session<'a>(
247 path: &'a Path,
248 options: &'a RunOptions,
249 session: &'a TestRunSession,
250) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
251 run_tests_with_session_and_operator_grant(path, options, session, None)
252}
253
254pub(crate) fn run_tests_with_session_and_operator_grant<'a>(
259 path: &'a Path,
260 options: &'a RunOptions,
261 session: &'a TestRunSession,
262 operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
263) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
264 Box::pin(async move {
265 let paths = [path.to_path_buf()];
266 run_tests_with_paths_and_operator_grant(&paths, options, session, operator_approval_grant)
267 .await
268 })
269}
270
271pub(crate) fn run_tests_with_paths_and_operator_grant<'a>(
274 paths: &'a [PathBuf],
275 options: &'a RunOptions,
276 session: &'a TestRunSession,
277 operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
278) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
279 Box::pin(run_tests_with_session_impl(
280 paths,
281 options,
282 session,
283 operator_approval_grant,
284 ))
285}
286
287async fn run_tests_with_session_impl(
288 paths: &[PathBuf],
289 options: &RunOptions,
290 session: &TestRunSession,
291 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
292) -> TestSummary {
293 let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
295 let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
296
297 let start = Instant::now();
298
299 let collection_start = Instant::now();
300 let canonical_targets = paths
301 .iter()
302 .map(|path| canonicalize_existing_path(path))
303 .collect::<Vec<_>>();
304 let mut files = canonical_targets
305 .iter()
306 .flat_map(|target| {
307 if target.is_dir() {
308 discover_test_files(target)
309 } else {
310 vec![target.clone()]
311 }
312 })
313 .collect::<Vec<_>>();
314 files.sort();
315 files.dedup();
316
317 let workers = resolve_workers(options);
318 let timings_path = canonical_targets
319 .first()
320 .and_then(|target| timings_cache_path(target));
321 let timings = timings_path
322 .as_deref()
323 .map(load_timings_cache)
324 .unwrap_or_default();
325
326 let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
327 let mut declared_dispatch: BTreeMap<PathBuf, bool> = BTreeMap::new();
334 for case in &mut discovery.cases {
335 let declared = *declared_dispatch
336 .entry(case.file.clone())
337 .or_insert_with(|| package::load_check_config(Some(&case.file)).trusted_host_dispatch);
338 case.trusted_host_dispatch = options.trusted_host_dispatch || declared;
339 }
340 if let Some(shard) = options.shard {
341 discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
342 if shard.index() > 1 {
343 discovery.discovery_errors.clear();
344 }
345 }
346 let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
347 let collection_ms = collection_start.elapsed().as_millis() as u64;
348 let selected_files_with_tests = if options.shard.is_some() {
349 count_files_with_cases(&discovery.cases)
350 } else {
351 discovery.files_with_tests
352 };
353
354 emit_progress(
355 &options.progress,
356 TestRunEvent::SuiteDiscovered {
357 total_tests: discovery.cases.len(),
358 total_files: selected_files_with_tests,
359 parallel: options.parallel,
360 workers,
361 },
362 );
363 if workers == 1
364 && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
365 {
366 emit_progress(
367 &options.progress,
368 TestRunEvent::LargeSequentialSuite {
369 total_tests: discovery.cases.len(),
370 total_files: selected_files_with_tests,
371 },
372 );
373 }
374
375 let mut cases = discovery.cases;
376 sort_cases_longest_first(&mut cases, &timings);
377 let module_preparation = session.prepare_import_graphs(
378 cases
379 .iter()
380 .map(|case| (case.file.clone(), case.trusted_host_dispatch)),
381 );
382
383 let mut all_results = discovery.discovery_errors;
384 let total_tests = cases.len();
385 let callable_preparation = if !options.fail_fast || all_results.is_empty() {
386 let prepared = prepare_callable_entries(cases, session);
387 cases = prepared.cases;
388 all_results.extend(prepared.failures);
389 prepared.timing
390 } else {
391 cases.clear();
392 SuiteCallablePreparation::default()
393 };
394 if !options.fail_fast || all_results.is_empty() {
395 let prepared = prepare_file_fixtures(
396 cases,
397 options,
398 session,
399 &skill_contexts,
400 operator_approval_grant,
401 )
402 .await;
403 cases = prepared.cases;
404 all_results.extend(prepared.failures);
405 } else {
406 cases.clear();
407 }
408 let execution = if !options.fail_fast || all_results.is_empty() {
409 execute_cases(
410 cases,
411 workers,
412 options,
413 total_tests,
414 session,
415 skill_contexts,
416 operator_approval_grant,
417 )
418 .await
419 } else {
420 CaseExecutionResults::default()
421 };
422
423 let timing = DurationSummary::from_samples(
424 &execution
425 .cases
426 .iter()
427 .map(|result| result.duration_ms)
428 .collect::<Vec<_>>(),
429 );
430 if let Some(path) = timings_path.as_deref() {
431 update_timings_cache(path, timings, &execution.cases);
432 }
433 all_results.extend(execution.cases);
434 all_results.extend(execution.infrastructure_errors);
435 let total = all_results.len();
436 let passed = all_results.iter().filter(|result| result.passed).count();
437 let failed = total - passed;
438 let aggregate = AggregateTimings::from_results(
439 collection_ms,
440 module_preparation,
441 callable_preparation,
442 &all_results,
443 );
444
445 TestSummary {
446 results: all_results,
447 passed,
448 failed,
449 total,
450 duration_ms: start.elapsed().as_millis() as u64,
451 timing,
452 aggregate,
453 }
454}
455
456pub async fn run_test_file(
464 path: &Path,
465 filter: Option<&str>,
466 timeout_ms: u64,
467 execution_cwd: Option<&Path>,
468 cli_skill_dirs: &[PathBuf],
469) -> Result<Vec<TestResult>, String> {
470 run_test_file_with_session(
471 path,
472 filter,
473 timeout_ms,
474 execution_cwd,
475 cli_skill_dirs,
476 &TestRunSession::default(),
477 )
478 .await
479}
480
481pub fn run_test_file_with_session<'a>(
483 path: &'a Path,
484 filter: Option<&'a str>,
485 timeout_ms: u64,
486 execution_cwd: Option<&'a Path>,
487 cli_skill_dirs: &'a [PathBuf],
488 session: &'a TestRunSession,
489) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
490 Box::pin(run_test_file_with_session_impl(
491 path,
492 filter,
493 timeout_ms,
494 execution_cwd,
495 cli_skill_dirs,
496 session,
497 ))
498}
499
500async fn run_test_file_with_session_impl(
501 path: &Path,
502 filter: Option<&str>,
503 timeout_ms: u64,
504 execution_cwd: Option<&Path>,
505 cli_skill_dirs: &[PathBuf],
506 session: &TestRunSession,
507) -> Result<Vec<TestResult>, String> {
508 let source =
509 fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
510 let program = parse_program(&source)?;
511 let source = Arc::new(source);
512 let program = Arc::new(program);
513
514 let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
515 seed_imported_enum_candidates(path, &source, &mut cases);
516 let trusted_host_dispatch = package::load_check_config(Some(path)).trusted_host_dispatch;
517 for case in &mut cases {
518 case.trusted_host_dispatch = trusted_host_dispatch;
519 }
520 let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
521 let _module_preparation = session.prepare_import_graphs(
522 cases
523 .iter()
524 .map(|case| (case.file.clone(), case.trusted_host_dispatch)),
525 );
526
527 let mut results = Vec::with_capacity(cases.len());
528 let callable_preparation = prepare_callable_entries(cases, session);
529 results.extend(callable_preparation.failures);
530 let cases = callable_preparation.cases;
531 let execution_cwd = execution_cwd
532 .map(Path::to_path_buf)
533 .unwrap_or_else(test_execution_cwd);
534 let prepared_module_cache = session.prepared_module_cache(0);
535 let fixture_options = RunOptions {
536 timeout_ms,
537 ..RunOptions::default()
538 };
539 let prepared =
540 prepare_file_fixtures(cases, &fixture_options, session, &skill_contexts, None).await;
541 results.extend(prepared.failures);
542 for case in prepared.cases {
543 let loaded_skills = skill_contexts.for_case(&case);
544 results.push(
545 execute_case(
546 &case,
547 &execution_cwd,
548 timeout_ms,
549 loaded_skills,
550 &prepared_module_cache,
551 session.stdio_available(),
552 None,
553 )
554 .await,
555 );
556 }
557 Ok(results)
558}
559
560fn resolve_workers(options: &RunOptions) -> usize {
561 if !options.parallel {
562 return 1;
563 }
564 if options.max_test_ms.is_some() || options.max_execute_ms.is_some() {
571 return 1;
572 }
573 if let Some(jobs) = options.jobs {
574 return jobs.max(1);
575 }
576 if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
577 if let Ok(parsed) = raw.trim().parse::<usize>() {
578 if parsed >= 1 {
579 return parsed;
580 }
581 }
582 }
583 let detected = thread::available_parallelism()
584 .map(|n| n.get())
585 .unwrap_or(1);
586 let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
587 apply_memory_cap(core_cap)
588}
589
590pub(crate) fn resolve_parallel_workers(jobs: Option<usize>) -> usize {
591 resolve_workers(&RunOptions {
592 parallel: true,
593 jobs,
594 ..RunOptions::default()
595 })
596}
597
598fn apply_memory_cap(core_cap: usize) -> usize {
604 let Some(available_mb) = available_memory_mb() else {
605 return core_cap;
606 };
607 let budget = per_worker_memory_mb();
608 let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
609 if mem_cap < core_cap {
610 eprintln!(
611 "harn test: capping workers {core_cap} -> {mem_cap} \
612 (~{available_mb} MiB available, {budget} MiB/worker; \
613 override with --jobs / HARN_TEST_JOBS)"
614 );
615 return mem_cap;
616 }
617 core_cap
618}
619
620fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
623 let usable = available_mb.saturating_sub(reserved_mb);
624 let per_worker = per_worker_mb.max(1);
625 ((usable / per_worker).max(1)) as usize
626}
627
628fn per_worker_memory_mb() -> u64 {
631 std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
632 .ok()
633 .and_then(|raw| raw.trim().parse::<u64>().ok())
634 .filter(|&n| n >= 1)
635 .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
636}
637
638fn available_memory_mb() -> Option<u64> {
649 let mut sys = sysinfo::System::new();
650 sys.refresh_memory();
651 let host_mb = match sys.available_memory() {
652 0 => None, bytes => Some(bytes / (1024 * 1024)),
654 };
655 match (host_mb, cgroup_v2_headroom_mb()) {
656 (Some(h), Some(c)) => Some(h.min(c)),
657 (Some(h), None) => Some(h),
658 (None, c) => c,
659 }
660}
661
662#[cfg(target_os = "linux")]
665fn cgroup_v2_headroom_mb() -> Option<u64> {
666 let dir = own_cgroup_v2_dir()?;
667 let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
668 let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
669 cgroup_headroom_mb(&max_raw, ¤t_raw)
670}
671
672#[cfg(not(target_os = "linux"))]
673fn cgroup_v2_headroom_mb() -> Option<u64> {
674 None
675}
676
677#[cfg(target_os = "linux")]
683fn own_cgroup_v2_dir() -> Option<PathBuf> {
684 let content = fs::read_to_string("/proc/self/cgroup").ok()?;
685 let rel = content
686 .lines()
687 .find_map(|line| line.strip_prefix("0::"))?
688 .trim();
689 let rel = rel.strip_prefix('/').unwrap_or(rel);
690 Some(Path::new("/sys/fs/cgroup").join(rel))
691}
692
693#[cfg(any(target_os = "linux", test))]
699fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
700 let max = memory_max.trim();
701 if max == "max" {
702 return None;
703 }
704 let max: u64 = max.parse().ok()?;
705 let current: u64 = memory_current.trim().parse().ok()?;
706 Some(max.saturating_sub(current) / (1024 * 1024))
707}
708
709struct Discovery {
710 cases: Vec<TestCase>,
711 files_with_tests: usize,
712 discovery_errors: Vec<TestResult>,
713}
714
715fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
716 let mut cases = Vec::new();
717 let mut files_with_tests = 0usize;
718 let mut discovery_errors = Vec::new();
719
720 for file in files {
721 let source = match fs::read_to_string(file) {
722 Ok(s) => s,
723 Err(e) => {
724 discovery_errors.push(TestResult {
725 name: "<file error>".to_string(),
726 file: file.display().to_string(),
727 passed: false,
728 error: Some(format!("Failed to read {}: {e}", file.display())),
729 captured_output: None,
730 timeout: None,
731 duration_ms: 0,
732 phases: None,
733 });
734 continue;
735 }
736 };
737
738 let program = match parse_program(&source) {
739 Ok(p) => p,
740 Err(e) => {
741 discovery_errors.push(TestResult {
742 name: "<file error>".to_string(),
743 file: file.display().to_string(),
744 passed: false,
745 error: Some(e),
746 captured_output: None,
747 timeout: None,
748 duration_ms: 0,
749 phases: None,
750 });
751 continue;
752 }
753 };
754
755 let source = Arc::new(source);
756 let program = Arc::new(program);
757 match extract_cases_from_program(file, &source, &program, filter, workers) {
758 Ok(mut file_cases) => {
759 if !file_cases.is_empty() {
760 seed_imported_enum_candidates(file, &source, &mut file_cases);
761 files_with_tests += 1;
762 cases.extend(file_cases);
763 }
764 }
765 Err(error) => discovery_errors.push(TestResult {
766 name: "<file error>".to_string(),
767 file: file.display().to_string(),
768 passed: false,
769 error: Some(error),
770 captured_output: None,
771 timeout: None,
772 duration_ms: 0,
773 phases: None,
774 }),
775 }
776 }
777
778 Discovery {
779 cases,
780 files_with_tests,
781 discovery_errors,
782 }
783}
784
785fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
786 cases.sort_by(|a, b| {
792 let key_a = timings_key(&a.file, &a.name);
793 let key_b = timings_key(&b.file, &b.name);
794 let dur_a = timings.get(&key_a).copied().unwrap_or(0);
795 let dur_b = timings.get(&key_b).copied().unwrap_or(0);
796 dur_a
797 .cmp(&dur_b)
798 .then_with(|| a.file.cmp(&b.file))
799 .then_with(|| a.name.cmp(&b.name))
800 });
801}
802
803fn select_shard_cases(
804 cases: Vec<TestCase>,
805 timings: &BTreeMap<String, u64>,
806 shard: TestShard,
807) -> Vec<TestCase> {
808 if shard.total() <= 1 {
809 return cases;
810 }
811
812 let mut ranked = cases.into_iter().collect::<Vec<_>>();
813 ranked.sort_by(|a, b| {
814 estimated_case_cost_ms(b, timings)
815 .cmp(&estimated_case_cost_ms(a, timings))
816 .then_with(|| a.file.cmp(&b.file))
817 .then_with(|| a.name.cmp(&b.name))
818 });
819
820 let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
821 let mut costs = vec![0u64; shard.total()];
822 let mut counts = vec![0usize; shard.total()];
823
824 for case in ranked {
825 let bucket_index = (0..shard.total())
826 .min_by_key(|&index| (costs[index], counts[index], index))
827 .unwrap_or(0);
828 costs[bucket_index] =
829 costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
830 counts[bucket_index] += 1;
831 buckets[bucket_index].push(case);
832 }
833
834 buckets.swap_remove(shard.index() - 1)
835}
836
837fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
838 timings
839 .get(&timings_key(&case.file, &case.name))
840 .copied()
841 .unwrap_or(case.weight as u64)
842 .max(1)
843}
844
845fn count_files_with_cases(cases: &[TestCase]) -> usize {
846 let mut files = HashSet::new();
847 for case in cases {
848 files.insert(case.file.as_path());
849 }
850 files.len()
851}
852
853fn timings_key(file: &Path, name: &str) -> String {
854 format!("{}::{}", file.display(), name)
855}
856
857fn timings_cache_path(target: &Path) -> Option<PathBuf> {
858 let probe_root = if target.is_dir() {
863 target.to_path_buf()
864 } else {
865 target.parent()?.to_path_buf()
866 };
867 let root = harn_vm::stdlib::process::find_project_root(&probe_root)
868 .unwrap_or_else(|| probe_root.clone());
869 Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
870}
871
872fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
873 let Ok(contents) = fs::read_to_string(path) else {
874 return BTreeMap::new();
875 };
876 serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
877}
878
879fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
880 for result in results {
881 existing.insert(
882 timings_key(Path::new(&result.file), &result.name),
883 result.duration_ms,
884 );
885 }
886 if let Some(parent) = path.parent() {
887 let _ = fs::create_dir_all(parent);
888 }
889 if let Ok(serialized) = serde_json::to_string(&existing) {
890 let _ = fs::write(path, serialized);
891 }
892}
893
894#[derive(Default)]
895struct CaseExecutionResults {
896 cases: Vec<TestResult>,
897 infrastructure_errors: Vec<TestResult>,
898}
899
900struct PreparedFixtureCases {
901 cases: Vec<TestCase>,
902 failures: Vec<TestResult>,
903}
904
905async fn prepare_file_fixtures(
906 cases: Vec<TestCase>,
907 options: &RunOptions,
908 session: &TestRunSession,
909 skill_contexts: &PreparedSkillContexts,
910 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
911) -> PreparedFixtureCases {
912 let mut values: BTreeMap<(PathBuf, String), Result<IsolateValue, TestResult>> = BTreeMap::new();
913 let mut prepared = Vec::with_capacity(cases.len());
914 let mut failures = Vec::new();
915 let prepared_module_cache = session.prepared_module_cache(0);
916
917 for mut case in cases {
918 let Some(fixture) = case
919 .fixture
920 .as_ref()
921 .filter(|fixture| fixture.scope == FixtureScope::File)
922 .cloned()
923 else {
924 prepared.push(case);
925 continue;
926 };
927 let key = (case.file.clone(), fixture.name.clone());
928 if !values.contains_key(&key) {
929 let cwd = case_execution_cwd(&case);
930 let value = execute_file_fixture(
931 &case,
932 &fixture,
933 &cwd,
934 options.timeout_ms,
935 skill_contexts.for_case(&case),
936 &prepared_module_cache,
937 session.stdio_available(),
938 operator_approval_grant,
939 )
940 .await;
941 if let Err(failure) = &value {
942 failures.push(failure.clone());
943 }
944 values.insert(key.clone(), value);
945 }
946 match values.get(&key).expect("fixture result inserted above") {
947 Ok(value) => {
948 case.file_fixture_value = Some(value.clone());
949 prepared.push(case);
950 }
951 Err(_) if options.fail_fast => {
952 prepared.clear();
953 break;
954 }
955 Err(_) => {}
956 }
957 }
958
959 PreparedFixtureCases {
960 cases: prepared,
961 failures,
962 }
963}
964
965async fn execute_cases(
966 cases: Vec<TestCase>,
967 workers: usize,
968 options: &RunOptions,
969 total_tests: usize,
970 session: &TestRunSession,
971 skill_contexts: PreparedSkillContexts,
972 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
973) -> CaseExecutionResults {
974 if cases.is_empty() {
975 return CaseExecutionResults::default();
976 }
977 let completed = Arc::new(Mutex::new(0usize));
978 if workers <= 1 {
979 let prepared_module_cache = session.prepared_module_cache(0);
980 let mut results = Vec::with_capacity(cases.len());
981 for case in cases {
982 let loaded_skills = skill_contexts.for_case(&case);
983 let cwd = case_execution_cwd(&case);
984 let test_index = next_test_index(&completed);
985 emit_progress(
986 &options.progress,
987 TestRunEvent::TestStarted {
988 name: case.name.clone(),
989 file: case.file.display().to_string(),
990 test_index,
991 total_tests,
992 },
993 );
994 let result = execute_case(
995 &case,
996 &cwd,
997 options.timeout_ms,
998 loaded_skills,
999 &prepared_module_cache,
1000 session.stdio_available(),
1001 operator_approval_grant,
1002 )
1003 .await;
1004 let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
1005 if options.diagnose {
1006 result.emit_diagnose();
1007 }
1008 emit_progress(
1009 &options.progress,
1010 TestRunEvent::TestFinished(result.clone()),
1011 );
1012 results.push(result);
1013 if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
1014 break;
1015 }
1016 }
1017 return CaseExecutionResults {
1018 cases: results,
1019 infrastructure_errors: Vec::new(),
1020 };
1021 }
1022
1023 let skill_contexts = Arc::new(skill_contexts);
1024 let prepared_module_caches = (0..workers)
1025 .map(|worker_idx| session.prepared_module_cache(worker_idx))
1026 .collect::<Vec<_>>();
1027 let stdio_available = session.stdio_available();
1028 let operator_approval_grant = operator_approval_grant.cloned();
1029 let timeout_ms = options.timeout_ms;
1030 let max_test_ms = options.max_test_ms;
1031 let max_execute_ms = options.max_execute_ms;
1032 let diagnose = options.diagnose;
1033 let parallel = harn_test_runner::execute_parallel_cases(
1034 cases,
1035 harn_test_runner::ParallelRunOptions {
1036 workers,
1037 total_tests,
1038 stack_size: CLI_RUNTIME_STACK_SIZE,
1039 fail_fast: options.fail_fast,
1040 progress: options.progress.clone(),
1041 },
1042 move |worker_idx| {
1043 let runtime = tokio::runtime::Builder::new_current_thread()
1044 .enable_all()
1045 .build()
1046 .map_err(|error| format!("failed to start test runtime: {error}"))?;
1047 Ok((runtime, prepared_module_caches[worker_idx].clone()))
1048 },
1049 move |worker, case| {
1050 let cwd = case_execution_cwd(case);
1051 let loaded_skills = skill_contexts.for_case(case);
1052 let result = worker.0.block_on(execute_case(
1053 case,
1054 &cwd,
1055 timeout_ms,
1056 loaded_skills,
1057 &worker.1,
1058 stdio_available,
1059 operator_approval_grant.as_ref(),
1060 ));
1061 let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1062 if diagnose {
1063 result.emit_diagnose();
1064 }
1065 result
1066 },
1067 );
1068 CaseExecutionResults {
1069 cases: parallel.cases,
1070 infrastructure_errors: parallel.infrastructure_errors,
1071 }
1072}
1073
1074fn enforce_case_budgets(
1075 mut result: TestResult,
1076 max_test_ms: Option<u64>,
1077 max_execute_ms: Option<u64>,
1078) -> TestResult {
1079 if !result.passed {
1080 return result;
1081 }
1082
1083 let phases = result
1084 .phases
1085 .expect("passed test results always carry measured phases");
1086 let mut violations = Vec::new();
1087 if let Some(max_ms) = max_test_ms {
1088 if result.duration_ms > max_ms {
1089 violations.push(format!(
1090 "exceeded test wall-clock budget: {}ms > {}ms",
1091 result.duration_ms, max_ms
1092 ));
1093 }
1094 }
1095 if let Some(max_ms) = max_execute_ms {
1096 if phases.execute_ms > max_ms {
1097 violations.push(format!(
1098 "exceeded test execute budget: {}ms > {}ms",
1099 phases.execute_ms, max_ms
1100 ));
1101 }
1102 }
1103
1104 if violations.is_empty() {
1105 return result;
1106 }
1107
1108 violations.push(format!(
1109 "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1110 phases.setup_ms,
1111 phases.compile_ms,
1112 phases.execute_ms,
1113 phases.teardown_ms,
1114 result.duration_ms
1115 ));
1116 result.passed = false;
1117 result.error = Some(violations.join("\n"));
1118 result
1119}
1120
1121fn next_test_index(counter: &Mutex<usize>) -> usize {
1122 let mut guard = counter.lock().unwrap();
1123 *guard += 1;
1124 *guard
1125}
1126
1127fn case_execution_cwd(case: &TestCase) -> PathBuf {
1128 case.file
1129 .parent()
1130 .filter(|p| !p.as_os_str().is_empty())
1131 .map(Path::to_path_buf)
1132 .unwrap_or_else(test_execution_cwd)
1133}
1134
1135fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1136 let mut files = Vec::new();
1137 if let Ok(entries) = fs::read_dir(dir) {
1138 for entry in entries.flatten() {
1139 let path = entry.path();
1140 if path.is_dir() {
1141 files.extend(discover_test_files(&path));
1142 } else if path.extension().is_some_and(|e| e == "harn") {
1143 if let Ok(content) = fs::read_to_string(&path) {
1144 if content.contains("test_") || content.contains("@test") {
1145 files.push(canonicalize_existing_path(&path));
1146 }
1147 }
1148 }
1149 }
1150 }
1151 files.sort();
1152 files
1153}
1154
1155pub(crate) fn discover_test_files_for_targets(targets: &[PathBuf]) -> Vec<PathBuf> {
1156 let mut files = targets
1157 .iter()
1158 .flat_map(|target| {
1159 let target = canonicalize_existing_path(target);
1160 if target.is_dir() {
1161 discover_test_files(&target)
1162 } else {
1163 vec![target]
1164 }
1165 })
1166 .collect::<Vec<_>>();
1167 files.sort();
1168 files.dedup();
1169 files
1170}
1171
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173pub(crate) enum AffectedTestFiles {
1174 Selected { files: Vec<PathBuf> },
1175 Full { files: Vec<PathBuf>, reason: String },
1176}
1177
1178pub(crate) fn select_affected_test_files(
1185 targets: &[PathBuf],
1186 changed_files: &[PathBuf],
1187) -> AffectedTestFiles {
1188 let test_files = discover_test_files_for_targets(targets);
1189
1190 if changed_files.is_empty() {
1191 return AffectedTestFiles::Selected { files: Vec::new() };
1192 }
1193
1194 let graph = harn_modules::build(&test_files);
1195 let test_file_set = test_files.iter().cloned().collect::<BTreeSet<_>>();
1196 let mut selected = BTreeSet::new();
1197
1198 for changed in changed_files {
1199 let changed = canonicalize_existing_path(changed);
1200 if !graph.contains_module(&changed) {
1201 return AffectedTestFiles::Full {
1202 files: test_files,
1203 reason: format!(
1204 "changed module {} is outside the resolved test module graph",
1205 changed.display()
1206 ),
1207 };
1208 }
1209
1210 if test_file_set.contains(&changed) {
1211 selected.insert(changed.clone());
1212 }
1213 for importer in graph.transitive_importers_of(&changed) {
1214 if test_file_set.contains(&importer) {
1215 selected.insert(importer);
1216 }
1217 }
1218 }
1219
1220 AffectedTestFiles::Selected {
1221 files: selected.into_iter().collect(),
1222 }
1223}