use std::ops::RangeInclusive;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::assertion::{AssertionFailure, MatchResult};
use crate::frame::TerminalFrame;
use crate::journey::Journey;
use crate::proof::report::ProofReport;
use crate::session::{PtySession, PtySessionBuilder, PtySessionError};
use crate::step::Step;
use crate::vhs::VhsTape;
#[must_use]
pub struct Scenario {
pub name: String,
pub steps: Vec<Step>,
}
impl Scenario {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
steps: Vec::new(),
}
}
pub fn step(mut self, step: Step) -> Self {
self.steps.push(step);
self
}
pub fn write_text(self, text: impl Into<String>) -> Self {
self.step(Step::write_text(text))
}
pub fn press_key(self, key: impl Into<String>) -> Self {
self.step(Step::press_key(key))
}
pub fn sleep(self, duration: Duration) -> Self {
self.step(Step::sleep(duration))
}
pub fn sleep_ms(self, ms: u64) -> Self {
self.step(Step::sleep_ms(ms))
}
pub fn wait_for_text(self, needle: impl Into<String>, timeout_ms: u32) -> Self {
self.step(Step::wait_for_text(needle, timeout_ms))
}
pub fn wait_for_stable_frame(self, stable_ms: u32, timeout_ms: u32) -> Self {
self.step(Step::wait_for_stable_frame(stable_ms, timeout_ms))
}
pub fn eventually<F>(self, timeout: Duration, poll: Duration, predicate: F) -> Self
where
F: Fn(&TerminalFrame) -> MatchResult + Send + Sync + 'static,
{
self.step(Step::eventually(timeout, poll, predicate))
}
pub fn viewing_pause(self, duration: Duration) -> Self {
self.step(Step::viewing_pause(duration))
}
pub fn viewing_pause_ms(self, ms: u64) -> Self {
self.step(Step::viewing_pause_ms(ms))
}
pub fn capture(self) -> Self {
self.step(Step::capture())
}
pub fn capture_labeled(self, label: impl Into<String>, description: impl Into<String>) -> Self {
self.step(Step::capture_labeled(label, description))
}
pub fn compose(mut self, journey: &Journey) -> Self {
self.steps.extend(journey.steps.iter().cloned());
self
}
pub fn execute_in_pty(
&self,
session: &mut PtySession,
) -> Result<crate::frame::TerminalFrame, PtySessionError> {
session.execute_steps(&self.steps)
}
pub fn execute_in_pty_with_proof(
&self,
session: &mut PtySession,
) -> Result<(crate::frame::TerminalFrame, ProofReport), PtySessionError> {
let mut report = ProofReport::new(&self.name);
let mut last_frame = None;
for step_range in self.proof_step_ranges() {
let final_step_index = *step_range.end();
let frame = session.execute_steps(&self.steps[step_range])?;
if let Step::CaptureLabeled { label, description } = &self.steps[final_step_index] {
report.add_capture(label, description, &frame);
}
last_frame = Some(frame);
}
let final_frame = last_frame.unwrap_or_else(|| session.capture_frame());
Ok((final_frame, report))
}
pub fn run(
&self,
builder: PtySessionBuilder,
) -> Result<crate::frame::TerminalFrame, PtySessionError> {
let mut session = builder.spawn()?;
self.execute_in_pty(&mut session)
}
pub fn run_with_proof(
&self,
builder: PtySessionBuilder,
) -> Result<(crate::frame::TerminalFrame, ProofReport), PtySessionError> {
let mut session = builder.spawn()?;
self.execute_in_pty_with_proof(&mut session)
}
pub fn to_vhs_tape(
&self,
binary_path: &Path,
screenshot_path: &Path,
env_vars: &[(&str, &str)],
) -> VhsTape {
VhsTape::from_scenario(self, binary_path, screenshot_path, env_vars)
}
pub fn write_vhs_tape(
&self,
binary_path: &Path,
screenshot_path: &Path,
env_vars: &[(&str, &str)],
tape_path: &Path,
) -> Result<PathBuf, std::io::Error> {
let tape = self.to_vhs_tape(binary_path, screenshot_path, env_vars);
tape.write_to(tape_path)?;
Ok(tape_path.to_path_buf())
}
fn proof_step_ranges(&self) -> Vec<RangeInclusive<usize>> {
let mut ranges = Vec::new();
let mut range_start = 0;
for (step_index, step) in self.steps.iter().enumerate() {
if matches!(step, Step::CaptureLabeled { .. }) {
ranges.push(range_start..=step_index);
range_start = step_index + 1;
}
}
if range_start < self.steps.len() {
ranges.push(range_start..=self.steps.len() - 1);
}
ranges
}
}
pub(crate) fn eventually_loop<F, S, N>(
timeout: Duration,
poll: Duration,
mut frame_source: F,
predicate: &(dyn Fn(&TerminalFrame) -> MatchResult + Send + Sync),
mut sleep: S,
mut now: N,
) -> Result<TerminalFrame, Box<AssertionFailure>>
where
F: FnMut() -> TerminalFrame,
S: FnMut(Duration),
N: FnMut() -> Instant,
{
let deadline = now() + timeout;
loop {
let frame = frame_source();
let failure = match predicate(&frame) {
Ok(()) => return Ok(frame),
Err(failure) => failure,
};
let remaining = deadline
.checked_duration_since(now())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
return Err(failure);
}
sleep(poll.min(remaining));
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::assertion::{AssertionFailure, Expected};
#[test]
fn scenario_builder_chains_steps() {
let scenario = Scenario::new("test")
.write_text("hello")
.press_key("Enter")
.sleep_ms(100)
.capture();
assert_eq!(scenario.name, "test");
assert_eq!(scenario.steps.len(), 4);
}
#[test]
fn scenario_compiles_to_vhs_tape() {
let scenario = Scenario::new("startup").sleep_ms(500).capture();
let tape = scenario.to_vhs_tape(
Path::new("/usr/bin/echo"),
Path::new("/tmp/screenshot.png"),
&[],
);
let content = tape.render();
assert!(content.contains("Screenshot"));
assert!(content.contains("Sleep"));
}
#[test]
fn scenario_capture_labeled_adds_step() {
let scenario = Scenario::new("labeled")
.capture_labeled("init", "Initial state")
.capture_labeled("done", "Final state");
assert_eq!(scenario.steps.len(), 2);
}
#[test]
fn proof_step_ranges_end_at_labeled_captures() {
let scenario = Scenario::new("proof-batches")
.press_key("Tab")
.viewing_pause_ms(500)
.capture_labeled("first", "First capture")
.write_text("hello")
.capture_labeled("second", "Second capture")
.press_key("Enter");
let ranges = scenario.proof_step_ranges();
assert_eq!(ranges, vec![0..=2, 3..=4, 5..=5]);
}
#[test]
fn scenario_compose_appends_journey_steps() {
let startup = Journey::wait_for_startup(300, 5000);
let navigate = Journey::navigate_with_key("Tab", "Sessions", 3000);
let scenario = Scenario::new("composed")
.compose(&startup)
.compose(&navigate)
.capture();
assert_eq!(scenario.steps.len(), 4);
}
#[test]
fn eventually_loop_returns_ok_after_predicate_succeeds() {
let frame_calls = AtomicUsize::new(0);
let predicate_calls = AtomicUsize::new(0);
let now_calls = AtomicUsize::new(0);
let sleep_calls = Mutex::new(Vec::<Duration>::new());
let predicate: Box<dyn Fn(&TerminalFrame) -> MatchResult + Send + Sync> =
Box::new(|_frame: &TerminalFrame| {
let count = predicate_calls.fetch_add(1, Ordering::SeqCst) + 1;
if count >= 3 {
Ok(())
} else {
Err(Box::new(AssertionFailure {
message: format!("not yet (call {count})"),
expected: Expected::TextInRegion {
needle: "Ready".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
}))
}
});
let start = Instant::now();
let result = eventually_loop(
Duration::from_mins(1),
Duration::from_millis(10),
|| {
frame_calls.fetch_add(1, Ordering::SeqCst);
TerminalFrame::new(80, 24, b"")
},
predicate.as_ref(),
|duration| {
sleep_calls
.lock()
.expect("sleep mutex must not be poisoned")
.push(duration);
},
|| {
now_calls.fetch_add(1, Ordering::SeqCst);
start
},
);
assert!(result.is_ok(), "expected Ok once the predicate succeeds");
assert!(
predicate_calls.load(Ordering::SeqCst) >= 2,
"predicate must run at least twice before succeeding, got {}",
predicate_calls.load(Ordering::SeqCst)
);
assert_eq!(
frame_calls.load(Ordering::SeqCst),
predicate_calls.load(Ordering::SeqCst),
"every tick must re-read the frame"
);
let recorded_sleeps = sleep_calls
.lock()
.expect("sleep mutex must not be poisoned")
.clone();
assert!(
recorded_sleeps
.iter()
.all(|duration| *duration == Duration::from_millis(10)),
"every sleep tick must use the configured poll interval"
);
}
#[test]
fn eventually_loop_returns_last_failure_on_timeout() {
let predicate_calls = AtomicUsize::new(0);
let now_calls = AtomicUsize::new(0);
let start = Instant::now();
let timeout = Duration::from_millis(50);
let predicate: Box<dyn Fn(&TerminalFrame) -> MatchResult + Send + Sync> =
Box::new(|_frame: &TerminalFrame| {
let count = predicate_calls.fetch_add(1, Ordering::SeqCst) + 1;
Err(Box::new(AssertionFailure {
message: format!("attempt {count} failed"),
expected: Expected::TextInRegion {
needle: "Ready".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
}))
});
let result = eventually_loop(
timeout,
Duration::from_millis(5),
|| TerminalFrame::new(80, 24, b""),
predicate.as_ref(),
|_duration| {},
|| {
let count = now_calls.fetch_add(1, Ordering::SeqCst);
if count == 0 {
start
} else {
start + timeout + Duration::from_millis(1)
}
},
);
let Err(failure) = result else {
unreachable!("loop must time out when predicate never succeeds");
};
assert_eq!(
predicate_calls.load(Ordering::SeqCst),
1,
"the surfaced failure must be the most recent predicate result"
);
assert_eq!(failure.message, "attempt 1 failed");
assert!(matches!(
failure.expected,
Expected::TextInRegion { ref needle, .. } if needle == "Ready"
));
}
#[test]
fn eventually_loop_clamps_final_sleep_to_remaining_budget() {
let predicate_calls = AtomicUsize::new(0);
let now_calls = AtomicUsize::new(0);
let sleep_calls = Mutex::new(Vec::<Duration>::new());
let start = Instant::now();
let timeout = Duration::from_millis(50);
let poll = Duration::from_millis(40);
let predicate: Box<dyn Fn(&TerminalFrame) -> MatchResult + Send + Sync> =
Box::new(|_frame: &TerminalFrame| {
predicate_calls.fetch_add(1, Ordering::SeqCst);
Err(Box::new(AssertionFailure {
message: "still failing".to_string(),
expected: Expected::TextInRegion {
needle: "Ready".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
}))
});
let result = eventually_loop(
timeout,
poll,
|| TerminalFrame::new(80, 24, b""),
predicate.as_ref(),
|duration| {
sleep_calls
.lock()
.expect("sleep mutex must not be poisoned")
.push(duration);
},
|| {
let count = now_calls.fetch_add(1, Ordering::SeqCst);
start + Duration::from_millis(30 * count as u64)
},
);
assert!(result.is_err(), "predicate never succeeds");
assert_eq!(
predicate_calls.load(Ordering::SeqCst),
2,
"the loop must give the predicate one final tick after the clamped sleep"
);
let recorded_sleeps = sleep_calls
.lock()
.expect("sleep mutex must not be poisoned")
.clone();
assert_eq!(
recorded_sleeps,
vec![Duration::from_millis(20)],
"final sleep must be clamped to the remaining time before the deadline"
);
}
#[test]
fn scenario_compose_preserves_existing_steps() {
let journey = Journey::type_and_confirm("hello");
let scenario = Scenario::new("mixed")
.sleep_ms(100)
.compose(&journey)
.capture();
assert_eq!(scenario.steps.len(), 4);
}
}