diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
| commit | 7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40 (patch) | |
| tree | 6aaced8bd6927565816ce48ae07fb5f397d6edf7 /tests | |
| parent | sl0ptty: M2 — server/client split, detach/reattach, control socket (diff) | |
| download | slosh-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.py | 13 | ||||
| -rw-r--r-- | tests/test_screen.py | 5 | ||||
| -rw-r--r-- | tests/test_tabs.py | 180 |
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()) |
