Skip to main content

rch_common/e2e/
harness.rs

1//! E2E Test Harness Framework
2//!
3//! Provides infrastructure for running end-to-end tests including:
4//! - Process lifecycle management (start/stop daemon, workers)
5//! - Temporary directory and file management
6//! - Command execution with output capture
7//! - Assertions and matchers for test validation
8
9use 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/// Error type for test harness operations
27#[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
54/// Result type for harness operations
55pub type HarnessResult<T> = Result<T, HarnessError>;
56
57/// Information about a managed process
58#[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    /// Check if the process is still running
68    pub fn is_running(&mut self) -> bool {
69        matches!(self.child.try_wait(), Ok(None))
70    }
71
72    /// Get the process exit status (non-blocking)
73    pub fn try_exit_status(&mut self) -> Option<ExitStatus> {
74        self.child.try_wait().ok().flatten()
75    }
76
77    /// Kill the process
78    pub fn kill(&mut self) -> std::io::Result<()> {
79        self.child.kill()
80    }
81
82    /// Wait for the process to exit
83    pub fn wait(&mut self) -> std::io::Result<ExitStatus> {
84        self.child.wait()
85    }
86
87    /// Take stdout for reading (can only be called once)
88    pub fn take_stdout(&mut self) -> Option<std::process::ChildStdout> {
89        self.child.stdout.take()
90    }
91
92    /// Take stderr for reading (can only be called once)
93    pub fn take_stderr(&mut self) -> Option<std::process::ChildStderr> {
94        self.child.stderr.take()
95    }
96}
97
98/// Result of a command execution
99#[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    /// Check if the command succeeded (exit code 0)
109    pub fn success(&self) -> bool {
110        self.exit_code == 0
111    }
112
113    /// Check if stdout contains a pattern
114    pub fn stdout_contains(&self, pattern: &str) -> bool {
115        self.stdout.contains(pattern)
116    }
117
118    /// Check if stderr contains a pattern
119    pub fn stderr_contains(&self, pattern: &str) -> bool {
120        self.stderr.contains(pattern)
121    }
122
123    /// Get combined output (stdout + stderr)
124    pub fn combined_output(&self) -> String {
125        format!("{}\n{}", self.stdout, self.stderr)
126    }
127}
128
129/// Configuration for the test harness
130#[derive(Debug, Clone)]
131pub struct HarnessConfig {
132    /// Base temporary directory for test artifacts
133    pub temp_dir: PathBuf,
134    /// Default timeout for commands
135    pub default_timeout: Duration,
136    /// Whether to clean up temp files on success
137    pub cleanup_on_success: bool,
138    /// Whether to clean up temp files on failure
139    pub cleanup_on_failure: bool,
140    /// Path to the rch binary
141    pub rch_binary: PathBuf,
142    /// Path to the rchd binary
143    pub rchd_binary: PathBuf,
144    /// Path to the rch-wkr binary
145    pub rch_wkr_binary: PathBuf,
146    /// Environment variables to set for all processes
147    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        // Find binaries in target/debug or target/release.
166        //
167        // NOTE: The harness spawns processes with `current_dir = test_dir`, so we must resolve
168        // binary paths relative to the workspace root (not the per-test temp dir).
169        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/// Failure hooks that can be explicitly injected into reliability scenarios.
253#[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/// Explicit allowlist for failure hooks. Hooks are denied unless enabled here.
275#[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    /// Enable all hooks for explicit high-risk test scenarios.
285    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    /// Returns true when the provided hook is explicitly enabled.
295    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/// One deterministic lifecycle command invoked by the reliability scenario runner.
310#[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    /// Build a command with required-success semantics.
324    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    /// Override timeout in seconds for this command.
340    pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
341        self.timeout_secs = Some(timeout_secs);
342        self
343    }
344
345    /// Marks this command optional; failures are logged but do not fail the scenario.
346    pub fn optional(mut self) -> Self {
347        self.required_success = false;
348        self
349    }
350
351    /// Run command via `rch exec -- <program> <args...>`.
352    pub fn via_rch_exec(mut self) -> Self {
353        self.via_rch_exec = true;
354        self
355    }
356}
357
358/// Worker lifecycle hooks consumed by reliability scenario suites.
359#[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/// Full reliability scenario definition used by the deterministic runner.
368#[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    /// Creates a new scenario with deterministic defaults.
383    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/// Recorded result of one lifecycle command execution.
454#[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/// Replay-focused artifact index for one scenario run.
469#[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
494/// Clean up stale sockets and test directories from previous test runs.
495///
496/// This function should be called during test harness setup to prevent
497/// leftover sockets from causing connection failures in new tests.
498///
499/// # Arguments
500/// * `base_dir` - The base directory containing test artifacts (e.g., `/tmp/rch_e2e_tests`)
501/// * `max_age` - Maximum age of directories to keep (default: 1 hour)
502pub 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    // First pass: clean up stale sockets in all test directories
512    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            // Check if the directory is old enough to clean
520            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            // Clean up sockets in stale directories
532            if is_stale {
533                // Look for socket files
534                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                // Try to remove the stale directory
546                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
561/// E2E Test Harness for managing test execution
562pub 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    /// Create a new test harness with the given configuration
574    pub fn new(test_name: &str, config: HarnessConfig) -> HarnessResult<Self> {
575        // Clean up stale artifacts from previous test runs (older than 1 hour)
576        cleanup_stale_test_artifacts(&config.temp_dir, Duration::from_secs(3600));
577
578        // Keep the filesystem component compact. RCH intentionally places
579        // remote TMPDIR beneath the project root, and embedding a descriptive
580        // test name plus timestamp here can push descendant Unix socket paths
581        // beyond sockaddr_un::sun_path (108 bytes on Linux). The descriptive
582        // name remains in every log and reliability event; a 48-bit random id
583        // is ample isolation for ephemeral per-process test directories.
584        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        // Create logger with log dir in test directory
590        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    /// Create a harness with default configuration
624    pub fn default_for_test(test_name: &str) -> HarnessResult<Self> {
625        Self::new(test_name, HarnessConfig::default())
626    }
627
628    /// Get the test directory path
629    pub fn test_dir(&self) -> &Path {
630        &self.test_dir
631    }
632
633    /// Run a reliability scenario with deterministic setup/execute/verify/cleanup phases.
634    ///
635    /// This is the shared foundation consumed by scenario-specific suites (path dependencies,
636    /// repo convergence, disk pressure, and process triage) so they can reuse one orchestration
637    /// engine instead of bespoke scaffolding.
638    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            // Route invoked args through the shared secret redactor before they
1057            // land in the JSONL artifact (bd-53ga7): a `--token <secret>` or
1058            // `KEY=secret` arg must not survive into the recorded transcript.
1059            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    /// Create a subdirectory in the test directory
1230    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    /// Create a file in the test directory
1240    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    /// Create a config file for the daemon
1253    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    /// Create a workers config file
1263    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    /// Spawn a managed process
1274    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        // Capture stdout/stderr to FILES under <test_dir>/logs — not pipes
1287        // (a long-running daemon can fill the 64KB pipe buffer and block),
1288        // and not Stdio::null() (which discarded the only evidence when a
1289        // daemon died during startup: every such failure surfaced as an
1290        // undiagnosable 10s socket timeout — bead tzk2s). File-backed
1291        // stdio keeps the no-pipe-blocking property AND the diagnostics;
1292        // wait_for_socket appends the file tails to its timeout error.
1293        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        // Set default environment variables
1308        for (k, v) in &self.config.env_vars {
1309            cmd.env(k, v);
1310        }
1311
1312        // Set additional environment variables
1313        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    /// Start the daemon process
1353    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    /// Stop a managed process by name
1363    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    /// Wait for a managed process to exit without killing it.
1379    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    /// Stop all managed processes
1422    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    /// Execute a command and capture output
1444    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    /// Execute a command with a specific timeout
1453    ///
1454    /// Terminates the process if it exceeds the provided timeout.
1455    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        // Set default environment variables
1480        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    /// Execute the rch binary
1679    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    /// Execute the rchd binary
1689    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    /// Execute the rch-wkr binary
1699    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    /// Wait for a condition to become true
1709    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    /// Wait for a file to exist
1741    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    /// Wait for a socket to be available
1752    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    /// Wait for a socket using exponential backoff for more reliable detection.
1757    ///
1758    /// Starts with a 10ms delay and doubles up to a maximum of 500ms per iteration.
1759    /// This reduces CPU usage while maintaining responsiveness for fast-starting daemons.
1760    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                    // A socket file can exist while the daemon is not yet accepting connections
1779                    // (or after a previous process died). Prefer probing connect() to avoid flakes.
1780                    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        // The timeout is the SYMPTOM; the captured spawned-process
1811        // stdio is the evidence (bead tzk2s: Stdio::null made every
1812        // startup death an undiagnosable timeout). Dump the tails.
1813        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    /// Mark the test as passed
1840    pub fn mark_passed(&self) {
1841        *self.test_passed.lock().unwrap() = true;
1842        self.logger.info("Test marked as PASSED");
1843    }
1844
1845    /// Mark the test as failed with a reason
1846    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    /// Check if the test passed
1853    pub fn passed(&self) -> bool {
1854        *self.test_passed.lock().unwrap()
1855    }
1856
1857    /// Assert that a condition is true
1858    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    /// Assert that two values are equal
1883    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    /// Assert that a command result succeeded
1914    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    /// Assert that a command result contains expected output
1946    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    /// Perform cleanup
1984    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        // Stop all managed processes
1995        self.stop_all_processes();
1996
1997        // Determine if we should clean up files
1998        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
2038/// Builder for creating a TestHarness with custom configuration
2039pub struct TestHarnessBuilder {
2040    test_name: String,
2041    config: HarnessConfig,
2042}
2043
2044impl TestHarnessBuilder {
2045    /// Create a new builder for the given test name
2046    pub fn new(test_name: &str) -> Self {
2047        Self {
2048            test_name: test_name.to_string(),
2049            config: HarnessConfig::default(),
2050        }
2051    }
2052
2053    /// Set the temp directory
2054    pub fn temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2055        self.config.temp_dir = dir.into();
2056        self
2057    }
2058
2059    /// Set the default command timeout
2060    pub fn default_timeout(mut self, timeout: Duration) -> Self {
2061        self.config.default_timeout = timeout;
2062        self
2063    }
2064
2065    /// Set whether to cleanup on success
2066    pub fn cleanup_on_success(mut self, cleanup: bool) -> Self {
2067        self.config.cleanup_on_success = cleanup;
2068        self
2069    }
2070
2071    /// Set whether to cleanup on failure
2072    pub fn cleanup_on_failure(mut self, cleanup: bool) -> Self {
2073        self.config.cleanup_on_failure = cleanup;
2074        self
2075    }
2076
2077    /// Set the rch binary path
2078    pub fn rch_binary(mut self, path: impl Into<PathBuf>) -> Self {
2079        self.config.rch_binary = path.into();
2080        self
2081    }
2082
2083    /// Set the rchd binary path
2084    pub fn rchd_binary(mut self, path: impl Into<PathBuf>) -> Self {
2085        self.config.rchd_binary = path.into();
2086        self
2087    }
2088
2089    /// Set the rch-wkr binary path
2090    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    /// Add an environment variable
2096    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    /// Build the TestHarness
2104    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        // Will cleanup on drop
2122    }
2123
2124    /// tzk2s acceptance (evidence half): a spawned process that dies
2125    /// during startup leaves its stderr in <test_dir>/logs, and a
2126    /// socket wait that times out is diagnosable from the captured
2127    /// files instead of being a bare 10s timeout.
2128    #[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        // A "daemon" that prints its dying words and exits before any
2137        // socket can appear — the exact shape Stdio::null() used to
2138        // erase.
2139        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        // The dying words are ON DISK under logs/ — the evidence the
2151        // timeout error also tails into the harness log.
2152        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}