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