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. [`Harness::film`] captures a whole sequence
22//! of renders across the clock, so an agent can watch a transition play
23//! instead of only ever seeing frozen frames — see its docs for how that
24//! squares with the reduced-motion default above.
25
26use std::sync::{Arc, Mutex, PoisonError};
27
28use std::collections::HashMap;
29
30use fenestra_core::{
31 AccessNode, App, Element, Frame, FrameState, InputEvent, KeyInput, MAIN_WINDOW, Proxy, Query,
32 Theme, build_frame, dispatch,
33};
34use image::RgbaImage;
35
36use crate::element_render::with_fonts;
37use crate::with_headless;
38
39/// Hard ceiling on `frames` in [`Harness::film`]: a filmstrip is meant for
40/// an agent to review in one sitting, and every frame is a full GPU render —
41/// this many already takes seconds and produces a strip nobody reviews at a
42/// glance. Clamp-over-panic: a hostile or mistaken huge request degrades to
43/// this ceiling instead of hanging or exhausting memory rendering it.
44pub const MAX_FILM_FRAMES: usize = 64;
45
46/// Hard ceiling on `interval_ms` in [`Harness::film`]: a span this long turns
47/// "watch a transition play" into unrelated snapshots minutes apart. Chain
48/// several `film` calls (or `pump` between them) to cover a longer timeline
49/// at a sensible cadence.
50pub const MAX_FILM_INTERVAL_MS: u64 = 60_000;
51
52/// One headless window: its own retained state, view, and frame —
53/// exactly like the windowed runner keeps per window.
54struct WindowSlot<Msg> {
55 state: FrameState,
56 view: Element<Msg>,
57 frame: Frame,
58 logical: (f32, f32),
59 size: (u32, u32),
60}
61
62/// A headless app under test. See the module docs for the model.
63pub struct Harness<A: App> {
64 app: A,
65 theme: Theme,
66 /// Deterministic clock in seconds, advanced only by [`Self::pump`].
67 clock: f64,
68 /// Messages emitted by handlers since the last [`Self::take_messages`].
69 msgs: Vec<A::Msg>,
70 pending: Arc<Mutex<Vec<A::Msg>>>,
71 /// Open windows by key; reconciled against [`App::windows`] after
72 /// every update, exactly like the windowed runner.
73 slots: HashMap<String, WindowSlot<A::Msg>>,
74 /// Animations snap by default (deterministic); motion tests opt in.
75 reduced_motion: bool,
76 /// The window verbs and queries currently target.
77 active: String,
78}
79
80impl<A: App> Harness<A>
81where
82 A::Msg: Send,
83{
84 /// Builds the first frame. [`App::init`] runs with a collecting
85 /// [`Proxy`]; proxied messages drain at every rebuild (after each
86 /// input, [`Self::pump`], or [`Self::update`]).
87 ///
88 /// # Panics
89 /// If no compute-capable GPU adapter exists.
90 pub fn new(mut app: A, theme: Theme, size: (u32, u32)) -> Self {
91 let size =
92 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
93 let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
94 let sink = Arc::clone(&pending);
95 app.init(Proxy::new(move |msg| {
96 sink.lock()
97 .unwrap_or_else(PoisonError::into_inner)
98 .push(msg);
99 }));
100 Self::drain(&mut app, &pending);
101 let mut harness = Self {
102 app,
103 theme,
104 clock: 0.0,
105 msgs: Vec::new(),
106 pending,
107 slots: HashMap::new(),
108 active: MAIN_WINDOW.to_owned(),
109 reduced_motion: true,
110 };
111 harness.slots.insert(
112 MAIN_WINDOW.to_owned(),
113 Self::new_slot(&harness.app, &harness.theme, MAIN_WINDOW, size, 0.0, true),
114 );
115 harness.rebuild();
116 harness
117 }
118
119 fn new_slot(
120 app: &A,
121 theme: &Theme,
122 key: &str,
123 size: (u32, u32),
124 clock: f64,
125 reduced_motion: bool,
126 ) -> WindowSlot<A::Msg> {
127 let size =
128 with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
129 let mut state = FrameState::new();
130 state.reduced_motion = reduced_motion;
131 state.tick(clock);
132 #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
133 let logical = (size.0 as f32, size.1 as f32);
134 let view = app.view_at(key, logical);
135 let frame = with_fonts(|fonts| build_frame(&view, theme, fonts, &mut state, logical, 1.0));
136 WindowSlot {
137 state,
138 view,
139 frame,
140 logical,
141 size,
142 }
143 }
144
145 fn drain(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
146 let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
147 for msg in msgs {
148 app.update(msg);
149 }
150 }
151
152 /// Rebuilds every window from current app state (proxied messages
153 /// drain first) and reconciles the declared window set: new keys
154 /// open, missing keys close (the active window falls back to main).
155 /// Runs automatically after every input; call it yourself only
156 /// after mutating via [`Self::app_mut`].
157 pub fn rebuild(&mut self) {
158 Self::drain(&mut self.app, &self.pending);
159 let descs = self.app.windows();
160 self.slots
161 .retain(|key, _| key == MAIN_WINDOW || descs.iter().any(|d| &d.key == key));
162 for desc in &descs {
163 if !self.slots.contains_key(&desc.key) {
164 #[expect(
165 clippy::cast_possible_truncation,
166 clippy::cast_sign_loss,
167 reason = "logical window sizes are small positive numbers"
168 )]
169 let size = (desc.size.0.max(1.0) as u32, desc.size.1.max(1.0) as u32);
170 let slot = Self::new_slot(
171 &self.app,
172 &self.theme,
173 &desc.key,
174 size,
175 self.clock,
176 self.reduced_motion,
177 );
178 self.slots.insert(desc.key.clone(), slot);
179 }
180 }
181 if !self.slots.contains_key(&self.active) {
182 self.active = MAIN_WINDOW.to_owned();
183 }
184 let keys: Vec<String> = self.slots.keys().cloned().collect();
185 for key in keys {
186 let slot = self.slots.get_mut(&key).expect("slot exists");
187 slot.view = self.app.view_at(&key, slot.logical);
188 slot.state.tick(self.clock);
189 slot.frame = with_fonts(|fonts| {
190 build_frame(
191 &slot.view,
192 &self.theme,
193 fonts,
194 &mut slot.state,
195 slot.logical,
196 1.0,
197 )
198 });
199 }
200 }
201
202 fn slot(&self) -> &WindowSlot<A::Msg> {
203 self.slots.get(&self.active).expect("active slot exists")
204 }
205
206 /// Enables or disables real animation. The harness defaults to
207 /// reduced motion (everything snaps — deterministic pixels); motion
208 /// tests opt into physics and drive it with [`Self::pump`].
209 pub fn set_reduced_motion(&mut self, reduced: bool) {
210 self.reduced_motion = reduced;
211 for slot in self.slots.values_mut() {
212 slot.state.reduced_motion = reduced;
213 }
214 self.rebuild();
215 }
216
217 /// Switches which window the verbs and queries target. Open windows
218 /// come from [`App::windows`]; [`MAIN_WINDOW`] is always open.
219 ///
220 /// # Panics
221 /// If no open window has this key (the message lists the open ones).
222 pub fn activate_window(&mut self, key: &str) {
223 assert!(
224 self.slots.contains_key(key),
225 "no open window {key:?}; open windows: {:?}",
226 self.window_keys()
227 );
228 self.active = key.to_owned();
229 }
230
231 /// Resizes one window: clamps to the renderer's limits, updates the slot's
232 /// pixel and logical size, and rebuilds its frame via [`App::view_at`] at
233 /// the new size — the headless analogue of dragging a window edge, and how
234 /// `view_at` window breakpoints and
235 /// [`responsive`](fenestra_core::responsive) container queries are driven.
236 /// Other windows are untouched.
237 ///
238 /// # Panics
239 /// If no open window has this key.
240 pub fn resize(&mut self, key: &str, width: u32, height: u32) {
241 assert!(
242 self.slots.contains_key(key),
243 "no open window {key:?}; open windows: {:?}",
244 self.window_keys()
245 );
246 let size =
247 with_headless(|h| h.clamp_size(width, height)).expect("headless renderer unavailable");
248 #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
249 let logical = (size.0 as f32, size.1 as f32);
250 let view = self.app.view_at(key, logical);
251 let slot = self.slots.get_mut(key).expect("checked above");
252 slot.size = size;
253 slot.logical = logical;
254 slot.view = view;
255 slot.state.tick(self.clock);
256 slot.frame = with_fonts(|fonts| {
257 build_frame(
258 &slot.view,
259 &self.theme,
260 fonts,
261 &mut slot.state,
262 logical,
263 1.0,
264 )
265 });
266 }
267
268 /// The keys of every open window, sorted (main first).
269 pub fn window_keys(&self) -> Vec<String> {
270 let mut keys: Vec<String> = self.slots.keys().cloned().collect();
271 keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
272 keys
273 }
274
275 /// Dispatches one raw input event against the active window's
276 /// current frame, logs and applies the emitted messages, and
277 /// rebuilds (which also reconciles the window set).
278 pub fn input(&mut self, event: InputEvent) {
279 let slot = self
280 .slots
281 .get_mut(&self.active)
282 .expect("active slot exists");
283 let result =
284 with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
285 for msg in result.msgs {
286 self.msgs.push(msg.clone());
287 self.app.update(msg);
288 }
289 self.rebuild();
290 }
291
292 fn center(&self, q: &Query) -> (f32, f32) {
293 let node = self.slot().frame.get(q);
294 let c = node.rect.center();
295 #[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
296 (c.x as f32, c.y as f32)
297 }
298
299 /// Moves the pointer to the center of the matched node.
300 ///
301 /// # Panics
302 /// If the query matches zero or several nodes.
303 pub fn hover(&mut self, q: &Query) {
304 let (x, y) = self.center(q);
305 self.input(InputEvent::PointerMove { x, y });
306 }
307
308 /// Clicks (press + release) the center of the matched node.
309 ///
310 /// # Panics
311 /// If the query matches zero or several nodes.
312 pub fn click(&mut self, q: &Query) {
313 self.hover(q);
314 self.input(InputEvent::PointerDown);
315 self.input(InputEvent::PointerUp);
316 }
317
318 /// Right-clicks the center of the matched node.
319 ///
320 /// # Panics
321 /// If the query matches zero or several nodes.
322 pub fn right_click(&mut self, q: &Query) {
323 self.hover(q);
324 self.input(InputEvent::RightDown);
325 self.input(InputEvent::RightUp);
326 }
327
328 /// Double-clicks the matched node (two clicks inside the
329 /// double-click window — the harness clock does not advance).
330 ///
331 /// # Panics
332 /// If the query matches zero or several nodes.
333 pub fn double_click(&mut self, q: &Query) {
334 self.click(q);
335 self.click(q);
336 }
337
338 /// Triple-clicks the matched node (text inputs select the line).
339 ///
340 /// # Panics
341 /// If the query matches zero or several nodes.
342 pub fn triple_click(&mut self, q: &Query) {
343 self.click(q);
344 self.click(q);
345 self.click(q);
346 }
347
348 /// Clicks with Shift held (text inputs extend the selection from
349 /// the caret to the click point).
350 ///
351 /// # Panics
352 /// If the query matches zero or several nodes.
353 pub fn shift_click(&mut self, q: &Query) {
354 self.input(InputEvent::Modifiers {
355 shift: true,
356 ctrl: false,
357 alt: false,
358 meta: false,
359 });
360 self.click(q);
361 self.input(InputEvent::Modifiers {
362 shift: false,
363 ctrl: false,
364 alt: false,
365 meta: false,
366 });
367 }
368
369 /// Commits text to the focused element (like typing or IME commit).
370 pub fn type_text(&mut self, text: impl Into<String>) {
371 self.input(InputEvent::Text(text.into()));
372 }
373
374 /// Presses one key.
375 pub fn key(&mut self, key: KeyInput) {
376 self.input(InputEvent::Key(key));
377 }
378
379 /// Focuses the next focusable element (Tab).
380 pub fn tab(&mut self) {
381 self.input(InputEvent::Tab);
382 }
383
384 /// Focuses the previous focusable element (Shift-Tab).
385 pub fn shift_tab(&mut self) {
386 self.input(InputEvent::ShiftTab);
387 }
388
389 /// Focuses the matched node directly (what assistive technology's
390 /// Focus action does). Prefer [`Self::tab`] to test the real path.
391 ///
392 /// # Panics
393 /// If the query matches zero or several nodes.
394 pub fn focus(&mut self, q: &Query) {
395 let slot = self
396 .slots
397 .get_mut(&self.active)
398 .expect("active slot exists");
399 let id = slot.frame.get(q).id;
400 slot.state.set_focus(Some(id));
401 self.rebuild();
402 }
403
404 /// Drags from one node to another: press on `from`, move to `to`
405 /// (recomputed after the press, in case layout shifted), release.
406 ///
407 /// # Panics
408 /// If either query matches zero or several nodes.
409 pub fn drag(&mut self, from: &Query, to: &Query) {
410 self.hover(from);
411 self.input(InputEvent::PointerDown);
412 let (x, y) = self.center(to);
413 self.input(InputEvent::PointerMove { x, y });
414 self.input(InputEvent::PointerUp);
415 }
416
417 /// Drops an OS file onto the matched node.
418 ///
419 /// # Panics
420 /// If the query matches zero or several nodes.
421 pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
422 self.hover(q);
423 self.input(InputEvent::FileDrop(path.into()));
424 }
425
426 /// Scrolls the wheel over the matched node (positive `dy` moves
427 /// content down, winit convention).
428 ///
429 /// # Panics
430 /// If the query matches zero or several nodes.
431 pub fn wheel(&mut self, q: &Query, dy: f32) {
432 self.hover(q);
433 self.input(InputEvent::Wheel { dx: 0.0, dy });
434 }
435
436 /// Scrolls the wheel on both axes over the matched node (positive `dx`
437 /// moves content right, positive `dy` moves content down).
438 ///
439 /// # Panics
440 /// If the query matches zero or several nodes.
441 pub fn wheel_xy(&mut self, q: &Query, dx: f32, dy: f32) {
442 self.hover(q);
443 self.input(InputEvent::Wheel { dx, dy });
444 }
445
446 /// Advances the deterministic clock by `ms` milliseconds and
447 /// rebuilds — animations and timers move exactly this far.
448 pub fn pump(&mut self, ms: f64) {
449 self.clock += ms / 1000.0;
450 self.rebuild();
451 }
452
453 /// Applies one message directly (as a proxy or window event would)
454 /// and rebuilds. Not logged in [`Self::take_messages`].
455 pub fn update(&mut self, msg: A::Msg) {
456 self.app.update(msg);
457 self.rebuild();
458 }
459
460 /// The single matching node; panics (with the accessibility tree in
461 /// the message) on zero or several matches.
462 ///
463 /// # Panics
464 /// If the query matches zero or several nodes.
465 pub fn get(&self, q: &Query) -> AccessNode {
466 self.slot().frame.get(q)
467 }
468
469 /// The single matching node, or `None`. Use to assert absence.
470 ///
471 /// # Panics
472 /// If the query matches several nodes.
473 pub fn query(&self, q: &Query) -> Option<AccessNode> {
474 self.slot().frame.query(q)
475 }
476
477 /// Every matching node in tree order.
478 pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
479 self.slot().frame.get_all(q)
480 }
481
482 /// Messages emitted by handlers since the last call (the Elm-level
483 /// assertion: *what the UI said*, independent of state effects).
484 /// Proxied and [`Self::update`] messages are inputs, not logged.
485 pub fn take_messages(&mut self) -> Vec<A::Msg> {
486 std::mem::take(&mut self.msgs)
487 }
488
489 /// The active window's current frame, for direct queries and
490 /// `access_yaml()`.
491 pub fn frame(&self) -> &Frame {
492 &self.slot().frame
493 }
494
495 /// The app under test.
496 pub fn app(&self) -> &A {
497 &self.app
498 }
499
500 /// Mutable access to the app; call [`Self::rebuild`] afterwards.
501 pub fn app_mut(&mut self) -> &mut A {
502 &mut self.app
503 }
504
505 /// Renders the active window to pixels. Mid-test captures are fine —
506 /// the frame is not consumed.
507 ///
508 /// # Panics
509 /// If rendering fails.
510 pub fn render(&mut self) -> RgbaImage {
511 let key = self.active.clone();
512 self.render_window(&key)
513 }
514
515 /// Renders any open window to pixels.
516 ///
517 /// # Panics
518 /// If no open window has this key, or rendering fails.
519 pub fn render_window(&mut self, key: &str) -> RgbaImage {
520 assert!(
521 self.slots.contains_key(key),
522 "no open window {key:?}; open windows: {:?}",
523 self.window_keys()
524 );
525 let bg = self.theme.bg;
526 let slot = self.slots.get_mut(key).expect("checked above");
527 // Two-pass planner (fonts → headless lock order) so frosted glass blurs;
528 // glass-free frames fast-path to a single pass.
529 with_fonts(|fonts| {
530 with_headless(|h| {
531 h.render_plan(
532 &slot.frame,
533 fonts,
534 &mut slot.state,
535 slot.size.0,
536 slot.size.1,
537 bg,
538 )
539 })
540 .expect("headless renderer unavailable")
541 })
542 .expect("headless render failed")
543 }
544
545 /// Captures `frames` renders of the active window, `interval_ms` apart on
546 /// the deterministic clock: the first frame is the window exactly as it
547 /// stands now, then [`Self::pump`] and [`Self::render`] repeat — so
548 /// `film(3, 100)` returns the states at +0ms, +100ms, +200ms.
549 ///
550 /// [`Self::new`] defaults every harness to reduced motion, the same
551 /// default every other verification path relies on so single-shot
552 /// goldens stay stable — under it every transition snaps to its target
553 /// immediately, so a filmstrip captured without changing that is `frames`
554 /// copies of the same pixels. Call
555 /// [`Self::set_reduced_motion`]`(false)` first to see real motion play;
556 /// determinism still holds, because it comes from the clock (advanced
557 /// only by [`Self::pump`]), never from suppressing animation.
558 ///
559 /// `frames` is floored at 1 and clamped to [`MAX_FILM_FRAMES`];
560 /// `interval_ms` is clamped to [`MAX_FILM_INTERVAL_MS`] (see their docs).
561 ///
562 /// # Panics
563 /// If rendering fails (see [`Self::render`]).
564 pub fn film(&mut self, frames: usize, interval_ms: u64) -> Vec<RgbaImage> {
565 let frames = frames.clamp(1, MAX_FILM_FRAMES);
566 let interval_ms = interval_ms.min(MAX_FILM_INTERVAL_MS);
567 let mut out = Vec::with_capacity(frames);
568 out.push(self.render());
569 for _ in 1..frames {
570 #[expect(
571 clippy::cast_precision_loss,
572 reason = "interval_ms is clamped to MAX_FILM_INTERVAL_MS, far under f64's exact-integer range"
573 )]
574 self.pump(interval_ms as f64);
575 out.push(self.render());
576 }
577 out
578 }
579}