kick/tests/test_smoke.py
Andrew Hamilton fec8266154 Add clipboard bridge smoke tests for the GNOME/Mutter backend
Two end-to-end tests driving the focus-based wl_data_device path with
wl-copy/wl-paste: nested->host (exercises bridge_host_set_selection and
the focus-serial defer/retry) and host->nested (exercises
bridge_dd_selection and the text prefetch mirror). Exact-byte matching
with unique tokens and a poll deadline; skipped when wl-clipboard tools
are absent.
2026-08-10 11:38:26 +10:00

348 lines
No EOL
12 KiB
Python

#!/usr/bin/env python3
"""Smoke tests for tabbed-wlroots.
The compositor is launched as a nested display (child of the host
Wayland session) and driven over the stdin event feed: raw keyboard
events are packed into binary records and dispatched into the *real*
host key handlers (host_kbd_key / host_kbd_modifiers), so the key
handler logic is under test without needing a focused window or wtype.
Each test launches its own instance (function-scoped fixture), so tests
are independent and can run in any order.
Requires: a live host Wayland session (WAYLAND_DISPLAY) and a prior
./build.sh. Missing prereqs skip the suite.
"""
import glob
import os
import re
import shutil
import struct
import subprocess
import time
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BIN = os.path.join(ROOT, "build", "tabbed-wlroots")
WL = os.path.join(ROOT, "build", "wltest")
LOG = "/tmp/tabtest.log"
RED = "#ff0000"
BLUE = "#0000ff"
# Raw keycodes used by the compositor's own key handler (see
# host_kbd_key in tabbed-wlroots.c). Names, not numbers, in tests.
F9 = 67
F10 = 68
F11 = 87
PAGE_UP = 104
PAGE_DOWN = 109
KEY_PRESSED = 1
KEY_RELEASED = 0
# Ctrl modifier bit, as read from host_mods_depressed (1u << 2).
CTRL = 1 << 2
CTL_CMD_KEY = 1
CTL_CMD_MODS = 2
ACTIVE_RE = re.compile(r"active app=(\d+)")
SOCKET_RE = re.compile(r"Wayland socket: (tabbed-wayland-\d+)")
class Session:
"""A running tabbed-wlroots instance plus helpers."""
def __init__(self, proc):
self.proc = proc
self._socket = None
# -- log watching ----------------------------------------------------
def read_log(self):
"""Whole current log contents (rescanned fresh each call)."""
with open(LOG, "r", errors="replace") as f:
return f.read()
def wait_for(self, pattern, timeout=15.0):
"""Wait until `pattern` matches somewhere in the whole log.
Re-scans from the start each poll: tabbed's log lines can arrive
out of order (e.g. `map app=1` before `map app=0`), so a line may
enter the file before we happen to be looking for it.
"""
rx = re.compile(pattern)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()) is not None:
return
time.sleep(0.1)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched {rx.pattern};\n{tail}")
@property
def socket(self):
if self._socket is None:
self.wait_for(SOCKET_RE)
m = SOCKET_RE.search(self.read_log())
assert m, "no 'Wayland socket:' line in log"
self._socket = m.group(1)
return self._socket
# -- input -----------------------------------------------------------
def key(self, code, state):
"""Feed one key event into the compositor's real key handler."""
record = struct.pack("<BII", CTL_CMD_KEY, code, state)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def mods(self, pattern):
"""Feed a keyboard-modifier set (e.g. CTRL, or 0 to clear)."""
record = struct.pack("<BIIII", CTL_CMD_MODS, pattern, 0, 0, 0)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def tap(self, code):
"""Press then release one key."""
self.key(code, KEY_PRESSED)
self.key(code, KEY_RELEASED)
def chord(self, mods, *keys):
"""Hold `mods`, tap each key in turn, then release `mods`."""
self.mods(pattern=mods)
for code in keys:
self.tap(code)
self.mods(pattern=0)
def cycle(self, direction):
"""Swap to the next (direction>0) or previous tab via Ctrl+Page."""
page = PAGE_DOWN if direction > 0 else PAGE_UP
self.chord(CTRL, page)
def active(self):
"""Current active app index from the log, or None."""
m = ACTIVE_RE.findall(self.read_log())
return int(m[-1]) if m else None
def wait_active(self, want, timeout=3.0):
since = time.monotonic()
while time.monotonic() - since < timeout:
if self.active() == want:
return
time.sleep(0.1)
pytest.fail(f"never reached active app={want}")
def wait_active_change(self, away_from, timeout=3.0):
"""Wait for active to differ from `away_from`; return the new value.
Unlike wait_active(), this ignores historical log lines: it only
returns once a *new* active tab shows up, so a stale launch-time
line can't satisfy it before the key event is processed.
"""
since = time.monotonic()
while time.monotonic() - since < timeout:
cur = self.active()
if cur is not None and cur != away_from:
return cur
time.sleep(0.1)
pytest.fail(f"active app never changed away from {away_from}")
def wait_any_active(self, timeout=10.0):
"""Wait until the active tab has settled, return its index.
The two test clients map in nondeterministic order, so the first
`active app=` line can name the first-mapped tab before the other
maps. Only once *both* tabs have logged an active line is the
last one the settled active tab.
"""
rx = re.compile(r"active app=(\d+)")
since = time.monotonic()
while time.monotonic() - since < timeout:
seen = set(rx.findall(self.read_log()))
if {"0", "1"}.issubset(seen):
return self.active()
time.sleep(0.1)
pytest.fail("both tabs never logged an active line")
# -- frame dumps ------------------------------------------------------
def dump_host(self, timeout=5.0):
rx = re.compile(r"\[dump\] host back buffer -> (\S+\.ppm)")
before = len(rx.findall(self.read_log()))
self.tap(F10)
# Wait for a *new* "host back buffer -> ...ppm" line: it is logged
# only after the file is fully written and closed. The file
# itself appears at fopen() while still mid-write, so file
# appearance alone is not enough.
since = time.monotonic()
while time.monotonic() - since < timeout:
m = rx.findall(self.read_log())
if len(m) > before:
return m[-1]
time.sleep(0.1)
raise AssertionError("F10 produced no 'host back buffer ->' line")
def centre_of(self, path):
"""Centre pixel of an 8-bit P6 ppm as an (R, G, B) tuple."""
with open(path, "rb") as f:
data = f.read()
lines = data.split(b"\n", 3)
if len(lines) < 3:
raise AssertionError(f"bad PPM {path}")
w, h = map(int, lines[1].split())
off = (len(lines[0]) + 1 + len(lines[1]) + 1 + len(lines[2]) + 1)
cx, cy = w // 2, h // 2
i = off + 3 * (cy * w + cx)
return tuple(data[i:i + 3])
def close(self):
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
for ppm in glob.glob("/tmp/host_*.ppm"):
os.unlink(ppm)
@pytest.fixture(scope="function")
def session():
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
try:
os.unlink(LOG)
except FileNotFoundError:
pass
proc = subprocess.Popen(
["env", "TABBED_LOG=2", "stdbuf", "-oL",
BIN, f"{WL} '{RED}'", f"{WL} '{BLUE}'"],
stdin=subprocess.PIPE,
stdout=open(LOG, "ab"), stderr=subprocess.STDOUT,
)
s = Session(proc)
try:
yield s
finally:
s.close()
# -- clipboard bridge (GNOME/Mutter focus-based wl_data_device backend) -----
def _run_display(display, args, **kw):
"""Run a command against a specific Wayland display, capturing output."""
env = dict(os.environ)
env["WAYLAND_DISPLAY"] = display
return subprocess.run(
args, env=env, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10, **kw)
def _poll_paste(display, want, deadline=10.0):
"""Wait until `wl-paste` on `display` returns exactly `want`."""
r = None
since = time.monotonic()
while time.monotonic() - since < deadline:
r = _run_display(display, ["wl-paste", "-n"])
if r.stdout == want:
return
time.sleep(0.2)
raise AssertionError(
f"wl-paste on {display} never returned {want!r}; "
f"last={r.stdout!r} err={r.stderr!r}")
NEED_WL_CLIPBOARD = shutil.which("wl-copy") is None or shutil.which("wl-paste") is None
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
def test_clipboard_nested_to_host(session):
token = f"tabbed-n2h-{os.getpid()}-{time.monotonic_ns()}"
copy = subprocess.Popen(
["wl-copy", "-f", token],
env={**os.environ, "WAYLAND_DISPLAY": session.socket},
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
_poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token)
finally:
copy.terminate()
copy.wait(timeout=5)
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
def test_clipboard_host_to_nested(session):
token = f"tabbed-h2n-{os.getpid()}-{time.monotonic_ns()}"
copy = subprocess.Popen(
["wl-copy", "-f", token],
env=dict(os.environ),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
_poll_paste(session.socket, token)
finally:
copy.terminate()
copy.wait(timeout=5)
def test_tabs_map(session):
session.wait_for(r"\[diag\] map app=0")
session.wait_for(r"\[diag\] map app=1")
def test_cycle_tabs(session):
start = session.wait_any_active()
assert start in (0, 1)
session.cycle(+1)
other = session.wait_active_change(start)
assert other == 1 - start
session.cycle(-1)
assert session.wait_active_change(other) == start
def test_host_colour_tracks_tab(session):
# warm up the present chain, then find each tab's colour
session.tap(F10)
session.wait_for(r"\[dump\] host")
start = session.wait_any_active()
assert start in (0, 1)
host_ppm = session.dump_host()
c0 = session.centre_of(host_ppm)
session.cycle(+1)
other = session.wait_active_change(start)
assert other == 1 - start
host_ppm = session.dump_host()
c1 = session.centre_of(host_ppm)
assert c0 != c1, f"host colour did not change across tabs ({c0} vs {c1})"
session.cycle(-1)
assert session.wait_active_change(other) == start
host_ppm = session.dump_host()
cb = session.centre_of(host_ppm)
assert cb == c0, f"host colour did not return to start ({cb} vs {c0})"
def test_f11_toggles_fullscreen(session):
session.tap(F11)
session.wait_for("host fullscreen=1")
session.tap(F11)
session.wait_for("host fullscreen=0")
assert session.proc.poll() is None
def test_sigterm_removes_socket(session):
sock_path = os.path.join(
os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"),
session.socket,
)
session.proc.terminate()
session.proc.wait(timeout=5)
assert session.proc.returncode == 0
assert not os.path.exists(sock_path), f"socket left behind: {sock_path}"