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