fenestra_shell/harness.rs
1//! The verification harness: drive an [`App`] headlessly through
2//! semantic queries instead of coordinates, and assert at three levels —
3//! pixels, accessibility tree, and emitted messages.
4//!
5//! ```no_run
6//! use fenestra_core::{App, by};
7//! use fenestra_shell::Harness;
8//! # struct Todo; #[derive(Clone)] enum Msg { Add }
9//! # impl App for Todo { type Msg = Msg; fn update(&mut self, _: Msg) {}
10//! # fn view(&self) -> fenestra_core::Element<Msg> { fenestra_core::col() } }
11//! let mut h = Harness::new(Todo, fenestra_core::Theme::light(), (480, 320));
12//! h.click(&by::label("Add")); // find like a user, not by (x, y)
13//! h.type_text("buy milk");
14//! assert!(h.query(&by::label("buy milk")).is_some());
15//! let _png = h.render(); // pixels only when asked
16//! ```
17//!
18//! Determinism: scale 1.0, reduced motion, embedded fonts, and an
19//! explicit clock — animations only advance when [`Harness::pump`] is
20//! called. Nothing is painted unless [`Harness::render`] is called, so
21//! structural tests stay fast.
22
23use std::sync::{Arc, Mutex, PoisonError};
24
25use std::collections::HashMap;
26
27use fenestra_core::{
28 AccessNode, App, Element, Frame, FrameState, InputEvent, KeyInput, MAIN_WINDOW, Proxy, Query,
29 Theme, build_frame, dispatch,
30};
31use image::RgbaImage;
32
33use crate::element_render::with_fonts;
34use crate::with_headless;
35
36/// One headless window: its own retained state, view, and frame —
37/// exactly like the windowed runner keeps per window.
38struct WindowSlot<Msg> {
39 state: FrameState,
40 view: Element<Msg>,
41 frame: Frame,
42 logical: (f32, f32),
43 size: (u32, u32),
44}
45
46/// A headless app under test. See the module docs for the model.
47pub struct Harness<A: App> {
48 app: A,
49 theme: Theme,
50 /// Deterministic clock in seconds, advanced only by [`Self::pump`].
51 clock: f64,
52 /// Messages emitted by handlers since the last [`Self::take_messages`].
53 msgs: Vec<A::Msg>,
54 pending: Arc<Mutex<Vec<A::Msg>>>,
55 /// Open windows by key; reconciled against [`App::windows`] after
56 /// every update, exactly like the windowed runner.
57 slots: HashMap<String, WindowSlot<A::Msg>>,
58 /// Animations snap by default (deterministic); motion tests opt in.
59 reduced_motion: bool,
60 /// The window verbs and queries currently target.
61 active: String,
62}
63
64impl<A: App> Harness<A>
65where
66 A::Msg: Send,
67{
68 /// Builds the first frame. [`App::init`] runs with a collecting
69 /// [`Proxy`]; proxied messages drain at every rebuild (after each
70 /// input, [`Self::pump`], or [`Self::update`]).
71 ///
72 /// # Panics
73 /// If no compute-capable GPU adapter exists.
74 pub fn new(mut app: A, theme: Theme, size: (u32, u32)) -> Self {
75 let size =
76 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
77 let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
78 let sink = Arc::clone(&pending);
79 app.init(Proxy::new(move |msg| {
80 sink.lock()
81 .unwrap_or_else(PoisonError::into_inner)
82 .push(msg);
83 }));
84 Self::drain(&mut app, &pending);
85 let mut harness = Self {
86 app,
87 theme,
88 clock: 0.0,
89 msgs: Vec::new(),
90 pending,
91 slots: HashMap::new(),
92 active: MAIN_WINDOW.to_owned(),
93 reduced_motion: true,
94 };
95 harness.slots.insert(
96 MAIN_WINDOW.to_owned(),
97 Self::new_slot(&harness.app, &harness.theme, MAIN_WINDOW, size, 0.0, true),
98 );
99 harness.rebuild();
100 harness
101 }
102
103 fn new_slot(
104 app: &A,
105 theme: &Theme,
106 key: &str,
107 size: (u32, u32),
108 clock: f64,
109 reduced_motion: bool,
110 ) -> WindowSlot<A::Msg> {
111 let size =
112 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
113 let mut state = FrameState::new();
114 state.reduced_motion = reduced_motion;
115 state.tick(clock);
116 #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
117 let logical = (size.0 as f32, size.1 as f32);
118 let view = app.view_for(key);
119 let frame = with_fonts(|fonts| build_frame(&view, theme, fonts, &mut state, logical, 1.0));
120 WindowSlot {
121 state,
122 view,
123 frame,
124 logical,
125 size,
126 }
127 }
128
129 fn drain(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
130 let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
131 for msg in msgs {
132 app.update(msg);
133 }
134 }
135
136 /// Rebuilds every window from current app state (proxied messages
137 /// drain first) and reconciles the declared window set: new keys
138 /// open, missing keys close (the active window falls back to main).
139 /// Runs automatically after every input; call it yourself only
140 /// after mutating via [`Self::app_mut`].
141 pub fn rebuild(&mut self) {
142 Self::drain(&mut self.app, &self.pending);
143 let descs = self.app.windows();
144 self.slots
145 .retain(|key, _| key == MAIN_WINDOW || descs.iter().any(|d| &d.key == key));
146 for desc in &descs {
147 if !self.slots.contains_key(&desc.key) {
148 #[expect(
149 clippy::cast_possible_truncation,
150 clippy::cast_sign_loss,
151 reason = "logical window sizes are small positive numbers"
152 )]
153 let size = (desc.size.0.max(1.0) as u32, desc.size.1.max(1.0) as u32);
154 let slot = Self::new_slot(
155 &self.app,
156 &self.theme,
157 &desc.key,
158 size,
159 self.clock,
160 self.reduced_motion,
161 );
162 self.slots.insert(desc.key.clone(), slot);
163 }
164 }
165 if !self.slots.contains_key(&self.active) {
166 self.active = MAIN_WINDOW.to_owned();
167 }
168 let keys: Vec<String> = self.slots.keys().cloned().collect();
169 for key in keys {
170 let slot = self.slots.get_mut(&key).expect("slot exists");
171 slot.view = self.app.view_for(&key);
172 slot.state.tick(self.clock);
173 slot.frame = with_fonts(|fonts| {
174 build_frame(
175 &slot.view,
176 &self.theme,
177 fonts,
178 &mut slot.state,
179 slot.logical,
180 1.0,
181 )
182 });
183 }
184 }
185
186 fn slot(&self) -> &WindowSlot<A::Msg> {
187 self.slots.get(&self.active).expect("active slot exists")
188 }
189
190 /// Enables or disables real animation. The harness defaults to
191 /// reduced motion (everything snaps — deterministic pixels); motion
192 /// tests opt into physics and drive it with [`Self::pump`].
193 pub fn set_reduced_motion(&mut self, reduced: bool) {
194 self.reduced_motion = reduced;
195 for slot in self.slots.values_mut() {
196 slot.state.reduced_motion = reduced;
197 }
198 self.rebuild();
199 }
200
201 /// Switches which window the verbs and queries target. Open windows
202 /// come from [`App::windows`]; [`MAIN_WINDOW`] is always open.
203 ///
204 /// # Panics
205 /// If no open window has this key (the message lists the open ones).
206 pub fn activate_window(&mut self, key: &str) {
207 assert!(
208 self.slots.contains_key(key),
209 "no open window {key:?}; open windows: {:?}",
210 self.window_keys()
211 );
212 self.active = key.to_owned();
213 }
214
215 /// The keys of every open window, sorted (main first).
216 pub fn window_keys(&self) -> Vec<String> {
217 let mut keys: Vec<String> = self.slots.keys().cloned().collect();
218 keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
219 keys
220 }
221
222 /// Dispatches one raw input event against the active window's
223 /// current frame, logs and applies the emitted messages, and
224 /// rebuilds (which also reconciles the window set).
225 pub fn input(&mut self, event: InputEvent) {
226 let slot = self
227 .slots
228 .get_mut(&self.active)
229 .expect("active slot exists");
230 let result =
231 with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
232 for msg in result.msgs {
233 self.msgs.push(msg.clone());
234 self.app.update(msg);
235 }
236 self.rebuild();
237 }
238
239 fn center(&self, q: &Query) -> (f32, f32) {
240 let node = self.slot().frame.get(q);
241 let c = node.rect.center();
242 #[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
243 (c.x as f32, c.y as f32)
244 }
245
246 /// Moves the pointer to the center of the matched node.
247 ///
248 /// # Panics
249 /// If the query matches zero or several nodes.
250 pub fn hover(&mut self, q: &Query) {
251 let (x, y) = self.center(q);
252 self.input(InputEvent::PointerMove { x, y });
253 }
254
255 /// Clicks (press + release) the center of the matched node.
256 ///
257 /// # Panics
258 /// If the query matches zero or several nodes.
259 pub fn click(&mut self, q: &Query) {
260 self.hover(q);
261 self.input(InputEvent::PointerDown);
262 self.input(InputEvent::PointerUp);
263 }
264
265 /// Right-clicks the center of the matched node.
266 ///
267 /// # Panics
268 /// If the query matches zero or several nodes.
269 pub fn right_click(&mut self, q: &Query) {
270 self.hover(q);
271 self.input(InputEvent::RightDown);
272 self.input(InputEvent::RightUp);
273 }
274
275 /// Double-clicks the matched node (two clicks inside the
276 /// double-click window — the harness clock does not advance).
277 ///
278 /// # Panics
279 /// If the query matches zero or several nodes.
280 pub fn double_click(&mut self, q: &Query) {
281 self.click(q);
282 self.click(q);
283 }
284
285 /// Triple-clicks the matched node (text inputs select the line).
286 ///
287 /// # Panics
288 /// If the query matches zero or several nodes.
289 pub fn triple_click(&mut self, q: &Query) {
290 self.click(q);
291 self.click(q);
292 self.click(q);
293 }
294
295 /// Clicks with Shift held (text inputs extend the selection from
296 /// the caret to the click point).
297 ///
298 /// # Panics
299 /// If the query matches zero or several nodes.
300 pub fn shift_click(&mut self, q: &Query) {
301 self.input(InputEvent::Modifiers {
302 shift: true,
303 ctrl: false,
304 alt: false,
305 meta: false,
306 });
307 self.click(q);
308 self.input(InputEvent::Modifiers {
309 shift: false,
310 ctrl: false,
311 alt: false,
312 meta: false,
313 });
314 }
315
316 /// Commits text to the focused element (like typing or IME commit).
317 pub fn type_text(&mut self, text: impl Into<String>) {
318 self.input(InputEvent::Text(text.into()));
319 }
320
321 /// Presses one key.
322 pub fn key(&mut self, key: KeyInput) {
323 self.input(InputEvent::Key(key));
324 }
325
326 /// Focuses the next focusable element (Tab).
327 pub fn tab(&mut self) {
328 self.input(InputEvent::Tab);
329 }
330
331 /// Focuses the previous focusable element (Shift-Tab).
332 pub fn shift_tab(&mut self) {
333 self.input(InputEvent::ShiftTab);
334 }
335
336 /// Focuses the matched node directly (what assistive technology's
337 /// Focus action does). Prefer [`Self::tab`] to test the real path.
338 ///
339 /// # Panics
340 /// If the query matches zero or several nodes.
341 pub fn focus(&mut self, q: &Query) {
342 let slot = self
343 .slots
344 .get_mut(&self.active)
345 .expect("active slot exists");
346 let id = slot.frame.get(q).id;
347 slot.state.set_focus(Some(id));
348 self.rebuild();
349 }
350
351 /// Drags from one node to another: press on `from`, move to `to`
352 /// (recomputed after the press, in case layout shifted), release.
353 ///
354 /// # Panics
355 /// If either query matches zero or several nodes.
356 pub fn drag(&mut self, from: &Query, to: &Query) {
357 self.hover(from);
358 self.input(InputEvent::PointerDown);
359 let (x, y) = self.center(to);
360 self.input(InputEvent::PointerMove { x, y });
361 self.input(InputEvent::PointerUp);
362 }
363
364 /// Drops an OS file onto the matched node.
365 ///
366 /// # Panics
367 /// If the query matches zero or several nodes.
368 pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
369 self.hover(q);
370 self.input(InputEvent::FileDrop(path.into()));
371 }
372
373 /// Scrolls the wheel over the matched node (positive `dy` moves
374 /// content down, winit convention).
375 ///
376 /// # Panics
377 /// If the query matches zero or several nodes.
378 pub fn wheel(&mut self, q: &Query, dy: f32) {
379 self.hover(q);
380 self.input(InputEvent::Wheel { dy });
381 }
382
383 /// Advances the deterministic clock by `ms` milliseconds and
384 /// rebuilds — animations and timers move exactly this far.
385 pub fn pump(&mut self, ms: f64) {
386 self.clock += ms / 1000.0;
387 self.rebuild();
388 }
389
390 /// Applies one message directly (as a proxy or window event would)
391 /// and rebuilds. Not logged in [`Self::take_messages`].
392 pub fn update(&mut self, msg: A::Msg) {
393 self.app.update(msg);
394 self.rebuild();
395 }
396
397 /// The single matching node; panics (with the accessibility tree in
398 /// the message) on zero or several matches.
399 ///
400 /// # Panics
401 /// If the query matches zero or several nodes.
402 pub fn get(&self, q: &Query) -> AccessNode {
403 self.slot().frame.get(q)
404 }
405
406 /// The single matching node, or `None`. Use to assert absence.
407 ///
408 /// # Panics
409 /// If the query matches several nodes.
410 pub fn query(&self, q: &Query) -> Option<AccessNode> {
411 self.slot().frame.query(q)
412 }
413
414 /// Every matching node in tree order.
415 pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
416 self.slot().frame.get_all(q)
417 }
418
419 /// Messages emitted by handlers since the last call (the Elm-level
420 /// assertion: *what the UI said*, independent of state effects).
421 /// Proxied and [`Self::update`] messages are inputs, not logged.
422 pub fn take_messages(&mut self) -> Vec<A::Msg> {
423 std::mem::take(&mut self.msgs)
424 }
425
426 /// The active window's current frame, for direct queries and
427 /// `access_yaml()`.
428 pub fn frame(&self) -> &Frame {
429 &self.slot().frame
430 }
431
432 /// The app under test.
433 pub fn app(&self) -> &A {
434 &self.app
435 }
436
437 /// Mutable access to the app; call [`Self::rebuild`] afterwards.
438 pub fn app_mut(&mut self) -> &mut A {
439 &mut self.app
440 }
441
442 /// Renders the active window to pixels. Mid-test captures are fine —
443 /// the frame is not consumed.
444 ///
445 /// # Panics
446 /// If rendering fails.
447 pub fn render(&mut self) -> RgbaImage {
448 let key = self.active.clone();
449 self.render_window(&key)
450 }
451
452 /// Renders any open window to pixels.
453 ///
454 /// # Panics
455 /// If no open window has this key, or rendering fails.
456 pub fn render_window(&mut self, key: &str) -> RgbaImage {
457 assert!(
458 self.slots.contains_key(key),
459 "no open window {key:?}; open windows: {:?}",
460 self.window_keys()
461 );
462 let bg = self.theme.bg;
463 let slot = self.slots.get_mut(key).expect("checked above");
464 let scene = with_fonts(|fonts| slot.frame.paint(fonts, &mut slot.state));
465 with_headless(|h| h.render(&scene, slot.size.0, slot.size.1, bg))
466 .expect("headless renderer unavailable")
467 .expect("headless render failed")
468 }
469}