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