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};
32use reporting::SuiteCallablePreparation;
33pub use reporting::{
34 AggregateTimings, PhaseTimings, TestPhase, TestResult, TestSummary, TestTimeout,
35};
36pub use session::{TestRunSession, TestRunSessionStats};
37use skill_context::PreparedSkillContexts;
38
39#[derive(Clone, Debug)]
40pub enum TestRunEvent {
41 SuiteDiscovered {
42 total_tests: usize,
43 total_files: usize,
44 parallel: bool,
45 workers: usize,
46 },
47 LargeSequentialSuite {
48 total_tests: usize,
49 total_files: usize,
50 },
51 TestStarted {
52 name: String,
53 file: String,
54 test_index: usize,
55 total_tests: usize,
56 },
57 TestFinished(TestResult),
58}
59
60pub type TestRunProgress = Arc<dyn Fn(TestRunEvent) + Send + Sync>;
61
62const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
63const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
64const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
65const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
66const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
67const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
68const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
69
70const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
77const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
78
79const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
87
88#[derive(Clone, Default)]
94pub struct RunOptions {
95 pub filter: Option<String>,
96 pub timeout_ms: u64,
97 pub max_test_ms: Option<u64>,
101 pub max_execute_ms: Option<u64>,
105 pub parallel: bool,
109 pub fail_fast: bool,
112 pub jobs: Option<usize>,
116 pub shard: Option<TestShard>,
119 pub cli_skill_dirs: Vec<PathBuf>,
120 pub progress: Option<TestRunProgress>,
123 pub diagnose: bool,
127 pub trusted_host_dispatch: bool,
130}
131
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub struct TestShard {
134 index: usize,
135 total: usize,
136}
137
138impl TestShard {
139 pub fn new(index: usize, total: usize) -> Result<Self, String> {
140 if total == 0 {
141 return Err("test shard total must be at least 1".to_string());
142 }
143 if index == 0 {
144 return Err("test shard index must be at least 1".to_string());
145 }
146 if index > total {
147 return Err(format!(
148 "test shard index {index} exceeds shard total {total}"
149 ));
150 }
151 Ok(Self { index, total })
152 }
153
154 pub fn index(self) -> usize {
155 self.index
156 }
157
158 pub fn total(self) -> usize {
159 self.total
160 }
161}
162
163impl RunOptions {
164 pub fn new(timeout_ms: u64) -> Self {
165 Self {
166 timeout_ms,
167 ..Default::default()
168 }
169 }
170}
171
172#[derive(Clone)]
176struct TestCase {
177 file: PathBuf,
178 name: String,
179 pipeline_name: String,
180 source: Arc<String>,
181 program: Arc<Vec<SNode>>,
182 imported_enum_candidates: Arc<Vec<String>>,
185 serial_group: Option<String>,
189 weight: usize,
192 args: Vec<VmValue>,
194 fixture: Option<TestFixture>,
196 file_fixture_value: Option<IsolateValue>,
199 compiled_entry: Option<Arc<harn_vm::CompiledCallableEntry>>,
203 compiled_file_fixture_entry:
206 Option<Result<Arc<harn_vm::CompiledCallableEntry>, harn_vm::CompileError>>,
207 trusted_host_dispatch: bool,
209}
210
211fn canonicalize_existing_path(path: &Path) -> PathBuf {
212 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
213}
214
215fn test_execution_cwd() -> PathBuf {
216 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
217}
218
219fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
220 if let Some(callback) = progress {
221 callback(event);
222 }
223}
224
225fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
226 total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
227}
228
229pub async fn run_tests(
231 path: &Path,
232 filter: Option<&str>,
233 timeout_ms: u64,
234 parallel: bool,
235 cli_skill_dirs: &[PathBuf],
236) -> TestSummary {
237 let options = RunOptions {
238 filter: filter.map(str::to_owned),
239 timeout_ms,
240 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
241 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
242 parallel,
243 fail_fast: false,
244 jobs: None,
245 shard: None,
246 cli_skill_dirs: cli_skill_dirs.to_vec(),
247 progress: None,
248 diagnose: diagnose_enabled_via_env(),
249 trusted_host_dispatch: false,
250 };
251 run_tests_with_options(path, &options).await
252}
253
254pub async fn run_tests_with_progress(
256 path: &Path,
257 filter: Option<&str>,
258 timeout_ms: u64,
259 parallel: bool,
260 cli_skill_dirs: &[PathBuf],
261 progress: Option<TestRunProgress>,
262) -> TestSummary {
263 let options = RunOptions {
264 filter: filter.map(str::to_owned),
265 timeout_ms,
266 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
267 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
268 parallel,
269 fail_fast: false,
270 jobs: None,
271 shard: None,
272 cli_skill_dirs: cli_skill_dirs.to_vec(),
273 progress,
274 diagnose: diagnose_enabled_via_env(),
275 trusted_host_dispatch: false,
276 };
277 run_tests_with_options(path, &options).await
278}
279
280fn diagnose_enabled_via_env() -> bool {
281 let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
282 return false;
283 };
284 matches!(
285 raw.to_ascii_lowercase().as_str(),
286 "1" | "true" | "yes" | "on"
287 )
288}
289
290fn test_budget_ms_via_env(name: &str) -> Option<u64> {
291 std::env::var(name)
292 .ok()
293 .and_then(|raw| raw.trim().parse::<u64>().ok())
294 .filter(|&value| value >= 1)
295}
296
297pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
302 run_tests_with_session(path, options, &TestRunSession::default()).await
303}
304
305pub fn run_tests_with_session<'a>(
311 path: &'a Path,
312 options: &'a RunOptions,
313 session: &'a TestRunSession,
314) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
315 run_tests_with_session_and_operator_grant(path, options, session, None)
316}
317
318pub(crate) fn run_tests_with_session_and_operator_grant<'a>(
323 path: &'a Path,
324 options: &'a RunOptions,
325 session: &'a TestRunSession,
326 operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
327) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
328 Box::pin(run_tests_with_session_impl(
329 path,
330 options,
331 session,
332 operator_approval_grant,
333 ))
334}
335
336async fn run_tests_with_session_impl(
337 path: &Path,
338 options: &RunOptions,
339 session: &TestRunSession,
340 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
341) -> TestSummary {
342 let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
344 let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
345
346 let start = Instant::now();
347
348 let collection_start = Instant::now();
349 let canonical_target = canonicalize_existing_path(path);
350 let files = if canonical_target.is_dir() {
351 discover_test_files(&canonical_target)
352 } else {
353 vec![canonical_target.clone()]
354 };
355
356 let workers = resolve_workers(options);
357 let timings_path = timings_cache_path(&canonical_target);
358 let timings = timings_path
359 .as_deref()
360 .map(load_timings_cache)
361 .unwrap_or_default();
362
363 let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
364 let mut declared_dispatch: BTreeMap<PathBuf, bool> = BTreeMap::new();
371 for case in &mut discovery.cases {
372 let declared = *declared_dispatch
373 .entry(case.file.clone())
374 .or_insert_with(|| package::load_check_config(Some(&case.file)).trusted_host_dispatch);
375 case.trusted_host_dispatch = options.trusted_host_dispatch || declared;
376 }
377 if let Some(shard) = options.shard {
378 discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
379 if shard.index() > 1 {
380 discovery.discovery_errors.clear();
381 }
382 }
383 let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
384 let collection_ms = collection_start.elapsed().as_millis() as u64;
385 let selected_files_with_tests = if options.shard.is_some() {
386 count_files_with_cases(&discovery.cases)
387 } else {
388 discovery.files_with_tests
389 };
390
391 emit_progress(
392 &options.progress,
393 TestRunEvent::SuiteDiscovered {
394 total_tests: discovery.cases.len(),
395 total_files: selected_files_with_tests,
396 parallel: options.parallel,
397 workers,
398 },
399 );
400 if workers == 1
401 && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
402 {
403 emit_progress(
404 &options.progress,
405 TestRunEvent::LargeSequentialSuite {
406 total_tests: discovery.cases.len(),
407 total_files: selected_files_with_tests,
408 },
409 );
410 }
411
412 let mut cases = discovery.cases;
413 sort_cases_longest_first(&mut cases, &timings);
414 let module_preparation = session.prepare_import_graphs(
415 cases
416 .iter()
417 .map(|case| (case.file.clone(), case.trusted_host_dispatch)),
418 );
419
420 let mut all_results = discovery.discovery_errors;
421 let total_tests = cases.len();
422 let callable_preparation = if !options.fail_fast || all_results.is_empty() {
423 let prepared = prepare_callable_entries(cases, session);
424 cases = prepared.cases;
425 all_results.extend(prepared.failures);
426 prepared.timing
427 } else {
428 cases.clear();
429 SuiteCallablePreparation::default()
430 };
431 if !options.fail_fast || all_results.is_empty() {
432 let prepared = prepare_file_fixtures(
433 cases,
434 options,
435 session,
436 &skill_contexts,
437 operator_approval_grant,
438 )
439 .await;
440 cases = prepared.cases;
441 all_results.extend(prepared.failures);
442 } else {
443 cases.clear();
444 }
445 let execution = if !options.fail_fast || all_results.is_empty() {
446 execute_cases(
447 cases,
448 workers,
449 options,
450 total_tests,
451 session,
452 skill_contexts,
453 operator_approval_grant,
454 )
455 .await
456 } else {
457 CaseExecutionResults::default()
458 };
459
460 let timing = DurationSummary::from_samples(
461 &execution
462 .cases
463 .iter()
464 .map(|result| result.duration_ms)
465 .collect::<Vec<_>>(),
466 );
467 if let Some(path) = timings_path.as_deref() {
468 update_timings_cache(path, timings, &execution.cases);
469 }
470 all_results.extend(execution.cases);
471 all_results.extend(execution.infrastructure_errors);
472 let total = all_results.len();
473 let passed = all_results.iter().filter(|result| result.passed).count();
474 let failed = total - passed;
475 let aggregate = AggregateTimings::from_results(
476 collection_ms,
477 module_preparation,
478 callable_preparation,
479 &all_results,
480 );
481
482 TestSummary {
483 results: all_results,
484 passed,
485 failed,
486 total,
487 duration_ms: start.elapsed().as_millis() as u64,
488 timing,
489 aggregate,
490 }
491}
492
493pub async fn run_test_file(
501 path: &Path,
502 filter: Option<&str>,
503 timeout_ms: u64,
504 execution_cwd: Option<&Path>,
505 cli_skill_dirs: &[PathBuf],
506) -> Result<Vec<TestResult>, String> {
507 run_test_file_with_session(
508 path,
509 filter,
510 timeout_ms,
511 execution_cwd,
512 cli_skill_dirs,
513 &TestRunSession::default(),
514 )
515 .await
516}
517
518pub fn run_test_file_with_session<'a>(
520 path: &'a Path,
521 filter: Option<&'a str>,
522 timeout_ms: u64,
523 execution_cwd: Option<&'a Path>,
524 cli_skill_dirs: &'a [PathBuf],
525 session: &'a TestRunSession,
526) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
527 Box::pin(run_test_file_with_session_impl(
528 path,
529 filter,
530 timeout_ms,
531 execution_cwd,
532 cli_skill_dirs,
533 session,
534 ))
535}
536
537async fn run_test_file_with_session_impl(
538 path: &Path,
539 filter: Option<&str>,
540 timeout_ms: u64,
541 execution_cwd: Option<&Path>,
542 cli_skill_dirs: &[PathBuf],
543 session: &TestRunSession,
544) -> Result<Vec<TestResult>, String> {
545 let source =
546 fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
547 let program = parse_program(&source)?;
548 let source = Arc::new(source);
549 let program = Arc::new(program);
550
551 let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
552 seed_imported_enum_candidates(path, &source, &mut cases);
553 let trusted_host_dispatch = package::load_check_config(Some(path)).trusted_host_dispatch;
554 for case in &mut cases {
555 case.trusted_host_dispatch = trusted_host_dispatch;
556 }
557 let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
558 let _module_preparation = session.prepare_import_graphs(
559 cases
560 .iter()
561 .map(|case| (case.file.clone(), case.trusted_host_dispatch)),
562 );
563
564 let mut results = Vec::with_capacity(cases.len());
565 let callable_preparation = prepare_callable_entries(cases, session);
566 results.extend(callable_preparation.failures);
567 let cases = callable_preparation.cases;
568 let execution_cwd = execution_cwd
569 .map(Path::to_path_buf)
570 .unwrap_or_else(test_execution_cwd);
571 let prepared_module_cache = session.prepared_module_cache(0);
572 let fixture_options = RunOptions {
573 timeout_ms,
574 ..RunOptions::default()
575 };
576 let prepared =
577 prepare_file_fixtures(cases, &fixture_options, session, &skill_contexts, None).await;
578 results.extend(prepared.failures);
579 for case in prepared.cases {
580 let loaded_skills = skill_contexts.for_case(&case);
581 results.push(
582 execute_case(
583 &case,
584 &execution_cwd,
585 timeout_ms,
586 loaded_skills,
587 &prepared_module_cache,
588 session.stdio_available(),
589 None,
590 )
591 .await,
592 );
593 }
594 Ok(results)
595}
596
597fn resolve_workers(options: &RunOptions) -> usize {
598 if !options.parallel {
599 return 1;
600 }
601 if options.max_test_ms.is_some() || options.max_execute_ms.is_some() {
608 return 1;
609 }
610 if let Some(jobs) = options.jobs {
611 return jobs.max(1);
612 }
613 if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
614 if let Ok(parsed) = raw.trim().parse::<usize>() {
615 if parsed >= 1 {
616 return parsed;
617 }
618 }
619 }
620 let detected = thread::available_parallelism()
621 .map(|n| n.get())
622 .unwrap_or(1);
623 let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
624 apply_memory_cap(core_cap)
625}
626
627pub(crate) fn resolve_parallel_workers(jobs: Option<usize>) -> usize {
628 resolve_workers(&RunOptions {
629 parallel: true,
630 jobs,
631 ..RunOptions::default()
632 })
633}
634
635fn apply_memory_cap(core_cap: usize) -> usize {
641 let Some(available_mb) = available_memory_mb() else {
642 return core_cap;
643 };
644 let budget = per_worker_memory_mb();
645 let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
646 if mem_cap < core_cap {
647 eprintln!(
648 "harn test: capping workers {core_cap} -> {mem_cap} \
649 (~{available_mb} MiB available, {budget} MiB/worker; \
650 override with --jobs / HARN_TEST_JOBS)"
651 );
652 return mem_cap;
653 }
654 core_cap
655}
656
657fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
660 let usable = available_mb.saturating_sub(reserved_mb);
661 let per_worker = per_worker_mb.max(1);
662 ((usable / per_worker).max(1)) as usize
663}
664
665fn per_worker_memory_mb() -> u64 {
668 std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
669 .ok()
670 .and_then(|raw| raw.trim().parse::<u64>().ok())
671 .filter(|&n| n >= 1)
672 .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
673}
674
675fn available_memory_mb() -> Option<u64> {
686 let mut sys = sysinfo::System::new();
687 sys.refresh_memory();
688 let host_mb = match sys.available_memory() {
689 0 => None, bytes => Some(bytes / (1024 * 1024)),
691 };
692 match (host_mb, cgroup_v2_headroom_mb()) {
693 (Some(h), Some(c)) => Some(h.min(c)),
694 (Some(h), None) => Some(h),
695 (None, c) => c,
696 }
697}
698
699#[cfg(target_os = "linux")]
702fn cgroup_v2_headroom_mb() -> Option<u64> {
703 let dir = own_cgroup_v2_dir()?;
704 let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
705 let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
706 cgroup_headroom_mb(&max_raw, ¤t_raw)
707}
708
709#[cfg(not(target_os = "linux"))]
710fn cgroup_v2_headroom_mb() -> Option<u64> {
711 None
712}
713
714#[cfg(target_os = "linux")]
720fn own_cgroup_v2_dir() -> Option<PathBuf> {
721 let content = fs::read_to_string("/proc/self/cgroup").ok()?;
722 let rel = content
723 .lines()
724 .find_map(|line| line.strip_prefix("0::"))?
725 .trim();
726 let rel = rel.strip_prefix('/').unwrap_or(rel);
727 Some(Path::new("/sys/fs/cgroup").join(rel))
728}
729
730#[cfg(any(target_os = "linux", test))]
736fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
737 let max = memory_max.trim();
738 if max == "max" {
739 return None;
740 }
741 let max: u64 = max.parse().ok()?;
742 let current: u64 = memory_current.trim().parse().ok()?;
743 Some(max.saturating_sub(current) / (1024 * 1024))
744}
745
746struct Discovery {
747 cases: Vec<TestCase>,
748 files_with_tests: usize,
749 discovery_errors: Vec<TestResult>,
750}
751
752fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
753 let mut cases = Vec::new();
754 let mut files_with_tests = 0usize;
755 let mut discovery_errors = Vec::new();
756
757 for file in files {
758 let source = match fs::read_to_string(file) {
759 Ok(s) => s,
760 Err(e) => {
761 discovery_errors.push(TestResult {
762 name: "<file error>".to_string(),
763 file: file.display().to_string(),
764 passed: false,
765 error: Some(format!("Failed to read {}: {e}", file.display())),
766 captured_output: None,
767 timeout: None,
768 duration_ms: 0,
769 phases: None,
770 });
771 continue;
772 }
773 };
774
775 let program = match parse_program(&source) {
776 Ok(p) => p,
777 Err(e) => {
778 discovery_errors.push(TestResult {
779 name: "<file error>".to_string(),
780 file: file.display().to_string(),
781 passed: false,
782 error: Some(e),
783 captured_output: None,
784 timeout: None,
785 duration_ms: 0,
786 phases: None,
787 });
788 continue;
789 }
790 };
791
792 let source = Arc::new(source);
793 let program = Arc::new(program);
794 match extract_cases_from_program(file, &source, &program, filter, workers) {
795 Ok(mut file_cases) => {
796 if !file_cases.is_empty() {
797 seed_imported_enum_candidates(file, &source, &mut file_cases);
798 files_with_tests += 1;
799 cases.extend(file_cases);
800 }
801 }
802 Err(error) => discovery_errors.push(TestResult {
803 name: "<file error>".to_string(),
804 file: file.display().to_string(),
805 passed: false,
806 error: Some(error),
807 captured_output: None,
808 timeout: None,
809 duration_ms: 0,
810 phases: None,
811 }),
812 }
813 }
814
815 Discovery {
816 cases,
817 files_with_tests,
818 discovery_errors,
819 }
820}
821
822fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
823 cases.sort_by(|a, b| {
829 let key_a = timings_key(&a.file, &a.name);
830 let key_b = timings_key(&b.file, &b.name);
831 let dur_a = timings.get(&key_a).copied().unwrap_or(0);
832 let dur_b = timings.get(&key_b).copied().unwrap_or(0);
833 dur_a
834 .cmp(&dur_b)
835 .then_with(|| a.file.cmp(&b.file))
836 .then_with(|| a.name.cmp(&b.name))
837 });
838}
839
840fn select_shard_cases(
841 cases: Vec<TestCase>,
842 timings: &BTreeMap<String, u64>,
843 shard: TestShard,
844) -> Vec<TestCase> {
845 if shard.total() <= 1 {
846 return cases;
847 }
848
849 let mut ranked = cases.into_iter().collect::<Vec<_>>();
850 ranked.sort_by(|a, b| {
851 estimated_case_cost_ms(b, timings)
852 .cmp(&estimated_case_cost_ms(a, timings))
853 .then_with(|| a.file.cmp(&b.file))
854 .then_with(|| a.name.cmp(&b.name))
855 });
856
857 let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
858 let mut costs = vec![0u64; shard.total()];
859 let mut counts = vec![0usize; shard.total()];
860
861 for case in ranked {
862 let bucket_index = (0..shard.total())
863 .min_by_key(|&index| (costs[index], counts[index], index))
864 .unwrap_or(0);
865 costs[bucket_index] =
866 costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
867 counts[bucket_index] += 1;
868 buckets[bucket_index].push(case);
869 }
870
871 buckets.swap_remove(shard.index() - 1)
872}
873
874fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
875 timings
876 .get(&timings_key(&case.file, &case.name))
877 .copied()
878 .unwrap_or(case.weight as u64)
879 .max(1)
880}
881
882fn count_files_with_cases(cases: &[TestCase]) -> usize {
883 let mut files = HashSet::new();
884 for case in cases {
885 files.insert(case.file.as_path());
886 }
887 files.len()
888}
889
890fn timings_key(file: &Path, name: &str) -> String {
891 format!("{}::{}", file.display(), name)
892}
893
894fn timings_cache_path(target: &Path) -> Option<PathBuf> {
895 let probe_root = if target.is_dir() {
900 target.to_path_buf()
901 } else {
902 target.parent()?.to_path_buf()
903 };
904 let root = harn_vm::stdlib::process::find_project_root(&probe_root)
905 .unwrap_or_else(|| probe_root.clone());
906 Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
907}
908
909fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
910 let Ok(contents) = fs::read_to_string(path) else {
911 return BTreeMap::new();
912 };
913 serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
914}
915
916fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
917 for result in results {
918 existing.insert(
919 timings_key(Path::new(&result.file), &result.name),
920 result.duration_ms,
921 );
922 }
923 if let Some(parent) = path.parent() {
924 let _ = fs::create_dir_all(parent);
925 }
926 if let Ok(serialized) = serde_json::to_string(&existing) {
927 let _ = fs::write(path, serialized);
928 }
929}
930
931#[derive(Default)]
932struct CaseExecutionResults {
933 cases: Vec<TestResult>,
934 infrastructure_errors: Vec<TestResult>,
935}
936
937struct PreparedFixtureCases {
938 cases: Vec<TestCase>,
939 failures: Vec<TestResult>,
940}
941
942#[derive(Default)]
943struct PreparedCallableCases {
944 cases: Vec<TestCase>,
945 failures: Vec<TestResult>,
946 timing: SuiteCallablePreparation,
947}
948
949fn prepare_callable_entries(
950 mut cases: Vec<TestCase>,
951 session: &TestRunSession,
952) -> PreparedCallableCases {
953 let started = Instant::now();
954 let mut by_file: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
955 for (index, case) in cases.iter().enumerate() {
956 by_file.entry(case.file.clone()).or_default().push(index);
957 }
958
959 let mut failed = HashSet::new();
960 let mut failures = Vec::new();
961 let mut compiled_entries = 0usize;
962 for indices in by_file.values() {
963 let first = &cases[indices[0]];
964 let mut request_indices: BTreeMap<(String, Option<String>), Vec<usize>> = BTreeMap::new();
965 for &index in indices {
966 let case = &cases[index];
967 let fixture = case
968 .fixture
969 .as_ref()
970 .filter(|fixture| fixture.scope == FixtureScope::Case)
971 .map(|fixture| fixture.name.clone());
972 request_indices
973 .entry((case.pipeline_name.clone(), fixture))
974 .or_default()
975 .push(index);
976 }
977 let owned_requests = request_indices.keys().cloned().collect::<Vec<_>>();
978 let requests = owned_requests
979 .iter()
980 .map(|(pipeline, fixture)| (pipeline.as_str(), fixture.as_deref()))
981 .collect::<Vec<_>>();
982 let mut fixture_indices: BTreeMap<String, Vec<usize>> = BTreeMap::new();
983 for &index in indices {
984 if let Some(fixture) = cases[index]
985 .fixture
986 .as_ref()
987 .filter(|fixture| fixture.scope == FixtureScope::File)
988 {
989 fixture_indices
990 .entry(fixture.name.clone())
991 .or_default()
992 .push(index);
993 }
994 }
995 let fixture_names = fixture_indices
996 .keys()
997 .map(String::as_str)
998 .collect::<Vec<_>>();
999 let imported_enums = first.imported_enum_candidates.iter().cloned();
1000 let compiler = if first.trusted_host_dispatch {
1001 harn_vm::Compiler::new_trusted_host_dispatch()
1002 .with_imported_enum_candidates(imported_enums)
1003 } else {
1004 crate::compiler_with_imported_enum_candidates(imported_enums)
1005 };
1006 let entries =
1007 compiler.compile_named_callable_entries(&first.program, &requests, &fixture_names);
1008 match entries {
1009 Ok(batch) => {
1010 let entries = batch.pipelines;
1011 let fixture_entries = batch.functions;
1012 compiled_entries += entries.iter().filter(|entry| entry.is_ok()).count();
1013 for ((_, case_indices), entry) in request_indices.into_iter().zip(entries) {
1014 match entry {
1015 Ok(entry) => {
1016 let entry = Arc::new(entry);
1017 for index in case_indices {
1018 cases[index].compiled_entry = Some(Arc::clone(&entry));
1019 }
1020 }
1021 Err(error) => {
1022 for index in case_indices {
1023 failed.insert(index);
1024 failures
1025 .push(prepared_compile_failure(&cases[index], error.clone()));
1026 }
1027 }
1028 }
1029 }
1030 compiled_entries += fixture_entries.iter().filter(|entry| entry.is_ok()).count();
1031 for ((_, case_indices), entry) in fixture_indices.into_iter().zip(fixture_entries) {
1032 let entry = entry.map(Arc::new);
1033 for index in case_indices {
1034 cases[index].compiled_file_fixture_entry = Some(entry.clone());
1035 }
1036 }
1037 }
1038 Err(error) => {
1039 for &index in indices {
1040 failed.insert(index);
1041 failures.push(prepared_compile_failure(&cases[index], error.clone()));
1042 }
1043 }
1044 }
1045 }
1046
1047 let files = by_file.len();
1048 cases = cases
1049 .into_iter()
1050 .enumerate()
1051 .filter_map(|(index, case)| (!failed.contains(&index)).then_some(case))
1052 .collect();
1053 session.record_callable_preparation(files, compiled_entries);
1054 PreparedCallableCases {
1055 cases,
1056 failures,
1057 timing: SuiteCallablePreparation {
1058 duration_ms: started.elapsed().as_millis() as u64,
1059 files,
1060 entries: compiled_entries,
1061 },
1062 }
1063}
1064
1065fn prepared_compile_failure(case: &TestCase, error: harn_vm::CompileError) -> TestResult {
1066 TestResult {
1067 name: case.name.clone(),
1068 file: case.file.display().to_string(),
1069 passed: false,
1070 error: Some(format!("Compile error: {error}")),
1071 captured_output: None,
1072 timeout: None,
1073 duration_ms: 0,
1074 phases: None,
1075 }
1076}
1077
1078async fn prepare_file_fixtures(
1079 cases: Vec<TestCase>,
1080 options: &RunOptions,
1081 session: &TestRunSession,
1082 skill_contexts: &PreparedSkillContexts,
1083 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
1084) -> PreparedFixtureCases {
1085 let mut values: BTreeMap<(PathBuf, String), Result<IsolateValue, TestResult>> = BTreeMap::new();
1086 let mut prepared = Vec::with_capacity(cases.len());
1087 let mut failures = Vec::new();
1088 let prepared_module_cache = session.prepared_module_cache(0);
1089
1090 for mut case in cases {
1091 let Some(fixture) = case
1092 .fixture
1093 .as_ref()
1094 .filter(|fixture| fixture.scope == FixtureScope::File)
1095 .cloned()
1096 else {
1097 prepared.push(case);
1098 continue;
1099 };
1100 let key = (case.file.clone(), fixture.name.clone());
1101 if !values.contains_key(&key) {
1102 let cwd = case_execution_cwd(&case);
1103 let value = execute_file_fixture(
1104 &case,
1105 &fixture,
1106 &cwd,
1107 options.timeout_ms,
1108 skill_contexts.for_case(&case),
1109 &prepared_module_cache,
1110 session.stdio_available(),
1111 operator_approval_grant,
1112 )
1113 .await;
1114 if let Err(failure) = &value {
1115 failures.push(failure.clone());
1116 }
1117 values.insert(key.clone(), value);
1118 }
1119 match values.get(&key).expect("fixture result inserted above") {
1120 Ok(value) => {
1121 case.file_fixture_value = Some(value.clone());
1122 prepared.push(case);
1123 }
1124 Err(_) if options.fail_fast => {
1125 prepared.clear();
1126 break;
1127 }
1128 Err(_) => {}
1129 }
1130 }
1131
1132 PreparedFixtureCases {
1133 cases: prepared,
1134 failures,
1135 }
1136}
1137
1138async fn execute_cases(
1139 cases: Vec<TestCase>,
1140 workers: usize,
1141 options: &RunOptions,
1142 total_tests: usize,
1143 session: &TestRunSession,
1144 skill_contexts: PreparedSkillContexts,
1145 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
1146) -> CaseExecutionResults {
1147 if cases.is_empty() {
1148 return CaseExecutionResults::default();
1149 }
1150 let completed = Arc::new(Mutex::new(0usize));
1151 if workers <= 1 {
1152 let prepared_module_cache = session.prepared_module_cache(0);
1153 let mut results = Vec::with_capacity(cases.len());
1154 for case in cases {
1155 let loaded_skills = skill_contexts.for_case(&case);
1156 let cwd = case_execution_cwd(&case);
1157 let test_index = next_test_index(&completed);
1158 emit_progress(
1159 &options.progress,
1160 TestRunEvent::TestStarted {
1161 name: case.name.clone(),
1162 file: case.file.display().to_string(),
1163 test_index,
1164 total_tests,
1165 },
1166 );
1167 let result = execute_case(
1168 &case,
1169 &cwd,
1170 options.timeout_ms,
1171 loaded_skills,
1172 &prepared_module_cache,
1173 session.stdio_available(),
1174 operator_approval_grant,
1175 )
1176 .await;
1177 let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
1178 if options.diagnose {
1179 result.emit_diagnose();
1180 }
1181 emit_progress(
1182 &options.progress,
1183 TestRunEvent::TestFinished(result.clone()),
1184 );
1185 results.push(result);
1186 if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
1187 break;
1188 }
1189 }
1190 return CaseExecutionResults {
1191 cases: results,
1192 infrastructure_errors: Vec::new(),
1193 };
1194 }
1195
1196 let queue = Arc::new(Mutex::new(cases));
1197 let skill_contexts = Arc::new(skill_contexts);
1198 let gate = Arc::new(ResourceGate::new(workers));
1199 let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1200 let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1201 let cancelled = Arc::new(AtomicBool::new(false));
1202
1203 let mut handles = Vec::with_capacity(workers);
1204 for worker_idx in 0..workers {
1205 let queue = Arc::clone(&queue);
1206 let skill_contexts = Arc::clone(&skill_contexts);
1207 let gate = Arc::clone(&gate);
1208 let results = Arc::clone(&results);
1209 let infrastructure_errors = Arc::clone(&infrastructure_errors);
1210 let completed = Arc::clone(&completed);
1211 let timeout_ms = options.timeout_ms;
1212 let max_test_ms = options.max_test_ms;
1213 let max_execute_ms = options.max_execute_ms;
1214 let progress = options.progress.clone();
1215 let diagnose = options.diagnose;
1216 let fail_fast = options.fail_fast;
1217 let cancelled = Arc::clone(&cancelled);
1218 let prepared_module_cache = session.prepared_module_cache(worker_idx);
1219 let stdio_available = session.stdio_available();
1220 let operator_approval_grant = operator_approval_grant.cloned();
1221 let handle = thread::Builder::new()
1222 .name(format!("harn-test-worker-{worker_idx}"))
1223 .stack_size(CLI_RUNTIME_STACK_SIZE)
1224 .spawn(move || {
1225 let runtime = match tokio::runtime::Builder::new_current_thread()
1226 .enable_all()
1227 .build()
1228 {
1229 Ok(rt) => rt,
1230 Err(error) => {
1231 infrastructure_errors.lock().unwrap().push(TestResult {
1232 name: "<worker error>".to_string(),
1233 file: String::new(),
1234 passed: false,
1235 error: Some(format!("failed to start test runtime: {error}")),
1236 captured_output: None,
1237 timeout: None,
1238 duration_ms: 0,
1239 phases: None,
1240 });
1241 return;
1242 }
1243 };
1244 loop {
1249 let case = claim_next_case(&queue, &cancelled, fail_fast);
1250 let Some(case) = case else { break };
1251 let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1252 if fail_fast && cancelled.load(Ordering::Acquire) {
1257 break;
1258 }
1259 let cwd = case_execution_cwd(&case);
1260 let loaded_skills = skill_contexts.for_case(&case);
1261 let test_index = next_test_index(&completed);
1262 emit_progress(
1263 &progress,
1264 TestRunEvent::TestStarted {
1265 name: case.name.clone(),
1266 file: case.file.display().to_string(),
1267 test_index,
1268 total_tests,
1269 },
1270 );
1271 let result = runtime.block_on(execute_case(
1272 &case,
1273 &cwd,
1274 timeout_ms,
1275 loaded_skills,
1276 &prepared_module_cache,
1277 stdio_available,
1278 operator_approval_grant.as_ref(),
1279 ));
1280 let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1281 if fail_fast && !result.passed {
1282 cancelled.store(true, Ordering::Release);
1283 }
1284 if diagnose {
1285 result.emit_diagnose();
1286 }
1287 emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1288 results.lock().unwrap().push(result);
1289 }
1290 })
1291 .expect("spawning a harn-test worker thread should succeed");
1292 handles.push(handle);
1293 }
1294
1295 for handle in handles {
1296 let _ = handle.join();
1297 }
1298
1299 let cases = Arc::try_unwrap(results)
1303 .map(|m| m.into_inner().unwrap_or_default())
1304 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1305 let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1306 .map(|mutex| mutex.into_inner().unwrap_or_default())
1307 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1308 CaseExecutionResults {
1309 cases,
1310 infrastructure_errors,
1311 }
1312}
1313
1314fn claim_next_case(
1315 queue: &Mutex<Vec<TestCase>>,
1316 cancelled: &AtomicBool,
1317 fail_fast: bool,
1318) -> Option<TestCase> {
1319 let mut queue = queue.lock().unwrap();
1320 if fail_fast && cancelled.load(Ordering::Acquire) {
1321 None
1322 } else {
1323 queue.pop()
1324 }
1325}
1326
1327fn enforce_case_budgets(
1328 mut result: TestResult,
1329 max_test_ms: Option<u64>,
1330 max_execute_ms: Option<u64>,
1331) -> TestResult {
1332 if !result.passed {
1333 return result;
1334 }
1335
1336 let phases = result
1337 .phases
1338 .expect("passed test results always carry measured phases");
1339 let mut violations = Vec::new();
1340 if let Some(max_ms) = max_test_ms {
1341 if result.duration_ms > max_ms {
1342 violations.push(format!(
1343 "exceeded test wall-clock budget: {}ms > {}ms",
1344 result.duration_ms, max_ms
1345 ));
1346 }
1347 }
1348 if let Some(max_ms) = max_execute_ms {
1349 if phases.execute_ms > max_ms {
1350 violations.push(format!(
1351 "exceeded test execute budget: {}ms > {}ms",
1352 phases.execute_ms, max_ms
1353 ));
1354 }
1355 }
1356
1357 if violations.is_empty() {
1358 return result;
1359 }
1360
1361 violations.push(format!(
1362 "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1363 phases.setup_ms,
1364 phases.compile_ms,
1365 phases.execute_ms,
1366 phases.teardown_ms,
1367 result.duration_ms
1368 ));
1369 result.passed = false;
1370 result.error = Some(violations.join("\n"));
1371 result
1372}
1373
1374fn next_test_index(counter: &Mutex<usize>) -> usize {
1375 let mut guard = counter.lock().unwrap();
1376 *guard += 1;
1377 *guard
1378}
1379
1380fn case_execution_cwd(case: &TestCase) -> PathBuf {
1381 case.file
1382 .parent()
1383 .filter(|p| !p.as_os_str().is_empty())
1384 .map(Path::to_path_buf)
1385 .unwrap_or_else(test_execution_cwd)
1386}
1387
1388struct ResourceGate {
1392 state: Mutex<GateState>,
1393 cond: Condvar,
1394 capacity: usize,
1395}
1396
1397struct GateState {
1398 available: usize,
1399 busy_groups: HashSet<String>,
1400}
1401
1402struct GateGuard<'a> {
1403 gate: &'a ResourceGate,
1404 weight: usize,
1405 group: Option<String>,
1406}
1407
1408impl ResourceGate {
1409 fn new(capacity: usize) -> Self {
1410 Self {
1411 state: Mutex::new(GateState {
1412 available: capacity,
1413 busy_groups: HashSet::new(),
1414 }),
1415 cond: Condvar::new(),
1416 capacity,
1417 }
1418 }
1419
1420 fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1421 let weight = weight.min(self.capacity).max(1);
1422 let mut state = self.state.lock().unwrap();
1423 loop {
1424 if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1425 return guard;
1426 }
1427 state = self.cond.wait(state).unwrap();
1428 }
1429 }
1430
1431 fn try_grab_locked<'a>(
1435 &'a self,
1436 state: &mut GateState,
1437 weight: usize,
1438 group: Option<&str>,
1439 ) -> Option<GateGuard<'a>> {
1440 let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1441 if state.available >= weight && group_free {
1442 state.available -= weight;
1443 if let Some(g) = group {
1444 state.busy_groups.insert(g.to_string());
1445 }
1446 return Some(GateGuard {
1447 gate: self,
1448 weight,
1449 group: group.map(str::to_owned),
1450 });
1451 }
1452 None
1453 }
1454
1455 #[cfg(test)]
1458 fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1459 let weight = weight.min(self.capacity).max(1);
1460 let mut state = self.state.lock().unwrap();
1461 self.try_grab_locked(&mut state, weight, group)
1462 }
1463}
1464
1465impl Drop for GateGuard<'_> {
1466 fn drop(&mut self) {
1467 let mut state = self.gate.state.lock().unwrap();
1468 state.available += self.weight;
1469 if let Some(group) = self.group.as_deref() {
1470 state.busy_groups.remove(group);
1471 }
1472 self.gate.cond.notify_all();
1473 }
1474}
1475
1476fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1477 let mut files = Vec::new();
1478 if let Ok(entries) = fs::read_dir(dir) {
1479 for entry in entries.flatten() {
1480 let path = entry.path();
1481 if path.is_dir() {
1482 files.extend(discover_test_files(&path));
1483 } else if path.extension().is_some_and(|e| e == "harn") {
1484 if let Ok(content) = fs::read_to_string(&path) {
1485 if content.contains("test_") || content.contains("@test") {
1486 files.push(canonicalize_existing_path(&path));
1487 }
1488 }
1489 }
1490 }
1491 }
1492 files.sort();
1493 files
1494}