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 for (k, v) in &self.config.env_vars {
1309 cmd.env(k, v);
1310 }
1311
1312 if let Some(env_vars) = env {
1314 for (k, v) in env_vars {
1315 cmd.env(k, v);
1316 }
1317 }
1318
1319 self.logger.info(format!(
1320 "Spawning process: {} {:?}",
1321 name,
1322 program.display()
1323 ));
1324
1325 let child = cmd.spawn().map_err(|e| {
1326 HarnessError::ProcessStartFailed(format!("{}: {}", program.display(), e))
1327 })?;
1328
1329 let pid = child.id();
1330 let info = ProcessInfo {
1331 name: name.to_string(),
1332 pid,
1333 started_at: Instant::now(),
1334 child,
1335 };
1336
1337 self.managed_processes
1338 .lock()
1339 .unwrap()
1340 .insert(name.to_string(), info);
1341
1342 self.logger.log_with_context(
1343 LogLevel::Info,
1344 LogSource::Harness,
1345 format!("Process spawned: {name}"),
1346 vec![("pid".to_string(), pid.to_string())],
1347 );
1348
1349 Ok(pid)
1350 }
1351
1352 pub fn start_daemon(&self, extra_args: &[&str]) -> HarnessResult<u32> {
1354 let workers_config = self.test_dir.join("config").join("workers.toml");
1355 let workers_config_str: String = workers_config.to_string_lossy().into_owned();
1356 let mut args: Vec<&str> = vec!["--workers-config", &workers_config_str];
1357 args.extend(extra_args);
1358
1359 self.spawn_process("daemon", &self.config.rchd_binary, &args, None)
1360 }
1361
1362 pub fn stop_process(&self, name: &str) -> HarnessResult<()> {
1364 let mut processes = self.managed_processes.lock().unwrap();
1365 if let Some(mut info) = processes.remove(name) {
1366 self.logger
1367 .info(format!("Stopping process: {} (pid={})", name, info.pid));
1368 info.kill()?;
1369 let status = info.wait()?;
1370 self.logger
1371 .debug(format!("Process {} exited with status: {:?}", name, status));
1372 Ok(())
1373 } else {
1374 Err(HarnessError::ProcessNotFound(name.to_string()))
1375 }
1376 }
1377
1378 pub fn wait_for_process_exit(
1380 &self,
1381 name: &str,
1382 timeout: Duration,
1383 ) -> HarnessResult<ExitStatus> {
1384 self.logger
1385 .debug(format!("Waiting for process to exit: {name} ({timeout:?})"));
1386
1387 let start = Instant::now();
1388 while start.elapsed() < timeout {
1389 let exit_status = {
1390 let mut processes = self.managed_processes.lock().unwrap();
1391 let Some(info) = processes.get_mut(name) else {
1392 return Err(HarnessError::ProcessNotFound(name.to_string()));
1393 };
1394
1395 match info.try_exit_status() {
1396 Some(status) => {
1397 self.logger.debug(format!(
1398 "Process {name} exited after {:?} with status: {:?}",
1399 start.elapsed(),
1400 status
1401 ));
1402 Some(status)
1403 }
1404 None => None,
1405 }
1406 };
1407
1408 if let Some(status) = exit_status {
1409 self.managed_processes.lock().unwrap().remove(name);
1410 return Ok(status);
1411 }
1412
1413 thread::sleep(Duration::from_millis(50));
1414 }
1415
1416 self.logger
1417 .error(format!("Timeout waiting for process to exit: {name}"));
1418 Err(HarnessError::Timeout(timeout))
1419 }
1420
1421 pub fn stop_all_processes(&self) {
1423 let mut processes = self.managed_processes.lock().unwrap();
1424 for (name, mut info) in processes.drain() {
1425 self.logger
1426 .info(format!("Stopping process: {} (pid={})", name, info.pid));
1427 if let Err(e) = info.kill() {
1428 self.logger.warn(format!("Failed to kill {}: {}", name, e));
1429 }
1430 match info.wait() {
1431 Ok(status) => {
1432 self.logger
1433 .debug(format!("Process {} exited: {:?}", name, status));
1434 }
1435 Err(e) => {
1436 self.logger
1437 .warn(format!("Failed to wait for {}: {}", name, e));
1438 }
1439 }
1440 }
1441 }
1442
1443 pub fn exec<I, S>(&self, program: &str, args: I) -> HarnessResult<CommandResult>
1445 where
1446 I: IntoIterator<Item = S>,
1447 S: AsRef<OsStr>,
1448 {
1449 self.exec_with_timeout(program, args, self.config.default_timeout)
1450 }
1451
1452 pub fn exec_with_timeout<I, S>(
1456 &self,
1457 program: &str,
1458 args: I,
1459 timeout: Duration,
1460 ) -> HarnessResult<CommandResult>
1461 where
1462 I: IntoIterator<Item = S>,
1463 S: AsRef<OsStr>,
1464 {
1465 let args: Vec<_> = args.into_iter().collect();
1466 let args_display: Vec<_> = args.iter().map(|s| s.as_ref().to_string_lossy()).collect();
1467
1468 self.logger
1469 .debug(format!("Executing: {} {}", program, args_display.join(" ")));
1470
1471 let start = Instant::now();
1472
1473 let mut cmd = Command::new(program);
1474 cmd.args(&args)
1475 .current_dir(&self.test_dir)
1476 .stdout(Stdio::piped())
1477 .stderr(Stdio::piped());
1478
1479 for (k, v) in &self.config.env_vars {
1481 cmd.env(k, v);
1482 }
1483
1484 let mut child = cmd.spawn()?;
1485 let stdout_handle = child
1486 .stdout
1487 .take()
1488 .map(|mut stdout| thread::spawn(move || Self::read_to_string(&mut stdout)));
1489 let stderr_handle = child
1490 .stderr
1491 .take()
1492 .map(|mut stderr| thread::spawn(move || Self::read_to_string(&mut stderr)));
1493
1494 let mut timed_out = false;
1495 let exit_status = loop {
1496 if let Some(status) = child.try_wait()? {
1497 break Some(status);
1498 }
1499
1500 if start.elapsed() >= timeout {
1501 timed_out = true;
1502 let _ = child.kill();
1503 break child.wait().ok();
1504 }
1505
1506 thread::sleep(Duration::from_millis(10));
1507 };
1508
1509 let duration = start.elapsed();
1510 let stdout = Self::join_output(stdout_handle);
1511 let mut stderr = Self::join_output(stderr_handle);
1512 if timed_out {
1513 if !stderr.is_empty() {
1514 stderr.push('\n');
1515 }
1516 stderr.push_str(&format!("Process timed out after {:?}.", timeout));
1517 }
1518
1519 let exit_code = exit_status
1520 .and_then(|status| status.code())
1521 .unwrap_or(if timed_out { 124 } else { -1 });
1522
1523 let result = CommandResult {
1524 exit_code,
1525 stdout,
1526 stderr,
1527 duration,
1528 };
1529
1530 let command_line = format!("{} {}", program, args_display.join(" "));
1531 let artifact_prefix = format!(
1532 "{}_{}",
1533 Self::sanitize_artifact_component(program),
1534 chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f")
1535 );
1536 let mut artifact_paths = Vec::new();
1537 let trace_payload = serde_json::json!({
1538 "command": command_line,
1539 "program": program,
1540 "args": args_display,
1541 "exit_code": result.exit_code,
1542 "duration_ms": duration.as_millis(),
1543 "timed_out": timed_out,
1544 "stdout_len_bytes": result.stdout.len(),
1545 "stderr_len_bytes": result.stderr.len(),
1546 });
1547 if let Ok(path) = self.logger.capture_artifact_json(
1548 self.logger.test_name(),
1549 &format!("{artifact_prefix}_trace"),
1550 &trace_payload,
1551 ) {
1552 artifact_paths.push(path.display().to_string());
1553 }
1554 if !result.stdout.is_empty()
1555 && let Ok(path) = self.logger.capture_artifact_text(
1556 self.logger.test_name(),
1557 &format!("{artifact_prefix}_stdout"),
1558 &result.stdout,
1559 )
1560 {
1561 artifact_paths.push(path.display().to_string());
1562 }
1563 if !result.stderr.is_empty()
1564 && let Ok(path) = self.logger.capture_artifact_text(
1565 self.logger.test_name(),
1566 &format!("{artifact_prefix}_stderr"),
1567 &result.stderr,
1568 )
1569 {
1570 artifact_paths.push(path.display().to_string());
1571 }
1572
1573 self.logger.log_with_context(
1574 if result.success() {
1575 LogLevel::Debug
1576 } else {
1577 LogLevel::Warn
1578 },
1579 LogSource::Harness,
1580 format!("Command completed: {program}"),
1581 vec![
1582 ("exit_code".to_string(), result.exit_code.to_string()),
1583 ("duration_ms".to_string(), duration.as_millis().to_string()),
1584 ("timed_out".to_string(), timed_out.to_string()),
1585 ],
1586 );
1587
1588 let decision_code = if timed_out {
1589 "CMD_TIMEOUT"
1590 } else if result.success() {
1591 "CMD_SUCCESS"
1592 } else {
1593 "CMD_FAILURE"
1594 };
1595 self.logger.log_reliability_event(ReliabilityEventInput {
1596 level: if result.success() {
1597 LogLevel::Info
1598 } else {
1599 LogLevel::Warn
1600 },
1601 phase: ReliabilityPhase::Execute,
1602 scenario_id: self.logger.test_name().to_string(),
1603 message: format!("Command execution finished: {program}"),
1604 context: ReliabilityContext {
1605 worker_id: None,
1606 repo_set: vec![self.test_dir.display().to_string()],
1607 pressure_state: None,
1608 triage_actions: if timed_out {
1609 vec!["process_killed_after_timeout".to_string()]
1610 } else {
1611 Vec::new()
1612 },
1613 decision_code: decision_code.to_string(),
1614 fallback_reason: if timed_out {
1615 Some(format!("command exceeded timeout {:?}", timeout))
1616 } else {
1617 None
1618 },
1619 },
1620 artifact_paths,
1621 });
1622
1623 if !result.stdout.is_empty() {
1624 for line in result.stdout.lines() {
1625 self.logger.log(
1626 LogLevel::Trace,
1627 LogSource::Custom(program.to_string()),
1628 line,
1629 );
1630 }
1631 }
1632
1633 if !result.stderr.is_empty() {
1634 for line in result.stderr.lines() {
1635 self.logger.log(
1636 LogLevel::Trace,
1637 LogSource::Custom(format!("{program}:stderr")),
1638 line,
1639 );
1640 }
1641 }
1642
1643 Ok(result)
1644 }
1645
1646 fn read_to_string<R: Read>(reader: &mut R) -> String {
1647 let mut buffer = Vec::new();
1648 if reader.read_to_end(&mut buffer).is_ok() {
1649 String::from_utf8_lossy(&buffer).to_string()
1650 } else {
1651 String::new()
1652 }
1653 }
1654
1655 fn join_output(handle: Option<thread::JoinHandle<String>>) -> String {
1656 match handle {
1657 Some(handle) => handle.join().unwrap_or_default(),
1658 None => String::new(),
1659 }
1660 }
1661
1662 fn sanitize_artifact_component(raw: &str) -> String {
1663 let mut sanitized = String::with_capacity(raw.len());
1664 for ch in raw.chars() {
1665 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1666 sanitized.push(ch);
1667 } else {
1668 sanitized.push('_');
1669 }
1670 }
1671 if sanitized.is_empty() {
1672 "artifact".to_string()
1673 } else {
1674 sanitized
1675 }
1676 }
1677
1678 pub fn exec_rch<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1680 where
1681 I: IntoIterator<Item = S>,
1682 S: AsRef<OsStr>,
1683 {
1684 let binary = self.config.rch_binary.to_string_lossy().to_string();
1685 self.exec(&binary, args)
1686 }
1687
1688 pub fn exec_rchd<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1690 where
1691 I: IntoIterator<Item = S>,
1692 S: AsRef<OsStr>,
1693 {
1694 let binary = self.config.rchd_binary.to_string_lossy().to_string();
1695 self.exec(&binary, args)
1696 }
1697
1698 pub fn exec_rch_wkr<I, S>(&self, args: I) -> HarnessResult<CommandResult>
1700 where
1701 I: IntoIterator<Item = S>,
1702 S: AsRef<OsStr>,
1703 {
1704 let binary = self.config.rch_wkr_binary.to_string_lossy().to_string();
1705 self.exec(&binary, args)
1706 }
1707
1708 pub fn wait_for<F>(
1710 &self,
1711 description: &str,
1712 timeout: Duration,
1713 interval: Duration,
1714 condition: F,
1715 ) -> HarnessResult<()>
1716 where
1717 F: Fn() -> bool,
1718 {
1719 self.logger
1720 .debug(format!("Waiting for: {description} (timeout: {timeout:?})"));
1721
1722 let start = Instant::now();
1723 while start.elapsed() < timeout {
1724 if condition() {
1725 self.logger.debug(format!(
1726 "Condition satisfied: {description} after {:?}",
1727 start.elapsed()
1728 ));
1729 return Ok(());
1730 }
1731 std::thread::sleep(interval);
1732 }
1733
1734 self.logger.error(format!(
1735 "Timeout waiting for: {description} after {timeout:?}"
1736 ));
1737 Err(HarnessError::Timeout(timeout))
1738 }
1739
1740 pub fn wait_for_file(&self, path: &Path, timeout: Duration) -> HarnessResult<()> {
1742 let path_display = path.display().to_string();
1743 self.wait_for(
1744 &format!("file to exist: {path_display}"),
1745 timeout,
1746 Duration::from_millis(100),
1747 || path.exists(),
1748 )
1749 }
1750
1751 pub fn wait_for_socket(&self, socket_path: &Path, timeout: Duration) -> HarnessResult<()> {
1753 self.wait_for_socket_with_backoff(socket_path, timeout)
1754 }
1755
1756 pub fn wait_for_socket_with_backoff(
1761 &self,
1762 socket_path: &Path,
1763 max_wait: Duration,
1764 ) -> HarnessResult<()> {
1765 let socket_display = socket_path.display().to_string();
1766 self.logger.debug(format!(
1767 "Waiting for socket with backoff: {socket_display} (timeout: {max_wait:?})"
1768 ));
1769
1770 let start = std::time::Instant::now();
1771 let mut delay = Duration::from_millis(10);
1772 let max_delay = Duration::from_millis(500);
1773
1774 while start.elapsed() < max_wait {
1775 if socket_path.exists() {
1776 #[cfg(unix)]
1777 {
1778 match std::os::unix::net::UnixStream::connect(socket_path) {
1781 Ok(stream) => {
1782 drop(stream);
1783 self.logger.info(format!(
1784 "Socket ready after {:?}: {socket_display}",
1785 start.elapsed()
1786 ));
1787 return Ok(());
1788 }
1789 Err(err) => {
1790 self.logger.debug(format!(
1791 "Socket exists but not connectable yet ({err}); retrying..."
1792 ));
1793 }
1794 }
1795 }
1796
1797 #[cfg(not(unix))]
1798 {
1799 self.logger.info(format!(
1800 "Socket ready after {:?}: {socket_display}",
1801 start.elapsed()
1802 ));
1803 return Ok(());
1804 }
1805 }
1806 std::thread::sleep(delay);
1807 delay = (delay * 2).min(max_delay);
1808 }
1809
1810 let mut evidence = String::new();
1814 if let Ok(entries) = std::fs::read_dir(self.test_dir.join("logs")) {
1815 for entry in entries.flatten() {
1816 let path = entry.path();
1817 let is_stdio = path.extension().is_some_and(|e| e == "out" || e == "err");
1818 if !is_stdio {
1819 continue;
1820 }
1821 if let Ok(content) = std::fs::read_to_string(&path) {
1822 let tail: Vec<&str> = content.lines().rev().take(20).collect();
1823 if !tail.is_empty() {
1824 evidence.push_str(&format!("\n--- tail of {} ---\n", path.display()));
1825 for line in tail.iter().rev() {
1826 evidence.push_str(line);
1827 evidence.push('\n');
1828 }
1829 }
1830 }
1831 }
1832 }
1833 self.logger.error(format!(
1834 "Socket timeout after {max_wait:?}: {socket_display}{evidence}"
1835 ));
1836 Err(HarnessError::Timeout(max_wait))
1837 }
1838
1839 pub fn mark_passed(&self) {
1841 *self.test_passed.lock().unwrap() = true;
1842 self.logger.info("Test marked as PASSED");
1843 }
1844
1845 pub fn mark_failed(&self, reason: &str) {
1847 *self.test_passed.lock().unwrap() = false;
1848 self.logger
1849 .error(format!("Test marked as FAILED: {reason}"));
1850 }
1851
1852 pub fn passed(&self) -> bool {
1854 *self.test_passed.lock().unwrap()
1855 }
1856
1857 pub fn assert(&self, condition: bool, message: &str) -> HarnessResult<()> {
1859 if condition {
1860 self.logger.debug(format!("Assertion passed: {message}"));
1861 self.logger
1862 .log_reliability_event(ReliabilityEventInput::with_decision(
1863 ReliabilityPhase::Verify,
1864 self.logger.test_name().to_string(),
1865 format!("Assertion passed: {message}"),
1866 "ASSERT_PASS",
1867 ));
1868 Ok(())
1869 } else {
1870 self.logger.error(format!("Assertion failed: {message}"));
1871 self.logger
1872 .log_reliability_event(ReliabilityEventInput::with_decision(
1873 ReliabilityPhase::Verify,
1874 self.logger.test_name().to_string(),
1875 format!("Assertion failed: {message}"),
1876 "ASSERT_FAIL",
1877 ));
1878 Err(HarnessError::AssertionFailed(message.to_string()))
1879 }
1880 }
1881
1882 pub fn assert_eq<T: PartialEq + std::fmt::Debug>(
1884 &self,
1885 actual: T,
1886 expected: T,
1887 message: &str,
1888 ) -> HarnessResult<()> {
1889 if actual == expected {
1890 self.logger.debug(format!("Assertion passed: {message}"));
1891 self.logger
1892 .log_reliability_event(ReliabilityEventInput::with_decision(
1893 ReliabilityPhase::Verify,
1894 self.logger.test_name().to_string(),
1895 format!("Equality assertion passed: {message}"),
1896 "ASSERT_EQ_PASS",
1897 ));
1898 Ok(())
1899 } else {
1900 let msg = format!("{}: expected {:?}, got {:?}", message, expected, actual);
1901 self.logger.error(format!("Assertion failed: {msg}"));
1902 self.logger
1903 .log_reliability_event(ReliabilityEventInput::with_decision(
1904 ReliabilityPhase::Verify,
1905 self.logger.test_name().to_string(),
1906 format!("Equality assertion failed: {msg}"),
1907 "ASSERT_EQ_FAIL",
1908 ));
1909 Err(HarnessError::AssertionFailed(msg))
1910 }
1911 }
1912
1913 pub fn assert_success(&self, result: &CommandResult, context: &str) -> HarnessResult<()> {
1915 if result.success() {
1916 self.logger.debug(format!("Command succeeded: {context}"));
1917 self.logger
1918 .log_reliability_event(ReliabilityEventInput::with_decision(
1919 ReliabilityPhase::Verify,
1920 self.logger.test_name().to_string(),
1921 format!("Command success assertion passed: {context}"),
1922 "CMD_ASSERT_SUCCESS",
1923 ));
1924 Ok(())
1925 } else {
1926 let msg = format!(
1927 "{}: command failed with exit code {} - stdout: {}, stderr: {}",
1928 context,
1929 result.exit_code,
1930 result.stdout.trim(),
1931 result.stderr.trim()
1932 );
1933 self.logger.error(&msg);
1934 self.logger
1935 .log_reliability_event(ReliabilityEventInput::with_decision(
1936 ReliabilityPhase::Verify,
1937 self.logger.test_name().to_string(),
1938 format!("Command success assertion failed: {msg}"),
1939 "CMD_ASSERT_FAIL",
1940 ));
1941 Err(HarnessError::AssertionFailed(msg))
1942 }
1943 }
1944
1945 pub fn assert_stdout_contains(
1947 &self,
1948 result: &CommandResult,
1949 pattern: &str,
1950 context: &str,
1951 ) -> HarnessResult<()> {
1952 if result.stdout_contains(pattern) {
1953 self.logger.debug(format!(
1954 "Stdout contains expected pattern: {context} -> {pattern}"
1955 ));
1956 self.logger
1957 .log_reliability_event(ReliabilityEventInput::with_decision(
1958 ReliabilityPhase::Verify,
1959 self.logger.test_name().to_string(),
1960 format!("Stdout assertion passed for pattern '{pattern}'"),
1961 "STDOUT_PATTERN_PASS",
1962 ));
1963 Ok(())
1964 } else {
1965 let msg = format!(
1966 "{}: stdout does not contain '{}'. Actual stdout: {}",
1967 context,
1968 pattern,
1969 result.stdout.trim()
1970 );
1971 self.logger.error(&msg);
1972 self.logger
1973 .log_reliability_event(ReliabilityEventInput::with_decision(
1974 ReliabilityPhase::Verify,
1975 self.logger.test_name().to_string(),
1976 format!("Stdout assertion failed for pattern '{pattern}'"),
1977 "STDOUT_PATTERN_FAIL",
1978 ));
1979 Err(HarnessError::AssertionFailed(msg))
1980 }
1981 }
1982
1983 pub fn cleanup(&self) {
1985 self.logger.info("Starting cleanup");
1986 self.logger
1987 .log_reliability_event(ReliabilityEventInput::with_decision(
1988 ReliabilityPhase::Cleanup,
1989 self.logger.test_name().to_string(),
1990 "Cleanup started",
1991 "CLEANUP_START",
1992 ));
1993
1994 self.stop_all_processes();
1996
1997 let should_cleanup = if *self.test_passed.lock().unwrap() {
1999 self.config.cleanup_on_success
2000 } else {
2001 self.config.cleanup_on_failure
2002 };
2003
2004 if should_cleanup {
2005 self.logger.debug(format!(
2006 "Removing test directory: {}",
2007 self.test_dir.display()
2008 ));
2009 if let Err(e) = std::fs::remove_dir_all(&self.test_dir) {
2010 self.logger
2011 .warn(format!("Failed to remove test directory: {}", e));
2012 }
2013 } else {
2014 self.logger.info(format!(
2015 "Preserving test directory for inspection: {}",
2016 self.test_dir.display()
2017 ));
2018 }
2019
2020 self.logger
2021 .log_reliability_event(ReliabilityEventInput::with_decision(
2022 ReliabilityPhase::Cleanup,
2023 self.logger.test_name().to_string(),
2024 "Cleanup finished",
2025 "CLEANUP_DONE",
2026 ));
2027
2028 self.logger.print_summary();
2029 }
2030}
2031
2032impl Drop for TestHarness {
2033 fn drop(&mut self) {
2034 self.cleanup();
2035 }
2036}
2037
2038pub struct TestHarnessBuilder {
2040 test_name: String,
2041 config: HarnessConfig,
2042}
2043
2044impl TestHarnessBuilder {
2045 pub fn new(test_name: &str) -> Self {
2047 Self {
2048 test_name: test_name.to_string(),
2049 config: HarnessConfig::default(),
2050 }
2051 }
2052
2053 pub fn temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2055 self.config.temp_dir = dir.into();
2056 self
2057 }
2058
2059 pub fn default_timeout(mut self, timeout: Duration) -> Self {
2061 self.config.default_timeout = timeout;
2062 self
2063 }
2064
2065 pub fn cleanup_on_success(mut self, cleanup: bool) -> Self {
2067 self.config.cleanup_on_success = cleanup;
2068 self
2069 }
2070
2071 pub fn cleanup_on_failure(mut self, cleanup: bool) -> Self {
2073 self.config.cleanup_on_failure = cleanup;
2074 self
2075 }
2076
2077 pub fn rch_binary(mut self, path: impl Into<PathBuf>) -> Self {
2079 self.config.rch_binary = path.into();
2080 self
2081 }
2082
2083 pub fn rchd_binary(mut self, path: impl Into<PathBuf>) -> Self {
2085 self.config.rchd_binary = path.into();
2086 self
2087 }
2088
2089 pub fn rch_wkr_binary(mut self, path: impl Into<PathBuf>) -> Self {
2091 self.config.rch_wkr_binary = path.into();
2092 self
2093 }
2094
2095 pub fn env(mut self, key: &str, value: &str) -> Self {
2097 self.config
2098 .env_vars
2099 .insert(key.to_string(), value.to_string());
2100 self
2101 }
2102
2103 pub fn build(self) -> HarnessResult<TestHarness> {
2105 TestHarness::new(&self.test_name, self.config)
2106 }
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111 use super::*;
2112
2113 #[test]
2114 fn test_harness_creation() {
2115 let harness = TestHarnessBuilder::new("test_creation")
2116 .cleanup_on_success(true)
2117 .build()
2118 .unwrap();
2119
2120 assert!(harness.test_dir().exists());
2121 }
2123
2124 #[test]
2129 #[cfg(unix)]
2130 fn test_dead_daemon_startup_stderr_is_captured_not_discarded() {
2131 let harness = TestHarnessBuilder::new("dead_daemon_evidence")
2132 .cleanup_on_success(true)
2133 .cleanup_on_failure(true)
2134 .build()
2135 .unwrap();
2136 harness
2140 .spawn_process(
2141 "daemon",
2142 Path::new("/bin/sh"),
2143 ["-c", "echo doomed-by-config >&2; exit 3"],
2144 None,
2145 )
2146 .unwrap();
2147 let socket = harness.test_dir().join("never.sock");
2148 let result = harness.wait_for_socket(&socket, Duration::from_millis(300));
2149 assert!(result.is_err(), "no socket ever appears");
2150 let captured = std::fs::read_to_string(harness.test_dir().join("logs").join("daemon.err"))
2153 .expect("stderr capture file must exist");
2154 assert!(
2155 captured.contains("doomed-by-config"),
2156 "captured stderr must hold the dying words: {captured:?}"
2157 );
2158 }
2159
2160 #[test]
2161 fn test_harness_directory_leaf_stays_compact_for_unix_sockets() {
2162 let harness = TestHarnessBuilder::new(
2163 "a_descriptive_test_name_that_must_not_expand_the_filesystem_path",
2164 )
2165 .cleanup_on_success(true)
2166 .build()
2167 .unwrap();
2168
2169 let leaf = harness
2170 .test_dir()
2171 .file_name()
2172 .and_then(OsStr::to_str)
2173 .expect("test directory should have a UTF-8 instance id");
2174 assert_eq!(leaf.len(), 12);
2175 assert!(leaf.bytes().all(|byte| byte.is_ascii_hexdigit()));
2176
2177 harness.mark_passed();
2178 }
2179
2180 #[test]
2181 fn test_harness_file_creation() {
2182 let harness = TestHarnessBuilder::new("test_files")
2183 .cleanup_on_success(true)
2184 .build()
2185 .unwrap();
2186
2187 let file_path = harness.create_file("test.txt", "hello world").unwrap();
2188 assert!(file_path.exists());
2189
2190 let content = std::fs::read_to_string(&file_path).unwrap();
2191 assert_eq!(content, "hello world");
2192 }
2193
2194 #[test]
2195 fn test_harness_exec() {
2196 let harness = TestHarnessBuilder::new("test_exec")
2197 .cleanup_on_success(true)
2198 .build()
2199 .unwrap();
2200
2201 let result = harness.exec("echo", ["hello"]).unwrap();
2202 assert!(result.success());
2203 assert!(result.stdout_contains("hello"));
2204 }
2205
2206 #[cfg(unix)]
2207 #[test]
2208 fn test_harness_exec_timeout() {
2209 let harness = TestHarnessBuilder::new("test_exec_timeout")
2210 .cleanup_on_success(true)
2211 .build()
2212 .unwrap();
2213
2214 let result = harness
2215 .exec_with_timeout("sleep", ["1"], Duration::from_millis(50))
2216 .unwrap();
2217
2218 assert!(!result.success());
2219 assert_eq!(result.exit_code, 124);
2220 assert!(result.stderr_contains("timed out"));
2221 }
2222
2223 #[test]
2224 fn test_command_result() {
2225 let result = CommandResult {
2226 exit_code: 0,
2227 stdout: "hello world\n".to_string(),
2228 stderr: "".to_string(),
2229 duration: Duration::from_millis(10),
2230 };
2231
2232 assert!(result.success());
2233 assert!(result.stdout_contains("hello"));
2234 assert!(!result.stderr_contains("error"));
2235 }
2236
2237 #[test]
2238 fn test_harness_assertions() {
2239 let harness = TestHarnessBuilder::new("test_assertions")
2240 .cleanup_on_success(true)
2241 .build()
2242 .unwrap();
2243
2244 harness.assert(true, "should pass").unwrap();
2245 harness.assert_eq(1, 1, "numbers equal").unwrap();
2246
2247 let result = CommandResult {
2248 exit_code: 0,
2249 stdout: "success".to_string(),
2250 stderr: "".to_string(),
2251 duration: Duration::from_millis(1),
2252 };
2253 harness.assert_success(&result, "echo command").unwrap();
2254
2255 harness.mark_passed();
2256 assert!(harness.passed());
2257 }
2258
2259 #[test]
2260 fn test_reliability_harness_phase_order_and_manifest_index() {
2261 let harness = TestHarnessBuilder::new("reliability_harness_phase_order")
2262 .cleanup_on_success(true)
2263 .build()
2264 .unwrap();
2265
2266 let scenario = ReliabilityScenarioSpec::new("reliability_harness_phase_order")
2267 .with_worker_id("worker-a")
2268 .with_repo_set([harness.test_dir().display().to_string()])
2269 .with_pressure_state("disk:normal,memory:normal")
2270 .add_triage_action("initial_context")
2271 .add_pre_check(ReliabilityLifecycleCommand::new(
2272 "pre-check",
2273 "echo",
2274 ["pre-check"],
2275 ))
2276 .add_execute_command(ReliabilityLifecycleCommand::new(
2277 "execute-build",
2278 "echo",
2279 ["execute-build"],
2280 ))
2281 .add_remote_probe(ReliabilityLifecycleCommand::new(
2282 "remote-probe",
2283 "echo",
2284 ["remote-probe"],
2285 ))
2286 .add_post_check(ReliabilityLifecycleCommand::new(
2287 "post-check",
2288 "echo",
2289 ["post-check"],
2290 ))
2291 .add_cleanup_verification(ReliabilityLifecycleCommand::new(
2292 "cleanup-check",
2293 "echo",
2294 ["cleanup-check"],
2295 ));
2296
2297 let report = harness.run_reliability_scenario(&scenario).unwrap();
2298 assert_eq!(
2299 report.phase_order,
2300 vec![
2301 ReliabilityPhase::Setup,
2302 ReliabilityPhase::Execute,
2303 ReliabilityPhase::Verify,
2304 ReliabilityPhase::Cleanup
2305 ]
2306 );
2307 let stages: Vec<_> = report
2308 .command_records
2309 .iter()
2310 .map(|record| record.stage.as_str())
2311 .collect();
2312 assert_eq!(
2313 stages,
2314 vec![
2315 "pre_checks",
2316 "execute",
2317 "remote_probes",
2318 "post_checks",
2319 "cleanup_verification"
2320 ]
2321 );
2322
2323 let manifest_path = report.manifest_path.expect("manifest path should exist");
2324 assert!(manifest_path.exists());
2325 let manifest = std::fs::read_to_string(manifest_path).unwrap();
2326 assert!(manifest.contains("\"scenario_id\": \"reliability_harness_phase_order\""));
2327 }
2328
2329 #[test]
2330 fn test_reliability_harness_denies_unflagged_failure_hooks() {
2331 let harness = TestHarnessBuilder::new("reliability_harness_hook_denied")
2332 .cleanup_on_success(true)
2333 .build()
2334 .unwrap();
2335
2336 let scenario = ReliabilityScenarioSpec::new("reliability_harness_hook_denied")
2337 .request_failure_hook(ReliabilityFailureHook::NetworkCut);
2338
2339 let err = harness
2340 .run_reliability_scenario(&scenario)
2341 .expect_err("unflagged failure hook must be rejected");
2342 assert!(matches!(err, HarnessError::SetupFailed(_)));
2343 assert!(err.to_string().contains("not enabled"));
2344 }
2345
2346 #[test]
2347 fn test_reliability_harness_arms_explicit_failure_hooks() {
2348 let harness = TestHarnessBuilder::new("reliability_harness_hook_enabled")
2349 .cleanup_on_success(true)
2350 .build()
2351 .unwrap();
2352
2353 let flags = ReliabilityFailureHookFlags::allow_all();
2354
2355 let scenario = ReliabilityScenarioSpec::new("reliability_harness_hook_enabled")
2356 .with_repo_set([harness.test_dir().display().to_string()])
2357 .request_failure_hook(ReliabilityFailureHook::NetworkCut)
2358 .request_failure_hook(ReliabilityFailureHook::SyncTimeout)
2359 .request_failure_hook(ReliabilityFailureHook::PartialUpdate)
2360 .request_failure_hook(ReliabilityFailureHook::DaemonRestart)
2361 .with_failure_hook_flags(flags)
2362 .add_cleanup_verification(ReliabilityLifecycleCommand::new(
2363 "cleanup-check",
2364 "echo",
2365 ["cleanup-check"],
2366 ));
2367
2368 let report = harness.run_reliability_scenario(&scenario).unwrap();
2369 assert_eq!(
2370 report.activated_failure_hooks,
2371 vec![
2372 ReliabilityFailureHook::NetworkCut,
2373 ReliabilityFailureHook::SyncTimeout,
2374 ReliabilityFailureHook::PartialUpdate,
2375 ReliabilityFailureHook::DaemonRestart
2376 ]
2377 );
2378 assert!(
2379 report
2380 .artifact_paths
2381 .iter()
2382 .any(|path| path.contains("failure_hook_network_cut"))
2383 );
2384 assert!(
2385 harness
2386 .test_dir()
2387 .join(".reliability-hooks/network_cut.enabled")
2388 .exists()
2389 );
2390 assert!(
2391 harness
2392 .test_dir()
2393 .join(".reliability-hooks/sync_timeout.enabled")
2394 .exists()
2395 );
2396 assert!(
2397 harness
2398 .test_dir()
2399 .join(".reliability-hooks/partial_update.enabled")
2400 .exists()
2401 );
2402 assert!(
2403 harness
2404 .test_dir()
2405 .join(".reliability-hooks/daemon_restart.enabled")
2406 .exists()
2407 );
2408 }
2409
2410 #[test]
2411 fn test_reliability_harness_primitives_cover_downstream_scenarios() {
2412 let harness = TestHarnessBuilder::new("reliability_harness_downstream")
2413 .cleanup_on_success(true)
2414 .build()
2415 .unwrap();
2416
2417 let scenario_ids = ["bd-vvmd.2.8", "bd-vvmd.3.6", "bd-vvmd.4.6", "bd-vvmd.5.6"];
2418 for scenario_id in scenario_ids {
2419 let scenario = ReliabilityScenarioSpec::new(scenario_id)
2420 .with_repo_set([harness.test_dir().display().to_string()])
2421 .add_execute_command(ReliabilityLifecycleCommand::new(
2422 "smoke",
2423 "echo",
2424 [format!("scenario={scenario_id}")],
2425 ));
2426
2427 let report = harness
2428 .run_reliability_scenario(&scenario)
2429 .unwrap_or_else(|error| panic!("scenario {scenario_id} failed: {error}"));
2430 assert!(
2431 report.manifest_path.is_some(),
2432 "missing manifest for {scenario_id}"
2433 );
2434 assert!(
2435 report
2436 .command_records
2437 .iter()
2438 .any(|record| record.stage == "execute"),
2439 "missing execute stage record for {scenario_id}"
2440 );
2441 }
2442 }
2443}