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. [`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}