aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:58:24 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:58:24 +0200
commit7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40 (patch)
tree6aaced8bd6927565816ce48ae07fb5f397d6edf7 /tests
parentsl0ptty: M2 — server/client split, detach/reattach, control socket (diff)
downloadslosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.gz
slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.xz
slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.zip
sl0ptty: M3 — tabs, purposes, and the JSON control API
Tabs are layout trees, one per tab, and panes in every tab keep running: fds, pumping and reaping span all of them, only the current one is composed. A one-row strip shows them (M5 makes it the real status bar) and each label registers a hit, so clicking a tab switches to it through the same mechanism as every other click. C-a c new tab C-a n / C-a p cycle C-a 1..9 select Purposes on panes and tabs with D8's trust model implemented as a lock: declared (from a layout or an operator) outranks in-band, cannot be overridden, and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives with the OSC scanner in M4 and always passes declared:false; the refusal is already tested. The control API is now JSON in, JSON out: {"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"} {"ok":true,"id":2} with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/ set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the focused one', so a background tab can be scripted. The bare-verb form stays as a harness alias and runs the same code, so the tests cannot drift from the API. Adds a small JSON reader (jsonval.c) — we could only emit until now. 129 checks green.
Diffstat (limited to 'tests')
-rw-r--r--tests/harness.py13
-rw-r--r--tests/test_screen.py5
-rw-r--r--tests/test_tabs.py180
3 files changed, 196 insertions, 2 deletions
diff --git a/tests/harness.py b/tests/harness.py
index c29f4d3..2271dcb 100644
--- a/tests/harness.py
+++ b/tests/harness.py
@@ -113,6 +113,19 @@ class Session:
raise RuntimeError("sl0ptty exited before answering snapshot")
return Snapshot(json.loads(line))
+ def api(self, cmd, **kw):
+ """One JSON control request; returns the parsed reply."""
+ kw["cmd"] = cmd
+ self._cmd(json.dumps(kw))
+ line = self.proc.stdout.readline()
+ if not line:
+ raise RuntimeError("sl0ptty exited before answering " + cmd)
+ return json.loads(line)
+
+ def tabs(self):
+ self._cmd("tabs")
+ return json.loads(self.proc.stdout.readline())
+
def panes(self):
self._cmd("panes")
line = self.proc.stdout.readline()
diff --git a/tests/test_screen.py b/tests/test_screen.py
index 6b82f1f..0dd24f2 100644
--- a/tests/test_screen.py
+++ b/tests/test_screen.py
@@ -93,9 +93,10 @@ def test_resize():
check("resize changes the composited size",
snap.cols == 50 and snap.rows == 12 and len(snap.line(0)) == 50,
f"{snap.cols}x{snap.rows}")
- # chrome budget: gap 1 (x2 aspect => 2 cols) each side, plus a border
+ # chrome budget: gap 1 (x2 aspect => 2 cols) each side, a border, and the
+ # one-row tab strip along the top
check("the pane grows with the screen, minus its chrome",
- p["content_w"] == snap.cols - 6 and p["content_h"] == snap.rows - 4,
+ p["content_w"] == snap.cols - 6 and p["content_h"] == snap.rows - 5,
str(p))
check("pane still responds after resize", s.alive())
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
new file mode 100644
index 0000000..c45073e
--- /dev/null
+++ b/tests/test_tabs.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+"""M3: tabs, purposes and the JSON control API."""
+import sys
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
+
+
+def test_tabs():
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ check("one tab to start", len(s.tabs()) == 1)
+
+ s.raw("in-tab-one")
+ s.settle()
+ s.key("c") # new tab
+ s.settle()
+ tabs = s.tabs()
+ check("C-a c makes a tab", len(tabs) == 2, str(tabs))
+ check("the new tab is active", tabs[1]["active"] and not tabs[0]["active"])
+ check("each tab has its own pane", [t["panes"] for t in tabs] == [1, 1],
+ str(tabs))
+
+ snap = s.snapshot()
+ check("the new tab's pane is empty", "in-tab-one" not in snap.screen())
+ check("the tab strip is drawn", "1" in snap.line(1) and "2" in snap.line(1),
+ repr(snap.line(1)))
+
+ s.key("p") # previous tab
+ s.settle()
+ check("C-a p goes back", s.tabs()[0]["active"])
+ check("the first tab kept its content", "in-tab-one" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:200]))
+
+ s.key("2")
+ s.settle()
+ check("C-a 2 selects by number", s.tabs()[1]["active"])
+
+
+def test_background_tabs_keep_running():
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ s.key("c")
+ s.settle()
+ first = [p for p in s.panes() if p["tab"] == 1][0]
+ # drive the *background* tab's pane through the API
+ s.api("focus", id=first["id"])
+ s.settle()
+ s.raw("still-alive")
+ s.settle()
+ s.key("n") # away again
+ s.settle()
+ check("a background pane still accepts input",
+ any(p["tab"] == 1 for p in s.panes()), str(s.panes()))
+ s.api("select-tab", index=1)
+ s.settle()
+ check("its output survived being off-screen",
+ "still-alive" in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:200]))
+
+
+def test_tab_click():
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ s.key("c")
+ s.settle()
+ snap = s.snapshot()
+ pos = snap.find("1") # the first tab's label, on the strip row
+ check("tab label is on the strip", pos is not None and pos[1] == 1, str(pos))
+ if pos:
+ x, y = pos
+ action = snap.hit_at(x, y)
+ check("the tab label is clickable", action == "tab:1", str(action))
+ s.click(x, y)
+ s.settle()
+ check("clicking a tab switches to it", s.tabs()[0]["active"],
+ str(s.tabs()))
+
+
+def test_closing_a_tab():
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ s.key("c")
+ s.settle()
+ s.key("\\\\") # two panes in tab 2
+ s.settle()
+ check("tab 2 has two panes", s.tabs()[1]["panes"] == 2, str(s.tabs()))
+ s.key("x")
+ s.settle()
+ check("closing one pane keeps the tab", len(s.tabs()) == 2, str(s.tabs()))
+ s.key("x")
+ s.settle()
+ check("closing the last pane closes the tab", len(s.tabs()) == 1,
+ str(s.tabs()))
+ check("the surviving tab is active and usable",
+ s.tabs()[0]["active"] and s.api("alive")["alive"])
+
+
+def test_purpose_trust_model():
+ """D8: a declared purpose outranks an in-band one and cannot be overridden."""
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ pane = s.pane()
+
+ r = s.api("set-purpose", target="pane", id=pane["id"],
+ purpose="agent:main", declared=True)
+ check("a declared purpose is accepted", r["ok"], str(r))
+ p = s.pane()
+ check("the purpose is reported", p["purpose"] == "agent:main", str(p))
+ check("and marked declared", p["purpose_declared"], str(p))
+
+ r = s.api("set-purpose", target="pane", id=pane["id"],
+ purpose="evil:overridden", declared=False)
+ check("an in-band purpose cannot override a declared one", not r["ok"],
+ str(r))
+ check("the declared purpose is intact",
+ s.pane()["purpose"] == "agent:main", str(s.pane()))
+
+ r = s.api("set-purpose", target="tab", id=s.tabs()[0]["id"],
+ purpose="project:foo bar/../$(rm -rf)", declared=True)
+ check("a tab purpose is sanitised on ingest",
+ s.tabs()[0]["purpose"] == "project:foobar/../rm-rf",
+ str(s.tabs()[0]["purpose"]))
+
+ with Session(SH, cols=60, rows=12) as s:
+ s.settle()
+ pane = s.pane()
+ r = s.api("set-purpose", target="pane", id=pane["id"],
+ purpose="guessed:by-the-pane", declared=False)
+ check("an in-band purpose is allowed when nothing is declared", r["ok"],
+ str(r))
+ check("and does not lock the slot",
+ not s.pane()["purpose_declared"], str(s.pane()))
+
+
+def test_json_api():
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ base = s.pane()["id"]
+
+ r = s.api("split", dir="rows", id=base)
+ check("split answers with the new pane id", r["ok"] and r["id"] != base,
+ str(r))
+ new = r["id"]
+
+ r = s.api("focus", id=base)
+ check("focus by id works", r["ok"] and r["id"] == base, str(r))
+ check("focus by id is reflected in the layout",
+ [p for p in s.panes() if p["focused"]][0]["id"] == base)
+
+ r = s.api("new-tab", name="build", purpose="project:x.deadbeef")
+ check("new-tab answers with an id", r["ok"] and r["id"], str(r))
+ t = [t for t in s.tabs() if t["id"] == r["id"]][0]
+ check("new-tab records name and declared purpose",
+ t["name"] == "build" and t["purpose"] == "project:x.deadbeef"
+ and t["purpose_declared"], str(t))
+
+ r = s.api("snapshot", format="text")
+ check("snapshot text comes back as JSON", r["ok"] and "text" in r,
+ str(r)[:80])
+
+ r = s.api("close", id=new)
+ check("close by id works", r["ok"], str(r))
+
+ check("unknown commands are refused, not fatal",
+ s.api("nope")["error"] == "unknown cmd")
+ r = s.api("focus", id=9999)
+ check("bad ids are refused, not fatal", not r["ok"] and s.api("alive")["ok"],
+ str(r))
+
+
+if __name__ == "__main__":
+ test_tabs()
+ test_background_tabs_keep_running()
+ test_tab_click()
+ test_closing_a_tab()
+ test_purpose_trust_model()
+ test_json_api()
+ sys.exit(report())