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