d0fef1478d
Two fixes found reviewing the paths the acceptance suite couldn't cover. Show-while-shown-elsewhere used activate_window, which sets active_monitor_idx and so dragged *focus* to whichever output the window was parked on. A scratchpad should come to you: if the window is on another monitor, remove and re-add it floating on the active workspace; if it is already on the active monitor, focus in place as before. add_removed_tile_floating dropped the RemovedTile on both of its early returns (NoOutputs, monitor-not-found), destroying the window while its client was still alive and leaving it wedged with no surface anywhere. Both were unreachable via scratchpad_show's guards, but it is a window-loss footgun for the next caller. It now returns Result<(), RemovedTile<W>> so callers can stash the tile instead. Adds two layout tests (they simulate outputs, so multi-monitor is covered without a second physical display) and corrects the acceptance suite's test-8 skip message, which claimed the code used active_output — it never did; it uses active_workspace. cargo test --lib scratchpad: 10/10. acceptance.py: 8 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
12 KiB
Python
322 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. Covered by the "
|
|
"layout unit tests instead (scratchpad_shows_on_the_focused_output, "
|
|
"scratchpad_show_pulls_already_shown_window_to_focused_output), which "
|
|
"simulate outputs: `cargo test --lib scratchpad`.")
|
|
|
|
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()
|