Skip to main content

ryra_test/
scenario.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5// ---------------------------------------------------------------------------
6// Result types — full trace of what happened in each test
7// ---------------------------------------------------------------------------
8
9/// Optional callback fired after each step completes. Receives the
10/// finished Event by reference so consumers can stream progress without
11/// waiting for the entire test to finish.
12pub type OnEvent = Arc<dyn Fn(&Event) + Send + Sync>;
13
14#[derive(Debug)]
15pub struct ScenarioResult {
16    pub name: String,
17    pub events: Vec<Event>,
18    pub duration: Duration,
19    pub outcome: Outcome,
20}
21
22#[derive(Debug)]
23pub struct Event {
24    pub description: String,
25    pub kind: EventKind,
26    pub outcome: Outcome,
27    pub duration: Duration,
28    /// Captured stdout from the step. Empty on failure (the error message
29    /// already embeds it) and for non-command events (waits, assertions).
30    pub stdout: String,
31    /// Captured stderr from the step. Same rules as stdout.
32    pub stderr: String,
33}
34
35impl Event {
36    /// Event with no captured output — for wait/service-status checks that
37    /// don't run a shell command.
38    pub fn bare(
39        description: String,
40        kind: EventKind,
41        outcome: Outcome,
42        duration: Duration,
43    ) -> Self {
44        Self {
45            description,
46            kind,
47            outcome,
48            duration,
49            stdout: String::new(),
50            stderr: String::new(),
51        }
52    }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum EventKind {
57    Step,
58    Assertion,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Outcome {
63    Passed,
64    Failed(String),
65    Skipped,
66}
67
68impl Outcome {
69    pub fn is_pass(&self) -> bool {
70        matches!(self, Outcome::Passed)
71    }
72
73    pub fn is_fail(&self) -> bool {
74        matches!(self, Outcome::Failed(_))
75    }
76}
77
78impl ScenarioResult {
79    pub fn passed(&self) -> bool {
80        self.outcome.is_pass()
81    }
82
83    /// A one-line summary of *why* this test failed: the failing step's
84    /// description plus the first line of its error. Falls back to the
85    /// top-level message for setup failures that produced no events. Returns
86    /// `None` when the test didn't fail.
87    pub fn failure_summary(&self) -> Option<String> {
88        let Outcome::Failed(top) = &self.outcome else {
89            return None;
90        };
91        let one_line = |s: &str| -> String {
92            let first = s.lines().next().unwrap_or("").trim();
93            if first.chars().count() > 100 {
94                format!("{}…", first.chars().take(99).collect::<String>())
95            } else {
96                first.to_string()
97            }
98        };
99        // Prefer the specific failing step/assert over the generic top-level
100        // message, so the reader sees which step broke and how.
101        for ev in &self.events {
102            if let Outcome::Failed(msg) = &ev.outcome {
103                return Some(format!("{}: {}", ev.description, one_line(msg)));
104            }
105        }
106        Some(one_line(top))
107    }
108}
109
110impl fmt::Display for ScenarioResult {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        let icon = match &self.outcome {
113            Outcome::Passed => "PASS",
114            Outcome::Failed(_) => "FAIL",
115            Outcome::Skipped => "SKIP",
116        };
117        writeln!(
118            f,
119            "{icon}  {} ({:.1}s)",
120            self.name,
121            self.duration.as_secs_f64()
122        )?;
123
124        // Show top-level failure reason when there are no events (setup failure)
125        if self.events.is_empty()
126            && let Outcome::Failed(msg) = &self.outcome
127        {
128            writeln!(f, "  {msg}")?;
129        }
130
131        for event in &self.events {
132            let mark = match &event.outcome {
133                Outcome::Passed => " ok ",
134                Outcome::Failed(_) => "FAIL",
135                Outcome::Skipped => "skip",
136            };
137            let kind_label = match event.kind {
138                EventKind::Step => "step",
139                EventKind::Assertion => "assert",
140            };
141            write!(
142                f,
143                "  [{mark}] {kind_label}: {} ({:.1}s)",
144                event.description,
145                event.duration.as_secs_f64()
146            )?;
147            if let Outcome::Failed(msg) = &event.outcome {
148                write!(f, "\n         {msg}")?;
149            }
150            writeln!(f)?;
151
152            // Emit captured stdout/stderr verbatim, indented. On failure the
153            // error message already embeds them, so we only render here when
154            // the step passed.
155            if matches!(event.outcome, Outcome::Passed) {
156                render_output(f, "stdout", &event.stdout)?;
157                render_output(f, "stderr", &event.stderr)?;
158            }
159        }
160
161        Ok(())
162    }
163}
164
165fn render_output(f: &mut fmt::Formatter<'_>, label: &str, text: &str) -> fmt::Result {
166    let trimmed = text.trim_end_matches('\n');
167    if trimmed.is_empty() {
168        return Ok(());
169    }
170    writeln!(f, "         [{label}]")?;
171    for line in trimmed.lines() {
172        writeln!(f, "         | {line}")?;
173    }
174    Ok(())
175}