aboutsummaryrefslogtreecommitdiffstats
path: root/packaging/checks/split-button-live.py
blob: 5926918a96750d6e837841166e1609049c185889 (plain) (blame)
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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
#!/usr/bin/env python3
"""Live check: is the `+` split button DRAWN, and does clicking the drawn glyph
split the pane?

The unit tests can only prove the title line composes correctly. This drives a
real session with the live config's frame settings -- centred titles, rounded
corners, a pane gap, no default pane names -- because every one of those takes a
different path through the title builders, and the gap moves the drawn frame
away from the pane's allocation.

It then attaches a SECOND client, because that is what the live box always had
(the phantom client a headless `sl0ppi up` leaks) and it switches the frame onto
a third title builder again -- the one with a "MY FOCUS" middle, which is where
the button was being dropped for two releases.

Usage: split-button-live.py [path-to-sl0ppij] [path-to-config.kdl]

With no config it uses a minimal one carrying just the live frame settings.
Pass `config/config.kdl` to drive the real thing, layouts and plugins included.
Exits non-zero with a description of what it saw.
"""

import fcntl
import os
import pty
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import termios
import time

BIN = os.path.abspath(
    sys.argv[1]
    if len(sys.argv) > 1
    else os.path.join(os.path.dirname(__file__), "../../sl0ppij/target/fastdev/sl0ppij")
)
ROWS, COLS = 30, 100
SESSION = f"sl0ptest-splitbutton-{os.getpid()}"

CONFIG = """
ui {
    pane_frames {
        rounded_corners true
        title_alignment "center"
        gap 1
        padding 0 0 0 0
        gap_aspect 2
        hide_default_pane_names true
    }
}
pane_frames true
pane_frame_style "full"
default_mode "locked"
show_startup_tips false
"""

ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b[]P][^\x07\x1b]*(\x07|\x1b\\)?|\x1b[()][0-9A-B]")


def screen_rows(raw):
    """Rebuild the visible grid from the pty stream: cursor moves, erases and
    printable runs. Not a terminal emulator, but it must honour the erases --
    without them a pane that shrinks leaves its old frame behind in the model,
    and the check then clicks a `+` that is no longer on screen and calls the
    product broken."""
    grid = {}
    row = col = 0
    i = 0

    def erase(cells):
        for cell in cells:
            grid.pop(cell, None)

    while i < len(raw):
        ch = raw[i]
        if ch == "\x1b":
            m = re.match(r"\x1b\[([0-9;?]*)([a-zA-Z])", raw[i:])
            if m:
                args, verb = m.group(1), m.group(2)
                nums = [int(n) for n in args.replace("?", "").split(";") if n != ""]
                first = nums[0] if nums else 0
                if verb in "Hf":
                    row = (nums[0] - 1) if nums else 0
                    col = (nums[1] - 1) if len(nums) > 1 else 0
                elif verb == "A":
                    row = max(0, row - max(1, first))
                elif verb == "B":
                    row += max(1, first)
                elif verb == "C":
                    col += max(1, first)
                elif verb == "D":
                    col = max(0, col - max(1, first))
                elif verb == "G":
                    col = max(0, first - 1)
                elif verb == "J":
                    if first == 0:
                        erase(
                            [(row, c) for c in range(col, COLS)]
                            + [(r, c) for r in range(row + 1, ROWS) for c in range(COLS)]
                        )
                    elif first == 1:
                        erase(
                            [(row, c) for c in range(0, col + 1)]
                            + [(r, c) for r in range(0, row) for c in range(COLS)]
                        )
                    else:
                        grid.clear()
                elif verb == "K":
                    if first == 0:
                        erase([(row, c) for c in range(col, COLS)])
                    elif first == 1:
                        erase([(row, c) for c in range(0, col + 1)])
                    else:
                        erase([(row, c) for c in range(COLS)])
                i += m.end()
                continue
            m = re.match(r"\x1b][^\x07\x1b]*(\x07|\x1b\\)", raw[i:])
            if m:
                i += m.end()
                continue
            i += 1
            continue
        if ch == "\r":
            col = 0
        elif ch == "\n":
            row += 1
            col = 0
        elif ch >= " ":
            grid[(row, col)] = ch
            col += 1
        i += 1
    out = []
    for r in range(ROWS):
        line = "".join(grid.get((r, c), " ") for c in range(COLS))
        out.append(line.rstrip())
    return out


