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