Test automation¶
How the north star gets
verified: tests drive the same workspace operation set users and
roostctl drive, and assert on its events and state. No test-only
backdoors that can drift from what ships.
Audience: Claude (primary) + the maintainer. Targets: dev Macs, the Pop!_OS (COSMIC/Wayland) box, and CI (Linux + macOS runners).
The three layers¶
tools/README.md
is the source of truth for this map; the summary:
| Layer | Dir | Drives via | Verifies | Where it runs |
|---|---|---|---|---|
| 1 — functional | roosttest/ |
JSON IPC (a thin Python client) | the op set — tab.dump / tab.list / palette.* / identify: behavior + content as text |
CI, both UIs + local |
| 2 — visual | screenshot/ |
roostctl + roostctl screenshot |
pixels: colors, badges, cursor, reflow, which tab/sidebar is shown | local |
| 3 — real input | input/linux/ |
OS key/pointer injection (uinput) | the real key-encoder + mouse-gesture + clipboard path | local + one soft CI lane |
Reach for the highest layer that can answer the question. Layer 1 is
where most new coverage lands: it needs no compositor, no macOS
Accessibility (TCC) grant, and no Wayland pointer mapping, which is
exactly what makes "Mac E2E in CI" tractable at all. cargo test /
swift test remain the fast first line below all three;
tools/perf/
measures cost on a sibling axis rather than correctness.
Principles¶
- Robustness lives in the driver + app affordances, not the test language. Flake resistance comes from waiting on conditions, reading content as text, reproducible rendering, and driving via IPC instead of OS input — none of which depend on what the cases are written in.
- Drive through the control protocol. The IPC socket is the seam.
Every UI action reachable only by keyboard or mouse gets an op
(
palette.*,tab.copy/tab.paste,screenshot) so a test can trigger it without synthetic input. - No test ever calls
sleep. Wait on a condition:roostctl wait(--state,--text,--gone) or the harness'swait_*helpers.waitis poll-backed today even though an in-process iced UI socket now servesevents.subscribe(the Mac's still answersnot-implemented) — the interface is what tests depend on, so swapping in wire events later changes no test. - Content as text, pixels only when targeted. Assert with
tab.dump, not OCR or whole-window image diffs. Layer-2 checks are targeted color probes ("this cell is amber#f0a040"), never golden full-frame comparisons. - Isolation by construction. Each test creates its own project and
cascade-closes it, and a harness-launched UI runs against a
throwaway
ROOST_STATE_DIR— a run never touches the developer's real saved tabs.
Test-mode IPC ops¶
ROOST_TEST_MODE=1 at UI launch (CI sets it; make *-ci sets it)
unlocks ops that would otherwise be a footgun. Both gated ops answer
not-enabled when the flag is absent — a deterministic error rather
than silent acceptance. The flag is read once at boot, so a tester
cannot toggle the gate mid-session.
| Op | What it does | Gated |
|---|---|---|
tab.feed_pty_bytes |
Inject bytes into a live tab's PTY-output drain — indistinguishable from real shell output to the OSC scanner and libghostty, because it rides the same channel (no shadow drain). | yes |
tab.capture_pty_input |
Read (and optionally drain) the bytes the UI queued onto a tab's PTY-input side — keystrokes, paste, synthesized OSC replies. One tap point catches everything. | yes |
tab.dump_resolved |
Walk the viewport through the same resolve_cell_colors call the production paint path runs. |
no |
Together the first two cover byte-level OSC and reply wiring end to end
without needing a real shell — the pattern walk is in
tools/roosttest/README.md.
tab.dump_resolved is ungated for the same reason the gated pair is
vision-compliant: it is a richer read of an existing surface, not a
new one.
A sibling gate, ROOST_TEST_PANIC, forces the crash-report + abort path
in the Rust UI end to end (=1 panics on the main thread at startup,
=thread from a named background thread). It fires right after the
panic hook is installed and before the single-instance lock, so it never
touches a running instance.
Harness flags¶
Full operational detail lives in
tools/roosttest/README.md.
| Knob | Set by | Effect |
|---|---|---|
ROOST_TEST_MODE=1 |
CI; make *-ci |
Unlocks the gated ops above. Read once at UI boot. |
--roost-fresh / ROOST_TEST_FRESH=1 |
make *-ci; CI |
The harness owns a hermetic instance: force-quit any running UI, launch with isolated state, always quit at teardown. Also flips precondition-skips into hard failures. |
ROOST_STATE_DIR |
harness (per-run mkdtemp) |
Production env, both UIs. Redirects only state.json's directory; socket/lock/log stay on the default path so the harness still finds the UI. |
ROOST_DEFAULTS_SUITE |
harness (Mac) | Production env, Mac only. Redirects UserDefaults (sidebar visibility/width) to a throwaway suite — the analog of ROOST_STATE_DIR, which cannot reach it. |
ROOST_TEST_TIMEOUT_SCALE |
CI (slower runners) | Scales every wait_* budget. Local default 1. |
ROOST_CONFIG |
harness | Points the UI at the seeded fixture config (launcher commands, theme). |
ROOST_ICED_APP |
make e2e-iced-bundle; CI |
Drives the iced target through a real .app bundle via LaunchServices instead of a bare binary. |
The skip policy (the trustworthiness rule)¶
A skip must mean only "this environment genuinely cannot exercise
this" — never "the setup didn't work" or "we didn't turn the mode on."
Three helpers in tools/roosttest/util.py enforce it:
precondition(ok, reason)— a setup precondition (seed config present, OSC 7 cwd tracking working) is a hard failure in fresh mode, where the harness guarantees the environment; a graceful skip otherwise, since an ad-hoc dev UI may genuinely lack the capability.skip_on_ci(reason, alt_coverage=…)— for the rare test that cannot run remotely. It must cite where the regression class is otherwise covered.cwd_reaches(...)— the shared, scaled cwd poll, replacing per-file copies that ignoredROOST_TEST_TIMEOUT_SCALE.
Every run prints a SKIPS: N summary listing each skipped test and its
reason (conftest.py::pytest_terminal_summary), so a run that quietly
skipped half the suite can never read as "all green" — the failure mode
that motivated the rule.
Make targets¶
| Target | What it runs |
|---|---|
make test |
cargo test --workspace (+ the roost-vt ffi tests) + swift test + the harness's own unit tests |
make e2e |
Dispatch on ROOST_TARGET (mac | iced, default iced) to one of the two below |
make e2e-iced / make e2e-mac |
Quick local functional runs — reuse a running UI if one is present |
make e2e-iced-ci / make e2e-mac-ci |
CI parity: ROOST_TEST_MODE=1 + --roost-fresh, so a local run exercises the same set CI does. Both are destructive (they force-quit a running UI) and labeled accordingly. |
make e2e-iced-exit / make e2e-iced-menu-quit |
Lifecycle lanes in their own runs — the UI they drive exits, so they cannot share a session-scoped fixture |
make e2e-iced-bundle / make e2e-iced-sparkle |
macOS only: assemble Roost-Iced.app (test-keyed, for Sparkle) and run the curated smoke against the real bundle |
make test-iced-real-input / make test-iced-wayland-input |
Layer 3 — X11 (Xvfb + xdotool) and Wayland (cage + a real uinput seat) |
make e2e-iced-real-input-mac |
Layer 3 on macOS — real CGEvent/Accessibility input against the branch Roost-Iced.app (see below) |
make smoke-iced / make smoke-mac |
Layer 2 — screenshot-driven UI smoke against a running UI |
make e2e dispatches rather than running the whole tools/roosttest
directory: that directory contains modules that deliberately end the UI
they drive, which under the iced default would strand every module the
session-scoped fixture runs afterward. e2e-iced runs a curated list
instead. The enumerated-list trap: a new roosttest module needs
adding to ICED_E2E_TESTS in the Makefile and to the corresponding
lists in ci.yml, or it never runs on the iced target.
The Mac real-input harness¶
The functional suite's app.key_event and app.context_menu_* ops start
after winit and AppKit have had their say, so a bug between the OS and Roost
is invisible to them. The Mac real-input harness (tools/input/mac/) closes
that gap with real CGEvents and the Accessibility API, driving a Roost-Iced
the test launches itself. It covers Option as Meta by side, Shift+Enter, the
native right-click popup, full screen, Secure Keyboard Entry, selection
auto-scroll, the remembered window frame and SGR mouse clicks, plus two
controls on the helper's own foreign key-window guard, against a small
fixture app built per run (tools/input/mac/fixtures/key_panel.swift).
- Two modes. On a dev Mac the harness runs in runner mode: its freely
rebuilt helper (
roost-input-mac) runs as a child ofRoost Test Runner.app, a tiny app that is built once, granted Accessibility, Input Monitoring and Screen Recording once, and never rebuilt (a rebuild changes the ad-hoc signature and voids the grants). On GitHub's hosted macOS runner it runs in direct mode (ROOST_REAL_INPUT_MODE=direct): the job's own processes already hold those grants, so there is no grant step. CI runs the whole suite as a blocking step of the macOS/wgpuiced-build-e2ecell (promoted after three consecutive qualifying attempts, plan 074). - Running it.
make e2e-iced-real-input-macbuilds the helper and the bundle and runstools/roosttest/test_real_input_mac.pywithROOST_REQUIRE_REAL_INPUT=1, which turns every "cannot run here" skip (a missing grant, a locked console) into a failure, per the skip policy below. - Its own UI. The module is marked
owns_ui: the shared launcher stands down and each test launches the branch'smac/build/Roost-Iced.appitself, underROOST_BUNDLE_PROFILE=linux, so it lives in theRoost-linuxnamespace, apart from an installed Roost-Iced. Because that namespace is one fixed location, a run holds/tmp/roost-real-input.lockand refuses to start while the namespace's socket answers. Each launch gets its own state directory,ROOST_TEST_MODE=1and a config that keeps local tabs in-process (noroost-sessionstarts) and turns agent hooks off. - Safety rules. The launched app is found, focused and quit by pid only, after its environment proves it is this launch's; never by bundle id or process name, since the branch build shares the installed app's bundle id. Selection assertions read the private selection pasteboard, never the general one. Every event is preceded by its own target check, and every press is journaled so a failed or interrupted run releases exactly what it held and nothing the person at the desk is holding.
- Other apps may stay open. A key is refused only when another app claims the keyboard with a window in front of Roost's, the way a non-activating panel (Spotlight, a launcher, a password manager) does. A Zed, a second Roost-Iced or any other app that claims the keyboard from a window behind Roost's no longer blocks the keys (#604).
- The screen-lock caveat. Real events need an unlocked console; a locked
screen is a skip, or a failure under
ROOST_REQUIRE_REAL_INPUT=1. The harness Mac must stay logged in and awake.
The one-time setup, the helper's commands and its accepted limits live in
tools/input/mac/test-runner/README.md.
CI lanes¶
The single required check is ci-success, which derives its membership
from its own needs: list — ten jobs: the changes path-filter job
plus the nine required ones below. (The table's last row,
e2e-iced-wayland-drag, is a non-blocking signal lane and is
deliberately not one of the ten.) changes and ci-success always
run; every other job is conditionally gated on changes' outputs, so a
PR pays only for what it touches.
| Job | Runs | Required |
|---|---|---|
rust-lint |
cargo fmt --check, clippy at -D warnings |
✅ |
harness-unit |
tools/roosttest_unit — target/path/capability wiring |
✅ |
themes-parity |
The Rust + Mac bundled-theme copies are byte-identical | ✅ |
rust-build |
cargo build/test on Linux + macOS, plus the roost-vt ffi and roost-engine/facade passes |
✅ |
swift-mac |
swift build / swift test, release bundle, embedded roostctl, entitlements + link guards |
✅ |
iced-build-e2e |
Six cells: ubuntu × (wgpu, tiny-skia) × (X11, Wayland), and macOS × (wgpu, tiny-skia). Each builds; the X11 and macOS cells also run the unit tests, clippy and the toolkit-boundary check. Then functional E2E under the cell's display: X11 (xvfb) or Wayland (headless weston), each in its own cell so the two run in parallel. Plus the exit-on-empty, menu-quit and (X11) real-input clipboard lanes, the six host-session client lanes (below), and (macOS) bundle-identity + smoke lanes | ✅ |
iced-release |
Release-profile build, the real .deb, a release-binary E2E subset, packaged-profile assertion, artifact smoke, dependency-closure check |
✅ |
e2e-mac |
The full tools/roosttest directory against a bundled Roost.app on a GUI-session runner |
✅ |
session-e2e |
The headless host-session lanes — roost-session driven with no UI and no display (SESSION_E2E_TESTS, marker-based rather than an enumerated CI list) |
✅ |
e2e-iced-wayland-drag |
roost-iced fullscreen under cage with a real /dev/uinput seat — the pointer-drag + system-clipboard guard the IPC-driven Wayland lane cannot cover |
❌ soft (continue-on-error, absent from ci-success) |
The six host-session client lanes inside iced-build-e2e. They
need a UI and a roost-session daemon, so they are in neither
ICED_E2E_TESTS nor SESSION_E2E_TESTS and run as their own steps —
e2e-host-client, e2e-host-ssh, e2e-host-missing-daemon,
e2e-host-local-spawn (added by plan 044 for #397),
e2e-host-localhost (added by plan 058 for #460: the literal
localhost target, its ↻ Restart verb, and the restart behind it) and
e2e-host-bootstrap, each with an X11 and a Wayland twin (in the X11
and Wayland cells respectively). Within a cell they run
serialized, never beside one another: they share the client's
incarnation counter and the default session socket, so two at once bind
probes to each other's terminals and fail looking exactly like a product
bug. The Makefile targets and the CI steps both carry that warning.
Two notes on the soft lane: it pins ROOST_REQUIRE_REAL_INPUT=1 so a
missing cage, /dev/uinput, or binary fails rather than silently
passing as a skip, and it tees diagnostics to an uploaded artifact —
continue-on-error hides a failure's conclusion, so the log body has
to be the honest record. Promotion to required is tracked separately.
Both required E2E lanes run --roost-fresh with a throwaway
ROOST_STATE_DIR and upload diagnostics on failure (the mac lane
additionally emits JUnit XML for GitHub's test annotations). Neither reruns failed tests: a genuine
intermittent bug must not be masked. The macOS lane additionally scales
its timeouts (ROOST_TEST_TIMEOUT_SCALE) and clears any stale
socket/lock before launching, since a crashed instance's held
single-instance flock is the one cascade mode that wedges the next run.
Proving a flake fix. A red run on a test the commit did not touch is
a defect to remove, not a rerun button. A race is fixed in the test by
removing the race, never by widening a budget — a budget moves only for
a proven runner-load case, and only through ROOST_TEST_TIMEOUT_SCALE,
never a bigger bare constant. One green run is not evidence about a
race, so the proof is a loop over the built binary — before the fix,
to show it reproduces; after, to show it does not — on the OS it was
seen on:
BIN=$(cargo test -p roost-engine --features server-vt --test tab_task_test --no-run 2>&1 | sed -n 's/^ *Executable .* (\(.*\))$/\1/p')
for i in $(seq 100); do perl -e 'alarm 120; exec @ARGV' "$BIN" >"/tmp/run-$i.log" 2>&1 || echo "FAIL $i exit=$?"; done
alarm is what makes a hang a counted failure — it reports as
exit=142 (macOS has no GNU timeout, and a pending alarm survives
exec); any other non-zero exit is the test itself. Taking the path from
cargo's own Executable line rather than ls -t matters: a stale hash
from another feature set — tab_task_test built without server-vt is
an empty binary — would run zero tests and pass. Check that
grep 'test result:' /tmp/run-1.log shows a non-zero count, and put the
before/after tallies in the PR.
#410 is the worked
example: a_query_flood… went 8/100 → 0/100 on Linux, and the loop is
also what surfaced #409.
Adding coverage¶
- Behavior or content — a Layer 1 case in
tools/roosttest/. Use the fixture that yields a clean workspace; assert withtab.dump/tab.list. Remember the enumerated-list trap above. - Rendering — a Layer 2 check via
tools/screenshot/andpngtool.py.tab.dumpis text-only, so anything about color or layout needs pixels. - Real key/pointer/clipboard behavior — Layer 3. Linux:
tools/input/linux/(uinput/xdotool). Mac:tools/input/mac/(real CGEvents + Accessibility through the never-rebuilt runner app; see the Mac real-input harness).
Agent verification¶
An agent should be able to verify a change on the machine it has, without a
human present. Three skills under .claude/skills/ carry the recipes:
mac-test (a local Mac, or a remote Mac over ssh, including the real-input
harness), linux-test (Linux from a Mac, in a shed VM) and popos-test (a
native Linux dev box). On a Mac, make mac-real-input-check answers "can this
Mac take real input right now?" (grants, lock, Secure Input, input layout)
before a real-input run. Skills use placeholders ($MAC_HOST, $MAC_REPO,
TEST_SHA, <shed>) rather than machine names.
The guiding rule: an agent should be able to go from "I changed X" to "here is the exact command that proves X still works" without guessing.