1use std::collections::{BTreeMap, HashSet};
2use std::fs;
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Condvar, Mutex};
8use std::thread;
9use std::time::Instant;
10
11use crate::env_guard::ScopedEnvVar;
12use crate::package;
13use crate::test_timing::DurationSummary;
14use crate::CLI_RUNTIME_STACK_SIZE;
15use harn_parser::SNode;
16use harn_vm::{IsolateValue, VmValue};
17
18mod discovery;
19mod execution;
20#[cfg(test)]
21mod fixture_tests;
22mod fixtures;
23mod reporting;
24mod session;
25mod skill_context;
26#[cfg(test)]
27mod tests;
28
29use discovery::{extract_cases_from_program, parse_program, seed_imported_enum_candidates};
30use execution::{execute_case, execute_file_fixture};
31use fixtures::{FixtureScope, TestFixture};
32pub use reporting::{
33 AggregateTimings, PhaseTimings, TestPhase, TestResult, TestSummary, TestTimeout,
34};
35pub use session::{TestRunSession, TestRunSessionStats};
36use skill_context::PreparedSkillContexts;
37
38#[derive(Clone, Debug)]
39pub enum TestRunEvent {
40 SuiteDiscovered {
41 total_tests: usize,
42 total_files: usize,
43 parallel: bool,
44 workers: usize,
45 },
46 LargeSequentialSuite {
47 total_tests: usize,
48 total_files: usize,
49 },
50 TestStarted {
51 name: String,
52 file: String,
53 test_index: usize,
54 total_tests: usize,
55 },
56 TestFinished(TestResult),
57}
58
59pub type TestRunProgress = Arc<dyn Fn(TestRunEvent) + Send + Sync>;
60
61const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
62const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
63const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
64const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
65const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
66const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
67const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
68
69const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
76const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
77
78const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
86
87#[derive(Clone, Default)]
93pub struct RunOptions {
94 pub filter: Option<String>,
95 pub timeout_ms: u64,
96 pub max_test_ms: Option<u64>,
100 pub max_execute_ms: Option<u64>,
104 pub parallel: bool,
108 pub fail_fast: bool,
111 pub jobs: Option<usize>,
115 pub shard: Option<TestShard>,
118 pub cli_skill_dirs: Vec<PathBuf>,
119 pub progress: Option<TestRunProgress>,
122 pub diagnose: bool,
126 pub trusted_host_dispatch: bool,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub struct TestShard {
133 index: usize,
134 total: usize,
135}
136
137impl TestShard {
138 pub fn new(index: usize, total: usize) -> Result<Self, String> {
139 if total == 0 {
140 return Err("test shard total must be at least 1".to_string());
141 }
142 if index == 0 {
143 return Err("test shard index must be at least 1".to_string());
144 }
145 if index > total {
146 return Err(format!(
147 "test shard index {index} exceeds shard total {total}"
148 ));
149 }
150 Ok(Self { index, total })
151 }
152
153 pub fn index(self) -> usize {
154 self.index
155 }
156
157 pub fn total(self) -> usize {
158 self.total
159 }
160}
161
162impl RunOptions {
163 pub fn new(timeout_ms: u64) -> Self {
164 Self {
165 timeout_ms,
166 ..Default::default()
167 }
168 }
169}
170
171#[derive(Clone)]
175struct TestCase {
176 file: PathBuf,
177 name: String,
178 pipeline_name: String,
179 source: Arc<String>,
180 program: Arc<Vec<SNode>>,
181 imported_enum_candidates: Arc<Vec<String>>,
184 serial_group: Option<String>,
188 weight: usize,
191 args: Vec<VmValue>,
193 fixture: Option<TestFixture>,
195 file_fixture_value: Option<IsolateValue>,
198 trusted_host_dispatch: bool,
200}
201
202fn canonicalize_existing_path(path: &Path) -> PathBuf {
203 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
204}
205
206fn test_execution_cwd() -> PathBuf {
207 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
208}
209
210fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
211 if let Some(callback) = progress {
212 callback(event);
213 }
214}
215
216fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
217 total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
218}
219
220pub async fn run_tests(
222 path: &Path,
223 filter: Option<&str>,
224 timeout_ms: u64,
225 parallel: bool,
226 cli_skill_dirs: &[PathBuf],
227) -> TestSummary {
228 let options = RunOptions {
229 filter: filter.map(str::to_owned),
230 timeout_ms,
231 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
232 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
233 parallel,
234 fail_fast: false,
235 jobs: None,
236 shard: None,
237 cli_skill_dirs: cli_skill_dirs.to_vec(),
238 progress: None,
239 diagnose: diagnose_enabled_via_env(),
240 trusted_host_dispatch: false,
241 };
242 run_tests_with_options(path, &options).await
243}
244
245pub async fn run_tests_with_progress(
247 path: &Path,
248 filter: Option<&str>,
249 timeout_ms: u64,
250 parallel: bool,
251 cli_skill_dirs: &[PathBuf],
252 progress: Option<TestRunProgress>,
253) -> TestSummary {
254 let options = RunOptions {
255 filter: filter.map(str::to_owned),
256 timeout_ms,
257 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
258 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
259 parallel,
260 fail_fast: false,
261 jobs: None,
262 shard: None,
263 cli_skill_dirs: cli_skill_dirs.to_vec(),
264 progress,
265 diagnose: diagnose_enabled_via_env(),
266 trusted_host_dispatch: false,
267 };
268 run_tests_with_options(path, &options).await
269}
270
271fn diagnose_enabled_via_env() -> bool {
272 let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
273 return false;
274 };
275 matches!(
276 raw.to_ascii_lowercase().as_str(),
277 "1" | "true" | "yes" | "on"
278 )
279}
280
281fn test_budget_ms_via_env(name: &str) -> Option<u64> {
282 std::env::var(name)
283 .ok()
284 .and_then(|raw| raw.trim().parse::<u64>().ok())
285 .filter(|&value| value >= 1)
286}
287
288pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
293 run_tests_with_session(path, options, &TestRunSession::default()).await
294}
295
296pub fn run_tests_with_session<'a>(
302 path: &'a Path,
303 options: &'a RunOptions,
304 session: &'a TestRunSession,
305) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
306 run_tests_with_session_and_operator_grant(path, options, session, None)
307}
308
309pub(crate) fn run_tests_with_session_and_operator_grant<'a>(
314 path: &'a Path,
315 options: &'a RunOptions,
316 session: &'a TestRunSession,
317 operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
318) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
319 Box::pin(run_tests_with_session_impl(
320 path,
321 options,
322 session,
323 operator_approval_grant,
324 ))
325}
326
327async fn run_tests_with_session_impl(
328 path: &Path,
329 options: &RunOptions,
330 session: &TestRunSession,
331 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
332) -> TestSummary {
333 let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
335 let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
336
337 let start = Instant::now();
338
339 let collection_start = Instant::now();
340 let canonical_target = canonicalize_existing_path(path);
341 let files = if canonical_target.is_dir() {
342 discover_test_files(&canonical_target)
343 } else {
344 vec![canonical_target.clone()]
345 };
346
347 let workers = resolve_workers(options);
348 let timings_path = timings_cache_path(&canonical_target);
349 let timings = timings_path
350 .as_deref()
351 .map(load_timings_cache)
352 .unwrap_or_default();
353
354 let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
355 let mut declared_dispatch: BTreeMap<PathBuf, bool> = BTreeMap::new();
362 for case in &mut discovery.cases {
363 let declared = *declared_dispatch
364 .entry(case.file.clone())
365 .or_insert_with(|| package::load_check_config(Some(&case.file)).trusted_host_dispatch);
366 case.trusted_host_dispatch = options.trusted_host_dispatch || declared;
367 }
368 if let Some(shard) = options.shard {
369 discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
370 if shard.index() > 1 {
371 discovery.discovery_errors.clear();
372 }
373 }
374 let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
375 let collection_ms = collection_start.elapsed().as_millis() as u64;
376 let selected_files_with_tests = if options.shard.is_some() {
377 count_files_with_cases(&discovery.cases)
378 } else {
379 discovery.files_with_tests
380 };
381
382 emit_progress(
383 &options.progress,
384 TestRunEvent::SuiteDiscovered {
385 total_tests: discovery.cases.len(),
386 total_files: selected_files_with_tests,
387 parallel: options.parallel,
388 workers,
389 },
390 );
391 if workers == 1
392 && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
393 {
394 emit_progress(
395 &options.progress,
396 TestRunEvent::LargeSequentialSuite {
397 total_tests: discovery.cases.len(),
398 total_files: selected_files_with_tests,
399 },
400 );
401 }
402
403 let mut cases = discovery.cases;
404 sort_cases_longest_first(&mut cases, &timings);
405 let module_preparation = session.prepare_import_graph(&case_files(&cases));
406
407 let mut all_results = discovery.discovery_errors;
408 let total_tests = cases.len();
409 if !options.fail_fast || all_results.is_empty() {
410 let prepared = prepare_file_fixtures(
411 cases,
412 options,
413 session,
414 &skill_contexts,
415 operator_approval_grant,
416 )
417 .await;
418 cases = prepared.cases;
419 all_results.extend(prepared.failures);
420 } else {
421 cases.clear();
422 }
423 let execution = if !options.fail_fast || all_results.is_empty() {
424 execute_cases(
425 cases,
426 workers,
427 options,
428 total_tests,
429 session,
430 skill_contexts,
431 operator_approval_grant,
432 )
433 .await
434 } else {
435 CaseExecutionResults::default()
436 };
437
438 let timing = DurationSummary::from_samples(
439 &execution
440 .cases
441 .iter()
442 .map(|result| result.duration_ms)
443 .collect::<Vec<_>>(),
444 );
445 if let Some(path) = timings_path.as_deref() {
446 update_timings_cache(path, timings, &execution.cases);
447 }
448 all_results.extend(execution.cases);
449 all_results.extend(execution.infrastructure_errors);
450 let total = all_results.len();
451 let passed = all_results.iter().filter(|result| result.passed).count();
452 let failed = total - passed;
453 let aggregate = AggregateTimings::from_results(collection_ms, module_preparation, &all_results);
454
455 TestSummary {
456 results: all_results,
457 passed,
458 failed,
459 total,
460 duration_ms: start.elapsed().as_millis() as u64,
461 timing,
462 aggregate,
463 }
464}
465
466pub async fn run_test_file(
474 path: &Path,
475 filter: Option<&str>,
476 timeout_ms: u64,
477 execution_cwd: Option<&Path>,
478 cli_skill_dirs: &[PathBuf],
479) -> Result<Vec<TestResult>, String> {
480 run_test_file_with_session(
481 path,
482 filter,
483 timeout_ms,
484 execution_cwd,
485 cli_skill_dirs,
486 &TestRunSession::default(),
487 )
488 .await
489}
490
491pub fn run_test_file_with_session<'a>(
493 path: &'a Path,
494 filter: Option<&'a str>,
495 timeout_ms: u64,
496 execution_cwd: Option<&'a Path>,
497 cli_skill_dirs: &'a [PathBuf],
498 session: &'a TestRunSession,
499) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
500 Box::pin(run_test_file_with_session_impl(
501 path,
502 filter,
503 timeout_ms,
504 execution_cwd,
505 cli_skill_dirs,
506 session,
507 ))
508}
509
510async fn run_test_file_with_session_impl(
511 path: &Path,
512 filter: Option<&str>,
513 timeout_ms: u64,
514 execution_cwd: Option<&Path>,
515 cli_skill_dirs: &[PathBuf],
516 session: &TestRunSession,
517) -> Result<Vec<TestResult>, String> {
518 let source =
519 fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
520 let program = parse_program(&source)?;
521 let source = Arc::new(source);
522 let program = Arc::new(program);
523
524 let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
525 seed_imported_enum_candidates(path, &source, &mut cases);
526 let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
527 let _module_preparation = session.prepare_import_graph(&case_files(&cases));
528
529 let mut results = Vec::with_capacity(cases.len());
530 let execution_cwd = execution_cwd
531 .map(Path::to_path_buf)
532 .unwrap_or_else(test_execution_cwd);
533 let prepared_module_cache = session.prepared_module_cache(0);
534 let fixture_options = RunOptions {
535 timeout_ms,
536 ..RunOptions::default()
537 };
538 let prepared =
539 prepare_file_fixtures(cases, &fixture_options, session, &skill_contexts, None).await;
540 results.extend(prepared.failures);
541 for case in prepared.cases {
542 let loaded_skills = skill_contexts.for_case(&case);
543 results.push(
544 execute_case(
545 &case,
546 &execution_cwd,
547 timeout_ms,
548 loaded_skills,
549 &prepared_module_cache,
550 session.stdio_available(),
551 None,
552 )
553 .await,
554 );
555 }
556 Ok(results)
557}
558
559fn resolve_workers(options: &RunOptions) -> usize {
560 if !options.parallel {
561 return 1;
562 }
563 if let Some(jobs) = options.jobs {
564 return jobs.max(1);
565 }
566 if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
567 if let Ok(parsed) = raw.trim().parse::<usize>() {
568 if parsed >= 1 {
569 return parsed;
570 }
571 }
572 }
573 let detected = thread::available_parallelism()
574 .map(|n| n.get())
575 .unwrap_or(1);
576 let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
577 apply_memory_cap(core_cap)
578}
579
580pub(crate) fn resolve_parallel_workers(jobs: Option<usize>) -> usize {
581 resolve_workers(&RunOptions {
582 parallel: true,
583 jobs,
584 ..RunOptions::default()
585 })
586}
587
588fn apply_memory_cap(core_cap: usize) -> usize {
594 let Some(available_mb) = available_memory_mb() else {
595 return core_cap;
596 };
597 let budget = per_worker_memory_mb();
598 let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
599 if mem_cap < core_cap {
600 eprintln!(
601 "harn test: capping workers {core_cap} -> {mem_cap} \
602 (~{available_mb} MiB available, {budget} MiB/worker; \
603 override with --jobs / HARN_TEST_JOBS)"
604 );
605 return mem_cap;
606 }
607 core_cap
608}
609
610fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
613 let usable = available_mb.saturating_sub(reserved_mb);
614 let per_worker = per_worker_mb.max(1);
615 ((usable / per_worker).max(1)) as usize
616}
617
618fn per_worker_memory_mb() -> u64 {
621 std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
622 .ok()
623 .and_then(|raw| raw.trim().parse::<u64>().ok())
624 .filter(|&n| n >= 1)
625 .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
626}
627
628fn available_memory_mb() -> Option<u64> {
639 let mut sys = sysinfo::System::new();
640 sys.refresh_memory();
641 let host_mb = match sys.available_memory() {
642 0 => None, bytes => Some(bytes / (1024 * 1024)),
644 };
645 match (host_mb, cgroup_v2_headroom_mb()) {
646 (Some(h), Some(c)) => Some(h.min(c)),
647 (Some(h), None) => Some(h),
648 (None, c) => c,
649 }
650}
651
652#[cfg(target_os = "linux")]
655fn cgroup_v2_headroom_mb() -> Option<u64> {
656 let dir = own_cgroup_v2_dir()?;
657 let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
658 let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
659 cgroup_headroom_mb(&max_raw, ¤t_raw)
660}
661
662#[cfg(not(target_os = "linux"))]
663fn cgroup_v2_headroom_mb() -> Option<u64> {
664 None
665}
666
667#[cfg(target_os = "linux")]
673fn own_cgroup_v2_dir() -> Option<PathBuf> {
674 let content = fs::read_to_string("/proc/self/cgroup").ok()?;
675 let rel = content
676 .lines()
677 .find_map(|line| line.strip_prefix("0::"))?
678 .trim();
679 let rel = rel.strip_prefix('/').unwrap_or(rel);
680 Some(Path::new("/sys/fs/cgroup").join(rel))
681}
682
683#[cfg(any(target_os = "linux", test))]
689fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
690 let max = memory_max.trim();
691 if max == "max" {
692 return None;
693 }
694 let max: u64 = max.parse().ok()?;
695 let current: u64 = memory_current.trim().parse().ok()?;
696 Some(max.saturating_sub(current) / (1024 * 1024))
697}
698
699struct Discovery {
700 cases: Vec<TestCase>,
701 files_with_tests: usize,
702 discovery_errors: Vec<TestResult>,
703}
704
705fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
706 let mut cases = Vec::new();
707 let mut files_with_tests = 0usize;
708 let mut discovery_errors = Vec::new();
709
710 for file in files {
711 let source = match fs::read_to_string(file) {
712 Ok(s) => s,
713 Err(e) => {
714 discovery_errors.push(TestResult {
715 name: "<file error>".to_string(),
716 file: file.display().to_string(),
717 passed: false,
718 error: Some(format!("Failed to read {}: {e}", file.display())),
719 captured_output: None,
720 timeout: None,
721 duration_ms: 0,
722 phases: None,
723 });
724 continue;
725 }
726 };
727
728 let program = match parse_program(&source) {
729 Ok(p) => p,
730 Err(e) => {
731 discovery_errors.push(TestResult {
732 name: "<file error>".to_string(),
733 file: file.display().to_string(),
734 passed: false,
735 error: Some(e),
736 captured_output: None,
737 timeout: None,
738 duration_ms: 0,
739 phases: None,
740 });
741 continue;
742 }
743 };
744
745 let source = Arc::new(source);
746 let program = Arc::new(program);
747 match extract_cases_from_program(file, &source, &program, filter, workers) {
748 Ok(mut file_cases) => {
749 if !file_cases.is_empty() {
750 seed_imported_enum_candidates(file, &source, &mut file_cases);
751 files_with_tests += 1;
752 cases.extend(file_cases);
753 }
754 }
755 Err(error) => discovery_errors.push(TestResult {
756 name: "<file error>".to_string(),
757 file: file.display().to_string(),
758 passed: false,
759 error: Some(error),
760 captured_output: None,
761 timeout: None,
762 duration_ms: 0,
763 phases: None,
764 }),
765 }
766 }
767
768 Discovery {
769 cases,
770 files_with_tests,
771 discovery_errors,
772 }
773}
774
775fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
776 cases.sort_by(|a, b| {
782 let key_a = timings_key(&a.file, &a.name);
783 let key_b = timings_key(&b.file, &b.name);
784 let dur_a = timings.get(&key_a).copied().unwrap_or(0);
785 let dur_b = timings.get(&key_b).copied().unwrap_or(0);
786 dur_a
787 .cmp(&dur_b)
788 .then_with(|| a.file.cmp(&b.file))
789 .then_with(|| a.name.cmp(&b.name))
790 });
791}
792
793fn select_shard_cases(
794 cases: Vec<TestCase>,
795 timings: &BTreeMap<String, u64>,
796 shard: TestShard,
797) -> Vec<TestCase> {
798 if shard.total() <= 1 {
799 return cases;
800 }
801
802 let mut ranked = cases.into_iter().collect::<Vec<_>>();
803 ranked.sort_by(|a, b| {
804 estimated_case_cost_ms(b, timings)
805 .cmp(&estimated_case_cost_ms(a, timings))
806 .then_with(|| a.file.cmp(&b.file))
807 .then_with(|| a.name.cmp(&b.name))
808 });
809
810 let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
811 let mut costs = vec![0u64; shard.total()];
812 let mut counts = vec![0usize; shard.total()];
813
814 for case in ranked {
815 let bucket_index = (0..shard.total())
816 .min_by_key(|&index| (costs[index], counts[index], index))
817 .unwrap_or(0);
818 costs[bucket_index] =
819 costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
820 counts[bucket_index] += 1;
821 buckets[bucket_index].push(case);
822 }
823
824 buckets.swap_remove(shard.index() - 1)
825}
826
827fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
828 timings
829 .get(&timings_key(&case.file, &case.name))
830 .copied()
831 .unwrap_or(case.weight as u64)
832 .max(1)
833}
834
835fn count_files_with_cases(cases: &[TestCase]) -> usize {
836 let mut files = HashSet::new();
837 for case in cases {
838 files.insert(case.file.as_path());
839 }
840 files.len()
841}
842
843fn case_files(cases: &[TestCase]) -> Vec<PathBuf> {
844 cases
845 .iter()
846 .map(|case| case.file.clone())
847 .collect::<std::collections::BTreeSet<_>>()
848 .into_iter()
849 .collect()
850}
851
852fn timings_key(file: &Path, name: &str) -> String {
853 format!("{}::{}", file.display(), name)
854}
855
856fn timings_cache_path(target: &Path) -> Option<PathBuf> {
857 let probe_root = if target.is_dir() {
862 target.to_path_buf()
863 } else {
864 target.parent()?.to_path_buf()
865 };
866 let root = harn_vm::stdlib::process::find_project_root(&probe_root)
867 .unwrap_or_else(|| probe_root.clone());
868 Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
869}
870
871fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
872 let Ok(contents) = fs::read_to_string(path) else {
873 return BTreeMap::new();
874 };
875 serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
876}
877
878fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
879 for result in results {
880 existing.insert(
881 timings_key(Path::new(&result.file), &result.name),
882 result.duration_ms,
883 );
884 }
885 if let Some(parent) = path.parent() {
886 let _ = fs::create_dir_all(parent);
887 }
888 if let Ok(serialized) = serde_json::to_string(&existing) {
889 let _ = fs::write(path, serialized);
890 }
891}
892
893#[derive(Default)]
894struct CaseExecutionResults {
895 cases: Vec<TestResult>,
896 infrastructure_errors: Vec<TestResult>,
897}
898
899struct PreparedFixtureCases {
900 cases: Vec<TestCase>,
901 failures: Vec<TestResult>,
902}
903
904async fn prepare_file_fixtures(
905 cases: Vec<TestCase>,
906 options: &RunOptions,
907 session: &TestRunSession,
908 skill_contexts: &PreparedSkillContexts,
909 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
910) -> PreparedFixtureCases {
911 let mut values: BTreeMap<(PathBuf, String), Result<IsolateValue, TestResult>> = BTreeMap::new();
912 let mut prepared = Vec::with_capacity(cases.len());
913 let mut failures = Vec::new();
914 let prepared_module_cache = session.prepared_module_cache(0);
915
916 for mut case in cases {
917 let Some(fixture) = case
918 .fixture
919 .as_ref()
920 .filter(|fixture| fixture.scope == FixtureScope::File)
921 .cloned()
922 else {
923 prepared.push(case);
924 continue;
925 };
926 let key = (case.file.clone(), fixture.name.clone());
927 if !values.contains_key(&key) {
928 let cwd = case_execution_cwd(&case);
929 let value = execute_file_fixture(
930 &case,
931 &fixture,
932 &cwd,
933 options.timeout_ms,
934 skill_contexts.for_case(&case),
935 &prepared_module_cache,
936 session.stdio_available(),
937 operator_approval_grant,
938 )
939 .await;
940 if let Err(failure) = &value {
941 failures.push(failure.clone());
942 }
943 values.insert(key.clone(), value);
944 }
945 match values.get(&key).expect("fixture result inserted above") {
946 Ok(value) => {
947 case.file_fixture_value = Some(value.clone());
948 prepared.push(case);
949 }
950 Err(_) if options.fail_fast => {
951 prepared.clear();
952 break;
953 }
954 Err(_) => {}
955 }
956 }
957
958 PreparedFixtureCases {
959 cases: prepared,
960 failures,
961 }
962}
963
964async fn execute_cases(
965 cases: Vec<TestCase>,
966 workers: usize,
967 options: &RunOptions,
968 total_tests: usize,
969 session: &TestRunSession,
970 skill_contexts: PreparedSkillContexts,
971 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
972) -> CaseExecutionResults {
973 if cases.is_empty() {
974 return CaseExecutionResults::default();
975 }
976 let completed = Arc::new(Mutex::new(0usize));
977 if workers <= 1 {
978 let prepared_module_cache = session.prepared_module_cache(0);
979 let mut results = Vec::with_capacity(cases.len());
980 for case in cases {
981 let loaded_skills = skill_contexts.for_case(&case);
982 let cwd = case_execution_cwd(&case);
983 let test_index = next_test_index(&completed);
984 emit_progress(
985 &options.progress,
986 TestRunEvent::TestStarted {
987 name: case.name.clone(),
988 file: case.file.display().to_string(),
989 test_index,
990 total_tests,
991 },
992 );
993 let result = execute_case(
994 &case,
995 &cwd,
996 options.timeout_ms,
997 loaded_skills,
998 &prepared_module_cache,
999 session.stdio_available(),
1000 operator_approval_grant,
1001 )
1002 .await;
1003 let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
1004 if options.diagnose {
1005 result.emit_diagnose();
1006 }
1007 emit_progress(
1008 &options.progress,
1009 TestRunEvent::TestFinished(result.clone()),
1010 );
1011 results.push(result);
1012 if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
1013 break;
1014 }
1015 }
1016 return CaseExecutionResults {
1017 cases: results,
1018 infrastructure_errors: Vec::new(),
1019 };
1020 }
1021
1022 let queue = Arc::new(Mutex::new(cases));
1023 let skill_contexts = Arc::new(skill_contexts);
1024 let gate = Arc::new(ResourceGate::new(workers));
1025 let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1026 let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1027 let cancelled = Arc::new(AtomicBool::new(false));
1028
1029 let mut handles = Vec::with_capacity(workers);
1030 for worker_idx in 0..workers {
1031 let queue = Arc::clone(&queue);
1032 let skill_contexts = Arc::clone(&skill_contexts);
1033 let gate = Arc::clone(&gate);
1034 let results = Arc::clone(&results);
1035 let infrastructure_errors = Arc::clone(&infrastructure_errors);
1036 let completed = Arc::clone(&completed);
1037 let timeout_ms = options.timeout_ms;
1038 let max_test_ms = options.max_test_ms;
1039 let max_execute_ms = options.max_execute_ms;
1040 let progress = options.progress.clone();
1041 let diagnose = options.diagnose;
1042 let fail_fast = options.fail_fast;
1043 let cancelled = Arc::clone(&cancelled);
1044 let prepared_module_cache = session.prepared_module_cache(worker_idx);
1045 let stdio_available = session.stdio_available();
1046 let operator_approval_grant = operator_approval_grant.cloned();
1047 let handle = thread::Builder::new()
1048 .name(format!("harn-test-worker-{worker_idx}"))
1049 .stack_size(CLI_RUNTIME_STACK_SIZE)
1050 .spawn(move || {
1051 let runtime = match tokio::runtime::Builder::new_current_thread()
1052 .enable_all()
1053 .build()
1054 {
1055 Ok(rt) => rt,
1056 Err(error) => {
1057 infrastructure_errors.lock().unwrap().push(TestResult {
1058 name: "<worker error>".to_string(),
1059 file: String::new(),
1060 passed: false,
1061 error: Some(format!("failed to start test runtime: {error}")),
1062 captured_output: None,
1063 timeout: None,
1064 duration_ms: 0,
1065 phases: None,
1066 });
1067 return;
1068 }
1069 };
1070 loop {
1075 let case = claim_next_case(&queue, &cancelled, fail_fast);
1076 let Some(case) = case else { break };
1077 let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1078 if fail_fast && cancelled.load(Ordering::Acquire) {
1083 break;
1084 }
1085 let cwd = case_execution_cwd(&case);
1086 let loaded_skills = skill_contexts.for_case(&case);
1087 let test_index = next_test_index(&completed);
1088 emit_progress(
1089 &progress,
1090 TestRunEvent::TestStarted {
1091 name: case.name.clone(),
1092 file: case.file.display().to_string(),
1093 test_index,
1094 total_tests,
1095 },
1096 );
1097 let result = runtime.block_on(execute_case(
1098 &case,
1099 &cwd,
1100 timeout_ms,
1101 loaded_skills,
1102 &prepared_module_cache,
1103 stdio_available,
1104 operator_approval_grant.as_ref(),
1105 ));
1106 let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1107 if fail_fast && !result.passed {
1108 cancelled.store(true, Ordering::Release);
1109 }
1110 if diagnose {
1111 result.emit_diagnose();
1112 }
1113 emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1114 results.lock().unwrap().push(result);
1115 }
1116 })
1117 .expect("spawning a harn-test worker thread should succeed");
1118 handles.push(handle);
1119 }
1120
1121 for handle in handles {
1122 let _ = handle.join();
1123 }
1124
1125 let cases = Arc::try_unwrap(results)
1129 .map(|m| m.into_inner().unwrap_or_default())
1130 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1131 let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1132 .map(|mutex| mutex.into_inner().unwrap_or_default())
1133 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1134 CaseExecutionResults {
1135 cases,
1136 infrastructure_errors,
1137 }
1138}
1139
1140fn claim_next_case(
1141 queue: &Mutex<Vec<TestCase>>,
1142 cancelled: &AtomicBool,
1143 fail_fast: bool,
1144) -> Option<TestCase> {
1145 let mut queue = queue.lock().unwrap();
1146 if fail_fast && cancelled.load(Ordering::Acquire) {
1147 None
1148 } else {
1149 queue.pop()
1150 }
1151}
1152
1153fn enforce_case_budgets(
1154 mut result: TestResult,
1155 max_test_ms: Option<u64>,
1156 max_execute_ms: Option<u64>,
1157) -> TestResult {
1158 if !result.passed {
1159 return result;
1160 }
1161
1162 let phases = result
1163 .phases
1164 .expect("passed test results always carry measured phases");
1165 let mut violations = Vec::new();
1166 if let Some(max_ms) = max_test_ms {
1167 if result.duration_ms > max_ms {
1168 violations.push(format!(
1169 "exceeded test wall-clock budget: {}ms > {}ms",
1170 result.duration_ms, max_ms
1171 ));
1172 }
1173 }
1174 if let Some(max_ms) = max_execute_ms {
1175 if phases.execute_ms > max_ms {
1176 violations.push(format!(
1177 "exceeded test execute budget: {}ms > {}ms",
1178 phases.execute_ms, max_ms
1179 ));
1180 }
1181 }
1182
1183 if violations.is_empty() {
1184 return result;
1185 }
1186
1187 violations.push(format!(
1188 "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1189 phases.setup_ms,
1190 phases.compile_ms,
1191 phases.execute_ms,
1192 phases.teardown_ms,
1193 result.duration_ms
1194 ));
1195 result.passed = false;
1196 result.error = Some(violations.join("\n"));
1197 result
1198}
1199
1200fn next_test_index(counter: &Mutex<usize>) -> usize {
1201 let mut guard = counter.lock().unwrap();
1202 *guard += 1;
1203 *guard
1204}
1205
1206fn case_execution_cwd(case: &TestCase) -> PathBuf {
1207 case.file
1208 .parent()
1209 .filter(|p| !p.as_os_str().is_empty())
1210 .map(Path::to_path_buf)
1211 .unwrap_or_else(test_execution_cwd)
1212}
1213
1214struct ResourceGate {
1218 state: Mutex<GateState>,
1219 cond: Condvar,
1220 capacity: usize,
1221}
1222
1223struct GateState {
1224 available: usize,
1225 busy_groups: HashSet<String>,
1226}
1227
1228struct GateGuard<'a> {
1229 gate: &'a ResourceGate,
1230 weight: usize,
1231 group: Option<String>,
1232}
1233
1234impl ResourceGate {
1235 fn new(capacity: usize) -> Self {
1236 Self {
1237 state: Mutex::new(GateState {
1238 available: capacity,
1239 busy_groups: HashSet::new(),
1240 }),
1241 cond: Condvar::new(),
1242 capacity,
1243 }
1244 }
1245
1246 fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1247 let weight = weight.min(self.capacity).max(1);
1248 let mut state = self.state.lock().unwrap();
1249 loop {
1250 if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1251 return guard;
1252 }
1253 state = self.cond.wait(state).unwrap();
1254 }
1255 }
1256
1257 fn try_grab_locked<'a>(
1261 &'a self,
1262 state: &mut GateState,
1263 weight: usize,
1264 group: Option<&str>,
1265 ) -> Option<GateGuard<'a>> {
1266 let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1267 if state.available >= weight && group_free {
1268 state.available -= weight;
1269 if let Some(g) = group {
1270 state.busy_groups.insert(g.to_string());
1271 }
1272 return Some(GateGuard {
1273 gate: self,
1274 weight,
1275 group: group.map(str::to_owned),
1276 });
1277 }
1278 None
1279 }
1280
1281 #[cfg(test)]
1284 fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1285 let weight = weight.min(self.capacity).max(1);
1286 let mut state = self.state.lock().unwrap();
1287 self.try_grab_locked(&mut state, weight, group)
1288 }
1289}
1290
1291impl Drop for GateGuard<'_> {
1292 fn drop(&mut self) {
1293 let mut state = self.gate.state.lock().unwrap();
1294 state.available += self.weight;
1295 if let Some(group) = self.group.as_deref() {
1296 state.busy_groups.remove(group);
1297 }
1298 self.gate.cond.notify_all();
1299 }
1300}
1301
1302fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1303 let mut files = Vec::new();
1304 if let Ok(entries) = fs::read_dir(dir) {
1305 for entry in entries.flatten() {
1306 let path = entry.path();
1307 if path.is_dir() {
1308 files.extend(discover_test_files(&path));
1309 } else if path.extension().is_some_and(|e| e == "harn") {
1310 if let Ok(content) = fs::read_to_string(&path) {
1311 if content.contains("test_") || content.contains("@test") {
1312 files.push(canonicalize_existing_path(&path));
1313 }
1314 }
1315 }
1316 }
1317 }
1318 files.sort();
1319 files
1320}