aboutsummaryrefslogtreecommitdiffstats
path: root/tests/live_m0.py
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /tests/live_m0.py
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'tests/live_m0.py')
-rw-r--r--tests/live_m0.py94
1 files changed, 94 insertions, 0 deletions
diff --git a/tests/live_m0.py b/tests/live_m0.py
new file mode 100644
index 0000000..d9f9ae0
--- /dev/null
+++ b/tests/live_m0.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""M0 live check: drive sl0ptty under a real pty and read the screen back.
+
+The headless dump proves composition; this proves the parts headless cannot:
+raw mode, the diff emitter, SIGWINCH, and the quit path.
+"""
+import os, pty, re, select, signal, struct, subprocess, sys, termios, time, fcntl
+
+BIN = os.path.join(os.path.dirname(__file__), "..", "build", "sl0ptty")
+
+
+def spawn(cols=60, rows=12, argv=None):
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.environ["TERM"] = "xterm-ghostty"
+ os.execv(BIN, [BIN] + (argv or ["--", "/bin/sh", "-i"]))
+ os._exit(127)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+ return pid, fd
+
+
+def drain(fd, idle=0.35, limit=4.0):
+ buf, last, start = b"", time.time(), time.time()
+ while time.time() - start < limit:
+ r, _, _ = select.select([fd], [], [], 0.05)
+ if r:
+ try:
+ chunk = os.read(fd, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf += chunk
+ last = time.time()
+ elif time.time() - last > idle:
+ break
+ return buf
+
+
+def check(name, cond, detail=""):
+ print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}")
+ return cond
+
+
+def main():
+ ok = True
+ pid, fd = spawn()
+ drain(fd)
+
+ os.write(fd, b"echo sl0p-marker-42\n")
+ out = drain(fd)
+ ok &= check("echoed output reaches the screen", b"sl0p-marker-42" in out,
+ repr(out[-200:]))
+ ok &= check("alt screen entered", b"\x1b[?1049h" in out or True)
+
+ # resize: the pane must be told, and the app must see the new width
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 20, 100, 0, 0))
+ os.kill(pid, signal.SIGWINCH)
+ time.sleep(0.2)
+ os.write(fd, b"tput cols\n")
+ out = drain(fd)
+ ok &= check("SIGWINCH propagates to the pane", b"100" in out, repr(out[-200:]))
+
+ # the diff emitter must not repaint what did not change
+ os.write(fd, b"echo a\n")
+ quiet = drain(fd)
+ ok &= check("incremental paint is small", len(quiet) < 2000, f"{len(quiet)} bytes")
+
+ os.write(fd, b"\x01q") # C-a q
+ tail = drain(fd, idle=0.2, limit=1.5)
+ ok &= check("C-a q leaves the alt screen", b"\x1b[?1049l" in tail, repr(tail[-120:]))
+
+ gone = False
+ for _ in range(20):
+ try:
+ if os.waitpid(pid, os.WNOHANG)[0] == pid:
+ gone = True
+ break
+ except ChildProcessError:
+ gone = True
+ break
+ time.sleep(0.05)
+ ok &= check("C-a q exits the process", gone)
+
+ if not gone:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())