Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
12 KiB
Testing
Frame Control is tested in three layers, from fast and fake to slow and real. A fourth, a SteamOS VM, may come later (issue #6).
| Layer | Runs | Needs | Covers |
|---|---|---|---|
Unit tests (tests/*.py) |
python3 -m unittest discover -s tests |
Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
Fake Frame (tests/e2e) |
scripts/e2e.sh |
Linux with Docker | The real server and scripts against a container that behaves like a Frame |
| Headset smoke test | scripts/frame-smoke.sh |
A Frame on the frame alias |
Install, launch and remove on the real device, recorded with its BUILD_ID |
Unit tests
python3 -m unittest discover -s tests
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
them on macOS, Windows and Linux. They don't pick up tests/e2e.
The fake Frame
tests/fakeframe/ builds a container that stands in for the headset, and a
second one for the computer Frame Control runs on. scripts/e2e.sh builds
both, starts them with docker compose, runs tests/e2e in the host
container and takes everything down, exiting with the tests' status:
scripts/e2e.sh # everything, about 2 minutes plus the first build
scripts/e2e.sh test_titles # one module
scripts/e2e.sh test_faults.Faults.test_disk_full # one test
FAKEFRAME_KEEP=1 scripts/e2e.sh # leave it running afterwards
It needs a Linux host with Docker and docker compose, and zsh. The images
are fakeframe-frame and fakeframe-host; the compose project, network and
volumes are fakeframe-e2e*. CI runs it on a native arm64 runner
(ubuntu-24.04-arm, the e2e job in .github/workflows/checks.yml).
The host container exists because OpenSSH reads ~/.ssh/config from the
passwd home directory, not $HOME. There, ssh frame reaches the fake Frame
through the same Host frame block ui/frame_connect.py writes, the
repository is mounted read-only at /repo, and each test module starts the
real ui/server.py (Python 3.9) and talks to it over HTTP with the headers
its guards want.
What's real and what's fake
| On the fake Frame | |
|---|---|
Arch Linux (archlinux:base, or Valve's Holo Core aarch64 preview on arm64), user steamos, /etc/os-release with BUILD_ID 20260922.6101926 |
Real OS, Frame's identity |
sshd with key and password logins, rsync, python3 |
Real |
Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in tests/fakeframe/steamos-devkit-service |
Real; only its dbus import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
| Valve's devkit-utils, copied over by Frame Control itself | Real |
fakesteam: ~/.steam/steam.pid, steam.token and the steam.pipe FIFO; answers approve-ssh-key, create-shortcut, run-game, list-shortcuts and delete-shortcut with the response files devkit-utils waits for; takes steam://rungameid, install and store URLs |
Fake |
DevTools on 127.0.0.1:8080 with a SharedJSContext target. The JavaScript Frame Control sends runs for real in Node against stand-in SteamClient, appStore and downloadsStore objects (cef_shim.js), so async functions, optional chaining and Maps behave as in Steam's CEF |
The JS engine is real; the objects are fake |
steam, wpctl, flatpak, podman, nmcli, qdbus6, gamescopectl, SteamOS's steamos-enable-sshd helper, and Lepton's launcher |
Stubs that record their calls |
Battery, charger and thermal zones under /sys/class |
Files the supervisor writes. /sys is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over /sys/class/... for frame_status.py to read, and under /var/lib/fakeframe/sys for the supervisor to write |
vrserver and plasmashell |
Renamed sleep processes, so the status page and the clipboard find them |
Every fake behaviour copied from the device has a comment citing the doc or
observation and the BUILD_ID it came from; anything not seen on a headset is
marked as a guess. The fake keeps its state in /var/lib/fakeframe/state.json
(shortcuts, devkit titles, compat tool mapping, launches, pairing requests,
Lepton instances, volume, Flatpaks, clipboard) and logs stub calls to
calls.jsonl beside it.
Native programs really run: a launched aarch64 title executes on an arm64 host, and an x86-64 one on x86-64 (the container shares the host's kernel). Proton titles are recorded with the command Steam would run, not run.
Fault switches
fakeframe-ctl works over SSH (ssh frame fakeframe-ctl help) and from the
host container (FAKEFRAME_CTL=http://fakeframe:9999), so a test can flip a
switch while SSH is down:
| Command | Effect |
|---|---|
pairing on|off |
Steam's Pair new host screen open or not; off gives the device's 403 text |
answer approve|deny|timeout |
How the pairing prompt is answered |
steam on|off |
Steam client running (pid file, pipe, DevTools) |
sleep on|off |
Headset asleep: ports 22 and 32000 accept and never answer, so SSH times out |
sshd on|off |
sshd stopped: new connections are refused, open ones stay |
devkit-service on|off |
Port 32000 closed |
disk-full on|off |
Fills the small (64 MB) filesystem on ~/devkit-game |
runtime NAME installed|missing |
Proton, the Steam Linux Runtimes, Lepton |
battery KEY=VALUE... |
e.g. capacity=15 status=Discharging current_now=-900000 |
keys harness|none, authorized-keys |
Set or read ~/.ssh/authorized_keys |
reset, state, calls [TOOL] |
Start over; read the state and call log |
What the fake can't show
- Rendering: the headset view, desktop capture content, live video, SteamVR, gamescope and panels. The capture stub returns a placeholder PNG.
- Proton and FEX: whether a Windows or x86-64 program actually runs.
- Android: there's no Android in the Lepton stand-in, so no ADB, display settings, probes or app crashes.
- The real Steam client's UI and anything it does that isn't modelled, and mDNS discovery.
sudoand the power buttons, Tailscale, and the Windows and macOS sides of the app (the host container is Linux, so thersyncpaths are tested and thescpfallback isn't).
Headset smoke test
Documented shared-device procedure: before a test installs, launches or
stops an application, acquire ssh frame 'mkdir /tmp/frame-test.lock'. If it
fails, leave that lock alone and continue offline work. Only the thread that
acquired it releases it with ssh frame 'rmdir /tmp/frame-test.lock', after
cleanup. Keep each device session to a few minutes.
Check battery capacity and charging state under /sys/class/power_supply
before and after; keep capacity above 20%. Stop only processes started by the
test, remove temporary installs and profiles, and restore the prior dashboard
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
Record the build, actual interaction results, cleanup and any unworn-headset
limits alongside screenshots or logs. These are caller responsibilities; the
smoke script below does not acquire this shared lock itself.
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
It checks properties.json and the status, then installs, launches and
removes three tiny titles built from bytes by tests/smoke/tiny_programs.py
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
an x86-64 .exe that exits at once). A launch passes only with fresh evidence:
the ARM64 program running, the .exe started (its process or Steam's log),
and the x86-64 program running or Steam logging that its runtime isn't
installed, which is what the Frame does today. Steam's log lines about each
title are kept.
Everything it installs is removed again, also after a failure: the titles and
their Steam shortcuts, a paired key, and ~/devkit-utils if it wasn't there
before (if it was, it stays, synced to this checkout as Frame Control always
does). A cleanup that fails counts as a failed step. Results go to
tests/smoke/results/<time>-<BUILD_ID>.json (not committed) with a summary on
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
isn't reachable.
--pair asks the devkit service to pair a new RSA key, which needs someone
in the headset to open Settings → Developer → Pair new host and approve
it; the key is checked and then taken out of authorized_keys again.
When the device disagrees with the fake
The fake is only as good as what's been seen on a headset. When the smoke test (or anyone) finds the Frame doing something else:
- Record what the device did, with the date and BUILD_ID, in the doc that
covers it (
docs/sideloading.md,docs/ssh.mdand so on). - Change the fake to match, with a comment citing that observation. The
behaviours are in
tests/fakeframe/rootfs/usr/local/lib/fakeframe/(fakesteam.pyfor Steam,cef_shim.jsfor DevTools,init.pyfor the switches, the stubs inrootfs/usr/local/bin). - Run
scripts/e2e.sh. If the app is wrong, the tests now fail the way the device did; fix the app and add a unit test.
For example, on 2026-09-27 the smoke test found that Steam's create-shortcut
refuses ids with a hyphen (missing/invalid arguments), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
Owned media player
tests/test_media.py covers layout evidence and overrides, OU eye ordering,
hardware-decoder command construction, malformed splats, stereo parallax and
fake-Frame library/process ownership. tests/e2e/test_media_transfer.py checks the real
HTTP/SSH upload and library listing without pretending the fake renders VR.
Real decode timings and captured stereo output are recorded in
vr-video.md. Generated media only; no external player required.
Verified 2026-09-28, real Frame BUILD_ID 20260925.6191901: ~/.local
and ~/.local/share are steamos:steamos, mode 0755. The fake supervisor
sets those parent owners too; previously its root-created Steam manifests
left the parents root-owned and incorrectly prevented user runtime installs.
Agent interfaces
tests/test_agent.py exercises MCP stdio, exact-action human approvals and the
assistant against an in-process HTTP endpoint with canned responses (no keys or
external calls). tests/e2e/test_agents.py runs the MCP/HTTP/SSH path against the
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
rendering and real screenshots still need a device; see agent evidence.
Family and comfort
tests/test_comfort.py uses an injected clock, fake headset sensor readings and
actions, plus a Node fake of Steam's Home API. It covers warnings before Home,
late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
invalidation, per-zone thermal trips and shared on-headset state. The server
guards reject invalid session settings before SSH. See
real-device evidence and limits.
Panel switcher
tests/test_panels.py supplies fake-Frame vrcmd --overlays output, checks
main-panel filtering (including hidden panels), revalidates closed panels before
focus, and drives the headset helper's real loopback HTTP server to test access
keys, Host/Origin guards, malformed requests, offline errors and Close. It runs
in the normal unit suite without OpenVR or a headset. The fixture format comes
from SteamVR 2.18.1, BUILD_ID 20260925.6191901; it does not simulate rendering.
On the Frame, run python3 - over SSH with ui/frame_panels.py on stdin to
list panels. --focus <key> rechecks the list and requests focus. In Frame
Control, Tools → Panel switcher → Open in headset exercises installation,
Chromium rendering and the same helper through HTTP. Close the switcher after
testing. The recorded device checks
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.