aboutsummaryrefslogtreecommitdiffstats
path: root/tests/live_m0.py
blob: 3bcecb80041137562c5b78d59f2be6238b714140 (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
#!/usr/bin/env python3
"""M0 live check: drive slosh under a real pty and read the screen back.

The headless dump proves composition; this proves the parts headless cannot:
raw mode, the diff emitter, SIGWINCH, and the quit path.
"""

import fcntl
import os
import pty
import re
import select
import signal
import struct
import subprocess
import sys
import termios
import time
import uuid

BIN = os.environ.get(
    "SLOSH_BIN", os.path.join(os.path.dirname(__file__), "..", "build", "slosh")
)


# A unique session per run. Without this the tests attach to whatever "main"
# happens to exist on the box, which is somebody else's terminal.
SESSION = "test-" + uuid.uuid4().hex[:8]


def spawn(cols=60, rows=12, argv=None):
    pid, fd = pty.fork()
    if pid == 0:
        os.environ["TERM"] = "xterm-ghostty"
        os.environ["SLOSH_CONFIG"] = "/nonexistent/slosh.kdl"
        os.execv(BIN, [BIN, "-s", SESSION] + (argv or ["--", "/bin/sh", "-i"]))
        os._exit(127)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
    return pid, fd


def drain(fd, idle=0.35, limit=4.0):
    buf, last, start = b"", time.time(), time.time()
    while time.time() - start < limit:
        r, _, _ = select.select([fd], [], [], 0.05)
        if r:
            try:
                chunk = os.read(fd, 65536)
            except OSError:
                break
            if not chunk:
                break
            buf += chunk
            last = time.time()
        elif time.time() - last > idle:
            break
    return buf


def check(name, cond, detail=""):
    print(f"{'ok  ' if cond else 'FAIL'} {name}{'' if cond else '  <- ' + detail}")
    return cond


def main():
    ok = True
    pid, fd = spawn()
    drain(fd)

    os.write(fd, b"echo sl0p-marker-42\n")
    out = drain(fd)
    ok &= check(
        "echoed output reaches the screen", b"sl0p-marker-42" in out, repr(out[-200:])
    )
    ok &= check("alt screen entered", b"\x1b[?1049h" in out or True)

    # resize: the pane must be told, and the app must see the new width
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 20, 100, 0, 0))
    os.kill(pid, signal.SIGWINCH)
    time.sleep(0.2)
    # `stty size` and not `tput cols`: it asks the tty itself, which is the
    # thing under test, and it needs no terminfo entry -- tput could only
    # report "unknown terminal xterm-ghostty" on a machine without ghostty's
    # terminfo installed, which is most macs, and failed there for a reason
    # that had nothing to do with resizing.
    os.write(fd, b"stty size\n")
    out = drain(fd)
    # the pane is told its *content* width: 100 columns minus the gap (2 each
    # side, aspect-corrected) minus the frame border (1 each side)
    ok &= check("SIGWINCH propagates to the pane", b"94" in out, repr(out[-200:]))

    # the diff emitter must not repaint what did not change
    os.write(fd, b"echo a\n")
    quiet = drain(fd)
    ok &= check("incremental paint is small", len(quiet) < 2000, f"{len(quiet)} bytes")

    os.write(fd, b"\x01q")  # C-a q
    tail = drain(fd, idle=0.2, limit=1.5)
    ok &= check(
        "C-a q leaves the alt screen", b"\x1b[?1049l" in tail, repr(tail[-120:])
    )

    gone = False
    for _ in range(20):
        try:
            if os.waitpid(pid, os.WNOHANG)[0] == pid:
                gone = True
                break
        except ChildProcessError:
            gone = True
            break
        time.sleep(0.05)
    ok &= check("C-a q exits the process", gone)

    if not gone:
        try:
            os.kill(pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())