Files
saphid--frame-control/tests/e2e/harness.py
T
saphidandClaude Opus 5.5 0181071b83 Tests against a fake Frame, and a headset smoke test
tests/fakeframe: a container that stands in for the Frame (Arch Linux, or
Arch Linux ARM on arm64) with sshd, rsync, Valve's steamos-devkit-service
and hooks (vendored unmodified), a fake Steam client for the devkit pipe and
the DevTools port (the app's JavaScript runs in Node against stand-in
SteamClient/appStore objects), stubs for steam, wpctl, flatpak, podman,
Lepton and friends, battery and thermal files under /sys, and fault
switches (fakeframe-ctl): pairing mode, approve/deny/timeout, Steam not
running, headset asleep, sshd off, disk full, runtimes missing. A second
container is the computer running Frame Control.

tests/e2e: 29 tests driving the real ui/server.py, frame_connect.py and
frame_titles.py against it; skipped unless FRAME_E2E=1. scripts/e2e.sh
builds, runs and tears down; CI runs it on ubuntu-24.04-arm.

tests/smoke + scripts/frame-smoke.sh: the core cases against a real Frame,
recorded with its BUILD_ID, cleaning up after itself; --pair to pair a
throwaway key. docs/testing.md describes the layers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00

199 lines
6.8 KiB
Python

"""Plumbing for the end-to-end tests against the fake Frame (tests/fakeframe).
scripts/e2e.sh runs these inside the compose `host` container, where
`ssh frame` reaches the fake Frame and FAKEFRAME_CTL is its control port.
Each test starts from `fakeframe-ctl reset` and drives the real ui/server.py
(started once, on a free port) over HTTP, then checks the fake's state.
Without FRAME_E2E=1 every test here is skipped.
"""
import atexit
import http.client
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
ENABLED = os.environ.get('FRAME_E2E') == '1'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
"""Call at module level: skips the module unless the fake Frame is up."""
if not ENABLED:
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
# ---- the fake Frame's control port --------------------------------------------
def _ctl_request(path, body=None, timeout=60):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(CTL + path, data=data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def ctl(*args):
out = _ctl_request('/ctl', {'args': list(args)})
if 'error' in out:
raise AssertionError(f'fakeframe-ctl {" ".join(args)}: {out["error"]}')
return out
def state():
return _ctl_request('/state')
def calls(tool=None):
return _ctl_request('/calls' + (f'?{tool}' if tool else ''))
def wait_for(check, timeout=30, what='a condition', every=0.3):
"""Poll check() until it returns something truthy; returns that."""
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
last = check()
if last:
return last
time.sleep(every)
raise AssertionError(f'timed out after {timeout}s waiting for {what} (last: {last!r})')
def reset():
ctl('reset')
wait_for(lambda: _ctl_request('/ping')['ok'], 30, 'the fake Frame to come back after reset')
def ssh(cmd, check=True, timeout=30):
"""Run cmd on the fake Frame through the `frame` alias, as Frame Control does."""
r = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame', cmd],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout)
if check and r.returncode != 0:
raise AssertionError(f'ssh frame {cmd!r} exited {r.returncode}: {r.stderr.strip()}')
return r.stdout
def exists(path):
return ssh(f'test -e {path} && echo yes || echo no').strip() == 'yes'
# ---- the real server ----------------------------------------------------------
class Server:
proc = None
port = None
@classmethod
def start(cls):
if cls.proc and cls.proc.poll() is None:
return
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
cls.port = s.getsockname()[1]
env = dict(os.environ, FRAME_CONTROL_LOCAL_LINKS='1')
log = open(SERVER_LOG, 'ab')
cls.proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'server.py'), '--port', str(cls.port)],
cwd=str(ROOT), env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log)
log.close()
atexit.register(cls.stop)
wait_for(lambda: cls._up(), 20, 'ui/server.py to listen')
@classmethod
def _up(cls):
try:
return api('GET', '/api/host')[0] == 200
except OSError:
return False
@classmethod
def stop(cls):
if cls.proc and cls.proc.poll() is None:
cls.proc.terminate()
try:
cls.proc.wait(10)
except subprocess.TimeoutExpired:
cls.proc.kill()
def api(method, path, body=None, raw=None, headers=None, timeout=120):
"""One request to the server with the headers its guards want. -> (status, JSON or bytes, headers)."""
conn = http.client.HTTPConnection('127.0.0.1', Server.port, timeout=timeout)
try:
hdrs = {'X-Frame-UI': '1', **(headers or {})} # Host is 127.0.0.1:<port>, which it accepts
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
if data is not None and 'Content-Type' not in hdrs:
hdrs['Content-Type'] = 'application/json'
conn.request(method, path, body=data, headers=hdrs)
r = conn.getresponse()
payload = r.read()
if r.getheader('Content-Type', '').startswith('application/json'):
payload = json.loads(payload)
return r.status, payload, dict(r.getheaders())
finally:
conn.close()
def ok(method, path, body=None, **kw):
status, out, _ = api(method, path, body, **kw)
if status != 200:
raise AssertionError(f'{method} {path} -> {status}: {out}')
return out
def upload(path, mode, name=None):
with open(path, 'rb') as f:
data = f.read()
return api('POST', '/api/upload', raw=data, headers={
'X-Filename': name or os.path.basename(path), 'X-Mode': mode, 'Content-Type': 'application/octet-stream'})
def wait_title_job(token, timeout=180):
def done():
job = ok('GET', f'/api/titles/job?token={token}')
return job if job['done'] else None
return wait_for(done, timeout, f'title install job {token}', every=0.5)
def install_title(path, **options):
"""Upload (or, for a folder, inspect by path) and install; returns (plan, finished job)."""
if os.path.isdir(path):
staged = ok('POST', '/api/titles', {'action': 'inspect', 'path': path})
else:
status, staged, _ = upload(path, 'title')
if status != 200:
raise AssertionError(f'upload -> {status}: {staged}')
started = ok('POST', '/api/titles', {'action': 'install', 'token': staged['token'], **options})
return staged['plan'], wait_title_job(started['job'])
def launches(kind=None):
return [r for r in state()['launches'] if kind is None or r['kind'] == kind]
class FrameTestCase(unittest.TestCase):
"""Starts the server once and the fake Frame afresh for every test."""
@classmethod
def setUpClass(cls):
Server.start()
def setUp(self):
reset()
self.tmp = tempfile.mkdtemp(prefix='fakeframe-e2e-')
self.addCleanup(subprocess.run, ['rm', '-rf', self.tmp])
def path(self, *parts):
return os.path.join(self.tmp, *parts)