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_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}