use std::time::Duration;
use super::model::{ExpectSpec, SUPPORTED_VERSION, ScenarioSpec, StepSpec};
use crate::assertion::{self, AssertionFailure, MatchResult};
use crate::frame::TerminalFrame;
use crate::recipe;
use crate::region::Region;
use crate::scenario::Scenario;
use crate::session::{PtySessionBuilder, PtySessionError};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SpecError {
#[error("scenario I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("scenario parse error: {0}")]
Parse(#[from] serde_yaml_ng::Error),
#[error("unsupported scenario version {found} (this build supports version {supported})")]
UnsupportedVersion {
found: u32,
supported: u32,
},
}
impl ScenarioSpec {
pub fn from_yaml(input: &str) -> Result<Self, SpecError> {
let spec: ScenarioSpec = serde_yaml_ng::from_str(input)?;
if spec.version != SUPPORTED_VERSION {
return Err(SpecError::UnsupportedVersion {
found: spec.version,
supported: SUPPORTED_VERSION,
});
}
Ok(spec)
}
pub fn lower(&self) -> LoweredScenario {
let mut builder = PtySessionBuilder::new(self.session.bin.clone());
if let Some([cols, rows]) = self.session.size {
builder = builder.size(cols, rows);
}
if !self.session.args.is_empty() {
builder = builder.args(self.session.args.clone());
}
for (key, value) in &self.session.env {
builder = builder.env(key, value);
}
if let Some(workdir) = &self.session.workdir {
builder = builder.workdir(workdir.clone());
}
let name = self.name.clone().unwrap_or_else(|| "scenario".to_string());
let mut scenario = Scenario::new(name);
for step in &self.steps {
scenario = lower_step(scenario, step);
}
LoweredScenario {
builder,
scenario,
expect: self.expect.clone(),
}
}
}
pub struct LoweredScenario {
pub builder: PtySessionBuilder,
pub scenario: Scenario,
expect: Vec<ExpectSpec>,
}
impl LoweredScenario {
pub fn run(self) -> Result<(TerminalFrame, Vec<AssertionFailure>), PtySessionError> {
let LoweredScenario {
builder,
scenario,
expect,
} = self;
let frame = scenario.run(builder)?;
let failures = check_expectations(&expect, &frame);
Ok((frame, failures))
}
pub fn check(&self, frame: &TerminalFrame) -> Vec<AssertionFailure> {
check_expectations(&self.expect, frame)
}
}
fn check_expectations(expect: &[ExpectSpec], frame: &TerminalFrame) -> Vec<AssertionFailure> {
let mut failures = Vec::new();
for spec in expect {
if let Err(failure) = evaluate(spec, frame) {
failures.push(*failure);
}
}
failures
}
fn lower_step(scenario: Scenario, step: &StepSpec) -> Scenario {
match step {
StepSpec::PressKey(key) => scenario.press_key(key.clone()),
StepSpec::WriteText(text) => scenario.write_text(text.clone()),
StepSpec::SleepMs(ms) => scenario.sleep_ms(*ms),
StepSpec::WaitForText { needle, timeout_ms } => {
scenario.wait_for_text(needle.clone(), *timeout_ms)
}
StepSpec::WaitForStableFrame {
stable_ms,
timeout_ms,
} => scenario.wait_for_stable_frame(*stable_ms, *timeout_ms),
StepSpec::Eventually {
matcher,
timeout_ms,
poll_ms,
} => {
let matcher = matcher.clone();
scenario.eventually(
Duration::from_millis(*timeout_ms),
Duration::from_millis(*poll_ms),
move |frame| evaluate(&matcher, frame),
)
}
StepSpec::Capture => scenario.capture(),
StepSpec::CaptureLabeled { label, description } => {
scenario.capture_labeled(label.clone(), description.clone())
}
}
}
fn evaluate(spec: &ExpectSpec, frame: &TerminalFrame) -> MatchResult {
match spec {
ExpectSpec::SelectedTab(label) => recipe::match_selected_tab(frame, label),
ExpectSpec::UnselectedTab(label) => recipe::match_unselected_tab(frame, label),
ExpectSpec::InstructionVisible(text) => recipe::match_instruction_visible(frame, text),
ExpectSpec::KeybindingHint(text) => recipe::match_keybinding_hint(frame, text),
ExpectSpec::FooterAction(text) => recipe::match_footer_action(frame, text),
ExpectSpec::DialogTitle(text) => recipe::match_dialog_title(frame, text),
ExpectSpec::StatusMessage(text) => recipe::match_status_message(frame, text),
ExpectSpec::NotVisible(text) => recipe::match_not_visible(frame, text),
ExpectSpec::TextInRegion { text, region } => assertion::match_text_in_region(
frame,
text,
&Region::new(region.0, region.1, region.2, region.3),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::model::RegionSpec;
#[test]
fn from_yaml_rejects_unsupported_version() {
let yaml = "
version: 999
session:
bin: ./app
";
let result = ScenarioSpec::from_yaml(yaml);
assert!(matches!(
result,
Err(SpecError::UnsupportedVersion {
found: 999,
supported: 1
})
));
}
#[test]
fn from_yaml_accepts_supported_version() {
let yaml = "
version: 1
session:
bin: ./app
steps:
- press_key: Tab
";
let spec = ScenarioSpec::from_yaml(yaml).expect("supported version parses");
assert_eq!(spec.steps.len(), 1);
}
#[test]
fn evaluate_text_in_region_passes_when_present() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let spec = ExpectSpec::TextInRegion {
text: "World".to_string(),
region: RegionSpec(0, 0, 80, 1),
};
let result = evaluate(&spec, &frame);
assert!(result.is_ok());
}
#[test]
fn evaluate_not_visible_fails_when_text_present() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let spec = ExpectSpec::NotVisible("Hello".to_string());
let result = evaluate(&spec, &frame);
assert!(result.is_err());
}
#[test]
fn check_collects_only_failing_expectations() {
let frame = TerminalFrame::new(80, 24, b"Hello World");
let spec = ScenarioSpec {
version: 1,
name: None,
session: crate::spec::model::SessionSpec {
bin: "./app".into(),
size: None,
args: Vec::new(),
env: std::collections::BTreeMap::new(),
workdir: None,
},
steps: Vec::new(),
expect: vec![
ExpectSpec::TextInRegion {
text: "World".to_string(),
region: RegionSpec(0, 0, 80, 1),
},
ExpectSpec::NotVisible("Hello".to_string()),
],
};
let lowered = spec.lower();
let failures = lowered.check(&frame);
assert_eq!(failures.len(), 1);
}
#[cfg(unix)]
#[test]
fn yaml_scenario_matches_code_api_against_same_binary() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir().expect("temp dir");
let script = temp_dir.path().join("greet.sh");
std::fs::write(&script, "#!/bin/sh\nprintf 'Hello World'\nsleep 60\n")
.expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("set permissions");
let yaml = format!(
"
session:
bin: {bin}
size: [80, 24]
steps:
- wait_for_stable_frame: {{ stable_ms: 300, timeout_ms: 5000 }}
expect:
- text_in_region: {{ text: \"Hello World\", region: [0, 0, 80, 1] }}
- not_visible: Goodbye
",
bin = script.display()
);
let (yaml_frame, yaml_failures) = ScenarioSpec::from_yaml(&yaml)
.expect("parse")
.lower()
.run()
.expect("yaml run");
let builder = PtySessionBuilder::new(&script).size(80, 24);
let code_frame = Scenario::new("code")
.wait_for_stable_frame(300, 5000)
.run(builder)
.expect("code run");
let region = Region::new(0, 0, 80, 1);
let code_text_ok =
assertion::match_text_in_region(&code_frame, "Hello World", ®ion).is_ok();
let code_not_visible_ok = recipe::match_not_visible(&code_frame, "Goodbye").is_ok();
assert!(yaml_failures.is_empty(), "yaml expectations should pass");
assert!(
code_text_ok && code_not_visible_ok,
"code expectations should pass"
);
assert_eq!(
yaml_frame.all_text(),
code_frame.all_text(),
"lowered YAML scenario must render the same frame as the code API"
);
}
}