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
|
"""Drive slosh's headless driver and assert on the composited screen.
with Session(["/bin/sh", "-c", "echo hi"], cols=20, rows=3) as s:
s.settle()
assert s.snapshot().line(0).strip() == "hi"
No pty, no sleeps beyond settling, no screen scraping: the compositor tells us
what it composed. This is the harness DESIGN.md asks for, and everything from
M1 on is tested through it.
"""
import json
import os
import subprocess
import time
# $SLOSH_BIN lets the same suite drive a differently-built binary — the
# coverage build, say — without a second copy of the harness.
BIN = os.environ.get(
"SLOSH_BIN",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "build", "slosh"),
)
class Snapshot:
def __init__(self, data):
self.data = data
self.text = data["text"]
self.cursor = data["cursor"]
self.styles = data["styles"]
self.hits = data["hits"]
self.cols = data["cols"]
self.rows = data["rows"]
def line(self, y):
return self.text[y]
def screen(self):
return "\n".join(self.text)
def find(self, needle):
"""(x, y) of the first occurrence, or None."""
for y, row in enumerate(self.text):
x = row.find(needle)
if x >= 0:
return x, y
return None
def style_at(self, x, y):
"""The style run covering a cell, or None if the cell is unstyled."""
for run in self.styles:
if run["y"] == y and run["x"] <= x < run["x"] + run["w"]:
return run
return None
def pane_line(self, pane, row):
"""One row of a pane's content area, chrome excluded."""
y = pane["content_y"] + row
x = pane["content_x"]
return self.text[y][x : x + pane["content_w"]]
def pane_text(self, pane):
return "\n".join(self.pane_line(pane, r) for r in range(pane["content_h"]))
def hit_at(self, x, y):
"""The action registered at a cell, last painted winning."""
for e in reversed(self.hits):
if e["x"] <= x < e["x"] + e["w"] and e["y"] <= y < e["y"] + e["h"]:
return e["action"]
return None
def handle(self, pane_id, side):
"""The middle cell of a pane's split handle, read from the hit list.
Read rather than recomputed on purpose: a test that derives the geometry
itself agrees with a broken formula as happily as with a correct one.
`side` is one of l r t b; the top row's handle answers to `title:` since
a press there can still become a drag.
"""
want = f"title:{pane_id}:t" if side == "t" else f"border:{pane_id}:{side}"
for e in self.hits:
if e["action"] == want:
return e["x"] + e["w"] // 2, e["y"] + e["h"] // 2
return None
def rim(self, pane_id, side):
"""A cell on the same edge that is *not* the handle, or None."""
h = self.handle(pane_id, side)
if not h:
return None
want = f"title:{pane_id}" if side == "t" else f"brim:{pane_id}:{side}"
for e in self.hits:
if e["action"] != want:
continue
for y in range(e["y"], e["y"] + e["h"]):
for x in range(e["x"], e["x"] + e["w"]):
if self.hit_at(x, y) == want:
return x, y
return None
def __str__(self):
return self.screen()
class Session:
def __init__(self, argv, cols=80, rows=24, config=None, env=None, layout=None):
self.cols, self.rows = cols, rows
environ = dict(os.environ)
if config:
environ["SLOSH_CONFIG"] = str(config)
else:
environ["SLOSH_CONFIG"] = "/nonexistent/slosh.kdl"
if env:
environ.update(env)
cmd = [BIN, "--script", "--cols", str(cols), "--rows", str(rows)]
if layout:
cmd += ["--layout", str(layout)]
self.proc = subprocess.Popen(
cmd + ["--"] + argv,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
env=environ,
)
# -- commands ---------------------------------------------------------
def _cmd(self, line):
self.proc.stdin.write(line + "\n")
self.proc.stdin.flush()
def send(self, escaped):
"""Bytes as if typed at the outer terminal (decoded, then re-encoded)."""
self._cmd(f"send {escaped}")
def key(self, chord):
r"""A prefixed command, e.g. key('\\\\') to split. C-a is \x01."""
self._cmd(f"send \\x01{chord}")
def click(self, x, y, button=0):
self._cmd(f"send \\e[<{button};{x + 1};{y + 1}M")
self._cmd(f"send \\e[<{button};{x + 1};{y + 1}m")
def raw(self, escaped):
"""Bytes straight into the pane's pty, decoder bypassed."""
self._cmd(f"raw {escaped}")
def settle(self, ms=20):
"""Pump every pane until none has produced output for `ms`.
The default is deliberately short. `settle` is a *duration*, and a
duration is a guess: too long and every call donates dead time to every
run forever, too short and it races. Where the answer is observable,
use until()/until_text() instead and stop guessing.
"""
self._cmd(f"settle {ms}")
def until(self, pred, timeout_ms=3000, step_ms=10):
"""Pump until pred(snapshot) holds, then return that snapshot.
`settle N` is the only thing that reads a pane's pty, so waiting for a
program's output means pumping — but it is also a *duration*, and a
duration is a guess about something observable. This polls the thing
itself and returns the instant it is true, which is both faster than a
generous settle and safer than a tight one: the deadline only bounds
failure, never success.
Returns the last snapshot either way, so a caller's own check reports
what was actually on screen rather than a timeout from in here.
"""
deadline = time.monotonic() + timeout_ms / 1000.0
while True:
self.settle(step_ms)
snap = self.snapshot()
if pred(snap) or time.monotonic() >= deadline:
return snap
def until_text(self, needle, **kw):
"""The common case: wait for a program to have printed something."""
return self.until(lambda snap: needle in snap.screen(), **kw)
def resize(self, cols, rows):
self.cols, self.rows = cols, rows
self._cmd(f"resize {cols} {rows}")
def snapshot(self):
self._cmd("snapshot json")
line = self.proc.stdout.readline()
if not line:
raise RuntimeError("slosh 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("slosh 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()
if not line:
raise RuntimeError("slosh exited before answering panes")
return json.loads(line)
def pane(self, n=0):
return self.panes()[n]
def focused(self):
for p in self.panes():
if p["focused"]:
return p
return None
def alive(self):
self._cmd("alive")
return self.proc.stdout.readline().strip() == "true"
def deadline(self):
"""Milliseconds until the session wants a frame of its own, or -1.
A property of the frame last composed -- an animated shader asks for a
clock, a toast asks to expire -- so snapshot() first or you are asking
about nothing."""
self._cmd("deadline")
return int(self.proc.stdout.readline().strip())
def close(self):
try:
self._cmd("quit")
self.proc.stdin.close()
self.proc.wait(timeout=5)
except Exception:
self.proc.kill()
def __enter__(self):
return self
def __exit__(self, *a):
self.close()
# -- tiny test runner ------------------------------------------------------
_fails = []
def check(name, cond, detail=""):
if cond:
print(f"ok {name}")
else:
_fails.append(name)
print(f"FAIL {name} <- {detail}")
def report():
print(f"\n{'FAILED' if _fails else 'all green'} ({len(_fails)} failures)")
return 1 if _fails else 0
|