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