Files
saphid--frame-control/tests/test_steam.py
T
saphidandClaude Opus 5.5 a954fc83c9 Devices: several headsets, several addresses each, and live connection status
Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.

- ui/frame_devices.py: registry in devices.json, imported from the managed
  ~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
  /api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
  keep tests off real data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:13:58 +10:00

107 lines
4.7 KiB
Python

"""Get games checks that need no headset or network.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_store # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
class SteamRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_steam_input_validation(self):
for body in ({"action": "install", "appid": "620; reboot"}, {"action": "install", "appid": ""},
{"action": "uninstall", "appid": "620"}, {"appid": "620"}):
status, payload = self.post("/api/steam", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_search_needs_country(self):
for query in ("q=portal", "q=portal&cc=A", "q=portal&cc=AU1", "q=portal&cc=%27x"):
status, _, _ = self.request("GET", f"/api/steam/search?{query}", headers={"X-Frame-UI": "1"})
self.assertEqual(status, 400, query)
def test_steam_routes_need_custom_header(self):
self.assertEqual(self.request("GET", "/api/steam/owned")[0], 403)
self.assertEqual(self.request("GET", "/api/steam/search?q=x&cc=AU")[0], 403)
self.assertEqual(self.request("POST", "/api/steam", {"action": "install", "appid": "620"})[0], 403)
# Don't rerun the inherited ServerGuards tests under this module.
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(SteamRoutes, name, None)
class FrameSteamHelper(unittest.TestCase):
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_steam.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class HelperErrors(unittest.TestCase):
def test_json_error_found_despite_ssh_stderr(self):
import server
noise = server.Failure("Warning: Permanently added 'frame' to the list of known hosts.")
noise.stdout = '{"error": "Steam\'s UI isn\'t answering"}\n'
with mock.patch.object(server, "ssh", side_effect=noise):
with self.assertRaises(server.Failure) as cm:
server.steam_frame("owned")
self.assertEqual(str(cm.exception), "Steam's UI isn't answering")
def test_other_failures_pass_through(self):
import server
with mock.patch.object(server, "ssh", side_effect=server.Failure("Timed out talking to frame")):
with self.assertRaises(server.Failure) as cm:
server.steam_frame("owned")
self.assertEqual(str(cm.exception), "Timed out talking to frame")
class StoreSearch(unittest.TestCase):
def setUp(self):
frame_store._compat.clear()
def test_keeps_apps_and_attaches_frame_rating(self):
def fake_get(path, params, timeout=10):
if path == "api/storesearch":
self.assertEqual(params["cc"], "AU")
return {"items": [{"type": "app", "id": 620, "name": "Portal 2", "price": {"final": 1450}},
{"type": "sub", "id": 7, "name": "Bundle"}]}
return {"results": {"frame_resolved_category": 3}}
with mock.patch.object(frame_store, "_get", side_effect=fake_get):
results = frame_store.search("portal", "AU")
self.assertEqual([(r["id"], r["frame"]) for r in results], [(620, 3)])
def test_rating_failure_is_unknown_and_not_cached(self):
with mock.patch.object(frame_store, "_get", side_effect=OSError("offline")):
self.assertEqual(frame_store.frame_rating(620), 0)
self.assertNotIn(620, frame_store._compat)
def test_malformed_rating_is_unknown(self):
for payload in ({"results": []}, {"results": {"frame_resolved_category": {}}},
{"results": {"frame_resolved_category": 9}}):
frame_store._compat.clear()
with mock.patch.object(frame_store, "_get", return_value=payload):
self.assertEqual(frame_store.frame_rating(620), 0, payload)
def test_blank_query_makes_no_request(self):
with mock.patch.object(frame_store, "_get") as get:
self.assertEqual(frame_store.search(" ", "AU"), [])
get.assert_not_called()
if __name__ == "__main__":
unittest.main()