Managed systemd-nspawn containers run the Linux desktop integration checks. GNOME's full Flatpak/input/audio sequence runs without QEMU or a host Wayland, session D-Bus, PipeWire, home-directory or device bind. Plasma has a separate configuration so each desktop selects its own portal implementations. This is the only supported Linux desktop runner.
Prepare once
The host needs the active managed-nspawn helpers and BTF-enabled systemd described in the container configuration. Prepare one small base directory, in a parent owned by the invoking user:
mkdir -p /tmp/runic-desktop-base
sudo install -d -o root -g root -m 0755 /tmp/runic-desktop-base/root \
/tmp/runic-desktop-base/root/usr /tmp/runic-desktop-base/root/usr/bin
sudo systemd-dissect --shift /tmp/runic-desktop-base/root foreign
This is a one-time privileged filesystem preparation, not a per-desktop or
per-run system activation. The runner reuses it with --volatile=yes, which
creates fresh writable state and ignores the base's stored /home and /etc.
A private /usr/bin tmpfs lets normal NixOS activation create its compatibility
shims. Test-installed apps, preferences and logs disappear at shutdown after
results have been copied out. Do not use a real system root as this base.
Build the chosen desktop and locked Flatpak preparation tools from the SDK root:
nix build .#nixosConfigurations.runic-headless-gnome.config.system.build.toplevel \
--out-link artifacts/container-gnome
nix build .#nixosConfigurations.runic-headless-kde.config.system.build.toplevel \
--out-link artifacts/container-kde
nix build .#desktop-flatpak-tools --out-link artifacts/desktop-flatpak-tools
Prepare the standard runtime once, outside the network-isolated test. The helper pins both GNOME Platform 50 and its Mesa GL extension and uses a dedicated cache; it does not install anything into the host user's normal Flatpak installation.
PATH="$PWD/artifacts/desktop-flatpak-tools/bin:$PATH" \
bash nixos/portal-container/prepare-runtime.sh "$PWD/.cache/container-flatpak" \
> /tmp/runic-runtime-path
nix-store --add-root "$PWD/artifacts/container-runtime" --indirect \
--realise "$(cat /tmp/runic-runtime-path)"
The runtime is shared read-only between test desktops. The nested Flatpak receives its standard runtime, not the container's Nix store. Reuse the cache and GC root; do not rebuild a runtime snapshot for every test invocation.
Prepare fixture inputs
Build the portable NativeAOT Flatpak fixture using the locked SDK environment. For the native suite, use the ordinary NativeAOT fixture publish. Put only the required executable and installer in a small directory, then freeze that directory as the test input:
mkdir -p .cache/container-inputs
cp tests/native/Runic.Desktop.Gtk4.Smoke/flatpak/install.sh \
.cache/container-inputs/install-flatpak.sh
cp artifacts/gtk4-flatpak/Runic.Desktop.Gtk4.Smoke \
.cache/container-inputs/Runic.Desktop.Gtk4.Smoke.flatpak
# For the native mode, also copy its ordinary NativeAOT publish:
cp artifacts/gtk4-usability-aot/Runic.Desktop.Gtk4.Smoke .cache/container-inputs/
nix store add-path .cache/container-inputs > /tmp/runic-input-path
nix-store --add-root "$PWD/artifacts/container-inputs" --indirect \
--realise "$(cat /tmp/runic-input-path)"
Omit --flatpak for the native suite. Recreate
the small input artifact after rebuilding a fixture; keep growing source trees,
SDK caches and build outputs out of Nix source snapshots.
Run and collect
python3 -B nixos/portal-container/run.py \
--desktop gnome --system artifacts/container-gnome \
--root /tmp/runic-desktop-base/root \
--inputs artifacts/container-inputs --runtime artifacts/container-runtime \
--output artifacts/container-results/gnome-1 --flatpak --orca --keyboard --scaling --notifications
For Plasma, use --desktop kde --system artifacts/container-kde with the same
options. --notifications also needs the ordinary NativeAOT executable in the
input directory, even when the usability suite uses Flatpak.
Use a new output directory for each run. The host launcher uses the active host
systemd tools, then launches the suite through the guest system manager as
runic. It waits for the actual desktop/display/Settings/PipeWire services,
retains boot.log, session.log, suite.log, journal.log and guest results,
and powers down its own machine on success or failure. Readiness and test
failures exit nonzero. Results are extracted with Python's safe data filter.
Run one desktop at a time initially.
GNOME checks cover accessible roles, WebView actions, native inhibition registration/removal, portal grant/private-sibling denial, chooser cancellation, atomic-write rejection preserving the destination, owner closure, real compositor keyboard navigation and Pinyin composition, and Orca speech with captured output from a private PipeWire null sink. The virtual keyboard remains alive for the suite: removing the last input device from a headless seat drops focus. The chooser uses verified native text entry and compositor Enter input.
The container run checks actual Orca speech requests and non-silent PCM
independently of any speech recognition. It requires Orca's speech-output records
for each label and role plus complete, sustained, non-silent PCM recordings; a
click, silence or truncated WAV fails. The results directory retains the
evidence: speech.json, orca.debug, orca-process.log, pipewire-before.json,
the capture-*.log recorder logs and speech-*.wav. PipeWire's recorder returns
status 1 at its sample limit (observed in 1.6.8) because its success flag is set on
playback drain; the runner narrowly accepts that case only with the exact sample
count and clean recorder diagnostics. See the
upstream recorder implementation.
Optional Whisper cross-check
Optionally cross-check the recordings with local CPU Whisper. Build the tool and checksum-pinned English model from the SDK root, outside the normal SDK shell:
nix build .#vm-whisper --out-link artifacts/vm-whisper
nix build .#vm-whisper-model --out-link artifacts/vm-whisper-model
direnv exec . python3 -B tests/native/Runic.Desktop.Gtk4.Smoke/transcribe-speech.py \
artifacts/container-results/gnome-1/results --model artifacts/vm-whisper-model \
--whisper artifacts/vm-whisper/bin/whisper-cli \
--output artifacts/container-results/gnome-1/transcripts
This uses locked whisper.cpp 1.9.2 with base.en, four CPU threads and no cloud service. The model is an opt-in approximately 148 MB dependency. The script requires the native checks to have passed, rechecks the WAV, and compares the recognized label and role. Expected phrases are never passed as recognition prompts. It retains the transcript and recognizer diagnostics, and returns nonzero for mismatches; inspect both audio and Orca output before attributing an ASR mismatch to Runic. Recognition can invent text in non-speech audio, so it cannot replace the independent native and PCM assertions. See the Whisper model card and whisper.cpp.
The audio checks have focused negative tests:
direnv exec . python3 -B -m unittest discover \
-s tests/native/Runic.Desktop.Gtk4.Smoke -p test_speech_audio.py
Compositor input and notifications
Plasma also covers its native Qt chooser, Flatpak grants/cancellation/atomic-write rejection, PowerDevil registration/removal and Orca/PipeWire audio. Its Qt accessibility bridge must be enabled before inspecting dialogs; the runner sets and restores the session accessibility status. PowerDevil is explicitly enabled because NixOS normally omits power management in containers.
Both keyboard adapters send real compositor input: Mutter RemoteDesktop for
GNOME and KWin EIS with the locked libei for Plasma. They type runic, verify
Tab/Shift+Tab focus order, switch the input source with the desktop shortcut,
compose and commit 你好, then check native text and application composition
events. Fcitx5 source restoration refocuses an entry because its active input
context disappears when a button takes focus. No host /dev/uinput is shared.
--scaling applies actual 100%, 150% and 200% compositor display scales, reads
them back, clicks the target with compositor pointer input, verifies the hit
counter and records the page's pixel ratio and geometry in scaling.json.
Plasma uses KScreen plus a temporary read-only KWin script to map GTK's local
surface bounds into desktop coordinates. GNOME maximizes the test window using
the real desktop shortcut, uses the shell top bar to locate the work area, and
reads the native panel that embeds the WebView to account for window decorations.
Its pointer starts at the right edge to avoid the overview hot corner.
The virtual displays are large enough for the fixture at 200%. Scale and input
source changes are restored. These checks do not substitute CSS zoom or native
button actions for pointer input, and do not claim physical USB device coverage.
--notifications installs a temporary receiver service for the fixture identity
already present when the desktop boots. It refreshes D-Bus service discovery and
verifies that the receiver is activatable. It activates the visible Open result
action through Plasma's native accessibility action or GNOME's real pointer
hover/click. The fixture verifies the actual action ID, activation token, process
ID and native GTK focused-window state for both live and cold activation. The
cold sender must exit first and the receiver must have a different PID. Calling
the application's activation callback directly is not part of this test.
Notification results and logs are collected in results/notifications. The
runner waits for the preceding popup and bus owner to disappear, removes its
temporary service, and cleans up owned receiver processes. GNOME keeps its
pointer inside the banner between hover and click so the action row stays open.
GTK X11 backend
For the X11 client path, add --backend x11 to the KDE command with
--keyboard --scaling --orca --flatpak. Refresh the immutable fixture inputs
with the current flatpak/install.sh before running. The runner reads DISPLAY
and XAUTHORITY from the guest's session manager; no host X server is used.
It requires an actual Runic window in the guest X server's client list and retains
its properties in x11-window.json.
This runs GTK's X11 backend under KDE's Xwayland server. It covers real Fcitx5 Pinyin through XIM, native focus/text events and Orca speech, compositor scaling, and sandboxed file grants/cancellation/owner closure. The Flatpak receives only the selected display socket. X11 pointer coordinates are converted to compositor coordinates using the measured WebView/client-buffer ratio, accounting for both fractional Xwayland scale and integer GTK scale.
For a standalone Plasma/Xorg session, build
nix build .#nixosConfigurations.runic-headless-kde-xorg.config.system.build.toplevel -o artifacts/container-kde-xorg
and pass --system artifacts/container-kde-xorg --desktop kde --session xorg to
the same runner. The existing --keyboard --scaling --orca --flatpak --notifications
options apply. The Xorg server uses a dummy software display inside the managed
container, with no host display socket, physical input devices or network access.
The session probe requires its private X socket and an actual Xorg process.
Keyboard and pointer events use XTEST, including Fcitx5 Pinyin through XIM. The DPI check reloads the session's XSettings daemon at 96, 144 and 192 DPI, reads back the published settings, requires matching WebView pixel ratios and clicks the native target at each setting. A real title-bar double-click maximizes the form; KWin client geometry accounts for Xorg's server-side decorations. The previous settings and window size are restored afterward. Notification actions must still focus the actual receiver in live and cold modes. This Plasma/Xorg session does not provide an activation token; the runner records its absence and retains the token requirement for Wayland sessions, including Xwayland clients. These are desktop DPI checks: the dummy driver rejects RandR output transforms, so physical-output magnification and multi-monitor transitions remain separate coverage.
Remaining coverage
Visual candidate placement, announcement quality, physical input devices and real
hardware/power transitions remain distinct from these headless integration
checks. The keyboard checks cover all fixture controls in both directions and require
native focus events, text insertion events, text values and caret positions;
accessibility-events.json retains the observed events. Numeric/range controls,
selection-change events and broader assistive-technology interaction remain
future coverage. CI host provisioning can extend the runner. Windows VM and future real-macOS
testing are separate workstreams. These coverage limitations do not add release
gates.
Extending the suite
Extend managed containers for Linux. Prepare immutable fixtures and pinned runtime dependencies before the interaction phase, and keep GNOME/Plasma jobs separate. Windows VM and real macOS adapters remain independent workstreams.
| Area | Automation approach and next assertion |
|---|---|
| Keyboard and IME | GNOME/IBus and KDE/Fcitx5 compositor typing, Tab/Shift+Tab and Pinyin pass in containers. Physical devices remain separate. Changing an accessible text value does not test an IME. |
| Scaling and targeting | The container adapters set/read actual Mutter/KScreen scales and verify compositor pointer hits at 100%, 150% and 200%. Standalone Xorg verifies XSettings 96/144/192 DPI, WebView pixel ratios and XTEST targeting. Visual caret/candidate placement remains separate. CSS zoom and an AT-SPI button action do not establish physical targeting. |
| Notifications | The container adapter activates the visible shell action and asserts the token, receiver PID and native focused-window result for live and cold launch. Calling the application's D-Bus callback directly would bypass the activation-token behavior under test. |
| Accessibility | Native roles/focus, Orca speech records, recorded audio and optional local ASR now work in GNOME and KDE. Native values, insertion/caret events and complete forward/reverse keyboard focus order also pass. Listening remains useful for announcement quality. |
| Windows | Use the Windows UI Automation smoke through the existing interactive VM login. It verifies WebView2 accessibility names, editable focus, ValuePattern, InvokePattern, actual typed text and complete forward/reverse Tab navigation with native focused-element identity and HasKeyboardFocus, a UIA-point mouse click at the VM's observed 96 DPI, native open-file selection, open/save cancellation and atomic save with independent file verification, live output and deterministic process exit for JIT and executable-only NativeAOT publishes. Run native power-request checks independently. Session-0 SSH alone cannot cover interactive display behavior. Opt-in Narrator label/role speech with real WASAPI audio is implemented. IME composition remains application-specific and outside this smoke. Physical input, visual candidate placement, independent audio transcription, display-scale changes/multi-DPI pointer behavior, overwrite-confirmation flows and visual rendering remain Windows work. |
| macOS | Add an AXUIElement/Accessibility adapter and native assertions after the real Mac is available. Keep native support explicitly untested until then. |
Start these as focused, opt-in desktop jobs. Move stable scenarios into CI with the same prepared dependencies and failure handling. Preserve structured results, native logs and failure screenshots; do not add broad soaks, mandatory manual gates or retry failures until they happen to pass. Visual IME placement, spoken quality and real power transitions are still outside the current automated runner's claims.