Files
qbz/scripts/test-orbit-daemon.py
T
vicrodh 74e1610433 feat(orbit): add shared library foundations and gated host tools
Extract the HTTP host and catalog layer into qbz-control, preserve the
existing daemon API, and keep private session credentials out of SSE.

Scope library stores and scan workers to their host. Share bounded source
search and show Local results alongside Qobuz in the Qt search page.

Add the launch-gated Qt Orbit laboratory and persistent remote-context
banner. Let qbzd run --orbit own and administer its file library through
instance-scoped API and CLI operations. Full remote UI control remains
pending.

Validate library/control/daemon suites, Qt search and QML regressions,
two isolated daemon processes, Qt release build and final cargo check.

cx
2026-09-12 13:02:29 -06:00

188 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Orbit against real qbzd processes, isolated profiles and no audio/session bus.
Uses a prebuilt binary; never compiles, authenticates to Qobuz or plays audio.
Linux XDG fixture only. Run with --bin /path/to/qbzd after the Rust test build.
"""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import wave
def check(condition, message):
if not condition:
raise AssertionError(message)
class Host:
def __init__(self, base, name, binary):
self.root = base / name
self.binary = binary
self.token = name + "-fixture-key"
self.env = os.environ.copy()
for key, folder in [("XDG_CONFIG_HOME", "config"), ("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"), ("XDG_RUNTIME_DIR", "run")]:
path = self.root / folder
path.mkdir(parents=True, mode=0o700)
self.env[key] = str(path)
self.env.update({
"DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(self.root / "absent-bus"),
"PIPEWIRE_RUNTIME_DIR": str(self.root / "run"),
"PIPEWIRE_REMOTE": "orbit-fixture-absent",
"PULSE_SERVER": "unix:" + str(self.root / "absent-pulse"),
"QBZD_MPRIS": "0", "QBZD_TOKEN": self.token,
"PATH": str(base / "fake-tools") + os.pathsep + self.env.get("PATH", ""),
})
self.env.pop("QBZD_HOST", None)
self.env.pop("QBZD_HOOK", None)
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
self.port = sock.getsockname()[1]
config = self.root / "config/qbzd"
config.mkdir()
(config / "qbzd.toml").write_text(
f'[server]\nbind = "127.0.0.1"\nport = {self.port}\ntoken = "{self.token}"\n')
self.music = self.root / "music"
self.music.mkdir()
with wave.open(str(self.music / f"Orbit {name}.wav"), "wb") as audio:
audio.setnchannels(1)
audio.setsampwidth(2)
audio.setframerate(44100)
audio.writeframes(b"\0\0" * 44100)
self.process = None
self.log = None
def start(self, orbit):
self.log = (self.root / "daemon.log").open("ab")
self.process = subprocess.Popen([self.binary, "run"] + (["--orbit"] if orbit else []),
env=self.env, stdout=self.log, stderr=subprocess.STDOUT)
end = time.monotonic() + 20
while time.monotonic() < end:
check(self.process.poll() is None, f"daemon exited: {self.root / 'daemon.log'}")
try:
if self.http("/api/ping")[0] == 200:
# The API starts before run() installs its signal waiter.
# Do not SIGTERM a booting process in that small window.
status = Path(f"/proc/{self.process.pid}/status").read_text()
caught = next(line.split()[1] for line in status.splitlines() if line.startswith("SigCgt:"))
if int(caught, 16) & (1 << (signal.SIGTERM - 1)):
return
except (OSError, urllib.error.URLError):
pass
time.sleep(0.05)
raise AssertionError("daemon startup timed out")
def stop(self):
if self.process is not None:
self.process.send_signal(signal.SIGTERM)
try:
code = self.process.wait(timeout=15)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
raise AssertionError("daemon failed to shut down")
finally:
self.process = None
self.log.close()
check(code == 0, f"unclean shutdown: {code}")
def http(self, path, body=None, token=None):
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}",
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": "Bearer " + (self.token if token is None else token),
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.load(error)
def cli(self, *args):
result = subprocess.run([self.binary, "--host", f"127.0.0.1:{self.port}", "library", *args],
env=self.env, capture_output=True, text=True, timeout=15)
check(result.returncode == 0, result.stderr)
return json.loads(result.stdout)
def indexed(self):
end = time.monotonic() + 15
while time.monotonic() < end:
state = self.cli("jobs")["library"]
if not state["progress"]["running"]:
check(state["last_scan"]["outcome"] == "complete", state)
return
time.sleep(0.05)
raise AssertionError("scan did not complete")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--bin", required=True)
args = parser.parse_args()
binary = str(Path(args.bin).resolve(strict=True))
if not os.sys.platform.startswith("linux"):
raise SystemExit("this fixture requires Linux XDG isolation")
with tempfile.TemporaryDirectory(prefix="orbit-daemon-") as scratch:
base = Path(scratch)
fake = base / "fake-tools"
fake.mkdir()
# Daemon quit's existing clock-reset subprocesses must not reach the
# user's PipeWire session, even if its environment changes in future.
for name in ("pw-metadata", "pw-cli"):
executable = fake / name
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o700)
a, b = Host(base, "Alpha", binary), Host(base, "Beta", binary)
try:
a.start(False)
check(a.http("/api/orbit/library")[0] == 404, "Orbit visible without flag")
check(not (a.root / "data/qbzd/library.db").exists(), "disabled Orbit created a DB")
a.stop()
a.start(True)
b.start(True)
for host in (a, b):
code, state = host.http("/api/status")
check(code == 200 and state["auth"]["state"] == "needs_auth", state)
check(host.cli("info")["tracks"] == 0, "nonempty new library")
folders = host.cli("add", str(host.music))
check(len(folders["folders"]) == 1, folders)
host.cli("scan")
host.indexed()
check(host.cli("info")["tracks"] == 1, "scan missing indexed WAV")
ia, ib = a.cli("info"), b.cli("info")
check(ia["instance"] != ib["instance"], "shared host instance")
pa, pb = a.cli("search", "Orbit"), b.cli("search", "Orbit")
check(pa["tracks"][0]["id"] == pb["tracks"][0]["id"], "fixture needs colliding native IDs")
check("Alpha" in pa["tracks"][0]["title"] and "Beta" in pb["tracks"][0]["title"], "crossed host metadata")
check(a.http("/api/orbit/library/scan", {"instance":ib["instance"]})[0] == 409, "crossed instance admitted")
check(a.http("/api/orbit/library", token=b.token)[0] == 401, "crossed token admitted")
a.stop()
a.start(True)
check(a.cli("info")["tracks"] == 1, "library did not survive restart")
check(a.http("/api/orbit/library/scan", {"instance":ia["instance"]})[0] == 409, "old instance admitted after restart")
check(b.cli("info")["instance"] == ib["instance"], "other host changed during restart")
print("PASS: flag gate, two daemon roots, no Qobuz auth, folder/scan/search CLI, access/instance isolation, restart persistence")
except Exception:
for host in (a, b):
log = host.root / "daemon.log"
if log.exists():
print(log.read_text(errors="replace")[-6000:], file=os.sys.stderr)
raise
finally:
try:
a.stop()
finally:
b.stop()
if __name__ == "__main__":
main()