End-to-end testing the windowing layer
A design note on how to catch input-behaviour bugs in the raylib GUI apps (hue --gui, apps/terminal) — the class of bug that only appears once a real window manager and real pointer-event routing are in the loop, and is therefore structurally invisible to in-process unit tests.
It is written against a concrete case study (mouse-drag-over-decoration bugs, below), motivates why the usual test layers miss them, and specifies an automated end-to-end harness that would catch them. Building the harness is deferred (see status). See also the windowing concepts and the platform comparison.
Case study: drag bugs that escaped every test
On GNOME/Wayland the raylib apps run through XWayland (GLFW's X11 backend — a GLFW window under mutter only gets a title bar + close/minimize/maximize buttons via X11; native Wayland under GNOME is decorationless). The apps hold no pointer grab during a drag, and XWayland/mutter routes a pointer event that lands on the decoration (or off the window) to the decoration, not the app. That single cause produces several symptoms:
- A drag released on a title-bar button triggers it — releasing a text selection or scrollbar drag over close / minimize / maximize fires that WM button (the window closes / minimizes / maximizes).
- A drag that leaves the content loses its mouse-up — the app never sees the release, so a selection keeps extending; and a scrollbar drag, which should keep working while the button is held outside the window, can't, because no motion events arrive out there.
Why in-process tests can't catch this
The decisive events happen outside the application:
- A WM-button activation (close/minimize/maximize) is performed by the window manager, not app code.
WindowShouldClose()(only for close) is observed after the fact; minimize/maximize aren't even visible as app events. - The lost mouse-up is a compositor routing decision. No app-visible event is generated to assert on — and worse, GLFW's button state can stay stuck "down".
A pure logic test — inject a scripted down → move → up at the raylib-call seam, assert the selection range / shouldClose — validates the app-side contract but cannot reproduce any of these: the bug lives in event delivery the mock supplies by fiat. This was confirmed the hard way: app-level mitigations (veto a mid-drag close; end the drag when the cursor leaves the content) both passed review and built green, yet the first can't touch minimize/maximize and the second broke legitimate outside-window scrollbar dragging — neither would have been caught without a real WM in the loop. Catching this class requires a test with (a) a real decorating window manager and (b) synthetic pointer events routed by that WM.
The approach: real headless WM + synthetic input + observe
A layered strategy; only the outer layer catches this class.
Layer 1 — input-logic seam (fast, in-process, deterministic)
Put an input provider behind a small interface (isMouseButtonPressed, getMousePosition, isCursorOnScreen, windowShouldClose, …): the real impl calls raylib; the test impl replays a scripted event list. Unit-test the frame-handler's state transitions and intents. @nogc-clean, matches the repo's design-by-introspection style, locks the app-side contract — but does not exercise WM routing.
Layer 2 — windowing E2E (slow, real, gated) — the one that catches it
A D harness (per the repo "scripts in D" rule) that:
- Stands up a headless display with real decorations — the load-bearing part:
- X11 / XWayland (the apps' actual path today):
Xvfb :N+ a decorating WM (metacity/openbox), or a nestedmutter --x11for mutter-faithful routing. Launchhue --gui/apps/terminalon:N. - Native Wayland (future): a headless compositor (
labwc/weston --backend=headless/sway --headless) plus thewlr-virtual-pointerprotocol (orydotool/wtypevia/dev/uinput).
- X11 / XWayland (the apps' actual path today):
- Reads the window's frame geometry (
xdotool/xwininfo;swaymsg -t get_tree) and locates each title-bar button. - Drives the exact failing gestures via OS-level input (
xdotoolXTEST on X11;ydotool/wtypeon Wayland — no app hook needed):mousedownin content →mousemoveonto the close/minimize/maximize button (and off the window entirely) →mouseup→mousemoveback. - Asserts the observable outcome: the window is still open / not minimized / not maximized; the selection ended (or, for the scrollbar, continued while the button was held outside — see observability). Inverse guards: a real click on each button still works; a normal in-content drag still selects.
skipTest("no headless WM")when the display / WM / input tool is absent (mirrors the repo'sskipTestidiom).
Observability: the app must expose state
"Window alive / minimized / maximized" is observable via the WM. "Did the selection end / did the scrollbar keep dragging" is not, from outside. Add an env-gated debug sink — e.g. HUE_GUI_DEBUG_STATE=/path — that dumps selecting, the selection byte range, scroll offset, and window state each frame. That turns behavioural assertions into file reads instead of pixel-diffing. The GUI already has golden screenshot capture (TakeScreenshot) as a fallback for "does it look right".
Fidelity and CI
- The bug is WM-specific.
openboxmay honour the X11 implicit grab and not reproduce the mutter/XWayland leak. The harness must first prove it reproduces the bug before it is trusted as a regression gate; if a light WM does not, use nestedmutter. "Confirm the test has teeth" is non-negotiable for this class. - Matrix on the routing layer. X11 vs XWayland vs native-Wayland route pointer events differently; the same code passes on one and fails on another.
- Isolation. Run in a throwaway display, never the developer's live session — synthetic clicks and cursor warps are disruptive.
- Packaging. A dedicated nix flake check / CI job provides
Xvfb+ a WM +xdotool(all in nixpkgs), gated and separate from the fast tests.
The essential principle
To catch "a drag that ends on a decoration fires a WM button / loses the mouse-up", the test must contain a real window manager drawing decorations and synthetic pointer events routed by that WM. Everything short of that — mocked raylib, in-process state assertions — structurally cannot see it, because the decisive event is produced outside the application.
Status
- Harness: deferred. This note is the design; no harness is built yet.
- Fix: open. The proper fix is a real active pointer grab for the duration of the drag (X11:
XGrabPointer(confine_to = content, owner_events, ButtonRelease | PointerMotion)via GLFW's native handles; native-Wayland:zwp_pointer_constraints), so every event reaches the app and the cursor can't reach the decorations. It is unverified against the actual compositor — exactly what the Layer-2 harness is for. Tracked insparkles:uiopen issueUI-O3.