def spawn_client(env, args, rows=ROWS, cols=COLS):
    pid, fd = pty.fork()
    if pid == 0:
        os.execvpe(BIN, [BIN] + args, env)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
    # non-blocking: a settled client produces nothing, and a blocking read on
    # its pty would hang this check instead of failing it
    os.set_blocking(fd, False)
    return pid, fd


def repaint(fd, cols=COLS, rows=ROWS):
    """Force a full redraw and return an EMPTY buffer to accumulate it into.

    The screen model here is deliberately simple, and the way a simple model
    goes wrong is stale cells: a pane that shrinks leaves its old frame behind,
    and the check then clicks a `+` that is not on screen any more. A resize
    makes the server repaint every cell, so reading from scratch afterwards
    cannot see anything that is no longer drawn.
    """
    # shrink, throw that paint away, then grow back: what the caller reads next
    # is one complete repaint of the final size and nothing else
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols - 1, 0, 0))
    time.sleep(0.8)
    try:
        while os.read(fd, 1 << 20):
            pass
    except (BlockingIOError, OSError):
        pass
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
    return ""


def read_screen(fd, buffered, seconds=2.5, until=None, quiet_for=0.7):
    """Accumulate output until the stream goes QUIET, not until a timer expires.

    A repaint arrives in many chunks, and stopping mid-repaint yields a screen
    that is half old and half new -- which reads exactly like a button drawn
    where it is not. Waiting for silence is the difference between a check that
    finds bugs and one that invents them.
    """
    deadline = time.time() + seconds
    last_data = time.time()
    while time.time() < deadline:
        time.sleep(0.15)
        try:
            chunk = os.read(fd, 1 << 20).decode("utf8", "replace")
        except BlockingIOError:
            chunk = ""
        except OSError:
            break
        if chunk:
            buffered += chunk
            last_data = time.time()
            continue
        if until and until(screen_rows(buffered)):
            break
        if buffered and time.time() - last_data >= quiet_for:
            break
    return buffered, screen_rows(buffered)


def find_button(rows):
    """(row, 0-based column of the `+`) on a pane's top frame row."""
    found = find_buttons(rows)
    return found[0] if found else None


def find_buttons(rows):
    """Every drawn split button: (row, 0-based column of the `+`).

    A frame row can hold more than one, once panes sit side by side.

    Identified by the CORNER it sits against: the button is the three columns
    immediately left of a pane's top-right corner (`split_button_columns`). A
    bare " + " search also matches the stack list's "+N more" and the short
    multi-client indication, and those share a screen row with a real frame as
    soon as two panes sit side by side -- which then reads as a button that does
    nothing when clicked, i.e. as this feature's own bug.
    """
    found = []
    for y, line in enumerate(rows):
        start = 0
        while True:
            at = line.find(" + ", start)
            if at < 0:
                break
            corner = line[at + 3] if at + 3 < len(line) else ""
            if corner in ("╮", "┐"):  # rounded / square top-right
                found.append((y, at + 1))
            start = at + 1
    return found


