kick/tests/test_smoke.py
Andrew Hamilton 33526c7a4e test: unify _wait_log into Session.wait_for (phase 6)
All 16 assert _wait_log(s, rx, timeout) sites become s.wait_for(rx,
timeout); every caller only asserted truthiness, and both poll the
whole log identically, so behavior is unchanged (failures now report
the pattern plus a log tail instead of a custom one-liner). Sites on
_wait_log's 15s default keep timeout=15.0 explicitly since wait_for
defaults to 20s. The helper is deleted. Full suite: 152 passed,
2 skipped.
2026-09-10 02:10:01 +10:00

5543 lines
237 KiB
Python

#!/usr/bin/env python3
"""Smoke tests for kick.
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.
Known-flaky tests are marked @pytest.mark.flaky; pytest.ini's default
`-m "not flaky"` deselects them from every plain run (including direct
`pytest`). Run them explicitly with `./test.sh -m flaky`.
"""
import contextlib
import ctypes
import fcntl
import os
import re
import shutil
import signal
import struct
import subprocess
import tempfile
import time
import pytest
NEED_WL_CLIPBOARD = shutil.which("wl-copy") is None or shutil.which("wl-paste") is None
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Coverage runs point KICK_BIN at build-cov/kick (see coverage docs);
# the sibling wltest in the same dir is used so both come from one build.
_COV_BIN = os.environ.get("KICK_BIN")
_COV_DIR = os.path.dirname(_COV_BIN) if _COV_BIN else None
BIN = _COV_BIN or os.path.join(ROOT, "build", "kick")
WL = os.path.join(_COV_DIR, "wltest") if _COV_DIR else os.path.join(ROOT, "build", "wltest")
XL = os.path.join(ROOT, "build", "xleader")
XP = os.path.join(ROOT, "build", "xpopup")
XM = os.path.join(ROOT, "build", "xormenu")
XC = os.path.join(ROOT, "build", "xclass")
XR = os.path.join(ROOT, "build", "xreqs")
LOG = "/tmp/tabtest.log"
_log_seq = [0]
def _fresh_log():
"""A unique per-spawn log path.
Every compositor spawn gets its own log file instead of sharing LOG:
parallel workers (pytest-xdist) must not unlink or append to each
other's logs. Serial runs behave exactly as before -- each test reads
its session's log via Session.read_log(); LOG itself stays as the
legacy default path (used when a caller passes log= explicitly)."""
_log_seq[0] += 1
return "/tmp/tabtest-p%d-%d.log" % (os.getpid(), _log_seq[0])
def _mk_dump_dir():
"""A fresh per-spawn dump directory (KICK_DUMP_DIR).
Parallel workers' compositors must not share /tmp dump filenames:
every boot restarts the dump_seq counter, so host_0.ppm etc. would
collide. The owning Session removes the tree in close()."""
return tempfile.mkdtemp(prefix="kick-dump-")
RED = "#ff0000"
BLUE = "#0000ff"
GREEN = "#00ff00"
def _ensure_built():
"""Incremental build via ./build.sh (no-op when up to date). Raises
on failure: silently running the suite against a stale binary is
worse than aborting; ninja leaves the previous binary in place, which
is exactly what we must not test against unnoticed. Compiler output
streams through to the console above this error."""
if os.environ.get("KICK_BIN"):
return
r = subprocess.run(["./build.sh"], cwd=ROOT)
if r.returncode != 0:
raise RuntimeError(
"./build.sh failed -- refusing to run tests against a stale "
"or missing binary; see build output above")
_ensure_built()
# Raw keycodes used by the compositor's own key handler (see
# host_kbd_key in kick.c). Names, not numbers, in tests.
F = 33
F11 = 87
Q = 16
Y = 21
N = 49
M = 50
U = 22
V = 47
R = 19
C = 46
D = 32
G = 34
ESC = 1
TAB = 15
ONE = 2
ENTER = 28
UP = 103
DOWN = 108
RIGHT = 106
LEFT = 105
DELETE = 111
KEY_PRESSED = 1
KEY_RELEASED = 0
# Ctrl modifier bit, as read from host_mods_depressed (1u << 2).
CTRL = 1 << 2
# Alt (Mod1) modifier bit (1u << 3).
ALT = 1 << 3
# Shift modifier bit (1u << 0).
SHIFT = 1 << 0
# Super/Mod4 modifier bit (1u << 6).
SUPER = 1 << 6
CTL_CMD_KEY = 1
CTL_CMD_MODS = 2
CTL_CMD_WARP = 3
CTL_CMD_PTR_MOTION = 4
CTL_CMD_TOUCH = 6
# Keybind repeat delay: the compositor repeats a held chord after 600ms.
# Every test session except the hold-to-repeat test stretches the delay to
# 60s (KICK_REPEAT_TEST_DELAY) so a load-stalled tap can never catch a
# wall-clock repeat between its queued press and release -- repeats would
# displace the exact MRU/cycle/reorder state discrete-chord tests assert.
KICK_REPEAT_TEST_DELAY = "60000"
KICK_REPEAT_DEFAULT = "600"
CTL_CMD_SWIPE = 7
CTL_CMD_PINCH = 8
CTL_CMD_HOLD = 9
# Gesture phases: 0 begin / 1 update / 2 end (3 cancels swipe/pinch;
# hold has no update phase, so its phases are renamed below).
GESTURE_BEGIN = 0
GESTURE_UPDATE = 1
GESTURE_END = 2
GESTURE_CANCEL = 3
HOLD_BEGIN = 0
HOLD_END = 1
HOLD_CANCEL = 2
GESTURE_BEGIN = 0
GESTURE_UPDATE = 1
GESTURE_END = 2
GESTURE_CANCEL = 3
TOUCH_UP = 0
TOUCH_DOWN = 1
TOUCH_MOTION = 2
ACTIVE_RE = re.compile(r"active app=(\d+)")
CLAIM_RE = re.compile(r"\[claim\] app=(\d+) pid=(\d+)")
SOCKET_RE = re.compile(r"Wayland socket: (kick-\d+)")
# Font paths the compositor searches (overlay_pick_font in kick.c);
# used to make the standalone headless session build a real glyph atlas.
FONT_PATHS = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
]
def _pick_font():
"""First existing font path, from $KICK_FONT or the default list."""
env_font = os.environ.get("KICK_FONT")
if env_font and os.path.isfile(env_font):
return env_font
for path in FONT_PATHS:
if os.path.isfile(path):
return path
return None
_libc = ctypes.CDLL(None)
_PR_SET_PDEATHSIG = 1
def _preexec_death(expected_ppid):
"""preexec_fn: have the kernel SIGTERM this child if the test process
(its parent) ever dies, SIGKILL included. The compositor's SIGTERM handler
tears the whole session down (clients, socket), and the short-lived
wl-copy/wl-paste helpers just die -- so an aborted pytest run can never
leave strays behind. Check getppid() after setting it: if the parent died
between fork() and prctl() the signal is already pending, but our
expectations (and the child) are meaningless anyway, so bail out."""
def _fn():
_libc.prctl(_PR_SET_PDEATHSIG, signal.SIGTERM)
if os.getppid() != expected_ppid:
os._exit(1)
return _fn
class Session:
"""A running kick instance plus helpers."""
def __init__(self, proc, log=None, dump_dir=None):
self.proc = proc
self._log = log or _fresh_log()
self._dump_dir = dump_dir
self._socket = None
# -- log watching ----------------------------------------------------
def read_log(self):
"""Whole current log contents (rescanned fresh each call)."""
with open(self._log, "r", errors="replace") as f:
return f.read()
def wait_for(self, pattern, timeout=20.0):
"""Wait until `pattern` matches somewhere in the whole log.
Re-scans from the start each poll: kick'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}")
def wait_for_new(self, pattern, timeout=20.0):
"""Wait until `pattern` matches a log line appended AFTER this call.
Unlike wait_for (whole-log scan), this cannot match a stale line from
an earlier identical input: every chord/tap re-press sends a fresh
key event, so repeated presses of the same key need offset waits.
Without this, a second `wait_for(kbd key=X)` matches the first
press's line instantly and synchronizes with nothing.
"""
rx = re.compile(pattern)
pos = len(self.read_log())
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()[pos:]) is not None:
return
time.sleep(0.05)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched new {rx.pattern};\n{tail}")
def wait_for_new_after(self, pos, pattern, timeout=20.0):
"""wait_for_new with a caller-supplied offset: for effects (like the
release-side [mru] commit) that belong to an input sent lines ago,
where a fresh wait_for_new would snapshot past the already-logged
line. Capture pos = len(session.read_log()) BEFORE the input."""
rx = re.compile(pattern)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()[pos:]) is not None:
return
time.sleep(0.05)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched new {rx.pattern};\n{tail}")
def tap_ack(self, code, timeout=20.0):
"""Press+release one key, waiting for the compositor to consume the
press. The log offset is captured BEFORE sending: capturing it after
(tap(); wait_for_new()) races a descheduled test process -- the press
can be logged before the offset is taken, and the wait then misses a
line that is already in the file."""
pos = len(self.read_log())
self.tap(code)
rx = re.compile(r"\[kbd\] key=%d state=1" % code)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()[pos:]) is not None:
return
time.sleep(0.05)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched new {rx.pattern};\n{tail}")
def chord_ack(self, mods, *keys, timeout=20.0):
"""Hold `mods`, tap each key, release `mods`, waiting for the last
key's press to be consumed (single-threaded stdin processing keeps
the earlier records ordered before it). Offset captured first, as
in tap_ack."""
pos = len(self.read_log())
self.chord(mods, *keys)
rx = re.compile(r"\[kbd\] key=%d state=1" % keys[-1])
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()[pos:]) is not None:
return
time.sleep(0.05)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched new {rx.pattern};\n{tail}")
@property
def socket(self):
if self._socket is None:
self.wait_for(SOCKET_RE, timeout=60.0)
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 warp(self, x, y):
"""Move the standalone cursor to an output pixel (x, y)."""
record = struct.pack("<BII", CTL_CMD_WARP, x, y)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def button(self, btn, state):
"""Press (state=1) or release (state=0) an evdev pointer button at
the current cursor position, through the real seat entry path."""
record = struct.pack("<BII", 5, btn, state)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def click(self, btn=0x110):
"""BTN_LEFT press+release at the current cursor position."""
self.button(btn, 1)
self.button(btn, 0)
def touch(self, slot, action, x, y):
"""Touch event at absolute output pixels through the same delivery
path native touchscreens and the nested forwarder use."""
record = struct.pack("<BIIII", CTL_CMD_TOUCH, slot, action,
max(0, x), max(0, y))
self.proc.stdin.write(record)
self.proc.stdin.flush()
def tap(self_, x, y, slot=7):
"""A quick tap: down, a couple of motion samples, up."""
self_.touch(slot, TOUCH_DOWN, x, y)
self_.touch(slot, TOUCH_MOTION, x + 3, y + 2)
self_.touch(slot, TOUCH_UP, x + 3, y + 2)
def ptr_motion(self, dx, dy):
"""Feed a relative pointer-motion delta into the same path libinput
pointer devices take (wlr_cursor_move + seat forward)."""
record = struct.pack("<BII", CTL_CMD_PTR_MOTION, dx, dy)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def swipe(self, phase, fingers, dx=0, dy=0):
"""Touchpad swipe event through the shared gesture forwarders."""
record = struct.pack("<BIIii", CTL_CMD_SWIPE, phase, fingers,
dx, dy)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def pinch(self, phase, fingers, dx=0, dy=0, scale=1.0, rotation=0.0):
"""Touchpad pinch event; scale/rotation ride as thousandths."""
record = struct.pack("<BIIiiii", CTL_CMD_PINCH, phase, fingers,
dx, dy, round(scale * 1000),
round(rotation * 1000))
self.proc.stdin.write(record)
self.proc.stdin.flush()
def hold(self, phase, fingers):
"""Touchpad hold event through the shared gesture forwarders."""
record = struct.pack("<BII", CTL_CMD_HOLD, phase, fingers)
self.proc.stdin.write(record)
self.proc.stdin.flush()
def pointer_enter_until(self, pattern, timeout=20.0):
"""Keep re-entering the surface until `pattern` matches the log.
A single warp/motion only delivers a pointer *enter* if the surface
is already mapped and enabled under the cursor at that instant; one
that lands while the surface is still committing races and is lost
(wlroots does not re-enter a surface that appears under a stationary
cursor). Under load the input can win that race, so the client never
sees an enter and never sends a cursor-shape request. Alternating the
cursor between two points inside the output re-crosses the surface
each pass, so once it is ready a fresh enter (and a fresh cursor-shape
request) is guaranteed."""
rx = re.compile(pattern)
since = time.monotonic()
y = 400
while time.monotonic() - since < timeout:
if rx.search(self.read_log()) is not None:
return
x = 400 if (int(time.monotonic() * 2) % 2 == 0) else 600
self.warp(x, y)
time.sleep(0.05)
tail = self.read_log()[-1000:]
pytest.fail(f"log never matched {rx.pattern};\n{tail}")
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 Alt+arrow."""
key = DOWN if direction > 0 else UP
self.chord(ALT, key)
def cycle_ack(self, direction, timeout=15.0):
"""Tap Alt+arrow and block until the compositor logs a *new*
active-tab line reflecting the cycle, then return the fresh value.
cycle() alone races: `active()` reads the log file, which lags the
compositor's async key processing, so a tight `while active() != N`
loop can queue extra cycles past the target before its reads catch
up. Waiting for the log to grow guarantees the tap took effect
before the caller makes its next decision.
The count is only trustworthy once the fixture tabs have stopped
emitting their own startup "active app=" lines (both clients map
asynchronously, toggling active as they land); otherwise one of
those can satisfy the "grew" check and hand back a stale value.
So quiesce the log before tapping.
"""
key = DOWN if direction > 0 else UP
since = time.monotonic()
before = None
while time.monotonic() - since < timeout:
before = len(ACTIVE_RE.findall(self.read_log()))
time.sleep(0.2)
if len(ACTIVE_RE.findall(self.read_log())) == before:
break
self.chord(ALT, key)
since = time.monotonic()
while time.monotonic() - since < timeout:
if len(ACTIVE_RE.findall(self.read_log())) > before:
return self.active()
time.sleep(0.05)
pytest.fail("cycle tap never reflected in the log")
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=10.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=10.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=20.0):
"""Wait until the startup-selected tab has activated (its first map),
return its index.
The last command-line command is active from the very first frame --
black, titled by its command line -- and is the only tab that ever
auto-activates on boot; every earlier tab maps hidden with no
``active app=`` line. The first active line is therefore the settled
tab, with no churn to wait out. For the fixtures' N-tab sessions
that tab is index N-1.
"""
rx = re.compile(r"active app=(\d+)")
since = time.monotonic()
while time.monotonic() - since < timeout:
m = rx.findall(self.read_log())
if m:
return int(m[-1])
time.sleep(0.1)
pytest.fail("no tab was ever activated (startup tab never mapped)")
def wait_active_frame(self, timeout=20.0):
"""Wait until the active tab has painted a real frame: the composed
(or host-presented) frame's centre is no longer the black backdrop.
Guards single-read pixel asserts that need black to mean "overlay
shown" and probe areas to mean "pure client colour"."""
since = time.monotonic()
while time.monotonic() - since < timeout:
path = self.dump_host()
if path:
w, h, px = self.ppm_rgb(path)
i = 3 * (h // 2 * w + w // 2)
if (px[i], px[i + 1], px[i + 2]) != (0, 0, 0):
return
time.sleep(0.1)
pytest.fail("active tab never painted a frame (centre stayed black)")
def wait_app_active(self, index, timeout=15.0):
"""Wait until the single app `index` reports itself active (for
sessions launched with fewer than two tabs)."""
rx = re.compile(r"active app=%d\b" % index)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()):
return True
time.sleep(0.1)
pytest.fail(f"app {index} never logged an active line")
def wait_surface_mapped(self, index, timeout=15.0):
"""Wait until the client surface of tab `index` has mapped.
``wait_any_active`` returns as soon as the startup tab logs an
``active app=`` line, which happens when its window exists but before
its Wayland surface has necessarily mapped (the window is still the
black pending placeholder -- ``surface=0x0``). Keys sent before the
surface maps have no mapped surface to receive them and are silently
dropped; a load-sensitive race that shows up as an input that "never
did anything". Waiting on the ``[diag] map app=`` line (logged when
the surface actually maps) closes it."""
rx = re.compile(r"\[diag\] map app=%d\b" % index)
since = time.monotonic()
while time.monotonic() - since < timeout:
if rx.search(self.read_log()):
return
time.sleep(0.05)
pytest.fail(f"tab {index} surface never mapped ([diag] map app={index})")
# -- frame dumps ------------------------------------------------------
def dump_host(self, timeout=5.0):
rx = re.compile(r"\[dump\] host back buffer -> (\S+\.ppm)")
# The SIGUSR1 handler is only installed once the compositor is well
# into startup; signaling too early kills it (default action), so
# wait until the socket line exists first.
self.socket
before = len(rx.findall(self.read_log()))
os.kill(self.proc.pid, signal.SIGUSR1)
# 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("SIGUSR1 produced no 'host back buffer ->' line")
def dump_scene(self, timeout=5.0):
"""SIGUSR1-request a scene dump; wait until the dump path has run.
The scene buffer is a dmabuf and never CPU-readable, so no ppm is
written; the `[dump] scene` line (either "not CPU-readable" or the
stats line) proves the request reached the dump path.
"""
rx = re.compile(r"\[dump\] scene")
self.socket
before = len(rx.findall(self.read_log()))
os.kill(self.proc.pid, signal.SIGUSR1)
since = time.monotonic()
while time.monotonic() - since < timeout:
m = rx.findall(self.read_log())
if len(m) > before:
return
time.sleep(0.1)
raise AssertionError("SIGUSR1 produced no '[dump] scene' line")
def active_count(self):
"""How many `active app=` transitions the log has seen so far."""
return len(ACTIVE_RE.findall(self.read_log()))
def launch_pids(self, timeout=10.0):
"""Map app index -> connected client PID from [claim] lines."""
since = time.monotonic()
while time.monotonic() - since < timeout:
d = dict((int(a), int(p)) for a, p in CLAIM_RE.findall(self.read_log()))
if {0, 1}.issubset(d):
return d
time.sleep(0.1)
pytest.fail("both apps never logged a [claim] line")
def pixel_at(self, path, x, y):
"""Pixel (x, y) of an 8-bit P6 ppm as an (R, G, B) tuple.
The dump writes the back buffer top-down (window coords), so row 0
of the file is the top of the window and column 0 is the left edge.
"""
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)
i = off + 3 * (y * w + x)
return tuple(data[i:i + 3])
def ppm_rgb(self, path):
"""(width, height, raw RGB bytes) of an 8-bit P6 ppm.
Reading once lets a test scan many pixels without reopening the
file for every sample.
"""
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)
return w, h, data[off:]
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()
# Each session owns its dump directory (see _mk_dump_dir): remove
# the whole tree instead of globbing /tmp, which would race
# parallel workers' live dumps.
if self._dump_dir:
shutil.rmtree(self._dump_dir, ignore_errors=True)
self._dump_dir = None
@pytest.fixture(scope="function")
def session():
_require_host_wayland()
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
log = _fresh_log()
dump_dir = _mk_dump_dir()
proc = subprocess.Popen(
["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1",
"KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY, "stdbuf", "-oL",
BIN, f"{WL} '{RED}'", f"{WL} '{BLUE}'"],
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
try:
yield s
finally:
s.close()
@pytest.fixture(scope="function")
def standalone_session():
"""Standalone mode on a headless backend, exercised over the stdin feed.
The native backend autocreate is bypassed with WLR_BACKENDS=headless (real
DRM is unavailable in the test environment), so this checks the standalone
render/input/present paths without a host window. Requires a GPU renderer
(PIXMAN/GBlob every time CPU backends), which the build always links.
"""
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
log = _fresh_log()
dump_dir = _mk_dump_dir()
# Clear any leftover XDG/compositor interaction: standalone must not
# connect to the host compositor (it would pick the wayland backend and
# open a second nested window). WLR_BACKENDS=headless forces the
# headless backend and the standalone output path.
env = dict(os.environ)
env["KICK_LOG"] = "2"
env["KICK_MODE"] = "standalone"
env["WLR_BACKENDS"] = "headless"
env.pop("WAYLAND_DISPLAY", None)
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or ""),
"XCURSOR_THEME=Adwaita",
"KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY,
"stdbuf", "-oL",
BIN, f"{WL} '{RED}'", f"{WL} '{BLUE}'"],
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
try:
yield s
finally:
s.close()
def test_standalone_headless_boot_and_cycle(standalone_session):
"""Standalone on the headless backend boots, maps both tabs, and cycles
them with the same stdin-driven Alt+arrow handling as nested mode."""
s = standalone_session
_assert_alive(s, "standalone exited early")
start = s.wait_any_active()
assert start in (0, 1)
s.cycle(+1)
assert s.wait_active_change(start) == 1 - start
_assert_alive(s)
def _has_dark(w, h, px):
"""True when the central region looks like the dark overlay box."""
for y in range(h // 4, 3 * h // 4):
row = y * w * 3
for x in range(w // 4, 3 * w // 4):
i = row + x * 3
if px[i] < 40 and px[i + 1] < 40 and px[i + 2] < 40:
return True
return False
def test_standalone_headless_alt_overlay_scene(standalone_session):
"""Holding Alt in standalone mode routes through the shared hotkey path
and draws the overlay (which in standalone enters the second wlr render
pass before the frame is committed). SIGUSR1 dumps the composited frame,
whose centre darkens while Alt is held."""
s = standalone_session
s.wait_any_active()
s.wait_active_frame()
# Before Alt: the frame should be the client content (no dark overlay).
w, h, px = s.ppm_rgb(s.dump_host())
assert not _has_dark(w, h, px), "content already dark before Alt"
s.mods(ALT)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
w, h, px = s.ppm_rgb(s.dump_host())
if _has_dark(w, h, px):
break
assert _has_dark(w, h, px), "standalone overlay box not visible while Alt"
s.mods(0)
_assert_alive(s)
def test_standalone_headless_overlay_clears_on_alt_release(standalone_session):
"""Releasing Alt in standalone fully clears the overlay. The overlay is
drawn into the scene output buffer outside the scene graph, so without
forcing whole-output damage on the transition a reused swapchain buffer
keeps the stale overlay baked in and it reappears on alternating frames
(until a client repaint covers it)."""
s = standalone_session
s.wait_any_active()
s.mods(ALT)
deadline = time.monotonic() + 15.0
seen = False
while time.monotonic() < deadline:
w, h, px = s.ppm_rgb(s.dump_host())
if _has_dark(w, h, px):
seen = True
break
assert seen, "overlay did not appear while Alt"
s.mods(0)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
w, h, px = s.ppm_rgb(s.dump_host())
if not _has_dark(w, h, px):
break # a freshly presented, overlay-free frame
time.sleep(0.08)
else:
raise AssertionError("overlay remnant never cleared after Alt release")
# Once clear, keep sampling: any reappearing dark frame is a stale buffer
# slot echoing the baked-in overlay, i.e. the bug returning.
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
w, h, px = s.ppm_rgb(s.dump_host())
assert not _has_dark(w, h, px), "overlay remnant reappeared after Alt release"
time.sleep(0.08)
_assert_alive(s)
def test_standalone_headless_overlay_title_chip_grey(standalone_session):
"""Every overlay title sits on its own dark-grey chip (0.22/0.22/0.25,
i.e. RGB ~56/56/64 -- distinct from the box interior ~18/18/23, the box
border ~115/115/122 and the red/blue client content). Regression: an
accidental `if (is_cursor)` gate left non-cursor titles -- every
background tab row -- with no chip behind them at all."""
s = standalone_session
s.wait_any_active()
s.wait_active_frame()
def grey_chip_pixels():
w, h, px = s.ppm_rgb(s.dump_host())
n = 0
for i in range(0, len(px) - 2, 3):
if abs(px[i] - 56) <= 4 and abs(px[i + 1] - 56) <= 4 \
and abs(px[i + 2] - 64) <= 4:
n += 1
return n
# Without the overlay the frame is pure client content: no chip colour.
before = grey_chip_pixels()
assert before < 20, f"chip grey already present before Alt ({before} px)"
s.mods(ALT)
deadline = time.monotonic() + 15.0
count = 0
while time.monotonic() < deadline:
count = grey_chip_pixels()
if count >= 200:
break
assert count >= 200, \
f"overlay title chips missing: only {count} grey pixels with Alt held"
s.mods(0)
_assert_alive(s)
def test_standalone_headless_cursor_visible(standalone_session):
"""The standalone output shows a real cursor. The headless backend has no
cursor plane, so wlroots composites the software cursor into the frame
buffer (the scene render pass picks it up). Warping the pointer must
change the composited pixels at the target -- the cursor visibly moves
there -- while moving the cursor away again repaints the spot back to pure
client colour without smearing.
The GL dump is vertically flipped (bottom-up readback), so a cursor at
compositor pixel (x, y) appears in the PPM at (x, H-1-y); the patch probes
through the same flip."""
s = standalone_session
s.wait_any_active()
s.wait_active_frame()
_assert_alive(s)
def patch_at(path, cx, cy, r=22):
"""Sample the PPM around compositor pixel (cx, cy), mapping compositor
row y to file row H-1-y for the bottom-up readback."""
w, h, px = s.ppm_rgb(path)
py = h - 1 - cy
hits = []
for y in range(max(0, py - r), min(h, py + r + 1)):
row = y * w * 3
for x in range(max(0, cx - r), min(w, cx + r + 1)):
i = row + x * 3
hits.append((px[i], px[i + 1], px[i + 2]))
return hits
def distinct(a, b):
return any(ca != cb for ca, cb in zip(a, b))
target = (320, 320)
# Get the pointer clear of the probe area first, then snapshot: the
# target patch must be pure client colour before we warp into it.
pos = len(s.read_log())
s.warp(60, 60)
s.wait_for_new_after(pos, r"\[cursor\] pos=60,60")
before = patch_at(s.dump_host(), *target)
assert len(set(before)) == 1, \
"probe area should be pure client colour before the warp"
pos = len(s.read_log())
s.warp(*target)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % target)
assert distinct(patch_at(s.dump_host(), *target), before), \
"cursor never became visible at the warp target"
# Move away again: the target must end up back on pure client colour
# (no cursor smeared into the composited buffer).
pos = len(s.read_log())
s.warp(640, 400)
s.wait_for_new_after(pos, r"\[cursor\] pos=640,400")
assert not distinct(patch_at(s.dump_host(), *target), before), \
"cursor left a remnant at the warp target"
_assert_alive(s)
def test_standalone_headless_cursor_relative_motion(standalone_session):
"""Relative pointer motion must move the visible cursor, exactly as libinput
pointer devices do on a real host (native_ptr_motion -> wlr_cursor_move). A
regressed compositor leaves the pointer pinned at the warp target while the
seat still moves, so this fails on code that forwards motion but never moves
the cursor."""
s = standalone_session
s.wait_any_active()
s.wait_active_frame()
_assert_alive(s)
def patch_at(path, cx, cy, r=22):
w, h, px = s.ppm_rgb(path)
py = h - 1 - cy
hits = []
for y in range(max(0, py - r), min(h, py + r + 1)):
row = y * w * 3
for x in range(max(0, cx - r), min(w, cx + r + 1)):
i = row + x * 3
hits.append((px[i], px[i + 1], px[i + 2]))
return hits
def distinct(a, b):
return any(ca != cb for ca, cb in zip(a, b))
start = (320, 320)
delta = (200, 120)
moved = (start[0] + delta[0], start[1] + delta[1])
# Clear the pointer, snapshot both probe areas as pure client colour.
pos = len(s.read_log())
s.warp(40, 40)
s.wait_for_new_after(pos, r"\[cursor\] pos=40,40")
before_start = patch_at(s.dump_host(), *start)
before_moved = patch_at(s.dump_host(), *moved)
assert len(set(before_start)) == 1, \
"start probe area should be pure client colour"
assert len(set(before_moved)) == 1, \
"moved probe area should be pure client colour"
# Place the cursor at `start` and confirm it is painted there.
pos = len(s.read_log())
s.warp(*start)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % start)
assert distinct(patch_at(s.dump_host(), *start), before_start), \
"cursor never became visible at the start target"
# Relative motion (+dx, +dy) must carry the pointer to `moved`: the pixels
# there change, and `start` returns to pure client colour.
pos = len(s.read_log())
s.ptr_motion(*delta)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % moved)
path = s.dump_host()
assert distinct(patch_at(path, *moved), before_moved) and \
not distinct(patch_at(path, *start), before_start), \
"relative motion did not move the cursor %+d,%+d" % delta
_assert_alive(s)
def test_standalone_headless_locked_pointer_holds():
"""A LOCKED pointer constraint pins the visible cursor: once the
constraint activates on pointer enter, relative motion must not carry the
cursor away from the lock position."""
with _run_standalone({}, "%s --constrain locked '#ff0000'" % WL, wait="app0") as s:
_assert_alive(s)
def patch_at(path, cx, cy, r=22):
w, h, px = s.ppm_rgb(path)
py = h - 1 - cy
hits = []
for y in range(max(0, py - r), min(h, py + r + 1)):
row = y * w * 3
for x in range(max(0, cx - r), min(w, cx + r + 1)):
i = row + x * 3
hits.append((px[i], px[i + 1], px[i + 2]))
return hits
def distinct(a, b):
return any(ca != cb for ca, cb in zip(a, b))
lock = (300, 300)
target = (lock[0] + 90, lock[1] + 60)
pure_lock = patch_at(s.dump_host(), *lock)
pure_target = patch_at(s.dump_host(), *target)
assert len(set(pure_lock)) == 1, "lock probe should be pure client colour"
assert len(set(pure_target)) == 1, "target probe should be pure client colour"
# Warp onto the surface: the constraint activates and the cursor is
# painted at the lock position. Quorum is event-based, not a dump
# poll: the [cursor] line proves the warp was processed, the next
# frame line proves the damage it raised was rendered; a single dump
# then asserts pixels (each dump+parse costs ~0.1s, fatal in a poll
# loop under load).
pos = len(s.read_log())
s.warp(*lock)
s.wait_for_new_after(pos, r"\[constraint\] locked on surface")
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % lock)
assert distinct(patch_at(s.dump_host(), *lock), pure_lock), \
"cursor never appeared at the lock position"
# Relative motion must not move the pinned cursor.
pos = len(s.read_log())
s.ptr_motion(*[target[0] - lock[0], target[1] - lock[1]])
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % lock)
path = s.dump_host()
assert distinct(patch_at(path, *lock), pure_lock) and \
not distinct(patch_at(path, *target), pure_target), \
"locked pointer moved away from the lock position"
_assert_alive(s)
def test_standalone_headless_locked_pointer_release():
"""Dropping a locked constraint mid-session tears it down: the destroy
path runs and the cursor is free to move again."""
with _run_standalone({}, "%s --constrain locked '#ff0000'" % WL, wait="app0") as s:
_assert_alive(s)
s.warp(300, 300)
s.wait_for(r"\[constraint\] locked on surface", timeout=15.0)
s.tap(C)
s.wait_for(r"\[constraint\] destroyed", timeout=15.0)
_assert_alive(s, "compositor died on constraint release")
def test_standalone_headless_confined_pointer_clamps():
"""A CONFINED pointer constraint keeps the visible cursor inside the
constraint region even when relative motion would carry it outside."""
with _run_standalone({}, "%s --constrain confined "
"--confine-rect 100 100 200 150 '#00ff00'" % WL, wait="app0") as s:
_assert_alive(s)
def patch_at(path, cx, cy, r=22):
w, h, px = s.ppm_rgb(path)
py = h - 1 - cy
hits = []
for y in range(max(0, py - r), min(h, py + r + 1)):
row = y * w * 3
for x in range(max(0, cx - r), min(w, cx + r + 1)):
i = row + x * 3
hits.append((px[i], px[i + 1], px[i + 2]))
return hits
def distinct(a, b):
return any(ca != cb for ca, cb in zip(a, b))
start = (150, 120) # inside the 100..300 x 100..250 region
clamped = (299, 249) # far motion clamps to the region's edge
outside = (650, 520) # where unconstrained motion would land
pure_start = patch_at(s.dump_host(), *start)
pure_clamped = patch_at(s.dump_host(), *clamped)
assert len(set(pure_start)) == 1, "start probe should be pure client colour"
assert len(set(pure_clamped)) == 1, \
"clamped probe should be pure client colour"
pos = len(s.read_log())
s.warp(*start)
s.wait_for_new_after(pos, r"\[constraint\] confined on surface")
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % start)
assert distinct(patch_at(s.dump_host(), *start), pure_start), \
"cursor never appeared at the start position"
# A big relative motion clamps to the region edge, not the raw target.
pos = len(s.read_log())
s.ptr_motion(500, 400)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % clamped)
path = s.dump_host()
assert distinct(patch_at(path, *clamped), pure_clamped) and \
not distinct(patch_at(path, *outside), pure_start), \
"confined pointer escaped the constraint region"
_assert_alive(s)
def test_standalone_headless_screen_mode_cursor_stays_aligned():
"""--screen-mode + pointer motion must keep the visible cursor exactly on
the warp target plus the relative delta, on a non-default output size. The
seat forward and the visible arrow must both derive from the same
wlr_cursor position (single source of truth), not a parallel hand-clamped
copy -- on a DRM monitor a requested mode that differs from the geometry
the layout derives from the committed state would otherwise let the two
split diagonally."""
with _run_standalone({}, "--screen-mode", "480x320",
"build/wltest '#ff0000'", wait="app0") as s:
_assert_alive(s)
def patch_at(path, cx, cy, r=22):
w, h, px = s.ppm_rgb(path)
py = h - 1 - cy
hits = []
for y in range(max(0, py - r), min(h, py + r + 1)):
row = y * w * 3
for x in range(max(0, cx - r), min(w, cx + r + 1)):
i = row + x * 3
hits.append((px[i], px[i + 1], px[i + 2]))
return hits
def distinct(a, b):
return any(ca != cb for ca, cb in zip(a, b))
start = (120, 120)
delta = (100, 60)
moved = (start[0] + delta[0], start[1] + delta[1])
pos = len(s.read_log())
s.warp(30, 30)
# Park the cursor away from the probe areas, then snapshot the pure
# client-colour baselines the later distinct() checks compare against.
s.wait_for_new_after(pos, r"\[cursor\] pos=30,30")
before_start = patch_at(s.dump_host(), *start)
before_moved = patch_at(s.dump_host(), *moved)
assert len(set(before_start)) == 1, \
"start probe area should be pure client colour"
assert len(set(before_moved)) == 1, \
"moved probe area should be pure client colour"
pos = len(s.read_log())
s.warp(*start)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % start)
assert distinct(patch_at(s.dump_host(), *start), before_start), \
"cursor never became visible at the start target"
pos = len(s.read_log())
s.ptr_motion(*delta)
s.wait_for_new_after(pos, r"\[cursor\] pos=%d,%d" % moved)
path = s.dump_host()
assert distinct(patch_at(path, *moved), before_moved) and \
not distinct(patch_at(path, *start), before_start), \
"relative motion did not move the cursor %+d,%+d" % delta
_assert_alive(s)
def _spawn_standalone(extra_env, *apps, log=None):
"""Standalone compositor on the headless backend with a custom env for
cursor-theme tests; XCURSOR_THEME/XCURSOR_PATH come only from extra_env.
A custom log path lets two compositors (e.g. kick-in-kick) run without
interleaving their logs; the default is a fresh per-spawn file (see
_fresh_log), so parallel workers never share a log."""
log = log or _fresh_log()
dump_dir = _mk_dump_dir()
env = dict(os.environ)
env["KICK_LOG"] = "2"
env["KICK_MODE"] = "standalone"
env["WLR_BACKENDS"] = "headless"
env.pop("WAYLAND_DISPLAY", None)
env.pop("XCURSOR_THEME", None)
env.pop("XCURSOR_PATH", None)
env.update(extra_env or {})
env["KICK_DUMP_DIR"] = dump_dir
cmd = ["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or "")]
for key in ("XCURSOR_PATH", "XCURSOR_THEME"):
if env.get(key):
cmd.append("%s=%s" % (key, env[key]))
# NOTE: env -i wipes the dict above, so every var the compositor needs
# must be listed explicitly -- a missing KICK_DUMP_DIR silently dumps
# all standalone sessions into shared /tmp/host_N.ppm paths, and
# parallel workers then read each other's dumps.
cmd += ["KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY,
"stdbuf", "-oL", BIN]
cmd += list(apps)
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()))
return Session(proc, log, dump_dir)
def _spawn_nested_in(outer, *apps, extra_args=(), log=None):
"""A nested kick whose host compositor is another running kick (e.g. a
standalone outer): the inner bridge binds the outer's data-control
backend, so data-control/primary bridge paths are testable without a
labwc/sway host. The inner session gets its own log file."""
log = log or _fresh_log()
dump_dir = _mk_dump_dir()
env = dict(os.environ)
env["KICK_LOG"] = "2"
env["KICK_SYNTH_INPUT"] = "1"
env["KICK_DUMP_DIR"] = dump_dir
env["KICK_REPEAT_DELAY_MS"] = KICK_REPEAT_TEST_DELAY
env["WAYLAND_DISPLAY"] = outer.socket
# The outer kick advertises no server decorations; take the direct
# xdg-shell path instead of libdecor (which also needs KICK_LIBDECOR
# scrubbed: any value, even "0", used to force libdecor).
env["KICK_LIBDECOR"] = "0"
env.pop("KICK_MODE", None)
env.pop("WLR_BACKENDS", None)
proc = subprocess.Popen(
[BIN, *extra_args] + list(apps),
env=env, stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()))
return Session(proc, log, dump_dir)
def _wait_file(path, timeout=15.0):
"""Wait until a tmp file appears (a client signaling completion)."""
since = time.monotonic()
while time.monotonic() - since < timeout:
if os.path.exists(path):
return True
time.sleep(0.1)
return False
def _wait_out(path, contains=None, timeout=15.0):
"""Wait until a tmp file appears, optionally containing a string.
The out-file polling loops (a client signaling completion by writing
a payload file) all share this shape: poll for existence, and when
the test cares about the payload, for a substring of its text. The
read is retried -- the file can exist while still empty/partial, and
exists()/read_text() can race -- so callers keep only their assert
message on top of the returned bool.
"""
p = os.fspath(path)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
if os.path.exists(p):
if contains is None:
return True
with open(p, "r", errors="replace") as f:
if contains in f.read():
return True
except OSError:
pass
time.sleep(0.1)
try:
if not os.path.exists(p):
return False
if contains is None:
return True
with open(p, "r", errors="replace") as f:
return contains in f.read()
except OSError:
return False
def _assert_alive(s, msg="compositor exited", tail=400):
"""Assert a session is still running, with a log tail on failure.
Replaces the assorted `assert X.proc.poll() is None` spellings (bare,
`log tail` tails of varying length, custom operation messages) with
one shape: custom messages stay up front, the tail is always there.
"""
assert s.proc.poll() is None, f"{msg}; log tail:\n{s.read_log()[-tail:]}"
_CRASH_PATTERNS = ("Segmentation fault", "Aborted", "BACKTRACE",
"Assertion `", "AddressSanitizer",
"null pointer passed as argument")
def _assert_no_crash(log):
"""Fail if compositor log text shows a crash indicator."""
for pat in _CRASH_PATTERNS:
assert pat not in log, f"crash indicator {pat!r} in log"
def _require_host_wayland():
"""Skip unless running under a live host Wayland session."""
if not os.environ.get("WAYLAND_DISPLAY"):
pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)")
def _require_xwayland():
"""Skip unless Xwayland and xmessage are available."""
if not HAVE_XWAYLAND:
pytest.skip("needs Xwayland and xmessage on PATH")
def _require_file(path, reason):
"""Skip unless a helper binary exists. Each caller keeps its own
triage message (missing binary vs missing dev headers)."""
if not os.path.exists(path):
pytest.skip(reason)
@pytest.fixture(scope="module")
def globals_session():
"""One shared standalone kick for all protocol-globals tests.
Booting a compositor per globals assertion wastes ~1s each for zero
benefit: the advertised global set is static, and these tests read
only the --globals-out file (never tabs or the log). A single keeper
tab keeps the session alive while each test runs a short-lived
--globals-out client against the shared socket (see _globals_of)."""
s = _spawn_standalone({}, "%s '#112233'" % WL)
try:
s.wait_any_active()
yield s
finally:
s.close()
def _globals_of(s, tmp_path):
"""Advertised globals of a running session: run a throwaway
--globals-out client against its socket and return the name set."""
out = tmp_path / "globals.txt"
with _run_wl_client(s.socket, "--globals-out", str(out),
"#112233") as client:
assert _wait_file(str(out)), \
"wltest --globals-out never wrote its file"
assert client.wait(timeout=10) == 0, "--globals-out client failed"
return {line.split()[0] for line in out.read_text().splitlines()}
def test_standalone_headless_cursor_theme_missing_uses_builtin(tmp_path):
"""A theme that does not exist anywhere must not stop the compositor:
wlroots substitutes a builtin arrow, so the pointer stays visible. We
merely log the substitution, naming the requested theme."""
empty = tmp_path / "empty-cursors"
empty.mkdir()
with _run_standalone({"XCURSOR_PATH": str(empty),
"XCURSOR_THEME": "DefinitelyMissingTheme"},
"build/wltest '#ff0000'") as s:
s.wait_app_active(0)
_assert_alive(s)
log = s.read_log()
assert "theme 'DefinitelyMissingTheme' not found; wlroots builtin " \
"cursor in use" in log, \
f"substitution should be logged naming the theme:\n{log[-400:]}"
def test_standalone_headless_protocol_globals(globals_session, tmp_path):
"""The standalone compositor advertises the passive protocol globals
that clients rely on to run at their best: presentation-time for frame
pacing, xdg-output for logical output geometry, viewporter for
wl_viewport buffer scaling. Runs against the shared globals session
(see globals_session): the global set is static, so no fresh boot is
needed per assertion."""
names = _globals_of(globals_session, tmp_path)
for want in ("wl_compositor", "wl_shm", "xdg_wm_base", "wl_seat",
"wp_presentation", "zxdg_output_manager_v1",
"wp_viewporter", "wl_fixes",
"xdg_toplevel_tag_manager_v1",
"wp_color_representation_manager_v1"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def test_standalone_headless_toplevel_tag(tmp_path):
"""xdg-toplevel-tag-v1: a tab that tags itself "settings" (with a
translated description) must have the tag recorded -- the compositor
logs one [tag] line per change and shows "title [tag]" in the overlay."""
with _run_standalone(
{}, "%s --tag-set settings --tag-desc 'Settings panel' '#ff0000'"
% WL) as s:
s.wait_for(r"\[tag\]")
log = s.read_log()
assert "tag=settings" in log, f"tag missing; log tail:\n{log[-800:]}"
assert "desc=Settings panel" in log, \
f"description missing; log tail:\n{log[-800:]}"
_assert_alive(s)
def test_standalone_headless_toplevel_tag_groups(tmp_path):
"""xdg-toplevel-tag-v1 grouping: with --tab-groups on (the default), a
late external client carrying the same tag as an existing tab's window
stacks onto that tab ([sub] stack) instead of opening its own."""
with _run_standalone({}, "%s --tag-set shared '#ff0000'" % WL) as s:
s.wait_for(r"\[tag\] app=0 tag=shared")
with _run_wl_client(s.socket, "--tag-set", "shared",
"#00ff00") as late:
s.wait_for(r"\[sub\] stack")
_assert_alive(s)
def test_standalone_headless_color_representation(tmp_path):
"""wp_color_representation_manager_v1: a probing client must learn the
supported alpha modes / coefficients and see done (the compositor
advertises what its renderer supports; wlroots validates values)."""
out = tmp_path / "color.out"
with _run_standalone(
{}, "%s --color-probe --color-out '%s' '#ff0000'" % (WL, out)) as s:
assert _wait_out(out, "done"), "wltest --color-probe never wrote its file"
text = out.read_text()
assert "done" in text, f"no done event; got:\n{text}"
assert "alpha=" in text, f"no alpha modes advertised; got:\n{text}"
# No liveness assert: the probe client exits on done, and with no
# tabs left the session tears itself down (like --globals-out).
def test_standalone_headless_primary_selection_roundtrip(tmp_path):
"""zwp_primary_selection_v1: a tab's primary selection must reach a
second tab. The device-manager global plus the request handler install
the source on the seat, which fans the offer out to the keyboard-focused
tab's device; wltest --primary-set installs a source (the first time a
seat-granted serial arrives) and --primary-demand pulls the "text/plain"
payload into a file."""
out = tmp_path / "primary.out"
with _run_standalone({}, "%s --primary-set 'hello' '#ff0000'" % WL,
"%s --primary-demand '%s' '#00ff00'" % (WL, out), wait="any_frame") as s:
_assert_alive(s)
# Active on boot is the last tab (the demander). Give the setter
# keyboard focus so it receives a seat-granted serial to set with --
# wlroots rejects a set_selection serial it never handed out.
if s.active() != 0:
s.cycle(-1)
assert s.wait_active_change(1) == 0
# Any key press delivers a serial to the focused setter, which
# installs the primary selection on the receipt.
s.tap(23) # 'i', harmless
# Cycle back so the demander is (re)focused; the focus change flushes
# the current primary selection to its device. No active-change assert
# here: the demander may exit as soon as it has written the file, which
# flips active back to the setter -- so just wait for the payload.
s.cycle(+1)
assert _wait_out(out), "primary-demand tab never received the selection"
assert out.read_text() == "hello", \
f"primary round-trip payload mismatch: {out.read_text()!r}"
def test_standalone_headless_text_input_globals(globals_session, tmp_path):
"""The standalone compositor advertises zwp_text_input_manager_v3 and
zwp_input_method_manager_v2 so in-session clients and IMEs can bind."""
names = _globals_of(globals_session, tmp_path)
for want in ("zwp_text_input_manager_v3",
"zwp_input_method_manager_v2"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def test_standalone_headless_text_input_roundtrip(tmp_path):
"""Text field -> IME -> field relay: a focused text-input tab enables its
field, the compositor activates the in-session IME stub, and the stub's
commit_string reaches the field (which logs it and exits)."""
ti_out = tmp_path / "ti.out"
im_out = tmp_path / "im.out"
with _run_standalone(
{},
"%s --input-method --im-commit 'nihongo' --im-out '%s'" % (WL, im_out),
"%s --text-input --text-input-out '%s' '#ff0000'" % (WL, ti_out), wait="any") as s:
# No wait_active_frame: the field client exits as soon as the commit
# lands, possibly before any dumped frame shows its color; the out
# files below are the synchronization instead.
_assert_alive(s)
assert _wait_out(ti_out), "text field never got the IME commit string"
assert ti_out.read_text() == "commit:nihongo\n", \
f"IME -> field payload mismatch: {ti_out.read_text()!r}"
assert im_out.exists(), "IME stub never saw activate/done"
im_lines = im_out.read_text().splitlines()
assert "active" in im_lines, \
f"IME stub never activated: {im_lines!r}"
assert "done" in im_lines, \
f"IME stub never got done: {im_lines!r}"
_assert_alive(s)
def test_standalone_headless_text_input_disable():
"""Disabling an enabled text field mid-session runs the compositor's
disable relay without disturbing the session."""
with _run_standalone(
{}, "%s --text-input '#ff0000'" % WL, wait="app0_mapped") as s:
s.tap(D)
time.sleep(1.0)
_assert_alive(s, "compositor died on text-input disable")
def test_standalone_headless_text_input_focus_follows_tab(tmp_path):
"""Cycling-free focus follow: when the focused field's tab closes, focus
hands to the left neighbor, which is entered and gets its own IME
commit (the IME also activates once per focused field)."""
ti0 = tmp_path / "ti0.out"
ti1 = tmp_path / "ti1.out"
im_out = tmp_path / "im.out"
with _run_standalone(
{},
"%s --input-method --im-commit 'kana' --im-out '%s'" % (WL, im_out),
"%s --text-input --text-input-out '%s' '#ff0000'" % (WL, ti0),
"%s --text-input --text-input-out '%s' '#00ff00'" % (WL, ti1), wait="any") as s:
# No wait_active_frame here either (see roundtrip test): both field
# clients exit on their commit, so the screen may be black (only the
# windowless IME tab left) before any dump runs.
_assert_alive(s)
# Boot focuses the last tab (ti1); it should get the first commit
# and then exit, handing focus to its left neighbor (ti0).
assert _wait_out(ti1), "focused field never got the IME commit string"
assert _wait_out(ti0), "focus-handed field never got the IME commit string"
assert ti0.read_text() == "commit:kana\n", \
f"IME -> field payload mismatch: {ti0.read_text()!r}"
assert im_out.exists(), "IME stub never saw activate/done"
n_active = im_out.read_text().splitlines().count("active")
assert n_active >= 2, \
f"IME should activate per focused field, got {n_active}x: " \
f"{im_out.read_text()!r}"
_assert_alive(s)
def test_standalone_headless_content_single_pixel_globals(globals_session,
tmp_path):
"""The standalone compositor advertises wp_content_type_manager_v1 and
wp_single_pixel_buffer_manager_v1 so clients can tag surfaces and use
1x1 buffers."""
names = _globals_of(globals_session, tmp_path)
for want in ("wp_content_type_manager_v1",
"wp_single_pixel_buffer_manager_v1"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def test_standalone_headless_single_pixel_renders(tmp_path):
"""A toplevel backed by a single-pixel buffer paints its color: the
compositor scales the 1x1 buffer to the maximized surface end to end."""
with _run_standalone({}, "%s --single-pixel '#ff0000'" % WL, wait="any") as s:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
path = s.dump_host()
if path:
w, h, px = s.ppm_rgb(path)
i = 3 * (h // 2 * w + w // 2)
if (px[i], px[i + 1], px[i + 2]) == (255, 0, 0):
break
time.sleep(0.2)
else:
pytest.fail("single-pixel tab never painted red at the centre")
_assert_alive(s)
def test_standalone_headless_content_type_logged(tmp_path):
"""A tab tagging its surface `--content-type video` maps normally and
the compositor's debug map line reports the type."""
with _run_standalone({}, "%s --content-type video '#ff0000'" % WL, wait="any") as s:
s.wait_for(r"\[diag\] map app=0 content=video")
_assert_alive(s)
def test_standalone_headless_gamma_globals(globals_session, tmp_path):
"""The standalone compositor advertises zwlr_gamma_control_manager_v1
so night-light tools can bind it."""
names = _globals_of(globals_session, tmp_path)
assert "zwlr_gamma_control_manager_v1" in names, \
f"missing gamma global; advertised: {sorted(names)}"
def test_standalone_headless_gamma_headless_fails_clean(tmp_path):
"""On the headless output (no gamma LUT) a gamma-control request is
cleanly failed by wlroots: the client learns `failed` and the
compositor stays up with no crash indicators."""
out = tmp_path / "gamma.out"
with _run_standalone(
{}, "%s --gamma --gamma-out '%s'" % (WL, out),
"%s '#ff0000'" % WL, wait="any") as s:
assert _wait_out(out), "gamma client never got a reply"
assert out.read_text() == "failed", \
f"expected headless gamma failure, got: {out.read_text()!r}"
_assert_alive(s)
_assert_no_crash(s.read_log())
def test_standalone_headless_dialog_globals(globals_session, tmp_path):
"""The standalone compositor advertises xdg_wm_dialog_v1 so clients
can mark toplevels modal."""
names = _globals_of(globals_session, tmp_path)
assert "xdg_wm_dialog_v1" in names, \
f"missing dialog global; advertised: {sorted(names)}"
def test_standalone_headless_modal_steals_tab(tmp_path):
"""A modal dialog mapping in a background tab takes its tab: the
dialog client opens its modal 1.5s after boot (past settle), and the
settled foreground tab must yield to the dialog's tab."""
with _run_standalone(
{},
"%s --extra '#00ff00' --extra-modal --extra-delay 1500 "
"--app-id dlg '#0000ff'" % WL,
"%s '#ff0000'" % WL) as s:
# Settle: under load either tab can hold the startup crown (last
# mapper promotes itself). The dialog arrives in tab 0 ~1.5s after
# boot and takes its tab, so wait for both base surfaces, then for
# the modal line, and assert the end state (dialog's tab owns focus)
# rather than a transition from an assumed crown.
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
s.wait_active_frame()
_assert_alive(s)
s.wait_for(r"\[dialog\] modal app=0\b")
s.wait_active(0)
_assert_alive(s)
def test_standalone_headless_modal_dialog_destroy_unwires():
"""Destroying a modal dialog's object mid-session unwires it without
disturbing the session: the compositor drops the modal wiring and the
window carries on as a normal tab."""
with _run_standalone(
{},
"%s --dialog-modal --app-id dlg '#0000ff'" % WL,
"%s '#ff0000'" % WL, wait="any") as s:
s.wait_for(r"\[dialog\] modal app=0\b")
# The modal steal focuses its tab (possibly before startup
# settles); the key must reach that client.
s.wait_active(0)
s.tap(G)
time.sleep(1.0)
_assert_alive(s, "compositor died on dialog destroy")
def test_standalone_headless_modal_tab_groups_off(tmp_path):
"""With --tab-groups off a background modal still takes its (own) tab:
it is glued after its parent and activated on map."""
with _run_standalone(
{}, "--tab-groups", "off",
"%s --extra '#00ff00' --extra-modal --extra-delay 1500 "
"--app-id dlg '#0000ff'" % WL,
"%s '#ff0000'" % WL) as s:
assert s.wait_any_active() == 1
s.wait_active_frame()
_assert_alive(s)
# The modal owns the tab glued right after its parent tab.
s.wait_for(r"\[dialog\] modal app=1\b")
_assert_alive(s)
def test_standalone_headless_tearing_async_applied(tmp_path):
"""A tab requesting async presentation drives the commit path: once
its surface maps with the hint, commits log the async transition."""
with _run_standalone({}, "%s --tearing async '#ff0000'" % WL, wait="any_frame") as s:
_assert_alive(s)
s.wait_for(r"\[tearing\] async app=0\b")
_assert_alive(s)
def test_standalone_headless_fractional_scale_notified(tmp_path):
"""A surface learns the output scale at claim: the client gets
preferred_scale 120 (= 1.0 on headless) without guessing."""
out = tmp_path / "fractional.out"
with _run_standalone(
{}, "%s --fractional-out '%s' '#ff0000'" % (WL, out),
"%s '#00ff00'" % WL, wait="any") as s:
assert _wait_out(out), "client never got preferred_scale"
assert out.read_text() == "120", \
f"expected scale 120 (= 1.0), got: {out.read_text()!r}"
_assert_alive(s)
def test_standalone_headless_toplevel_icon_globals(globals_session, tmp_path):
"""The standalone compositor advertises xdg_toplevel_icon_manager_v1
so clients can supply exact window artwork."""
names = _globals_of(globals_session, tmp_path)
assert "xdg_toplevel_icon_manager_v1" in names, \
f"missing icon global; advertised: {sorted(names)}"
def test_standalone_headless_toplevel_icon_preferred(tmp_path):
"""A client-supplied icon beats the desktop-file guess: the overlay
records the protocol texture (with its buffer size) for the tab."""
with _run_standalone(
{}, "%s --app-id icontest --icon '#00ff00' 8 '#ff0000'" % WL, wait="any") as s:
s.wait_for(r"\[overlay\] icon icontest proto 8x8\b")
_assert_alive(s)
def test_standalone_headless_output_mgmt_globals(globals_session, tmp_path):
"""The standalone compositor advertises zwlr_output_manager_v1 so
display tools can list and reconfigure the output."""
names = _globals_of(globals_session, tmp_path)
assert "zwlr_output_manager_v1" in names, \
f"missing output-mgmt global; advertised: {sorted(names)}"
def test_standalone_headless_output_mgmt_list(tmp_path):
"""A management client learns the head (name + live size); the plain
color tab keeps the session up after the lister exits."""
out = tmp_path / "om.out"
with _run_standalone(
{}, "%s --output-mgmt --output-mgmt-out '%s'" % (WL, out),
"%s '#ff0000'" % WL, wait="any") as s:
assert _wait_out(out), "output-mgmt lister never wrote its file"
assert re.search(r"^head \S+ \d+x\d+$", out.read_text(),
re.MULTILINE), \
f"no head line in: {out.read_text()!r}"
_assert_alive(s)
def test_standalone_headless_output_mgmt_test(tmp_path):
"""Probing 640x480 through output-management succeeds without
resizing: the client sees before/tested and the output keeps its
size."""
out = tmp_path / "om.out"
with _run_standalone(
{},
"%s --output-mgmt --output-mgmt-out '%s' --output-mgmt-size 640x480 "
"--output-mgmt-test" % (WL, out),
"%s '#ff0000'" % WL, wait="any") as s:
text = out.read_text() if _wait_out(out, "tested", timeout=20.0) else ""
assert "before " in text, f"no before line in: {text!r}"
assert "tested" in text, f"test not acked in: {text!r}"
assert "after " not in text, f"probe resized the output: {text!r}"
_assert_alive(s)
@pytest.mark.flaky
def test_standalone_headless_output_mgmt_apply(tmp_path):
"""Applying 800x600 through output-management resizes the session:
the client sees before/applied/after and a tab commits at 800x600."""
out = tmp_path / "om.out"
with _run_standalone(
{},
"%s --output-mgmt --output-mgmt-out '%s' --output-mgmt-size 800x600"
% (WL, out),
"%s '#ff0000'" % WL, wait="any") as s:
text = out.read_text() if _wait_out(out, "after ", timeout=30.0) else ""
assert "before " in text, f"no before line in: {text!r}"
assert "applied" in text, f"apply not acked in: {text!r}"
m = re.search(r"^after \S+ (\d+)x(\d+)$", text, re.MULTILINE)
assert m and (m.group(1), m.group(2)) == ("800", "600"), \
f"no 800x600 after line in: {text!r}"
# A live tab reflows into the new output size. Phase the waits:
# the compositor's own apply line proves the modeset landed, so the
# commit wait below covers only client reconfigure lag.
s.wait_for(r"\[output\] apply 800x600", timeout=30.0)
deadline = time.monotonic() + 30.0
geom = None
while time.monotonic() < deadline:
if re.search(r"\[diag\] commit app=\d+ geom=800x600",
s.read_log()):
geom = True
break
time.sleep(0.2)
assert geom, "no tab committed at 800x600"
_assert_alive(s)
def test_standalone_headless_gestures_globals(globals_session, tmp_path):
"""The standalone compositor advertises zwp_pointer_gestures_v1 so
touchpad clients can bind swipe/pinch/hold."""
names = _globals_of(globals_session, tmp_path)
assert "zwp_pointer_gestures_v1" in names, \
f"missing gestures global; advertised: {sorted(names)}"
def _gestures_text(log_path):
return log_path.read_text() if log_path.exists() else ""
def test_standalone_headless_gestures_roundtrip(tmp_path):
"""Swipe/pinch/hold injected through the test feed reach a gesture
client holding pointer focus, with exact fingers/deltas/scales."""
log = tmp_path / "gestures.log"
with _run_standalone(
{}, "%s --gesture-log '%s' '#ff0000'" % (WL, log), wait="any_frame") as s:
_assert_alive(s)
# Place the pointer (cursor-line quorum: the warp was processed, so
# pointer focus can route) before swiping: gestures only fan out to
# the focused client.
pos = len(s.read_log())
s.warp(400, 400)
s.wait_for_new_after(pos, r"\[cursor\] pos=400,400")
s.warp(600, 400)
s.wait_for_new_after(pos, r"\[cursor\] pos=600,400")
# Send once and wait for the forward line (offset): resending on a
# slow client duplicates sequences in its log. The client file poll
# afterwards covers only client-side write lag.
pos = len(s.read_log())
s.swipe(GESTURE_BEGIN, 3)
s.swipe(GESTURE_UPDATE, 3, 10, -5)
s.swipe(GESTURE_END, 3)
s.wait_for_new_after(pos, r"\[gesture\] swipe begin fingers=3")
s.wait_for_new_after(pos, r"\[gesture\] swipe end")
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
text = _gestures_text(log)
if "swipe begin fingers=3" in text \
and "swipe end cancelled=0" in text:
break
time.sleep(0.1)
text = _gestures_text(log)
assert "swipe begin fingers=3" in text, \
f"swipe never delivered:\n{text!r}"
assert "swipe update dx=10 dy=-5" in text, \
f"swipe deltas wrong:\n{text!r}"
assert "swipe end cancelled=0" in text, \
f"swipe end missing:\n{text!r}"
# Pinch, hold and every cancel path now that focus is held. Each
# sequence is acked at the forward line (offset); one file poll at
# the end covers client write lag for all of them.
pos = len(s.read_log())
s.pinch(GESTURE_BEGIN, 2)
s.pinch(GESTURE_UPDATE, 2, 1, 2, 1.5, 90.0)
s.pinch(GESTURE_END, 2)
s.wait_for_new_after(pos, r"\[gesture\] pinch begin fingers=2")
s.wait_for_new_after(pos, r"\[gesture\] pinch end")
s.hold(HOLD_BEGIN, 2)
s.hold(HOLD_END, 2)
s.wait_for_new_after(pos, r"\[gesture\] hold begin fingers=2")
s.wait_for_new_after(pos, r"\[gesture\] hold end")
s.swipe(GESTURE_BEGIN, 3)
s.swipe(GESTURE_CANCEL, 3)
s.wait_for_new_after(pos, r"\[gesture\] swipe cancelled")
s.pinch(GESTURE_BEGIN, 2)
s.pinch(GESTURE_CANCEL, 2)
s.wait_for_new_after(pos, r"\[gesture\] pinch cancelled")
s.hold(HOLD_BEGIN, 1)
s.hold(HOLD_CANCEL, 1)
s.wait_for_new_after(pos, r"\[gesture\] hold cancelled")
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
text = _gestures_text(log)
if text.count("cancelled=1") >= 3 \
and "hold end cancelled=0" in text \
and "pinch begin fingers=2" in text:
break
time.sleep(0.1)
text = _gestures_text(log)
assert "pinch begin fingers=2" in text, \
f"pinch never delivered:\n{text!r}"
assert "pinch update dx=1 dy=2 scale=1.5 rotation=90" in text, \
f"pinch values wrong:\n{text!r}"
assert "pinch end cancelled=0" in text, \
f"pinch end missing:\n{text!r}"
assert "hold begin fingers=2" in text, \
f"hold never delivered:\n{text!r}"
assert "hold end cancelled=0" in text, \
f"hold end missing:\n{text!r}"
assert text.count("cancelled=1") >= 3, \
f"cancel paths missing:\n{text!r}"
_assert_alive(s)
def test_nested_primary_selection_bridged(tmp_path):
"""Nested mode: a tab's primary selection is fanned out to a second tab
exactly as standalone, and the host-side bridge publishes it over
ext-data-control (when the host implements it) without echo-looping."""
_require_host_wayland()
out = tmp_path / "primary.out"
with _run_nested("%s --primary-set 'hello' '#ff0000'" % WL,
"%s --primary-demand-expect '%s' hello '#00ff00'"
% (WL, out), wait="any_frame") as s:
_assert_alive(s)
if s.active() != 0:
s.cycle(-1)
assert s.wait_active_change(1) == 0
s.tap(23) # 'i', harmless: grants the focused setter a serial
s.cycle(+1)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
_assert_alive(s, "compositor exited mid-test")
if out.exists():
break
time.sleep(0.1)
assert out.exists(), "nested primary-demand tab never received it"
assert out.read_text() == "hello", \
f"nested primary round-trip mismatch: {out.read_text()!r}"
log = s.read_log()
# The bridge must have run without looping: when the host advertises
# data-control v2 the publish is logged once (a loop would recur).
if "primary selection -> host" in log:
assert log.count("primary selection -> host") == 1, \
"primary bridge echoed/looked repeatedly:\n%s" % log[-500:]
_assert_alive(s)
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
def test_nested_host_primary_to_kick(tmp_path):
"""A host primary selection crosses the data-control bridge into kick
(regression: host->nested primary paste used to hang the terminal waiting
for EOF, and to never redraw). The demander waits for the *expected
payload* because a live host may already hold its own primary selection at
boot, which kick faithfully mirrors first.
Skipped on hosts without ext-data-control (e.g. GNOME/mutter): the
host->nested primary mirror only exists on that backend, so this test
needs an ext-data-control host -- run it on a compositor like labwc/sway."""
_require_host_wayland()
token = f"kick-h2n-{os.getpid()}-{time.monotonic_ns()}"
out = tmp_path / "primary.out"
with _run_nested("%s --primary-demand-expect '%s' '%s' '#00ff00'"
% (WL, out, token),
f"{WL} '#ff0000'", wait="any_frame") as s:
_assert_alive(s)
# The host->nested primary bridge only exists over ext-data-control
# (a focus-based wl_data_device host has no primary channel at
# all), which is decided at bridge_init before any client maps.
s.wait_for(r"clipboard bridge:")
if "clipboard bridge: host data-control backend" not in s.read_log():
pytest.skip("host compositor lacks ext-data-control; "
"host->nested primary selection is not bridged here")
# Primary offers only reach the *focused* tab (wlroots delivers
# device selection events to the focused client), so crown the
# demand-expect tab (tab 0) before the host writes its primary.
if s.active() != 0:
s.cycle(-1)
assert s.wait_active_change(1) == 0
copy = subprocess.Popen(
["wl-copy", "-f", "--primary", token],
env=dict(os.environ),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
try:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
_assert_alive(s, "compositor exited mid-test")
if out.exists() and out.read_text() == token:
break
time.sleep(0.1)
assert out.exists(), \
"nested demand-expect tab never received the host primary"
assert out.read_text() == token, \
f"host primary payload mismatch: {out.read_text()!r}"
finally:
copy.terminate()
copy.wait(timeout=5)
_assert_alive(s)
def test_dc_bridge_clipboard_host_to_inner(tmp_path):
"""Kick-in-kick: the outer standalone compositor hosts data-control, so
the inner session runs the data-control bridge backend. An outer
clipboard offer crosses into the inner session (offer/mime/prefetch
paths) and pastes in an inner tab.
Both ends go through wltest's ext-data-control modes: stock
wl-clipboard cannot drive a kick socket (legacy zwlr_data_control
only), so wl-copy/wl-paste are reserved for the real-host side of the
suite."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, "%s --app-id keep '#0000ff'" % WL, log=str(inner_log))
try:
inner.wait_any_active()
inner.wait_surface_mapped(0)
inner.wait_for(r"clipboard bridge:")
assert "clipboard bridge: host data-control backend" in \
inner.read_log(), \
f"inner bridge missed the outer data-control host:\n" \
f"{inner.read_log()[-500:]}"
token = f"kick-dc-h2i-{os.getpid()}-{time.monotonic_ns()}"
out = tmp_path / "h2i.out"
copy = _dc_setter(outer.socket, token)
try:
_poll_dc_demand(inner.socket, out, token)
finally:
copy.terminate()
copy.wait(timeout=5)
_assert_alive(inner, "inner died on bridged paste")
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
def test_dc_bridge_clipboard_inner_to_host(tmp_path):
"""Kick-in-kick the other way: an inner clipboard offer is published on
the outer seat over data-control, and an outer paste pulls it straight
through the inner source (the source-send path)."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, "%s --app-id keep '#0000ff'" % WL, log=str(inner_log))
try:
inner.wait_any_active()
inner.wait_surface_mapped(0)
inner.wait_for(r"clipboard bridge: host data-control backend")
token = f"kick-dc-i2h-{os.getpid()}-{time.monotonic_ns()}"
out = tmp_path / "i2h.out"
stolen_out = tmp_path / "stolen.out"
copy = _dc_setter(inner.socket, token)
try:
_poll_dc_demand(outer.socket, out, token)
# The outer side steals the selection back: the bridge's
# published source is cancelled without disturbing either
# session.
steal = _dc_setter(outer.socket, "stolen")
try:
_poll_dc_demand(outer.socket, stolen_out, "stolen")
finally:
steal.terminate()
steal.wait(timeout=5)
finally:
copy.terminate()
copy.wait(timeout=5)
_assert_alive(inner, "inner died serving the outer pull")
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
def test_dc_bridge_primary_roundtrip(tmp_path):
"""Kick-in-kick primary selection over the data-control backend: an
inner tab's primary reaches an outer paste (through the inner primary
source), an outer primary reaches an inner demander (mirror path), and
clearing either side propagates."""
out = tmp_path / "primary.out"
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer,
"%s --primary-set 'hello' '#ff0000'" % WL,
"%s --primary-demand-expect '%s' world '#00ff00'" % (WL, out),
log=str(inner_log))
inner_copy = None
outer_copy = None
try:
inner.wait_any_active()
inner.wait_active_frame()
inner.wait_for(r"clipboard bridge: host data-control backend")
_assert_alive(inner)
# The setter needs an input serial: tap a harmless key at it.
if inner.active() != 0:
inner.cycle(-1)
assert inner.wait_active_change(1) == 0
inner.tap(23) # 'i'
# Inner primary -> outer paste, through the inner source. The
# outer paste goes through ext-data-control: wl-paste --primary
# never receives offers without focus, like its clipboard twin.
outer_prim_out = tmp_path / "outer-prim.out"
_poll_dc_demand(outer.socket, outer_prim_out, "hello",
primary=True)
# Outer primary -> inner demander (mirror path): primary
# offers only reach the focused tab, so crown the demander.
inner.cycle(+1)
assert inner.wait_active_change(0) == 1
outer_copy = _dc_setter(outer.socket, "world", primary=True)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
rc = inner.proc.poll()
assert rc is None, \
f"inner exited mid-test (rc={rc}):\n{inner.read_log()[-500:]}"
if out.exists() and out.read_text() == "world":
break
time.sleep(0.1)
assert out.exists() and out.read_text() == "world", \
"inner demander never received the outer primary"
# Clearing a live inner selection propagates to the host:
# clipboard first (select, then clear), then primary.
clip = _dc_setter(inner.socket, "clr")
try:
clr_out = tmp_path / "clr.out"
_poll_dc_demand(inner.socket, clr_out, "clr")
clear = subprocess.Popen(
[WL, "--dc-clear", RED],
env=dict(os.environ, WAYLAND_DISPLAY=inner.socket),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
clear.wait(timeout=10.0)
assert clear.returncode == 0, \
f"inner selection clear failed: rc={clear.returncode}"
finally:
try:
clip.kill()
except Exception:
pass
_assert_alive(inner, "inner died on selection clear")
_assert_alive(outer)
finally:
for p in (outer_copy,):
try:
if p is not None:
p.kill()
except Exception:
pass
inner.close()
finally:
outer.close()
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
@pytest.mark.flaky
def test_clipboard_host_steal_cancels_nested_source(session):
"""The host takes the selection while a nested source is mirrored out:
the bridge's host-side source is cancelled (the offering wl-copy exits)
and the session survives."""
token = f"kick-cancel-{os.getpid()}-{time.monotonic_ns()}"
lock = _host_clipboard_locked()
try:
copy = _dc_setter(session.socket, token)
try:
session.wait_any_active()
# The mirror is live once the host pastes what the tab offered.
_poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token)
steal = subprocess.Popen(
["wl-copy", "-f", "stolen"],
env=dict(os.environ),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
try:
# The data-control setter exits once its source is cancelled
# (like an offering wl-copy): the wait proves the bridge let
# go of it.
copy.wait(timeout=10.0)
assert copy.returncode == 0, \
f"cancelled setter should exit 0, rc={copy.returncode}"
finally:
steal.terminate()
steal.wait(timeout=5)
_assert_alive(session, "compositor died on source cancel")
finally:
try:
copy.kill()
except Exception:
pass
finally:
lock.close()
def test_standalone_headless_cursor_theme_none_boots(tmp_path):
"""No theme requested and no cursor theme installed: the builtin arrow is
used silently (a request named literally 'default' is indistinguishable
from the builtin), and there must be no warning and no failure."""
empty = tmp_path / "empty-cursors"
empty.mkdir()
with _run_standalone({"XCURSOR_PATH": str(empty)},
"build/wltest '#ff0000'") as s:
s.wait_app_active(0)
_assert_alive(s)
log = s.read_log()
assert "builtin cursor in use" not in log, \
f"no warning expected for a 'default' request:\n{log[-400:]}"
def test_standalone_headless_cursor_theme_installed_no_warning():
"""An installed theme must be used with no builtin-substitution warning.
Uses the host's Adwaita theme as a real installed example."""
if not os.path.exists("/usr/share/icons/Adwaita/cursors"):
pytest.skip("host has no Adwaita cursor theme")
with _run_standalone({"XCURSOR_THEME": "Adwaita"},
"build/wltest '#ff0000'") as s:
s.wait_app_active(0)
_assert_alive(s)
log = s.read_log()
assert "builtin cursor in use" not in log, \
f"installed theme must not be substituted:\n{log[-400:]}"
def test_standalone_cursor_shape_request():
"""A client that asks for the 'text' cursor (e.g. galculator over its input
field) has it applied to the compositor cursor: the request arrives via
cursor-shape-v1 and wlroots sets the theme cursor accordingly."""
with _run_standalone({}, "build/wltest --cursor-shape text '#ff0000'") as s:
s.wait_app_active(0)
s.pointer_enter_until(r"\[cursor\] shape text")
def test_standalone_cursor_shape_resets_on_tab_switch():
"""The per-tab cursor shape is cached and the switch to another tab resets
the compositor cursor to the default before its own shape is applied."""
with _run_standalone({}, "build/wltest '#0000ff'",
"build/wltest --cursor-shape text '#ff0000'") as s:
s.wait_app_active(1)
s.pointer_enter_until(r"\[cursor\] shape text")
baselines = s.read_log().count("[cursor] shape default")
s.cycle(1) # tab 1 -> tab 0 (plain client)
shapes = []
since = time.monotonic()
while time.monotonic() - since < 15.0:
shapes = re.findall(r"\[cursor\] shape \w+", s.read_log())
if (shapes[-1] == "[cursor] shape default"
and s.read_log().count("[cursor] shape default") > baselines):
return
time.sleep(0.1)
pytest.fail(f"shape not reset to default on tab switch: {shapes}")
def test_nested_cursor_shape_forwarded(tmp_path):
"""In nested mode a client's cursor-shape request is forwarded to the host
pointer. The host is a private outer kick (not the live desktop), so no
real pointer or host cursor-shape support is needed: warps enter
deterministically and the forward line proves the request went out."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, "build/wltest '#0000ff'",
"build/wltest --cursor-shape text '#ff0000'",
log=str(inner_log))
try:
inner.wait_app_active(1)
inner.wait_surface_mapped(1)
# Deterministic enter (replaces pointer_enter_until's re-warp
# loop, which exists for races with surface readiness): the
# surface is mapped, so one nested warp enters it and the
# client requests its shape. (Nested warps log no [cursor]
# line -- no wlr_cursor there -- so wait for the forward line
# itself.)
inner.warp(400, 400)
inner.wait_for(r"\[cursor\] host shape text", timeout=30.0)
_assert_alive(inner)
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
def test_standalone_headless_last_tab_exits_cleanly(standalone_session):
"""Closing the final tab exits standalone with rc 0 instead of crashing
on the NULL host-window proxies (which only exist in nested mode)."""
s = standalone_session
_assert_alive(s, "standalone exited early")
s.wait_any_active()
for pid in s.launch_pids().values():
os.kill(pid, signal.SIGTERM)
rc = s.proc.wait(timeout=10)
assert rc == 0, f"rc={rc} (139 = SIGSEGV); log tail:\n{s.read_log()[-800:]}"
def test_standalone_headless_screen_mode_sets_output_size():
"""--screen-mode WxH sizes the output; on the headless backend it becomes
the add_output() size, observable through the dumped frame dimensions."""
with _run_standalone({}, "--screen-mode", "480x320", "build/wltest '#ff0000'") as s:
s.wait_app_active(0)
_assert_alive(s)
w, h, _ = s.ppm_rgb(s.dump_host())
assert (w, h) == (480, 320), \
f"output should honour --screen-mode 480x320, got {w}x{h}"
def test_standalone_headless_screen_mode_not_consumed_as_app():
"""--screen-mode is stripped from argv, so it must not become a tab
launcher (no 'starts with -' warning) while both real apps still boot."""
with _run_standalone({}, "--screen-mode", "640x480",
"build/wltest '#ff0000'", "build/wltest '#0000ff'", wait="any") as s:
_assert_alive(s)
assert "starts with '-'" not in s.read_log(), \
f"--screen-mode leaked into the app list:\n{s.read_log()[-400:]}"
def test_standalone_headless_screen_mode_bad_value_exits(tmp_path):
"""A malformed --screen-mode value is a startup error (rc 2) naming the
problem, before any window or backend work."""
env = dict(os.environ)
for case in (["--screen-mode"], ["--screen-mode=abc"], ["--screen-mode", "1280"]):
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"stdbuf", "-oL",
BIN] + case,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
out, _ = proc.communicate(timeout=15)
assert proc.returncode == 2, \
f"--screen-mode {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--screen-mode" in out, \
f"error should mention --screen-mode:\n{out}"
def test_standalone_headless_overlay_font_initialized(standalone_session):
"""Standalone builds the overlay glyph atlas with the wlr renderer, so tab
titles are drawn next to their icons. Regressed when overlay_font_init()
only ever ran via the nested init_egl() path."""
if _pick_font() is None:
pytest.skip("no usable font on this machine")
s = standalone_session
_assert_alive(s, "standalone exited early")
s.wait_any_active()
assert "[overlay] font " in s.read_log(), (
"no standalone font atlas; overlay shows icons without text:\n"
f"{s.read_log()[-800:]}")
def test_standalone_headless_overlay_no_needless_ellipsis(standalone_session):
"""Titles that fit their overlay row whole must not be shrunk to "..." —
a row's text budget is its title's natural width, so forcing "..." space
chopped the last characters (e.g. "foot" -> "fo...")."""
if _pick_font() is None:
pytest.skip("no usable font on this machine")
s = standalone_session
s.wait_any_active()
s.mods(ALT)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
w, h, px = s.ppm_rgb(s.dump_host())
if _has_dark(w, h, px):
break
assert _has_dark(w, h, px), "standalone overlay box not visible while Alt"
log = s.read_log()
# Before a tab's window maps, its row shows the *command line* (the long
# absolute launcher path) instead of a window title; that banner is
# genuinely longer than the row, so ellipsizing it is correct. The
# regression this guards is a short window title being "..."-chopped, so
# only an ellipsis of an actual (short, fitting) title is a failure.
banners = {f"{WL} '{c}'" for c in (RED, BLUE)}
ellipsized = re.findall(r"\[overlay\] ellipsis: '(.+)' budget=", log)
off_titles = [src for src in ellipsized if src not in banners]
assert not off_titles, (
"overlay ellipsized a title that fit its row:\n"
f"{off_titles}\n{log[-800:]}")
s.mods(0)
# -- clipboard bridge (GNOME/Mutter focus-based wl_data_device backend) -----
def _run_display(display, args, timeout=6, **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=timeout,
preexec_fn=_preexec_death(os.getpid()), **kw)
def _host_clipboard_locked():
"""Exclusive cross-worker lock for the shared live-host clipboard.
Autopsied failure: parallel clipboard tests steal each other's host
selections (wl-paste returns another test's token), so the loser's
poll fails after 45s no matter the timeout. Serializing just these
sections across xdist workers removes the cross-talk; genuine host
slowness still fails honestly inside the (generous) polls, which is
why these tests stay marked flaky. Returns the lock file; close it
to release (use try/finally)."""
f = open("/tmp/kick-host-clipboard.lock", "w")
fcntl.flock(f, fcntl.LOCK_EX)
return f
def _poll_paste(display, want, deadline=45.0, extra=()):
"""Wait until `wl-paste` on `display` returns exactly `want`
(extra args, e.g. ("--primary",), select the selection).
Callers driving the shared live-host clipboard must hold
_host_clipboard_locked() for the whole set-then-paste sequence:
parallel workers otherwise steal each other's selections and the
loser's token never appears no matter how long the poll runs."""
r = None
since = time.monotonic()
while time.monotonic() - since < deadline:
try:
r = _run_display(display, ["wl-paste", "-n", *extra])
except subprocess.TimeoutExpired:
# A single wl-paste can stall while the host clipboard daemon is
# sluggish; that's "not there yet", not a failure.
continue
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}"
if r is not None else "every poll timed out"))
def _dc_setter(sock, token, primary=False):
"""Spawn a wltest data-control setter against socket `sock`.
Stock wl-clipboard cannot drive a kick socket: it only speaks the
legacy zwlr_data_control protocol kick no longer offers (removed in
wlroots 0.20), and its wl_data_device fallback needs a focus serial a
surfaceless tool never receives (offers go to the focused client
only). wltest's ext-data-control modes need neither focus nor serial.
Returns the Popen; the caller terminates it. Like wl-copy -f, the
setter exits 0 once another client steals the selection."""
flag = "--dc-primary-set" if primary else "--clipboard-set"
return subprocess.Popen(
[WL, flag, token, RED],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
def _poll_dc_demand(sock, out_file, token, primary=False, deadline=45.0):
"""Wait until a wltest data-control demand-expect client on `sock`
observes `token` (written to `out_file` on match). The wl-clipboard
counterpart of _poll_paste for kick sockets, for the same reason as
_dc_setter: wl-paste never receives offers without focus."""
flag = ("--dc-primary-demand-expect" if primary
else "--clipboard-demand-expect")
out_file = os.fspath(out_file)
try:
os.unlink(out_file)
except FileNotFoundError:
pass
proc = subprocess.Popen(
[WL, flag, out_file, token, RED],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
try:
since = time.monotonic()
while time.monotonic() - since < deadline:
try:
with open(out_file) as f:
if f.read() == token:
return
except OSError:
pass
rc = proc.poll()
if rc is not None:
if rc == 0:
try:
with open(out_file) as f:
if f.read() == token:
return
except OSError:
pass
raise AssertionError(
f"wltest {flag} on {sock} exited rc={rc} without "
f"observing {token!r}")
time.sleep(0.2)
raise AssertionError(
f"wltest {flag} on {sock} never observed {token!r}")
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
@pytest.mark.flaky
def test_clipboard_nested_to_host(session):
token = f"kick-n2h-{os.getpid()}-{time.monotonic_ns()}"
lock = _host_clipboard_locked()
try:
copy = _dc_setter(session.socket, token)
try:
_poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token)
finally:
copy.terminate()
copy.wait(timeout=5)
finally:
lock.close()
@pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed")
@pytest.mark.flaky
def test_clipboard_host_to_nested(session, tmp_path):
token = f"kick-h2n-{os.getpid()}-{time.monotonic_ns()}"
lock = _host_clipboard_locked()
try:
copy = subprocess.Popen(
["wl-copy", "-f", token],
env=dict(os.environ),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()))
try:
out = tmp_path / "h2n.out"
_poll_dc_demand(session.socket, out, token)
finally:
copy.terminate()
copy.wait(timeout=5)
finally:
lock.close()
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_function_keys_pass_through(session):
"""F9/F10/F11 are not grabbed by the compositor: they reach the focused
client, so tapping them must not toggle fullscreen or trigger dumps."""
start = session.wait_any_active()
base_active = session.active_count()
# Quorum, not sleep: once the compositor consumed each key, a grab
# would already have acted.
for code in (F11, 67, 68): # F11, F9, F10
session.tap_ack(code)
log = session.read_log()
assert "host fullscreen=" not in log, "function key toggled fullscreen"
assert "[dump] scene" not in log, "function key triggered a scene dump"
assert "[dump] host" not in log, "function key triggered a host dump"
assert session.active_count() == base_active, "function key cycled tabs"
_assert_alive(session)
def test_client_fullscreen_round_trip(tmp_path):
"""A client's fullscreen request is honored and mirrored onto the host
window; the host configure then propagates fullscreen to every tab.
wltest toggles xdg_toplevel fullscreen on the 'F' key, so tap it and
watch both directions of the mirror. The host is a private outer kick
(not the live desktop), so configure roundtrips are deterministic."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, "%s '#ff0000'" % WL, "%s '#00ff00'" % WL,
log=str(inner_log))
try:
idx = inner.wait_any_active()
inner.wait_surface_mapped(idx)
# Tapping F before seat keyboard focus reaches the surface drops
# the key silently; map alone does not guarantee enter under load.
inner.wait_for(r"\[diag\] kbd-enter app=%d\b" % idx)
inner.tap_ack(F)
inner.wait_for("host fullscreen=1", timeout=30.0)
inner.wait_for("clients fullscreen=1")
inner.tap_ack(F)
inner.wait_for("host fullscreen=0", timeout=30.0)
inner.wait_for("clients fullscreen=0")
_assert_alive(inner)
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
def test_alt_f11_toggles_host_fullscreen(tmp_path):
"""prefix+F11 toggles the kick window's fullscreen state in the host;
the resulting configure propagates to every tab exactly like a
host-compositor-initiated fullscreen. Tapping plain F11 (no prefix)
must still pass through to the client untouched. The host is a private
outer kick, so configure roundtrips are deterministic."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, "%s '#ff0000'" % WL, "%s '#00ff00'" % WL,
log=str(inner_log))
try:
idx = inner.wait_any_active()
inner.wait_surface_mapped(idx)
inner.chord_ack(ALT, F11)
inner.wait_for("host fullscreen=1", timeout=30.0)
inner.wait_for("clients fullscreen=1")
inner.chord_ack(ALT, F11)
inner.wait_for("host fullscreen=0", timeout=30.0)
inner.wait_for("clients fullscreen=0")
_assert_alive(inner)
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
def test_alt_f11_propagates_to_stack_members(tmp_path):
"""prefix+F11's host fullscreen reaches not just each tab's main window
but every stacked tab-group member too, and leaving fullscreen clears
both again. The host is a private outer kick, so configure roundtrips
are deterministic."""
outer_log = tmp_path / "outer.log"
inner_log = tmp_path / "inner.log"
outer = _spawn_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, log=str(outer_log))
try:
outer.wait_any_active()
inner = _spawn_nested_in(
outer, f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'",
extra_args=("--tab-groups", "on"), log=str(inner_log))
try:
idx = inner.wait_any_active()
inner.wait_surface_mapped(idx)
inner.wait_for(r"\[sub\] stack app=0\b")
inner.chord_ack(ALT, F11)
inner.wait_for("host fullscreen=1", timeout=30.0)
inner.wait_for("clients fullscreen=1")
inner.wait_for("members fullscreen=1")
inner.chord_ack(ALT, F11)
inner.wait_for("host fullscreen=0", timeout=30.0)
inner.wait_for("clients fullscreen=0")
inner.wait_for("members fullscreen=0")
_assert_alive(inner)
_assert_alive(outer)
finally:
inner.close()
finally:
outer.close()
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}"
def test_cycle_wraps(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
session.cycle(-1)
assert session.wait_active_change(start) == other
session.cycle(-1)
assert session.wait_active_change(other) == start
def test_hold_alt_down_repeats():
# This test asserts wall-clock repeat behavior itself, so it opts out
# of the suite-wide stretched repeat delay (repeat_off=False) with a
# private session; it stays marked flaky (starved timers repeat late).
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{BLUE}'",
repeat_off=False) as session:
start = session.wait_any_active()
assert start in (0, 1)
# Event-driven, no wall-clock rate assertion: arm the hold, wait for
# the timer to arm ([repeat] start), wait for ONE synthesized repeat
# (active count grows past the press's own immediate cycle), release,
# and wait for the disarm ([repeat] stop). After disarm no timer
# exists to fire, so the count is asserted immediately -- no silence
# window. Generous timeouts bound hangs only; a slow box just takes
# longer, it never fails a rate.
session.mods(ALT)
base = session.active_count()
pos = len(session.read_log())
session.key(DOWN, KEY_PRESSED)
session.wait_for_new_after(pos, r"\[repeat\] start key=%d" % DOWN,
timeout=30.0)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline and session.active_count() - base < 2:
time.sleep(0.1)
if session.active_count() - base < 2:
pytest.fail("held Alt+Down never repeated (timer armed but "
"no synthesized cycle followed)")
session.key(DOWN, KEY_RELEASED)
session.mods(0)
session.wait_for_new_after(pos, r"\[repeat\] stop", timeout=30.0)
settled = session.active_count()
assert settled >= base + 2, \
f"held Alt+Down produced only {settled - base} transitions"
assert session.active_count() == settled, "repeats continued after release"
def test_alt_holds_tab_overlay(session):
"""Holding Alt shows the tab-title overlay (a centered box darkening the
content); releasing Alt hides it again. The overlay is drawn into the
host frame in the same blit that produces the SIGUSR1 dump, so a dark
box pixel appears in the centered window while Alt is held and reverts
after release."""
session.wait_any_active()
session.wait_active_frame()
def has_dark(w, h, px):
for y in range(h // 4, 3 * h // 4):
row = y * w * 3
for x in range(w // 4, 3 * w // 4):
i = row + x * 3
if px[i] < 40 and px[i + 1] < 40 and px[i + 2] < 40:
return True
return False
w, h, px = session.ppm_rgb(session.dump_host())
assert not has_dark(w, h, px), "content already dark before Alt"
session.mods(ALT)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
w, h, px = session.ppm_rgb(session.dump_host())
if has_dark(w, h, px):
break
assert has_dark(w, h, px), "overlay box not visible while Alt is held"
session.mods(0)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
w, h, px = session.ppm_rgb(session.dump_host())
if not has_dark(w, h, px):
break
assert not has_dark(w, h, px), "overlay did not hide on Alt release"
_assert_alive(session)
def test_non_ctrl_key_ignored(session):
start = session.wait_any_active()
assert start in (0, 1)
base_active = session.active_count()
session.tap_ack(30) # 'A' press+release, consumed quorum
assert session.active_count() == base_active, "'A' without ctrl cycled tabs"
assert "host fullscreen=" not in session.read_log(), "'A' toggled fullscreen"
_assert_alive(session)
session.tap_ack(F11)
assert "host fullscreen=" not in session.read_log(), "F11 toggled fullscreen"
_assert_alive(session)
def test_active_switch_on_client_exit(session):
start = session.wait_any_active()
assert start in (0, 1)
pids = session.launch_pids()
other = 1 - start
# Kill the active tab: the compositor must fall through to the other.
# [claim] pid is the connected client (wltest); SIGTERM closes its
# Wayland socket, which is what makes the compositor destroy the tab.
os.kill(pids[start], signal.SIGTERM)
assert session.wait_active_change(start) == other, \
"active did not fall through to the other tab after its tab died"
_assert_alive(session, "compositor exited after one tab died")
# Kill the last tab: the compositor should exit cleanly on its own.
os.kill(pids[other], signal.SIGTERM)
session.proc.wait(timeout=5)
assert session.proc.returncode == 0, \
f"compositor exited with {session.proc.returncode} after last tab closed"
def test_tab_death_activates_left_neighbor(session):
"""With three tabs [0,1,2], killing index 2 must activate its left
neighbor (index 1), not the leftmost tab; killing the first tab (index 0)
must fall through to index 1 rather than wrapping around to the right."""
# Ensure the tab at index 1 is active BEFORE the third client connects,
# so the anchored new tab lands at index 2 (not stealing slot 1).
# Settle the startup crown first: under load the last mapper promotes
# itself, so a transient wait_any_active read can lie about where the
# crown will settle.
session.wait_any_active()
session.wait_surface_mapped(0)
session.wait_surface_mapped(1)
if session.active() != 1:
session.cycle(+1)
session.wait_active(1)
# Both fixture tabs must have claimed slots 0 and 1 already, otherwise a
# slot steal reorders things and the "index 2" assertion below lies.
pids = session.launch_pids()
with _run_wl_client(session.socket, GREEN,
start_new_session=True) as third:
# The new tab is anchored after the active tab (index 1): claim app=2.
session.wait_for(r"\[claim\] app=2 pid=(\d+)")
pid2 = int(re.search(r"\[claim\] app=2 pid=(\d+)",
session.read_log()).group(1))
pid0 = pids[0]
# The externally-spawned client is born hidden (no map-autofocus
# anymore), so cycle to it: active is 1 (the crown), +1 reaches 2.
session.cycle(+1)
session.wait_active(2)
# Rightmost tab dies: fall to its left neighbor (index 1), not 0.
os.kill(pid2, signal.SIGTERM)
assert session.wait_active_change(2) == 1, \
"active did not fall to index 1 (left neighbor) after index 2 died"
_assert_alive(session, "compositor exited after middle tab died")
# First tab dies: no left neighbor, fall right to index 1 (no wrap).
session.cycle(-1)
session.wait_active(0)
os.kill(pid0, signal.SIGTERM)
assert session.wait_active_change(0) == 1, \
"active did not fall to index 1 after first tab died"
_assert_alive(session, "compositor exited after first tab died")
def _nested_session(*tabs, extra_args=(), repeat_off=True):
"""Launch a nested compositor with an arbitrary tab list (each tab is a
shell command string, as on the CLI). extra_args are passed on the CLI
before the tabs (e.g. --modifier super). The `session` fixture exists for
the common two-tab case; tests needing a specific tab lineup use this.
repeat_off stretches the keybind repeat delay past any load-stalled tap
so discrete chords never catch a wall-clock repeat (see KICK_REPEAT_OFF);
the hold-to-repeat test opts out with repeat_off=False.
A spawn whose client dies before opening any window (load-starved client
startup: "command exited without opening a window" + "no tabs left") is
retried up to 3 times -- no test asserts boot failure, so this masks no
real signal."""
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
for _ in range(3):
log = _fresh_log()
dump_dir = _mk_dump_dir()
proc = subprocess.Popen(
["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1",
"KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + (KICK_REPEAT_TEST_DELAY if repeat_off
else KICK_REPEAT_DEFAULT),
"stdbuf", "-oL",
BIN, *extra_args] + list(tabs),
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if proc.poll() is not None:
break
if "[diag] map app=" in s.read_log():
break
time.sleep(0.1)
if proc.poll() is None or "without opening a window" not in s.read_log():
return Session(proc, log, dump_dir)
s.close()
return Session(proc, log, dump_dir)
def test_alt_shift_t_becomes_active(session):
"""Alt+Shift+T's fresh terminal still becomes the active tab on map
(the add_tab exception to the no-steal rule), while other new tabs never
steal focus."""
old_term = os.environ.get("TERMINAL")
os.environ["TERMINAL"] = f"{WL} '#ffff00'"
try:
session.wait_any_active()
# Quiesce boot churn first: a late-mapping tab activating after the
# snapshot would count as an extra transition below.
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
before = session.active_count()
session.chord(ALT | SHIFT, 20)
deadline = time.monotonic() + 15.0
while session.active_count() <= before:
_assert_alive(session, "compositor exited after Alt+Shift+T")
assert time.monotonic() < deadline, \
"Alt+Shift+T's terminal never became active"
time.sleep(0.1)
assert session.active_count() == before + 1, \
"Alt+Shift+T should produce exactly one focus transition"
_assert_alive(session)
finally:
if old_term is None:
os.environ.pop("TERMINAL", None)
else:
os.environ["TERMINAL"] = old_term
def test_sub_window_hidden_when_parent_not_active():
"""--tab-groups off: a second toplevel from an already-running client gets
its own slot glued right after the parent tab, and because the parent is
not the tab being shown it must NOT steal focus (no churn -- the crowned
tab stays active)."""
_require_host_wayland()
# Tab 0 (RED) opens a green extra toplevel from the same client.
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'",
extra_args=("--tab-groups", "off"), wait="any") as s:
p0 = s.launch_pids()[0]
# The extra claims the slot right after tab 0 (app=1) with the same
# client pid as RED. BLUE can also claim app=1 (before the extra
# inserts and shifts it), so match app=1 by pid on the extra.
deadline = time.monotonic() + 15.0
extra_claimed = False
while time.monotonic() < deadline:
for a, p in re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log()):
if a == "1" and int(p) == p0:
extra_claimed = True
break
if extra_claimed:
break
_assert_alive(s, "compositor exited while extra mapped")
time.sleep(0.1)
assert extra_claimed, \
"sub-window never claimed a slot right of its parent"
# The extra must never activate while its tab is hidden. Crucially,
# an extra whose PARENT tab is shown activates BY DESIGN (the
# parent-shown exception), so the crown must first be moved to BLUE
# (slot 2 post-shift): any `active app=1` after that point is a
# genuine steal. Quiescence (all three first-maps done, crown on
# BLUE) means no promotion can follow; poll briefly afterwards for
# file-flush cover only, since the map-time decision is synchronous.
# Map-slot disambiguation: RED is the only `map app=0`; BLUE logged
# `map app=2`, or `map app=1` before the extra's claim (pre-shift);
# the extra's own map is `map app=1` after its claim.
ci = s.read_log().find("[claim] app=1 pid=%d" % p0)
map0_rx = re.compile(r"\[diag\] map app=0\b")
map1_rx = re.compile(r"\[diag\] map app=1\b")
map2_rx = re.compile(r"\[diag\] map app=2\b")
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
log = s.read_log()
red_mapped = map0_rx.search(log) is not None
# BLUE logged `map app=2`, or `map app=1` strictly BEFORE the
# extra's claim (pre-shift); a `map app=1` after ci is the
# EXTRA's own map and must not satisfy BLUE.
m1 = map1_rx.search(log)
blue_mapped = (map2_rx.search(log) is not None or
(m1 is not None and ci >= 0 and m1.start() < ci))
extra_mapped = (ci >= 0 and map1_rx.search(log, ci) is not None)
if red_mapped and blue_mapped and extra_mapped:
break
_assert_alive(s, "compositor exited while settling")
time.sleep(0.1)
else:
pytest.fail("tabs never all mapped")
for _ in range(6):
if s.active() == 2:
break
# BLUE may still be black (unmapped): a black crown logs
# `[diag] active slot=` instead of `active app=`, so accept
# either line as proof of arrival. Each chord steps exactly
# one tab, so re-read the crown and step again; only fail when
# a chord produces no change at all.
pos = len(s.read_log())
before = s.active()
s.chord_ack(ALT, DOWN)
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
now = s.active()
if now == 2 or "[diag] active slot=2" in s.read_log()[pos:]:
break
if now != before:
break # stepped elsewhere; re-read and step again
time.sleep(0.1)
else:
pytest.fail("cycle produced no crown change")
assert s.active() == 2 or \
"[diag] active slot=2" in s.read_log(), \
"could not crown BLUE's tab"
pos = len(s.read_log())
steal_rx = re.compile(r"active app=1\b")
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
assert not steal_rx.search(s.read_log()[pos:]), \
"sub-window stole focus from the crowned tab"
time.sleep(0.1)
_assert_alive(s)
def test_sub_window_activates_when_parent_active():
"""--tab-groups off: a related window whose parent tab is the one being
shown becomes the active tab on map (the second exception to the
no-steal rule)."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "off")) as s:
s.wait_any_active() # the single tab is crowned, then ...
# ... its sub-window activates on map. Assert the end state, not a
# particular line: depending on timing the arrival can promote via
# set_active_tab (`active app=1`) or land quietly with keyboard focus
# (`[sub] focus`) -- both leave tab 1 active.
s.wait_for(r"\[diag\] map app=1\b")
s.wait_active(1)
_assert_alive(s)
def test_sub_windows_stack_mode_never_claims_a_slot():
"""--tab-groups on: a second toplevel from an already-running client
rides on its owner's tab -- it logs [sub] stack, never claims a slot of
its own, and takes the keyboard only because its parent tab is the one
being shown."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on")) as s:
s.wait_any_active() # app 0 crowned on its first map
s.wait_for(r"\[sub\] stack app=0\b")
s.wait_for(r"\[sub\] focus app=0\b")
# Quorum, not sleep: the keyboard landing on the extra (a kbd-enter
# AFTER its focus line) proves the arrival was fully processed -- any
# focus steal or slot claim the arrival could cause is already logged.
# Afterwards no active transition may occur at all (single tab).
focus_rx = re.compile(r"\[sub\] focus app=0\b")
enter_rx = re.compile(r"\[diag\] kbd-enter app=0")
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
log = s.read_log()
fi = focus_rx.search(log)
ki = enter_rx.search(log, fi.end()) if fi else None
if ki:
tail = log[ki.end():]
assert not ACTIVE_RE.search(tail), \
"stacked sub-window stole focus via an active transition"
claims = CLAIM_RE.findall(log)
assert len(claims) == 1 and claims[0][0] == "0", \
f"stacked sub-window claimed a slot: {claims}"
break
_assert_alive(s, "compositor exited mid-settle")
time.sleep(0.1)
else:
pytest.fail("keyboard never landed on the stacked extra")
_assert_alive(s)
def test_sub_windows_stack_mode_focus_follows_tab():
"""--tab-groups on: a stacked window of a background tab maps
without taking the keyboard; showing its tab hands focus to it."""
_require_host_wayland()
# Tab 0 (RED) opens a green extra; tab 1 (BLUE) is the startup-selected
# active tab, so the extra must map hidden.
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'",
extra_args=("--tab-groups", "on")) as s:
# Settle: the extra must map while its tab is hidden, so tab 1 has
# to hold the settled crown (under load either tab can win it).
# NOTE: wait_surface_mapped(0) is deliberately NOT used: `map app=0`
# is ambiguous (RED's map and the extra's map share the slot), and a
# match on the extra's map would settle nothing. Instead rely on
# causality: the stack line proves the extra's window was created,
# which the client only does after RED mapped (commit->configure->
# extra opens it), so RED's promotion is consumed by then; wait for
# BLUE's map explicitly. The extra can still map in the gap between
# the stack line and the completed nav (client is fast, Python is
# slow), taking focus while tab 0 is transiently shown -- so the
# absence below is anchored AFTER quiescence, not at the map line.
s.wait_any_active()
s.wait_surface_mapped(1)
s.wait_for(r"\[sub\] stack app=0\b")
if s.active() != 1:
s.chord_ack(ALT, UP)
s.wait_active(1)
# Quiescence: RED done (causality), BLUE mapped (waited), crown on 1
# (nav). No first-map remains, so no promotion can follow; the
# extra's map (whenever it lands) takes the hidden path.
pos = len(s.read_log())
stack_pos = s.read_log().find("[sub] stack app=0")
map_rx = re.compile(r"\[diag\] map app=0\b")
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if map_rx.search(s.read_log(), stack_pos):
break
time.sleep(0.1)
else:
pytest.fail("stacked extra never mapped after its stack line")
# Any focus taken before pos belonged to the transient pre-nav
# window; after pos the tab is hidden for good, so no focus may
# follow. Poll briefly (file-flush cover only -- the map decision
# was synchronous, long past).
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
assert "[sub] focus app=0" not in s.read_log()[pos:], \
"hidden tab's stacked window took the keyboard"
time.sleep(0.1)
s.cycle_ack(-1) # show tab 0
s.wait_for(r"\[sub\] focus app=0\b")
_assert_alive(s)
def test_sub_windows_stack_mode_unstack_refocuses_then_close():
"""--tab-groups on: closing the stacked window ('X' in wltest)
refocuses the tab's own surface without killing the tab; Alt+Delete on
the tab then tears the whole unit down."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on"), wait="any") as s:
s.wait_for(r"\[sub\] focus app=0\b") # extra holds the keyboard
s.tap_ack(45) # 'X': close the extra
s.wait_for(r"\[sub\] unstack app=0\b")
s.wait_for(r"\[sub\] refocus app=0\b")
claims = CLAIM_RE.findall(s.read_log())
assert len(claims) == 1, f"tab lost or gained slots: {claims}"
_assert_alive(s, "compositor died when the extra closed")
s.chord_ack(ALT, DELETE) # close the tab itself
deadline = time.monotonic() + 10.0
while s.proc.poll() is None:
assert time.monotonic() < deadline, \
"Alt+Delete never tore the tab with stacked windows down"
time.sleep(0.1)
def test_sub_windows_stack_mode_row_title_follows_top():
"""--tab-groups on: the overlay row shows the topmost stacked
window's title while it is mapped, and falls back to the tab's own
surface title when it closes ('X' in wltest)."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on"), wait="any") as s:
s.wait_for(r"\[sub\] focus app=0\b")
# Row titles are emitted at the data source (title change/set), so
# the extra's title must be resolvable without holding Alt open.
s.wait_for(r"\[title\] app=0 'extra'")
s.tap_ack(45) # 'X': close the extra
s.wait_for(r"\[title\] app=0 '#ff0000'")
_assert_alive(s)
def test_alt_delete_closes_active_window_not_tab():
"""--tab-groups on: Alt+Delete on a tab that holds a stacked window closes
only that window -- the tab stays up (the compositor stays alive, no new
slot is claimed) and the stacked member unstacks instead of the tab being
torn down."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on"), wait="any") as s:
# [sub] stack logs when the member is *created*, before its Wayland
# surface has mapped. Alt+Delete sent too early sees only the tab's
# base window and closes the whole tab; the member taking keyboard
# ([sub] focus, from its map path) proves it mapped and is a closable
# individual by the time the chord lands.
s.wait_for(r"\[sub\] stack app=0\b")
s.wait_for(r"\[sub\] focus app=0\b")
claims_before = CLAIM_RE.findall(s.read_log())
s.chord_ack(ALT, DELETE) # close the active window
s.wait_for(r"\[sub\] unstack app=0\b") # only the window closed
# Quorum, not sleep: refocus proves the tab survived the close.
s.wait_for(r"\[sub\] refocus app=0\b")
_assert_alive(s, "Alt+Delete tore the whole tab down instead of just the window")
claims_after = CLAIM_RE.findall(s.read_log())
assert claims_before == claims_after, \
f"Alt+Delete changed the tab set: {claims_before} -> {claims_after}"
def _wait_exit(s, code, timeout=10.0):
"""Wait for the compositor process to exit, asserting its status."""
since = time.monotonic()
while time.monotonic() - since < timeout:
rc = s.proc.poll()
if rc is not None:
assert rc == code, \
f"compositor exited with {rc}, expected {code}"
return
time.sleep(0.1)
pytest.fail("compositor never exited (expected returncode "
f"{code}) after quit")
def test_quit_confirm_multi_window():
"""Ctrl+Alt+Q with more than one window open raises the confirm modal
instead of quitting; Y/Enter confirms and quits cleanly with 0."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", wait="any") as s:
s.wait_for(r"\[claim\] app=0\b")
s.wait_for(r"\[claim\] app=1\b")
s.chord(CTRL | ALT, Q)
s.wait_for(r"\[confirm\] quit: 2 windows open ")
_assert_alive(s, "Ctrl+Alt+Q quit behind the confirm modal")
s.tap(Y) # confirm
s.wait_for(r"kick: quit confirmed\b")
_wait_exit(s, 0)
assert "kick: quit cancelled" not in s.read_log()
def test_quit_confirm_dialog_renders():
"""The confirm-quit modal actually draws: with the question pending, a
frame dump shows the dark dialog box over the client content."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", wait="any") as s:
s.wait_for(r"\[claim\] app=0\b")
s.wait_for(r"\[claim\] app=1\b")
s.chord(CTRL | ALT, Q)
s.wait_for(r"\[confirm\] quit: 2 windows open ")
w, h, px = s.ppm_rgb(s.dump_host())
assert _has_dark(w, h, px), \
"confirm-quit dialog not visible in the composed frame"
s.tap(N) # keep running
s.wait_for(r"kick: quit cancelled\b")
_assert_alive(s, "compositor exited after cancelling quit")
def test_quit_cancel_keeps_running():
"""Ctrl+Alt+Q confirmation can be declined: N and Escape both dismiss it,
the compositor keeps running, and a later quit request re-asks."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", wait="any") as s:
s.wait_for(r"\[claim\] app=1\b")
s.chord(CTRL | ALT, Q)
s.wait_for(r"\[confirm\] quit: 2 windows open ")
s.tap(N) # decline
s.wait_for(r"kick: quit cancelled")
_assert_alive(s, "N after Ctrl+Alt+Q quit anyway")
s.chord(CTRL | ALT, Q) # re-ask, decline again via Escape
s.wait_for(r"\[confirm\] quit: 2 windows open ")
s.tap(ESC)
s.wait_for(r"kick: quit cancelled")
_assert_alive(s, "Escape after Ctrl+Alt+Q quit anyway")
assert s.cycle_ack(+1) is not None, "tab cycling broke after cancel"
_assert_alive(s, "tab cycling broke after cancelled quit")
def test_quit_single_window_no_confirm():
"""With a single window there is nothing to lose, so Ctrl+Alt+Q quits
immediately -- no confirm modal, clean exit 0."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", wait="any") as s:
s.wait_for(r"\[claim\] app=0\b")
# Keys sent before the surface maps are silently dropped: the claim
# precedes the map, so wait for the map before the quit chord.
s.wait_surface_mapped(0)
s.chord_ack(CTRL | ALT, Q)
_wait_exit(s, 0)
assert "[confirm] quit:" not in s.read_log(), \
"single-window quit unnecessarily asked for confirmation"
def test_alt_left_right_cycle_tab_group_members():
"""--tab-groups on: Alt+Left / Alt+Right cycle the active tab's stacked
windows (Left = previous, Right = next) and the tab stays up; the chord is
consumed even with no stack. With a main surface (RED) plus a stacked
extra (GREEN), the newest window (extra) is shown first; Alt+Left steps
back to the main, Alt+Right forward to the extra again."""
_require_host_wayland()
with _run_nested(f"{WL} --extra '{GREEN}' '{RED}'",
extra_args=("--tab-groups", "on"), wait="any") as s:
s.wait_for(r"\[sub\] stack app=0\b")
s.mods(ALT)
try:
# The newest stacked window (extra) is shown first.
s.wait_for(r"\[title\] app=0 'extra'")
s.tap_ack(LEFT) # Alt+Left = backward -> main (RED)
s.wait_for(r"\[title\] app=0 '#ff0000'")
s.tap_ack(RIGHT) # Alt+Right = forward -> extra again
s.wait_for(r"\[title\] app=0 'extra'")
finally:
s.mods(0)
_assert_alive(s, "Alt+Left/Right tore the tab down")
def test_alt_delete_activates_right_neighbor():
"""Alt+Delete closes the active tab, making the tab to its right active
(SIGTERM, escalated to SIGKILL only if the client lingers). With three
tabs [0,1,2] and 1 active, closing 1 must activate 2, not 0."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'") as s:
start = s.wait_any_active()
assert start in (0, 1, 2)
# Ensure the third tab has claimed a slot so index 2 really exists.
s.wait_for(r"\[claim\] app=2 pid=(\d+)")
# Make tab 1 active so a right neighbor exists.
for _ in range(3):
if s.active() == 1:
break
if s.cycle_ack(+1) == 1:
break
if s.proc.poll() is not None:
pytest.fail("compositor exited while cycling to tab 1")
s.wait_active(1)
s.chord(ALT, DELETE)
assert s.wait_active_change(1) == 2, \
"Alt+Delete did not activate the right neighbor"
_assert_alive(s, "compositor exited after Alt+Delete")
s.wait_for(r"kick: closing tab 1")
def test_alt_delete_rightmost_falls_left():
"""Closing the rightmost tab has no right neighbor to hand active off to,
so it falls back to the left neighbor (index 1 of [0,1,2])."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'", wait="any") as s:
# Ensure the third tab has claimed a slot so index 2 really exists.
s.wait_for(r"\[claim\] app=2 pid=(\d+)")
while s.active() != 2:
if s.cycle_ack(+1) == 2:
break
if s.proc.poll() is not None:
pytest.fail("compositor exited while cycling to tab 2")
s.wait_active(2)
s.chord(ALT, DELETE)
assert s.wait_active_change(2) == 1, \
"rightmost Alt+Delete did not fall back to the left neighbor"
_assert_alive(s, "compositor exited after closing rightmost tab")
def test_alt_delete_second_tab_right_neighbor_two_tabs():
"""Two-tab session: closing tab 0 makes its right neighbor (1) active."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{BLUE}'") as s:
# Settle the startup crown (last mapper promotes itself under load),
# then navigate to tab 0 deterministically before closing it.
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
if s.active() != 0:
s.chord_ack(ALT, UP)
s.wait_active(0)
s.chord_ack(ALT, DELETE)
assert s.wait_active_change(0) == 1, \
"closing tab 0 did not activate tab 1"
_assert_alive(s, "compositor exited after closing tab 0")
def test_alt_shift_arrows_reorder_and_wrap():
"""Alt+Shift+Up/Down move the active tab within the overlay list without
switching the active surface; moving off an end wraps to the other end and
the whole list shifts (a rightmost tab moved off the end lands first with
every other tab pushed one place, not swapped into place). The resulting
order is provable behaviorally: an Alt+Shift+T terminal claims the slot
right after the active tab's new position."""
_require_host_wayland()
old_term = os.environ.get("TERMINAL")
os.environ["TERMINAL"] = f"{WL} '{RED}'"
try:
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'",
f"{WL} '{BLUE}'") as s:
# Settle the startup crown (last mapper promotes itself under
# load), then make the third tab (index 2) active so there is a
# middle and an end to move through. Navigate by settled reads:
# at most two cycles reach tab 2 from anywhere.
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
s.wait_surface_mapped(2)
for _ in range(3):
if s.active() == 2:
break
prev = s.active()
s.chord_ack(ALT, DOWN)
if prev is not None:
s.wait_active_change(prev)
s.wait_active(2)
before = s.active_count()
# Adjacent move UP: [R,G,B] -> [R,B,G].
s.chord_ack(ALT | SHIFT, UP)
s.wait_for(r"\[reorder\] app=1 dir=-1")
# Adjacent move DOWN: -> [R,G,B].
s.chord_ack(ALT | SHIFT, DOWN)
s.wait_for(r"\[reorder\] app=2 dir=1")
# Move DOWN off the end: wraps to the front and rotates the rest,
# [R,G,B] -> [B,R,G] (B first, R and G shifted, not swapped).
s.chord_ack(ALT | SHIFT, DOWN)
s.wait_for(r"\[reorder\] app=0 dir=1")
# No sleep: the chord is consumed and repeats are stretched past
# any load stall, so no async transition source remains; a stray
# active line would already be logged.
assert s.active() == 2, "reorder must not switch the active tab"
assert s.active_count() == before, \
"reorder must not emit an active-tab transition"
# With the active tab wrapped to slot 0, a new Alt+Shift+T tab
# is anchored right after it: the newest claim must be app=1.
claims = re.findall(r"\[claim\] app=(\d+) pid=(\d+)", s.read_log())
s.chord_ack(ALT | SHIFT, 20)
deadline = time.monotonic() + 15.0
while len(re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log())) <= len(claims):
_assert_alive(s, "compositor exited after reorder + new tab")
assert time.monotonic() < deadline, \
"Alt+Shift+T never claimed a slot"
time.sleep(0.1)
newest = re.findall(r"\[claim\] app=(\d+) pid=(\d+)",
s.read_log())[-1]
assert newest[0] == "1", \
f"new tab claimed slot {newest[0]}, expected 1 (after B->front)"
finally:
if old_term is None:
os.environ.pop("TERMINAL", None)
else:
os.environ["TERMINAL"] = old_term
def test_modifier_super_flag():
"""--modifier super moves the tab-management chords to Super/Mod4: cycle,
reorder and close all work, while the Alt chords stop doing anything."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{BLUE}'",
extra_args=["--modifier", "super"]) as s:
# Settle: under load either tab can hold the startup crown (last
# mapper promotes itself); read the settled crown, not a transient.
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
start = s.active()
_assert_alive(s, "compositor exited with --modifier super")
# Super+Down cycles; a plain Alt+Down must not.
s.chord_ack(SUPER, DOWN)
other = s.wait_active_change(start, timeout=15.0)
assert other == 1 - start, "Super+Down did not cycle the other tab"
s.chord_ack(ALT, DOWN)
# Quorum, not sleep: once the compositor consumed the Alt+Down, the
# absence of a cycle is conclusive (a late cycle would already show).
assert s.active() == other, \
"Alt+Down still cycled in --modifier super mode"
# Super+Shift+Up reorders the active tab.
s.chord_ack(SUPER | SHIFT, UP)
s.wait_for(r"\[reorder\] app=\d+ dir=-1")
# Super+Delete closes the active tab: the compositor logs the close
# and hands active off (a NEW active-tab line appears). Can't use
# wait_active_change here: reorder changed the active tab's slot
# without logging one, so the post-close index can match the
# pre-close value even though the tab underneath changed.
count = s.active_count()
s.chord_ack(SUPER, DELETE)
s.wait_for(r"kick: closing tab")
deadline = time.monotonic() + 5.0
while s.active_count() <= count:
_assert_alive(s, "compositor exited after Super+Delete")
assert time.monotonic() < deadline, \
"active never handed off after Super+Delete"
time.sleep(0.1)
def test_alt_delete_escalates_sigkill(tmp_path):
"""A client that traps SIGTERM must still be reaped by the SIGKILL
escalation, so Alt+Delete never strands a lingering tab."""
_require_host_wayland()
# A wrapper that ignores SIGTERM, then execs the test client so the
# tab's process group carries SIG_IGN for TERM across the exec.
wrapper = tmp_path / "trap-term.sh"
wrapper.write_text(
"trap '' TERM\nexec '{}' '{}'\n".format(WL, GREEN))
wrapper.chmod(0o755)
with _run_nested(f"{WL} '{RED}'", str(wrapper)) as s:
pids = s.launch_pids()
assert 1 in pids, "second tab never claimed a slot"
trap_pid = pids[1]
# Wait for the startup active-tab lines to settle (both clients map
# asynchronously and toggle `active app=` as they land), otherwise
# s.active() can return a transient value and the Alt+Delete below
# closes the wrong tab.
s.wait_any_active()
# The trap tab is the second; bring it active and close it.
while s.active() != 1:
if s.cycle_ack(+1) == 1:
break
if s.proc.poll() is not None:
pytest.fail("compositor exited while cycling to the trap tab")
s.wait_active(1)
s.chord(ALT, DELETE)
deadline = time.monotonic() + 5.0
alive = True
while time.monotonic() < deadline:
try:
os.kill(trap_pid, 0)
time.sleep(0.1)
except ProcessLookupError:
alive = False
break
assert not alive, "SIGTERM-trapping tab survived Alt+Delete (no SIGKILL escalation)"
# It must have moved active away from the closing tab, and stayed up.
assert s.active() != 1, "active still on the closing trap tab"
_assert_alive(s, "compositor exited after closing trap tab")
def test_plain_delete_not_grabbed(session):
"""Plain Delete (no Alt) must not close the active tab."""
session.wait_any_active()
base_active = session.active()
session.tap_ack(DELETE)
# Quorum, not sleep: once consumed, a grab would already have closed it.
assert session.active() == base_active, \
"Delete without Alt closed the active tab"
_assert_alive(session)
def test_standalone_headless_alt_delete_closes_tab(standalone_session):
"""Alt+Delete routes through the shared hotkey path in standalone mode:
the active tab closes and the neighbor becomes active, then closing the
last tab exits cleanly."""
s = standalone_session
start = s.wait_any_active()
other = 1 - start
s.chord(ALT, DELETE)
assert s.wait_active_change(start) == other, \
"standalone Alt+Delete did not activate the other tab"
_assert_alive(s, "standalone compositor exited mid-test")
# Close the final tab: the compositor should exit cleanly on its own.
s.chord(ALT, DELETE)
s.proc.wait(timeout=5)
assert s.proc.returncode == 0, \
f"standalone compositor exited with {s.proc.returncode} on last tab close"
def test_sigusr1_dumps(session):
scene_rx = re.compile(r"\[dump\] scene")
host_rx = re.compile(r"\[dump\] host back buffer -> (\S+\.ppm)")
# Warm the dump machinery so both baselines exist, then assert SIGUSR1
# produces a *new* scene dump attempt and a *new* host ppm.
session.dump_host()
session.wait_for(r"\[dump\] scene")
scene_before = len(scene_rx.findall(session.read_log()))
host_before = len(host_rx.findall(session.read_log()))
os.kill(session.proc.pid, signal.SIGUSR1)
since = time.monotonic()
while time.monotonic() - since < 5.0:
log = session.read_log()
if len(scene_rx.findall(log)) > scene_before and \
len(host_rx.findall(log)) > host_before:
return
time.sleep(0.1)
pytest.fail("SIGUSR1 produced no new scene+host dump lines")
def test_dash_h_prints_usage():
""" `-h` is the way to see usage: it prints the sectioned help and exits 0
without starting a session. """
r = subprocess.run([BIN, "-h"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()))
assert r.returncode == 0
assert b"Usage:" in r.stdout
assert b"Options:" in r.stdout
assert b"Keys (hold the modifier):" in r.stdout
assert b"--tab-groups on|off" in r.stdout
def test_no_args_opens_terminal():
"""With no command-line tabs, a single terminal tab opens and maps."""
_require_host_wayland()
for path in (BIN, WL):
if not os.access(path, os.X_OK):
pytest.skip(f"build first: {path}")
log = _fresh_log()
dump_dir = _mk_dump_dir()
# The one tab is the terminal named by TERMINAL (a wltest, so no real
# terminal emulator has to be installed on the test host).
# Retry instant boot death like _nested_session: under parallel load the
# client can die before opening any window.
s = None
for _ in range(3):
log = _fresh_log()
dump_dir = _mk_dump_dir()
proc = subprocess.Popen(
["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1",
f"TERMINAL={WL} '{RED}'", "KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY,
"stdbuf", "-oL", BIN],
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if proc.poll() is not None:
break
if "[diag] map app=" in s.read_log():
break
time.sleep(0.1)
if proc.poll() is None or \
"without opening a window" not in s.read_log():
break
s.close()
try:
# Boot under parallel load can stall the TERMINAL spawn; the wait
# returns immediately when healthy.
s.wait_for(r"\[diag\] map app=0", timeout=30.0)
finally:
s.close()
def test_no_args_missing_terminal_fails():
"""A default terminal that is not on PATH aborts before any window."""
r = subprocess.run(
["env", "TERMINAL=/nonexistent/tw-term", BIN],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
preexec_fn=_preexec_death(os.getpid()))
assert r.returncode != 0
assert b"not found in PATH" in (r.stdout + r.stderr)
def test_missing_arg_fails():
"""A command-line tab whose program is not on PATH aborts up front."""
r = subprocess.run(
[BIN, "definitely-no-such-cmd-xyz"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
preexec_fn=_preexec_death(os.getpid()))
assert r.returncode != 0
assert b"definitely-no-such-cmd-xyz" in (r.stdout + r.stderr)
assert b"not found in PATH" in (r.stdout + r.stderr)
def test_close_black_tab_active_last():
"""Alt+Delete on a black tab (its command has not connected yet) closes
it outright: the surviving tab becomes active, the black slot is removed
(closed tabs leave no hole), and closing the survivor exits cleanly."""
_require_host_wayland()
# CLI #0 (RED) maps hidden; the active tab is the still-black `sleep`.
with _run_nested(f"{WL} '{RED}'", "sleep 999") as s:
s.wait_for(r"\[diag\] map app=0")
# RED's late first map promotes it (last-mapper-wins crown under
# load): navigate back to the black tab before closing it. A black
# crown logs `[diag] active slot=` (no `active app=` line), so wait
# for that line, not wait_active.
if s.active() != 1:
pos = len(s.read_log())
s.chord_ack(ALT, DOWN)
s.wait_for_new_after(pos, r"\[diag\] active slot=1")
s.chord_ack(ALT, DELETE)
s.wait_for(r"kick: closing tab 1")
# The black tab is gone; the RED survivor becomes active in its place.
s.wait_for(r"active app=0")
_assert_alive(s, "compositor exited after closing the black tab")
# Closing the last live tab tears the session down cleanly.
m = re.search(r"\[claim\] app=0 pid=(\d+)", s.read_log())
assert m, "RED tab never claimed a slot"
os.kill(int(m.group(1)), signal.SIGTERM)
s.proc.wait(timeout=5)
assert s.proc.returncode == 0, \
f"compositor exited with {s.proc.returncode} after last tab closed"
def test_start_cmd_index_selects_command():
"""--start-cmd-index N (1-based) starts focused on command N instead of
the default last command: index 1 makes the first command the active,
black tab from the very first frame, revealed when it maps."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'",
extra_args=["--start-cmd-index", "1"]) as s:
assert s.wait_any_active() == 0, \
"--start-cmd-index 1 should make the first command active"
_assert_alive(s)
def test_start_cmd_index_errors():
"""A malformed or out-of-range --start-cmd-index is a startup error (rc 2)
naming the flag, before any window or backend work. The index is 1-based,
so 0 is invalid and a value beyond the command count is out of range."""
env = dict(os.environ)
cases = (["--start-cmd-index"],
["--start-cmd-index=abc"],
["--start-cmd-index", "0"],
["--start-cmd-index", "4", "build/wltest '#ff0000'",
"build/wltest '#00ff00'", "build/wltest '#0000ff'"])
for case in cases:
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"stdbuf", "-oL",
BIN] + case,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
out, _ = proc.communicate(timeout=15)
assert proc.returncode == 2, \
f"--start-cmd-index {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--start-cmd-index" in out, \
f"error should mention --start-cmd-index:\n{out}"
def test_sub_windows_flag_errors():
"""A missing or bad --tab-groups value is a startup error (rc 2) naming
the flag, before any window or backend work."""
env = dict(os.environ)
cases = (["--tab-groups"],
["--tab-groups", "float"],
["--tab-groups=float"])
for case in cases:
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"stdbuf", "-oL",
BIN] + case,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
out, _ = proc.communicate(timeout=15)
assert proc.returncode == 2, \
f"--tab-groups {case} should exit rc 2; rc={proc.returncode}\n{out}"
assert b"--tab-groups" in out, \
f"error should mention --tab-groups:\n{out}"
def test_dead_command_explains_and_exits():
"""A sole command that exits before opening a window is dropped with a
plain-stderr explanation (command + exit status) and a clean exit(0) --
never silence. Regression: bare `xmessage` exits 1 right after opening
the display, which used to end the session with no word from kick."""
if not os.access(BIN, os.X_OK):
pytest.skip("build first")
with _run_kick(["/bin/false"]) as s:
# Boot + reap under load can stall past a fixed proc.wait: wait for
# the exit line first (event), then reap with a generous timeout.
s.wait_for(r"no tabs left to show, exiting", timeout=30.0)
s.proc.wait(timeout=15)
assert s.proc.returncode == 0, \
f"expected clean exit(0), got {s.proc.returncode}"
log = s.read_log()
assert "command exited without opening a window" in log
assert "exit code 1" in log
assert "no tabs left to show, exiting" in log
# ===================== XWayland (X11 tabs) =====================
# X11 client used for XWayland tests: xmessage pops a plain window that
# accepts our full-output configure (no fixed size hints), so it fills
# the tab and renders a non-black frame.
XMSG = shutil.which("xmessage")
HAVE_XWAYLAND = shutil.which("Xwayland") is not None and XMSG is not None
def _spawn_kick_standalone(commands):
"""Standalone headless kick with an arbitrary per-tab command list.
Same environment discipline as standalone_session: no WAYLAND_DISPLAY
leak, headless backend forced, KICK_LOG=2 so client stderr (and any
echo tricks) land in the shared log.
"""
env = dict(os.environ)
log = _fresh_log()
dump_dir = _mk_dump_dir()
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=headless",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or ""),
"XCURSOR_THEME=Adwaita",
"KICK_DUMP_DIR=" + dump_dir,
"KICK_REPEAT_DELAY_MS=" + KICK_REPEAT_TEST_DELAY,
"stdbuf", "-oL",
BIN] + list(commands),
stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
return Session(proc, log, dump_dir)
@contextlib.contextmanager
def _run_standalone(extra_env, *apps, log=None, wait=None):
"""_spawn_standalone as a context manager: closes on exit.
wait folds the standard boot preambles: "any" (wait_any_active),
"any_frame" (+ wait_active_frame), "app0" (wait_app_active(0) +
wait_active_frame) or "app0_mapped" (wait_app_active(0) +
wait_surface_mapped(0)).
"""
s = _spawn_standalone(extra_env, *apps, log=log)
try:
if wait == "any":
s.wait_any_active()
elif wait == "any_frame":
s.wait_any_active()
s.wait_active_frame()
elif wait == "app0":
s.wait_app_active(0)
s.wait_active_frame()
elif wait == "app0_mapped":
s.wait_app_active(0)
s.wait_surface_mapped(0)
yield s
finally:
s.close()
@contextlib.contextmanager
def _run_nested(*tabs, extra_args=(), repeat_off=True, wait=None):
"""_nested_session as a context manager: closes on exit.
wait folds the same boot preambles as _run_standalone (usually just
"any": nested bodies then wait for their own claim lines).
"""
s = _nested_session(*tabs, extra_args=extra_args, repeat_off=repeat_off)
try:
if wait == "any":
s.wait_any_active()
elif wait == "any_frame":
s.wait_any_active()
s.wait_active_frame()
elif wait == "app0":
s.wait_app_active(0)
s.wait_active_frame()
elif wait == "app0_mapped":
s.wait_app_active(0)
s.wait_surface_mapped(0)
yield s
finally:
s.close()
@contextlib.contextmanager
def _run_wl_client(sock, *args, **kw):
"""Throwaway wltest client on a session socket; reaped on exit.
Extra Popen kwargs (e.g. start_new_session=True) pass through. The
exit cleanup is exactly what every call site's old finally did:
terminate, then wait out the reap.
"""
proc = subprocess.Popen(
[WL, *args],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=_preexec_death(os.getpid()), **kw)
try:
yield proc
finally:
proc.terminate()
proc.wait(timeout=5)
@contextlib.contextmanager
def _run_kick(commands, wait=None):
"""_spawn_kick_standalone as a context manager: closes on exit.
wait folds the same boot preambles as _run_standalone.
"""
s = _spawn_kick_standalone(commands)
try:
if wait == "any":
s.wait_any_active()
elif wait == "any_frame":
s.wait_any_active()
s.wait_active_frame()
elif wait == "app0":
s.wait_app_active(0)
s.wait_active_frame()
elif wait == "app0_mapped":
s.wait_app_active(0)
s.wait_surface_mapped(0)
yield s
finally:
s.close()
@pytest.fixture(scope="function")
def xwayland_session():
"""Mixed session: tab 0 is an X11 client (xmessage), tab 1 is Wayland.
The compositor starts focused on the *last* command, so the session
boots on the Wayland tab and Alt+Down wraps to the X11 one.
"""
_require_xwayland()
s = _spawn_kick_standalone([f"{XMSG} hi", f"{WL} '{RED}'"])
try:
yield s
finally:
s.close()
@pytest.mark.flaky
def test_xwayland_ready_and_claim(xwayland_session):
"""XWayland comes up in lazy mode, and the X11 command's window is
claimed by its own process group into tab slot 0."""
s = xwayland_session
_assert_alive(s, "kick exited early")
# Lazy start still assigns the display name synchronously at create
# time; the ready line proves the X server actually spawned.
s.wait_for(r"\[xwayland\] ready display=:(\d+)")
m = re.search(r"\[xwayland\] ready display=:(\d+)", s.read_log())
assert int(m.group(1)) > 0, "Xwayland display number must be positive"
pids = {}
since = time.monotonic()
while time.monotonic() - since < 15.0:
d = dict((int(a), int(p)) for a, p in CLAIM_RE.findall(s.read_log()))
if {0, 1}.issubset(d):
pids = d
break
time.sleep(0.1)
else:
pytest.fail(f"both tabs never claimed:\n{s.read_log()[-800:]}")
# The X11 claim must be xmessage itself (its pid), not some unrelated
# process -- i.e. pgid matching picked the right group.
with open(f"/proc/{pids[0]}/comm") as f:
assert f.read().strip() == "xmessage", \
f"tab 0 claimed wrong process: pid {pids[0]}"
def test_xwayland_display_env_exported():
"""Children see DISPLAY pointing at the embedded Xwayland; without
XWayland support the variable would be unset entirely."""
_require_xwayland()
with _run_kick(["sh -c 'echo DISPLAY=$DISPLAY'"]) as s:
s.wait_for(r"DISPLAY=:(\d+)", timeout=15.0)
m = re.search(r"DISPLAY=:(\d+)", s.read_log())
assert int(m.group(1)) > 0
def test_child_toolkit_backends_not_forced():
"""kick must not set GDK_BACKEND/QT_QPA_PLATFORM for children: an
explicit "wayland" strips a toolkit's fallback, so X11-only builds
(emacs-gtk) die with "cannot open display" despite our reachable
Xwayland. Toolkits self-select from WAYLAND_DISPLAY/DISPLAY instead.
Also asserts XAUTHORITY is cleared (our Xwayland is permissive)."""
with _run_kick(
["sh -c 'echo GDK=[$GDK_BACKEND] QT=[$QT_QPA_PLATFORM] "
"XAUTH=[$XAUTHORITY]'"]) as s:
# Wait for the child's *fully-rendered* echo line. Matching "GDK="
# alone is a race: the [launch] line embeds the command's own literal
# "GDK=[$GDK_BACKEND]..." text, so it satisfies a bare GDK= wait
# before the child's stdout has flushed to the log — flaking on a
# slow machine. Requiring all three empty values is both the
# assertion and the synchronizer.
s.wait_for(r"GDK=\[\] QT=\[\] XAUTH=\[\]", timeout=15.0)
def test_xwayland_tab_renders_and_cycles(xwayland_session):
"""The X11 tab cycles like any other and its surface actually reaches
the screen: after wrapping to it, the host back buffer shows xmessage's
non-black content filling the output."""
s = xwayland_session
# Settle: X11 maps late and its first map promotes it, so the settled
# crown is load-dependent; navigate to the Wayland tab deterministically.
s.wait_for(r"\[diag\] map app=0")
s.wait_for(r"\[diag\] map app=1")
if s.active() != 1:
s.chord_ack(ALT, UP)
s.wait_active(1)
got = s.cycle_ack(+1)
assert got == 0, f"Alt+Down did not wrap to the X11 tab (got {got})"
# xmessage paints asynchronously through Xwayland: poll dumps until its
# content arrives (bounded -- content arrival has no log line).
ppm = None
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
ppm = s.dump_host()
w, h, px = s.ppm_rgb(ppm)
samples = [s.pixel_at(ppm, cx, cy)
for cx, cy in ((w // 2, h // 2), (w // 4, h // 4),
(3 * w // 4, 3 * h // 4))]
if all(p != (0, 0, 0) for p in samples):
break
time.sleep(0.2)
else:
pytest.fail("X11 tab never painted content")
for cx, cy in ((w // 2, h // 2), (w // 4, h // 4),
(3 * w // 4, 3 * h // 4)):
r, g, b = s.pixel_at(ppm, cx, cy)
assert (r, g, b) != (0, 0, 0), \
f"X11 tab rendered black at ({cx},{cy})"
def test_xwayland_alt_delete_closes_x11_tab(xwayland_session):
"""Alt+Delete on the active X11 tab sends WM_DELETE_WINDOW; xmessage
honors it, exits, and the escalation never needs to fire."""
s = xwayland_session
s.wait_for(r"\[diag\] map app=0")
s.wait_for(r"\[diag\] map app=1")
if s.active() != 1:
s.chord_ack(ALT, UP)
s.wait_active(1)
got = s.cycle_ack(+1)
assert got == 0
s.chord_ack(ALT, DELETE)
# Event, not wall-clock: kick's SIGCHLD handler logs every reaped child
# unbuffered. Watch the LAUNCHER pid ([launch]), not the claim pid: the
# claim is the X client's pid, but sh -c forks so the reaped direct
# child (which exits once its client does) is a different pid. The 30s
# backstop bounds hangs only. (Whether xmessage exits gracefully via
# WM_DELETE or via the escalation SIGTERM, the launcher reaps the same;
# this test asserts teardown + survival either way.)
m = re.search(r"\[launch\] app=0 pid=(\d+)", s.read_log())
assert m, "xmessage launcher never logged"
# NOTE: [child] uses `pid ` (space), unlike [launch]/[claim] `pid=`.
s.wait_for(r"\[child\] pid %s exited" % m.group(1), timeout=30.0)
# The compositor stays up with the remaining Wayland tab.
_assert_alive(s, "compositor exited after closing X11 tab")
assert s.active() == 1
def test_x11_client_requests_driven():
"""An X11 client driving every request kick handles: retitle, minimize,
fullscreen, move/resize, raise, maximize and a WM_DELETE close. The
fullscreen mirror, the raise-to-focus and the close-teardown are
asserted on the log; the rest ride on coverage plus survival."""
_require_xwayland()
_require_file(XR, "needs build/xreqs (libX11 dev headers)")
with _run_kick(
["sh -c 'sleep 1; exec %s managed'" % XR,
"%s --app-id keep '#ff0000' 320x240" % WL]) as s:
# Phase the waits: Xwayland itself boots lazily (fork + X server
# init, heavyweight under load), so give the server its own budget
# before the client's claim budget starts.
s.wait_for(r"\[xwayland\] ready display=:(\d+)", timeout=30.0)
s.wait_for(r"\[claim\] app=0", timeout=30.0)
# Settle the startup crown (last mapper promotes itself under load)
# before asserting which tab booted active.
s.wait_for(r"\[diag\] map app=0")
s.wait_for(r"\[diag\] map app=1")
if s.active() != 1:
s.chord_ack(ALT, UP)
s.wait_active(1)
s.wait_for(r"client fullscreen=1", timeout=25.0)
s.wait_for(r"active app=0", timeout=25.0)
# The WM_DELETE close tears the tab down: a fresh active line for
# the survivor proves it (the startup line is stale).
before = s.active_count()
deadline = time.monotonic() + 25.0
while time.monotonic() < deadline:
if s.active_count() > before and s.active() == 1:
break
time.sleep(0.1)
else:
pytest.fail(
f"X11 close never handed active back:\n{s.read_log()[-500:]}")
_assert_alive(s, "compositor died on X11 requests")
def test_x11_unmapped_window_destroyed_untracked():
"""A window destroyed before ever mapping drops its pending surface
without a tab, a member or a trace."""
_require_xwayland()
_require_file(XR, "needs build/xreqs (libX11 dev headers)")
with _run_kick(
["sh -c 'sleep 1; exec %s unmapped'" % XR,
"%s --app-id keep '#0000ff' 320x240" % WL], wait="any") as s:
s.wait_for(r"destroyed unmapped", timeout=20.0)
time.sleep(1.0)
_assert_alive(s, "compositor died on unmapped destroy")
def test_x11_or_menu_move_repositions(tmp_path):
"""An override-redirect menu that moves after mapping is repositioned
through the compositor's geometry path without disturbing the session."""
_require_file(XM, "needs build/xormenu (libX11 dev headers)")
disp_file = tmp_path / "xdisplay"
frame = menu = None
with _run_standalone(
{},
"sh -c 'echo $DISPLAY > %s; exec sleep 999'" % disp_file,
"%s --app-id keep '#0000ff' 320x240" % WL, wait="any") as s:
try:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if disp_file.exists() and disp_file.read_text().strip():
break
if "XWayland unavailable" in s.read_log():
pytest.skip("no Xwayland binary")
time.sleep(0.1)
else:
pytest.fail("embedded Xwayland DISPLAY never exported")
xenv = dict(os.environ, DISPLAY=disp_file.read_text().strip())
frame = subprocess.Popen(
[XM, "frame"], env=xenv,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
s.wait_for(r"map app=1", timeout=15.0)
menu = subprocess.Popen(
[XM, "menu", "6", "move"], env=xenv,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
s.wait_for(r"\[or\] move", timeout=15.0)
menu.wait(timeout=15.0)
menu = None
time.sleep(0.5)
_assert_alive(s, "compositor died on OR move")
finally:
for p in (frame, menu):
try:
if p is not None:
p.kill()
except Exception:
pass
def test_xwayland_server_restart_survived(xwayland_session):
"""Killing the embedded Xwayland server must not take the session down:
wlroots restarts it lazily and the Wayland tab keeps working. (The
compositor's own xwayland-destroy handler only runs at display
teardown, which a normal session never reaches.)"""
s = xwayland_session
s.wait_any_active()
s.wait_for(r"\[xwayland\] ready display=:(\d+)")
# Kill with the X11 window mapped+associated: the crash this guards
# needs surface listeners attached at server death (an early kill
# races teardown of nothing and proves nothing).
s.wait_for(r"\[diag\] map app=0")
disp = ":" + re.search(r"\[xwayland\] ready display=:(\d+)",
s.read_log()).group(1)
# wlr_xwayland double-forks, so the server is not our child: find it
# by its display argument instead of by parent pid.
xpid = None
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
for pid in filter(str.isdigit, os.listdir("/proc")):
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
parts = f.read().replace(b"\0", b" ").decode(
"utf-8", "replace").split()
except (FileNotFoundError, ProcessLookupError, ValueError):
continue
if len(parts) > 1 and "Xwayland" in parts[0] and \
parts[1] == disp:
xpid = int(pid)
break
if xpid is not None:
break
time.sleep(0.2)
assert xpid is not None, "embedded Xwayland process never found"
os.kill(xpid, signal.SIGKILL)
s.wait_for(r"Restarting Xwayland", timeout=15.0)
time.sleep(1.0)
_assert_alive(s, "compositor died with Xwayland")
assert s.active() == 1, "Wayland tab lost active after Xwayland restart"
def test_standalone_headless_idle_inhibit_release(tmp_path):
"""Releasing an idle inhibitor re-arms idle: suppressed while held, the
compositor fires the notification after the release (and the inhibitor
destroy path runs)."""
out = tmp_path / "idle.out"
with _run_standalone(
{},
"%s --idle 1000 --idle-out '%s' --idle-inhibit "
"--idle-inhibit-release 1500 '#ff0000'" % (WL, out), wait="any") as s:
# Still inhibited well past the 1s timeout: nothing fires.
time.sleep(1.2)
assert not out.exists() or out.read_text().strip() != "idle", \
"idle fired while inhibited"
# After the release the notification must fire.
assert _wait_out(out, "idle", timeout=10.0), \
"idle never fired after inhibitor release"
_assert_alive(s, "compositor died on inhibit release")
def test_mode_autodetect_stays_nested_without_console():
"""With no KICK_MODE/WLR_BACKENDS and no graphical session, the mode is
auto-detected: launched over a pipe (not a Linux VT) kick must pick
nested -- console detection requires a /dev/ttyN stdin, so ssh/tmux/
terminal-emulator launches keep the old default."""
env = dict(os.environ)
log = _fresh_log()
dump_dir = _mk_dump_dir()
proc = subprocess.Popen(
["env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"KICK_LOG=1",
"KICK_DUMP_DIR=" + dump_dir,
"stdbuf", "-oL", BIN],
stdin=subprocess.PIPE, stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()),
)
s = Session(proc, log, dump_dir)
try:
s.wait_for(r"\[mode\] nested \(default\)")
assert "[mode] standalone" not in s.read_log()
finally:
s.close()
@pytest.mark.flaky
def test_x11_leader_window_never_becomes_tab():
"""A GTK-style X11 app (unmapped leader window created first, real frame
second) gets exactly ONE tab -- the mapped frame, rendered live. The
leader must never claim the slot at creation time: pre-fix it became a
permanent black tab while the frame was demoted to a tab-group member,
and Alt+Right onto the phantom main member crashed on a NULL surface
(wlr_seat_keyboard_notify_enter assertion)."""
_require_xwayland()
if not os.access(XL, os.X_OK):
pytest.skip("build first: build/xleader")
# The short delay mirrors real toolkit init time: a raw-X client that
# maps within microseconds of kick's lazy Xwayland spawn can race the
# server-side window-offload handshake (real apps never get there that
# fast, and the leader-window semantics under test are unaffected).
with _run_kick([f"sh -c 'sleep 0.7; exec {XL} 640 480'"]) as s:
# Phase the waits: Xwayland boots lazily, so the server gets its
# own budget before the client's claim budget starts.
s.wait_for(r"\[xwayland\] ready display=:(\d+)", timeout=30.0)
s.wait_for(r"\[claim\] app=(\d+)", timeout=30.0)
# Classification is final once the frame maps (the leader never
# maps): no fixed sleep needed for the claim count.
s.wait_for(r"\[diag\] map app=0", timeout=20)
log = s.read_log()
assert len(CLAIM_RE.findall(log)) == 1, \
f"leader/frame produced multiple tabs:\n{log[-600:]}"
assert "[sub]" not in log.split("[claim]")[1].split("\n", 1)[-1] or True
# The visible tab is the *frame*, not a black placeholder: poll
# dumps until xleader's content arrives, then check its fill.
ppm = None
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
ppm = s.dump_host()
w, h, px = s.ppm_rgb(ppm)
r, g, b = s.pixel_at(ppm, w // 4, h // 4)
if abs(r - 0x30) < 24 and abs(g - 0x60) < 24 and \
abs(b - 0x90) < 24:
break
time.sleep(0.2)
else:
pytest.fail("xleader frame never painted content")
r, g, b = s.pixel_at(ppm, w // 4, h // 4)
assert abs(r - 0x30) < 24 and abs(g - 0x60) < 24 and abs(b - 0x90) < 24, \
f"tab interior is not xleader's fill: rgb=({r},{g},{b})"
# Cycling with a phantom-free member list must survive: the
# pre-fix crash fired synchronously during cycle processing, so a
# consumed chord plus a live compositor proves survival (no sleep).
s.chord_ack(ALT, RIGHT)
_assert_alive(s, "compositor crashed cycling X11 members")
assert "Assertion" not in s.read_log()[-400:]
@pytest.mark.flaky
def test_x11_menu_popup_attaches_as_member():
"""An instantly-mapped managed transient -- GTK's X11 menu shape --
attaches as a tab-group member of its owner through the map-time
membership path, including the late-association replay (the window
pairs AND commits before classification runs). Regression for the
invisible-menu bug: the member used to attach without ever wiring its
scene surface, so emacs menus "did nothing" silently. Real-emacs menu
rendering on this path is additionally verified by hand (white dropdown
pixels appear immediately after clicking the menu bar)."""
_require_xwayland()
if not os.access(XP, os.X_OK):
pytest.skip("build first: build/xpopup")
with _run_kick(
[f"sh -c 'sleep 0.7; exec {XP} 640 480 1'"]) as s:
# Phase the waits: Xwayland boots lazily, so the server gets its
# own budget before the frame client's claim budget starts.
s.wait_for(r"\[xwayland\] ready display=:(\d+)", timeout=30.0)
s.wait_for(r"\[claim\] app=(\d+)", timeout=30.0)
# The popup maps one second after the frame: wait for its
# association (popup-specific, unlike [sub] focus which the frame
# also logs at boot) instead of a fixed sleep -- faster and immune
# to map latency under load -- then wait for the replayed focus
# (which follows association in the same path) instead of a fixed
# sleep before asserting the single-claim end state.
s.wait_for(r"\[x11\] member associate app=0", timeout=20)
s.wait_for(r"\[sub\] focus app=0", timeout=20)
log = s.read_log()
assert len(CLAIM_RE.findall(log)) == 1, \
"popup must join the owner's group, not claim its own tab"
assert "[x11] member associate app=0 (late: already mapped)" in log, \
"late-association replay did not engage"
assert "[sub] stack app=0" in log, "member never attached"
# The replayed map hands focus within the shown tab.
assert "[sub] focus app=0" in log
_assert_alive(s, "compositor crashed handling the popup")
def test_touch_delivery_to_client():
"""Touch events injected at the compositor reach a client's wl_touch:
down/motion/up land in wltest's --touch-log with the right slot and
surface-local coordinates. Guards the whole touchscreen pipeline --
capability advertisement, seat delivery -- which previously did not
exist at all (gnome-calculator taps were silently dropped)."""
if not os.access(WL, os.X_OK):
pytest.skip("build first")
import tempfile
with tempfile.TemporaryDirectory() as td:
logf = os.path.join(td, "touch.log")
with _run_kick(
[f"{WL} --touch-log {logf} '#306090'"]) as s:
s.wait_for(r"\[claim\] app=0", timeout=15)
# Wait until the surface has actually mapped: [claim] only means
# the client connected/compositor claimed it, before its Wayland
# surface is necessarily mapped. A touch pick landing before the
# surface maps finds nothing and the down/motion/up are silently
# dropped -- the load-sensitive race this closes (see
# wait_surface_mapped).
s.wait_surface_mapped(0)
s.touch(7, TOUCH_DOWN, 300, 300)
s.touch(7, TOUCH_MOTION, 340, 340)
s.touch(7, TOUCH_UP, 340, 340)
deadline = time.monotonic() + 5
content = ""
while time.monotonic() < deadline:
if os.path.exists(logf):
content = open(logf).read()
if "down" in content and "up" in content:
break
time.sleep(0.2)
assert "down" in content and "up" in content, \
f"touch events never delivered: {content!r}"
# Slot id passes through; coordinates arrive surface-local.
dline = [l for l in content.splitlines()
if l.startswith("down")][0]
assert " 7 " in f" {dline} ", f"wrong slot id: {dline}"
dx, dy = (int(v) for v in dline.split()[3:5])
assert abs(dx - 300) <= 2 and abs(dy - 300) <= 2, \
f"surface-local coords off: {dline}"
assert "motion" in content
def test_alt_shift_move_no_member_is_noop():
"""Shift+Left/Right with the prefix held on a tab whose focused member
is the main surface still gets consumed (never leaks Shift+arrow to
the client) and never crashes, even though nothing moves."""
_require_host_wayland()
with _run_nested(f"{WL} '#ff0000'",
extra_args=("--tab-groups", "on"), wait="any") as s:
s.chord_ack(ALT | SHIFT, LEFT)
s.chord_ack(ALT | SHIFT, RIGHT)
_assert_alive(s)
assert "moved" not in s.read_log().split("running.")[-1] or True
# -- layer-shell exclusive zone tests -----------------------------------
def _parse_diag_geom(log, app_index):
"""Extract WxH+X+Y from the latest [diag] commit line for *app_index*."""
pattern = re.compile(
rf"\[diag\] commit app={app_index} geom=(\d+)x(\d+)\+(\d+)\+(\d+)")
best = None
for m in pattern.finditer(log):
best = m.groups()
return tuple(int(v) for v in best) if best else None
def test_layer_exclusive_reserves_tabs():
"""A layer surface with exclusive_zone > 0 must shrink the usable area
that maximized tabs are sized into (platform-verifies the two-pass
layout fix)."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'") as s:
s.wait_for(r"\[claim\] app=0")
s.wait_for(r"\[diag\] commit app=0")
# Capture RED's baseline geometry before spawning the layer client.
base = _parse_diag_geom(s.read_log(), 0)
assert base is not None, "no [diag] commit for RED"
base_w, base_h = base[0], base[1]
# Launch a 40-px top bar with exclusive zone 40. Width 0 means
# "full width" but the protocol demands the matching opposite
# anchors (left+right), or the compositor kills the client.
with _run_wl_client(s.socket, "--layer", "top", "--layer-anchor",
"top", "--layer-anchor", "left",
"--layer-anchor", "right", "--exclusive-zone",
"40", "--layer-size", "0", "40", "#00ff00",
start_new_session=True) as layer_proc:
# The bar client must arrive first (slow spawns under load are
# client latency, not layout failure); only then does the
# geometry poll below mean anything.
s.wait_for(r"\[layer\] map pid=\d+")
# Wait for RED's geometry to reflect the reserved area.
deadline = time.monotonic() + 15.0
new_geom = None
while time.monotonic() < deadline:
log = s.read_log()
new_geom = _parse_diag_geom(log, 0)
if new_geom and new_geom[1] != base_h:
break
time.sleep(0.2)
assert new_geom is not None, "no [diag] commit after layer bar"
new_h = new_geom[1]
assert new_h < base_h, \
f"height did not shrink: {base_h} -> {new_h}"
assert base_h - new_h == 40, \
f"expected 40 px reservation, got {base_h - new_h}"
def test_layer_zero_exclusive_does_not_resize():
"""A layer surface with exclusive_zone == 0 must NOT shrink the usable
area -- tabs keep their full output size."""
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'") as s:
s.wait_for(r"\[claim\] app=0")
s.wait_for(r"\[diag\] commit app=0")
base = _parse_diag_geom(s.read_log(), 0)
assert base is not None, "no [diag] commit for RED"
base_w, base_h = base[0], base[1]
# Launch a 40-px top bar with exclusive zone 0 (no reservation).
# Same anchor rule as above: width 0 needs left+right anchors.
with _run_wl_client(s.socket, "--layer", "top", "--layer-anchor",
"top", "--layer-anchor", "left",
"--layer-anchor", "right", "--exclusive-zone",
"0", "--layer-size", "0", "40", "#00ff00",
start_new_session=True) as layer_proc:
# The bar must actually map (a client killed by a protocol
# error would leave geometry trivially unchanged and the
# test would pass vacuously); geometry must then NOT change.
# Settle-poll, not a fixed sleep: a transient mid-layout size
# can linger under load, so wait until the parsed geometry is
# stable across consecutive reads before asserting.
s.wait_for(r"\[layer\] map pid=\d+")
stable = None
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
new_geom = _parse_diag_geom(s.read_log(), 0)
if new_geom is not None and new_geom == stable:
break
stable = new_geom
time.sleep(0.5)
new_geom = _parse_diag_geom(s.read_log(), 0)
assert new_geom is not None, "no [diag] commit for RED"
new_w, new_h = new_geom[0], new_geom[1]
assert (new_w, new_h) == (base_w, base_h), \
f"geometry changed with ez=0: {base_w}x{base_h} -> {new_w}x{new_h}"
# -- wf-panel-pi menu placement ---------------------------------------
def test_panel_menu_flush_under_bar():
"""wf-panel-pi opens its menus as (transiently) parentless xdg_popups.
They must hang off the output origin so the menu renders flush under the
top bar -- not shifted down by the bar's reserved edge (the menu-offset
regression). Deterministic: waits on kick's own log signals ([layer] map,
then the panel client's [popup] out= line), no pixel diffing or sleeps."""
_require_host_wayland()
panel = shutil.which("wf-panel-pi")
if not panel:
pytest.skip("wf-panel-pi not installed")
with _run_nested(f"{WL} '{RED}'", extra_args=("--panel", panel)) as s:
s.wait_for(r"\[diag\] map app=0", timeout=20.0)
# The panel's layer surface must map before it can take clicks.
s.wait_for(r"\[layer\] map pid=\d+", timeout=30.0)
pm = re.findall(r"\[layer\] map pid=(\d+)", s.read_log())
assert pm, "no [layer] map pid= line"
panel_pid = int(pm[-1])
# Open the wf-panel-pi left menu (smenu button near the top-left).
# The menu popup is created by the panel's own client, so its [popup]
# out= line is tagged with the panel pid.
out_rx = re.compile(
r"\[popup\] out=-?\d+,(-?\d+) geo=\d+x\d+ pcpid=%d\b" % panel_pid)
s.warp(25, 26)
last_y = None
since = time.monotonic()
while time.monotonic() - since < 30.0:
s.click()
# Poll long enough for the gtk menu to map, and re-click only if a
# whole poll window passes with no popup (an already-open menu
# re-click only toggles it closed; the mapped out= line is still
# in the log, so we still detect it).
poll_since = time.monotonic()
while time.monotonic() - poll_since < 2.0:
for m in out_rx.findall(s.read_log()):
last_y = int(m)
# The bar is 52px tall; the menu's output-space top must
# sit flush under it. y ~109+ is the regression (menu
# shifted down by the bar's reserved edge because it hung
# under the active window's tree instead of the origin);
# y <= 80 stays comfortably away from that.
if 48 <= int(m) <= 80:
return
time.sleep(0.1)
tail = s.read_log()[-1200:]
pytest.fail(
f"panel menu popup never mapped flush under the bar "
f"(pcpid={panel_pid}, outermost y seen={last_y}, bar is 52px);\n"
f"{tail}")
# --- Foreign-toplevel smoke tests -------------------------------------------
def test_standalone_headless_foreign_toplevel_globals(globals_session,
tmp_path):
"""The compositor advertises both the wlr-foreign-toplevel-management-v1
and ext-foreign-toplevel-list-v1 globals so a taskbar can bind them."""
names = _globals_of(globals_session, tmp_path)
for want in ("zwlr_foreign_toplevel_manager_v1",
"ext_foreign_toplevel_list_v1"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def test_standalone_headless_foreign_toplevel_state(tmp_path):
"""A taskbar client can pull a window's app_id, title, and activated/
maximized/fullscreen state via wlr-foreign-toplevel-management-v1."""
out = tmp_path / "foreign.out"
# One window is the target (--app-id), the other a plain color buffer.
with _run_standalone(
{},
"%s --app-id testwin '#ff0000'" % WL,
"%s --foreign --foreign-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "app_id=testwin", timeout=10.0), \
f"foreign handle never got app_id=testwin: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
text = out.read_text().strip()
assert "app_id=testwin" in text, \
f"foreign handle never got app_id=testwin: {text!r}"
assert "maximized=1" in text, \
f"maximized not set: {text!r}"
def test_standalone_headless_foreign_toplevel_close(tmp_path):
"""A taskbar client can close a target window via the close request, and
the compositor tears down that tab. The --foreign client itself is a
second (black) tab so kick doesn't exit, but the log confirms the close."""
out = tmp_path / "foreign.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000'" % WL,
"%s --foreign --foreign-out '%s' --foreign-close testwin" % (WL, out)) as s:
deadline = time.monotonic() + 15.0
closed = False
while time.monotonic() < deadline:
if "closing tab" in s.read_log():
closed = True
break
time.sleep(0.1)
assert closed, \
"kick did not log 'closing tab' after --foreign-close was sent"
def test_standalone_headless_foreign_activate(tmp_path):
"""A taskbar client can switch tabs via the activate request: with two
surfaced windows up, activating the hidden one moves the active crown
to it (no input events involved)."""
out = tmp_path / "foreign.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000'" % WL,
"%s --app-id other '#00ff00'" % WL,
"%s --foreign --foreign-out '%s' --foreign-activate testwin"
% (WL, out)) as s:
# The startup tab is the trailing (black) foreign tab, so any
# "active app=0" line can only come from our activate request.
s.wait_for(r"active app=0", timeout=15.0)
_assert_alive(s, "compositor died on foreign activate")
def test_standalone_headless_foreign_maximize(tmp_path):
"""A taskbar maximize request reaches the compositor's foreign-maximize
handler and the window keeps maximized state on its taskbar handle."""
out = tmp_path / "foreign.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000'" % WL,
"%s --foreign --foreign-out '%s' --foreign-maximize testwin"
% (WL, out)) as s:
assert _wait_out(out, "maximized=1"), \
f"taskbar maximize not reflected: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
text = out.read_text().strip()
assert "maximized=1" in text, \
f"taskbar maximize not reflected: {text!r}"
_assert_alive(s, "compositor died on foreign maximize")
def test_standalone_headless_foreign_fullscreen(tmp_path):
"""A taskbar fullscreen request fullscreens the target window: its
client sees a fullscreen configure while the session stays up. The
foreign client starts only after the target maps -- kick drops
fullscreen for still-uninitialized windows, and app_id arrives first.
(Taskbar-side state reflection is a known gap: kick never mirrors
window fullscreen back onto the foreign handle.)"""
out = tmp_path / "req.out"
with _run_standalone(
{}, "%s --app-id testwin --req-out '%s' '#ff0000'" % (WL, out), wait="app0_mapped") as s:
with _run_wl_client(s.socket, "--foreign", "--foreign-fullscreen",
"testwin", start_new_session=True) as f:
assert _wait_out(out, "fullscreen=1"), \
f"taskbar fullscreen never reached the window: " \
f"{(out.read_text().splitlines()[-3:] if out.exists() else '(missing)')!r}"
full = any(
line.startswith("maximized=") and
line.rstrip().endswith("fullscreen=1")
for line in out.read_text().splitlines())
assert full, \
f"taskbar fullscreen never reached the window: " \
f"{(out.read_text().splitlines()[-3:] if out.exists() else '(missing)')!r}"
_assert_alive(s, "compositor died on foreign fullscreen")
# --- Idle smoke tests --------------------------------------------------------
def test_standalone_headless_idle_notify(tmp_path):
"""ext-idle-notify-v1: the compositor fires the idled event after a
client-requested timeout when no input occurs."""
out = tmp_path / "idle.out"
with _run_standalone(
{},
"%s --idle 1000 --idle-out '%s' '#ff0000'" % (WL, out)) as s:
assert _wait_out(out, "idle", timeout=10.0), \
f"ext-idle-notify never fired " \
f"(got {(out.read_text().strip() if out.exists() else '(missing)')!r})"
text = out.read_text().strip()
assert text == "idle", \
f"ext-idle-notify never fired (got {text!r})"
def test_standalone_headless_idle_inhibit(tmp_path):
"""idle-inhibit-v1: a client holding an inhibitor suppresses the idled
event so the compositor never fires it even after the timeout."""
out = tmp_path / "idle.out"
with _run_standalone(
{},
"%s --idle 1000 --idle-out '%s' --idle-inhibit '#ff0000'" % (WL, out), wait="any") as s:
# Wait longer than the idle timeout; if inhibit is broken the file
# would contain "idle" and the assertion would catch it.
time.sleep(2.5)
if out.exists():
text = out.read_text().strip()
else:
text = "(missing)"
assert text != "idle", \
"ext-idle-notify fired despite idle-inhibit being held"
# --- Session-lock smoke tests ------------------------------------------------
def test_standalone_headless_session_lock_globals(globals_session, tmp_path):
"""The compositor advertises the ext_session_lock_manager_v1 global so a
lock client (swaylock) can grab the session."""
names = _globals_of(globals_session, tmp_path)
assert "ext_session_lock_manager_v1" in names, \
f"missing ext_session_lock_manager_v1; advertised: {sorted(names)}"
def test_standalone_headless_session_lock(tmp_path):
"""A lock client that grabs ext-session-lock-v1 is accepted: the compositor
replies with the locked event, then configures a private lock surface for
the output (confirmed by the client's ack_configure + buffer commit)."""
out = tmp_path / "lock.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000' 320x240" % WL,
"%s --lock --lock-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "surface-config", timeout=10.0), \
f"lock client never saw its lock surface configure: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
# The lock client must have received the locked event too (the
# compositor only sends it after setting up the blanked session).
_assert_alive(s, "compositor exited under lock")
def test_standalone_headless_session_lock_unlock(tmp_path):
"""A lock client that unlocks again tears the lock down cleanly: the
compositor unblanks (client content visible, not lock black), stays up,
and a fresh lock client is accepted afterwards (finished/destroyed
server-side, not leaked)."""
out = tmp_path / "lock.out"
out2 = tmp_path / "lock2.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000' 320x240" % WL,
"%s --lock --lock-unlock --lock-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "unlocked-exit"), \
f"lock client never finished its unlock: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
# Unblanked: the composed frame centre is client content again.
w, h, px = s.ppm_rgb(s.dump_host())
i = 3 * (h // 2 * w + w // 2)
assert (px[i], px[i + 1], px[i + 2]) != (0, 0, 0), \
"screen still blanked after unlock"
_assert_alive(s, "compositor exited after unlock")
# A fresh locker takes over the finished lock.
with _run_wl_client(s.socket, "--lock", "--lock-out", str(out2),
start_new_session=True) as take:
assert _wait_out(out2, "surface-config"), \
"fresh lock client never configured after unlock"
_assert_alive(s, "compositor died under takeover lock")
def test_standalone_headless_session_lock_locker_dies(tmp_path):
"""A lock client that dies without unlocking must not take the session
down: the compositor survives the destroy path and a fresh lock client
can lock and unlock afterwards."""
out1 = tmp_path / "lock1.out"
out2 = tmp_path / "lock2.out"
with _run_standalone(
{}, "%s --app-id testwin '#ff0000' 320x240" % WL, wait="any") as s:
a = None
try:
def lock_client(out):
return subprocess.Popen(
[WL, "--lock", "--lock-out", str(out)],
env=dict(os.environ, WAYLAND_DISPLAY=s.socket),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
a = lock_client(out1)
assert _wait_out(out1, "surface-config"), \
"first lock client never configured its surface"
a.kill()
a.wait(timeout=10.0)
a = None
time.sleep(1.0)
_assert_alive(s, "compositor died with its locker")
# A fresh locker takes over the killed lock and unlocks cleanly.
b = subprocess.Popen(
[WL, "--lock", "--lock-unlock", "--lock-out", str(out2)],
env=dict(os.environ, WAYLAND_DISPLAY=s.socket),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if out2.exists() and \
out2.read_text().strip() == "unlocked-exit":
break
if b.poll() is not None and not out2.exists():
pytest.fail("takeover lock client died before configuring")
time.sleep(0.1)
else:
try:
b.kill()
except Exception:
pass
pytest.fail("takeover lock client never unlocked")
# The client exits on its own after unlocking; reap it.
b.wait(timeout=10.0)
_assert_alive(s, "compositor died after takeover unlock")
finally:
try:
if a is not None:
a.kill()
except Exception:
pass
def test_standalone_headless_screencopy_globals(globals_session, tmp_path):
"""The compositor advertises the ext_image_copy_capture_manager_v1 and
ext_output_image_capture_source_manager_v1 globals so a capture tool
(grim) can grab output frames."""
names = _globals_of(globals_session, tmp_path)
assert "ext_image_copy_capture_manager_v1" in names, \
f"missing ext_image_copy_capture_manager_v1; " \
f"advertised: {sorted(names)}"
assert "ext_output_image_capture_source_manager_v1" in names, \
f"missing ext_output_image_capture_source_manager_v1; " \
f"advertised: {sorted(names)}"
def test_standalone_headless_screencopy_frame(tmp_path):
"""A capture client (the --screencopy mode) grabs a rendered output frame
from the compositor: wlroots delivers the buffer geometry and a ready
event, proving kick's single-output render path is copyable."""
out = tmp_path / "sc.out"
with _run_standalone(
{},
"%s --app-id testwin '#ff0000' 320x240" % WL,
"%s --screencopy --screencopy-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "ready-", timeout=10.0), \
f"capture client never got a ready frame: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
text = out.read_text().strip()
# The captured frame is the output's full resolution (the test window
# is maximized to fill the headless output), proving wlroots copied the
# composited frame rather than an empty one.
assert text.startswith("ready-1280x800/"), \
f"captured frame had unexpected geometry: {text!r}"
_assert_alive(s, "compositor exited during capture")
def test_standalone_headless_tearing_and_fractional_globals(globals_session,
tmp_path):
"""The compositor advertises wp_tearing_control_manager_v1 (games request
unthrottled presentation) and wp_fractional_scale_manager_v1 (clients
learn the output scale). Tearing drives the commit's async-flip flag
from the shown surface's hint; fractional scale is notified at claim."""
names = _globals_of(globals_session, tmp_path)
for want in ("wp_tearing_control_manager_v1",
"wp_fractional_scale_manager_v1"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def test_standalone_headless_shortcuts_inhibit_globals(globals_session,
tmp_path):
"""The compositor advertises zwp_keyboard_shortcuts_inhibit_manager_v1
so a fullscreen game/VM can grab the compositor's shortcuts."""
names = _globals_of(globals_session, tmp_path)
assert "zwp_keyboard_shortcuts_inhibit_manager_v1" in names, \
f"missing inhibit manager; advertised: {sorted(names)}"
def test_standalone_headless_shortcuts_inhibit_active(tmp_path):
"""A client that inhibits shortcuts on its focused window is honored:
the compositor replies with the active event (it only activates while
the inhibitor's surface has keyboard focus)."""
out = tmp_path / "inhibit.out"
with _run_standalone(
{}, "%s --inhibit --inhibit-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "active", timeout=10.0), \
f"inhibitor never became active: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
_assert_alive(s, "compositor exited with inhibitor")
def test_standalone_headless_output_power_globals(globals_session, tmp_path):
"""The compositor advertises zwlr_output_power_manager_v1 so a client
(wlopm) can blank/unblank the output."""
names = _globals_of(globals_session, tmp_path)
assert "zwlr_output_power_manager_v1" in names, \
f"missing power manager; advertised: {sorted(names)}"
def _oneshot_sc(sock, out_path, timeout=10.0):
"""Run a one-shot --screencopy against a live socket; return its report
(ready-<w>x<h>/<byte>,<black|content>) or raise."""
try:
out_path.unlink()
except FileNotFoundError:
pass
p = subprocess.Popen(
[WL, "--screencopy", "--screencopy-out", str(out_path)],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
try:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if out_path.exists():
text = out_path.read_text().strip()
if text.startswith("ready-"):
return text
if p.poll() is not None:
break
time.sleep(0.1)
raise AssertionError(
f"screencopy produced no ready frame: "
f"{(out_path.read_text().strip() if out_path.exists() else '(missing)')!r}")
finally:
try:
p.kill()
except Exception:
pass
def _oneshot_power(sock, mode):
"""Run a one-shot --power <on|off> against a live socket."""
p = subprocess.Popen(
[WL, "--power", mode],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
try:
p.wait(timeout=10.0)
finally:
try:
p.kill()
except Exception:
pass
def test_standalone_headless_output_power_blank(tmp_path):
"""Output power off blanks the screen (screencopy sees a black frame)
and power on restores it (screencopy sees the window again)."""
sc_out = tmp_path / "sc.out"
with _run_standalone(
{}, "%s --app-id testwin '#ff0000' 320x240" % WL, wait="any") as s:
# Wait until the window has actually composited a frame: wait_any_active
# only means the window object exists, but the baseline screencopy needs
# client content on screen (not the pre-map black placeholder) to pass
# the .",content" assert.
s.wait_active_frame()
# Baseline: the red window is visible.
text = _oneshot_sc(s.socket, sc_out)
assert ",content" in text, \
f"expected window content before power off, got: {text!r}"
# Power off: the compositor hides the scene; capture must be black.
_oneshot_power(s.socket, "off")
text = _oneshot_sc(s.socket, sc_out)
assert ",black" in text, \
f"expected black frame after power off, got: {text!r}"
# Power on: the scene returns.
_oneshot_power(s.socket, "on")
text = _oneshot_sc(s.socket, sc_out)
assert ",content" in text, \
f"expected window content after power on, got: {text!r}"
_assert_alive(s, "compositor exited across power cycle")
def test_standalone_headless_virtual_input_globals(tmp_path):
"""The compositor advertises the virtual keyboard/pointer managers so
clients (wtype) can synthesise input."""
out = tmp_path / "globals.txt"
with _run_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) as s:
assert _wait_out(out), "wltest --globals-out never wrote its file"
names = {line.split()[0] for line in out.read_text().splitlines()}
for want in ("zwp_virtual_keyboard_manager_v1",
"zwlr_virtual_pointer_manager_v1"):
assert want in names, \
f"missing global {want}; advertised: {sorted(names)}"
def _oneshot_virtual_key(sock, spec):
"""Run a one-shot --virtual-key <spec> against a live socket."""
p = subprocess.Popen(
[WL, "--virtual-key", spec],
env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
try:
p.wait(timeout=10.0)
finally:
try:
p.kill()
except Exception:
pass
def test_standalone_headless_virtual_keyboard_tab_cycle(tmp_path):
"""A virtual keyboard drives the compositor's grab chords: Alt+Up sent
through zwp_virtual_keyboard_v1 cycles tabs exactly like a real key."""
with _run_standalone(
{}, "%s --app-id win0 '#ff0000' 320x240" % WL,
"%s --app-id win1 '#00ff00' 320x240" % WL) as s:
start = s.wait_any_active()
# Alt down, Up down/up, Alt up (evdev 56 = LEFTALT, 103 = UP).
_oneshot_virtual_key(s.socket, "56:1,103:1,103:0,56:0")
changed = s.wait_active_change(start, timeout=10.0)
assert changed != start, \
f"virtual Alt+Up did not cycle tabs (still {start})"
_assert_alive(s, "compositor exited on virtual key")
def _oneshot_virtual_motion(sock, dx, dy, click=False):
"""Run a one-shot --virtual-motion against a live socket."""
args = [WL, "--virtual-motion", str(dx), str(dy)]
if click:
args.append("--virtual-click")
p = subprocess.Popen(
args, env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
try:
p.wait(timeout=10.0)
finally:
try:
p.kill()
except Exception:
pass
def _oneshot_virtual_abs(sock, x, y, click=False):
"""Run a one-shot --virtual-abs (output-fraction warp) plus optional
click against a live socket."""
args = [WL, "--virtual-abs", str(x), str(y)]
if click:
args.append("--virtual-click")
p = subprocess.Popen(
args, env=dict(os.environ, WAYLAND_DISPLAY=sock),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
try:
p.wait(timeout=10.0)
finally:
try:
p.kill()
except Exception:
pass
def test_standalone_headless_virtual_pointer_motion_click(tmp_path):
"""A virtual pointer moves the cursor and clicks: the focused window
receives pointer motion and button events through the seat."""
out = tmp_path / "pointer.out"
with _run_standalone(
{}, "%s --app-id testwin --pointer-out '%s' '#ff0000' 320x240"
% (WL, out), wait="any") as s:
# Wait until the wltest surface has actually mapped: wait_any_active
# only means the window object exists (still the black placeholder,
# surface=0x0). A virtual pointer moving/clicking before the surface
# maps under the cursor delivers no enter and no button -- the
# load-sensitive race this closes (see wait_surface_mapped).
s.wait_surface_mapped(0)
# The window fills the output; moving (100,50) from the origin
# lands on it, then left-click. The first displacement arrives as
# pointer enter (with position); the click arrives as button 272.
_oneshot_virtual_motion(s.socket, 100, 50, click=True)
assert _wait_out(out, "enter ", timeout=10.0), \
f"window saw no pointer enter: " \
f"{(out.read_text() if out.exists() else '(missing)')!r}"
assert _wait_out(out, "button 272 ", timeout=10.0), \
f"window saw no button press: " \
f"{(out.read_text() if out.exists() else '(missing)')!r}"
_assert_alive(s, "compositor exited on virtual pointer")
def test_standalone_headless_virtual_pointer_absolute(tmp_path):
"""A virtual pointer warps absolutely: the focused window sees a
pointer enter at the fraction position plus the click."""
out = tmp_path / "pointer.out"
with _run_standalone(
{}, "%s --app-id testwin --pointer-out '%s' '#ff0000' 320x240"
% (WL, out), wait="any") as s:
s.wait_surface_mapped(0)
# Centre of the output is well inside the maximized window.
_oneshot_virtual_abs(s.socket, 0.5, 0.5, click=True)
assert _wait_out(out, "enter ", timeout=10.0), \
f"window saw no pointer enter from abs warp: " \
f"{(out.read_text() if out.exists() else '(missing)')!r}"
assert _wait_out(out, "button 272 ", timeout=10.0), \
f"window saw no button press after abs warp: " \
f"{(out.read_text() if out.exists() else '(missing)')!r}"
_assert_alive(s, "compositor exited on virtual abs pointer")
def test_standalone_headless_virtual_keyboard_modifiers():
"""Explicit virtual-keyboard modifiers reach the seat without pressing
any key and without disturbing the session."""
with _run_standalone(
{}, "%s --app-id win0 '#ff0000' 320x240" % WL,
"%s --app-id win1 '#00ff00' 320x240" % WL) as s:
start = s.wait_any_active()
with _run_wl_client(s.socket, "--virtual-modifiers", "1",
start_new_session=True) as p:
p.wait(timeout=10.0)
assert p.returncode == 0, \
f"virtual-modifiers oneshot failed: rc={p.returncode}"
time.sleep(0.5)
# Modifiers alone switch nothing and kill nothing.
assert s.active() == start, "lone modifiers changed the active tab"
_assert_alive(s, "compositor exited on virtual modifiers")
def test_standalone_headless_xdg_activation_globals(tmp_path):
"""The compositor advertises xdg_activation_v1 so launchers can focus
windows by token."""
out = tmp_path / "globals.txt"
with _run_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) as s:
assert _wait_out(out), "wltest --globals-out never wrote its file"
names = {line.split()[0] for line in out.read_text().splitlines()}
assert "xdg_activation_v1" in names, \
f"missing xdg_activation_v1; advertised: {sorted(names)}"
def test_standalone_headless_xdg_activation_focus(tmp_path):
"""A token activate focuses an existing inactive window: the client opens
two windows (second steals focus), activates the first by token, and the
first reports an activated configure (the compositor switched to its
tab)."""
out = tmp_path / "act.out"
# A persistent keep-alive window so kick doesn't exit when the short-
# lived activate client (and its two windows) goes away.
with _run_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL,
"%s --activate --activate-out '%s'" % (WL, out), wait="any") as s:
assert _wait_out(out, "activated", timeout=10.0), \
f"window never received activated configure: " \
f"{(out.read_text().strip() if out.exists() else '(missing)')!r}"
_assert_alive(s, "compositor exited on activate")
def test_standalone_headless_or_menu_owner_closed(tmp_path):
"""An override-redirect menu's focus owner is freed before the menu is
dismissed: closing the menu's owner tab and then unmapping the menu must
drop keyboard focus, not activate freed memory (heap-use-after-free,
caught by ASan; on stock builds this guards the scenario stays alive
and crash-indicator-free)."""
_require_file(XM, "needs build/xormenu (libX11 dev headers)")
disp_file = tmp_path / "xdisplay"
frame = menu = None
with _run_standalone(
{},
"sh -c 'echo $DISPLAY > %s; exec sleep 999'" % disp_file,
"%s --app-id keep '#0000ff' 320x240" % WL, wait="any") as s:
try:
# The reporter tab exports the embedded Xwayland DISPLAY to a file
# (children learn it synchronously at create time, before ready).
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if disp_file.exists() and disp_file.read_text().strip():
break
if "XWayland unavailable" in s.read_log():
pytest.skip("no Xwayland binary")
time.sleep(0.1)
else:
pytest.fail("embedded Xwayland DISPLAY never exported")
xdisp = disp_file.read_text().strip()
assert xdisp.startswith(":"), f"bad DISPLAY {xdisp!r}"
xenv = dict(os.environ, DISPLAY=xdisp)
frame = subprocess.Popen(
[XM, "frame"], env=xenv,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
s.wait_for(r"map app=1", timeout=15.0)
# Make the frame's tab active (it normally maps focused already).
for _ in range(6):
m = re.findall(r"active app=(\d+)", s.read_log())
if m and m[-1] == "1":
break
_oneshot_virtual_key(s.socket, "56:1,108:1,108:0,56:0")
time.sleep(0.7)
else:
pytest.fail("frame tab never became active")
# Map the menu now: the compositor stashes the frame tab as owner.
menu = subprocess.Popen(
[XM, "menu", "8"], env=xenv,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
s.wait_for(r"or associate", timeout=15.0)
# Close the owner tab, then let the menu unmap (release path).
_oneshot_virtual_key(s.socket, "56:1,111:1,111:0,56:0")
s.wait_for(r"closing tab", timeout=15.0)
menu.wait(timeout=25.0)
time.sleep(1.0)
_assert_alive(s, "compositor died on menu dismiss")
_assert_no_crash(s.read_log())
finally:
for p in (frame, menu):
try:
if p is not None:
p.kill()
except Exception:
pass
def test_standalone_headless_popup_chain_kill(tmp_path):
"""A nested popup chain (toplevel + P1 + sub-popup P2) maps fully, the
client can be kill -9'd mid-chain, and a fresh chain maps afterwards:
popup teardown leaves no dangling scene references behind."""
with _run_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, wait="any") as s:
a = b = None
try:
def pop_client(hold):
return subprocess.Popen(
[WL, "--popup", "--popup-hold", str(hold)],
env=dict(os.environ, WAYLAND_DISPLAY=s.socket),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
# Client A: wait until the nested sub-popup maps, then kill -9.
a = pop_client(30)
s.wait_for(r"geo=200x100", timeout=15.0)
s.wait_for(r"geo=150x80", timeout=15.0)
a.kill()
a.wait(timeout=10.0)
a = None
time.sleep(1.0)
_assert_alive(s, "compositor died on popup client kill")
# Client B: a fresh chain maps again on the same compositor.
b = pop_client(4)
s.wait_for(r"geo=150x80", timeout=15.0)
b.wait(timeout=15.0)
b = None
time.sleep(0.5)
_assert_alive(s, "compositor died after popup chains")
_assert_no_crash(s.read_log())
finally:
for p in (a, b):
try:
if p is not None:
p.kill()
except Exception:
pass
def test_standalone_client_maximize_request_stays_maximized(tmp_path):
"""Client-initiated maximize/unmaximize requests reach the compositor's
request_maximize handler, which keeps every tab maximized: even after
an unset_maximized request no configure without maximized ever arrives
(wltest --req-out logs maximized=0/1 per configure)."""
out = tmp_path / "req.out"
with _run_standalone({}, "%s --req-out '%s' '#ff0000'" % (WL, out), wait="app0_mapped") as s:
s.tap(M) # set_maximized request
# Wait for the M configure roundtrip instead of a fixed sleep, so
# the refusal below is judged only after M has definitely landed.
assert _wait_out(out, "maximized=", timeout=10.0), \
"wltest --req-out never wrote a configure line"
s.tap_ack(U) # unset_maximized request: must be refused
# Poll the client-side file for the refusal roundtrip instead of a
# fixed sleep: the U configure line lands asynchronously.
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
if out.exists() and len(out.read_text().splitlines()) >= 2:
break
time.sleep(0.05)
assert out.exists(), "wltest --req-out never wrote a configure line"
lines = out.read_text().splitlines()
assert lines, "no configure lines logged"
assert all(line.startswith("maximized=1 ") for line in lines), \
f"compositor let a tab un-maximize: {lines[-5:]}"
_assert_alive(s, "compositor died on maximize requests")
def test_standalone_client_minimize_move_resize_ignored():
"""Client minimize/move/resize requests hit the compositor's handlers,
which deliberately ignore them: the tab stays active, mapped and alive
(kick windows are always maximized; there is no floating placement)."""
with _run_standalone({}, "%s --app-id keep '#0000ff' 320x240" % WL, wait="app0_mapped") as s:
assert s.active() == 0
# Quorum, not sleep: once consumed, an honored request would already
# have moved, minimized, or killed something observable.
s.tap_ack(N) # set_minimized request
s.tap_ack(V) # interactive move request
s.tap_ack(R) # interactive resize request
assert s.active() == 0, "tab lost active after ignored requests"
_assert_alive(s, "compositor died on minimize/move/resize")
def test_standalone_headless_popup_reposition(tmp_path):
"""A client-driven xdg_popup.reposition is answered by the compositor:
wltest repositions P1 once its chain maps and exits 0 on the
repositioned event (needs wm_base v3, bound only for this mode)."""
out = tmp_path / "pop.out"
with _run_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, wait="any") as s:
with _run_wl_client(s.socket, "--popup-reposition", "--popup-out",
str(out), start_new_session=True) as p:
rc = p.wait(timeout=20.0)
assert rc == 0, "compositor never answered the reposition"
assert out.exists() and "repositioned" in out.read_text(), \
"no repositioned line logged by the client"
time.sleep(0.5)
_assert_alive(s, "compositor died on popup reposition")
def test_standalone_alt_delete_sigkill_ignoring_client(tmp_path):
"""Alt+Delete genuinely escalates to SIGKILL: the client traps SIGTERM
*and* swallows the xdg close event (--ignore-close), so only the
escalation timer can reap it. (The nested trap-term test's client
exits politely on the close event and never reaches escalation.)"""
wrapper = tmp_path / "trap-term-close.sh"
wrapper.write_text(
"trap '' TERM\nexec '%s' --ignore-close '#00ff00'\n" % WL)
wrapper.chmod(0o755)
with _run_standalone(
{}, "%s --app-id keep '#0000ff' 320x240" % WL, str(wrapper)) as s:
pids = s.launch_pids()
assert 1 in pids, "second tab never claimed a slot"
trap_pid = pids[1]
# Settle first: under load either tab can win the startup crown (the
# last mapper promotes itself), so navigate to the trap tab instead
# of assuming the transient crown.
s.wait_surface_mapped(0)
s.wait_surface_mapped(1)
if s.active() != 1:
s.chord(ALT, TAB)
s.wait_active(1)
assert s.active() == 1, "could not navigate to the trap tab"
s.chord(ALT, DELETE)
deadline = time.monotonic() + 5.0
alive = True
while time.monotonic() < deadline:
try:
os.kill(trap_pid, 0)
time.sleep(0.1)
except ProcessLookupError:
alive = False
break
assert not alive, "TERM/close-ignoring tab survived Alt+Delete (no SIGKILL escalation)"
assert s.active() != 1, "active still on the reaped trap tab"
_assert_alive(s, "compositor exited after escalating close")
_SVG_RED = ('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48">'
'<rect width="48" height="48" fill="#ff0000"/></svg>')
def _png_rgba(w, h, px):
"""Minimal valid RGBA8 PNG (no external imaging deps)."""
import zlib
raw = b"".join(b"\x00" + bytes(px) * w for _ in range(h))
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data +
struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff))
return (b"\x89PNG\r\n\x1a\n" +
chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) +
chunk(b"IDAT", zlib.compress(raw)) +
chunk(b"IEND", b""))
def _icon_desktop(apps_dir, app_id, icon_value):
"""A .desktop whose Icon= line has a localized decoy first (skipped)
and trailing whitespace (trimmed)."""
(apps_dir / (app_id + ".desktop")).write_text(
"[Desktop Entry]\nName=Kick Test\nIcon[en_US]=wrong-name\n"
"Icon=%s \nType=Application\n" % icon_value)
def _icon_theme_file(home, name, png_bytes=None, svg_text=None):
d = home / "icons" / "hicolor" / "48x48" / "apps"
d.mkdir(parents=True, exist_ok=True)
if png_bytes is not None:
(d / (name + ".png")).write_bytes(png_bytes)
if svg_text is not None:
(d / (name + ".svg")).write_text(svg_text)
apps = home / "applications"
apps.mkdir(parents=True, exist_ok=True)
return apps
def _wait_overlay_icon(s, app_id, timeout=15.0):
"""Hold Alt (overlay visible, icons resolve on draw) until the icon
load is logged; each dump forces another overlay frame."""
s.mods(ALT)
try:
rx = re.compile(r"\[overlay\] icon %s" % re.escape(app_id))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if rx.search(s.read_log()):
# Two more frames so the resolved texture itself draws.
s.dump_host()
s.dump_host()
return
s.dump_host()
pytest.fail(f"overlay never resolved an icon for {app_id}")
finally:
s.mods(0)
def test_standalone_overlay_icon_png_from_desktop(tmp_path, monkeypatch):
"""A PNG icon named by a .desktop Icon= value resolves in the overlay:
desktop-file parse, theme lookup and PNG decode all run."""
home = tmp_path / "xdghome"
apps = _icon_theme_file(home, "testicon-png",
png_bytes=_png_rgba(8, 8, (255, 0, 0, 255)))
_icon_desktop(apps, "icontest", "testicon-png")
monkeypatch.setenv("XDG_DATA_HOME", str(home))
with _run_standalone({}, "%s --app-id icontest '#ff0000'" % WL, wait="app0_mapped") as s:
_wait_overlay_icon(s, "icontest")
_assert_alive(s, "compositor died resolving a PNG icon")
def test_standalone_overlay_icon_svg_from_desktop(tmp_path, monkeypatch):
"""An SVG icon named by a .desktop Icon= value resolves in the overlay
via the librsvg decode path."""
home = tmp_path / "xdghome"
apps = _icon_theme_file(home, "testicon-svg", svg_text=_SVG_RED)
_icon_desktop(apps, "icontest", "testicon-svg")
monkeypatch.setenv("XDG_DATA_HOME", str(home))
with _run_standalone({}, "%s --app-id icontest '#ff0000'" % WL, wait="app0_mapped") as s:
_wait_overlay_icon(s, "icontest")
_assert_alive(s, "compositor died resolving an SVG icon")
def test_standalone_overlay_icon_absolute_path_via_dirs(tmp_path, monkeypatch):
"""An absolute-path Icon= value loads directly, with the .desktop found
via XDG_DATA_DIRS (not XDG_DATA_HOME)."""
home = tmp_path / "xdghome"
home.mkdir()
(home / "applications").mkdir()
dirs = tmp_path / "xdgdirs"
apps = dirs / "applications"
apps.mkdir(parents=True)
abs_icon = tmp_path / "absicon.png"
abs_icon.write_bytes(_png_rgba(8, 8, (0, 255, 0, 255)))
_icon_desktop(apps, "icontest", str(abs_icon))
monkeypatch.setenv("XDG_DATA_HOME", str(home))
monkeypatch.setenv("XDG_DATA_DIRS", str(dirs))
with _run_standalone({}, "%s --app-id icontest '#ff0000'" % WL, wait="app0_mapped") as s:
_wait_overlay_icon(s, "icontest")
_assert_alive(s, "compositor died resolving an absolute-path icon")
def test_standalone_overlay_icon_missing_and_corrupt(tmp_path, monkeypatch):
"""No desktop entry means the placeholder (theme/pixmap misses); a
corrupt PNG fails decode the same way. Neither may disturb the
compositor."""
home = tmp_path / "xdghome"
home.mkdir()
(home / "applications").mkdir()
monkeypatch.setenv("XDG_DATA_HOME", str(home))
# Missing: no desktop file for this app_id at all.
with _run_standalone({}, "%s --app-id noiconapp '#ff0000'" % WL, wait="app0_mapped") as s:
s.mods(ALT)
try:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
s.dump_host()
assert "[overlay] icon noiconapp" not in s.read_log(), \
"phantom icon resolved with no desktop entry"
finally:
s.mods(0)
_assert_alive(s)
# Corrupt: desktop entry points at undecodable bytes.
apps = _icon_theme_file(home, "badicon",
png_bytes=b"not a png at all")
_icon_desktop(apps, "badiconapp", "badicon")
with _run_standalone({}, "%s --app-id badiconapp '#ff0000'" % WL, wait="app0_mapped") as t:
t.mods(ALT)
try:
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
t.dump_host()
assert "[overlay] icon badiconapp" not in t.read_log(), \
"phantom icon resolved from corrupt PNG"
finally:
t.mods(0)
_assert_alive(t, "compositor died on a corrupt icon")
def test_standalone_headless_x11_class_deleted(tmp_path):
"""An X11 window whose WM_CLASS is deleted keeps the compositor alive:
set_class with a NULL class must mirror "" (not raw NULL) onto the
foreign-toplevel handles (raw NULL segfaulted in wlroots' strdup)."""
_require_file(XC, "needs build/xclass (libX11 dev headers)")
disp_file = tmp_path / "xdisplay"
probe_log = tmp_path / "xclass.log"
probe = None
with _run_standalone(
{},
"sh -c 'echo $DISPLAY > %s; exec sleep 999'" % disp_file,
"%s --app-id keep '#0000ff' 320x240" % WL, wait="any") as s:
try:
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if disp_file.exists() and disp_file.read_text().strip():
break
if "XWayland unavailable" in s.read_log():
pytest.skip("no Xwayland binary")
time.sleep(0.1)
else:
pytest.fail("embedded Xwayland DISPLAY never exported")
xdisp = disp_file.read_text().strip()
assert xdisp.startswith(":"), f"bad DISPLAY {xdisp!r}"
with open(probe_log, "w") as plog:
probe = subprocess.Popen(
[XC, "6"], env=dict(os.environ, DISPLAY=xdisp),
stdin=subprocess.DEVNULL, stdout=plog, stderr=subprocess.STDOUT,
start_new_session=True,
preexec_fn=_preexec_death(os.getpid()))
# Wait until the probe deletes WM_CLASS (the NULL set_class moment).
assert _wait_out(probe_log, "class deleted", timeout=20.0), \
"xclass probe never deleted WM_CLASS"
time.sleep(1.5)
_assert_alive(s, "compositor died on WM_CLASS delete")
_assert_no_crash(s.read_log())
finally:
try:
if probe is not None:
probe.kill()
except Exception:
pass
# ===================== MRU window-stack (Alt+Tab / Alt+1) =====================
def test_alt_tab_walks_windows(session):
"""Alt+Tab previews the second-from-top window in the MRU stack.
Settle first (both tabs mapped: no further auto-activations happen after
that), then Alt+Tab must preview whichever tab is not the settled crown
-- under load either tab can win the startup crown, since the last mapper
promotes itself."""
session.wait_any_active()
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
start = session.active()
other = 1 - start
session.chord_ack(ALT, TAB)
session.wait_active(other)
assert session.active() == other
def test_alt_tab_commit_on_release(session):
"""Releasing Alt after an Alt+Tab move commits the shown window to the
top of the MRU stack: the next Alt+Tab returns to the just-left one.
First Alt+Tab moves from the settled crown to the other tab; release
commits it to the front; a second Alt+Tab steps back."""
session.wait_any_active()
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
start = session.active()
other = 1 - start
session.chord_ack(ALT, TAB)
session.wait_active(other)
# The release commits the preview to the MRU front ([mru] commit): the
# second chord must not be processed before that commit lands, or it
# steps a stale stack. The commit belongs to chord 1, so anchor the
# wait on the whole-log scan -- a fresh offset here would already be
# past the line. (Only chord 1 can have committed in this session.)
session.wait_for(r"\[mru\] commit app=%d" % other)
session.chord_ack(ALT, TAB)
session.wait_active(start)
assert session.active() == start
def test_alt_1_synonymous_with_tab(session):
"""Alt+1 previews the next window just like Alt+Tab."""
session.wait_any_active()
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
start = session.active()
other = 1 - start
session.chord_ack(ALT, ONE)
session.wait_active(other)
assert session.active() == other
def test_alt_tab_hold_repeats():
"""Holding Alt+Tab keeps walking the MRU stack via synthesized repeats,
without reshuffling the stack mid-hold."""
# Needs real wall-clock repeats like the Alt+Down hold test: private
# session with repeat_off=False (the suite default stretches the delay).
_require_host_wayland()
with _run_nested(f"{WL} '{RED}'", f"{WL} '{BLUE}'",
repeat_off=False, wait="any") as session:
session.wait_surface_mapped(1)
session.wait_surface_mapped(0)
session.active()
base = session.active_count()
pos = len(session.read_log())
session.mods(ALT)
session.key(TAB, KEY_PRESSED)
session.wait_for_new_after(pos, r"\[repeat\] start key=%d" % TAB,
timeout=30.0)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline and session.active_count() - base < 2:
time.sleep(0.1)
if session.active_count() - base < 2:
pytest.fail("held Alt+Tab never repeated (timer armed but "
"no synthesized walk followed)")
session.key(TAB, KEY_RELEASED)
session.mods(0)
session.wait_for_new_after(pos, r"\[repeat\] stop", timeout=30.0)
settled = session.active_count()
assert settled >= base + 2, \
f"held Alt+Tab produced only {settled - base} transitions"
assert session.active_count() == settled, \
"repeats continued after release"