diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:27:47 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:27:47 +0200 |
| commit | 58afd137373fb395fc0479d091c4da9ffa60324a (patch) | |
| tree | 93839e3c20ff722819444ed22deaef07dc6e6041 /tests | |
| parent | sl0ptty: M6 — the config file, and a README (diff) | |
| download | slosh-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.py | 8 | ||||
| -rw-r--r-- | tests/live_input.py | 6 | ||||
| -rw-r--r-- | tests/live_m0.py | 10 | ||||
| -rw-r--r-- | tests/test_layout_files.py | 211 | ||||
| -rw-r--r-- | tests/test_session.py | 1 |
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 |
