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 { checked: false },
156        "switch" => Semantics::Switch { on: false },
157        "radio" => Semantics::Radio { selected: false },
158        "slider" => Semantics::Slider {
159            value: 0.0,
160            min: 0.0,
161            max: 1.0,
162        },
163        "textbox" => Semantics::TextInput { multiline: false },
164        "combobox" => Semantics::ComboBox,
165        "dialog" => Semantics::Dialog,
166        "tab" => Semantics::Tab { selected: false },
167        "alert" => Semantics::Alert,
168        "text" => Semantics::Label,
169        "image" => Semantics::Image,
170        other => {
171            return Err(format!(
172                "unknown role {other:?} (expected button/checkbox/switch/radio/slider/\
173                 textbox/combobox/dialog/tab/alert/text/image)"
174            ));
175        }
176    })
177}
178
179fn key_from_str(spec: &str) -> Result<KeyInput, String> {
180    let mut input = KeyInput::plain(Key::Enter);
181    let mut key = None;
182    for token in spec.split('+') {
183        match token.trim().to_lowercase().as_str() {
184            "shift" => input.shift = true,
185            "ctrl" | "control" => input.ctrl = true,
186            "alt" | "option" => input.alt = true,
187            "cmd" | "meta" | "super" | "win" => input.meta = true,
188            "enter" | "return" => key = Some(Key::Enter),
189            "space" => key = Some(Key::Space),
190            "escape" | "esc" => key = Some(Key::Escape),
191            "left" | "arrowleft" => key = Some(Key::ArrowLeft),
192            "right" | "arrowright" => key = Some(Key::ArrowRight),
193            "up" | "arrowup" => key = Some(Key::ArrowUp),
194            "down" | "arrowdown" => key = Some(Key::ArrowDown),
195            "home" => key = Some(Key::Home),
196            "end" => key = Some(Key::End),
197            "backspace" => key = Some(Key::Backspace),
198            "delete" => key = Some(Key::Delete),
199            "pageup" => key = Some(Key::PageUp),
200            "pagedown" => key = Some(Key::PageDown),
201            other => {
202                let mut chars = other.chars();
203                match (chars.next(), chars.next()) {
204                    (Some(c), None) => key = Some(Key::Char(c)),
205                    _ => return Err(format!("unknown key token {token:?} in {spec:?}")),
206                }
207            }
208        }
209    }
210    match key {
211        Some(k) => {
212            input.key = k;
213            Ok(input)
214        }
215        None => Err(format!("no key in {spec:?} (only modifiers)")),
216    }
217}
218
219/// Runs a JSON scenario against the harness. Screenshots from `shot`
220/// steps land in `shots_dir` as `<name>.png`.
221///
222/// # Errors
223/// On JSON that does not parse, a target that matches zero or several
224/// nodes, an unknown role/key, or a failed assertion — with the step
225/// index and (for target failures) the accessibility tree.
226pub fn run_scenario<A: App>(
227    harness: &mut Harness<A>,
228    json: &str,
229    shots_dir: impl AsRef<Path>,
230) -> Result<ScenarioReport, ScenarioError>
231where
232    A::Msg: Send,
233{
234    let scenario: Scenario = serde_json::from_str(json).map_err(|e| ScenarioError {
235        step: None,
236        message: format!("invalid scenario JSON: {e}"),
237    })?;
238    let shots_dir = shots_dir.as_ref();
239    let mut shots = Vec::new();
240
241    for (i, step) in scenario.steps.iter().enumerate() {
242        let fail = |message: String| ScenarioError {
243            step: Some(i),
244            message,
245        };
246        // Resolves a target strictly, with the tree in the error.
247        macro_rules! target {
248            ($spec:expr) => {{
249                let q = $spec.to_query().map_err(&fail)?;
250                harness.frame().try_get(&q).map_err(|e| {
251                    fail(format!(
252                        "target [{q}]: {e}\naccessibility tree:\n{}",
253                        harness.frame().access_yaml()
254                    ))
255                })?;
256                q
257            }};
258        }
259        match step {
260            Step::Click(spec) => {
261                let q = target!(spec);
262                harness.click(&q);
263            }
264            Step::RightClick(spec) => {
265                let q = target!(spec);
266                harness.right_click(&q);
267            }
268            Step::DoubleClick(spec) => {
269                let q = target!(spec);
270                harness.double_click(&q);
271            }
272            Step::TripleClick(spec) => {
273                let q = target!(spec);
274                harness.triple_click(&q);
275            }
276            Step::ShiftClick(spec) => {
277                let q = target!(spec);
278                harness.shift_click(&q);
279            }
280            Step::Hover(spec) => {
281                let q = target!(spec);
282                harness.hover(&q);
283            }
284            Step::Type(text) => harness.type_text(text.clone()),
285            Step::Key(spec) => {
286                let key = key_from_str(spec).map_err(&fail)?;
287                harness.key(key);
288            }
289            Step::Tab(count) => {
290                for _ in 0..*count {
291                    harness.tab();
292                }
293            }
294            Step::ShiftTab(count) => {
295                for _ in 0..*count {
296                    harness.shift_tab();
297                }
298            }
299            Step::Wheel { target, dy } => {
300                let q = target!(target);
301                harness.wheel(&q, *dy);
302            }
303            Step::Drag { from, to } => {
304                let from = target!(from);
305                let to = to.to_query().map_err(&fail)?;
306                harness.drag(&from, &to);
307            }
308            Step::DropFile { target, path } => {
309                let q = target!(target);
310                harness.drop_file(&q, path.clone());
311            }
312            Step::PumpMs(ms) => harness.pump(*ms),
313            Step::Window(key) => {
314                if !harness.window_keys().iter().any(|k| k == key) {
315                    return Err(fail(format!(
316                        "no open window {key:?}; open windows: {:?}",
317                        harness.window_keys()
318                    )));
319                }
320                harness.activate_window(key);
321            }
322            Step::Shot(name) => {
323                std::fs::create_dir_all(shots_dir)
324                    .map_err(|e| fail(format!("create shots dir: {e}")))?;
325                let path = shots_dir.join(format!("{name}.png"));
326                let image = harness.render();
327                image
328                    .save(&path)
329                    .map_err(|e| fail(format!("write {}: {e}", path.display())))?;
330                shots.push(path);
331            }
332            Step::Assert(assert) => run_assert(harness, assert).map_err(&fail)?,
333        }
334    }
335    Ok(ScenarioReport {
336        steps_run: scenario.steps.len(),
337        shots,
338    })
339}
340
341fn run_assert<A: App>(harness: &Harness<A>, assert: &AssertSpec) -> Result<(), String>
342where
343    A::Msg: Send,
344{
345    let tree = || format!("\naccessibility tree:\n{}", harness.frame().access_yaml());
346    match assert {
347        AssertSpec::Exists(spec) => {
348            let q = spec.to_query()?;
349            harness
350                .frame()
351                .try_get(&q)
352                .map_err(|e| format!("assert exists [{q}]: {e}{}", tree()))?;
353        }
354        AssertSpec::Absent(spec) => {
355            let q = spec.to_query()?;
356            if !harness.frame().get_all(&q).is_empty() {
357                return Err(format!("assert absent [{q}]: it exists{}", tree()));
358            }
359        }
360        AssertSpec::Count { target, equals } => {
361            let q = target.to_query()?;
362            let n = harness.frame().get_all(&q).len();
363            if n != *equals {
364                return Err(format!("assert count [{q}]: {n} != {equals}{}", tree()));
365            }
366        }
367        AssertSpec::Value { target, equals } => {
368            let q = target.to_query()?;
369            let node = harness
370                .frame()
371                .try_get(&q)
372                .map_err(|e| format!("assert value [{q}]: {e}{}", tree()))?;
373            let value = node.value.as_deref().unwrap_or("");
374            if value != equals {
375                return Err(format!("assert value [{q}]: {value:?} != {equals:?}"));
376            }
377        }
378        AssertSpec::Windows(expected) => {
379            let open = harness.window_keys();
380            if &open != expected {
381                return Err(format!("assert windows: open {open:?} != {expected:?}"));
382            }
383        }
384    }
385    Ok(())
386}