#!/usr/bin/env python3 """Live check: `sl0ppi up` creates a real session from the SHIPPED config. The CLI writes its own layout file and hands it to the binary, so anything the shipped `layouts/default.kdl` refers to has to be copied into that file. When the status bar became a `pane_template`, every `sl0ppi up` died with × Failed to parse Zellij configuration 43 │ sl0pbar size=3 · ╰── Invalid tab property: sl0pbar and the CLI's own unit tests were all green, because they check strings rather than whether zellij will parse the result. This runs the real thing. Usage: sl0ppi-up-live.py [path-to-sl0ppij] [path-to-zjstatus.wasm] """ import fcntl import json import os import pty import shutil import struct import subprocess import sys import tempfile import termios import time HERE = os.path.dirname(os.path.abspath(__file__)) def bar_on_screen(binary, session, env, cols, marker="alpha"): """Attach a client at `cols` and report (visible, framed, last few rows). Borrows the screen reader from the status bar check rather than growing a third copy of it. """ import importlib.util spec = importlib.util.spec_from_file_location( "status_bar_live", os.path.join(HERE, "status-bar-live.py") ) screen = importlib.util.module_from_spec(spec) spec.loader.exec_module(screen) screen.COLS, screen.ROWS = cols, 30 pid, fd = pty.fork() if pid == 0: os.execvpe(binary, [binary, "attach", session], env) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 30, cols, 0, 0)) os.set_blocking(fd, False) time.sleep(4) try: _, rows = screen.read_screen(fd, screen.repaint(fd), seconds=10) finally: try: os.close(fd) except OSError: pass os.waitpid(pid, os.WNOHANG) # the tab strip, not the mode indicator: `format_hide_on_overlength` drops # the bar's left section first, so on a narrow terminal LOCKED is gone while # the bar is very much there visible = any(marker in row for row in rows[-4:]) framed = any("\u256d" in row for row in rows[-4:]) and any("\u2570" in row for row in rows[-2:]) return visible, framed, [row.rstrip()[:100] for row in rows if row.strip()][-4:] REPO = os.path.abspath(os.path.join(HERE, "../..")) BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else f"{REPO}/sl0ppij/target/fastdev/sl0ppij") WASM = os.path.abspath( sys.argv[2] if len(sys.argv) > 2 else f"{REPO}/sl0pstatus/target/wasm32-wasip1/release/zjstatus.wasm" ) CLI = f"{REPO}/cli/src/index.ts" # the session is named after the root directory: one root, one session SESSION = f"sl0ptest-up-{os.getpid()}" PROJECTS = ["alpha", "beta"] def main(): for path in (BIN, WASM, CLI): if not os.path.exists(path): print(f"FAIL: missing {path}") return 1 if shutil.which("bun") is None: print("FAIL: bun is needed to run the CLI from source") return 1 tmp = tempfile.mkdtemp(prefix="sl0p-up-") config_dir = os.path.join(tmp, "config") shutil.copytree(os.path.join(REPO, "config"), config_dir) os.makedirs(os.path.join(config_dir, "plugins"), exist_ok=True) shutil.copy(WASM, os.path.join(config_dir, "plugins", "zjstatus.wasm")) for name in ("config.kdl", "layouts/default.kdl", "layouts/default.swap.kdl"): path = os.path.join(config_dir, name) if os.path.isfile(path): with open(path) as f: text = f.read() with open(path, "w") as f: f.write(text.replace("~/.config/sl0ppij", config_dir)) root = os.path.join(tmp, SESSION) for project in PROJECTS: os.makedirs(os.path.join(root, project, ".sl0ppi"), exist_ok=True) env = dict(os.environ) env.update( { "SL0PPI_BIN": BIN, "SL0PPIJ_CONFIG_DIR": config_dir, "SL0PPIJ_CONFIG_FILE": os.path.join(config_dir, "config.kdl"), "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"), "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"), "XDG_CACHE_HOME": os.path.join(tmp, "cache"), "HOME": tmp, "TERM": "xterm-256color", "SHELL": "/bin/sh", } ) failures = [] try: up = subprocess.run( ["bun", CLI, "up", "--root", root], env=env, capture_output=True, text=True, timeout=120, ) output = up.stdout + up.stderr print(output.strip()[:1200]) # the failure mode this exists for prints a parse error and still exits 0 for marker in ("Failed to parse", "Invalid tab property", "error:"): if marker in output: failures.append(f"`sl0ppi up` reported {marker!r}") if up.returncode != 0: failures.append(f"`sl0ppi up` exited {up.returncode}") tabs = subprocess.run( [BIN, "-s", SESSION, "action", "list-tabs", "--json"], env=env, capture_output=True, text=True, timeout=60, ).stdout try: names = [tab.get("name", "") for tab in json.loads(tabs)] except Exception: names = [] print(f"tabs: {names}") for project in PROJECTS: if not any(project in name for name in names): failures.append(f"no tab for project {project!r} (got {names})") # identity: the fork hangs a tab's purpose on its root layout, and the # CLI inlines the template precisely so templating cannot eat it # Identity: `sl0ppi ls` finds project tabs by the purpose on each tab. # Emitting a tab template used to erase that -- which is why the CLI # inlined instead, at the cost of runtime tabs having no bar. Both # properties have to hold at once, so both are checked here. listing = subprocess.run( ["bun", CLI, "ls", "--root", root], env=env, capture_output=True, text=True, timeout=60 ).stdout print("sl0ppi ls:", " | ".join(line.strip() for line in listing.strip().splitlines())) for project in PROJECTS: if project not in listing: failures.append(f"`sl0ppi ls` does not see project {project!r}") # a project with a tab reads "alpha tab 0 1 pane(s) ..."; # one without reads "alpha —" if listing.count("tab ") < len(PROJECTS): failures.append(f"`sl0ppi ls` does not see a tab per project: {listing.strip()!r}") layout_dump = subprocess.run( [BIN, "-s", SESSION, "action", "dump-layout"], env=env, capture_output=True, text=True ).stdout for project in PROJECTS: if f'purpose="project:{project}"' not in layout_dump: failures.append(f"tab for {project!r} lost its purpose= (identity is gone)") # A tab made at RUNTIME must come up with a status bar too -- exactly # one. This is what the bar's `+` button does and what the `n` keybinding # does; while the CLI inlined its tab template instead of emitting one, # both produced a bare tab, because there was no template for the new tab # to take a bar from. # # Counted per tab from list-panes rather than by grepping dump-layout: # that dump also contains the template itself and every swap layout, so # substring counting there reports bars that are definitions, not panes. # # BEFORE any client attaches, too: list-panes stops reporting borderless # plugin panes for a tab a client has rendered, which reads exactly like # the bar being missing. subprocess.run([BIN, "-s", SESSION, "action", "new-tab"], env=env, capture_output=True) time.sleep(3) per_tab = {} panes = subprocess.run( [BIN, "-s", SESSION, "action", "list-panes", "--json"], env=env, capture_output=True, text=True, timeout=60, ).stdout for pane in json.loads(panes or "[]"): if pane.get("is_suppressed"): continue tab = pane.get("tab_name") or pane.get("tab_position") per_tab.setdefault(tab, 0) if "zjstatus" in (pane.get("plugin_url") or ""): per_tab[tab] += 1 print(f"status bars per tab after new-tab: {per_tab}") for tab, count in per_tab.items(): if count != 1: failures.append(f"tab {tab!r} has {count} status bars, expected exactly 1") # The bar has to survive a client attaching, a resize, and auto_layout # swapping the tab around. Read from the SCREEN: `list-panes` stops # reporting the borderless plugin pane once a client is attached, which # is a fine way to convince yourself of a bug that is not there. for cols, expect_framed in ((100, True), (70, True)): visible, framed, tail = bar_on_screen(BIN, SESSION, env, cols) print(f"at {cols} columns: bar visible={visible} framed={framed}") if not visible: failures.append(f"at {cols} columns the generated session shows no status bar") print("\n".join(f" {line}" for line in tail)) elif framed != expect_framed: failures.append( f"at {cols} columns the bar framed={framed}, expected {expect_framed}" ) # KNOWN GAP: a generated layout has no sibling .swap.kdl, so the # responsive collapse falls back to a synthesized stack and the bar # keeps its full height instead of collapsing to one row. Copying the # user's swap layouts in is NOT the fix: their `tab` nodes expect a # default_tab_template, which the CLI does not emit (templating a tab # destroys its purpose, and purpose is identity here), so as soon as # auto_layout swaps the tab the bar vanishes entirely. Measured, both # ways, on screen. subprocess.run([BIN, "-s", SESSION, "action", "new-pane"], env=env, capture_output=True) time.sleep(1.5) subprocess.run([BIN, "-s", SESSION, "action", "new-pane"], env=env, capture_output=True) time.sleep(1.5) visible, _, tail = bar_on_screen(BIN, SESSION, env, 100) print(f"after adding panes: bar visible={visible}") if not visible: failures.append("the status bar disappeared once the tab was rearranged") print("\n".join(f" {line}" for line in tail)) return failures finally: subprocess.run([BIN, "kill-session", SESSION], env=env, capture_output=True) # `kill-session NAME`, not `-s NAME kill-session`: the latter parses but # leaves the session running. Delete too: killed sessions linger in # `list-sessions` as resurrectable. subprocess.run([BIN, "delete-session", SESSION], env=env, capture_output=True) ps = subprocess.run(["ps", "-eo", "pid,comm,args"], capture_output=True, text=True).stdout for line in ps.splitlines(): fields = line.split(None, 2) if len(fields) == 3 and fields[1] == os.path.basename(BIN) and tmp in fields[2]: try: os.kill(int(fields[0]), 15) except (ProcessLookupError, ValueError, PermissionError): pass shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": problems = main() if problems == 1: sys.exit(1) for p in problems: print(f"FAIL: {p}") if not problems: print("ok: `sl0ppi up` builds a session the binary accepts") sys.exit(1 if problems else 0)