1use std::path::{Path, PathBuf};
31
32use fenestra_core::{App, Key, KeyInput, Query, Semantics, by};
33use serde::Deserialize;
34
35use crate::Harness;
36
37#[derive(Debug)]
40pub struct ScenarioError {
41 pub step: Option<usize>,
43 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#[derive(Debug)]
60pub struct ScenarioReport {
61 pub steps_run: usize,
63 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
216pub 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 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}