aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:26:02 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:26:02 +0200
commit34fcd16e59e3087e9bf84ebce4e05d439eea9198 (patch)
treee07b9dfc235e3a0b67047d59a9c521c6e98a28ae /tests
parentsl0ptty: input decoder + per-pane re-encode (diff)
downloadslosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.tar.gz
slosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.tar.xz
slosh-34fcd16e59e3087e9bf84ebce4e05d439eea9198.zip
sl0ptty: M0.5 — the headless driver and the harness everything rests on
--script runs the whole compositor with no tty: commands in (send, raw, settle, resize, snapshot, alive), composited screen out as JSON — rows of text, style runs, cursor, and the hit list. Styles are emitted as runs rather than per-cell objects, because a test wants 'columns 0..5 of row 0 are bold red', not 1920 objects. The hit list is empty until M1 paints something clickable, but the type, the JSON, and hit_test() exist now: one geometry, established before there is anything to get wrong. Also fixes two host-policy defaults found by the cursor never appearing. lib-vt is a VT library, not a terminal, so it starts with DECTCEM (mode 25) off and grapheme clustering (2027) off. Both are now set via MODE_DEFAULT, so they survive RIS. tests/harness.py + tests/test_screen.py: 19 checks over composition, styling, wide chars/clusters, scrollback, resize and the full input round trip — including the kitty/legacy re-encoding matrix, which no longer needs a pty. make test is now 2.4s for 59 checks; make test-live keeps the four that genuinely need a tty.
Diffstat (limited to 'tests')
-rw-r--r--tests/__pycache__/harness.cpython-314.pycbin0 -> 7957 bytes
-rw-r--r--tests/harness.py132
-rw-r--r--tests/live_input.py109
-rw-r--r--tests/test_screen.py137
4 files changed, 297 insertions, 81 deletions
diff --git a/tests/__pycache__/harness.cpython-314.pyc b/tests/__pycache__/harness.cpython-314.pyc
new file mode 100644
index 0000000..a6f472f
--- /dev/null
+++ b/tests/__pycache__/harness.cpython-314.pyc
Binary files differ
diff --git a/tests/harness.py b/tests/harness.py
new file mode 100644
index 0000000..617faf6
--- /dev/null
+++ b/tests/harness.py
@@ -0,0 +1,132 @@
+"""Drive sl0ptty's headless driver and assert on the composited screen.
+
+ with Session(["/bin/sh", "-c", "echo hi"], cols=20, rows=3) as s:
+ s.settle()
+ assert s.snapshot().line(0).strip() == "hi"
+
+No pty, no sleeps beyond settling, no screen scraping: the compositor tells us
+what it composed. This is the harness DESIGN.md asks for, and everything from
+M1 on is tested through it.
+"""
+import json
+import os
+import subprocess
+
+BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "build", "sl0ptty")
+
+
+class Snapshot:
+ def __init__(self, data):
+ self.data = data
+ self.text = data["text"]
+ self.cursor = data["cursor"]
+ self.styles = data["styles"]
+ self.hits = data["hits"]
+ self.cols = data["cols"]
+ self.rows = data["rows"]
+
+ def line(self, y):
+ return self.text[y]
+
+ def screen(self):
+ return "\n".join(self.text)
+
+ def find(self, needle):
+ """(x, y) of the first occurrence, or None."""
+ for y, row in enumerate(self.text):
+ x = row.find(needle)
+ if x >= 0:
+ return x, y
+ return None
+
+ def style_at(self, x, y):
+ """The style run covering a cell, or None if the cell is unstyled."""
+ for run in self.styles:
+ if run["y"] == y and run["x"] <= x < run["x"] + run["w"]:
+ return run
+ return None
+
+ def hit_at(self, x, y):
+ """The action registered at a cell, last painted winning."""
+ for e in reversed(self.hits):
+ if e["x"] <= x < e["x"] + e["w"] and e["y"] <= y < e["y"] + e["h"]:
+ return e["action"]
+ return None
+
+ def __str__(self):
+ return self.screen()
+
+
+class Session:
+ def __init__(self, argv, cols=80, rows=24):
+ self.cols, self.rows = cols, rows
+ self.proc = subprocess.Popen(
+ [BIN, "--script", "--cols", str(cols), "--rows", str(rows), "--"] + argv,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ )
+
+ # -- commands ---------------------------------------------------------
+ def _cmd(self, line):
+ self.proc.stdin.write(line + "\n")
+ self.proc.stdin.flush()
+
+ def send(self, escaped):
+ """Bytes as if typed at the outer terminal (decoded, then re-encoded)."""
+ self._cmd(f"send {escaped}")
+
+ def raw(self, escaped):
+ """Bytes straight into the pane's pty, decoder bypassed."""
+ self._cmd(f"raw {escaped}")
+
+ def settle(self, ms=120):
+ self._cmd(f"settle {ms}")
+
+ def resize(self, cols, rows):
+ self.cols, self.rows = cols, rows
+ self._cmd(f"resize {cols} {rows}")
+
+ def snapshot(self):
+ self._cmd("snapshot json")
+ line = self.proc.stdout.readline()
+ if not line:
+ raise RuntimeError("sl0ptty exited before answering snapshot")
+ return Snapshot(json.loads(line))
+
+ def alive(self):
+ self._cmd("alive")
+ return self.proc.stdout.readline().strip() == "true"
+
+ def close(self):
+ try:
+ self._cmd("quit")
+ self.proc.stdin.close()
+ self.proc.wait(timeout=5)
+ except Exception:
+ self.proc.kill()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ self.close()
+
+
+# -- tiny test runner ------------------------------------------------------
+
+_fails = []
+
+
+def check(name, cond, detail=""):
+ if cond:
+ print(f"ok {name}")
+ else:
+ _fails.append(name)
+ print(f"FAIL {name} <- {detail}")
+
+
+def report():
+ print(f"\n{'FAILED' if _fails else 'all green'} ({len(_fails)} failures)")
+ return 1 if _fails else 0
diff --git a/tests/live_input.py b/tests/live_input.py
index 30dd12b..31dade8 100644
--- a/tests/live_input.py
+++ b/tests/live_input.py
@@ -1,27 +1,27 @@
#!/usr/bin/env python3
-"""Live check of decode -> re-encode.
+"""The interactive client's input path, which headless cannot cover.
-The pane runs `cat -v`, which prints control bytes visibly, so whatever the
-inner app *actually receives* ends up on our screen. That is the only honest
-way to test that we re-encode for the pane rather than forwarding bytes.
+The encoding matrix (kitty/legacy/mouse/paste, per pane) lives in
+tests/test_screen.py, where it runs deterministically through the headless
+driver. What is left here is the part that only exists with a real tty: raw
+mode, reading stdin, and the prefix key.
"""
import os, pty, select, struct, sys, termios, time, fcntl, signal
BIN = os.path.join(os.path.dirname(__file__), "..", "build", "sl0ptty")
INNER = ["--", "/bin/sh", "-c", "stty raw -echo; cat -v"]
+fails = 0
-def spawn(cols=80, rows=10, inner=None):
- pid, fd = pty.fork()
- if pid == 0:
- os.environ["TERM"] = "xterm-ghostty"
- os.execv(BIN, [BIN] + (inner or INNER))
- os._exit(127)
- fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
- return pid, fd
+def check(name, cond, detail=""):
+ global fails
+ if not cond:
+ fails += 1
+ print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}")
-def drain(fd, idle=0.3, limit=3.0):
+
+def drain(fd, idle=0.25, limit=2.0):
buf, last, start = b"", time.time(), time.time()
while time.time() - start < limit:
r, _, _ = select.select([fd], [], [], 0.05)
@@ -39,79 +39,26 @@ def drain(fd, idle=0.3, limit=3.0):
return buf
-fails = 0
-
-
-def check(name, cond, detail=""):
- global fails
- if not cond:
- fails += 1
- print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}")
-
-
def main():
- pid, fd = spawn()
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.environ["TERM"] = "xterm-ghostty"
+ os.execv(BIN, [BIN] + INNER)
+ os._exit(127)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 10, 80, 0, 0))
drain(fd)
- cases = [
- # name, what the outer terminal sends us, what the pane must receive
- ("legacy ctrl-up survives", b"\x1b[1;5A", "^[[1;5A"),
- # ctrl-a is the prefix, so ctrl-b is the honest test of a kitty ctrl key
- ("kitty ctrl-b -> legacy ^B", b"\x1b[98;5u", "^B"),
- ("kitty plain 'z' -> 'z'", b"\x1b[122u", "z"),
- # each case must leave the prefix state clean for the next one
- ("prefix + unbound key is swallowed", b"\x01x", ""),
- ("C-a C-a sends a literal ^A", b"\x01\x01", "^A"),
- ("kitty enter -> \\r", b"\x1b[13u", "^M"),
- ("alt-b -> ESC b", b"\x1bb", "^[b"),
- ("utf8 passes through", "é".encode(), "M-CM-)"),
- ("paste is unwrapped for a plain app", b"\x1b[200~xy\x1b[201~", "xy"),
- ]
-
- for name, send, want in cases:
- os.write(fd, send)
- out = drain(fd, idle=0.25, limit=1.5)
- text = out.decode("utf-8", "replace")
- if want == "":
- # nothing may reach the pane: no repaint at all
- check(name, out == b"", repr(text[-80:]))
- else:
- check(name, want in text, repr(text[-80:]))
-
- # A key release must not reach an app that never asked for one.
- os.write(fd, b"\x1b[97;1:3u")
- out = drain(fd, idle=0.25, limit=1.0).decode("utf-8", "replace")
- check("key release is not sent to a legacy app", "a" not in out.replace("\x1b", ""),
- repr(out[-80:]))
-
- # Mouse events must not leak into an app with no mouse tracking on.
- os.write(fd, b"\x1b[<0;5;5M\x1b[<0;5;5m")
- out = drain(fd, idle=0.25, limit=1.0).decode("utf-8", "replace")
- check("mouse does not leak into a non-mouse app", "[<" not in out, repr(out[-80:]))
-
- os.write(fd, b"\x01q")
- drain(fd, idle=0.2, limit=1.0)
- try:
- os.kill(pid, signal.SIGKILL)
- os.waitpid(pid, 0)
- except (ProcessLookupError, ChildProcessError):
- pass
-
- # --- the other direction: a pane that negotiates modern protocols -----
- # Same keys, same client; only the pane's own state differs. Passing here
- # *and* above means we encode per pane rather than forwarding bytes.
- inner = r'stty raw -echo; printf "\033[>1u\033[?1000h\033[?1006h"; cat -v'
- pid, fd = spawn(inner=["--", "/bin/sh", "-c", inner])
- drain(fd)
+ os.write(fd, b"\x1b[1;5A")
+ out = drain(fd).decode("utf-8", "replace")
+ check("a key typed at a real tty reaches the pane", "^[[1;5A" in out, repr(out[-60:]))
- os.write(fd, b"\x02") # legacy ctrl-b in
- out = drain(fd, idle=0.3, limit=2.0).decode("utf-8", "replace")
- check("ctrl-b -> kitty CSI u for a kitty pane", "^[[98;5u" in out, repr(out[-80:]))
+ os.write(fd, b"\x01x") # prefix + unbound
+ out = drain(fd)
+ check("prefix + unbound key is swallowed", out == b"", repr(out[-60:]))
- os.write(fd, b"\x1b[<0;7;3M") # SGR mouse press in
- out = drain(fd, idle=0.3, limit=2.0).decode("utf-8", "replace")
- check("mouse reaches a pane that asked for it", "^[[<0;7;3M" in out,
- repr(out[-80:]))
+ os.write(fd, b"\x01\x01") # prefix + prefix = literal
+ out = drain(fd).decode("utf-8", "replace")
+ check("C-a C-a sends a literal ^A", "^A" in out, repr(out[-60:]))
os.write(fd, b"\x01q")
drain(fd, idle=0.2, limit=1.0)
diff --git a/tests/test_screen.py b/tests/test_screen.py
new file mode 100644
index 0000000..4156a0e
--- /dev/null
+++ b/tests/test_screen.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""Composition, styling, cursor, scrollback, resize, and the input round trip.
+
+Everything here runs through the headless driver, so it is deterministic and
+takes milliseconds. Contrast tests/live_*.py, which exist only for the parts
+that genuinely need a tty.
+"""
+import sys
+
+from harness import Session, check, report
+
+CAT = ["/bin/sh", "-c", "stty raw -echo; cat -v"]
+SH = lambda script: ["/bin/sh", "-c", script]
+
+
+def test_text_and_cursor():
+ with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s:
+ s.settle()
+ s.raw("hello")
+ s.settle()
+ snap = s.snapshot()
+ check("text lands on row 0", snap.line(0).startswith("hello"), repr(snap.line(0)))
+ check("cursor follows the text", snap.cursor == {"visible": True, "x": 5, "y": 0},
+ str(snap.cursor))
+
+ s.raw("\\r\\nsecond")
+ s.settle()
+ snap = s.snapshot()
+ check("newline moves to row 1", snap.line(1).startswith("second"), repr(snap.line(1)))
+ check("cursor on row 1", (snap.cursor["x"], snap.cursor["y"]) == (6, 1),
+ str(snap.cursor))
+
+
+def test_styles():
+ with Session(SH(r'printf "n\033[1mB\033[31mR\033[0mn"; sleep 5'), cols=10, rows=2) as s:
+ s.settle()
+ snap = s.snapshot()
+ check("style: plain cell has no run", snap.style_at(0, 0) is None,
+ str(snap.style_at(0, 0)))
+ b = snap.style_at(1, 0)
+ check("style: bold is recorded", b and b["attrs"] == ["bold"], str(b))
+ r = snap.style_at(2, 0)
+ check("style: colour is resolved to rgb",
+ r and r["fg"] is not None and "bold" in r["attrs"], str(r))
+ check("style: reset ends the run", snap.style_at(3, 0) is None,
+ str(snap.style_at(3, 0)))
+
+
+def test_wide_and_clusters():
+ with Session(SH('printf "[\\346\\227\\245]"; sleep 5'), cols=10, rows=1) as s:
+ s.settle()
+ snap = s.snapshot()
+ check("wide char occupies one cell in the dump", snap.line(0).startswith("[日]"),
+ repr(snap.line(0)))
+
+
+def test_scrollback():
+ with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s:
+ s.settle()
+ s.raw("1\\r\\n2\\r\\n3\\r\\n4\\r\\n5")
+ s.settle()
+ snap = s.snapshot()
+ check("viewport shows the tail, not the head",
+ snap.line(0).startswith("3") and snap.line(2).startswith("5"),
+ repr(snap.text))
+
+
+def test_resize():
+ with Session(SH("stty raw -echo; cat"), cols=20, rows=3) as s:
+ s.settle()
+ s.resize(40, 5)
+ s.settle()
+ snap = s.snapshot()
+ check("resize changes the composited size",
+ snap.cols == 40 and snap.rows == 5 and len(snap.line(0)) == 40,
+ f"{snap.cols}x{snap.rows} line={len(snap.line(0))}")
+
+ s.raw("\\033[999;999H\\033[6n") # ask the pane where it thinks it is
+ s.settle()
+ check("pane still responds after resize", s.alive())
+
+
+def test_input_round_trip():
+ """The decoder and the per-pane encoder, without a pty in sight."""
+ with Session(CAT, cols=40, rows=4) as s:
+ s.settle()
+ s.send(r"\e[1;5A") # legacy ctrl-up
+ s.settle()
+ check("legacy key reaches the pane", "^[[1;5A" in s.snapshot().screen())
+
+ with Session(CAT, cols=40, rows=4) as s:
+ s.settle()
+ s.send(r"\e[98;5u") # kitty ctrl-b -> legacy pane
+ s.settle()
+ check("kitty key is re-encoded for a legacy pane",
+ "^B" in s.snapshot().screen())
+
+ with Session(CAT, cols=40, rows=4) as s:
+ s.settle()
+ s.send(r"\e[200~pasted\e[201~")
+ s.settle()
+ check("paste is unwrapped for a pane that did not ask",
+ "pasted" in s.snapshot().screen())
+
+ kitty = ["/bin/sh", "-c",
+ 'stty raw -echo; printf "\\033[>1u\\033[?1000h\\033[?1006h"; cat -v']
+ with Session(kitty, cols=40, rows=4) as s:
+ s.settle()
+ s.send(r"\x02") # legacy ctrl-b -> kitty pane
+ s.settle()
+ check("legacy key is re-encoded for a kitty pane",
+ "^[[98;5u" in s.snapshot().screen(), s.snapshot().screen())
+
+ s.send(r"\e[<0;7;3M")
+ s.settle()
+ check("mouse reaches a pane that asked for it",
+ "^[[<0;7;3M" in s.snapshot().screen())
+
+
+def test_hitlist_contract():
+ """Empty until M1 paints something clickable, but the contract is live."""
+ with Session(SH("sleep 5"), cols=10, rows=2) as s:
+ s.settle()
+ snap = s.snapshot()
+ check("hit list exists and is empty", snap.hits == [], str(snap.hits))
+ check("hit test on empty list is None", snap.hit_at(0, 0) is None)
+
+
+if __name__ == "__main__":
+ test_text_and_cursor()
+ test_styles()
+ test_wide_and_clusters()
+ test_scrollback()
+ test_resize()
+ test_input_round_trip()
+ test_hitlist_contract()
+ sys.exit(report())