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