def main():
    tmp = tempfile.mkdtemp(prefix="sl0p-splitbutton-")
    if len(sys.argv) > 2:
        config_file = os.path.abspath(sys.argv[2])
        config_dir = os.path.dirname(config_file)
        print(f"config: {config_file}")
    else:
        config_file = os.path.join(tmp, "config.kdl")
        config_dir = tmp
        with open(config_file, "w") as f:
            f.write(CONFIG)
    env = dict(os.environ)
    env.update(
        {
            "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"),  # isolate from live sessions
            "SL0PPIJ_CONFIG_FILE": config_file,
            "SL0PPIJ_CONFIG_DIR": config_dir,
            "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"),
            "TERM": "xterm-256color",
            "SHELL": "/bin/sh",
            "PS1": "$ ",
        }
    )

    pid, fd = spawn_client(env, ["-s", SESSION])
    second_pid = second_fd = None

    raw = ""
    failures = []
    try:
        raw, rows = read_screen(fd, raw, seconds=20, until=find_button)

        # 1. one client: is it drawn at all, on the pane's top frame row?
        hit = find_button(rows)
        if hit is None:
            failures.append("one client: the split button was not drawn on any frame row")
            print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
            return failures
        y, x = hit
        print(f"one client:  drawn at row {y}, column {x}")
        print(f"   {rows[y]}")

        # 2. two clients: the frame grows a "MY FOCUS" middle, which is a
        #    different title builder -- and the one the live box always used,
        #    because a headless `sl0ppi up` leaves a phantom client behind.
        second_pid, second_fd = spawn_client(env, ["attach", SESSION])
        _, second_rows = read_screen(second_fd, "", seconds=10, until=find_button)
        raw, rows = read_screen(fd, repaint(fd), seconds=5)
        focus_middle = any("FOCUS" in r or "| ME |" in r for r in rows + second_rows)
        if not focus_middle:
            failures.append("two clients: no focus indication -- the second client did not attach")
        hit2 = find_button(rows)
        print(f"two clients: drawn at {hit2}")
        if hit2 is None:
            failures.append("two clients: the split button was dropped from the title line")
            print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
        elif hit2 != hit:
            failures.append(f"two clients: the button moved, {hit} -> {hit2}")
        if hit2:
            print(f"   {rows[hit2[0]]}")

        def pane_count():
            proc = subprocess.run(
                [BIN, "-s", SESSION, "action", "list-panes", "--json"],
                env=env,
                capture_output=True,
                text=True,
            )
            if os.environ.get("DEBUG"):
                print(f"list-panes: {proc.stdout[:2000]!r} {proc.stderr[:500]!r}")
            return proc.stdout.count('"pane_x"')

        def pane_geometries():
            out = subprocess.run(
                [BIN, "-s", SESSION, "action", "list-panes", "--json"],
                env=env,
                capture_output=True,
                text=True,
            ).stdout
            try:
                import json

                return [
                    {
                        k: p[k]
                        for k in (
                            "title",
                            "is_plugin",
                            "is_focused",
                            "pane_x",
                            "pane_y",
                            "pane_columns",
                            "pane_rows",
                            "pane_content_x",
                            "pane_content_y",
                        )
                    }
                    for p in json.loads(out)
                    if not p["is_plugin"]
                ]
            except Exception as e:  # noqa: BLE001 - diagnostics only
                return [f"could not parse list-panes: {e}"]

        before = pane_count()

        # 2. clicking two columns right of the glyph -- where the OLD hit-test
        #    looked, out in the pane gap -- must do nothing
        os.write(fd, f"\x1b[<0;{x + 3};{y + 1}M".encode())
        os.write(fd, f"\x1b[<0;{x + 3};{y + 1}m".encode())
        time.sleep(1.5)
        if pane_count() != before:
            failures.append("a click in the gap beside the button split the pane")

        # 3. clicking the drawn glyph splits
        def click(col, row):
            os.write(fd, f"\x1b[<0;{col + 1};{row + 1}M".encode())
            os.write(fd, f"\x1b[<0;{col + 1};{row + 1}m".encode())
            time.sleep(1.5)

        def settled():
            """A freshly repainted, fully arrived screen."""
            return read_screen(fd, repaint(fd), seconds=8)

        click(x, y)
        after = pane_count()
        print(f"panes: {before} -> {after}")
        if after != before + 1:
            failures.append(f"clicking the drawn button did not split ({before} -> {after})")

        # 4. EVERY drawn button must split the pane it belongs to, not just the
        #    first one -- panes away from the screen edge are inset differently
        #    (the gap is only applied at the edges), an unfocused pane is a
        #    different code path in the handler, and a stacked pane's frame is
        #    one row somewhere else entirely.
        for phase, setup in (
            ("tiled, side by side", None),
            ("stacked", ["action", "new-pane", "--", "/bin/sh"]),
        ):
            if setup:
                subprocess.run([BIN, "-s", SESSION] + setup, env=env, capture_output=True)
                time.sleep(1)
                ids = subprocess.run(
                    [BIN, "-s", SESSION, "action", "list-panes", "--json"],
                    env=env,
                    capture_output=True,
                    text=True,
                ).stdout
                terminals = [
                    str(m)
                    for m in re.findall(r'"id": (\d+),\n    "is_plugin": false', ids)
                ]
                if len(terminals) >= 2:
                    subprocess.run(
                        [BIN, "-s", SESSION, "action", "stack-panes", "--"] + terminals[:3],
                        env=env,
                        capture_output=True,
                    )
                time.sleep(1.5)
            raw, rows = settled()
            print(f"{phase}: {len(find_buttons(rows))} button(s) drawn at {find_buttons(rows)}")
            if not find_buttons(rows):
                failures.append(f"{phase}: no split button drawn at all")
            # Re-scan between clicks: every split moves the panes, so a list of
            # buttons collected before the first click is stale by the second.
            clicked = set()
            for _ in range(4):
                raw, rows = settled()
                todo = [b for b in find_buttons(rows) if b not in clicked]
                if not todo:
                    break
                by, bx = todo[0]
                clicked.add((by, bx))
                n0 = pane_count()
                click(bx, by)
                n1 = pane_count()
                print(f"   click ({by},{bx}): {n0} -> {n1}")
                if n1 != n0 + 1:
                    failures.append(
                        f"{phase}: the button drawn at row {by} column {bx} did not split"
                        f" ({n0} -> {n1})"
                    )
                    # the screen the button was found on, so the next reader can
                    # see WHICH pane's button was ignored, plus the geometry the
                    # server thinks those panes have
                    print(f"   screen when ({by},{bx}) was clicked:")
                    print("\n".join(f"   {i:02d}|{r}" for i, r in enumerate(rows)))
                    print("   panes (x, y, cols, rows) per the server:")
                    for p in pane_geometries():
                        print(f"     {p}")

        if os.environ.get("DEBUG"):
            raw, rows = read_screen(fd, raw, seconds=1)
            print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
        return failures
    finally:
        subprocess.run(
            [BIN, "kill-session", SESSION], env=env, capture_output=True, text=True
        )
        # `kill-session NAME`, not `-s NAME kill-session`: the latter parses
        # but leaves the session running. Then DELETE it -- a killed session
        # stays in `list-sessions` as "EXITED - attach to resurrect", so a day
        # of check runs silently fills the user's session picker.
        subprocess.run([BIN, "delete-session", SESSION], env=env, capture_output=True, text=True)
        # kill-session does not always take the server with it, and a leftover
        # test session is how a "MY FOCUS" bug report gets filed against nobody.
        #
        # Matched on `comm` plus THIS run's socket dir, never `pkill -f`: that
        # matches the checking process's own command line, and has killed real
        # clients here before.
        time.sleep(1)
        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:
                continue
            pid_str, comm, args = fields
            if comm == os.path.basename(BIN) and tmp in args:
                try:
                    os.kill(int(pid_str), 15)
                    print(f"cleaned up leftover server {pid_str}")
                except (ProcessLookupError, ValueError, PermissionError):
                    pass
        shutil.rmtree(tmp, ignore_errors=True)
        for p, f in ((pid, fd), (second_pid, second_fd)):
            if f is None:
                continue
            try:
                os.close(f)
            except OSError:
                pass
            os.waitpid(p, os.WNOHANG)


if __name__ == "__main__":
    problems = main()
    for p in problems:
        print(f"FAIL: {p}")
    print("ok: the split button is drawn where it is clicked" if not problems else "")
    sys.exit(1 if problems else 0)