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