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