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 {
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
233pub 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 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}