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_lexer::Lexer;
15use harn_parser::const_eval::{const_eval, ConstEnv, ConstValue};
16use harn_parser::{Attribute, Node, Parser, SNode, TypedParam};
17use harn_vm::VmValue;
18
19mod execution;
20mod reporting;
21mod session;
22mod skill_context;
23#[cfg(test)]
24mod tests;
25
26use execution::execute_case;
27pub use reporting::{
28 AggregateTimings, PhaseTimings, TestPhase, TestResult, TestSummary, TestTimeout,
29};
30pub use session::{TestRunSession, TestRunSessionStats};
31use skill_context::PreparedSkillContexts;
32
33#[derive(Clone, Debug)]
34pub enum TestRunEvent {
35 SuiteDiscovered {
36 total_tests: usize,
37 total_files: usize,
38 parallel: bool,
39 workers: usize,
40 },
41 LargeSequentialSuite {
42 total_tests: usize,
43 total_files: usize,
44 },
45 TestStarted {
46 name: String,
47 file: String,
48 test_index: usize,
49 total_tests: usize,
50 },
51 TestFinished(TestResult),
52}
53
54pub type TestRunProgress = Arc<dyn Fn(TestRunEvent) + Send + Sync>;
55
56const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
57const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
58const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
59const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
60const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
61const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
62const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
63
64const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
71const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
72
73const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
81
82#[derive(Clone, Default)]
88pub struct RunOptions {
89 pub filter: Option<String>,
90 pub timeout_ms: u64,
91 pub max_test_ms: Option<u64>,
95 pub max_execute_ms: Option<u64>,
99 pub parallel: bool,
103 pub fail_fast: bool,
106 pub jobs: Option<usize>,
110 pub shard: Option<TestShard>,
113 pub cli_skill_dirs: Vec<PathBuf>,
114 pub progress: Option<TestRunProgress>,
117 pub diagnose: bool,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct TestShard {
125 index: usize,
126 total: usize,
127}
128
129impl TestShard {
130 pub fn new(index: usize, total: usize) -> Result<Self, String> {
131 if total == 0 {
132 return Err("test shard total must be at least 1".to_string());
133 }
134 if index == 0 {
135 return Err("test shard index must be at least 1".to_string());
136 }
137 if index > total {
138 return Err(format!(
139 "test shard index {index} exceeds shard total {total}"
140 ));
141 }
142 Ok(Self { index, total })
143 }
144
145 pub fn index(self) -> usize {
146 self.index
147 }
148
149 pub fn total(self) -> usize {
150 self.total
151 }
152}
153
154impl RunOptions {
155 pub fn new(timeout_ms: u64) -> Self {
156 Self {
157 timeout_ms,
158 ..Default::default()
159 }
160 }
161}
162
163#[derive(Clone)]
167struct TestCase {
168 file: PathBuf,
169 name: String,
170 pipeline_name: String,
171 source: Arc<String>,
172 program: Arc<Vec<SNode>>,
173 imported_enum_candidates: Arc<Vec<String>>,
176 serial_group: Option<String>,
180 weight: usize,
183 bindings: Vec<(String, VmValue)>,
185}
186
187fn canonicalize_existing_path(path: &Path) -> PathBuf {
188 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
189}
190
191fn test_execution_cwd() -> PathBuf {
192 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
193}
194
195fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
196 if let Some(callback) = progress {
197 callback(event);
198 }
199}
200
201fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
202 total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
203}
204
205pub async fn run_tests(
207 path: &Path,
208 filter: Option<&str>,
209 timeout_ms: u64,
210 parallel: bool,
211 cli_skill_dirs: &[PathBuf],
212) -> TestSummary {
213 let options = RunOptions {
214 filter: filter.map(str::to_owned),
215 timeout_ms,
216 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
217 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
218 parallel,
219 fail_fast: false,
220 jobs: None,
221 shard: None,
222 cli_skill_dirs: cli_skill_dirs.to_vec(),
223 progress: None,
224 diagnose: diagnose_enabled_via_env(),
225 };
226 run_tests_with_options(path, &options).await
227}
228
229pub async fn run_tests_with_progress(
231 path: &Path,
232 filter: Option<&str>,
233 timeout_ms: u64,
234 parallel: bool,
235 cli_skill_dirs: &[PathBuf],
236 progress: Option<TestRunProgress>,
237) -> TestSummary {
238 let options = RunOptions {
239 filter: filter.map(str::to_owned),
240 timeout_ms,
241 max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
242 max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
243 parallel,
244 fail_fast: false,
245 jobs: None,
246 shard: None,
247 cli_skill_dirs: cli_skill_dirs.to_vec(),
248 progress,
249 diagnose: diagnose_enabled_via_env(),
250 };
251 run_tests_with_options(path, &options).await
252}
253
254fn diagnose_enabled_via_env() -> bool {
255 let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
256 return false;
257 };
258 matches!(
259 raw.to_ascii_lowercase().as_str(),
260 "1" | "true" | "yes" | "on"
261 )
262}
263
264fn test_budget_ms_via_env(name: &str) -> Option<u64> {
265 std::env::var(name)
266 .ok()
267 .and_then(|raw| raw.trim().parse::<u64>().ok())
268 .filter(|&value| value >= 1)
269}
270
271pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
276 run_tests_with_session(path, options, &TestRunSession::default()).await
277}
278
279pub fn run_tests_with_session<'a>(
285 path: &'a Path,
286 options: &'a RunOptions,
287 session: &'a TestRunSession,
288) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
289 run_tests_with_session_and_operator_grant(path, options, session, None)
290}
291
292pub(crate) fn run_tests_with_session_and_operator_grant<'a>(
297 path: &'a Path,
298 options: &'a RunOptions,
299 session: &'a TestRunSession,
300 operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
301) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
302 Box::pin(run_tests_with_session_impl(
303 path,
304 options,
305 session,
306 operator_approval_grant,
307 ))
308}
309
310async fn run_tests_with_session_impl(
311 path: &Path,
312 options: &RunOptions,
313 session: &TestRunSession,
314 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
315) -> TestSummary {
316 let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
318 let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
319
320 let start = Instant::now();
321
322 let collection_start = Instant::now();
323 let canonical_target = canonicalize_existing_path(path);
324 let files = if canonical_target.is_dir() {
325 discover_test_files(&canonical_target)
326 } else {
327 vec![canonical_target.clone()]
328 };
329
330 let workers = resolve_workers(options);
331 let timings_path = timings_cache_path(&canonical_target);
332 let timings = timings_path
333 .as_deref()
334 .map(load_timings_cache)
335 .unwrap_or_default();
336
337 let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
338 if let Some(shard) = options.shard {
339 discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
340 if shard.index() > 1 {
341 discovery.discovery_errors.clear();
342 }
343 }
344 let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
345 let collection_ms = collection_start.elapsed().as_millis() as u64;
346 let selected_files_with_tests = if options.shard.is_some() {
347 count_files_with_cases(&discovery.cases)
348 } else {
349 discovery.files_with_tests
350 };
351
352 emit_progress(
353 &options.progress,
354 TestRunEvent::SuiteDiscovered {
355 total_tests: discovery.cases.len(),
356 total_files: selected_files_with_tests,
357 parallel: options.parallel,
358 workers,
359 },
360 );
361 if workers == 1
362 && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
363 {
364 emit_progress(
365 &options.progress,
366 TestRunEvent::LargeSequentialSuite {
367 total_tests: discovery.cases.len(),
368 total_files: selected_files_with_tests,
369 },
370 );
371 }
372
373 let mut cases = discovery.cases;
374 sort_cases_longest_first(&mut cases, &timings);
375
376 let mut all_results = discovery.discovery_errors;
377 let total_tests = cases.len();
378 let execution = if !options.fail_fast || all_results.is_empty() {
379 execute_cases(
380 cases,
381 workers,
382 options,
383 total_tests,
384 session,
385 skill_contexts,
386 operator_approval_grant,
387 )
388 .await
389 } else {
390 CaseExecutionResults::default()
391 };
392
393 let timing = DurationSummary::from_samples(
394 &execution
395 .cases
396 .iter()
397 .map(|result| result.duration_ms)
398 .collect::<Vec<_>>(),
399 );
400 if let Some(path) = timings_path.as_deref() {
401 update_timings_cache(path, timings, &execution.cases);
402 }
403 all_results.extend(execution.cases);
404 all_results.extend(execution.infrastructure_errors);
405 let total = all_results.len();
406 let passed = all_results.iter().filter(|result| result.passed).count();
407 let failed = total - passed;
408 let aggregate = AggregateTimings::from_results(collection_ms, &all_results);
409
410 TestSummary {
411 results: all_results,
412 passed,
413 failed,
414 total,
415 duration_ms: start.elapsed().as_millis() as u64,
416 timing,
417 aggregate,
418 }
419}
420
421pub async fn run_test_file(
429 path: &Path,
430 filter: Option<&str>,
431 timeout_ms: u64,
432 execution_cwd: Option<&Path>,
433 cli_skill_dirs: &[PathBuf],
434) -> Result<Vec<TestResult>, String> {
435 run_test_file_with_session(
436 path,
437 filter,
438 timeout_ms,
439 execution_cwd,
440 cli_skill_dirs,
441 &TestRunSession::default(),
442 )
443 .await
444}
445
446pub fn run_test_file_with_session<'a>(
448 path: &'a Path,
449 filter: Option<&'a str>,
450 timeout_ms: u64,
451 execution_cwd: Option<&'a Path>,
452 cli_skill_dirs: &'a [PathBuf],
453 session: &'a TestRunSession,
454) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
455 Box::pin(run_test_file_with_session_impl(
456 path,
457 filter,
458 timeout_ms,
459 execution_cwd,
460 cli_skill_dirs,
461 session,
462 ))
463}
464
465async fn run_test_file_with_session_impl(
466 path: &Path,
467 filter: Option<&str>,
468 timeout_ms: u64,
469 execution_cwd: Option<&Path>,
470 cli_skill_dirs: &[PathBuf],
471 session: &TestRunSession,
472) -> Result<Vec<TestResult>, String> {
473 let source =
474 fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
475 let program = parse_program(&source)?;
476 let source = Arc::new(source);
477 let program = Arc::new(program);
478
479 let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
480 seed_imported_enum_candidates(path, &source, &mut cases);
481 let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
482
483 let mut results = Vec::with_capacity(cases.len());
484 let execution_cwd = execution_cwd
485 .map(Path::to_path_buf)
486 .unwrap_or_else(test_execution_cwd);
487 let prepared_module_cache = session.prepared_module_cache(0);
488 for case in cases {
489 let loaded_skills = skill_contexts.for_case(&case);
490 results.push(
491 execute_case(
492 &case,
493 &execution_cwd,
494 timeout_ms,
495 loaded_skills,
496 &prepared_module_cache,
497 session.stdio_available(),
498 None,
499 )
500 .await,
501 );
502 }
503 Ok(results)
504}
505
506fn resolve_workers(options: &RunOptions) -> usize {
507 if !options.parallel {
508 return 1;
509 }
510 if let Some(jobs) = options.jobs {
511 return jobs.max(1);
512 }
513 if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
514 if let Ok(parsed) = raw.trim().parse::<usize>() {
515 if parsed >= 1 {
516 return parsed;
517 }
518 }
519 }
520 let detected = thread::available_parallelism()
521 .map(|n| n.get())
522 .unwrap_or(1);
523 let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
524 apply_memory_cap(core_cap)
525}
526
527fn apply_memory_cap(core_cap: usize) -> usize {
533 let Some(available_mb) = available_memory_mb() else {
534 return core_cap;
535 };
536 let budget = per_worker_memory_mb();
537 let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
538 if mem_cap < core_cap {
539 eprintln!(
540 "harn test: capping workers {core_cap} -> {mem_cap} \
541 (~{available_mb} MiB available, {budget} MiB/worker; \
542 override with --jobs / HARN_TEST_JOBS)"
543 );
544 return mem_cap;
545 }
546 core_cap
547}
548
549fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
552 let usable = available_mb.saturating_sub(reserved_mb);
553 let per_worker = per_worker_mb.max(1);
554 ((usable / per_worker).max(1)) as usize
555}
556
557fn per_worker_memory_mb() -> u64 {
560 std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
561 .ok()
562 .and_then(|raw| raw.trim().parse::<u64>().ok())
563 .filter(|&n| n >= 1)
564 .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
565}
566
567fn available_memory_mb() -> Option<u64> {
578 let mut sys = sysinfo::System::new();
579 sys.refresh_memory();
580 let host_mb = match sys.available_memory() {
581 0 => None, bytes => Some(bytes / (1024 * 1024)),
583 };
584 match (host_mb, cgroup_v2_headroom_mb()) {
585 (Some(h), Some(c)) => Some(h.min(c)),
586 (Some(h), None) => Some(h),
587 (None, c) => c,
588 }
589}
590
591#[cfg(target_os = "linux")]
594fn cgroup_v2_headroom_mb() -> Option<u64> {
595 let dir = own_cgroup_v2_dir()?;
596 let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
597 let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
598 cgroup_headroom_mb(&max_raw, ¤t_raw)
599}
600
601#[cfg(not(target_os = "linux"))]
602fn cgroup_v2_headroom_mb() -> Option<u64> {
603 None
604}
605
606#[cfg(target_os = "linux")]
612fn own_cgroup_v2_dir() -> Option<PathBuf> {
613 let content = fs::read_to_string("/proc/self/cgroup").ok()?;
614 let rel = content
615 .lines()
616 .find_map(|line| line.strip_prefix("0::"))?
617 .trim();
618 let rel = rel.strip_prefix('/').unwrap_or(rel);
619 Some(Path::new("/sys/fs/cgroup").join(rel))
620}
621
622#[cfg(any(target_os = "linux", test))]
628fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
629 let max = memory_max.trim();
630 if max == "max" {
631 return None;
632 }
633 let max: u64 = max.parse().ok()?;
634 let current: u64 = memory_current.trim().parse().ok()?;
635 Some(max.saturating_sub(current) / (1024 * 1024))
636}
637
638struct Discovery {
639 cases: Vec<TestCase>,
640 files_with_tests: usize,
641 discovery_errors: Vec<TestResult>,
642}
643
644fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
645 let mut cases = Vec::new();
646 let mut files_with_tests = 0usize;
647 let mut discovery_errors = Vec::new();
648
649 for file in files {
650 let source = match fs::read_to_string(file) {
651 Ok(s) => s,
652 Err(e) => {
653 discovery_errors.push(TestResult {
654 name: "<file error>".to_string(),
655 file: file.display().to_string(),
656 passed: false,
657 error: Some(format!("Failed to read {}: {e}", file.display())),
658 captured_output: None,
659 timeout: None,
660 duration_ms: 0,
661 phases: None,
662 });
663 continue;
664 }
665 };
666
667 let program = match parse_program(&source) {
668 Ok(p) => p,
669 Err(e) => {
670 discovery_errors.push(TestResult {
671 name: "<file error>".to_string(),
672 file: file.display().to_string(),
673 passed: false,
674 error: Some(e),
675 captured_output: None,
676 timeout: None,
677 duration_ms: 0,
678 phases: None,
679 });
680 continue;
681 }
682 };
683
684 let source = Arc::new(source);
685 let program = Arc::new(program);
686 match extract_cases_from_program(file, &source, &program, filter, workers) {
687 Ok(mut file_cases) => {
688 if !file_cases.is_empty() {
689 seed_imported_enum_candidates(file, &source, &mut file_cases);
690 files_with_tests += 1;
691 cases.extend(file_cases);
692 }
693 }
694 Err(error) => discovery_errors.push(TestResult {
695 name: "<file error>".to_string(),
696 file: file.display().to_string(),
697 passed: false,
698 error: Some(error),
699 captured_output: None,
700 timeout: None,
701 duration_ms: 0,
702 phases: None,
703 }),
704 }
705 }
706
707 Discovery {
708 cases,
709 files_with_tests,
710 discovery_errors,
711 }
712}
713
714fn parse_program(source: &str) -> Result<Vec<SNode>, String> {
715 let mut lexer = Lexer::new(source);
716 let tokens = lexer.tokenize().map_err(|e| format!("{e}"))?;
717 let mut parser = Parser::new(tokens);
718 parser.parse().map_err(|e| format!("{e}"))
719}
720
721fn extract_cases_from_program(
722 file: &Path,
723 source: &Arc<String>,
724 program: &Arc<Vec<SNode>>,
725 filter: Option<&str>,
726 workers: usize,
727) -> Result<Vec<TestCase>, String> {
728 let mut cases = Vec::new();
729 for snode in program.iter() {
730 let Some(meta) = inspect_test_pipeline(snode)? else {
731 continue;
732 };
733 let weight = meta.weight.min(workers).max(1);
736 if meta.rows.is_empty() {
737 if filter.is_some_and(|pattern| !meta.name.contains(pattern)) {
738 continue;
739 }
740 cases.push(TestCase {
741 file: file.to_path_buf(),
742 name: meta.name.clone(),
743 pipeline_name: meta.name,
744 source: Arc::clone(source),
745 program: Arc::clone(program),
746 imported_enum_candidates: Arc::new(Vec::new()),
747 serial_group: meta.serial_group,
748 weight,
749 bindings: Vec::new(),
750 });
751 } else {
752 for row in meta.rows {
753 let case_name = format!("{}[{}]", meta.name, row.name);
754 if filter.is_some_and(|pattern| !case_name.contains(pattern)) {
755 continue;
756 }
757 cases.push(TestCase {
758 file: file.to_path_buf(),
759 name: case_name,
760 pipeline_name: meta.name.clone(),
761 source: Arc::clone(source),
762 program: Arc::clone(program),
763 imported_enum_candidates: Arc::new(Vec::new()),
764 serial_group: meta.serial_group.clone(),
765 weight,
766 bindings: meta.params.iter().cloned().zip(row.args).collect(),
767 });
768 }
769 }
770 }
771 Ok(cases)
772}
773
774fn seed_imported_enum_candidates(file: &Path, source: &str, cases: &mut [TestCase]) {
775 if cases.is_empty() || !harn_parser::visit::contains_identifier_enum_pattern(&cases[0].program)
776 {
777 return;
778 }
779 let mut candidates = harn_modules::build_with_source(file, source)
780 .imported_names_by_kind_for_file(file, harn_modules::DefKind::Enum)
781 .unwrap_or_default()
782 .into_iter()
783 .collect::<Vec<_>>();
784 candidates.sort_unstable();
785 let candidates = Arc::new(candidates);
786 for case in cases {
787 case.imported_enum_candidates = Arc::clone(&candidates);
788 }
789}
790
791struct PipelineMeta {
792 name: String,
793 params: Vec<String>,
794 serial_group: Option<String>,
795 weight: usize,
796 rows: Vec<ParameterizedRow>,
797}
798
799struct ParameterizedRow {
800 name: String,
801 args: Vec<VmValue>,
802}
803
804fn inspect_test_pipeline(snode: &SNode) -> Result<Option<PipelineMeta>, String> {
805 let (attributes, inner) = match &snode.node {
809 Node::AttributedDecl { attributes, inner } => (attributes.as_slice(), inner.as_ref()),
810 _ => (&[][..], snode),
811 };
812 let (name, params) = match &inner.node {
813 Node::Pipeline { name, params, .. } => (name.clone(), TypedParam::names(params)),
814 _ => return Ok(None),
815 };
816 let has_test_attr = attributes.iter().any(|a| a.name == "test");
817 if !has_test_attr && !name.starts_with("test_") {
818 return Ok(None);
819 }
820 let serial_group = attributes
821 .iter()
822 .find(|a| a.name == "serial")
823 .map(serial_group_for);
824 let weight = attributes
825 .iter()
826 .find(|a| a.name == "heavy")
827 .and_then(heavy_weight_for)
828 .unwrap_or(1);
829 let rows = match attributes.iter().find(|a| a.name == "test") {
830 Some(attribute) => parameterized_rows(attribute, &name, params.len())?,
831 None => Vec::new(),
832 };
833 Ok(Some(PipelineMeta {
834 name,
835 params,
836 serial_group,
837 weight,
838 rows,
839 }))
840}
841
842fn parameterized_rows(
843 attribute: &Attribute,
844 pipeline_name: &str,
845 parameter_count: usize,
846) -> Result<Vec<ParameterizedRow>, String> {
847 let Some(cases) = attribute.named_arg("cases") else {
848 return Ok(Vec::new());
849 };
850 let Node::ListLiteral(items) = &cases.node else {
851 return Err(format!(
852 "@test cases for `{pipeline_name}` must be a list of {{name, args}} rows"
853 ));
854 };
855 if items.is_empty() {
856 return Err(format!(
857 "@test cases for `{pipeline_name}` must not be empty"
858 ));
859 }
860
861 let mut rows = Vec::with_capacity(items.len());
862 let mut names = HashSet::new();
863 for item in items {
864 let Node::DictLiteral(entries) = &item.node else {
865 return Err(format!(
866 "@test case in `{pipeline_name}` must be a {{name, args}} dict"
867 ));
868 };
869 let name_node = dict_entry(entries, "name").ok_or_else(|| {
870 format!("@test case in `{pipeline_name}` is missing string field `name`")
871 })?;
872 let name = match &name_node.node {
873 Node::StringLiteral(value) | Node::RawStringLiteral(value) => value.trim().to_string(),
874 _ => {
875 return Err(format!(
876 "@test case name in `{pipeline_name}` must be a string literal"
877 ));
878 }
879 };
880 if name.is_empty() || !names.insert(name.clone()) {
881 return Err(format!(
882 "@test case names in `{pipeline_name}` must be non-empty and unique: `{name}`"
883 ));
884 }
885 let args_node = dict_entry(entries, "args").ok_or_else(|| {
886 format!("@test case `{name}` in `{pipeline_name}` is missing list field `args`")
887 })?;
888 let Node::ListLiteral(args) = &args_node.node else {
889 return Err(format!(
890 "@test case `{name}` in `{pipeline_name}` must provide `args` as a list"
891 ));
892 };
893 if args.len() != parameter_count {
894 return Err(format!(
895 "@test case `{name}` in `{pipeline_name}` has {} arguments; expected {parameter_count}",
896 args.len()
897 ));
898 }
899 let args = args
900 .iter()
901 .map(attribute_value)
902 .collect::<Result<Vec<_>, _>>()?;
903 rows.push(ParameterizedRow { name, args });
904 }
905 Ok(rows)
906}
907
908fn dict_entry<'a>(entries: &'a [harn_parser::DictEntry], key: &str) -> Option<&'a SNode> {
909 entries.iter().find_map(|entry| {
910 let matches = match &entry.key.node {
911 Node::Identifier(value) | Node::StringLiteral(value) => value == key,
912 _ => false,
913 };
914 matches.then_some(&entry.value)
915 })
916}
917
918fn attribute_value(node: &SNode) -> Result<VmValue, String> {
919 let value = const_eval(node, &ConstEnv::new())
920 .map_err(|error| format!("@test case arguments must be compile-time values: {error:?}"))?;
921 Ok(const_value_to_vm(value))
922}
923
924fn const_value_to_vm(value: ConstValue) -> VmValue {
925 match value {
926 ConstValue::Int(value) => VmValue::Int(value),
927 ConstValue::Float(value) => VmValue::Float(value),
928 ConstValue::Bool(value) => VmValue::Bool(value),
929 ConstValue::String(value) => VmValue::String(value.into()),
930 ConstValue::Nil => VmValue::Nil,
931 ConstValue::List(items) => {
932 VmValue::List(Arc::new(items.into_iter().map(const_value_to_vm).collect()))
933 }
934 ConstValue::Dict(entries) => VmValue::dict(
935 entries
936 .into_iter()
937 .map(|(key, value)| (key, const_value_to_vm(value)))
938 .collect::<Vec<(String, VmValue)>>(),
939 ),
940 }
941}
942
943fn serial_group_for(attr: &Attribute) -> String {
944 attr.string_arg("group")
945 .unwrap_or_else(|| "__default__".to_string())
946}
947
948fn heavy_weight_for(attr: &Attribute) -> Option<usize> {
949 attr.args
950 .iter()
951 .find(|a| a.name.as_deref() == Some("threads"))
952 .and_then(|a| match &a.value.node {
953 Node::IntLiteral(n) if *n >= 1 => Some(*n as usize),
954 _ => None,
955 })
956}
957
958fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
959 cases.sort_by(|a, b| {
965 let key_a = timings_key(&a.file, &a.name);
966 let key_b = timings_key(&b.file, &b.name);
967 let dur_a = timings.get(&key_a).copied().unwrap_or(0);
968 let dur_b = timings.get(&key_b).copied().unwrap_or(0);
969 dur_a
970 .cmp(&dur_b)
971 .then_with(|| a.file.cmp(&b.file))
972 .then_with(|| a.name.cmp(&b.name))
973 });
974}
975
976fn select_shard_cases(
977 cases: Vec<TestCase>,
978 timings: &BTreeMap<String, u64>,
979 shard: TestShard,
980) -> Vec<TestCase> {
981 if shard.total() <= 1 {
982 return cases;
983 }
984
985 let mut ranked = cases.into_iter().collect::<Vec<_>>();
986 ranked.sort_by(|a, b| {
987 estimated_case_cost_ms(b, timings)
988 .cmp(&estimated_case_cost_ms(a, timings))
989 .then_with(|| a.file.cmp(&b.file))
990 .then_with(|| a.name.cmp(&b.name))
991 });
992
993 let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
994 let mut costs = vec![0u64; shard.total()];
995 let mut counts = vec![0usize; shard.total()];
996
997 for case in ranked {
998 let bucket_index = (0..shard.total())
999 .min_by_key(|&index| (costs[index], counts[index], index))
1000 .unwrap_or(0);
1001 costs[bucket_index] =
1002 costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
1003 counts[bucket_index] += 1;
1004 buckets[bucket_index].push(case);
1005 }
1006
1007 buckets.swap_remove(shard.index() - 1)
1008}
1009
1010fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
1011 timings
1012 .get(&timings_key(&case.file, &case.name))
1013 .copied()
1014 .unwrap_or(case.weight as u64)
1015 .max(1)
1016}
1017
1018fn count_files_with_cases(cases: &[TestCase]) -> usize {
1019 let mut files = HashSet::new();
1020 for case in cases {
1021 files.insert(case.file.as_path());
1022 }
1023 files.len()
1024}
1025
1026fn timings_key(file: &Path, name: &str) -> String {
1027 format!("{}::{}", file.display(), name)
1028}
1029
1030fn timings_cache_path(target: &Path) -> Option<PathBuf> {
1031 let probe_root = if target.is_dir() {
1036 target.to_path_buf()
1037 } else {
1038 target.parent()?.to_path_buf()
1039 };
1040 let root = harn_vm::stdlib::process::find_project_root(&probe_root)
1041 .unwrap_or_else(|| probe_root.clone());
1042 Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
1043}
1044
1045fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
1046 let Ok(contents) = fs::read_to_string(path) else {
1047 return BTreeMap::new();
1048 };
1049 serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
1050}
1051
1052fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
1053 for result in results {
1054 existing.insert(
1055 timings_key(Path::new(&result.file), &result.name),
1056 result.duration_ms,
1057 );
1058 }
1059 if let Some(parent) = path.parent() {
1060 let _ = fs::create_dir_all(parent);
1061 }
1062 if let Ok(serialized) = serde_json::to_string(&existing) {
1063 let _ = fs::write(path, serialized);
1064 }
1065}
1066
1067#[derive(Default)]
1068struct CaseExecutionResults {
1069 cases: Vec<TestResult>,
1070 infrastructure_errors: Vec<TestResult>,
1071}
1072
1073async fn execute_cases(
1074 cases: Vec<TestCase>,
1075 workers: usize,
1076 options: &RunOptions,
1077 total_tests: usize,
1078 session: &TestRunSession,
1079 skill_contexts: PreparedSkillContexts,
1080 operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
1081) -> CaseExecutionResults {
1082 if cases.is_empty() {
1083 return CaseExecutionResults::default();
1084 }
1085 let completed = Arc::new(Mutex::new(0usize));
1086 if workers <= 1 {
1087 let prepared_module_cache = session.prepared_module_cache(0);
1088 let mut results = Vec::with_capacity(cases.len());
1089 for case in cases {
1090 let loaded_skills = skill_contexts.for_case(&case);
1091 let cwd = case_execution_cwd(&case);
1092 let test_index = next_test_index(&completed);
1093 emit_progress(
1094 &options.progress,
1095 TestRunEvent::TestStarted {
1096 name: case.name.clone(),
1097 file: case.file.display().to_string(),
1098 test_index,
1099 total_tests,
1100 },
1101 );
1102 let result = execute_case(
1103 &case,
1104 &cwd,
1105 options.timeout_ms,
1106 loaded_skills,
1107 &prepared_module_cache,
1108 session.stdio_available(),
1109 operator_approval_grant,
1110 )
1111 .await;
1112 let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
1113 if options.diagnose {
1114 result.emit_diagnose();
1115 }
1116 emit_progress(
1117 &options.progress,
1118 TestRunEvent::TestFinished(result.clone()),
1119 );
1120 results.push(result);
1121 if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
1122 break;
1123 }
1124 }
1125 return CaseExecutionResults {
1126 cases: results,
1127 infrastructure_errors: Vec::new(),
1128 };
1129 }
1130
1131 let queue = Arc::new(Mutex::new(cases));
1132 let skill_contexts = Arc::new(skill_contexts);
1133 let gate = Arc::new(ResourceGate::new(workers));
1134 let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1135 let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1136 let cancelled = Arc::new(AtomicBool::new(false));
1137
1138 let mut handles = Vec::with_capacity(workers);
1139 for worker_idx in 0..workers {
1140 let queue = Arc::clone(&queue);
1141 let skill_contexts = Arc::clone(&skill_contexts);
1142 let gate = Arc::clone(&gate);
1143 let results = Arc::clone(&results);
1144 let infrastructure_errors = Arc::clone(&infrastructure_errors);
1145 let completed = Arc::clone(&completed);
1146 let timeout_ms = options.timeout_ms;
1147 let max_test_ms = options.max_test_ms;
1148 let max_execute_ms = options.max_execute_ms;
1149 let progress = options.progress.clone();
1150 let diagnose = options.diagnose;
1151 let fail_fast = options.fail_fast;
1152 let cancelled = Arc::clone(&cancelled);
1153 let prepared_module_cache = session.prepared_module_cache(worker_idx);
1154 let stdio_available = session.stdio_available();
1155 let operator_approval_grant = operator_approval_grant.cloned();
1156 let handle = thread::Builder::new()
1157 .name(format!("harn-test-worker-{worker_idx}"))
1158 .stack_size(CLI_RUNTIME_STACK_SIZE)
1159 .spawn(move || {
1160 let runtime = match tokio::runtime::Builder::new_current_thread()
1161 .enable_all()
1162 .build()
1163 {
1164 Ok(rt) => rt,
1165 Err(error) => {
1166 infrastructure_errors.lock().unwrap().push(TestResult {
1167 name: "<worker error>".to_string(),
1168 file: String::new(),
1169 passed: false,
1170 error: Some(format!("failed to start test runtime: {error}")),
1171 captured_output: None,
1172 timeout: None,
1173 duration_ms: 0,
1174 phases: None,
1175 });
1176 return;
1177 }
1178 };
1179 loop {
1184 let case = claim_next_case(&queue, &cancelled, fail_fast);
1185 let Some(case) = case else { break };
1186 let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1187 let cwd = case_execution_cwd(&case);
1188 let loaded_skills = skill_contexts.for_case(&case);
1189 let test_index = next_test_index(&completed);
1190 emit_progress(
1191 &progress,
1192 TestRunEvent::TestStarted {
1193 name: case.name.clone(),
1194 file: case.file.display().to_string(),
1195 test_index,
1196 total_tests,
1197 },
1198 );
1199 let result = runtime.block_on(execute_case(
1200 &case,
1201 &cwd,
1202 timeout_ms,
1203 loaded_skills,
1204 &prepared_module_cache,
1205 stdio_available,
1206 operator_approval_grant.as_ref(),
1207 ));
1208 let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1209 if fail_fast && !result.passed {
1210 cancelled.store(true, Ordering::Release);
1211 }
1212 if diagnose {
1213 result.emit_diagnose();
1214 }
1215 emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1216 results.lock().unwrap().push(result);
1217 }
1218 })
1219 .expect("spawning a harn-test worker thread should succeed");
1220 handles.push(handle);
1221 }
1222
1223 for handle in handles {
1224 let _ = handle.join();
1225 }
1226
1227 let cases = Arc::try_unwrap(results)
1231 .map(|m| m.into_inner().unwrap_or_default())
1232 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1233 let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1234 .map(|mutex| mutex.into_inner().unwrap_or_default())
1235 .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1236 CaseExecutionResults {
1237 cases,
1238 infrastructure_errors,
1239 }
1240}
1241
1242fn claim_next_case(
1243 queue: &Mutex<Vec<TestCase>>,
1244 cancelled: &AtomicBool,
1245 fail_fast: bool,
1246) -> Option<TestCase> {
1247 let mut queue = queue.lock().unwrap();
1248 if fail_fast && cancelled.load(Ordering::Acquire) {
1249 None
1250 } else {
1251 queue.pop()
1252 }
1253}
1254
1255fn enforce_case_budgets(
1256 mut result: TestResult,
1257 max_test_ms: Option<u64>,
1258 max_execute_ms: Option<u64>,
1259) -> TestResult {
1260 if !result.passed {
1261 return result;
1262 }
1263
1264 let phases = result
1265 .phases
1266 .expect("passed test results always carry measured phases");
1267 let mut violations = Vec::new();
1268 if let Some(max_ms) = max_test_ms {
1269 if result.duration_ms > max_ms {
1270 violations.push(format!(
1271 "exceeded test wall-clock budget: {}ms > {}ms",
1272 result.duration_ms, max_ms
1273 ));
1274 }
1275 }
1276 if let Some(max_ms) = max_execute_ms {
1277 if phases.execute_ms > max_ms {
1278 violations.push(format!(
1279 "exceeded test execute budget: {}ms > {}ms",
1280 phases.execute_ms, max_ms
1281 ));
1282 }
1283 }
1284
1285 if violations.is_empty() {
1286 return result;
1287 }
1288
1289 violations.push(format!(
1290 "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1291 phases.setup_ms,
1292 phases.compile_ms,
1293 phases.execute_ms,
1294 phases.teardown_ms,
1295 result.duration_ms
1296 ));
1297 result.passed = false;
1298 result.error = Some(violations.join("\n"));
1299 result
1300}
1301
1302fn next_test_index(counter: &Mutex<usize>) -> usize {
1303 let mut guard = counter.lock().unwrap();
1304 *guard += 1;
1305 *guard
1306}
1307
1308fn case_execution_cwd(case: &TestCase) -> PathBuf {
1309 case.file
1310 .parent()
1311 .filter(|p| !p.as_os_str().is_empty())
1312 .map(Path::to_path_buf)
1313 .unwrap_or_else(test_execution_cwd)
1314}
1315
1316struct ResourceGate {
1320 state: Mutex<GateState>,
1321 cond: Condvar,
1322 capacity: usize,
1323}
1324
1325struct GateState {
1326 available: usize,
1327 busy_groups: HashSet<String>,
1328}
1329
1330struct GateGuard<'a> {
1331 gate: &'a ResourceGate,
1332 weight: usize,
1333 group: Option<String>,
1334}
1335
1336impl ResourceGate {
1337 fn new(capacity: usize) -> Self {
1338 Self {
1339 state: Mutex::new(GateState {
1340 available: capacity,
1341 busy_groups: HashSet::new(),
1342 }),
1343 cond: Condvar::new(),
1344 capacity,
1345 }
1346 }
1347
1348 fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1349 let weight = weight.min(self.capacity).max(1);
1350 let mut state = self.state.lock().unwrap();
1351 loop {
1352 if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1353 return guard;
1354 }
1355 state = self.cond.wait(state).unwrap();
1356 }
1357 }
1358
1359 fn try_grab_locked<'a>(
1363 &'a self,
1364 state: &mut GateState,
1365 weight: usize,
1366 group: Option<&str>,
1367 ) -> Option<GateGuard<'a>> {
1368 let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1369 if state.available >= weight && group_free {
1370 state.available -= weight;
1371 if let Some(g) = group {
1372 state.busy_groups.insert(g.to_string());
1373 }
1374 return Some(GateGuard {
1375 gate: self,
1376 weight,
1377 group: group.map(str::to_owned),
1378 });
1379 }
1380 None
1381 }
1382
1383 #[cfg(test)]
1386 fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1387 let weight = weight.min(self.capacity).max(1);
1388 let mut state = self.state.lock().unwrap();
1389 self.try_grab_locked(&mut state, weight, group)
1390 }
1391}
1392
1393impl Drop for GateGuard<'_> {
1394 fn drop(&mut self) {
1395 let mut state = self.gate.state.lock().unwrap();
1396 state.available += self.weight;
1397 if let Some(group) = self.group.as_deref() {
1398 state.busy_groups.remove(group);
1399 }
1400 self.gate.cond.notify_all();
1401 }
1402}
1403
1404fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1405 let mut files = Vec::new();
1406 if let Ok(entries) = fs::read_dir(dir) {
1407 for entry in entries.flatten() {
1408 let path = entry.path();
1409 if path.is_dir() {
1410 files.extend(discover_test_files(&path));
1411 } else if path.extension().is_some_and(|e| e == "harn") {
1412 if let Ok(content) = fs::read_to_string(&path) {
1413 if content.contains("test_") || content.contains("@test") {
1414 files.push(canonicalize_existing_path(&path));
1415 }
1416 }
1417 }
1418 }
1419 }
1420 files.sort();
1421 files
1422}