Skip to main content

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}