#!/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): """Spawn an alacritty window and return its id once it appears.""" before = {w["id"] for w in self.windows()} self.action("spawn", "--", "alacritty") t0 = time.time() while time.time() - t0 < 10: 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("spawned window never appeared") 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()