aboutsummaryrefslogtreecommitdiffstats
path: root/tests/harness.py
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/harness.py
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/harness.py')
-rw-r--r--tests/harness.py132
1 files changed, 132 insertions, 0 deletions
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