Files
dotfiles/pkg/niri-scratchpad/tests/acceptance.py
T
funman300 640deb397e niri-scratchpad: stop screencasts on stash; harden acceptance spawn wait
Stashing a window now stops an active screencast of it, rather than leaving the
consumer on a frozen last frame (a stashed window is never rendered). Covers
both stash paths - Mod+M and Mod+Shift+M's hide branch - by diffing which
windows left the layout across the action, so scratchpad_show's branch logic
isn't duplicated in the caller. Dynamic-target casts are retargeted to Nothing
rather than ended; that carve-out is documented inline.

Also widen the acceptance harness spawn wait to 30s: each test launches its own
nested niri, and a cold alacritty start occasionally exceeded the old 10s.
Deliberately not a retry, which could double-spawn and corrupt window counts.

117 unit tests and 8/8 live acceptance pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 11:04:14 -07:00

320 lines
12 KiB
Python

#!/usr/bin/env python3
"""Live acceptance / regression test for the niri native scratchpad patch.
Drives a real (patched) niri through its own IPC — the exact `do_action`
path a keybind hits — and asserts on `niri msg -j windows`. No keyboard
injection, so it can't type into the host session's windows. Each test gets
its own fresh nested niri, so cases are hermetic and order-independent.
Run this after rebasing the patch onto a new niri release to confirm the
scratchpad still behaves. It needs:
- a running Wayland (or X) session to host the nested winit window,
- the patched niri binary (built from niri-patches/ via the PKGBUILD, or a
dev checkout),
- `alacritty` as the throwaway test client.
Usage:
python3 acceptance.py [PATH_TO_NIRI]
Binary resolution order: argv[1], $NIRI_BIN, `niri` on PATH,
~/build/niri-scratchpad/target/release/niri. The binary MUST be patched
(its `niri msg action --help` must list `scratchpad-show`).
Exit code 0 iff every non-skipped test passes.
"""
import glob, json, os, shutil, signal, subprocess, sys, tempfile, time
MINIMAL_CONFIG = """\
// Hermetic config for scratchpad acceptance: no startup spawns.
input { keyboard { xkb { } } }
binds {
Mod+M { move-window-to-scratchpad; }
Mod+Shift+M { scratchpad-show; }
Mod+Q { close-window; }
}
"""
def resolve_niri():
for cand in (sys.argv[1] if len(sys.argv) > 1 else None,
os.environ.get("NIRI_BIN"),
shutil.which("niri"),
os.path.expanduser("~/build/niri-scratchpad/target/release/niri")):
if cand and os.path.exists(cand):
return cand
sys.exit("ERROR: no niri binary found (pass a path, set $NIRI_BIN, or "
"install niri-scratchpad).")
NIRI = resolve_niri()
RT = os.environ["XDG_RUNTIME_DIR"]
class Niri:
"""A fresh nested niri instance driven over IPC."""
def __init__(self, config_path):
before = set(glob.glob(f"{RT}/niri.*.sock"))
self.proc = subprocess.Popen(
[NIRI, "-c", config_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sock = None
t0 = time.time()
while time.time() - t0 < 15:
new = set(glob.glob(f"{RT}/niri.*.sock")) - before
if new:
self.sock = new.pop()
break
time.sleep(0.2)
if not self.sock:
self.close()
raise RuntimeError("nested niri socket never appeared")
self.env = dict(os.environ, NIRI_SOCKET=self.sock)
for _ in range(40): # wait for IPC to answer
if self.alive():
break
time.sleep(0.2)
def msg(self, *args):
r = subprocess.run([NIRI, "msg", "-j", *args],
capture_output=True, text=True, env=self.env)
return json.loads(r.stdout) if r.stdout.strip() else None
def action(self, *args):
return subprocess.run([NIRI, "msg", "action", *args],
capture_output=True, text=True, env=self.env)
def alive(self):
return self.msg("version") is not None
def windows(self):
return self.msg("windows") or []
def wait_count(self, n, timeout=8):
t0 = time.time()
while time.time() - t0 < timeout:
if len(self.windows()) == n:
return self.windows()
time.sleep(0.15)
return self.windows()
def spawn_window(self, timeout=30):
"""Spawn an alacritty window and return its id once it appears.
The wait is deliberately generous: every test launches its own nested
niri, so a cold alacritty start can occasionally be slow under that
load. Do NOT convert this into a retry — re-issuing the spawn risks a
second window arriving late and corrupting the window counts the tests
assert on. A slow test beats a flaky one.
"""
before = {w["id"] for w in self.windows()}
self.action("spawn", "--", "alacritty")
t0 = time.time()
while time.time() - t0 < timeout:
new = [w for w in self.windows() if w["id"] not in before]
if new:
return new[0]["id"]
time.sleep(0.15)
raise RuntimeError(
f"spawned window never appeared within {timeout}s "
f"(compositor alive={self.alive()})")
def by_id(self, i):
return next((w for w in self.windows() if w["id"] == i), None)
def focused(self):
return next((w for w in self.windows() if w["is_focused"]), None)
def floating_pos(self, i, tries=12):
# tile_pos_in_workspace_view is transiently null right after a show.
for _ in range(tries):
w = self.by_id(i)
p = w["layout"].get("tile_pos_in_workspace_view") if w else None
if p is not None:
return p
time.sleep(0.08)
return None
def output_size(self):
outs = self.msg("outputs") or {}
for o in outs.values():
lg = o.get("logical") or {}
if lg.get("width") and lg.get("height"):
return lg["width"], lg["height"]
return None
def close(self):
try:
self.proc.send_signal(signal.SIGTERM)
except Exception:
pass
try:
self.proc.wait(timeout=3)
except Exception:
try:
self.proc.kill()
except Exception:
pass
# ---- tests: each takes a fresh Niri and returns (ok, detail) ----
def t_stash_hides_and_moves_focus(n):
a = n.spawn_window()
b = n.spawn_window()
n.action("focus-window", "--id", str(b))
time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(1)
f = n.focused()
ok = (len(n.windows()) == 1 and n.by_id(b) is None
and f is not None and f["id"] == a)
return ok, f"remaining={len(n.windows())}, b_gone={n.by_id(b) is None}, focus={f['id'] if f else None}/want {a}"
def t_show_floating_focused_centered(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
w = n.by_id(b)
pos = n.floating_pos(b)
centered = "n/a"
ok = bool(w and w["is_floating"] and w["is_focused"] and pos and pos[0] > 5)
osz = n.output_size()
if ok and osz and w:
ww, wh = w["layout"]["window_size"]
cx, cy = (osz[0] - ww) / 2, (osz[1] - wh) / 2
centered = abs(pos[0] - cx) < osz[0] * 0.15 and abs(pos[1] - cy) < osz[1] * 0.15
ok = ok and bool(centered)
return ok, f"floating={w['is_floating'] if w else None}, focused={w['is_focused'] if w else None}, pos={pos}, centered={centered}"
def t_reshow_hides(n):
b = n.spawn_window()
n.action("focus-window", "--id", str(b)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1) # show (focused)
n.action("scratchpad-show"); n.wait_count(0) # hide
ok = len(n.windows()) == 0
return ok, f"count after re-show={len(n.windows())} (want 0)"
def t_cycle_round_robin(n):
a = n.spawn_window()
b = n.spawn_window()
for wid in (b, a):
n.action("focus-window", "--id", str(wid)); time.sleep(0.2)
n.action("move-window-to-scratchpad")
n.wait_count(0)
n.action("scratchpad-show"); w1 = n.wait_count(1); first = w1[0]["id"] if w1 else None
n.action("scratchpad-show"); n.wait_count(0) # hide (focused)
n.action("scratchpad-show"); w2 = n.wait_count(1); second = w2[0]["id"] if w2 else None
ok = first is not None and second is not None and first != second
return ok, f"first={first}, second={second} (want different)"
def t_geometry_remembered(n):
w = n.spawn_window()
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
n.action("scratchpad-show"); n.wait_count(1)
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-floating-window", "-x", "+130", "-y", "+90"); time.sleep(0.4)
pos1 = n.floating_pos(w)
n.action("scratchpad-show"); n.wait_count(0) # hide
n.action("scratchpad-show"); n.wait_count(1) # show again (single window)
pos2 = n.floating_pos(w)
back = n.by_id(w)
ok = (back is not None and back["is_floating"] and pos1 and pos2
and abs(pos1[0] - pos2[0]) < 2 and abs(pos1[1] - pos2[1]) < 2)
return ok, f"pos before hide={pos1}, after show={pos2}, floating={back['is_floating'] if back else None}"
def t_shown_not_focused_gets_focus(n):
p = n.spawn_window() # persistent tiled window
s = n.spawn_window() # to be stashed + shown
n.action("focus-window", "--id", str(s)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(1)
n.action("scratchpad-show"); n.wait_count(2) # s shown floating, focused
n.action("focus-window", "--id", str(p)); time.sleep(0.3)
npre = len(n.windows())
n.action("scratchpad-show"); time.sleep(0.4) # should just focus s back
f = n.focused()
ok = len(n.windows()) == npre and f is not None and f["id"] == s
return ok, f"count {npre}->{len(n.windows())} (want same), focus={f['id'] if f else None}/want {s}"
def t_close_while_stashed(n):
w = n.spawn_window()
pid = n.by_id(w)["pid"]
n.action("focus-window", "--id", str(w)); time.sleep(0.2)
n.action("move-window-to-scratchpad"); n.wait_count(0)
os.kill(pid, signal.SIGKILL) # client dies WHILE stashed
time.sleep(0.6)
r = n.action("scratchpad-show"); time.sleep(0.5)
ok = n.alive() and n.by_id(w) is None and r.returncode == 0
return ok, f"alive={n.alive()}, dead_shown={n.by_id(w) is not None}, rc={r.returncode}"
def t_empty_stash_noop(n):
n.spawn_window()
r = n.action("scratchpad-show"); time.sleep(0.3)
ok = len(n.windows()) == 1 and n.alive() and r.returncode == 0
return ok, f"count stayed {len(n.windows())}, alive={n.alive()}, rc={r.returncode}"
TESTS = [
("1 stash hides + focus moves", t_stash_hides_and_moves_focus),
("2 show floating+focused+centered", t_show_floating_focused_centered),
("3 re-show hides", t_reshow_hides),
("4 cycle round-robin", t_cycle_round_robin),
("5 geometry remembered", t_geometry_remembered),
("6 shown-not-focused -> focus it", t_shown_not_focused_gets_focus),
("7 close-while-stashed no crash", t_close_while_stashed),
("10 empty-stash no-op", t_empty_stash_noop),
]
def main():
if not shutil.which("alacritty"):
sys.exit("ERROR: alacritty is required as the test client.")
# confirm the binary is patched
h = subprocess.run([NIRI, "msg", "action", "--help"],
capture_output=True, text=True)
if "scratchpad-show" not in h.stdout:
sys.exit(f"ERROR: {NIRI} is not patched (no scratchpad-show action).")
print(f"niri: {NIRI}\n")
cfg = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
cfg.write(MINIMAL_CONFIG); cfg.close()
results = []
try:
for name, fn in TESTS:
n = None
try:
n = Niri(cfg.name)
ok, detail = fn(n)
except Exception as e:
ok, detail = False, f"exception: {e}"
finally:
if n:
n.close()
time.sleep(0.3)
results.append((name, ok, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
finally:
os.unlink(cfg.name)
# #8 multi-monitor cannot be exercised nested (single winit output).
print(" [SKIP] 8 multi-monitor: needs a physical 2nd output; "
"code uses active_output (correct by construction).")
passed = sum(1 for _, ok, _ in results if ok)
failed = sum(1 for _, ok, _ in results if not ok)
print(f"\n{passed} passed, {failed} failed, 1 skipped")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()