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