1use std::collections::HashMap;
10use std::ffi::OsStr;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::process::{Child, Command, ExitStatus, Stdio};
14use std::sync::{Arc, Mutex};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use super::logging::{
22 LogLevel, LogSource, RELIABILITY_EVENT_SCHEMA_VERSION, ReliabilityContext,
23 ReliabilityEventInput, ReliabilityPhase, TestLogger, TestLoggerBuilder,
24};
25
26#[derive(Debug, thiserror::Error)]
28pub enum HarnessError {
29 #[error("Process failed to start: {0}")]
30 ProcessStartFailed(String),
31
32 #[error("Process exited with non-zero status: {0}")]
33 ProcessFailed(i32),
34
35 #[error("Process timed out after {0:?}")]
36 Timeout(Duration),
37
38 #[error("Process not found: {0}")]
39 ProcessNotFound(String),
40
41 #[error("IO error: {0}")]
42 Io(#[from] std::io::Error),
43
44 #[error("Assertion failed: {0}")]
45 AssertionFailed(String),
46
47 #[error("Setup failed: {0}")]
48 SetupFailed(String),
49
50 #[error("Cleanup failed: {0}")]
51 CleanupFailed(String),
52}
53
54pub type HarnessResult<T> = Result<T, HarnessError>;
56
57#[derive(Debug)]
59pub struct ProcessInfo {
60 pub name: String,
61 pub pid: u32,
62 pub started_at: Instant,
63 child: Child,
64}
65
66impl ProcessInfo {
67 pub fn is_running(&mut self) -> bool {
69 matches!(self.child.try_wait(), Ok(None))
70 }
71
72 pub fn try_exit_status(&mut self) -> Option<ExitStatus> {
74 self.child.try_wait().ok().flatten()
75 }
76
77 pub fn kill(&mut self) -> std::io::Result<()> {
79 self.child.kill()
80 }
81
82 pub fn wait(&mut self) -> std::io::Result<ExitStatus> {
84 self.child.wait()
85 }
86
87 pub fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
89 self.child.stdout.take()
90 }
91
92 pub fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
94 self.child.stderr.take()
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct CommandResult {
101 pub exit_code: i32,
102 pub stdout: String,
103 pub stderr: String,
104 pub duration: Duration,
105}
106
107impl CommandResult {
108 pub fn success(&self) -> bool {
110 self.exit_code == 0
111 }
112
113 pub fn stdout_contains(&self, pattern: &str) -> bool {
115 self.stdout.contains(pattern)
116 }
117
118 pub fn stderr_contains(&self, pattern: &str) -> bool {
120 self.stderr.contains(pattern)
121 }
122
123 pub fn combined_output(&self) -> String {
125 format!("{}\n{}", self.stdout, self.stderr)
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct HarnessConfig {
132 pub temp_dir: PathBuf,
134 pub default_timeout: Duration,
136 pub cleanup_on_success: bool,
138 pub cleanup_on_failure: bool,
140 pub rch_binary: PathBuf,
142 pub rchd_binary: PathBuf,
144 pub rch_wkr_binary: PathBuf,
146 pub env_vars: HashMap<String, String>,
148}
149
150impl Default for HarnessConfig {
151 fn default() -> Self {
152 fn cargo_bin_exe(candidates: &[&str]) -> Option<PathBuf> {
153 for candidate in candidates {
154 let key = format!("CARGO_BIN_EXE_{candidate}");
155 if let Ok(value) = std::env::var(&key) {
156 let trimmed = value.trim();
157 if !trimmed.is_empty() {
158 return Some(PathBuf::from(trimmed));
159 }
160 }
161 }
162 None
163 }
164
165 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
170 .map(PathBuf::from)
171 .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")));
172 let manifest_dir = manifest_dir.canonicalize().unwrap_or(manifest_dir);
173 let workspace_root = manifest_dir
174 .parent()
175 .unwrap_or(manifest_dir.as_path())
176 .to_path_buf();
177 let cargo_target = std::env::var("CARGO_TARGET_DIR")
178 .map(PathBuf::from)
179 .unwrap_or_else(|_| workspace_root.join("target"));
180 let cargo_target = if cargo_target.is_absolute() {
181 cargo_target
182 } else {
183 workspace_root.join(cargo_target)
184 };
185
186 let profile = if cfg!(debug_assertions) {
187 "debug"
188 } else {
189 "release"
190 };
191
192 let default_bin_dir = cargo_target.join(profile);
193 let bin_dir = if std::env::var("LLVM_PROFILE_FILE")
194 .map(|value| !value.trim().is_empty())
195 .unwrap_or(false)
196 {
197 let llvm_cov_dir = cargo_target.join("llvm-cov-target").join(profile);
198 if llvm_cov_dir.is_dir() {
199 llvm_cov_dir
200 } else {
201 default_bin_dir.clone()
202 }
203 } else {
204 default_bin_dir.clone()
205 };
206
207 let mut env_vars = HashMap::new();
208 if std::env::var("CI")
209 .map(|v| v == "1" || v.to_lowercase() == "true")
210 .unwrap_or(false)
211 {
212 env_vars.insert("RCH_MOCK_SSH".to_string(), "1".to_string());
213 }
214
215 Self {
216 temp_dir: std::env::temp_dir().join("rch_e2e_tests"),
217 default_timeout: Duration::from_secs(30),
218 cleanup_on_success: true,
219 cleanup_on_failure: false,
220 rch_binary: cargo_bin_exe(&["rch"])
221 .map(|path| {
222 if path.is_relative() {
223 workspace_root.join(path)
224 } else {
225 path
226 }
227 })
228 .unwrap_or_else(|| bin_dir.join("rch")),
229 rchd_binary: cargo_bin_exe(&["rchd"])
230 .map(|path| {
231 if path.is_relative() {
232 workspace_root.join(path)
233 } else {
234 path
235 }
236 })
237 .unwrap_or_else(|| bin_dir.join("rchd")),
238 rch_wkr_binary: cargo_bin_exe(&["rch-wkr", "rch_wkr"])
239 .map(|path| {
240 if path.is_relative() {
241 workspace_root.join(path)
242 } else {
243 path
244 }
245 })
246 .unwrap_or_else(|| bin_dir.join("rch-wkr")),
247 env_vars,
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum ReliabilityFailureHook {
256 NetworkCut,
257 SyncTimeout,
258 PartialUpdate,
259 DaemonRestart,
260}
261
262impl std::fmt::Display for ReliabilityFailureHook {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 let label = match self {
265 Self::NetworkCut => "network_cut",
266 Self::SyncTimeout => "sync_timeout",
267 Self::PartialUpdate => "partial_update",
268 Self::DaemonRestart => "daemon_restart",
269 };
270 write!(f, "{label}")
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
276pub struct ReliabilityFailureHookFlags {
277 pub allow_network_cut: bool,
278 pub allow_sync_timeout: bool,
279 pub allow_partial_update: bool,
280 pub allow_daemon_restart: bool,
281}
282
283impl ReliabilityFailureHookFlags {
284 pub fn allow_all() -> Self {
286 Self {
287 allow_network_cut: true,
288 allow_sync_timeout: true,
289 allow_partial_update: true,
290 allow_daemon_restart: true,
291 }
292 }
293
294 pub fn allows(&self, hook: ReliabilityFailureHook) -> bool {
296 match hook {
297 ReliabilityFailureHook::NetworkCut => self.allow_network_cut,
298 ReliabilityFailureHook::SyncTimeout => self.allow_sync_timeout,
299 ReliabilityFailureHook::PartialUpdate => self.allow_partial_update,
300 ReliabilityFailureHook::DaemonRestart => self.allow_daemon_restart,
301 }
302 }
303}
304
305fn default_required_success() -> bool {
306 true
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct ReliabilityLifecycleCommand {
312 pub name: String,
313 pub program: String,
314 pub args: Vec<String>,
315 pub timeout_secs: Option<u64>,
316 #[serde(default = "default_required_success")]
317 pub required_success: bool,
318 #[serde(default)]
319 pub via_rch_exec: bool,
320}
321
322impl ReliabilityLifecycleCommand {
323 pub fn new(
325 name: impl Into<String>,
326 program: impl Into<String>,
327 args: impl IntoIterator<Item = impl Into<String>>,
328 ) -> Self {
329 Self {
330 name: name.into(),
331 program: program.into(),
332 args: args.into_iter().map(Into::into).collect(),
333 timeout_secs: None,
334 required_success: true,
335 via_rch_exec: false,
336 }
337 }
338
339 pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
341 self.timeout_secs = Some(timeout_secs);
342 self
343 }
344
345 pub fn optional(mut self) -> Self {
347 self.required_success = false;
348 self
349 }
350
351 pub fn via_rch_exec(mut self) -> Self {
353 self.via_rch_exec = true;
354 self
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
360pub struct ReliabilityWorkerLifecycleHooks {
361 pub pre_checks: Vec<ReliabilityLifecycleCommand>,
362 pub remote_probes: Vec<ReliabilityLifecycleCommand>,
363 pub post_checks: Vec<ReliabilityLifecycleCommand>,
364 pub cleanup_verification: Vec<ReliabilityLifecycleCommand>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct ReliabilityScenarioSpec {
370 pub scenario_id: String,
371 pub worker_id: Option<String>,
372 pub repo_set: Vec<String>,
373 pub pressure_state: Option<String>,
374 pub triage_actions: Vec<String>,
375 pub lifecycle: ReliabilityWorkerLifecycleHooks,
376 pub execute_commands: Vec<ReliabilityLifecycleCommand>,
377 pub requested_failure_hooks: Vec<ReliabilityFailureHook>,
378 pub failure_hook_flags: ReliabilityFailureHookFlags,
379}
380
381impl ReliabilityScenarioSpec {
382 pub fn new(scenario_id: impl Into<String>) -> Self {
384 Self {
385 scenario_id: scenario_id.into(),
386 worker_id: None,
387 repo_set: Vec::new(),
388 pressure_state: None,
389 triage_actions: Vec::new(),
390 lifecycle: ReliabilityWorkerLifecycleHooks::default(),
391 execute_commands: Vec::new(),
392 requested_failure_hooks: Vec::new(),
393 failure_hook_flags: ReliabilityFailureHookFlags::default(),
394 }
395 }
396
397 pub fn with_worker_id(mut self, worker_id: impl Into<String>) -> Self {
398 self.worker_id = Some(worker_id.into());
399 self
400 }
401
402 pub fn with_repo_set(mut self, repos: impl IntoIterator<Item = impl Into<String>>) -> Self {
403 self.repo_set = repos.into_iter().map(Into::into).collect();
404 self
405 }
406
407 pub fn with_pressure_state(mut self, pressure_state: impl Into<String>) -> Self {
408 self.pressure_state = Some(pressure_state.into());
409 self
410 }
411
412 pub fn add_triage_action(mut self, action: impl Into<String>) -> Self {
413 self.triage_actions.push(action.into());
414 self
415 }
416
417 pub fn add_pre_check(mut self, command: ReliabilityLifecycleCommand) -> Self {
418 self.lifecycle.pre_checks.push(command);
419 self
420 }
421
422 pub fn add_remote_probe(mut self, command: ReliabilityLifecycleCommand) -> Self {
423 self.lifecycle.remote_probes.push(command);
424 self
425 }
426
427 pub fn add_post_check(mut self, command: ReliabilityLifecycleCommand) -> Self {
428 self.lifecycle.post_checks.push(command);
429 self
430 }
431
432 pub fn add_cleanup_verification(mut self, command: ReliabilityLifecycleCommand) -> Self {
433 self.lifecycle.cleanup_verification.push(command);
434 self
435 }
436
437 pub fn add_execute_command(mut self, command: ReliabilityLifecycleCommand) -> Self {
438 self.execute_commands.push(command);
439 self
440 }
441
442 pub fn request_failure_hook(mut self, hook: ReliabilityFailureHook) -> Self {
443 self.requested_failure_hooks.push(hook);
444 self
445 }
446
447 pub fn with_failure_hook_flags(mut self, flags: ReliabilityFailureHookFlags) -> Self {
448 self.failure_hook_flags = flags;
449 self
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct ReliabilityCommandRecord {
456 pub phase: ReliabilityPhase,
457 pub stage: String,
458 pub command_name: String,
459 pub invoked_program: String,
460 pub invoked_args: Vec<String>,
461 pub exit_code: i32,
462 pub duration_ms: u64,
463 pub required_success: bool,
464 pub succeeded: bool,
465 pub artifact_paths: Vec<String>,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct ReliabilityScenarioReport {
471 pub schema_version: String,
472 pub scenario_id: String,
473 pub phase_order: Vec<ReliabilityPhase>,
474 pub activated_failure_hooks: Vec<ReliabilityFailureHook>,
475 pub command_records: Vec<ReliabilityCommandRecord>,
476 pub artifact_paths: Vec<String>,
477 pub manifest_path: Option<PathBuf>,
478}
479
480impl ReliabilityScenarioReport {
481 fn new(scenario_id: &str) -> Self {
482 Self {
483 schema_version: RELIABILITY_EVENT_SCHEMA_VERSION.to_string(),
484 scenario_id: scenario_id.to_string(),
485 phase_order: Vec::new(),
486 activated_failure_hooks: Vec::new(),
487 command_records: Vec::new(),
488 artifact_paths: Vec::new(),
489 manifest_path: None,
490 }
491 }
492}
493
494pub fn cleanup_stale_test_artifacts(base_dir: &Path, max_age: Duration) {
503 if !base_dir.exists() {
504 return;
505 }
506
507 let now = std::time::SystemTime::now();
508 let mut cleaned_sockets = 0;
509 let mut cleaned_dirs = 0;
510
511 if let Ok(entries) = std::fs::read_dir(base_dir) {
513 for entry in entries.flatten() {
514 let path = entry.path();
515 if !path.is_dir() {
516 continue;
517 }
518
519 let is_stale = entry
521 .metadata()
522 .ok()
523 .and_then(|m| m.modified().ok())
524 .map(|modified| {
525 now.duration_since(modified)
526 .map(|age| age > max_age)
527 .unwrap_or(false)
528 })
529 .unwrap_or(false);
530
531 if is_stale {
533 if let Ok(dir_entries) = std::fs::read_dir(&path) {
535 for file_entry in dir_entries.flatten() {
536 let file_path = file_entry.path();
537 if file_path.extension().is_some_and(|e| e == "sock")
538 && std::fs::remove_file(&file_path).is_ok()
539 {
540 cleaned_sockets += 1;
541 }
542 }
543 }
544
545 if std::fs::remove_dir_all(&path).is_ok() {
547 cleaned_dirs += 1;
548 }
549 }
550 }
551 }
552
553 if cleaned_sockets > 0 || cleaned_dirs > 0 {
554 eprintln!(
555 "[e2e::harness] Pre-test cleanup: removed {} stale sockets, {} stale directories",
556 cleaned_sockets, cleaned_dirs
557 );
558 }
559}
560
561pub struct TestHarness {
563 pub config: HarnessConfig,
564 pub logger: TestLogger,
565 test_dir: PathBuf,
566 managed_processes: Arc<Mutex<HashMap<String, ProcessInfo>>>,
567 created_files: Arc<Mutex<Vec<PathBuf>>>,
568 created_dirs: Arc<Mutex<Vec<PathBuf>>>,
569 test_passed: Arc<Mutex<bool>>,
570}
571
572impl TestHarness {
573 pub fn new(test_name: &str, config: HarnessConfig) -> HarnessResult<Self> {
575 cleanup_stale_test_artifacts(&config.temp_dir, Duration::from_secs(3600));
577
578 let instance_id = Uuid::new_v4().simple().to_string();
585 let test_dir = config.temp_dir.join(&instance_id[..12]);
586
587 std::fs::create_dir_all(&test_dir)?;
588
589 let logger = TestLoggerBuilder::new(test_name)
591 .log_dir(test_dir.join("logs"))
592 .build();
593
594 logger.info(format!("Test harness initialized: {test_name}"));
595 logger.debug(format!("Test directory: {}", test_dir.display()));
596 logger.log_reliability_event(ReliabilityEventInput {
597 level: LogLevel::Info,
598 phase: ReliabilityPhase::Setup,
599 scenario_id: test_name.to_string(),
600 message: "Test harness initialized".to_string(),
601 context: ReliabilityContext {
602 worker_id: None,
603 repo_set: vec![test_dir.display().to_string()],
604 pressure_state: None,
605 triage_actions: Vec::new(),
606 decision_code: "HARNESS_INIT".to_string(),
607 fallback_reason: None,
608 },
609 artifact_paths: Vec::new(),
610 });
611
612 Ok(Self {
613 config,
614 logger,
615 test_dir,
616 managed_processes: Arc::new(Mutex::new(HashMap::new())),
617 created_files: Arc::new(Mutex::new(Vec::new())),
618 created_dirs: Arc::new(Mutex::new(vec![])),
619 test_passed: Arc::new(Mutex::new(false)),
620 })
621 }
622
623 pub fn default_for_test(test_name: &str) -> HarnessResult<Self> {
625 Self::new(test_name, HarnessConfig::default())
626 }
627
628 pub fn test_dir(&self) -> &Path {
630 &self.test_dir
631 }
632
633 pub fn run_reliability_scenario(
639 &self,
640 scenario: &ReliabilityScenarioSpec,
641 ) -> HarnessResult<ReliabilityScenarioReport> {
642 let mut report = ReliabilityScenarioReport::new(&scenario.scenario_id);
643 let mut triage_actions = scenario.triage_actions.clone();
644
645 report.phase_order.push(ReliabilityPhase::Setup);
646 self.log_scenario_event(
647 scenario,
648 ReliabilityPhase::Setup,
649 LogLevel::Info,
650 "Reliability setup phase started",
651 "SCENARIO_SETUP_START",
652 &triage_actions,
653 None,
654 Vec::new(),
655 );
656
657 let setup_result: HarnessResult<()> = (|| {
658 report.activated_failure_hooks =
659 self.activate_failure_hooks(scenario, &mut report, &mut triage_actions)?;
660
661 self.run_phase_lifecycle_commands(
662 scenario,
663 ReliabilityPhase::Setup,
664 "pre_checks",
665 &scenario.lifecycle.pre_checks,
666 &mut report,
667 &mut triage_actions,
668 )
669 })();
670
671 if let Err(error) = setup_result.as_ref() {
672 self.log_scenario_event(
673 scenario,
674 ReliabilityPhase::Setup,
675 LogLevel::Error,
676 format!("Reliability setup phase failed: {error}"),
677 "SCENARIO_SETUP_FAIL",
678 &triage_actions,
679 Some(error.to_string()),
680 Vec::new(),
681 );
682 } else {
683 self.log_scenario_event(
684 scenario,
685 ReliabilityPhase::Setup,
686 LogLevel::Info,
687 "Reliability setup phase completed",
688 "SCENARIO_SETUP_DONE",
689 &triage_actions,
690 None,
691 Vec::new(),
692 );
693 }
694
695 report.phase_order.push(ReliabilityPhase::Execute);
696 let execute_result: HarnessResult<()> = if setup_result.is_ok() {
697 self.log_scenario_event(
698 scenario,
699 ReliabilityPhase::Execute,
700 LogLevel::Info,
701 "Reliability execute phase started",
702 "SCENARIO_EXECUTE_START",
703 &triage_actions,
704 None,
705 Vec::new(),
706 );
707
708 let result = (|| {
709 self.run_phase_lifecycle_commands(
710 scenario,
711 ReliabilityPhase::Execute,
712 "execute",
713 &scenario.execute_commands,
714 &mut report,
715 &mut triage_actions,
716 )?;
717 self.run_phase_lifecycle_commands(
718 scenario,
719 ReliabilityPhase::Execute,
720 "remote_probes",
721 &scenario.lifecycle.remote_probes,
722 &mut report,
723 &mut triage_actions,
724 )
725 })();
726
727 if let Err(error) = result.as_ref() {
728 self.log_scenario_event(
729 scenario,
730 ReliabilityPhase::Execute,
731 LogLevel::Error,
732 format!("Reliability execute phase failed: {error}"),
733 "SCENARIO_EXECUTE_FAIL",
734 &triage_actions,
735 Some(error.to_string()),
736 Vec::new(),
737 );
738 } else {
739 self.log_scenario_event(
740 scenario,
741 ReliabilityPhase::Execute,
742 LogLevel::Info,
743 "Reliability execute phase completed",
744 "SCENARIO_EXECUTE_DONE",
745 &triage_actions,
746 None,
747 Vec::new(),
748 );
749 }
750
751 result
752 } else {
753 self.log_scenario_event(
754 scenario,
755 ReliabilityPhase::Execute,
756 LogLevel::Warn,
757 "Reliability execute phase skipped due setup failure",
758 "SCENARIO_EXECUTE_SKIPPED",
759 &triage_actions,
760 Some("setup phase failed".to_string()),
761 Vec::new(),
762 );
763 Ok(())
764 };
765
766 report.phase_order.push(ReliabilityPhase::Verify);
767 let verify_result: HarnessResult<()> = if setup_result.is_ok() && execute_result.is_ok() {
768 self.log_scenario_event(
769 scenario,
770 ReliabilityPhase::Verify,
771 LogLevel::Info,
772 "Reliability verify phase started",
773 "SCENARIO_VERIFY_START",
774 &triage_actions,
775 None,
776 Vec::new(),
777 );
778
779 let result = self.run_phase_lifecycle_commands(
780 scenario,
781 ReliabilityPhase::Verify,
782 "post_checks",
783 &scenario.lifecycle.post_checks,
784 &mut report,
785 &mut triage_actions,
786 );
787
788 if let Err(error) = result.as_ref() {
789 self.log_scenario_event(
790 scenario,
791 ReliabilityPhase::Verify,
792 LogLevel::Error,
793 format!("Reliability verify phase failed: {error}"),
794 "SCENARIO_VERIFY_FAIL",
795 &triage_actions,
796 Some(error.to_string()),
797 Vec::new(),
798 );
799 } else {
800 self.log_scenario_event(
801 scenario,
802 ReliabilityPhase::Verify,
803 LogLevel::Info,
804 "Reliability verify phase completed",
805 "SCENARIO_VERIFY_DONE",
806 &triage_actions,
807 None,
808 Vec::new(),
809 );
810 }
811
812 result
813 } else {
814 self.log_scenario_event(
815 scenario,
816 ReliabilityPhase::Verify,
817 LogLevel::Warn,
818 "Reliability verify phase skipped due earlier failure",
819 "SCENARIO_VERIFY_SKIPPED",
820 &triage_actions,
821 Some("setup or execute phase failed".to_string()),
822 Vec::new(),
823 );
824 Ok(())
825 };
826
827 report.phase_order.push(ReliabilityPhase::Cleanup);
828 self.log_scenario_event(
829 scenario,
830 ReliabilityPhase::Cleanup,
831 LogLevel::Info,
832 "Reliability cleanup phase started",
833 "SCENARIO_CLEANUP_START",
834 &triage_actions,
835 None,
836 Vec::new(),
837 );
838
839 let cleanup_result = self.run_phase_lifecycle_commands(
840 scenario,
841 ReliabilityPhase::Cleanup,
842 "cleanup_verification",
843 &scenario.lifecycle.cleanup_verification,
844 &mut report,
845 &mut triage_actions,
846 );
847
848 let manifest_payload = serde_json::json!({
849 "schema_version": report.schema_version,
850 "scenario_id": report.scenario_id,
851 "phase_order": report.phase_order.iter().map(ToString::to_string).collect::<Vec<_>>(),
852 "activated_failure_hooks": report
853 .activated_failure_hooks
854 .iter()
855 .map(ToString::to_string)
856 .collect::<Vec<_>>(),
857 "command_records": report.command_records,
858 "artifact_paths": report.artifact_paths,
859 });
860 let mut cleanup_artifacts = Vec::new();
861 if let Ok(path) = self.logger.capture_artifact_json(
862 &scenario.scenario_id,
863 "scenario_artifact_index",
864 &manifest_payload,
865 ) {
866 let as_string = path.display().to_string();
867 report.manifest_path = Some(path);
868 Self::push_unique_string(&mut report.artifact_paths, as_string.clone());
869 cleanup_artifacts.push(as_string);
870 }
871
872 if let Err(error) = cleanup_result.as_ref() {
873 self.log_scenario_event(
874 scenario,
875 ReliabilityPhase::Cleanup,
876 LogLevel::Error,
877 format!("Reliability cleanup phase failed: {error}"),
878 "SCENARIO_CLEANUP_FAIL",
879 &triage_actions,
880 Some(error.to_string()),
881 cleanup_artifacts,
882 );
883 } else {
884 self.log_scenario_event(
885 scenario,
886 ReliabilityPhase::Cleanup,
887 LogLevel::Info,
888 "Reliability cleanup phase completed",
889 "SCENARIO_CLEANUP_DONE",
890 &triage_actions,
891 None,
892 cleanup_artifacts,
893 );
894 }
895
896 setup_result?;
897 execute_result?;
898 verify_result?;
899 cleanup_result?;
900
901 Ok(report)
902 }
903
904 fn run_phase_lifecycle_commands(
905 &self,
906 scenario: &ReliabilityScenarioSpec,
907 phase: ReliabilityPhase,
908 stage: &str,
909 commands: &[ReliabilityLifecycleCommand],
910 report: &mut ReliabilityScenarioReport,
911 triage_actions: &mut Vec<String>,
912 ) -> HarnessResult<()> {
913 for command in commands {
914 self.execute_lifecycle_command(
915 scenario,
916 phase,
917 stage,
918 command,
919 report,
920 triage_actions,
921 )?;
922 }
923 Ok(())
924 }
925
926 fn execute_lifecycle_command(
927 &self,
928 scenario: &ReliabilityScenarioSpec,
929 phase: ReliabilityPhase,
930 stage: &str,
931 command: &ReliabilityLifecycleCommand,
932 report: &mut ReliabilityScenarioReport,
933 triage_actions: &mut Vec<String>,
934 ) -> HarnessResult<()> {
935 let mut invoked_args = command.args.clone();
936 let invoked_program = if command.via_rch_exec {
937 let mut wrapped = vec![
938 "exec".to_string(),
939 "--".to_string(),
940 command.program.clone(),
941 ];
942 wrapped.extend(invoked_args);
943 invoked_args = wrapped;
944 "rch".to_string()
945 } else {
946 command.program.clone()
947 };
948
949 let timeout = command
950 .timeout_secs
951 .map(Duration::from_secs)
952 .unwrap_or(self.config.default_timeout);
953 let result = self.exec_with_timeout(
954 &invoked_program,
955 invoked_args.iter().map(String::as_str),
956 timeout,
957 )?;
958
959 let artifact_prefix = format!(
960 "{}_{}_{}",
961 Self::sanitize_artifact_component(stage),
962 Self::sanitize_artifact_component(&command.name),
963 chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f")
964 );
965 let mut command_artifacts = Vec::new();
966 let trace_payload = serde_json::json!({
967 "scenario_id": scenario.scenario_id,
968 "phase": phase.to_string(),
969 "stage": stage,
970 "command_name": command.name,
971 "invoked_program": invoked_program,
972 "invoked_args": invoked_args,
973 "required_success": command.required_success,
974 "exit_code": result.exit_code,
975 "duration_ms": result.duration.as_millis(),
976 });
977 if let Ok(path) = self.logger.capture_artifact_json(
978 &scenario.scenario_id,
979 &format!("{artifact_prefix}_trace"),
980 &trace_payload,
981 ) {
982 command_artifacts.push(path.display().to_string());
983 }
984 if !result.stdout.is_empty()
985 && let Ok(path) = self.logger.capture_artifact_text(
986 &scenario.scenario_id,
987 &format!("{artifact_prefix}_stdout"),
988 &result.stdout,
989 )
990 {
991 command_artifacts.push(path.display().to_string());
992 }
993 if !result.stderr.is_empty()
994 && let Ok(path) = self.logger.capture_artifact_text(
995 &scenario.scenario_id,
996 &format!("{artifact_prefix}_stderr"),
997 &result.stderr,
998 )
999 {
1000 command_artifacts.push(path.display().to_string());
1001 }
1002
1003 for artifact_path in &command_artifacts {
1004 Self::push_unique_string(&mut report.artifact_paths, artifact_path.clone());
1005 }
1006
1007 let mut event_triage = triage_actions.clone();
1008 if result.success() {
1009 Self::push_unique_string(
1010 &mut event_triage,
1011 format!("{}_{}_pass", stage, command.name),
1012 );
1013 } else {
1014 Self::push_unique_string(
1015 &mut event_triage,
1016 format!("{}_{}_fail", stage, command.name),
1017 );
1018 }
1019
1020 let decision_code = format!(
1021 "{}_{}",
1022 Self::sanitize_decision_token(stage),
1023 if result.success() { "PASS" } else { "FAIL" }
1024 );
1025
1026 self.log_scenario_event(
1027 scenario,
1028 phase,
1029 if result.success() {
1030 LogLevel::Info
1031 } else {
1032 LogLevel::Warn
1033 },
1034 format!(
1035 "Lifecycle command '{}' in stage '{}' finished with exit {}",
1036 command.name, stage, result.exit_code
1037 ),
1038 decision_code,
1039 &event_triage,
1040 if result.success() {
1041 None
1042 } else {
1043 Some(format!(
1044 "lifecycle command '{}' failed with exit {}",
1045 command.name, result.exit_code
1046 ))
1047 },
1048 command_artifacts.clone(),
1049 );
1050
1051 report.command_records.push(ReliabilityCommandRecord {
1052 phase,
1053 stage: stage.to_string(),
1054 command_name: command.name.clone(),
1055 invoked_program: invoked_program.clone(),
1056 invoked_args: invoked_args
1060 .iter()
1061 .map(|a| crate::redaction::redact_secrets(a))
1062 .collect(),
1063 exit_code: result.exit_code,
1064 duration_ms: result.duration.as_millis() as u64,
1065 required_success: command.required_success,
1066 succeeded: result.success(),
1067 artifact_paths: command_artifacts,
1068 });
1069
1070 if !result.success() && command.required_success {
1071 return Err(HarnessError::AssertionFailed(format!(
1072 "required lifecycle command '{}' failed in stage '{}' (exit={})",
1073 command.name, stage, result.exit_code
1074 )));
1075 }
1076 if !result.success() && !command.required_success {
1077 Self::push_unique_string(
1078 triage_actions,
1079 format!("optional_command_failed:{}:{}", stage, command.name),
1080 );
1081 }
1082
1083 Ok(())
1084 }
1085
1086 fn activate_failure_hooks(
1087 &self,
1088 scenario: &ReliabilityScenarioSpec,
1089 report: &mut ReliabilityScenarioReport,
1090 triage_actions: &mut Vec<String>,
1091 ) -> HarnessResult<Vec<ReliabilityFailureHook>> {
1092 let mut activated = Vec::new();
1093 if scenario.requested_failure_hooks.is_empty() {
1094 return Ok(activated);
1095 }
1096
1097 let marker_dir = self.test_dir.join(".reliability-hooks");
1098 std::fs::create_dir_all(&marker_dir)?;
1099
1100 for hook in &scenario.requested_failure_hooks {
1101 if !scenario.failure_hook_flags.allows(*hook) {
1102 self.log_scenario_event(
1103 scenario,
1104 ReliabilityPhase::Setup,
1105 LogLevel::Error,
1106 format!("Failure hook denied: {hook}"),
1107 "FAILURE_HOOK_DENIED",
1108 triage_actions,
1109 Some(format!("hook {hook} requested without explicit allow flag")),
1110 Vec::new(),
1111 );
1112 return Err(HarnessError::SetupFailed(format!(
1113 "failure hook '{hook}' requested but not enabled"
1114 )));
1115 }
1116
1117 let marker_path = marker_dir.join(format!("{hook}.enabled"));
1118 std::fs::write(
1119 &marker_path,
1120 format!(
1121 "scenario_id={}\nhook={hook}\narmed_at={}\n",
1122 scenario.scenario_id,
1123 chrono::Utc::now().to_rfc3339()
1124 ),
1125 )?;
1126
1127 let mut hook_artifacts = Vec::new();
1128 if let Ok(path) = self.logger.capture_artifact_text(
1129 &scenario.scenario_id,
1130 &format!("failure_hook_{hook}_marker"),
1131 &format!("marker_path={}", marker_path.display()),
1132 ) {
1133 hook_artifacts.push(path.display().to_string());
1134 }
1135 let hook_payload = serde_json::json!({
1136 "scenario_id": scenario.scenario_id,
1137 "hook": hook.to_string(),
1138 "marker_path": marker_path.display().to_string(),
1139 "armed_at": chrono::Utc::now().to_rfc3339(),
1140 });
1141 if let Ok(path) = self.logger.capture_artifact_json(
1142 &scenario.scenario_id,
1143 &format!("failure_hook_{hook}_payload"),
1144 &hook_payload,
1145 ) {
1146 hook_artifacts.push(path.display().to_string());
1147 }
1148
1149 for artifact_path in &hook_artifacts {
1150 Self::push_unique_string(&mut report.artifact_paths, artifact_path.clone());
1151 }
1152
1153 Self::push_unique_string(triage_actions, format!("failure_hook:{hook}:armed"));
1154
1155 self.log_scenario_event(
1156 scenario,
1157 ReliabilityPhase::Setup,
1158 LogLevel::Info,
1159 format!("Failure hook armed: {hook}"),
1160 "FAILURE_HOOK_ARMED",
1161 triage_actions,
1162 None,
1163 hook_artifacts,
1164 );
1165
1166 activated.push(*hook);
1167 }
1168
1169 Ok(activated)
1170 }
1171
1172 #[allow(clippy::too_many_arguments)]
1173 fn log_scenario_event(
1174 &self,
1175 scenario: &ReliabilityScenarioSpec,
1176 phase: ReliabilityPhase,
1177 level: LogLevel,
1178 message: impl Into<String>,
1179 decision_code: impl Into<String>,
1180 triage_actions: &[String],
1181 fallback_reason: Option<String>,
1182 artifact_paths: Vec<String>,
1183 ) {
1184 let repo_set = if scenario.repo_set.is_empty() {
1185 vec![self.test_dir.display().to_string()]
1186 } else {
1187 scenario.repo_set.clone()
1188 };
1189
1190 self.logger.log_reliability_event(ReliabilityEventInput {
1191 level,
1192 phase,
1193 scenario_id: scenario.scenario_id.clone(),
1194 message: message.into(),
1195 context: ReliabilityContext {
1196 worker_id: scenario.worker_id.clone(),
1197 repo_set,
1198 pressure_state: scenario.pressure_state.clone(),
1199 triage_actions: triage_actions.to_vec(),
1200 decision_code: decision_code.into(),
1201 fallback_reason,
1202 },
1203 artifact_paths,
1204 });
1205 }
1206
1207 fn push_unique_string(values: &mut Vec<String>, value: String) {
1208 if !values.iter().any(|existing| existing == &value) {
1209 values.push(value);
1210 }
1211 }
1212
1213 fn sanitize_decision_token(raw: &str) -> String {
1214 let mut token = String::with_capacity(raw.len());
1215 for ch in raw.chars() {
1216 if ch.is_ascii_alphanumeric() {
1217 token.push(ch.to_ascii_uppercase());
1218 } else {
1219 token.push('_');
1220 }
1221 }
1222 if token.is_empty() {
1223 "PHASE".to_string()
1224 } else {
1225 token
1226 }
1227 }
1228
1229 pub fn create_dir(&self, name: &str) -> HarnessResult<PathBuf> {
1231 let path = self.test_dir.join(name);
1232 std::fs::create_dir_all(&path)?;
1233 self.created_dirs.lock().unwrap().push(path.clone());
1234 self.logger
1235 .debug(format!("Created directory: {}", path.display()));
1236 Ok(path)
1237 }
1238
1239 pub fn create_file(&self, name: &str, content: &str) -> HarnessResult<PathBuf> {
1241 let path = self.test_dir.join(name);
1242 if let Some(parent) = path.parent() {
1243 std::fs::create_dir_all(parent)?;
1244 }
1245 std::fs::write(&path, content)?;
1246 self.created_files.lock().unwrap().push(path.clone());
1247 self.logger
1248 .debug(format!("Created file: {}", path.display()));
1249 Ok(path)
1250 }
1251
1252 pub fn create_daemon_config(&self, config_content: &str) -> HarnessResult<PathBuf> {
1254 let config_dir = self.create_dir("config")?;
1255 let config_path = config_dir.join("daemon.toml");
1256 std::fs::write(&config_path, config_content)?;
1257 self.logger
1258 .info(format!("Created daemon config: {}", config_path.display()));
1259 Ok(config_path)
1260 }
1261
1262 pub fn create_workers_config(&self, config_content: &str) -> HarnessResult<PathBuf> {
1264 let config_dir = self.test_dir.join("config");
1265 std::fs::create_dir_all(&config_dir)?;
1266 let config_path = config_dir.join("workers.toml");
1267 std::fs::write(&config_path, config_content)?;
1268 self.logger
1269 .info(format!("Created workers config: {}", config_path.display()));
1270 Ok(config_path)
1271 }
1272
1273 pub fn spawn_process<I, S>(
1275 &self,
1276 name: &str,
1277 program: &Path,
1278 args: I,
1279 env: Option<&HashMap<String, String>>,
1280 ) -> HarnessResult<u32>
1281 where
1282 I: IntoIterator<Item = S>,
1283 S: AsRef<OsStr>,
1284 {
1285 let mut cmd = Command::new(program);
1286 let logs_dir = self.test_dir.join("logs");
1294 let _ = std::fs::create_dir_all(&logs_dir);
1295 let stdio_for = |suffix: &str| -> Stdio {
1296 std::fs::OpenOptions::new()
1297 .create(true)
1298 .append(true)
1299 .open(logs_dir.join(format!("{name}.{suffix}")))
1300 .map_or_else(|_| Stdio::null(), Stdio::from)
1301 };
1302 cmd.args(args)
1303 .current_dir(&self.test_dir)
1304 .stdout(stdio_for("out"))
1305 .stderr(stdio_for("err"));
1306
1307 cmd.env("RCH_EXIT_WITH_PARENT_PID", std::process::id().to_string());
1310
1311 for (k, v) in &self.config.env_vars {
1313 cmd.env(k, v);
1314 }
1315
1316 if let Some(env_vars) = env {
1318 for (k, v) in env_vars {
1319 cmd.env(k, v);
1320 }
1321 }
1322
1323 self.logger.info(format!(
1324 "Spawning process: {} {:?}",
1325 name,
1326 program.display()
1327 ));
1328
1329 let child = cmd.spawn().map_err(|e| {
1330 HarnessError::ProcessStartFailed(format!("{}: {}", program.display(), e))
1331 })?;
1332
1333 let pid = child.id();
1334 let info = ProcessInfo {
1335 name: name.to_string(),
1336 pid,
1337 started_at: Instant::now(),
1338 child,
1339 };
1340
1341 self.managed_processes
1342 .lock()
1343 .unwrap()
1344 .insert(name.to_string(), info);
1345
1346 self.logger.log_with_context(
1347 LogLevel::Info,
1348 LogSource::Harness,
1349 format!("Process spawned: {name}"),
1350 vec![("pid".to_string(), pid.to_string())],
1351 );
1352
1353 Ok(pid)
1354 }
1355
1356 pub fn start_daemon(&self, extra_args: &[&str]) -> HarnessResult<u32> {
1358 let workers_config = self.test_dir.join("config").join("workers.toml");
1359 let workers_config_str: String = workers_config.to_string_lossy().into_owned();
1360 let mut args: Vec<&str> = vec!["--workers-config", &workers_config_str];
1361 args.extend(extra_args);
1362
1363 self.spawn_process("daemon", &self.config.rchd_binary, &args, None)
1364 }
1365
1366 pub fn stop_process(&self, name: &str) -> HarnessResult<()> {
1368 let mut processes = self.managed_processes.lock().unwrap();
1369 if let Some(mut info) = processes.remove(name) {
1370 self.logger
1371 .info(format!("Stopping process: {} (pid={})", name, info.pid));
1372 info.kill()?;
1373 let status = info.wait()?;
1374 self.logger
1375 .debug(format!("Process {} exited with status: {:?}", name, status));
1376 Ok(())
1377 } else {
1378 Err(HarnessError::ProcessNotFound(name.to_string()))
1379 }
1380 }
1381
1382 pub fn wait_for_process_exit(
1384 &self,
1385 name: &str,
1386 timeout: Duration,
1387 ) -> HarnessResult<ExitStatus> {
1388 self.logger
1389 .debug(format!("Waiting for process to exit: {name} ({timeout:?})"));
1390
1391 let start = Instant::now();
1392 while start.elapsed() < timeout {
1393 let exit_status = {
1394 let mut processes = self.managed_processes.lock().unwrap();
1395 let Some(info) = processes.get_mut(name) else {
1396 return Err(HarnessError::ProcessNotFound(name.to_string()));
1397 };
1398
1399 match info.try_exit_status() {
1400 Some(status) => {
1401 self.logger.debug(format!(
1402 "Process {name} exited after {:?} with status: {:?}",
1403 start.elapsed(),
1404 status
1405 ));
1406 Some(status)
1407 }
1408 None => None,
1409 }
1410 };
1411
1412 if let Some(status) = exit_status {
1413 self.managed_processes.lock().unwrap().remove(name);
1414 return Ok(status);
1415 }
1416
1417 thread::sleep(Duration::from_millis(50));
1418 }
1419
1420 self.logger
1421 .error(format!("Timeout waiting for process to exit: {name}"));
1422 Err(HarnessError::Timeout(timeout))
1423 }
1424
1425 pub fn stop_all_processes(&self) {
1427 let mut processes = self.managed_processes.lock().unwrap();
1428 for (name, mut info) in processes.drain() {
1429 self.logger
1430 .info(format!("Stopping process: {} (pid={})", name, info.pid));
1431 if let Err(e) = info.kill() {
1432 self.logger.warn(format!("Failed to kill {}: {}", name, e));
1433 }
1434 match info.wait() {
1435 Ok(status) => {
1436 self.logger
1437 .debug(format!("Process {} exited: {:?}", name, status));
1438 }
1439 Err(e) => {
1440 self.logger
1441 .warn(format!("Failed to wait for {}: {}", name, e));
1442 }
1443 }
1444 }
1445 }
1446
1447 pub fn exec<I, S>(&self, program: &str, args: I) -> HarnessResult<CommandResult>
1449 where
1450 I: IntoIterator<Item = S>,
1451 S: AsRef<OsStr>,
1452 {
1453 self.exec_with_timeout(program, args, self.config.default_timeout)
1454 }
1455
1456 pub fn exec_with_timeout<I, S>(
1460 &self,
1461 program: &str,
1462 args: I,
1463 timeout: Duration,
1464 ) -> HarnessResult<CommandResult>
1465 where
1466 I: IntoIterator<Item = S>,
1467 S: AsRef<OsStr>,
1468 {
1469 let args: Vec<_> = args.into_iter().collect();
1470 let args_display: Vec<_> = args.iter().map(|s| s.as_ref().to_string_lossy()).collect();
1471
1472 self.logger
1473 .debug(format!("Executing: {} {}", program, args_display.join(" ")));
1474
1475 let start = Instant::now();
1476
1477 let mut cmd = Command::new(program);
1478 cmd.args(&args)
1479 .current_dir(&self.test_dir)
1480 .stdout(Stdio::piped())
1481 .stderr(Stdio::piped());
1482
1483 for (k, v) in &self.config.env_vars {
1485 cmd.env(k, v);
1486 }
1487
1488 let mut child = cmd.spawn()?;
1489 let stdout_handle = child
1490 .stdout
1491 .take()
1492 .map(|mut stdout| thread::spawn(move || Self::read_to_string(&mut stdout)));
1493 let stderr_handle = child
1494 .stderr
1495 .take()
1496 .map(|mut stderr| thread::spawn(move || Self::read_to_string(&mut stderr)));
1497
1498 let mut timed_out = false;
1499 let exit_status = loop {
1500 if let Some(status) = child.try_wait()? {
1501 break Some(status);
1502 }
1503
1504 if start.elapsed() >= timeout {
1505 timed_out = true;
1506 let _ = child.kill();
1507 break child.wait().ok();
1508 }
1509
1510 thread::sleep(Duration::from_millis(10));
1511 };
1512
1513 let duration = start.elapsed();
1514 let stdout = Self::join_output(stdout_handle);
1515 let mut stderr = Self::join_output(stderr_handle);
1516 if timed_out {
1517 if !stderr.is_empty() {
1518 stderr.push('\n');
1519 }
1520 stderr.push_str(&format!("Process timed out after {:?}.", timeout));
1521 }
1522
1523 let exit_code = exit_status
1524 .and_then(|status| status.code())
1525 .unwrap_or(if timed_out { 124 } else { -1 });
1526
1527 let result = CommandResult {
1528 exit_code,
1529 stdout,
1530 stderr,
1531 duration,
1532 };
1533
1534 let command_line = format!("{} {}", program, args_display.join(" "));
1535 let artifact_prefix = format!(
1536 "{}_{}",
1537 Self::sanitize_artifact_component(program),
1538 chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f")
1539 );
1540 let mut artifact_paths = Vec::new();
1541 let trace_payload = serde_json::json!({
1542 "command": command_line,
1543 "program": program,
1544 "args": args_display,
1545 "exit_code": result.exit_code,
1546 "duration_ms": duration.as_millis(),
1547 "timed_out": timed_out,
1548 "stdout_len_bytes": result.stdout.len(),
1549 "stderr_len_bytes": result.stderr.len(),
1550 });
1551 if let Ok(path) = self.logger.capture_artifact_json(
1552 self.logger.test_name(),
1553 &format!("{artifact_prefix}_trace"),
1554 &trace_payload,
1555 ) {
1556 artifact_paths.push(path.display().to_string());
1557 }
1558 if !result.stdout.is_empty()
1559 && let Ok(path) = self.logger.capture_artifact_text(
1560 self.logger.test_name(),
1561 &format!("{artifact_prefix}_stdout"),
1562 &result.stdout,
1563 )
1564 {
1565 artifact_paths.push(path.display().to_string());
1566 }
1567 if !result.stderr.is_empty()
1568 && let Ok(path) = self.logger.capture_artifact_text(
1569 self.logger.test_name(),
1570 &format!("{artifact_prefix}_stderr"),
1571 &result.stderr,
1572 )
1573 {
1574 artifact_paths.push(path.display().to_string());
1575 }
1576
1577 self.logger.log_with_context(
1578 if result.success() {
1579 LogLevel::Debug
1580 } else {
1581 LogLevel::Warn
1582 },
1583 LogSource::Harness,
1584 format!("Command completed: {program}"),
1585 vec![
1586 ("exit_code".to_string(), result.exit_code.to_string()),
1587 ("duration_ms".to_string(), duration.as_millis().to_string()),
1588 ("timed_out".to_string(), timed_out.to_string()),
1589 ],
1590 );
1591
1592 let decision_code = if timed_out {
1593 "CMD_TIMEOUT"
1594 } else if result.success() {
1595 "CMD_SUCCESS"
1596 } else {
1597 "CMD_FAILURE"
1598 };
1599 self.logger.log_reliability_event(ReliabilityEventInput {
1600 level: if result.success() {
1601 LogLevel::Info
1602 } else {
1603 LogLevel::Warn
1604 },
1605 phase: ReliabilityPhase::Execute,
1606 scenario_id: self.logger.test_name().to_string(),
1607 message: format!("Command execution finished: {program}"),
1608 context: ReliabilityContext {
1609 worker_id: None,
1610 repo_set: vec![self.test_dir.display().to_string()],
1611 pressure_state: None,
1612 triage_actions: if timed_out {
1613 vec!["process_killed_after_timeout".to_string()]
1614 } else {
1615 Vec::new()
1616 },
1617 decision_code: decision_code.to_string(),
1618 fallback_reason: if timed_out {
1619 Some(format!("command exceeded timeout {:?}", timeout))
1620 } else {
1621 None
1622 },
1623 },
1624 artifact_paths,
1625 });
1626
1627 if !result.stdout.is_empty() {
1628 for line in result.stdout.lines() {
1629 self.logger.log(
1630 LogLevel::Trace,
1631 LogSource::Custom(program.to_string()),
1632 line,
1633 );
1634 }
1635 }
1636
1637 if !result.stderr.is_empty() {
1638 for line in result.stderr.lines() {
1639 self.logger.log(
1640 LogLevel::Trace,
1641 LogSource::Custom(format!("{program}:stderr")),
1642 line,
1643 );
1644 }
1645 }
1646
1647 Ok(result)
1648 }
1649
1650 fn read_to_string<R: Read>(reader: &mut R) -> String {
1651 let mut buffer = Vec::new();
1652 if reader.read_to_end(&mut buffer).is_ok() {
1653 String::from_utf8_lossy(&buffer).to_string()
1654 } else {
1655 String::new()
1656 }
1657 }
1658
1659 fn join_output(handle: Option<thread::JoinHandle<String>>) -> String {
1660 match handle {
1661 Some(handle) => handle.join().unwrap_or_default(),
1662 None => String::new(),
1663 }
1664 }
1665
1666 fn sanitize_artifact_component(raw: &str) -> String {
1667 let mut sanitized = String::with_capacity(raw.len());
1668 for ch in raw.chars() {
1669 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1670 sanitized.push(ch);
1671 } else {
1672 sanitized.push('_');
1673 }
1674 }
1675 if sanitized.is_empty() {
1676 "artifact".to_string()
1677 } else {
1678 sanitized
1679 }
1680 }
1681
1682 pub fn exec_rch<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1684 where
1685 I: IntoIterator<Item = S>,
1686 S: AsRef<OsStr>,
1687 {
1688 let binary = self.config.rch_binary.to_string_lossy().to_string();
1689 self.exec(&binary, args)
1690 }
1691
1692 pub fn exec_rchd<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1694 where
1695 I: IntoIterator<Item = S>,
1696 S: AsRef<OsStr>,
1697 {
1698 let binary = self.config.rchd_binary.to_string_lossy().to_string();
1699 self.exec(&binary, args)
1700 }
1701
1702 pub fn exec_rch_wkr<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1704 where
1705 I: IntoIterator<Item = S>,
1706 S: AsRef<OsStr>,
1707 {
1708 let binary = self.config.rch_wkr_binary.to_string_lossy().to_string();
1709 self.exec(&binary, args)
1710 }
1711
1712 pub fn wait_for<F>(
1714 &self,
1715 description: &str,
1716 timeout: Duration,
1717 interval: Duration,
1718 condition: F,
1719 ) -> HarnessResult<()>
1720 where
1721 F: Fn() -> bool,
1722 {
1723 self.logger
1724 .debug(format!("Waiting for: {description} (timeout: {timeout:?})"));
1725
1726 let start = Instant::now();
1727 while start.elapsed() < timeout {
1728 if condition() {
1729 self.logger.debug(format!(
1730 "Condition satisfied: {description} after {:?}",
1731 start.elapsed()
1732 ));
1733 return Ok(());
1734 }
1735 std::thread::sleep(interval);
1736 }
1737
1738 self.logger.error(format!(
1739 "Timeout waiting for: {description} after {timeout:?}"
1740 ));
1741 Err(HarnessError::Timeout(timeout))
1742 }
1743
1744 pub fn wait_for_file(&self, path: &Path, timeout: Duration) -> HarnessResult<()> {
1746 let path_display = path.display().to_string();
1747 self.wait_for(
1748 &format!("file to exist: {path_display}"),
1749 timeout,
1750 Duration::from_millis(100),
1751 || path.exists(),
1752 )
1753 }
1754
1755 pub fn wait_for_socket(&self, socket_path: &Path, timeout: Duration) -> HarnessResult<()> {
1757 self.wait_for_socket_with_backoff(socket_path, timeout)
1758 }
1759
1760 pub fn wait_for_socket_with_backoff(
1765 &self,
1766 socket_path: &Path,
1767 max_wait: Duration,
1768 ) -> HarnessResult<()> {
1769 let socket_display = socket_path.display().to_string();
1770 self.logger.debug(format!(
1771 "Waiting for socket with backoff: {socket_display} (timeout: {max_wait:?})"
1772 ));
1773
1774 let start = std::time::Instant::now();
1775 let mut delay = Duration::from_millis(10);
1776 let max_delay = Duration::from_millis(500);
1777
1778 while start.elapsed() < max_wait {
1779 if socket_path.exists() {
1780 #[cfg(unix)]
1781 {
1782 match std::os::unix::net::UnixStream::connect(socket_path) {
1785 Ok(stream) => {
1786 drop(stream);
1787 self.logger.info(format!(
1788 "Socket ready after {:?}: {socket_display}",
1789 start.elapsed()
1790 ));
1791 return Ok(());
1792 }
1793 Err(err) => {
1794 self.logger.debug(format!(
1795 "Socket exists but not connectable yet ({err}); retrying..."
1796 ));
1797 }
1798 }
1799 }
1800
1801 #[cfg(not(unix))]
1802 {
1803 self.logger.info(format!(
1804 "Socket ready after {:?}: {socket_display}",
1805 start.elapsed()
1806 ));
1807 return Ok(());
1808 }
1809 }
1810 std::thread::sleep(delay);
1811 delay = (delay * 2).min(max_delay);
1812 }
1813
1814 let mut evidence = String::new();
1818 if let Ok(entries) = std::fs::read_dir(self.test_dir.join("logs")) {
1819 for entry in entries.flatten() {
1820 let path = entry.path();
1821 let is_stdio = path.extension().is_some_and(|e| e == "out" || e == "err");
1822 if !is_stdio {
1823 continue;
1824 }
1825 if let Ok(content) = std::fs::read_to_string(&path) {
1826 let tail: Vec<&str> = content.lines().rev().take(20).collect();
1827 if !tail.is_empty() {
1828 evidence.push_str(&format!("\n--- tail of {} ---\n", path.display()));
1829 for line in tail.iter().rev() {
1830 evidence.push_str(line);
1831 evidence.push('\n');
1832 }
1833 }
1834 }
1835 }
1836 }
1837 self.logger.error(format!(
1838 "Socket timeout after {max_wait:?}: {socket_display}{evidence}"
1839 ));
1840 Err(HarnessError::Timeout(max_wait))
1841 }
1842
1843 pub fn mark_passed(&self) {
1845 *self.test_passed.lock().unwrap() = true;
1846 self.logger.info("Test marked as PASSED");
1847 }
1848
1849 pub fn mark_failed(&self, reason: &str) {
1851 *self.test_passed.lock().unwrap() = false;
1852 self.logger
1853 .error(format!("Test marked as FAILED: {reason}"));
1854 }
1855
1856 pub fn passed(&self) -> bool {
1858 *self.test_passed.lock().unwrap()
1859 }
1860
1861 pub fn assert(&self, condition: bool, message: &str) -> HarnessResult<()> {
1863 if condition {
1864 self.logger.debug(format!("Assertion passed: {message}"));
1865 self.logger
1866 .log_reliability_event(ReliabilityEventInput::with_decision(
1867 ReliabilityPhase::Verify,
1868 self.logger.test_name().to_string(),
1869 format!("Assertion passed: {message}"),
1870 "ASSERT_PASS",
1871 ));
1872 Ok(())
1873 } else {
1874 self.logger.error(format!("Assertion failed: {message}"));
1875 self.logger
1876 .log_reliability_event(ReliabilityEventInput::with_decision(
1877 ReliabilityPhase::Verify,
1878 self.logger.test_name().to_string(),
1879 format!("Assertion failed: {message}"),
1880 "ASSERT_FAIL",
1881 ));
1882 Err(HarnessError::AssertionFailed(message.to_string()))
1883 }
1884 }
1885
1886 pub fn assert_eq<T: PartialEq + std::fmt::Debug>(
1888 &self,
1889 actual: T,
1890 expected: T,
1891 message: &str,
1892 ) -> HarnessResult<()> {
1893 if actual == expected {
1894 self.logger.debug(format!("Assertion passed: {message}"));
1895 self.logger
1896 .log_reliability_event(ReliabilityEventInput::with_decision(
1897 ReliabilityPhase::Verify,
1898 self.logger.test_name().to_string(),
1899 format!("Equality assertion passed: {message}"),
1900 "ASSERT_EQ_PASS",
1901 ));
1902 Ok(())
1903 } else {
1904 let msg = format!("{}: expected {:?}, got {:?}", message, expected, actual);
1905 self.logger.error(format!("Assertion failed: {msg}"));
1906 self.logger
1907 .log_reliability_event(ReliabilityEventInput::with_decision(
1908 ReliabilityPhase::Verify,
1909 self.logger.test_name().to_string(),
1910 format!("Equality assertion failed: {msg}"),
1911 "ASSERT_EQ_FAIL",
1912 ));
1913 Err(HarnessError::AssertionFailed(msg))
1914 }
1915 }
1916
1917 pub fn assert_success(&self, result: &CommandResult, context: &str) -> HarnessResult<()> {
1919 if result.success() {
1920 self.logger.debug(format!("Command succeeded: {context}"));
1921 self.logger
1922 .log_reliability_event(ReliabilityEventInput::with_decision(
1923 ReliabilityPhase::Verify,
1924 self.logger.test_name().to_string(),
1925 format!("Command success assertion passed: {context}"),
1926 "CMD_ASSERT_SUCCESS",
1927 ));
1928 Ok(())
1929 } else {
1930 let msg = format!(
1931 "{}: command failed with exit code {} - stdout: {}, stderr: {}",
1932 context,
1933 result.exit_code,
1934 result.stdout.trim(),
1935 result.stderr.trim()
1936 );
1937 self.logger.error(&msg);
1938 self.logger
1939 .log_reliability_event(ReliabilityEventInput::with_decision(
1940 ReliabilityPhase::Verify,
1941 self.logger.test_name().to_string(),
1942 format!("Command success assertion failed: {msg}"),
1943 "CMD_ASSERT_FAIL",
1944 ));
1945 Err(HarnessError::AssertionFailed(msg))
1946 }
1947 }
1948
1949 pub fn assert_stdout_contains(
1951 &self,
1952 result: &CommandResult,
1953 pattern: &str,
1954 context: &str,
1955 ) -> HarnessResult<()> {
1956 if result.stdout_contains(pattern) {
1957 self.logger.debug(format!(
1958 "Stdout contains expected pattern: {context} -> {pattern}"
1959 ));
1960 self.logger
1961 .log_reliability_event(ReliabilityEventInput::with_decision(
1962 ReliabilityPhase::Verify,
1963 self.logger.test_name().to_string(),
1964 format!("Stdout assertion passed for pattern '{pattern}'"),
1965 "STDOUT_PATTERN_PASS",
1966 ));
1967 Ok(())
1968 } else {
1969 let msg = format!(
1970 "{}: stdout does not contain '{}'. Actual stdout: {}",
1971 context,
1972 pattern,
1973 result.stdout.trim()
1974 );
1975 self.logger.error(&msg);
1976 self.logger
1977 .log_reliability_event(ReliabilityEventInput::with_decision(
1978 ReliabilityPhase::Verify,
1979 self.logger.test_name().to_string(),
1980 format!("Stdout assertion failed for pattern '{pattern}'"),
1981 "STDOUT_PATTERN_FAIL",
1982 ));
1983 Err(HarnessError::AssertionFailed(msg))
1984 }
1985 }
1986
1987 pub fn cleanup(&self) {
1989 self.logger.info("Starting cleanup");
1990 self.logger
1991 .log_reliability_event(ReliabilityEventInput::with_decision(
1992 ReliabilityPhase::Cleanup,
1993 self.logger.test_name().to_string(),
1994 "Cleanup started",
1995 "CLEANUP_START",
1996 ));
1997
1998 self.stop_all_processes();
2000
2001 let should_cleanup = if *self.test_passed.lock().unwrap() {
2003 self.config.cleanup_on_success
2004 } else {
2005 self.config.cleanup_on_failure
2006 };
2007
2008 if should_cleanup {
2009 self.logger.debug(format!(
2010 "Removing test directory: {}",
2011 self.test_dir.display()
2012 ));
2013 if let Err(e) = std::fs::remove_dir_all(&self.test_dir) {
2014 self.logger
2015 .warn(format!("Failed to remove test directory: {}", e));
2016 }
2017 } else {
2018 self.logger.info(format!(
2019 "Preserving test directory for inspection: {}",
2020 self.test_dir.display()
2021 ));
2022 }
2023
2024 self.logger
2025 .log_reliability_event(ReliabilityEventInput::with_decision(
2026 ReliabilityPhase::Cleanup,
2027 self.logger.test_name().to_string(),
2028 "Cleanup finished",
2029 "CLEANUP_DONE",
2030 ));
2031
2032 self.logger.print_summary();
2033 }
2034}
2035
2036impl Drop for TestHarness {
2037 fn drop(&mut self) {
2038 self.cleanup();
2039 }
2040}
2041
2042pub struct TestHarnessBuilder {
2044 test_name: String,
2045 config: HarnessConfig,
2046}
2047
2048impl TestHarnessBuilder {
2049 pub fn new(test_name: &str) -> Self {
2051 Self {
2052 test_name: test_name.to_string(),
2053 config: HarnessConfig::default(),
2054 }
2055 }
2056
2057 pub fn temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2059 self.config.temp_dir = dir.into();
2060 self
2061 }
2062
2063 pub fn default_timeout(mut self, timeout: Duration) -> Self {
2065 self.config.default_timeout = timeout;
2066 self
2067 }
2068
2069 pub fn cleanup_on_success(mut self, cleanup: bool) -> Self {
2071 self.config.cleanup_on_success = cleanup;
2072 self
2073 }
2074
2075 pub fn cleanup_on_failure(mut self, cleanup: bool) -> Self {
2077 self.config.cleanup_on_failure = cleanup;
2078 self
2079 }
2080
2081 pub fn rch_binary(mut self, path: impl Into<PathBuf>) -> Self {
2083 self.config.rch_binary = path.into();
2084 self
2085 }
2086
2087 pub fn rchd_binary(mut self, path: impl Into<PathBuf>) -> Self {
2089 self.config.rchd_binary = path.into();
2090 self
2091 }
2092
2093 pub fn rch_wkr_binary(mut self, path: impl Into<PathBuf>) -> Self {
2095 self.config.rch_wkr_binary = path.into();
2096 self
2097 }
2098
2099 pub fn env(mut self, key: &str, value: &str) -> Self {
2101 self.config
2102 .env_vars
2103 .insert(key.to_string(), value.to_string());
2104 self
2105 }
2106
2107 pub fn build(self) -> HarnessResult<TestHarness> {
2109 TestHarness::new(&self.test_name, self.config)
2110 }
2111}
2112
2113#[cfg(test)]
2114mod tests {
2115 use super::*;
2116
2117 #[test]
2118 fn test_harness_creation() {
2119 let harness = TestHarnessBuilder::new("test_creation")
2120 .cleanup_on_success(true)
2121 .build()
2122 .unwrap();
2123
2124 assert!(harness.test_dir().exists());
2125 }
2127
2128 #[test]
2133 #[cfg(unix)]
2134 fn test_dead_daemon_startup_stderr_is_captured_not_discarded() {
2135 let harness = TestHarnessBuilder::new("dead_daemon_evidence")
2136 .cleanup_on_success(true)
2137 .cleanup_on_failure(true)
2138 .build()
2139 .unwrap();
2140 harness
2144 .spawn_process(
2145 "daemon",
2146 Path::new("/bin/sh"),
2147 ["-c", "echo doomed-by-config >&2; exit 3"],
2148 None,
2149 )
2150 .unwrap();
2151 let socket = harness.test_dir().join("never.sock");
2152 let result = harness.wait_for_socket(&socket, Duration::from_millis(300));
2153 assert!(result.is_err(), "no socket ever appears");
2154 let captured = std::fs::read_to_string(harness.test_dir().join("logs").join("daemon.err"))
2157 .expect("stderr capture file must exist");
2158 assert!(
2159 captured.contains("doomed-by-config"),
2160 "captured stderr must hold the dying words: {captured:?}"
2161 );
2162 }
2163
2164 #[test]
2165 fn test_harness_directory_leaf_stays_compact_for_unix_sockets() {
2166 let harness = TestHarnessBuilder::new(
2167 "a_descriptive_test_name_that_must_not_expand_the_filesystem_path",
2168 )
2169 .cleanup_on_success(true)
2170 .build()
2171 .unwrap();
2172
2173 let leaf = harness
2174 .test_dir()
2175 .file_name()
2176 .and_then(OsStr::to_str)
2177 .expect("test directory should have a UTF-8 instance id");
2178 assert_eq!(leaf.len(), 12);
2179 assert!(leaf.bytes().all(|byte| byte.is_ascii_hexdigit()));
2180
2181 harness.mark_passed();
2182 }
2183
2184 #[test]
2185 fn test_harness_file_creation() {
2186 let harness = TestHarnessBuilder::new("test_files")
2187 .cleanup_on_success(true)
2188 .build()
2189 .unwrap();
2190
2191 let file_path = harness.create_file("test.txt", "hello world").unwrap();
2192 assert!(file_path.exists());
2193
2194 let content = std::fs::read_to_string(&file_path).unwrap();
2195 assert_eq!(content, "hello world");
2196 }
2197
2198 #[test]
2199 fn test_harness_exec() {
2200 let harness = TestHarnessBuilder::new("test_exec")
2201 .cleanup_on_success(true)
2202 .build()
2203 .unwrap();
2204
2205 let result = harness.exec("echo", ["hello"]).unwrap();
2206 assert!(result.success());
2207 assert!(result.stdout_contains("hello"));
2208 }
2209
2210 #[cfg(unix)]
2211 #[test]
2212 fn test_harness_exec_timeout() {
2213 let harness = TestHarnessBuilder::new("test_exec_timeout")
2214 .cleanup_on_success(true)
2215 .build()
2216 .unwrap();
2217
2218 let result = harness
2219 .exec_with_timeout("sleep", ["1"], Duration::from_millis(50))
2220 .unwrap();
2221
2222 assert!(!result.success());
2223 assert_eq!(result.exit_code, 124);
2224 assert!(result.stderr_contains("timed out"));
2225 }
2226
2227 #[test]
2228 fn test_command_result() {
2229 let result = CommandResult {
2230 exit_code: 0,
2231 stdout: "hello world\n".to_string(),
2232 stderr: "".to_string(),
2233 duration: Duration::from_millis(10),
2234 };
2235
2236 assert!(result.success());
2237 assert!(result.stdout_contains("hello"));
2238 assert!(!result.stderr_contains("error"));
2239 }
2240
2241 #[test]
2242 fn test_harness_assertions() {
2243 let harness = TestHarnessBuilder::new("test_assertions")
2244 .cleanup_on_success(true)
2245 .build()
2246 .unwrap();
2247
2248 harness.assert(true, "should pass").unwrap();
2249 harness.assert_eq(1, 1, "numbers equal").unwrap();
2250
2251 let result = CommandResult {
2252 exit_code: 0,
2253 stdout: "success".to_string(),
2254 stderr: "".to_string(),
2255 duration: Duration::from_millis(1),
2256 };
2257 harness.assert_success(&result, "echo command").unwrap();
2258
2259 harness.mark_passed();
2260 assert!(harness.passed());
2261 }
2262
2263 #[test]
2264 fn test_reliability_harness_phase_order_and_manifest_index() {
2265 let harness = TestHarnessBuilder::new("reliability_harness_phase_order")
2266 .cleanup_on_success(true)
2267 .build()
2268 .unwrap();
2269
2270 let scenario = ReliabilityScenarioSpec::new("reliability_harness_phase_order")
2271 .with_worker_id("worker-a")
2272 .with_repo_set([harness.test_dir().display().to_string()])
2273 .with_pressure_state("disk:normal,memory:normal")
2274 .add_triage_action("initial_context")
2275 .add_pre_check(ReliabilityLifecycleCommand::new(
2276 "pre-check",
2277 "echo",
2278 ["pre-check"],
2279 ))
2280 .add_execute_command(ReliabilityLifecycleCommand::new(
2281 "execute-build",
2282 "echo",
2283 ["execute-build"],
2284 ))
2285 .add_remote_probe(ReliabilityLifecycleCommand::new(
2286 "remote-probe",
2287 "echo",
2288 ["remote-probe"],
2289 ))
2290 .add_post_check(ReliabilityLifecycleCommand::new(
2291 "post-check",
2292 "echo",
2293 ["post-check"],
2294 ))
2295 .add_cleanup_verification(ReliabilityLifecycleCommand::new(
2296 "cleanup-check",
2297 "echo",
2298 ["cleanup-check"],
2299 ));
2300
2301 let report = harness.run_reliability_scenario(&scenario).unwrap();
2302 assert_eq!(
2303 report.phase_order,
2304 vec![
2305 ReliabilityPhase::Setup,
2306 ReliabilityPhase::Execute,
2307 ReliabilityPhase::Verify,
2308 ReliabilityPhase::Cleanup
2309 ]
2310 );
2311 let stages: Vec<_> = report
2312 .command_records
2313 .iter()
2314 .map(|record| record.stage.as_str())
2315 .collect();
2316 assert_eq!(
2317 stages,
2318 vec![
2319 "pre_checks",
2320 "execute",
2321 "remote_probes",
2322 "post_checks",
2323 "cleanup_verification"
2324 ]
2325 );
2326
2327 let manifest_path = report.manifest_path.expect("manifest path should exist");
2328 assert!(manifest_path.exists());
2329 let manifest = std::fs::read_to_string(manifest_path).unwrap();
2330 assert!(manifest.contains("\"scenario_id\": \"reliability_harness_phase_order\""));
2331 }
2332
2333 #[test]
2334 fn test_reliability_harness_denies_unflagged_failure_hooks() {
2335 let harness = TestHarnessBuilder::new("reliability_harness_hook_denied")
2336 .cleanup_on_success(true)
2337 .build()
2338 .unwrap();
2339
2340 let scenario = ReliabilityScenarioSpec::new("reliability_harness_hook_denied")
2341 .request_failure_hook(ReliabilityFailureHook::NetworkCut);
2342
2343 let err = harness
2344 .run_reliability_scenario(&scenario)
2345 .expect_err("unflagged failure hook must be rejected");
2346 assert!(matches!(err, HarnessError::SetupFailed(_)));
2347 assert!(err.to_string().contains("not enabled"));
2348 }
2349
2350 #[test]
2351 fn test_reliability_harness_arms_explicit_failure_hooks() {
2352 let harness = TestHarnessBuilder::new("reliability_harness_hook_enabled")
2353 .cleanup_on_success(true)
2354 .build()
2355 .unwrap();
2356
2357 let flags = ReliabilityFailureHookFlags::allow_all();
2358
2359 let scenario = ReliabilityScenarioSpec::new("reliability_harness_hook_enabled")
2360 .with_repo_set([harness.test_dir().display().to_string()])
2361 .request_failure_hook(ReliabilityFailureHook::NetworkCut)
2362 .request_failure_hook(ReliabilityFailureHook::SyncTimeout)
2363 .request_failure_hook(ReliabilityFailureHook::PartialUpdate)
2364 .request_failure_hook(ReliabilityFailureHook::DaemonRestart)
2365 .with_failure_hook_flags(flags)
2366 .add_cleanup_verification(ReliabilityLifecycleCommand::new(
2367 "cleanup-check",
2368 "echo",
2369 ["cleanup-check"],
2370 ));
2371
2372 let report = harness.run_reliability_scenario(&scenario).unwrap();
2373 assert_eq!(
2374 report.activated_failure_hooks,
2375 vec![
2376 ReliabilityFailureHook::NetworkCut,
2377 ReliabilityFailureHook::SyncTimeout,
2378 ReliabilityFailureHook::PartialUpdate,
2379 ReliabilityFailureHook::DaemonRestart
2380 ]
2381 );
2382 assert!(
2383 report
2384 .artifact_paths
2385 .iter()
2386 .any(|path| path.contains("failure_hook_network_cut"))
2387 );
2388 assert!(
2389 harness
2390 .test_dir()
2391 .join(".reliability-hooks/network_cut.enabled")
2392 .exists()
2393 );
2394 assert!(
2395 harness
2396 .test_dir()
2397 .join(".reliability-hooks/sync_timeout.enabled")
2398 .exists()
2399 );
2400 assert!(
2401 harness
2402 .test_dir()
2403 .join(".reliability-hooks/partial_update.enabled")
2404 .exists()
2405 );
2406 assert!(
2407 harness
2408 .test_dir()
2409 .join(".reliability-hooks/daemon_restart.enabled")
2410 .exists()
2411 );
2412 }
2413
2414 #[test]
2415 fn test_reliability_harness_primitives_cover_downstream_scenarios() {
2416 let harness = TestHarnessBuilder::new("reliability_harness_downstream")
2417 .cleanup_on_success(true)
2418 .build()
2419 .unwrap();
2420
2421 let scenario_ids = ["bd-vvmd.2.8", "bd-vvmd.3.6", "bd-vvmd.4.6", "bd-vvmd.5.6"];
2422 for scenario_id in scenario_ids {
2423 let scenario = ReliabilityScenarioSpec::new(scenario_id)
2424 .with_repo_set([harness.test_dir().display().to_string()])
2425 .add_execute_command(ReliabilityLifecycleCommand::new(
2426 "smoke",
2427 "echo",
2428 [format!("scenario={scenario_id}")],
2429 ));
2430
2431 let report = harness
2432 .run_reliability_scenario(&scenario)
2433 .unwrap_or_else(|error| panic!("scenario {scenario_id} failed: {error}"));
2434 assert!(
2435 report.manifest_path.is_some(),
2436 "missing manifest for {scenario_id}"
2437 );
2438 assert!(
2439 report
2440 .command_records
2441 .iter()
2442 .any(|record| record.stage == "execute"),
2443 "missing execute stage record for {scenario_id}"
2444 );
2445 }
2446 }
2447}