"""Test playback and window visibility on a private bus and nested desktop. FERESE_TEST_IDLE_INHIBITION=1 python3 scripts/tests/test_idle_inhibition_isolated.py Requires Wayland, PyGObject, dbus-run-session, cc and wayland-scanner. FERESE_TEST_BINARY and FERESE_TEST_CTL can select release binaries. """ import json import os from pathlib import Path import signal import subprocess import sys import tempfile import threading import time import unittest REPO = Path(__file__).resolve().parents[2] BINARY = REPO / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") CTL = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") NAME = "org.mpris.MediaPlayer2.ferese_test" PATH = "/org/mpris/MediaPlayer2" PLAYER = "org.mpris.MediaPlayer2.Player" APP = "ferese.test.window-capture" @unittest.skipUnless(os.environ.get("FERESE_TEST_IDLE_INHIBITION") == "1", "requires a Wayland host") class IdleInhibition(unittest.TestCase): def test_private_playback_and_visibility(self): result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def private_checks(): import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib loop = GLib.MainLoop() threading.Thread(target=loop.run, daemon=True).start() class FakePlayer: def __init__(self, status="Paused"): self.status = status self.connection = Gio.DBusConnection.new_for_address_sync( os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) node = Gio.DBusNodeInfo.new_for_xml(f''' ''') self.registrations = [self.connection.register_object(PATH, interface, None, self.property, None) for interface in node.interfaces] self.connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", (NAME, 0)), None, Gio.DBusCallFlags.NONE, 2000, None) def property(self, connection, sender, path, interface, name): return GLib.Variant("s", self.status if name == "PlaybackStatus" else APP) def set(self, status, invalidated=False): self.status = status changed = {} if invalidated else {"PlaybackStatus": GLib.Variant("s", status)} fields = ["PlaybackStatus"] if invalidated else [] self.connection.emit_signal(None, PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", GLib.Variant("(sa{sv}as)", (PLAYER, changed, fields))) self.connection.flush_sync(None) def close(self): self.connection.close_sync(None) processes = [] player = FakePlayer() try: with tempfile.TemporaryDirectory(prefix="ferese-idle-") as directory: root = Path(directory) runtime = root / "runtime" runtime.mkdir(mode=0o700) config = root / "config/ferese/config.kdl" config.parent.mkdir(parents=True) base = f'animations {{ reduced-motion #true; }}\nwindow-rule app-id="{APP}" floating=#true\n' config.write_text(base) protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip() xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml") for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]: subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True) flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split() subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"), str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True) display = Path(os.environ["WAYLAND_DISPLAY"]) if not display.is_absolute(): display = Path(os.environ["XDG_RUNTIME_DIR"]) / display env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state")) for variable in ["WAYLAND_SOCKET", "FERESE_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"]: env.pop(variable, None) with (root / "compositor.log").open("w") as log: try: compositor = subprocess.Popen([str(BINARY), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) processes.append(compositor) def wait(check, message): deadline = time.monotonic() + 8 while time.monotonic() < deadline: if compositor.poll() is not None: raise AssertionError((root / "compositor.log").read_text()) if check(): return time.sleep(0.02) raise AssertionError(message + "\n" + (root / "compositor.log").read_text()) wait(lambda: (runtime / "ferese/control.sock").exists(), "compositor startup") env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock"))) def call(*args): return json.loads(subprocess.check_output([str(CTL), *args], env=env, timeout=5)) def inhibited(expected): def matches(): state = call("get-idle-inhibition") return state["automatic"] == expected and state["inhibited"] == expected wait(matches, f"idle inhibition != {expected}") wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "MPRIS discovery") client = subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True) processes.append(client) wait(lambda: len(call("get-windows")) == 1, "window map") call("toggle-fullscreen") inhibited(False) player.set("Playing") inhibited(True) player.set("Paused") inhibited(False) player.set("Playing", invalidated=True) inhibited(True) player.set("Stopped") inhibited(False) player.set("Playing") inhibited(True) call("workspace", "2") inhibited(False) call("workspace", "1") inhibited(True) player.close() inhibited(False) player = FakePlayer("Paused") wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "replacement discovery") inhibited(False) player.set("Playing") inhibited(True) ambiguous = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True) processes.append(ambiguous) wait(lambda: len(call("get-windows")) == 2, "second player window") inhibited(False) os.killpg(ambiguous.pid, signal.SIGTERM) ambiguous.wait(timeout=5) wait(lambda: len(call("get-windows")) == 1, "ambiguous window closure") inhibited(True) config.write_text(base + f'window-rule app-id="{APP}" idle-inhibit="none"\n') call("reload-config") inhibited(False) config.write_text(base) call("reload-config") inhibited(True) call("toggle-fullscreen") inhibited(False) config.write_text(base + f'idle-inhibit {{ fullscreen-playback #false; }}\nwindow-rule app-id="{APP}" idle-inhibit="visible"\n') call("reload-config") inhibited(True) player.set("Stopped") inhibited(True) call("workspace", "2") inhibited(False) call("workspace", "1") inhibited(True) config.write_text(base + 'idle-inhibit { fullscreen-playback #false; }\n') call("reload-config") inhibited(False) config.write_text(base + f'window-rule app-id="{APP}" idle-inhibit="visible"\n') call("reload-config") inhibited(True) os.killpg(client.pid, signal.SIGTERM) client.wait(timeout=5) wait(lambda: not call("get-windows"), "closed window remained registered") inhibited(False) finally: for process in reversed(processes): if process.poll() is None: os.killpg(process.pid, signal.SIGTERM) process.wait(timeout=5) finally: player.close() loop.quit() if __name__ == "__main__": if "--private" in sys.argv: private_checks() else: unittest.main()