test/native_picker.py
Ref: Size: 18.7 KiB History
#!/usr/bin/env python3
"""Native picker acceptance through ordinary SDL keys and independent daemon reads."""
import os
from pathlib import Path
import socket
import struct
import sys
import threading
import time
from native_tiling import eventually, require
def read_exact(stream, count):
result = bytearray()
while len(result) < count:
data = stream.recv(count - len(result))
if not data:
raise EOFError("daemon closed before replying")
result.extend(data)
return bytes(result)
def frame(stream):
kind, size = struct.unpack("<BI", read_exact(stream, 5))
require(size <= 16 * 1024 * 1024, "oversized daemon frame")
return kind, read_exact(stream, size)
def send_frame(stream, kind, payload=b""):
stream.sendall(struct.pack("<BI", kind, len(payload)) + payload)
def request(sock, kind, payload, expected):
with socket.socket(socket.AF_UNIX) as stream:
stream.settimeout(3)
stream.connect(sock)
send_frame(stream, kind, payload)
while True:
answer, data = frame(stream)
if answer == expected:
return data
def sessions(sock):
return {line for line in request(sock, 0x0c, b"", 0x91).decode().splitlines()
if line and not line.startswith("#")}
def create(sock, name):
reply = request(sock, 0x12, struct.pack("<HH", 80, 24) + name.encode(), 0x98)
require(reply[0] == 0, f"fixture could not create {name}: {reply!r}")
def end(sock, name):
reply = request(sock, 0x11, b"\x01" + name.encode(), 0x94)
require(reply[0] == 0, f"fixture could not end {name}: {reply!r}")
eventually(lambda: name not in sessions(sock), "ended session remained live")
def region(capture, rect):
width, height, pixels = capture
x, y = rect["x"], rect["y"]
right, bottom = min(x + rect["w"], width), min(y + rect["h"], height)
return b"".join(pixels[(row * width + x) * 3:(row * width + right) * 3]
for row in range(y, bottom))
def outside_picker_point(rig, state, avoid=None, strict_avoid=False):
"""Pick a covered pane header point that is demonstrably outside the popup."""
rect = state["picker"]["rect"]
for prefer_unfocused in (True, False):
if not prefer_unfocused and strict_avoid:
break
for pane in state["panes"]:
if prefer_unfocused and pane["id"] == avoid:
continue
header = pane["header"]
x, y = header["x"] + header["w"] / 2, header["y"] + header["h"] / 2
if not (rect["x"] <= x < rect["x"] + rect["w"] and
rect["y"] <= y < rect["y"] + rect["h"]):
return rig.point(state, x, y)
raise AssertionError("picker covered every pane header; fixture has no outside point")
class SlowHost:
"""A real socket peer, with optional delayed list and ignored create requests."""
def __init__(self, root, label, delay=0):
self.path = str(root / (label + ".sock"))
self.delay = delay
self.requests = []
self.stop = threading.Event()
self.listener = socket.socket(socket.AF_UNIX)
self.listener.bind(self.path)
self.listener.listen(8)
self.listener.settimeout(0.1)
self.workers = []
self.thread = threading.Thread(target=self.run)
self.thread.start()
def run(self):
while not self.stop.is_set():
try:
stream, _ = self.listener.accept()
except socket.timeout:
continue
worker = threading.Thread(target=self.serve, args=(stream,))
self.workers.append(worker)
worker.start()
def serve(self, stream):
with stream:
stream.settimeout(0.1)
try:
# Clients send small complete requests; retain partial frames
# across timeouts so the fixture itself cannot lose a header.
data = bytearray()
while not self.stop.is_set():
try:
chunk = stream.recv(8192)
except socket.timeout:
continue
if not chunk:
return
data.extend(chunk)
while len(data) >= 5:
kind, size = struct.unpack("<BI", data[:5])
if len(data) < 5 + size:
break
payload = bytes(data[5:5 + size])
del data[:5 + size]
self.requests.append((kind, payload))
if kind == 0x0c:
if self.stop.wait(self.delay):
return
send_frame(stream, 0x91, b"legacy")
except (OSError, EOFError):
pass
def close(self):
self.stop.set()
self.thread.join(2)
self.listener.close()
for worker in self.workers:
worker.join(2)
require(not self.thread.is_alive() and all(not w.is_alive() for w in self.workers),
"slow-host fixture leaked a worker")
def workflow(rig, refs):
"""Picker editing on the journey's already attached three-pane workspace."""
left, right, _ = refs
first, _ = refs[left]
second, _ = refs[right]
first_target = "--sock " + first
second_target = rig.targets.get(second, "--sock " + second)
initial = rig.state()
rig.focus(right)
original_layout = [p["outer"] for p in initial["panes"]]
before = sessions(second)
rig.host(second_target, "beside")
rig.choose("New session...")
rig.picker("session_name")
rig.send("text:CANCELLED-NAME", "key:escape", "key:escape", "key:escape")
state = rig.wait_state(lambda s: not s.get("picker"))
require(sessions(second) == before, "cancelling picker created a session")
require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout")
require(state["pending"] is not None, "closing picker lost armed direction")
require(all("CANCELLED-NAME" not in rig.dump(sock, session)
for sock, session in refs.values()), "editor text leaked to PTY")
rig.ok("host/editor back and cancel preserve layout, session table and armed split")
rig.key("escape")
rig.wait_state(lambda s: s["pending"] is None)
rig.host(first_target, "beside")
rig.choose("New session...")
rig.picker("session_name")
# One event batch: Enter must use the actual resized window even before
# the next paint/observation gets a chance to refresh cached geometry.
rig.send("text:no-space-shell", "resize:8x8", "key:enter")
rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8 and
s.get("picker") and s["picker"].get("notice"))
require("no-space-shell" not in sessions(first), "creation happened before checking split capacity")
require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree")
rig.send("resize:960x600", "key:escape", "key:escape", "key:escape")
rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker"))
rig.key("escape")
rig.wait_state(lambda s: s["pending"] is None)
rig.ok("window becoming too small during name entry refuses split before creating a remote shell")
def pointer_gestures(rig, refs):
"""The shared popup presents picker rows without leaking covered pane input."""
initial = rig.state()
initial_focus, initial_pending = initial["focus"], initial["pending"]
origin, neighbour, _ = list(refs)
second, _ = refs[neighbour]
target = rig.targets.get(second, "--sock " + second)
rig.focus(origin)
rig.open_picker("beside")
state = rig.picker("hosts")
row = next(row for row in state["picker"]["rows"] if row["label"] == target)
point = rig.point(state, row["rect"]["x"] + row["rect"]["w"] / 2,
row["rect"]["y"] + row["rect"]["h"] / 2)
rig.send("mousedown:" + point)
held = rig.picker("hosts")
require(held["focus"] == origin, "picker row press focused a covered pane before release")
rig.send("mouseup:" + point)
state = rig.picker("sessions")
require(state["focus"] == origin, "picker row release focused a covered pane")
outside = outside_picker_point(rig, state, avoid=origin, strict_avoid=True)
rig.send("mousedown:" + outside, "mouseup:" + outside)
closed = rig.wait_state(lambda s: not s.get("picker"))
require(closed["focus"] == origin and len(closed["panes"]) == 3,
"outside picker gesture leaked focus or changed the plural workspace")
rig.key("escape")
rig.wait_state(lambda s: s["pending"] is None)
if initial_focus is not None:
rig.focus(initial_focus)
restored = rig.wait_state(lambda s: s["focus"] == initial_focus and s["pending"] == initial_pending)
require(len(restored["panes"]) == len(initial["panes"]),
"picker pointer fixture did not restore its workspace setup")
rig.ok("picker row activation waits for release; outside dismissal stays owned over plural panes")
def races(rig, refs):
first, left_session = refs[next(iter(refs))]
second, right_session = refs[list(refs)[1]]
first_target = "--sock " + first
# Change the server after the user has already entered a fresh name.
rig.host(first_target, "beside")
rig.choose("New session...")
rig.picker("session_name")
rig.send("key:r")
rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "r")
rig.send("text:aced-name")
create(first, "raced-name")
old_size = rig.status(first, "raced-name")
rig.key("enter")
rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"))
require(len(rig.state()["panes"]) == 3, "duplicate create inserted a pane")
now = rig.status(first, "raced-name")
require((now["cols"], now["rows"]) == (old_size["cols"], old_size["rows"]),
"duplicate create resized existing shell")
rig.ok("competing create refuses the name without joining or resizing its existing session")
rig.send("key:escape", "key:escape", "key:escape")
rig.wait_state(lambda s: not s.get("picker"))
rig.host(first_target)
end(first, "raced-name")
rig.choose("raced-name")
rig.wait_state(lambda s: not s.get("picker") and len(s["panes"]) == 4 and
s["panes"][-1]["phase"] in ("refused", "exited", "failed"))
require("raced-name" not in sessions(first), "vanished selection recreated a shell")
rig.ok("session disappearing after listing is never recreated by selection")
rig.quit()
require(right_session in sessions(second) and left_session in sessions(first), "GUI close ended a session")
def add_host(rig):
first, _ = rig.daemon("add-host-a")
second, _ = rig.daemon("add-host-b")
first_target, second_target = "--sock " + first, "--sock " + second
rig.catalogue([first_target])
rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host")
before = rig.pixels()
rig.open_picker("beside")
state = rig.picker("hosts")
rect = state["picker"]["rect"]
painted = rig.pixels()
require(sum(a != b for a, b in zip(region(before, rect), region(painted, rect))) > 100,
"host picker state changed without rendering its popup")
rig.choose("Add host...")
rig.picker("host_name")
rig.send("text:invalid#session", "key:enter")
rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"))
catalogue = Path(rig.env["XDG_STATE_HOME"]) / "mux" / "hosts"
require(catalogue.read_text().splitlines() == [first_target], "invalid target changed shared catalogue")
rig.key("escape")
rig.picker("hosts")
rig.choose("Add host...")
rig.picker("host_name")
rig.send("text:" + second_target, "key:enter")
rig.picker("sessions")
require(catalogue.read_text().splitlines().count(second_target) == 1,
"confirmed Add host did not save exactly one target")
rig.new_session("added")
rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
rig.shell("printf 'ADDED-HOST-%s\\n' LIVE")
rig.wait_marker(second, "added", "ADDED-HOST-LIVE")
require("ADDED-HOST-LIVE" not in rig.dump(first, "left"), "Add host editor leaked into existing terminal")
rig.quit()
rig.ok("Add host validates spelling, saves shared catalogue, and inserts a working session")
def slow_and_legacy(rig):
first, _ = rig.daemon("picker-responsive")
slow = SlowHost(rig.root, "delayed-list", delay=2)
legacy = SlowHost(rig.root, "legacy-create")
try:
first_target = "--sock " + first
slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path
rig.catalogue([first_target, slow_target, legacy_target])
rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker")
rig.open_picker("beside")
rig.picker("hosts")
rig.choose(slow_target)
eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried")
started = time.monotonic()
rig.key("escape")
rig.picker("hosts")
rig.choose(first_target)
rig.picker("sessions")
require(time.monotonic() - started < 1, "slow host blocked choosing another host")
# Let the old request finish after the target has changed.
time.sleep(2.1)
state = rig.picker("sessions")
labels = [r["label"] if isinstance(r, dict) else r for r in state["picker"]["rows"]]
require("legacy" not in labels and "left" in labels, "late response replaced the current host's sessions")
rig.send("key:escape", "key:escape")
rig.wait_state(lambda s: not s.get("picker"))
rig.shell("printf 'AFTER-CANCEL-%s\\n' LIVE")
rig.wait_marker(first, "left", "AFTER-CANCEL-LIVE")
rig.wait_state(lambda s: "AFTER-CANCEL-LIVE" in s["panes"][0]["painted_text"])
rig.ok("slow discovery cancels promptly; stale replies cannot replace a new host or block terminal painting")
rig.host(legacy_target)
rig.choose("New session...")
rig.picker("session_name")
rig.send("key:r")
rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "r")
rig.send("text:aced-fallback", "key:enter")
rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane")
require(sum(kind == 0x12 for kind, _ in legacy.requests) == 1,
"creation was not submitted exactly once")
require(not any(kind == 0x01 for kind, _ in legacy.requests),
"old daemon was sent an unsafe attach fallback")
state = rig.state()
outside = outside_picker_point(rig, state)
rig.send("mousedown:" + outside, "mouseup:" + outside)
rig.wait_state(lambda s: not s.get("picker") and
any(word in s["notice"].lower() for word in ("unknown", "uncertain")))
rig.key("escape")
rig.wait_state(lambda s: s["pending"] is None)
rig.ok("older daemon's unanswered create reports uncertainty without retry or attach fallback")
rig.host(legacy_target)
rig.choose("New session...")
rig.picker("session_name")
rig.send("key:p")
rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "p")
before_creates = sum(kind == 0x12 for kind, _ in legacy.requests)
rig.send("text:ending-dismiss", "key:enter")
eventually(lambda: sum(kind == 0x12 for kind, _ in legacy.requests) == before_creates + 1,
"pending dismissal create request was not dispatched")
state = rig.picker("busy")
require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane")
require(not any(kind == 0x01 for kind, _ in legacy.requests),
"old daemon was sent an unsafe attach fallback")
outside = outside_picker_point(rig, state)
rig.send("mousedown:" + outside, "mouseup:" + outside)
rig.wait_state(lambda s: not s.get("picker") and
any(word in s["notice"].lower() for word in ("unknown", "uncertain")),
seconds=20)
require(sum(kind == 0x12 for kind, _ in legacy.requests) == before_creates + 1,
"pending dismissal retried creation")
rig.key("escape")
rig.wait_state(lambda s: s["pending"] is None)
rig.ok("ordinary keydown text enters the editor; outside close retains a pending create outcome")
rig.chord("enter")
rig.picker("hosts")
before_requests = sum(kind == 0x0c for kind, _ in slow.requests)
rig.choose(slow_target)
eventually(lambda: sum(kind == 0x0c for kind, _ in slow.requests) > before_requests,
"shutdown test never dispatched its slow discovery request")
started = time.monotonic()
rig.quit()
require(time.monotonic() - started < 1, "GUI shutdown waited for slow discovery")
require("left" in sessions(first), "shutdown ended live terminal session")
rig.ok("window closes during discovery without waiting for the remote reply or ending sessions")
finally:
slow.close()
legacy.close()
def old_daemon(rig, binary):
current = rig.mux
try:
rig.mux = str(Path(binary).resolve())
first, _ = rig.daemon("prior-sprint-daemon")
finally:
rig.mux = current
target = "--sock " + first
rig.catalogue([target])
rig.launch_gui(["--sock", first, "--session", "left"], "gui-old-daemon")
rig.host(target, "beside")
rig.choose("0")
rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
rig.shell("printf 'OLD-DAEMON-%s\\n' JOINED")
rig.wait_marker(first, "0", "OLD-DAEMON-JOINED")
rig.host(target, "stacked")
rig.new_session("unsupported-create")
rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
require("unsupported-create" not in sessions(first), "old daemon fallback created a shell")
rig.quit()
rig.ok("actual prior-sprint daemon supports join-only selection and safely leaves new creation unanswered")
def main():
from native_lifecycle import LifecycleRig, start_workspace
rig = LifecycleRig(*sys.argv[1:3])
try:
refs = start_workspace(rig, quic=True)
pointer_gestures(rig, refs)
races(rig, refs)
add_host(rig)
slow_and_legacy(rig)
if binary := os.environ.get("MUXG_OLD_DAEMON"):
old_daemon(rig, binary)
print(f"native picker OK ({rig.checkpoints} checkpoints)")
except BaseException:
print("Native picker failure artifacts:", rig.root, file=sys.stderr)
raise
finally:
rig.close()
if __name__ == "__main__":
main()