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_at(key, logical);
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_at(&key, slot.logical);
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 /// Resizes one window: clamps to the renderer's limits, updates the slot's
216 /// pixel and logical size, and rebuilds its frame via [`App::view_at`] at
217 /// the new size — the headless analogue of dragging a window edge, and how
218 /// `view_at` window breakpoints and
219 /// [`responsive`](fenestra_core::responsive) container queries are driven.
220 /// Other windows are untouched.
221 ///
222 /// # Panics
223 /// If no open window has this key.
224 pub fn resize(&mut self, key: &str, width: u32, height: u32) {
225 assert!(
226 self.slots.contains_key(key),
227 "no open window {key:?}; open windows: {:?}",
228 self.window_keys()
229 );
230 let size =
231 with_headless(|h| h.clamp_size(width, height)).expect("headless renderer unavailable");
232 #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
233 let logical = (size.0 as f32, size.1 as f32);
234 let view = self.app.view_at(key, logical);
235 let slot = self.slots.get_mut(key).expect("checked above");
236 slot.size = size;
237 slot.logical = logical;
238 slot.view = view;
239 slot.state.tick(self.clock);
240 slot.frame = with_fonts(|fonts| {
241 build_frame(
242 &slot.view,
243 &self.theme,
244 fonts,
245 &mut slot.state,
246 logical,
247 1.0,
248 )
249 });
250 }
251
252 /// The keys of every open window, sorted (main first).
253 pub fn window_keys(&self) -> Vec<String> {
254 let mut keys: Vec<String> = self.slots.keys().cloned().collect();
255 keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
256 keys
257 }
258
259 /// Dispatches one raw input event against the active window's
260 /// current frame, logs and applies the emitted messages, and
261 /// rebuilds (which also reconciles the window set).
262 pub fn input(&mut self, event: InputEvent) {
263 let slot = self
264 .slots
265 .get_mut(&self.active)
266 .expect("active slot exists");
267 let result =
268 with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
269 for msg in result.msgs {
270 self.msgs.push(msg.clone());
271 self.app.update(msg);
272 }
273 self.rebuild();
274 }
275
276 fn center(&self, q: &Query) -> (f32, f32) {
277 let node = self.slot().frame.get(q);
278 let c = node.rect.center();
279 #[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
280 (c.x as f32, c.y as f32)
281 }
282
283 /// Moves the pointer to the center of the matched node.
284 ///
285 /// # Panics
286 /// If the query matches zero or several nodes.
287 pub fn hover(&mut self, q: &Query) {
288 let (x, y) = self.center(q);
289 self.input(InputEvent::PointerMove { x, y });
290 }
291
292 /// Clicks (press + release) the center of the matched node.
293 ///
294 /// # Panics
295 /// If the query matches zero or several nodes.
296 pub fn click(&mut self, q: &Query) {
297 self.hover(q);
298 self.input(InputEvent::PointerDown);
299 self.input(InputEvent::PointerUp);
300 }
301
302 /// Right-clicks the center of the matched node.
303 ///
304 /// # Panics
305 /// If the query matches zero or several nodes.
306 pub fn right_click(&mut self, q: &Query) {
307 self.hover(q);
308 self.input(InputEvent::RightDown);
309 self.input(InputEvent::RightUp);
310 }
311
312 /// Double-clicks the matched node (two clicks inside the
313 /// double-click window — the harness clock does not advance).
314 ///
315 /// # Panics
316 /// If the query matches zero or several nodes.
317 pub fn double_click(&mut self, q: &Query) {
318 self.click(q);
319 self.click(q);
320 }
321
322 /// Triple-clicks the matched node (text inputs select the line).
323 ///
324 /// # Panics
325 /// If the query matches zero or several nodes.
326 pub fn triple_click(&mut self, q: &Query) {
327 self.click(q);
328 self.click(q);
329 self.click(q);
330 }
331
332 /// Clicks with Shift held (text inputs extend the selection from
333 /// the caret to the click point).
334 ///
335 /// # Panics
336 /// If the query matches zero or several nodes.
337 pub fn shift_click(&mut self, q: &Query) {
338 self.input(InputEvent::Modifiers {
339 shift: true,
340 ctrl: false,
341 alt: false,
342 meta: false,
343 });
344 self.click(q);
345 self.input(InputEvent::Modifiers {
346 shift: false,
347 ctrl: false,
348 alt: false,
349 meta: false,
350 });
351 }
352
353 /// Commits text to the focused element (like typing or IME commit).
354 pub fn type_text(&mut self, text: impl Into<String>) {
355 self.input(InputEvent::Text(text.into()));
356 }
357
358 /// Presses one key.
359 pub fn key(&mut self, key: KeyInput) {
360 self.input(InputEvent::Key(key));
361 }
362
363 /// Focuses the next focusable element (Tab).
364 pub fn tab(&mut self) {
365 self.input(InputEvent::Tab);
366 }
367
368 /// Focuses the previous focusable element (Shift-Tab).
369 pub fn shift_tab(&mut self) {
370 self.input(InputEvent::ShiftTab);
371 }
372
373 /// Focuses the matched node directly (what assistive technology's
374 /// Focus action does). Prefer [`Self::tab`] to test the real path.
375 ///
376 /// # Panics
377 /// If the query matches zero or several nodes.
378 pub fn focus(&mut self, q: &Query) {
379 let slot = self
380 .slots
381 .get_mut(&self.active)
382 .expect("active slot exists");
383 let id = slot.frame.get(q).id;
384 slot.state.set_focus(Some(id));
385 self.rebuild();
386 }
387
388 /// Drags from one node to another: press on `from`, move to `to`
389 /// (recomputed after the press, in case layout shifted), release.
390 ///
391 /// # Panics
392 /// If either query matches zero or several nodes.
393 pub fn drag(&mut self, from: &Query, to: &Query) {
394 self.hover(from);
395 self.input(InputEvent::PointerDown);
396 let (x, y) = self.center(to);
397 self.input(InputEvent::PointerMove { x, y });
398 self.input(InputEvent::PointerUp);
399 }
400
401 /// Drops an OS file onto the matched node.
402 ///
403 /// # Panics
404 /// If the query matches zero or several nodes.
405 pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
406 self.hover(q);
407 self.input(InputEvent::FileDrop(path.into()));
408 }
409
410 /// Scrolls the wheel over the matched node (positive `dy` moves
411 /// content down, winit convention).
412 ///
413 /// # Panics
414 /// If the query matches zero or several nodes.
415 pub fn wheel(&mut self, q: &Query, dy: f32) {
416 self.hover(q);
417 self.input(InputEvent::Wheel { dx: 0.0, dy });
418 }
419
420 /// Scrolls the wheel on both axes over the matched node (positive `dx`
421 /// moves content right, positive `dy` moves content down).
422 ///
423 /// # Panics
424 /// If the query matches zero or several nodes.
425 pub fn wheel_xy(&mut self, q: &Query, dx: f32, dy: f32) {
426 self.hover(q);
427 self.input(InputEvent::Wheel { dx, dy });
428 }
429
430 /// Advances the deterministic clock by `ms` milliseconds and
431 /// rebuilds — animations and timers move exactly this far.
432 pub fn pump(&mut self, ms: f64) {
433 self.clock += ms / 1000.0;
434 self.rebuild();
435 }
436
437 /// Applies one message directly (as a proxy or window event would)
438 /// and rebuilds. Not logged in [`Self::take_messages`].
439 pub fn update(&mut self, msg: A::Msg) {
440 self.app.update(msg);
441 self.rebuild();
442 }
443
444 /// The single matching node; panics (with the accessibility tree in
445 /// the message) on zero or several matches.
446 ///
447 /// # Panics
448 /// If the query matches zero or several nodes.
449 pub fn get(&self, q: &Query) -> AccessNode {
450 self.slot().frame.get(q)
451 }
452
453 /// The single matching node, or `None`. Use to assert absence.
454 ///
455 /// # Panics
456 /// If the query matches several nodes.
457 pub fn query(&self, q: &Query) -> Option<AccessNode> {
458 self.slot().frame.query(q)
459 }
460
461 /// Every matching node in tree order.
462 pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
463 self.slot().frame.get_all(q)
464 }
465
466 /// Messages emitted by handlers since the last call (the Elm-level
467 /// assertion: *what the UI said*, independent of state effects).
468 /// Proxied and [`Self::update`] messages are inputs, not logged.
469 pub fn take_messages(&mut self) -> Vec<A::Msg> {
470 std::mem::take(&mut self.msgs)
471 }
472
473 /// The active window's current frame, for direct queries and
474 /// `access_yaml()`.
475 pub fn frame(&self) -> &Frame {
476 &self.slot().frame
477 }
478
479 /// The app under test.
480 pub fn app(&self) -> &A {
481 &self.app
482 }
483
484 /// Mutable access to the app; call [`Self::rebuild`] afterwards.
485 pub fn app_mut(&mut self) -> &mut A {
486 &mut self.app
487 }
488
489 /// Renders the active window to pixels. Mid-test captures are fine —
490 /// the frame is not consumed.
491 ///
492 /// # Panics
493 /// If rendering fails.
494 pub fn render(&mut self) -> RgbaImage {
495 let key = self.active.clone();
496 self.render_window(&key)
497 }
498
499 /// Renders any open window to pixels.
500 ///
501 /// # Panics
502 /// If no open window has this key, or rendering fails.
503 pub fn render_window(&mut self, key: &str) -> RgbaImage {
504 assert!(
505 self.slots.contains_key(key),
506 "no open window {key:?}; open windows: {:?}",
507 self.window_keys()
508 );
509 let bg = self.theme.bg;
510 let slot = self.slots.get_mut(key).expect("checked above");
511 // Two-pass planner (fonts → headless lock order) so frosted glass blurs;
512 // glass-free frames fast-path to a single pass.
513 with_fonts(|fonts| {
514 with_headless(|h| {
515 h.render_plan(
516 &slot.frame,
517 fonts,
518 &mut slot.state,
519 slot.size.0,
520 slot.size.1,
521 bg,
522 )
523 })
524 .expect("headless renderer unavailable")
525 })
526 .expect("headless render failed")
527 }
528}