aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 23:27:47 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 23:27:47 +0200
commit58afd137373fb395fc0479d091c4da9ffa60324a (patch)
tree93839e3c20ff722819444ed22deaef07dc6e6041 /tests
parentsl0ptty: M6 — the config file, and a README (diff)
downloadslosh-58afd137373fb395fc0479d091c4da9ffa60324a.tar.gz
slosh-58afd137373fb395fc0479d091c4da9ffa60324a.tar.xz
slosh-58afd137373fb395fc0479d091c4da9ffa60324a.zip
sl0ptty: M7 — layout files and suspended panes
A session can be a file: layout { tab name="api" purpose="project:api.a1b2c3" cwd="~/dev/api" { pane purpose="agent:main" command="pi" pane split="rows" { pane purpose="service:web" command="npm run dev" suspended=true pane purpose="shell:scratch" } } } built by --layout at startup or applied to a running session with {"cmd":"apply-layout", path|kdl, replace?}. Idempotency stays with the caller, which is where sl0ppi's CLI design already puts it: diff list-tabs against the projects on disk, then apply what is missing. close-tab completes that loop. Purposes a layout declares are locked, so identity comes from the file rather than from whatever the program inside prints — tested by having a pane try to take one in-band and be refused. Suspended panes: a pane that exists, is laid out, and has run nothing, showing what it would run and starting on its first keystroke (which is consumed, not forwarded). This is sl0ppi's D6 — a root with twelve projects must not become twelve running servers — and it is the reason can be cheap. Also fixes a test-isolation bug latent since M2: the live tests attached to the session called "main", which is whatever happens to exist on the box rather than one they created. They now use a unique session per run, which is how a run that had somebody else's htop in it became reproducible. 284 checks green (257 headless + 27 live).
Diffstat (limited to 'tests')
-rw-r--r--tests/harness.py8
-rw-r--r--tests/live_input.py6
-rw-r--r--tests/live_m0.py10
-rw-r--r--tests/test_layout_files.py211
-rw-r--r--tests/test_session.py1
5 files changed, 230 insertions, 6 deletions
diff --git a/tests/harness.py b/tests/harness.py
index 420423d..855e5b3 100644
--- a/tests/harness.py
+++ b/tests/harness.py
@@ -68,7 +68,8 @@ class Snapshot:
class Session:
- def __init__(self, argv, cols=80, rows=24, config=None, env=None):
+ def __init__(self, argv, cols=80, rows=24, config=None, env=None,
+ layout=None):
self.cols, self.rows = cols, rows
environ = dict(os.environ)
if config:
@@ -77,8 +78,11 @@ class Session:
environ["SL0PTTY_CONFIG"] = "/nonexistent/sl0ptty.kdl"
if env:
environ.update(env)
+ cmd = [BIN, "--script", "--cols", str(cols), "--rows", str(rows)]
+ if layout:
+ cmd += ["--layout", str(layout)]
self.proc = subprocess.Popen(
- [BIN, "--script", "--cols", str(cols), "--rows", str(rows), "--"] + argv,
+ cmd + ["--"] + argv,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
diff --git a/tests/live_input.py b/tests/live_input.py
index 5e9e472..839b710 100644
--- a/tests/live_input.py
+++ b/tests/live_input.py
@@ -6,7 +6,7 @@ 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
+import os, pty, uuid, 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"]
@@ -40,10 +40,12 @@ def drain(fd, idle=0.25, limit=2.0):
def main():
+ session = "test-" + uuid.uuid4().hex[:8]
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "xterm-ghostty"
- os.execv(BIN, [BIN] + INNER)
+ os.environ["SL0PTTY_CONFIG"] = "/nonexistent/sl0ptty.kdl"
+ os.execv(BIN, [BIN, "-s", session] + INNER)
os._exit(127)
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 10, 80, 0, 0))
drain(fd)
diff --git a/tests/live_m0.py b/tests/live_m0.py
index 58720c2..ead8092 100644
--- a/tests/live_m0.py
+++ b/tests/live_m0.py
@@ -4,16 +4,22 @@
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
+import os, pty, uuid, re, select, signal, struct, subprocess, sys, termios, time, fcntl
BIN = os.path.join(os.path.dirname(__file__), "..", "build", "sl0ptty")
+# A unique session per run. Without this the tests attach to whatever "main"
+# happens to exist on the box, which is somebody else's terminal.
+SESSION = "test-" + uuid.uuid4().hex[:8]
+
+
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.environ["SL0PTTY_CONFIG"] = "/nonexistent/sl0ptty.kdl"
+ os.execv(BIN, [BIN, "-s", SESSION] + (argv or ["--", "/bin/sh", "-i"]))
os._exit(127)
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
return pid, fd
diff --git a/tests/test_layout_files.py b/tests/test_layout_files.py
new file mode 100644
index 0000000..4adfbef
--- /dev/null
+++ b/tests/test_layout_files.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+"""M7: layout files — the prerequisite for porting `sl0ppi up`.
+
+A layout declares tabs, purposes and panes; the purposes it declares are
+locked, and commands can be suspended so that opening twelve projects does not
+start twelve servers.
+"""
+import os
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+LAYOUT = """
+layout {
+ tab name="api" purpose="project:api.a1b2" cwd="/tmp" {
+ pane purpose="agent:main" command="stty raw -echo; cat"
+ pane split="rows" {
+ pane purpose="service:web" command="stty raw -echo; printf web-running; cat" suspended=true
+ pane purpose="logs:tail" command="stty raw -echo; cat"
+ }
+ }
+ tab name="notes" {
+ pane
+ }
+}
+"""
+
+
+def layout_file(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def session_with(text, **kw):
+ path = layout_file(text)
+ s = Session(SH, layout=path, **kw)
+ s._layout_path = path
+ return s
+
+
+def test_structure():
+ with session_with(LAYOUT, cols=78, rows=18) as s:
+ s.settle()
+ tabs = s.tabs()
+ check("a layout builds its tabs", [t["name"] for t in tabs] == ["api", "notes"],
+ str(tabs))
+ check("and only its tabs (the default one is replaced)", len(tabs) == 2,
+ str(tabs))
+ check("tab purposes are declared, so nothing can override them",
+ tabs[0]["purpose"] == "project:api.a1b2" and tabs[0]["purpose_declared"],
+ str(tabs[0]))
+
+ panes = s.panes()
+ check("panes are built in every tab", len(panes) == 4, str(len(panes)))
+ purposes = [p["purpose"] for p in panes if p["tab"] == 1]
+ check("pane purposes come from the layout",
+ purposes == ["agent:main", "service:web", "logs:tail"], str(purposes))
+ check("pane purposes are declared too",
+ all(p["purpose_declared"] for p in panes if p["purpose"]), str(panes))
+
+ first, web, logs = panes[0], panes[1], panes[2]
+ check("the tab body splits into columns by default",
+ first["x"] < web["x"] and first["y"] == web["y"],
+ f"{first['x']},{first['y']} vs {web['x']},{web['y']}")
+ check("a nested pane with split=rows stacks its children",
+ web["x"] == logs["x"] and web["y"] < logs["y"],
+ f"{web['y']} vs {logs['y']}")
+ os.unlink(s._layout_path)
+
+
+def test_suspended():
+ with session_with(LAYOUT, cols=78, rows=18) as s:
+ s.settle()
+ panes = s.panes()
+ web = [p for p in panes if p["purpose"] == "service:web"][0]
+ check("a suspended pane is marked as such", web["suspended"], str(web))
+ check("others are not",
+ not [p for p in panes if p["purpose"] == "agent:main"][0]["suspended"])
+
+ snap = s.snapshot()
+ check("a suspended pane says what it would run",
+ "press a key to run:" in snap.screen(), repr(snap.screen()[:400]))
+ check("but has not run it", "web-running" not in snap.pane_text(web),
+ repr(snap.pane_text(web)))
+
+ s.api("focus", id=web["id"])
+ s.send("x")
+ s.settle()
+ now = [p for p in s.panes() if p["id"] == web["id"]]
+ check("a keystroke starts it", now and not now[0]["suspended"],
+ str(s.panes()))
+ check("and the command actually runs",
+ "web-running" in s.snapshot().pane_text(now[0]),
+ repr(s.snapshot().pane_text(now[0])))
+ check("the keystroke that started it is not also sent to it",
+ "x" not in s.snapshot().pane_text(now[0]).replace("web-running", ""),
+ repr(s.snapshot().pane_text(now[0])))
+ check("the session is unharmed", s.api("alive")["ok"])
+ os.unlink(s._layout_path)
+
+
+def test_declared_purposes_are_locked():
+ with session_with(LAYOUT, cols=78, rows=18) as s:
+ s.settle()
+ agent = [p for p in s.panes() if p["purpose"] == "agent:main"][0]
+ r = s.api("set-purpose", target="pane", id=agent["id"],
+ purpose="evil:relabelled", declared=False)
+ check("a layout-declared pane purpose cannot be taken in-band",
+ not r["ok"], str(r))
+ check("it is unchanged",
+ [p for p in s.panes() if p["id"] == agent["id"]][0]["purpose"]
+ == "agent:main")
+
+ r = s.api("set-purpose", target="tab", id=s.tabs()[0]["id"],
+ purpose="evil:tab", declared=False)
+ check("nor a tab purpose", not r["ok"], str(r))
+ os.unlink(s._layout_path)
+
+
+def test_cwd():
+ text = """
+ layout {
+ tab name="t" cwd="/tmp" {
+ pane command="pwd; cat"
+ pane cwd="/usr" command="pwd; cat"
+ }
+ }
+ """
+ with session_with(text, cols=70, rows=14) as s:
+ s.settle()
+ panes = s.panes()
+ snap = s.snapshot()
+ check("a tab's cwd is inherited by its panes",
+ "/tmp" in snap.pane_text(panes[0]), repr(snap.pane_text(panes[0])))
+ check("a pane can override it",
+ "/usr" in snap.pane_text(panes[1]), repr(snap.pane_text(panes[1])))
+ os.unlink(s._layout_path)
+
+
+def test_apply_over_the_api():
+ """How `sl0ppi up` will add a project tab to a running session."""
+ with Session(SH, cols=70, rows=14) as s:
+ s.settle()
+ check("one tab to start", len(s.tabs()) == 1)
+
+ frag = ('layout { tab name="added" purpose="project:x.deadbeef" '
+ '{ pane purpose="agent:main" } }')
+ r = s.api("apply-layout", kdl=frag)
+ s.settle()
+ check("apply-layout answers with the tab list", r["ok"] and "tabs" in r,
+ str(r)[:100])
+ check("the tab is added, not replacing what was there",
+ len(s.tabs()) == 2 and s.tabs()[1]["name"] == "added", str(s.tabs()))
+ check("its purpose is declared",
+ s.tabs()[1]["purpose"] == "project:x.deadbeef"
+ and s.tabs()[1]["purpose_declared"], str(s.tabs()[1]))
+
+ # idempotency belongs to the caller: it diffs, then adds what is missing
+ have = {t["purpose"] for t in s.tabs()}
+ if "project:x.deadbeef" not in have:
+ s.api("apply-layout", kdl=frag)
+ check("a caller that diffs first does not duplicate", len(s.tabs()) == 2,
+ str(s.tabs()))
+
+ r = s.api("close-tab", id=s.tabs()[1]["id"])
+ check("close-tab removes it", r["ok"] and len(s.tabs()) == 1, str(s.tabs()))
+
+ r = s.api("apply-layout", kdl='layout { tab name="only" { pane } }',
+ replace=True)
+ s.settle()
+ check("replace=true rebuilds the session",
+ len(s.tabs()) == 1 and s.tabs()[0]["name"] == "only", str(s.tabs()))
+
+
+def test_bad_layouts():
+ with Session(SH, cols=70, rows=14) as s:
+ s.settle()
+ r = s.api("apply-layout", kdl="layout { tab name=\"x\" ")
+ check("a broken layout is refused", not r["ok"], str(r))
+ check("with a line number", "line" in str(r.get("error", "")), str(r))
+
+ r = s.api("apply-layout", kdl="layout { }")
+ check("a layout with no tabs is refused", not r["ok"], str(r))
+
+ r = s.api("apply-layout", path="/nonexistent/layout.kdl")
+ check("a missing file is refused", not r["ok"], str(r))
+
+ check("the session survived all of it",
+ s.api("alive")["alive"] and len(s.tabs()) == 1, str(s.tabs()))
+
+ broken = layout_file("layout { tab name=\"x\"")
+ with Session(SH, cols=70, rows=14, layout=broken) as s:
+ s.settle()
+ check("a broken layout at startup still gives you a session",
+ s.api("alive")["alive"] and len(s.panes()) == 1, str(s.panes()))
+ os.unlink(broken)
+
+
+if __name__ == "__main__":
+ test_structure()
+ test_suspended()
+ test_declared_purposes_are_locked()
+ test_cwd()
+ test_apply_over_the_api()
+ test_bad_layouts()
+ sys.exit(report())
diff --git a/tests/test_session.py b/tests/test_session.py
index 7e56999..7138c6f 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -33,6 +33,7 @@ def attach(name, cols=60, rows=12, inner=None):
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "xterm-ghostty"
+ os.environ["SL0PTTY_CONFIG"] = "/nonexistent/sl0ptty.kdl"
args = [BIN, "-s", name]
if inner:
args += ["--"] + inner