Files

178 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Start the installed SolidCraft snap on headless X11 and Wayland and model a box in it.
Runs the confined app (`snap install --dangerous` first) with its normal plugs, drives it over
the control channel from the host (snaps share the host's network namespace), checks a
30x20x10 box measures 6000 mm³ and that the GPU drew a non-blank frame, and copies each frame
to --out (default: the current directory) as snap-<backend>.png. Needs xvfb-run and weston.
Reuses the frame checks of flatpak-smoke.py.
"""
import argparse
import importlib.util
import json
import math
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import secrets
import tempfile
import time
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("flatpak_smoke", HERE / "flatpak-smoke.py")
flatpak_smoke = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(flatpak_smoke)
PORTS = {"x11": 18839, "wayland": 18840}
# The control channel's token (docs/control-protocol.md), given to the app so this client knows it.
TOKEN = secrets.token_hex(32)
REQUESTS = [
{"id": 1, "method": "ui.inspect", "params": {}, "token": TOKEN},
{"id": 2, "method": "engine.execute",
"params": {"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}},
{"id": 3, "method": "document.inspect", "params": {"measure": True}},
]
def drive(app, port, frame):
"""Connect to the app's control port, model the box and capture a frame."""
deadline = time.monotonic() + 60
while True:
if app.poll() is not None:
raise RuntimeError(f"the app exited with {app.returncode} before listening")
try:
conn = socket.create_connection(("127.0.0.1", port), timeout=60)
break
except OSError:
if time.monotonic() >= deadline:
raise RuntimeError("the control port never opened") from None
time.sleep(.2)
with conn, conn.makefile("rw") as io:
# Listening precedes the first frame, whose ui.inspect still has viewport:null.
deadline = time.monotonic() + 60
while True:
if app.poll() is not None:
raise RuntimeError(f"the app exited with {app.returncode} before creating a viewport")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("the viewport never appeared")
conn.settimeout(remaining)
io.write(json.dumps(REQUESTS[0]) + "\n")
io.flush()
try:
line = io.readline()
except TimeoutError as error:
raise RuntimeError("the viewport never appeared before control timed out") from error
if not line:
raise RuntimeError("the control connection closed before creating a viewport")
reply = json.loads(line)
if not isinstance(reply, dict) or reply.get("id") != 1 or reply.get("ok") is not True:
raise ValueError(f"ui.inspect failed: {reply}")
ui = reply["result"]
if ui["renderer"] not in {"gpu", "cpu"}:
raise ValueError("ui.inspect returned an invalid renderer")
viewport = ui["viewport"]
if viewport is not None:
if (not isinstance(viewport, list) or len(viewport) != 4
or not all(isinstance(value, (int, float)) and math.isfinite(value) for value in viewport)
or viewport[2] <= 0 or viewport[3] <= 0):
raise ValueError("ui.inspect returned an unusable viewport")
break
time.sleep(.25)
conn.settimeout(60)
replies = [line]
for request in REQUESTS[1:] + [{"id": 4, "method": "ui.screenshot", "params": {"path": str(frame)}},
{"id": 5, "method": "app.quit", "params": {"force": True}}]:
io.write(json.dumps(request) + "\n")
io.flush()
replies.append(io.readline())
renderer = flatpak_smoke.validate_replies("".join(replies[:3]))
capture = json.loads(replies[3])
if any(json.loads(reply).get("ok") is not True for reply in replies[4:]):
raise ValueError(f"app.quit failed: {replies[4:]}")
if capture.get("ok") is not True or renderer != "gpu":
raise ValueError(f"GPU frame capture failed: renderer={renderer}, {capture}")
return renderer, flatpak_smoke.frame_statistics(frame.read_bytes())
def run_backend(backend, out):
runtime = Path(f"/run/user/{os.getuid()}")
if not runtime.is_dir():
raise RuntimeError(f"{runtime} is missing: snaps expect the login session's runtime directory")
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), SOLIDCRAFT_CONTROL_TOKEN=TOKEN)
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "SOLIDCRAFT_WAYLAND", "SOLVECRAFT_WAYLAND"):
env.pop(name, None)
# The snap can write to the (non-hidden) home folder, not to the host's /tmp.
work = Path(tempfile.mkdtemp(prefix="solidcraft-snap-smoke-", dir=Path.home()))
compositor = None
log = (work / "app.log").open("wb")
try:
# No private dbus-run-session bus: snapd moves the app into its own systemd scope through
# the session's systemd, which a private bus doesn't have.
prefix = []
if backend == "x11":
prefix += ["xvfb-run", "-a", "-s", "-screen 0 1600x1000x24 -nolisten tcp"]
else:
# The wayland plug only allows sockets named wayland-N in the runtime directory.
env["WAYLAND_DISPLAY"] = "wayland-77"
compositor = subprocess.Popen(["weston", "--backend=headless", "--renderer=pixman", "--no-config",
"--socket=wayland-77", "--width=1600", "--height=1000"],
env=env, stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + 20
while not (runtime / "wayland-77").exists():
if compositor.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError("headless Weston failed")
time.sleep(.1)
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", PORTS[backend])) == 0:
raise RuntimeError(f"port {PORTS[backend]} is already in use (another SolidCraft?)")
# Its own process group, so stopping it also stops the app under xvfb-run.
app = subprocess.Popen(prefix + ["/snap/bin/solidcraft", "--control", str(PORTS[backend])],
env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
try:
renderer, stats = drive(app, PORTS[backend], work / "frame.png")
# app.quit exits cleanly, so the next run doesn't report a crash.
app.wait(timeout=30)
finally:
if app.poll() is None:
os.killpg(app.pid, signal.SIGTERM)
try:
app.wait(timeout=15)
except subprocess.TimeoutExpired:
os.killpg(app.pid, signal.SIGKILL)
app.wait()
shutil.copy(work / "frame.png", out / f"snap-{backend}.png")
print(f"snap {backend}: PASS renderer={renderer}, box=6000 mm³, frame={stats}")
except BaseException:
log.flush()
sys.stderr.write((work / "app.log").read_text(errors="replace")[-8000:])
raise
finally:
log.close()
if compositor:
compositor.terminate()
compositor.wait(timeout=10)
shutil.rmtree(work, ignore_errors=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--backend", choices=["x11", "wayland", "both"], default="both")
parser.add_argument("--out", type=Path, default=Path.cwd())
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
for backend in (["x11", "wayland"] if args.backend == "both" else [args.backend]):
run_backend(backend, args.out.resolve())
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as error:
print(f"snap desktop smoke failed: {error}", file=sys.stderr)
sys.exit(1)