Share visibility checks and test client presentation lifecycle

This commit is contained in:
Samuel Onoja committed 2026-10-02 08:07:24 +01:00
1 parent bb78801720
commit aecf54bc76
4 files changed
+302 -12

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
"""Verify visibility-gated callbacks and idle inhibition on a private desktop.
FERESE_TEST_PRESENTATION=1 python3 scripts/tests/test_presentation_isolated.py
Requires built debug compositor/control binaries, a Wayland host, cc and wayland-scanner.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
REPO = Path(__file__).resolve().parents[2]
@unittest.skipUnless(os.environ.get("FERESE_TEST_PRESENTATION") == "1", "requires a Wayland host")
class Presentation(unittest.TestCase):
def test_covered_and_unmapped_inhibitors_stop_inhibiting(self):
processes = []
with tempfile.TemporaryDirectory(prefix="ferese-presentation-") as directory:
root = Path(directory)
protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()
sources = []
for name, relative in [("xdg-shell", "stable/xdg-shell/xdg-shell.xml"),
("idle-inhibit", "unstable/idle-inhibit/idle-inhibit-unstable-v1.xml"),
("idle-notify", "staging/ext-idle-notify/ext-idle-notify-v1.xml")]:
xml = str(Path(protocols) / relative)
for kind, suffix in [("client-header", "client-protocol.h"), ("private-code", "protocol.c")]:
subprocess.run(["wayland-scanner", kind, xml, str(root / f"{name}-{suffix}")], check=True)
sources.append(str(root / f"{name}-protocol.c"))
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/presentation.c"),
*sources, "-o", str(root / "client"), *flags], check=True)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text('window-rule app-id="ferese.test.presentation" floating=#true\n'
'animations { enabled #false; }\n'
'theme { geometry { window-radius 0; border-width 0; focus-ring-width 0; }; }\n')
# Remembered geometry deliberately places both clients together;
# ordinary placement now avoids overlap. Keep host state isolated.
state = root / "state/ferese"
state.mkdir(parents=True)
(state / "floating.json").write_text(json.dumps({
"ferese.test.presentation": {
"output": "ferese-winit", "fractions": [.1, .1, .4, .4],
},
}))
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"), XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1")
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w")
try:
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(compositor)
deadline = time.monotonic() + 10
while not (runtime / "ferese/control.sock").exists():
if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(0.02)
sockets = list(runtime.glob("wayland-*"))
env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
def call(*args):
return subprocess.check_output([str(REPO / "target/debug/feresectl"), *args], env=env, timeout=10)
def windows(count):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
result = json.loads(call("get-windows"))
if len(result) == count and all(window["mapped"] and window["width"] == 640 for window in result):
return result
time.sleep(0.02)
self.fail((root / "compositor.log").read_text())
events = (root / "events").open("w")
target = subprocess.Popen([str(root / "client")], env=env, stdin=subprocess.PIPE,
stdout=events, stderr=log, start_new_session=True)
processes.append(target)
windows(1)
time.sleep(.4)
def count(event):
return (root / "events").read_text().splitlines().count(event)
before = count("frame")
idle = count("idle")
time.sleep(.5)
self.assertGreater(count("frame"), before, "visible empty commits must get callbacks")
self.assertEqual(count("idle"), idle, "visible inhibitor must keep the notifier active")
cover = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log,
stderr=log, start_new_session=True)
processes.append(cover)
current = windows(2)
self.assertEqual((current[0]["x"], current[0]["y"]), (current[1]["x"], current[1]["y"]))
time.sleep(.5)
before = count("frame")
time.sleep(.5)
self.assertEqual(count("frame"), before, "fully occluded client must stop receiving callbacks")
self.assertGreater(count("idle"), idle, "occluded inhibitor must stop inhibiting")
cover.terminate()
cover.wait(timeout=5)
windows(1)
time.sleep(.5)
self.assertGreater(count("frame"), before, "revealed client must resume callbacks")
idle = count("idle")
time.sleep(.4)
self.assertEqual(count("idle"), idle)
target.stdin.write(b"u")
target.stdin.flush()
time.sleep(.6)
self.assertGreater(count("idle"), idle, "unmapped inhibitor must stop inhibiting")
target.stdin.close()
events.close()
finally:
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
log.close()
if __name__ == "__main__":
unittest.main()