1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
|
#!/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)
|