#!/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 ctypes import glob 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__))) BIN = os.path.join(ROOT, "build", "kick") WL = 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") LOG = "/tmp/tabtest.log" 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.""" 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 ESC = 1 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 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): self.proc = proc self._socket = None # -- log watching ---------------------------------------------------- def read_log(self): """Whole current log contents (rescanned fresh each call).""" with open(LOG, "r", errors="replace") as f: return f.read() def wait_for(self, pattern, timeout=15.0): """Wait until `pattern` matches somewhere in the whole log. Re-scans from the start each poll: 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}") @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("0) or previous tab via Alt+arrow.""" key = DOWN if direction > 0 else UP self.chord(ALT, key) def cycle_ack(self, direction, timeout=5.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=3.0): since = time.monotonic() while time.monotonic() - since < timeout: if self.active() == want: return time.sleep(0.1) pytest.fail(f"never reached active app={want}") def wait_active_change(self, away_from, timeout=3.0): """Wait for active to differ from `away_from`; return the new value. Unlike wait_active(), this ignores historical log lines: it only returns once a *new* active tab shows up, so a stale launch-time line can't satisfy it before the key event is processed. """ since = time.monotonic() while time.monotonic() - since < timeout: cur = self.active() if cur is not None and cur != away_from: return cur time.sleep(0.1) pytest.fail(f"active app never changed away from {away_from}") def wait_any_active(self, timeout=10.0): """Wait until the 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=10.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=10.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=10.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() for ppm in glob.glob("/tmp/host_*.ppm") + glob.glob("/tmp/scene_*.ppm"): os.unlink(ppm) @pytest.fixture(scope="function") def session(): if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") for path in (BIN, WL): if not os.access(path, os.X_OK): pytest.skip(f"build first: {path}") try: os.unlink(LOG) except FileNotFoundError: pass proc = subprocess.Popen( ["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1", "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) 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}") try: os.unlink(LOG) except FileNotFoundError: pass # 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", "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) 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 s.proc.poll() is None, f"standalone exited early: {s.read_log()[-500:]}" start = s.wait_any_active() assert start in (0, 1) s.cycle(+1) assert s.wait_active_change(start) == 1 - start assert s.proc.poll() is None 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 s.proc.poll() is None 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 s.proc.poll() is None 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 s.proc.poll() is None 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 s.proc.poll() is None 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. s.warp(60, 60) time.sleep(0.3) before = patch_at(s.dump_host(), *target) assert len(set(before)) == 1, \ "probe area should be pure client colour before the warp" s.warp(*target) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if distinct(patch_at(s.dump_host(), *target), before): break time.sleep(0.1) else: pytest.fail("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). s.warp(640, 400) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if not distinct(patch_at(s.dump_host(), *target), before): break time.sleep(0.1) else: pytest.fail("cursor left a remnant at the warp target") assert s.proc.poll() is None 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 s.proc.poll() is None 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. s.warp(40, 40) time.sleep(0.3) 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. s.warp(*start) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if distinct(patch_at(s.dump_host(), *start), before_start): break time.sleep(0.1) else: pytest.fail("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. s.ptr_motion(*delta) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: path = s.dump_host() if distinct(patch_at(path, *moved), before_moved) and \ not distinct(patch_at(path, *start), before_start): break time.sleep(0.1) else: pytest.fail("relative motion did not move the cursor %+d,%+d" % delta) assert s.proc.poll() is None 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.""" s = _spawn_standalone({}, "%s --constrain locked '#ff0000'" % WL) try: s.wait_app_active(0) s.wait_active_frame() assert s.proc.poll() is None 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. s.warp(*lock) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if "[constraint] locked on surface" in s.read_log(): break time.sleep(0.1) else: pytest.fail("locked constraint never activated") deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if distinct(patch_at(s.dump_host(), *lock), pure_lock): break time.sleep(0.1) else: pytest.fail("cursor never appeared at the lock position") # Relative motion must not move the pinned cursor. s.ptr_motion(*[target[0] - lock[0], target[1] - lock[1]]) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: path = s.dump_host() if distinct(patch_at(path, *lock), pure_lock) and \ not distinct(patch_at(path, *target), pure_target): break time.sleep(0.1) else: pytest.fail("locked pointer moved away from the lock position") assert s.proc.poll() is None finally: s.close() 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.""" s = _spawn_standalone({}, "%s --constrain confined " "--confine-rect 100 100 200 150 '#00ff00'" % WL) try: s.wait_app_active(0) s.wait_active_frame() assert s.proc.poll() is None 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" s.warp(*start) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if "[constraint] confined on surface" in s.read_log(): break time.sleep(0.1) else: pytest.fail("confined constraint never activated") deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if distinct(patch_at(s.dump_host(), *start), pure_start): break time.sleep(0.1) else: pytest.fail("cursor never appeared at the start position") # A big relative motion clamps to the region edge, not the raw target. s.ptr_motion(500, 400) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: path = s.dump_host() if distinct(patch_at(path, *clamped), pure_clamped) and \ not distinct(patch_at(path, *outside), pure_start): break time.sleep(0.1) else: pytest.fail("confined pointer escaped the constraint region") assert s.proc.poll() is None finally: s.close() 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.""" s = _spawn_standalone({}, "--screen-mode", "480x320", "build/wltest '#ff0000'") try: s.wait_app_active(0) s.wait_active_frame() assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" 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]) s.warp(30, 30) time.sleep(0.3) 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" s.warp(*start) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: if distinct(patch_at(s.dump_host(), *start), before_start): break time.sleep(0.1) else: pytest.fail("cursor never became visible at the start target") s.ptr_motion(*delta) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: path = s.dump_host() if distinct(patch_at(path, *moved), before_moved) and \ not distinct(patch_at(path, *start), before_start): break time.sleep(0.1) else: pytest.fail("relative motion did not move the cursor %+d,%+d" % delta) assert s.proc.poll() is None finally: s.close() def _spawn_standalone(extra_env, *apps): """Standalone compositor on the headless backend with a custom env for cursor-theme tests; XCURSOR_THEME/XCURSOR_PATH come only from extra_env.""" try: os.unlink(LOG) except FileNotFoundError: pass 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 {}) 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])) cmd += ["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) 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() s = _spawn_standalone({"XCURSOR_PATH": str(empty), "XCURSOR_THEME": "DefinitelyMissingTheme"}, "build/wltest '#ff0000'") try: s.wait_app_active(0) assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" 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:]}" finally: s.close() def test_standalone_headless_protocol_globals(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. wltest --globals-out dumps the advertised interface list to a file (CLI-tab stdout is /dev/null'd by kick).""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} for want in ("wl_compositor", "wl_shm", "xdg_wm_base", "wl_seat", "wp_presentation", "zxdg_output_manager_v1", "wp_viewporter"): assert want in names, \ f"missing global {want}; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone({}, "%s --primary-set 'hello' '#ff0000'" % WL, "%s --primary-demand '%s' '#00ff00'" % (WL, out)) try: s.wait_any_active() s.wait_active_frame() assert s.proc.poll() is None # 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) deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "primary-demand tab never received the selection" assert out.read_text() == "hello", \ f"primary round-trip payload mismatch: {out.read_text()!r}" finally: s.close() def test_standalone_headless_text_input_globals(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.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} for want in ("zwp_text_input_manager_v3", "zwp_input_method_manager_v2"): assert want in names, \ f"missing global {want}; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --input-method --im-commit 'nihongo' --im-out '%s'" % (WL, im_out), "%s --text-input --text-input-out '%s' '#ff0000'" % (WL, ti_out)) try: s.wait_any_active() # 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 s.proc.poll() is None deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if ti_out.exists(): break time.sleep(0.1) assert ti_out.exists(), "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 s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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" s = _spawn_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)) try: s.wait_any_active() # 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 s.proc.poll() is None # Boot focuses the last tab (ti1); it should get the first commit # and then exit, handing focus to its left neighbor (ti0). deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if ti1.exists(): break time.sleep(0.1) assert ti1.exists(), "focused field never got the IME commit string" deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if ti0.exists(): break time.sleep(0.1) assert ti0.exists(), \ "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 s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_content_single_pixel_globals(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.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} 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)}" finally: s.close() 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.""" s = _spawn_standalone({}, "%s --single-pixel '#ff0000'" % WL) try: s.wait_any_active() 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 s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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.""" s = _spawn_standalone({}, "%s --content-type video '#ff0000'" % WL) try: s.wait_any_active() s.wait_for(r"\[diag\] map app=0 content=video") assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_gamma_globals(tmp_path): """The standalone compositor advertises zwlr_gamma_control_manager_v1 so night-light tools can bind it.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "zwlr_gamma_control_manager_v1" in names, \ f"missing gamma global; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --gamma --gamma-out '%s'" % (WL, out), "%s '#ff0000'" % WL) try: s.wait_any_active() deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "gamma client never got a reply" assert out.read_text() == "failed", \ f"expected headless gamma failure, got: {out.read_text()!r}" assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" log = s.read_log() for marker in ("Segfault", "Aborted", "Assertion", "BACKTRACE"): assert marker not in log, \ f"crash indicator {marker!r} in log:\n{log[-2000:]}" finally: s.close() def test_standalone_headless_dialog_globals(tmp_path): """The standalone compositor advertises xdg_wm_dialog_v1 so clients can mark toplevels modal.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "xdg_wm_dialog_v1" in names, \ f"missing dialog global; advertised: {sorted(names)}" finally: s.close() 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.""" s = _spawn_standalone( {}, "%s --extra '#00ff00' --extra-modal --extra-delay 1500 " "--app-id dlg '#0000ff'" % WL, "%s '#ff0000'" % WL) try: assert s.wait_any_active() == 1 s.wait_active_frame() assert s.proc.poll() is None assert s.wait_active_change(1, timeout=10.0) == 0 s.wait_for(r"\[dialog\] modal app=0\b") assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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.""" s = _spawn_standalone( {}, "--tab-groups", "off", "%s --extra '#00ff00' --extra-modal --extra-delay 1500 " "--app-id dlg '#0000ff'" % WL, "%s '#ff0000'" % WL) try: assert s.wait_any_active() == 1 s.wait_active_frame() assert s.proc.poll() is None # The modal owns the tab glued right after its parent tab. s.wait_for(r"\[dialog\] modal app=1\b") assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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.""" s = _spawn_standalone({}, "%s --tearing async '#ff0000'" % WL) try: s.wait_any_active() s.wait_active_frame() assert s.proc.poll() is None s.wait_for(r"\[tearing\] async app=0\b") assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --fractional-out '%s' '#ff0000'" % (WL, out), "%s '#00ff00'" % WL) try: s.wait_any_active() deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "client never got preferred_scale" assert out.read_text() == "120", \ f"expected scale 120 (= 1.0), got: {out.read_text()!r}" assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_gestures_globals(tmp_path): """The standalone compositor advertises zwp_pointer_gestures_v1 so touchpad clients can bind swipe/pinch/hold.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "zwp_pointer_gestures_v1" in names, \ f"missing gestures global; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --gesture-log '%s' '#ff0000'" % (WL, log)) try: s.wait_any_active() s.wait_active_frame() assert s.proc.poll() is None # Warp until the client logs a full swipe: proves pointer-focus # routing (gestures only fan out to the focused client) and # delivery in one loop; focus then persists for pinch/hold. deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: s.warp(400, 400) time.sleep(0.05) s.warp(600, 400) s.swipe(GESTURE_BEGIN, 3) s.swipe(GESTURE_UPDATE, 3, 10, -5) s.swipe(GESTURE_END, 3) time.sleep(0.2) text = _gestures_text(log) if "swipe begin fingers=3" in text \ and "swipe end cancelled=0" in text: break 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. s.pinch(GESTURE_BEGIN, 2) s.pinch(GESTURE_UPDATE, 2, 1, 2, 1.5, 90.0) s.pinch(GESTURE_END, 2) s.hold(HOLD_BEGIN, 2) s.hold(HOLD_END, 2) s.swipe(GESTURE_BEGIN, 3) s.swipe(GESTURE_CANCEL, 3) s.pinch(GESTURE_BEGIN, 2) s.pinch(GESTURE_CANCEL, 2) s.hold(HOLD_BEGIN, 1) s.hold(HOLD_CANCEL, 1) deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: text = _gestures_text(log) if text.count("cancelled=1") >= 3 \ and "hold end cancelled=0" 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 s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" finally: s.close() 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 wlr-data-control (when the host implements v2) without echo-looping.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") out = tmp_path / "primary.out" s = _nested_session("%s --primary-set 'hello' '#ff0000'" % WL, "%s --primary-demand-expect '%s' hello '#00ff00'" % (WL, out)) try: s.wait_any_active() s.wait_active_frame() assert s.proc.poll() is None 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 s.proc.poll() is None, \ f"compositor exited mid-test:\n{s.read_log()[-500:]}" 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 s.proc.poll() is None finally: s.close() @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 wlr-data-control v2 (e.g. GNOME/mutter): the host->nested primary mirror only exists on that backend, so this test needs a data-control host -- run it on a compositor like labwc/sway.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") token = f"kick-h2n-{os.getpid()}-{time.monotonic_ns()}" out = tmp_path / "primary.out" s = _nested_session("%s --primary-demand-expect '%s' '%s' '#00ff00'" % (WL, out, token), f"{WL} '#ff0000'") try: s.wait_any_active() s.wait_active_frame() assert s.proc.poll() is None # The host->nested primary bridge only exists over wlr-data-control # v2 (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 wlr-data-control v2; " "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 s.proc.poll() is None, \ f"compositor exited mid-test:\n{s.read_log()[-500:]}" 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 s.proc.poll() is None finally: s.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() s = _spawn_standalone({"XCURSOR_PATH": str(empty)}, "build/wltest '#ff0000'") try: s.wait_app_active(0) assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" log = s.read_log() assert "builtin cursor in use" not in log, \ f"no warning expected for a 'default' request:\n{log[-400:]}" finally: s.close() 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") s = _spawn_standalone({"XCURSOR_THEME": "Adwaita"}, "build/wltest '#ff0000'") try: s.wait_app_active(0) assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" log = s.read_log() assert "builtin cursor in use" not in log, \ f"installed theme must not be substituted:\n{log[-400:]}" finally: s.close() 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.""" s = _spawn_standalone({}, "build/wltest --cursor-shape text '#ff0000'") try: s.wait_app_active(0) s.pointer_enter_until(r"\[cursor\] shape text") finally: s.close() 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.""" s = _spawn_standalone({}, "build/wltest '#0000ff'", "build/wltest --cursor-shape text '#ff0000'") try: 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}") finally: s.close() def test_nested_cursor_shape_forwarded(): """In nested mode a client's cursor-shape request is forwarded to the host pointer (the host draws the cursor). Deterministic when the host honours the forwarded set_shape; on a desktop host the outcome depends on the host's cursor-shape-v1 support (and on whether the real pointer has ever entered the window, which provides the serial the request is tagged with).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("no host compositor for nested mode") headless_host = os.environ["WAYLAND_DISPLAY"].startswith("kick-") s = _nested_session("build/wltest '#0000ff'", "build/wltest --cursor-shape text '#ff0000'") try: s.wait_app_active(1) if headless_host: s.pointer_enter_until(r"\[cursor\] host shape text") else: s.pointer_enter_until(r"\[cursor\] host shape (text|n/a)") finally: s.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 s.proc.poll() is None, f"standalone exited early: {s.read_log()[-500:]}" 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.""" s = _spawn_standalone({}, "--screen-mode", "480x320", "build/wltest '#ff0000'") try: s.wait_app_active(0) assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" w, h, _ = s.ppm_rgb(s.dump_host()) assert (w, h) == (480, 320), \ f"output should honour --screen-mode 480x320, got {w}x{h}" finally: s.close() 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.""" s = _spawn_standalone({}, "--screen-mode", "640x480", "build/wltest '#ff0000'", "build/wltest '#0000ff'") try: s.wait_any_active() assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" assert "starts with '-'" not in s.read_log(), \ f"--screen-mode leaked into the app list:\n{s.read_log()[-400:]}" finally: s.close() 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 s.proc.poll() is None, f"standalone exited early: {s.read_log()[-500:]}" 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 _poll_paste(display, want, deadline=45.0): """Wait until `wl-paste` on `display` returns exactly `want`.""" r = None since = time.monotonic() while time.monotonic() - since < deadline: try: r = _run_display(display, ["wl-paste", "-n"]) 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")) @pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed") def test_clipboard_nested_to_host(session): token = f"kick-n2h-{os.getpid()}-{time.monotonic_ns()}" copy = subprocess.Popen( ["wl-copy", "-f", token], env={**os.environ, "WAYLAND_DISPLAY": session.socket}, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=_preexec_death(os.getpid())) try: _poll_paste(os.environ.get("WAYLAND_DISPLAY", "wayland-0"), token) finally: copy.terminate() copy.wait(timeout=5) @pytest.mark.skipif(NEED_WL_CLIPBOARD, reason="wl-clipboard tools not installed") def test_clipboard_host_to_nested(session): token = f"kick-h2n-{os.getpid()}-{time.monotonic_ns()}" copy = subprocess.Popen( ["wl-copy", "-f", token], env=dict(os.environ), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=_preexec_death(os.getpid())) try: _poll_paste(session.socket, token) finally: copy.terminate() copy.wait(timeout=5) def test_tabs_map(session): session.wait_for(r"\[diag\] map app=0") session.wait_for(r"\[diag\] map app=1") def test_cycle_tabs(session): start = session.wait_any_active() assert start in (0, 1) session.cycle(+1) other = session.wait_active_change(start) assert other == 1 - start session.cycle(-1) assert session.wait_active_change(other) == start def test_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() for code in (F11, 67, 68): # F11, F9, F10 session.tap(code) time.sleep(0.3) 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 session.proc.poll() is None def test_client_fullscreen_round_trip(session): """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.""" idx = session.wait_any_active() session.wait_surface_mapped(idx) session.tap(F) session.wait_for("host fullscreen=1") session.wait_for("clients fullscreen=1") session.tap(F) session.wait_for("host fullscreen=0") session.wait_for("clients fullscreen=0") assert session.proc.poll() is None def test_alt_f11_toggles_host_fullscreen(session): """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.""" idx = session.wait_any_active() session.wait_surface_mapped(idx) session.chord(ALT, F11) session.wait_for("host fullscreen=1") session.wait_for("clients fullscreen=1") session.chord(ALT, F11) session.wait_for("host fullscreen=0") session.wait_for("clients fullscreen=0") assert session.proc.poll() is None def test_alt_f11_propagates_to_stack_members(): """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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'", extra_args=("--tab-groups", "on")) try: idx = s.wait_any_active() s.wait_surface_mapped(idx) s.wait_for(r"\[sub\] stack app=0\b") s.chord(ALT, F11) s.wait_for("host fullscreen=1") s.wait_for("clients fullscreen=1") s.wait_for("members fullscreen=1") s.chord(ALT, F11) s.wait_for("host fullscreen=0") s.wait_for("clients fullscreen=0") s.wait_for("members fullscreen=0") assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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(session): start = session.wait_any_active() assert start in (0, 1) # Press and hold Alt+Down and wait for the compositor's own repeat # timer (600ms delay, then 25Hz) to cycle several tabs. base is counted # BEFORE the press so the press's own immediate cycle always counts toward # the >=3 transitions: the count can't race with the press being processed. # We still poll for the transitions rather than sleeping a fixed 1.3s so # any scheduling slack never pushes the repeats past the window. session.mods(ALT) base = session.active_count() session.key(DOWN, KEY_PRESSED) deadline = time.monotonic() + 5.0 while time.monotonic() < deadline and session.active_count() - base < 3: time.sleep(0.05) transitions = session.active_count() - base # Clean release: let go of the key and the modifier. session.key(DOWN, KEY_RELEASED) session.mods(0) assert transitions >= 3, ( f"held Alt+Down produced only {transitions} active transitions " "(expected repeats to cycle several tabs)" ) settled = session.active_count() time.sleep(0.3) 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 session.proc.poll() is None def test_non_ctrl_key_ignored(session): start = session.wait_any_active() assert start in (0, 1) base_active = session.active_count() session.key(30, KEY_PRESSED) # 'A' session.key(30, KEY_RELEASED) time.sleep(0.3) assert session.active_count() == base_active, "'A' without ctrl cycled tabs" assert "host fullscreen=" not in session.read_log(), "'A' toggled fullscreen" assert session.proc.poll() is None session.tap(F11) time.sleep(0.3) assert "host fullscreen=" not in session.read_log(), "F11 toggled fullscreen" assert session.proc.poll() is None 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 session.proc.poll() is None, "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.""" start = session.wait_any_active() assert start in (0, 1) # 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). if start == 0: 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() third = subprocess.Popen( [WL, GREEN], env=dict(os.environ, WAYLAND_DISPLAY=session.socket), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, preexec_fn=_preexec_death(os.getpid())) try: # 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 session.proc.poll() is None, \ "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 session.proc.poll() is None, \ "compositor exited after first tab died" finally: if third.poll() is None: third.terminate() third.wait(timeout=5) def _nested_session(*tabs, extra_args=()): """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.""" for path in (BIN, WL): if not os.access(path, os.X_OK): pytest.skip(f"build first: {path}") try: os.unlink(LOG) except FileNotFoundError: pass proc = subprocess.Popen( ["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1", "stdbuf", "-oL", BIN, *extra_args] + list(tabs), stdin=subprocess.PIPE, stdout=open(LOG, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid()), ) return Session(proc) 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() before = session.active_count() session.chord(ALT | SHIFT, 20) deadline = time.monotonic() + 15.0 while session.active_count() <= before: assert session.proc.poll() is None, \ "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 session.proc.poll() is None finally: if old_term is None: os.environ.pop("TERMINAL", None) else: os.environ["TERMINAL"] = old_term @pytest.mark.flaky 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).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") # Tab 0 (RED) opens a green extra toplevel from the same client. s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'", extra_args=("--tab-groups", "off")) try: s.wait_any_active() 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 s.proc.poll() is None, "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 have appeared hidden: only the crown's active line # may exist. time.sleep(0.5) assert len(ACTIVE_RE.findall(s.read_log())) == 1, \ "sub-window stole focus from the crowned tab" assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "off")) try: s.wait_any_active() # the single tab is crowned, then ... s.wait_for(r"active app=1") # ... its sub-window activates on map. assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "on")) try: 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") time.sleep(0.5) claims = CLAIM_RE.findall(s.read_log()) assert len(claims) == 1 and claims[0][0] == "0", \ f"stacked sub-window claimed a slot: {claims}" assert len(ACTIVE_RE.findall(s.read_log())) == 1, \ "stacked sub-window stole focus via an active transition" assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") # Tab 0 (RED) opens a green extra; tab 1 (BLUE) is the startup-selected # active tab, so the extra must map hidden. s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", f"{WL} '{BLUE}'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() # app 1 crowned s.wait_for(r"\[sub\] stack app=0\b") s.wait_for(r"\[sub\] map app=0\b") # mapped while its tab is hidden time.sleep(0.3) assert "[sub] focus app=0" not in s.read_log(), \ "hidden tab's stacked window took the keyboard" s.cycle_ack(-1) # show tab 0 s.wait_for(r"\[sub\] focus app=0\b") assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() s.wait_for(r"\[sub\] focus app=0\b") # extra holds the keyboard s.tap(45) # 'X': close the extra s.wait_for(r"\[sub\] unstack app=0\b") s.wait_for(r"\[sub\] refocus app=0\b") time.sleep(0.5) claims = CLAIM_RE.findall(s.read_log()) assert len(claims) == 1, f"tab lost or gained slots: {claims}" assert s.proc.poll() is None, "compositor died when the extra closed" s.chord(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) finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() s.wait_for(r"\[sub\] focus app=0\b") # The resolved-title log only fires while overlay rows are drawn, # so hold Alt for the whole check. s.mods(ALT) try: s.wait_for(r"\[title\] app=0 'extra'") s.tap(45) # 'X': close the extra s.wait_for(r"\[title\] app=0 '#ff0000'") finally: s.mods(0) assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() # [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; wait for the member's surface # to actually map ([diag] map app=0 appearing after the stack line) # so it is a closable individual by the time the chord lands. stack_rx = re.compile(r"\[sub\] stack app=0\b") map_rx = re.compile(r"\[diag\] map app=0\b") stack_pos = -1 deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: log = s.read_log() if stack_pos < 0: ms = list(stack_rx.finditer(log)) if ms: stack_pos = ms[-1].start() if stack_pos >= 0 and map_rx.search(log, stack_pos): break time.sleep(0.05) else: pytest.fail("stacked member surface never mapped" " ([diag] map app=0 after [sub] stack)") claims_before = CLAIM_RE.findall(s.read_log()) s.chord(ALT, DELETE) # close the active window s.wait_for(r"\[sub\] unstack app=0\b") # only the window closed time.sleep(0.5) assert s.proc.poll() is None, \ "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}" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'") try: s.wait_any_active() 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 s.proc.poll() is None, \ "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() finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'") try: s.wait_any_active() 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 s.proc.poll() is None, "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 s.proc.poll() is None, "Escape after Ctrl+Alt+Q quit anyway" assert s.cycle_ack(+1) is not None, "tab cycling broke after cancel" assert s.proc.poll() is None, "tab cycling broke after cancelled quit" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'") try: s.wait_any_active() s.wait_for(r"\[claim\] app=0\b") s.chord(CTRL | ALT, Q) _wait_exit(s, 0) assert "[confirm] quit:" not in s.read_log(), \ "single-window quit unnecessarily asked for confirmation" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} --extra '{GREEN}' '{RED}'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() 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(LEFT) # Alt+Left = backward -> main (RED) s.wait_for(r"\[title\] app=0 '#ff0000'") s.tap(RIGHT) # Alt+Right = forward -> extra again s.wait_for(r"\[title\] app=0 'extra'") finally: s.mods(0) assert s.proc.poll() is None, "Alt+Left/Right tore the tab down" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'") try: 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 s.proc.poll() is None, "compositor exited after Alt+Delete" s.wait_for(r"kick: closing tab 1") finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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]).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'") try: s.wait_any_active() # 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 s.proc.poll() is None, \ "compositor exited after closing rightmost tab" finally: s.close() if s.proc.poll() is None: s.proc.terminate() def test_alt_delete_second_tab_right_neighbor_two_tabs(): """Two-tab session: closing tab 0 makes its right neighbor (1) active.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{BLUE}'") try: s.wait_any_active() if s.active() == 1: if s.cycle_ack(-1) != 0: s.wait_active(0) s.chord(ALT, DELETE) assert s.wait_active_change(0) == 1, \ "closing tab 0 did not activate tab 1" assert s.proc.poll() is None, "compositor exited after closing tab 0" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") old_term = os.environ.get("TERMINAL") os.environ["TERMINAL"] = f"{WL} '{RED}'" try: s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'") try: s.wait_any_active() # Make the third tab (index 2) active so there is a middle and an # end to move through. for _ in range(3): if s.active() == 2: break 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) before = s.active_count() # Adjacent move UP: [R,G,B] -> [R,B,G]. s.chord(ALT | SHIFT, UP) s.wait_for(r"\[reorder\] app=1 dir=-1") # Adjacent move DOWN: -> [R,G,B]. s.chord(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(ALT | SHIFT, DOWN) s.wait_for(r"\[reorder\] app=0 dir=1") time.sleep(0.3) 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(ALT | SHIFT, 20) deadline = time.monotonic() + 15.0 while len(re.findall(r"\[claim\] app=(\d+) pid=(\d+)", s.read_log())) <= len(claims): assert s.proc.poll() is None, \ "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: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{BLUE}'", extra_args=["--modifier", "super"]) try: start = s.wait_any_active() assert s.proc.poll() is None, "compositor exited with --modifier super" # Super+Down cycles; a plain Alt+Down must not. s.chord(SUPER, DOWN) other = s.wait_active_change(start, timeout=5.0) assert other == 1 - start, "Super+Down did not cycle the other tab" s.chord(ALT, DOWN) time.sleep(0.3) assert s.active() == other, \ "Alt+Down still cycled in --modifier super mode" # Super+Shift+Up reorders the active tab. s.chord(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(SUPER, DELETE) s.wait_for(r"kick: closing tab") deadline = time.monotonic() + 5.0 while s.active_count() <= count: assert s.proc.poll() is None, \ "compositor exited after Super+Delete" assert time.monotonic() < deadline, \ "active never handed off after Super+Delete" time.sleep(0.1) finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") # 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) s = _nested_session(f"{WL} '{RED}'", str(wrapper)) try: 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 s.proc.poll() is None, "compositor exited after closing trap tab" finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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(DELETE) time.sleep(0.3) assert session.active() == base_active, \ "Delete without Alt closed the active tab" assert session.proc.poll() is None 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 s.proc.poll() is None, "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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") for path in (BIN, WL): if not os.access(path, os.X_OK): pytest.skip(f"build first: {path}") try: os.unlink(LOG) except FileNotFoundError: pass # The one tab is the terminal named by TERMINAL (a wltest, so no real # terminal emulator has to be installed on the test host). proc = subprocess.Popen( ["env", "KICK_LOG=2", "KICK_SYNTH_INPUT=1", f"TERMINAL={WL} '{RED}'", "stdbuf", "-oL", BIN], stdin=subprocess.PIPE, stdout=open(LOG, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid()), ) s = Session(proc) try: s.wait_for(r"\[diag\] map app=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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") # CLI #0 (RED) maps hidden; the active tab is the still-black `sleep`. s = _nested_session(f"{WL} '{RED}'", "sleep 999") try: s.wait_for(r"\[diag\] map app=0") s.chord(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 s.proc.poll() is None, \ "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" finally: s.close() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'", f"{WL} '{GREEN}'", f"{WL} '{BLUE}'", extra_args=["--start-cmd-index", "1"]) try: assert s.wait_any_active() == 0, \ "--start-cmd-index 1 should make the first command active" assert s.proc.poll() is None finally: s.close() if s.proc.poll() is None: s.proc.terminate() 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") s = _spawn_kick_standalone(["/bin/false"]) try: s.proc.wait(timeout=10) 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 finally: s.close() # ===================== 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) try: os.unlink(LOG) except FileNotFoundError: pass 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", "stdbuf", "-oL", BIN] + list(commands), stdin=subprocess.PIPE, stdout=open(LOG, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid()), ) return Session(proc) @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. """ if not HAVE_XWAYLAND: pytest.skip("needs Xwayland and xmessage on PATH") s = _spawn_kick_standalone([f"{XMSG} hi", f"{WL} '{RED}'"]) try: yield s finally: s.close() 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 s.proc.poll() is None, f"kick exited early: {s.read_log()[-500:]}" # 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.""" if not HAVE_XWAYLAND: pytest.skip("needs Xwayland and xmessage on PATH") s = _spawn_kick_standalone(["sh -c 'echo DISPLAY=$DISPLAY'"]) try: s.wait_for(r"DISPLAY=:(\d+)", timeout=15.0) m = re.search(r"DISPLAY=:(\d+)", s.read_log()) assert int(m.group(1)) > 0 finally: s.close() 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).""" s = _spawn_kick_standalone( ["sh -c 'echo GDK=[$GDK_BACKEND] QT=[$QT_QPA_PLATFORM] " "XAUTH=[$XAUTHORITY]'"]) try: # 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) finally: s.close() 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 start = s.wait_any_active() assert start == 1, f"expected to boot on the Wayland tab, got {start}" # Wait until the X11 surface has mapped before cycling, so the wrap # lands on a live tab rather than a black placeholder. s.wait_for(r"\[diag\] map app=0") got = s.cycle_ack(+1) assert got == 0, f"Alt+Down did not wrap to the X11 tab (got {got})" ppm = s.dump_host() w, h, px = s.ppm_rgb(ppm) # Sample the centre plus quarter points; xmessage paints its whole # window grey/white, so every sample must be non-black. 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_any_active() s.wait_for(r"\[diag\] map app=0") got = s.cycle_ack(+1) assert got == 0 d = dict((int(a), int(p)) for a, p in CLAIM_RE.findall(s.read_log())) xpid = d[0] s.chord(ALT, DELETE) since = time.monotonic() while time.monotonic() - since < 10.0: try: os.kill(xpid, 0) except ProcessLookupError: break # xmessage exited from the graceful close time.sleep(0.1) else: pytest.fail(f"xmessage (pid {xpid}) survived Alt+Delete") # The compositor stays up with the remaining Wayland tab. assert s.proc.poll() is None, "compositor exited after closing X11 tab" assert s.active() == 1 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) try: os.unlink(LOG) except FileNotFoundError: pass proc = subprocess.Popen( ["env", "-i", "PATH=" + env["PATH"], "HOME=" + env.get("HOME", "/"), "XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"), "KICK_LOG=1", "stdbuf", "-oL", BIN], stdin=subprocess.PIPE, stdout=open(LOG, "ab"), stderr=subprocess.STDOUT, preexec_fn=_preexec_death(os.getpid()), ) s = Session(proc) try: s.wait_for(r"\[mode\] nested \(default\)") assert "[mode] standalone" not in s.read_log() finally: s.close() 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).""" if not HAVE_XWAYLAND: pytest.skip("needs Xwayland and xmessage on PATH") 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). s = _spawn_kick_standalone([f"sh -c 'sleep 0.7; exec {XL} 640 480'"]) try: s.wait_for(r"\[claim\] app=(\d+)", timeout=20) time.sleep(1.0) # let any wrong-path classification surface itself 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: dump and # check a point inside the 640-wide frame for xleader's muted-blue # fill (0x30/0x60/0x90). ppm = s.dump_host() w, h, px = s.ppm_rgb(ppm) 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. s.chord(ALT, RIGHT) time.sleep(0.5) assert s.proc.poll() is None, "compositor crashed cycling X11 members" assert "Assertion" not in s.read_log()[-400:] finally: s.close() 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).""" if not HAVE_XWAYLAND: pytest.skip("needs Xwayland and xmessage on PATH") if not os.access(XP, os.X_OK): pytest.skip("build first: build/xpopup") s = _spawn_kick_standalone( [f"sh -c 'sleep 0.7; exec {XP} 640 480 1'"]) try: s.wait_for(r"\[claim\] app=(\d+)", timeout=20) time.sleep(2.5) # popup maps one second after the frame 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 s.proc.poll() is None, "compositor crashed handling the popup" finally: s.close() 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") s = _spawn_kick_standalone( [f"{WL} --touch-log {logf} '#306090'"]) try: s.wait_for(r"\[claim\] app=0", timeout=15) time.sleep(1.0) # client mapped; surface ready for picks 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 finally: s.close() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '#ff0000'", extra_args=("--tab-groups", "on")) try: s.wait_any_active() s.chord(ALT | SHIFT, LEFT) time.sleep(0.5) s.chord(ALT | SHIFT, RIGHT) time.sleep(0.5) assert s.proc.poll() is None assert "moved" not in s.read_log().split("running.")[-1] or True finally: if s.proc.poll() is None: s.proc.terminate() # -- 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).""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'") try: 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. layer_proc = subprocess.Popen( [WL, "--layer", "top", "--layer-anchor", "top", "--exclusive-zone", "40", "--layer-size", "0", "40", "#00ff00"], 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())) try: # 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}" finally: if layer_proc.poll() is None: layer_proc.terminate() layer_proc.wait(timeout=5) finally: s.close() 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") s = _nested_session(f"{WL} '{RED}'") try: 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). layer_proc = subprocess.Popen( [WL, "--layer", "top", "--layer-anchor", "top", "--exclusive-zone", "0", "--layer-size", "0", "40", "#00ff00"], 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())) try: # Give it time to appear; geometry must NOT change. time.sleep(2.0) 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}" finally: if layer_proc.poll() is None: layer_proc.terminate() layer_proc.wait(timeout=5) finally: s.close() # -- 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.""" if not os.environ.get("WAYLAND_DISPLAY"): pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") panel = shutil.which("wf-panel-pi") if not panel: pytest.skip("wf-panel-pi not installed") s = _nested_session(f"{WL} '{RED}'", extra_args=("--panel", panel)) try: 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}") finally: s.close() # --- Foreign-toplevel smoke tests ------------------------------------------- def test_standalone_headless_foreign_toplevel_globals(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.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} for want in ("zwlr_foreign_toplevel_manager_v1", "ext_foreign_toplevel_list_v1"): assert want in names, \ f"missing global {want}; advertised: {sorted(names)}" finally: s.close() 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. s = _spawn_standalone( {}, "%s --app-id testwin '#ff0000'" % WL, "%s --foreign --foreign-out '%s'" % (WL, out)) try: s.wait_any_active() deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if "app_id=testwin" in text: break time.sleep(0.1) 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}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --app-id testwin '#ff0000'" % WL, "%s --foreign --foreign-out '%s' --foreign-close testwin" % (WL, out)) try: 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" finally: s.close() # --- 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" s = _spawn_standalone( {}, "%s --idle 1000 --idle-out '%s' '#ff0000'" % (WL, out)) try: deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if text == "idle": break time.sleep(0.1) text = out.read_text().strip() if out.exists() else "(missing)" assert text == "idle", \ f"ext-idle-notify never fired (got {text!r})" finally: s.close() 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" s = _spawn_standalone( {}, "%s --idle 1000 --idle-out '%s' --idle-inhibit '#ff0000'" % (WL, out)) try: s.wait_any_active() # 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" finally: s.close() # --- Session-lock smoke tests ------------------------------------------------ def test_standalone_headless_session_lock_globals(tmp_path): """The compositor advertises the ext_session_lock_manager_v1 global so a lock client (swaylock) can grab the session.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "ext_session_lock_manager_v1" in names, \ f"missing ext_session_lock_manager_v1; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --app-id testwin '#ff0000' 320x240" % WL, "%s --lock --lock-out '%s'" % (WL, out)) try: s.wait_any_active() deadline = time.monotonic() + 10.0 got_surface = False while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if "surface-config" in text: got_surface = True break time.sleep(0.1) assert got_surface, \ 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 s.proc.poll() is None, \ f"compositor exited under lock; log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_screencopy_globals(tmp_path): """The compositor advertises the zwlr_screencopy_manager_v1 global so a capture tool (grim) can grab output frames.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "zwlr_screencopy_manager_v1" in names, \ f"missing zwlr_screencopy_manager_v1; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --app-id testwin '#ff0000' 320x240" % WL, "%s --screencopy --screencopy-out '%s'" % (WL, out)) try: s.wait_any_active() deadline = time.monotonic() + 10.0 got_ready = False while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if text.startswith("ready-"): got_ready = True break time.sleep(0.1) assert got_ready, \ f"capture client never got a ready frame: " \ f"{(out.read_text().strip() if out.exists() else '(missing)')!r}" # 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 s.proc.poll() is None, \ f"compositor exited during capture; log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_tearing_and_fractional_globals(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.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} for want in ("wp_tearing_control_manager_v1", "wp_fractional_scale_manager_v1"): assert want in names, \ f"missing global {want}; advertised: {sorted(names)}" finally: s.close() def test_standalone_headless_shortcuts_inhibit_globals(tmp_path): """The compositor advertises zwp_keyboard_shortcuts_inhibit_manager_v1 so a fullscreen game/VM can grab the compositor's shortcuts.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "zwp_keyboard_shortcuts_inhibit_manager_v1" in names, \ f"missing inhibit manager; advertised: {sorted(names)}" finally: s.close() 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" s = _spawn_standalone( {}, "%s --inhibit --inhibit-out '%s'" % (WL, out)) try: s.wait_any_active() deadline = time.monotonic() + 10.0 got_active = False while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if "active" in text: got_active = True break time.sleep(0.1) assert got_active, \ f"inhibitor never became active: " \ f"{(out.read_text().strip() if out.exists() else '(missing)')!r}" assert s.proc.poll() is None, \ f"compositor exited with inhibitor; log tail:\n{s.read_log()[-400:]}" finally: s.close() def test_standalone_headless_output_power_globals(tmp_path): """The compositor advertises zwlr_output_power_manager_v1 so a client (wlopm) can blank/unblank the output.""" out = tmp_path / "globals.txt" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "wltest --globals-out never wrote its file" names = {line.split()[0] for line in out.read_text().splitlines()} assert "zwlr_output_power_manager_v1" in names, \ f"missing power manager; advertised: {sorted(names)}" finally: s.close() def _oneshot_sc(sock, out_path, timeout=10.0): """Run a one-shot --screencopy against a live socket; return its report (ready-x/,) 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 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" s = _spawn_standalone( {}, "%s --app-id testwin '#ff0000' 320x240" % WL) try: s.wait_any_active() # 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 s.proc.poll() is None, \ f"compositor exited across power cycle; log tail:\n{s.read_log()[-400:]}" finally: s.close() 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" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "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)}" finally: s.close() def _oneshot_virtual_key(sock, spec): """Run a one-shot --virtual-key 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.""" s = _spawn_standalone( {}, "%s --app-id win0 '#ff0000' 320x240" % WL, "%s --app-id win1 '#00ff00' 320x240" % WL) try: 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 s.proc.poll() is None, \ f"compositor exited on virtual key; log tail:\n{s.read_log()[-400:]}" finally: s.close() 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 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" s = _spawn_standalone( {}, "%s --app-id testwin --pointer-out '%s' '#ff0000' 320x240" % (WL, out)) try: s.wait_any_active() # 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) deadline = time.monotonic() + 10.0 got_enter = got_button = False while time.monotonic() < deadline: if out.exists(): text = out.read_text() got_enter = "enter " in text got_button = "button 272 " in text if got_enter and got_button: break time.sleep(0.1) assert got_enter, \ f"window saw no pointer enter: " \ f"{(out.read_text() if out.exists() else '(missing)')!r}" assert got_button, \ f"window saw no button press: " \ f"{(out.read_text() if out.exists() else '(missing)')!r}" assert s.proc.poll() is None, \ f"compositor exited on virtual pointer; log tail:\n{s.read_log()[-400:]}" finally: s.close() 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" s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) try: deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: if out.exists(): break time.sleep(0.1) assert out.exists(), "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)}" finally: s.close() 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. s = _spawn_standalone( {}, "%s --app-id keep '#0000ff' 320x240" % WL, "%s --activate --activate-out '%s'" % (WL, out)) try: s.wait_any_active() deadline = time.monotonic() + 10.0 got_activated = False while time.monotonic() < deadline: if out.exists(): text = out.read_text().strip() if "activated" in text: got_activated = True break time.sleep(0.1) assert got_activated, \ f"window never received activated configure: " \ f"{(out.read_text().strip() if out.exists() else '(missing)')!r}" assert s.proc.poll() is None, \ f"compositor exited on activate; log tail:\n{s.read_log()[-400:]}" finally: s.close() def _wait_log(s, rx, timeout=15.0): """Wait until the compositor log matches rx; return the match or None.""" pat = re.compile(rx) deadline = time.monotonic() + timeout while time.monotonic() < deadline: m = pat.search(s.read_log()) if m: return m time.sleep(0.1) return None 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).""" if not os.path.exists(XM): pytest.skip("needs build/xormenu (libX11 dev headers)") disp_file = tmp_path / "xdisplay" frame = menu = None s = _spawn_standalone( {}, "sh -c 'echo $DISPLAY > %s; exec sleep 999'" % disp_file, "%s --app-id keep '#0000ff' 320x240" % WL) try: s.wait_any_active() # 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())) assert _wait_log(s, r"map app=1", timeout=15.0), \ f"X11 frame never mapped:\n{s.read_log()[-500:]}" # 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())) assert _wait_log(s, r"or associate", timeout=15.0), \ f"OR menu never associated:\n{s.read_log()[-500:]}" # Close the owner tab, then let the menu unmap (release path). _oneshot_virtual_key(s.socket, "56:1,111:1,111:0,56:0") assert _wait_log(s, r"closing tab", timeout=15.0), \ "owner tab never closed" menu.wait(timeout=25.0) time.sleep(1.0) assert s.proc.poll() is None, \ f"compositor died on menu dismiss; log tail:\n{s.read_log()[-400:]}" log = s.read_log() for pat in ("Segmentation fault", "Aborted", "BACKTRACE", "Assertion `", "AddressSanitizer"): assert pat not in log, f"crash indicator {pat!r} in log" finally: for p in (frame, menu): try: if p is not None: p.kill() except Exception: pass s.close() 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.""" s = _spawn_standalone( {}, "%s --app-id keep '#0000ff' 320x240" % WL) a = b = None try: s.wait_any_active() 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) assert _wait_log(s, r"geo=200x100", timeout=15.0), \ f"P1 never mapped:\n{s.read_log()[-500:]}" assert _wait_log(s, r"geo=150x80", timeout=15.0), \ f"nested P2 never mapped:\n{s.read_log()[-500:]}" a.kill() a.wait(timeout=10.0) a = None time.sleep(1.0) assert s.proc.poll() is None, \ f"compositor died on popup client kill; log tail:\n{s.read_log()[-400:]}" # Client B: a fresh chain maps again on the same compositor. b = pop_client(4) assert _wait_log(s, r"geo=150x80", timeout=15.0), \ f"fresh nested popup never mapped:\n{s.read_log()[-500:]}" b.wait(timeout=15.0) b = None time.sleep(0.5) assert s.proc.poll() is None, \ f"compositor died after popup chains; log tail:\n{s.read_log()[-400:]}" log = s.read_log() for pat in ("Segmentation fault", "Aborted", "BACKTRACE", "Assertion `", "AddressSanitizer"): assert pat not in log, f"crash indicator {pat!r} in log" finally: for p in (a, b): try: if p is not None: p.kill() except Exception: pass s.close() 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).""" if not os.path.exists(XC): pytest.skip("needs build/xclass (libX11 dev headers)") disp_file = tmp_path / "xdisplay" probe_log = tmp_path / "xclass.log" probe = None s = _spawn_standalone( {}, "sh -c 'echo $DISPLAY > %s; exec sleep 999'" % disp_file, "%s --app-id keep '#0000ff' 320x240" % WL) try: s.wait_any_active() 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). deadline = time.monotonic() + 20.0 while time.monotonic() < deadline: if "class deleted" in probe_log.read_text(): break time.sleep(0.1) else: pytest.fail("xclass probe never deleted WM_CLASS") time.sleep(1.5) assert s.proc.poll() is None, \ f"compositor died on WM_CLASS delete; log tail:\n{s.read_log()[-400:]}" log = s.read_log() for pat in ("Segmentation fault", "Aborted", "BACKTRACE", "Assertion `", "AddressSanitizer", "null pointer passed as argument"): assert pat not in log, f"crash indicator {pat!r} in log" finally: try: if probe is not None: probe.kill() except Exception: pass s.close()