mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-27 02:01:55 +01:00
266 lines
12 KiB
Python
266 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""GUI smoke test: serve the Flet app on the web, drive its screens from Python, screenshot each with headless
|
|
Chromium (Playwright). Any exception in a view shows up on stdout; screenshots land in --out.
|
|
|
|
FRAMEPORT_HOME=<a test data dir> python scripts/ui_smoke.py --out /tmp/shots [--game <package>]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
|
|
import flet as ft # noqa: E402
|
|
|
|
from frameport.core import library # noqa: E402
|
|
from frameport.ui.app import FramePortApp # noqa: E402
|
|
|
|
PORT = 8557
|
|
ERRORS: list[str] = []
|
|
|
|
|
|
class FakeFS:
|
|
"""--fake-frame's file system for the Files tab: a temp folder with sample files, served like SFTP."""
|
|
|
|
def __init__(self):
|
|
import tempfile
|
|
|
|
self.home = tempfile.mkdtemp(prefix="fp-fake-frame-")
|
|
samples = {"Videos": ["Earth from orbit (360).mp4", "Mars landing (VR180).mkv", "Concert 8K 3D.mp4"],
|
|
"Downloads": ["Mods/", "readme.txt"], "Documents": ["Saves/", "Notes.pdf"]}
|
|
sizes = {".mp4": 2_400_000_000, ".mkv": 1_100_000_000}
|
|
for folder, names in samples.items():
|
|
base = os.path.join(self.home, folder)
|
|
os.makedirs(os.path.join(base, "Trips"), exist_ok=True)
|
|
for n in names:
|
|
if n.endswith("/"):
|
|
os.makedirs(os.path.join(base, n), exist_ok=True)
|
|
else:
|
|
with open(os.path.join(base, n), "wb") as f:
|
|
f.truncate(sizes.get(os.path.splitext(n)[1], 48_000)) # sparse: no real disk use
|
|
|
|
def agent(self, command, **args):
|
|
if command == "storage_targets":
|
|
return {"targets": [{"id": n.lower(), "path": os.path.join(self.home, n), "android": a, "shared": True}
|
|
for n, a in (("Videos", "/sdcard/Movies"), ("Downloads", "/sdcard/Download"),
|
|
("Documents", "/sdcard/Documents"))]}
|
|
raise RuntimeError(f"fake Frame: {command} not available")
|
|
|
|
@property
|
|
def sftp(self):
|
|
import paramiko
|
|
|
|
class SFTP:
|
|
def listdir_attr(self, path):
|
|
out = []
|
|
for name in sorted(os.listdir(path)):
|
|
a = paramiko.SFTPAttributes.from_stat(os.lstat(os.path.join(path, name)))
|
|
a.filename = name
|
|
out.append(a)
|
|
return out
|
|
|
|
def stat(self, path):
|
|
return paramiko.SFTPAttributes.from_stat(os.stat(path))
|
|
return SFTP()
|
|
|
|
|
|
class FakeTarget:
|
|
"""--fake-frame: a pretend Steam Frame with the library's working Quest builds installed (no network, no device),
|
|
for documentation screenshots."""
|
|
|
|
def __init__(self, count: int = 8):
|
|
from frameport.frame.connection import parse_target
|
|
|
|
self.label = "steamframe"
|
|
self.frame = FakeFS()
|
|
self.target = parse_target("steamos@steamframe.local")
|
|
works = [g for g in library.games() if g.get("kind") != "rift" and (g.get("build") or {}).get("sha256")
|
|
and (g.get("recipe") or {}).get("status") in ("works", "issues")]
|
|
works.sort(key=lambda g: -(g.get("data_bytes") or 0))
|
|
self.games = [{"package": g["package"], "kind": "quest", "title": g.get("title"),
|
|
"apk_size": (g.get("data_bytes") or 0) + 2**28, "sha256": g["build"]["sha256"],
|
|
"recipe": {"patches": (g.get("recipe") or {}).get("patches", [])}} for g in works[:count]]
|
|
|
|
def describe(self) -> dict:
|
|
return {"hostname": "steamframe", "os": "SteamOS", "os_version": "3.8", "build_id": "20260922",
|
|
"free_bytes": 312 * 2**30, "installed": self.games, "lepton": True,
|
|
"proton": {"ready": {"display_name": "Proton 11 (ARM64)"}, "openxr": {"name": "SteamVR"}},
|
|
"kernel_keys": {"keys": 31, "max_keys": 200}}
|
|
|
|
def installed(self) -> list[dict]:
|
|
return self.games
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
STEP_SECONDS = 4 # per screen (the screenshot is taken ~3 s in)
|
|
|
|
|
|
def driver(app: FramePortApp, steps: list[tuple[str, callable]], ready: threading.Event, done_step: list):
|
|
for name, action in steps:
|
|
try:
|
|
action(app)
|
|
except Exception: # noqa: BLE001
|
|
ERRORS.append(f"{name}: {traceback.format_exc()}")
|
|
done_step.append(name)
|
|
ready.set()
|
|
time.sleep(STEP_SECONDS)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", type=Path, required=True)
|
|
ap.add_argument("--game", default=None)
|
|
ap.add_argument("--frame", default=None, help="steamos@host: also connect and run a launch-test job for --game")
|
|
ap.add_argument("--no-test", action="store_true", help="with --frame: skip the launch-test job")
|
|
ap.add_argument("--scale", type=float, default=1.0, help="UI scale to render at (e.g. 1.5)")
|
|
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
|
|
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI)")
|
|
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
|
|
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
|
|
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
|
|
help="open the install questions for these games, then click each dialog's main button twice "
|
|
"(a double click must not duplicate dialogs); nothing is installed")
|
|
ap.add_argument("--docs", action="store_true", help="only the screens used in the docs (Library, --game, Frame)")
|
|
args = ap.parse_args()
|
|
global STEP_SECONDS
|
|
if args.hover:
|
|
STEP_SECONDS = 7
|
|
from frameport.ui import theme
|
|
|
|
theme.set_scale(args.scale)
|
|
vw, vh = (int(x) for x in args.viewport.split("x"))
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
game = args.game or (library.games()[0]["package"] if library.games() else None)
|
|
steps = [("library", lambda a: a.navigate(0)), ("frame", lambda a: a.navigate(1)),
|
|
("files", lambda a: a.go("files")), ("tools", lambda a: a.go("settings"))]
|
|
if game:
|
|
steps.insert(1, ("game", lambda a: a.open_game(game)))
|
|
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
|
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
|
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game)))]
|
|
# last: the right-click menu stays open over whatever comes next
|
|
steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
|
a.library_view.open_menu(game))))
|
|
if args.docs:
|
|
steps = [("library", lambda a: a.navigate(0))]
|
|
if game:
|
|
steps += [("game", lambda a: a.open_game(game)),
|
|
("game-customize", lambda a: a.open_game(game, advanced=True))]
|
|
steps.append(("frame", lambda a: a.navigate(1)))
|
|
steps.append(("files", lambda a: a.go("files")))
|
|
steps.append(("files-select", lambda a: [a.files_view._toggle(e.path, True)
|
|
for e in a.files_view.entries[1:3]]))
|
|
if args.install_questions:
|
|
queued: list[str] = []
|
|
|
|
def open_questions(a):
|
|
a._submit_install = lambda p, to: queued.append(p)
|
|
a.install_many(args.install_questions, "frame")
|
|
|
|
def double_click(a):
|
|
dialogs = [d for d in a.page._dialogs.controls if d.open]
|
|
print(f"open dialogs: {len(dialogs)}", flush=True)
|
|
button = dialogs[-1].actions[-1]
|
|
button.on_click(None)
|
|
button.on_click(None)
|
|
|
|
def report(a):
|
|
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
|
|
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
|
|
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
|
|
if args.update:
|
|
from frameport import updates
|
|
|
|
fake = updates.Update(version="9.9.9", tag="v9.9.9", page="https://example.invalid", asset=None, asset_url=None,
|
|
sums_url=None, wheel_url=None,
|
|
notes="## What's new\n- Self-update test release\n- **Bold** and `code` in notes")
|
|
steps += [("update-banner", lambda a: (a.updater._set(fake), a.navigate(0))),
|
|
("update-dialog", lambda a: a.updater.show_dialog()),
|
|
("update-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
|
|
if args.frame:
|
|
from frameport.frame.connection import parse_target
|
|
|
|
def connect(a):
|
|
a.connect(parse_target(args.frame))
|
|
for _ in range(60):
|
|
if a.target:
|
|
break
|
|
time.sleep(1)
|
|
a.navigate(1)
|
|
|
|
def job(a):
|
|
a.start_job(game, build=False, install=False, test=True)
|
|
time.sleep(5)
|
|
while a.job_running:
|
|
time.sleep(2)
|
|
|
|
steps += [("frame-connected", connect), ("library-connected", lambda a: a.navigate(0))]
|
|
if game:
|
|
steps.append(("game-connected", lambda a: a.open_game(game)))
|
|
if not args.no_test:
|
|
steps.append(("launch-test-job", job))
|
|
ready, done = threading.Event(), []
|
|
|
|
if args.fake_frame: # never reach a real Frame (start-up auto-connect, discovery, the 30 s poll)
|
|
FramePortApp.connect = lambda self, *a, **k: None
|
|
FramePortApp.refresh_frame = lambda self, *a, **k: None
|
|
|
|
def app_main(page: ft.Page):
|
|
try:
|
|
app = FramePortApp(page)
|
|
if args.fake_frame:
|
|
app.target = FakeTarget()
|
|
app.frame_info, app.frame_state = app.target.describe(), "connected"
|
|
except Exception: # noqa: BLE001
|
|
ERRORS.append("startup: " + traceback.format_exc())
|
|
return
|
|
threading.Thread(target=driver, args=(app, steps, ready, done), daemon=True).start()
|
|
|
|
def shooter():
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
time.sleep(6)
|
|
try:
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(args=["--use-gl=swiftshader", "--enable-unsafe-swiftshader"])
|
|
page = browser.new_page(viewport={"width": vw, "height": vh})
|
|
page.goto(f"http://127.0.0.1:{PORT}", wait_until="networkidle", timeout=120_000)
|
|
shot = 0
|
|
deadline = time.time() + 240 + 8 * len(steps)
|
|
while len(done) < len(steps) and time.time() < deadline:
|
|
if ready.wait(1):
|
|
ready.clear()
|
|
name = done[-1]
|
|
time.sleep(3) # let Flutter paint
|
|
if args.hover and name == "library":
|
|
hx, hy = (float(v) for v in args.hover.split(","))
|
|
page.mouse.move(hx, hy)
|
|
time.sleep(1.5)
|
|
page.screenshot(path=str(args.out / f"{shot:02d}-{name}.png"))
|
|
shot += 1
|
|
browser.close()
|
|
except Exception: # noqa: BLE001
|
|
ERRORS.append("browser: " + traceback.format_exc())
|
|
for e in ERRORS:
|
|
print("ERROR", e, flush=True)
|
|
print(f"screens: {[p.name for p in sorted(args.out.glob('*.png'))]}", flush=True)
|
|
os._exit(1 if ERRORS else 0)
|
|
|
|
threading.Thread(target=shooter, daemon=True).start()
|
|
from frameport.ui.app import assets_dir
|
|
|
|
ft.run(app_main, view=ft.AppView.WEB_BROWSER, port=PORT, assets_dir=assets_dir())
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|