use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use serde::de::{self, IgnoredAny, MapAccess, Visitor};
use serde::{Deserialize, Deserializer};
pub const SUPPORTED_VERSION: u32 = 1;
fn default_version() -> u32 {
SUPPORTED_VERSION
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioSpec {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub name: Option<String>,
pub session: SessionSpec,
#[serde(default)]
pub steps: Vec<StepSpec>,
#[serde(default)]
pub expect: Vec<ExpectSpec>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionSpec {
pub bin: PathBuf,
#[serde(default)]
pub size: Option<[u16; 2]>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub workdir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StepSpec {
PressKey(String),
WriteText(String),
SleepMs(u64),
WaitForText {
needle: String,
timeout_ms: u32,
},
WaitForStableFrame {
stable_ms: u32,
timeout_ms: u32,
},
Eventually {
matcher: ExpectSpec,
timeout_ms: u64,
poll_ms: u64,
},
Capture,
CaptureLabeled {
label: String,
description: String,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ExpectSpec {
SelectedTab(String),
UnselectedTab(String),
InstructionVisible(String),
KeybindingHint(String),
FooterAction(String),
DialogTitle(String),
StatusMessage(String),
NotVisible(String),
TextInRegion {
text: String,
region: RegionSpec,
},
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct RegionSpec(pub u16, pub u16, pub u16, pub u16);
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitForTextArgs {
needle: String,
timeout_ms: u32,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitForStableFrameArgs {
stable_ms: u32,
timeout_ms: u32,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EventuallyArgs {
#[serde(rename = "match")]
matcher: ExpectSpec,
timeout_ms: u64,
poll_ms: u64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CaptureLabeledArgs {
label: String,
description: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TextInRegionArgs {
text: String,
region: RegionSpec,
}
fn reject_extra_key<'de, A: MapAccess<'de>>(map: &mut A, kind: &str) -> Result<(), A::Error> {
if map.next_key::<IgnoredAny>()?.is_some() {
return Err(de::Error::custom(format!(
"a {kind} must have exactly one key"
)));
}
Ok(())
}
impl<'de> Deserialize<'de> for StepSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct StepVisitor;
impl<'de> Visitor<'de> for StepVisitor {
type Value = StepSpec;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter
.write_str("a step: `capture` or a single-key map such as `press_key: Tab`")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<StepSpec, E> {
match value {
"capture" => Ok(StepSpec::Capture),
other => Err(E::custom(format!("unknown step `{other}`"))),
}
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<StepSpec, A::Error> {
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::custom("a step map must have one key"));
};
let step = match key.as_str() {
"press_key" => StepSpec::PressKey(map.next_value()?),
"write_text" => StepSpec::WriteText(map.next_value()?),
"sleep_ms" => StepSpec::SleepMs(map.next_value()?),
"wait_for_text" => {
let args: WaitForTextArgs = map.next_value()?;
StepSpec::WaitForText {
needle: args.needle,
timeout_ms: args.timeout_ms,
}
}
"wait_for_stable_frame" => {
let args: WaitForStableFrameArgs = map.next_value()?;
StepSpec::WaitForStableFrame {
stable_ms: args.stable_ms,
timeout_ms: args.timeout_ms,
}
}
"eventually" => {
let args: EventuallyArgs = map.next_value()?;
StepSpec::Eventually {
matcher: args.matcher,
timeout_ms: args.timeout_ms,
poll_ms: args.poll_ms,
}
}
"capture_labeled" => {
let args: CaptureLabeledArgs = map.next_value()?;
StepSpec::CaptureLabeled {
label: args.label,
description: args.description,
}
}
other => return Err(de::Error::custom(format!("unknown step `{other}`"))),
};
reject_extra_key(&mut map, "step")?;
Ok(step)
}
}
deserializer.deserialize_any(StepVisitor)
}
}
impl<'de> Deserialize<'de> for ExpectSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ExpectVisitor;
impl<'de> Visitor<'de> for ExpectVisitor {
type Value = ExpectSpec;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an expectation map such as `selected_tab: Sessions`")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<ExpectSpec, A::Error> {
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::custom("an expectation map must have one key"));
};
let expect = match key.as_str() {
"selected_tab" => ExpectSpec::SelectedTab(map.next_value()?),
"unselected_tab" => ExpectSpec::UnselectedTab(map.next_value()?),
"instruction_visible" => ExpectSpec::InstructionVisible(map.next_value()?),
"keybinding_hint" => ExpectSpec::KeybindingHint(map.next_value()?),
"footer_action" => ExpectSpec::FooterAction(map.next_value()?),
"dialog_title" => ExpectSpec::DialogTitle(map.next_value()?),
"status_message" => ExpectSpec::StatusMessage(map.next_value()?),
"not_visible" => ExpectSpec::NotVisible(map.next_value()?),
"text_in_region" => {
let args: TextInRegionArgs = map.next_value()?;
ExpectSpec::TextInRegion {
text: args.text,
region: args.region,
}
}
other => {
return Err(de::Error::custom(format!("unknown expectation `{other}`")));
}
};
reject_extra_key(&mut map, "expectation")?;
Ok(expect)
}
}
deserializer.deserialize_any(ExpectVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_press_key_step() {
let yaml = "
session:
bin: ./app
steps:
- press_key: Tab
";
let spec: ScenarioSpec = serde_yaml_ng::from_str(yaml).expect("parse");
assert_eq!(spec.version, SUPPORTED_VERSION);
assert_eq!(spec.session.bin, PathBuf::from("./app"));
assert_eq!(spec.steps.len(), 1);
assert!(matches!(&spec.steps[0], StepSpec::PressKey(key) if key == "Tab"));
}
#[test]
fn deserializes_session_size_args_and_expect() {
let yaml = "
version: 1
session:
bin: ./app
size: [80, 24]
args: [--flag, value]
steps:
- write_text: hello
- wait_for_stable_frame: { stable_ms: 500, timeout_ms: 5000 }
expect:
- selected_tab: Sessions
- text_in_region: { text: \"Counter: 3\", region: [0, 0, 80, 24] }
";
let spec: ScenarioSpec = serde_yaml_ng::from_str(yaml).expect("parse");
assert_eq!(spec.session.size, Some([80, 24]));
assert_eq!(spec.session.args, vec!["--flag", "value"]);
assert!(matches!(&spec.steps[0], StepSpec::WriteText(text) if text == "hello"));
assert!(matches!(
spec.steps[1],
StepSpec::WaitForStableFrame {
stable_ms: 500,
timeout_ms: 5000
}
));
assert!(matches!(&spec.expect[0], ExpectSpec::SelectedTab(tab) if tab == "Sessions"));
assert!(matches!(
&spec.expect[1],
ExpectSpec::TextInRegion { text, region }
if text == "Counter: 3" && (region.0, region.1, region.2, region.3) == (0, 0, 80, 24)
));
}
#[test]
fn deserializes_bare_capture_step() {
let yaml = "
session:
bin: ./app
steps:
- capture
";
let spec: ScenarioSpec = serde_yaml_ng::from_str(yaml).expect("parse");
assert!(matches!(spec.steps[0], StepSpec::Capture));
}
#[test]
fn deserializes_eventually_step_with_nested_matcher() {
let yaml = "
session:
bin: ./app
steps:
- eventually:
match: { not_visible: Loading }
timeout_ms: 3000
poll_ms: 50
";
let spec: ScenarioSpec = serde_yaml_ng::from_str(yaml).expect("parse");
assert!(matches!(
&spec.steps[0],
StepSpec::Eventually { matcher, timeout_ms: 3000, poll_ms: 50 }
if matches!(matcher, ExpectSpec::NotVisible(text) if text == "Loading")
));
}
#[test]
fn rejects_unknown_top_level_field() {
let yaml = "
session:
bin: ./app
step:
- press_key: Tab
";
let result: Result<ScenarioSpec, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err());
}
#[test]
fn rejects_unknown_step_key() {
let yaml = "
session:
bin: ./app
steps:
- press_buttn: Tab
";
let result: Result<ScenarioSpec, _> = serde_yaml_ng::from_str(yaml);
assert!(result.is_err());
}
}