Skip to main content

fenestra_shell/
scenario.rs

1//! Scenario scripts: drive a [`Harness`] from JSON — events, semantic
2//! targets, assertions, and named screenshots — so an agent can verify
3//! a UI without writing Rust for each probe.
4//!
5//! A scenario is `{"steps": [...]}` where each step is one verb:
6//!
7//! ```json
8//! {"steps": [
9//!   {"click":  {"role": "button", "name": "Add"}},
10//!   {"type":   "buy milk"},
11//!   {"key":    "enter"},
12//!   {"assert": {"exists": {"label": "buy milk"}}},
13//!   {"assert": {"count": {"target": {"role": "checkbox"}, "equals": 1}}},
14//!   {"shot":   "after-add"}
15//! ]}
16//! ```
17//!
18//! Verbs: `click`, `right_click`, `double_click`, `hover` (semantic
19//! target inline); `type` (string); `key` (e.g. `"enter"`,
20//! `"cmd+z"`, `"ctrl+shift+a"`); `tab` / `shift_tab` (count);
21//! `wheel` `{target, dy}`; `drag` `{from, to}`; `drop_file`
22//! `{target, path}`; `pump_ms` (advance the clock); `window` (activate
23//! by key); `shot` (PNG into the scenario's shot directory); `assert`
24//! with `exists` / `absent` / `count` / `value` `{target, equals}` /
25//! `windows` (the open set). Targets use the query vocabulary:
26//! `role`, `name`/`name_contains`, `label`/`label_contains`,
27//! `value`/`value_contains`, `id`. Unknown fields are errors, not
28//! typos silently ignored.
29
30use std::path::{Path, PathBuf};
31
32use fenestra_core::{App, Key, KeyInput, Query, Semantics, by};
33use serde::Deserialize;
34
35use crate::Harness;
36
37/// A failed step (or a parse failure, `step: None`), with enough
38/// context to fix the scenario without re-running it.
39#[derive(Debug)]
40pub struct ScenarioError {
41    /// Zero-based index of the failing step; `None` for parse errors.
42    pub step: Option<usize>,
43    /// What went wrong, including the accessibility tree where useful.
44    pub message: String,
45}
46
47impl std::fmt::Display for ScenarioError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self.step {
50            Some(i) => write!(f, "scenario step {i}: {}", self.message),
51            None => write!(f, "scenario: {}", self.message),
52        }
53    }
54}
55
56impl std::error::Error for ScenarioError {}
57
58/// What a successful run did.
59#[derive(Debug)]
60pub struct ScenarioReport {
61    /// Steps executed.
62    pub steps_run: usize,
63    /// Screenshots written, in step order.
64    pub shots: Vec<PathBuf>,
65}
66
67#[derive(Deserialize)]
68#[serde(deny_unknown_fields)]
69struct Scenario {
70    steps: Vec<Step>,
71}
72
73#[derive(Deserialize)]
74#[serde(rename_all = "snake_case", deny_unknown_fields)]
75enum Step {
76    Click(QuerySpec),
77    RightClick(QuerySpec),
78    DoubleClick(QuerySpec),
79    Hover(QuerySpec),
80    Type(String),
81    Key(String),
82    Tab(u32),
83    ShiftTab(u32),
84    Wheel { target: QuerySpec, dy: f32 },
85    Drag { from: QuerySpec, to: QuerySpec },
86    DropFile { target: QuerySpec, path: String },
87    PumpMs(f64),
88    Window(String),
89    Shot(String),
90    Assert(AssertSpec),
91}
92
93#[derive(Deserialize)]
94#[serde(rename_all = "snake_case", deny_unknown_fields)]
95enum AssertSpec {
96    Exists(QuerySpec),
97    Absent(QuerySpec),
98    Count { target: QuerySpec, equals: usize },
99    Value { target: QuerySpec, equals: String },
100    Windows(Vec<String>),
101}
102
103#[derive(Deserialize)]
104#[serde(deny_unknown_fields)]
105struct QuerySpec {
106    role: Option<String>,
107    name: Option<String>,
108    name_contains: Option<String>,
109    label: Option<String>,
110    label_contains: Option<String>,
111    value: Option<String>,
112    value_contains: Option<String>,
113    id: Option<String>,
114}
115
116impl QuerySpec {
117    fn to_query(&self) -> Result<Query, String> {
118        let mut q = match self.role.as_deref() {
119            Some(role) => by::role(role_from_str(role)?),
120            None => match (&self.label, &self.label_contains) {
121                (Some(l), _) => by::label(l),
122                (None, Some(l)) => by::label_contains(l),
123                (None, None) => match (&self.value, &self.value_contains) {
124                    (Some(v), _) => by::value(v),
125                    (None, Some(v)) => by::value_contains(v),
126                    (None, None) => match &self.id {
127                        Some(id) => by::id(id),
128                        None => return Err("empty target: set role, label, value, or id".into()),
129                    },
130                },
131            },
132        };
133        if self.role.is_some() {
134            if let Some(l) = &self.label {
135                q = q.name(l);
136            } else if let Some(l) = &self.label_contains {
137                q = q.name_contains(l);
138            }
139        }
140        if let Some(n) = &self.name {
141            q = q.name(n);
142        } else if let Some(n) = &self.name_contains {
143            q = q.name_contains(n);
144        }
145        Ok(q)
146    }
147}
148
149fn role_from_str(role: &str) -> Result<Semantics, String> {
150    Ok(match role {
151        "button" => Semantics::Button,
152        "checkbox" => Semantics::Checkbox { checked: false },
153        "switch" => Semantics::Switch { on: false },
154        "radio" => Semantics::Radio { selected: false },
155        "slider" => Semantics::Slider {
156            value: 0.0,
157            min: 0.0,
158            max: 1.0,
159        },
160        "textbox" => Semantics::TextInput { multiline: false },
161        "combobox" => Semantics::ComboBox,
162        "dialog" => Semantics::Dialog,
163        "tab" => Semantics::Tab { selected: false },
164        "alert" => Semantics::Alert,
165        "text" => Semantics::Label,
166        "image" => Semantics::Image,
167        other => {
168            return Err(format!(
169                "unknown role {other:?} (expected button/checkbox/switch/radio/slider/\
170                 textbox/combobox/dialog/tab/alert/text/image)"
171            ));
172        }
173    })
174}
175
176fn key_from_str(spec: &str) -> Result<KeyInput, String> {
177    let mut input = KeyInput::plain(Key::Enter);
178    let mut key = None;
179    for token in spec.split('+') {
180        match token.trim().to_lowercase().as_str() {
181            "shift" => input.shift = true,
182            "ctrl" | "control" => input.ctrl = true,
183            "alt" | "option" => input.alt = true,
184            "cmd" | "meta" | "super" | "win" => input.meta = true,
185            "enter" | "return" => key = Some(Key::Enter),
186            "space" => key = Some(Key::Space),
187            "escape" | "esc" => key = Some(Key::Escape),
188            "left" | "arrowleft" => key = Some(Key::ArrowLeft),
189            "right" | "arrowright" => key = Some(Key::ArrowRight),
190            "up" | "arrowup" => key = Some(Key::ArrowUp),
191            "down" | "arrowdown" => key = Some(Key::ArrowDown),
192            "home" => key = Some(Key::Home),
193            "end" => key = Some(Key::End),
194            "backspace" => key = Some(Key::Backspace),
195            "delete" => key = Some(Key::Delete),
196            "pageup" => key = Some(Key::PageUp),
197            "pagedown" => key = Some(Key::PageDown),
198            other => {
199                let mut chars = other.chars();
200                match (chars.next(), chars.next()) {
201                    (Some(c), None) => key = Some(Key::Char(c)),
202                    _ => return Err(format!("unknown key token {token:?} in {spec:?}")),
203                }
204            }
205        }
206    }
207    match key {
208        Some(k) => {
209            input.key = k;
210            Ok(input)
211        }
212        None => Err(format!("no key in {spec:?} (only modifiers)")),
213    }
214}
215
216/// Runs a JSON scenario against the harness. Screenshots from `shot`
217/// steps land in `shots_dir` as `<name>.png`.
218///
219/// # Errors
220/// On JSON that does not parse, a target that matches zero or several
221/// nodes, an unknown role/key, or a failed assertion — with the step
222/// index and (for target failures) the accessibility tree.
223pub fn run_scenario<A: App>(
224    harness: &mut Harness<A>,
225    json: &str,
226    shots_dir: impl AsRef<Path>,
227) -> Result<ScenarioReport, ScenarioError>
228where
229    A::Msg: Send,
230{
231    let scenario: Scenario = serde_json::from_str(json).map_err(|e| ScenarioError {
232        step: None,
233        message: format!("invalid scenario JSON: {e}"),
234    })?;
235    let shots_dir = shots_dir.as_ref();
236    let mut shots = Vec::new();
237
238    for (i, step) in scenario.steps.iter().enumerate() {
239        let fail = |message: String| ScenarioError {
240            step: Some(i),
241            message,
242        };
243        // Resolves a target strictly, with the tree in the error.
244        macro_rules! target {
245            ($spec:expr) => {{
246                let q = $spec.to_query().map_err(&fail)?;
247                harness.frame().try_get(&q).map_err(|e| {
248                    fail(format!(
249                        "target [{q}]: {e}\naccessibility tree:\n{}",
250                        harness.frame().access_yaml()
251                    ))
252                })?;
253                q
254            }};
255        }
256        match step {
257            Step::Click(spec) => {
258                let q = target!(spec);
259                harness.click(&q);
260            }
261            Step::RightClick(spec) => {
262                let q = target!(spec);
263                harness.right_click(&q);
264            }
265            Step::DoubleClick(spec) => {
266                let q = target!(spec);
267                harness.double_click(&q);
268            }
269            Step::Hover(spec) => {
270                let q = target!(spec);
271                harness.hover(&q);
272            }
273            Step::Type(text) => harness.type_text(text.clone()),
274            Step::Key(spec) => {
275                let key = key_from_str(spec).map_err(&fail)?;
276                harness.key(key);
277            }
278            Step::Tab(count) => {
279                for _ in 0..*count {
280                    harness.tab();
281                }
282            }
283            Step::ShiftTab(count) => {
284                for _ in 0..*count {
285                    harness.shift_tab();
286                }
287            }
288            Step::Wheel { target, dy } => {
289                let q = target!(target);
290                harness.wheel(&q, *dy);
291            }
292            Step::Drag { from, to } => {
293                let from = target!(from);
294                let to = to.to_query().map_err(&fail)?;
295                harness.drag(&from, &to);
296            }
297            Step::DropFile { target, path } => {
298                let q = target!(target);
299                harness.drop_file(&q, path.clone());
300            }
301            Step::PumpMs(ms) => harness.pump(*ms),
302            Step::Window(key) => {
303                if !harness.window_keys().iter().any(|k| k == key) {
304                    return Err(fail(format!(
305                        "no open window {key:?}; open windows: {:?}",
306                        harness.window_keys()
307                    )));
308                }
309                harness.activate_window(key);
310            }
311            Step::Shot(name) => {
312                std::fs::create_dir_all(shots_dir)
313                    .map_err(|e| fail(format!("create shots dir: {e}")))?;
314                let path = shots_dir.join(format!("{name}.png"));
315                let image = harness.render();
316                image
317                    .save(&path)
318                    .map_err(|e| fail(format!("write {}: {e}", path.display())))?;
319                shots.push(path);
320            }
321            Step::Assert(assert) => run_assert(harness, assert).map_err(&fail)?,
322        }
323    }
324    Ok(ScenarioReport {
325        steps_run: scenario.steps.len(),
326        shots,
327    })
328}
329
330fn run_assert<A: App>(harness: &Harness<A>, assert: &AssertSpec) -> Result<(), String>
331where
332    A::Msg: Send,
333{
334    let tree = || format!("\naccessibility tree:\n{}", harness.frame().access_yaml());
335    match assert {
336        AssertSpec::Exists(spec) => {
337            let q = spec.to_query()?;
338            harness
339                .frame()
340                .try_get(&q)
341                .map_err(|e| format!("assert exists [{q}]: {e}{}", tree()))?;
342        }
343        AssertSpec::Absent(spec) => {
344            let q = spec.to_query()?;
345            if !harness.frame().get_all(&q).is_empty() {
346                return Err(format!("assert absent [{q}]: it exists{}", tree()));
347            }
348        }
349        AssertSpec::Count { target, equals } => {
350            let q = target.to_query()?;
351            let n = harness.frame().get_all(&q).len();
352            if n != *equals {
353                return Err(format!("assert count [{q}]: {n} != {equals}{}", tree()));
354            }
355        }
356        AssertSpec::Value { target, equals } => {
357            let q = target.to_query()?;
358            let node = harness
359                .frame()
360                .try_get(&q)
361                .map_err(|e| format!("assert value [{q}]: {e}{}", tree()))?;
362            let value = node.value.as_deref().unwrap_or("");
363            if value != equals {
364                return Err(format!("assert value [{q}]: {value:?} != {equals:?}"));
365            }
366        }
367        AssertSpec::Windows(expected) => {
368            let open = harness.window_keys();
369            if &open != expected {
370                return Err(format!("assert windows: open {open:?} != {expected:?}"));
371            }
372        }
373    }
374    Ok(())
375}