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