Files
feresaria/scripts/tests/test_portal_isolated.py
T
Samuel Onoja a6f24e8188 Harden desktop recovery and pre-release stability checks
Bound IPC transport deadlines while preserving portal leases, supervise desktop helper and agent recovery, and reserve final floating window geometry during placement.

Stabilize CLI output and isolated tests, expand workspace CI, resolve formatting and lint failures, and bundle the MIT license with installer compatibility.
2026-10-07 09:08:23 +01:00

228 lines
13 KiB
Python

"""Opt-in portal integration test; only captures a temporary nested compositor.
FERESE_TEST_PORTAL=1 python3 scripts/tests/test_portal_isolated.py
Add FERESE_TEST_PORTAL_CONSENT=1 to exercise the actual picker interactively.
Requires built binaries, Python GI/GStreamer (pipewiresrc), dbus-daemon,
xdg-desktop-portal and bwrap. Host PAM and D-Bus activation are never modified.
"""
import json
import os
from pathlib import Path
import selectors
import signal
import subprocess
import tempfile
import time
import unittest
from session_socket import ipc_environment
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in")
class PortalTest(unittest.TestCase):
def test_portal_capture_cancel_and_lock(self):
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gio, GLib, Gst
Gst.init(None)
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
processes, logs = [], []
with tempfile.TemporaryDirectory(prefix="ferese-portal-test-") as tmp:
root = Path(tmp)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text('window-rule app-id="dev.ferese.ScreenShare" floating=#true\n')
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1",
PIPEWIRE_RUNTIME_DIR=os.environ["XDG_RUNTIME_DIR"], XDG_CURRENT_DESKTOP="Ferese",
XDG_DESKTOP_PORTAL_DIR=str(repo / "packaging/portal"))
def launch(name, command, **kwargs):
log = (root / (name + ".log")).open("w")
logs.append(log)
child = subprocess.Popen(command, env=ipc_environment(env), stderr=log,
stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
processes.append(child)
return child
def until(predicate, seconds=10):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return
while GLib.MainContext.default().iteration(False):
pass
time.sleep(.02)
self.fail("Timed out waiting for test condition")
def read_node(worker):
with selectors.DefaultSelector() as selector:
selector.register(worker.stdout, selectors.EVENT_READ)
self.assertTrue(selector.select(10), "stream did not publish a node")
return json.loads(worker.stdout.readline())["node"]
def consume(node, fd=None):
source = f'pipewiresrc path={node} num-buffers=15'
if fd is not None:
source += f' fd={fd}'
pipeline = Gst.parse_launch(source + ' ! video/x-raw,format=BGRx ! appsink name=sink sync=false emit-signals=true')
sizes, timestamps = [], []
def sample(sink):
buffer = sink.emit("pull-sample").get_buffer()
sizes.append(buffer.get_size())
timestamps.append(buffer.pts)
return Gst.FlowReturn.OK
pipeline.get_by_name("sink").connect("new-sample", sample)
try:
pipeline.set_state(Gst.State.PLAYING)
message = pipeline.get_bus().timed_pop_filtered(15 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
self.assertIsNotNone(message, "PipeWire consumer timed out")
self.assertEqual(message.type, Gst.MessageType.EOS)
self.assertEqual(len(sizes), 15)
self.assertGreater(min(sizes), 0)
self.assertTrue(all(b > a for a, b in zip(timestamps, timestamps[1:])), timestamps)
self.assertGreater(timestamps[-1] - timestamps[0], Gst.SECOND // 5)
finally:
pipeline.set_state(Gst.State.NULL)
try:
launch("compositor", [str(compositor_binary), "--backend", "nested"])
sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
until(sockets)
env["WAYLAND_DISPLAY"] = str(sockets()[0])
env = ipc_environment(env)
# Private bus, including the real portal frontend and permission store.
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
backend = launch("backend", [str(binary)])
# The frontend can D-Bus-activate an installed backend if it
# starts before this test's binary owns its service name.
def backend_ready():
bus_name = "org.freedesktop.DBus"
impl_name = "org.freedesktop.impl.portal.desktop.ferese"
owner = connection.call_sync(bus_name, "/org/freedesktop/DBus", bus_name,
"NameHasOwner", GLib.Variant("(s)", (impl_name,)), GLib.VariantType("(b)"),
Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
if not owner:
return False
pid = connection.call_sync(bus_name, "/org/freedesktop/DBus", bus_name,
"GetConnectionUnixProcessID", GLib.Variant("(s)", (impl_name,)), GLib.VariantType("(u)"),
Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
return pid == backend.pid
until(backend_ready)
frontend = launch("frontend", [os.environ.get("FERESE_TEST_PORTAL_FRONTEND", "/usr/libexec/xdg-desktop-portal")])
name, path, iface = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
responses = {}
connection.signal_subscribe(name, "org.freedesktop.portal.Request", "Response", None, None,
Gio.DBusSignalFlags.NONE, lambda c, s, p, i, n, args, _: responses.update({p: args.unpack()}), None)
def call(method, args):
return connection.call_sync(name, path, iface, method, args, GLib.VariantType("(o)"),
Gio.DBusCallFlags.NONE, 10000, None).unpack()[0]
def request(method, args, timeout=10):
handle = call(method, args)
until(lambda: handle in responses, timeout)
code, result = responses.pop(handle)
self.assertEqual(code, 0)
return result
def has_frontend():
return connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
"NameHasOwner", GLib.Variant("(s)", (name,)), GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, 1000, None).unpack()[0]
until(has_frontend)
# Direct implementation calls must not bypass the public frontend.
with self.assertRaises(GLib.Error):
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", path, "org.freedesktop.impl.portal.ScreenCast",
"CreateSession", GLib.Variant("(oosa{sv})", (path + "/request/test", path + "/session/test", "", {})),
None, Gio.DBusCallFlags.NONE, 5000, None)
def create(token):
session = request("CreateSession", GLib.Variant("(a{sv})", ({"session_handle_token": GLib.Variant("s", token)},)))["session_handle"]
# An ordinary portal client cannot suppress its stop indicator.
with self.assertRaises(GLib.Error) as denied:
connection.call_sync("org.freedesktop.impl.portal.desktop.ferese", "/org/ferese/ScreenRecorder",
"org.ferese.ScreenRecorder", "UseBarControls", GLib.Variant("(o)", (session,)),
None, Gio.DBusCallFlags.NONE, 5000, None)
self.assertIn("AccessDenied", str(denied.exception))
request("SelectSources", GLib.Variant("(oa{sv})", (session, {"types": GLib.Variant("u", 1)})))
return session
def helpers():
return [pid for children in Path(f"/proc/{backend.pid}/task").glob("*/children")
for pid in children.read_text().split()]
session = create("canceltest")
handle = call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
connection.call_sync(name, handle, "org.freedesktop.portal.Request", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
until(lambda: not helpers())
print("PASS: public session negotiation, caller authorization, request cancellation", flush=True)
if os.environ.get("FERESE_TEST_PORTAL_CONSENT") == "1":
session = create("consenttest")
print("Select the temporary display and click Share in the nested preview.", flush=True)
result = request("Start", GLib.Variant("(osa{sv})", (session, "", {})), 120)
reply, fds = connection.call_with_unix_fd_list_sync(name, path, iface, "OpenPipeWireRemote",
GLib.Variant("(oa{sv})", (session, {})), GLib.VariantType("(h)"), Gio.DBusCallFlags.NONE, 5000, None, None)
fd = fds.get(reply.unpack()[0])
try:
consume(result["streams"][0][0], fd)
finally:
os.close(fd)
connection.call_sync(name, session, "org.freedesktop.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
print("PASS: native consent and video through restricted portal FD", flush=True)
session = create("frontenddeath")
call("Start", GLib.Variant("(osa{sv})", (session, "", {})))
until(helpers)
frontend.terminate()
frontend.wait(timeout=5)
until(lambda: not helpers())
print("PASS: frontend death revokes pending consent", flush=True)
sources = json.loads(subprocess.check_output([str(binary), "--sources"], env=env, timeout=10))
worker = launch("capture", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
node = read_node(worker)
consume(node)
worker.stdin.close()
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: frame delivery and control-pipe shutdown", flush=True)
# Test revocation with NO consumer: paused captures must also end on lock.
worker = launch("paused", [str(binary), "--stream", sources[0]["name"], "hidden"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
read_node(worker)
pam = root / "pam"
pam.mkdir()
(pam / "ferese-lock").write_text("auth required pam_deny.so\naccount required pam_deny.so\n")
env["FERESE_LOCK_READY"] = "1"
locker = launch("locker", ["bwrap", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--ro-bind", str(pam), "/etc/pam.d", "--unshare-user", "--", str(repo / "target/release/ferese-lock")])
self.assertEqual(locker.wait(timeout=20), 0)
self.assertEqual(worker.wait(timeout=7), 0)
print("PASS: paused capture revoked by session lock", flush=True)
except BaseException:
for log in logs:
log.flush()
print(Path(log.name).name, Path(log.name).read_text()[-3000:])
raise
finally:
for child in reversed(processes):
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait()
for stream in (child.stdin, child.stdout):
if stream is not None:
stream.close()
for log in logs:
log.close()
if __name__ == "__main__":
unittest.main()