mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
The tabs UI (#4) runs Flatpak installs as server.start_job jobs, so a failed install answers 200 with a job id and reports the error on /api/job. The test now waits for the job instead of expecting an immediate 502. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
205 lines
7.1 KiB
Python
205 lines
7.1 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 finished(started, timeout=60):
|
|
"""Wait for a background job (server.start_job's {"job": id}); returns its final state."""
|
|
return wait_for(lambda: (lambda j: j['done'] and j)(ok('GET', f"/api/job?id={started['job']}")),
|
|
timeout, f"job {started['job']}")
|
|
|
|
|
|
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)
|