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
|
#!/usr/bin/env python3
"""Hash settled SDL frames of a fixed set of scenes, to prove a GUI refactor
left every pixel where it was.
python3 -B test/gui_golden.py <pardes-gui binary> compare
python3 -B test/gui_golden.py <pardes-gui binary> --update rewrite goldens
The window is hidden (PARDES_TEST renders into an offscreen capture texture),
and the capture carries PARDES_TEST_PAD leftover pixels so edge bugs show.
GPU rasterisation differs between machines and drivers: the goldens in
test/gui-goldens.txt are a LOCAL gate for one machine, never a CI one. Each
scene waits until the window says it is idle (its status file) before it is
hashed, so a loaded machine gives the same frames.
"""
import argparse
import hashlib
import os
from pathlib import Path
import tempfile
import shutil
import time
from fs import session
from ui_review import require, wait_for
GOLDENS = Path(__file__).resolve().parent / 'gui-goldens.txt'
ZIG = b'''const std = @import("std");
pub const Shape = union(enum) {
circle: f32,
square: f32,
pub fn area(shape: Shape) f32 {
return switch (shape) {
.circle => |r| std.math.pi * r * r,
.square => |s| s * s,
};
}
};
pub fn main() void {
var total: f32 = 0;
const shapes = [_]Shape{ .{ .circle = 1 }, .{ .square = 2 } };
for (shapes) |shape| {
''' + b''.join(b' total += shape.area() * %d; // line %d\n' % (n, n) for n in range(60)) + b''' }
std.debug.print("{d}\\n", .{total});
}
'''
def ppm(width, height):
rows = []
for y in range(height):
rows.append(bytes(v for x in range(width) for v in ((x * 255) // width, (y * 255) // height, 128)))
return b'P6\n%d %d\n255\n' % (width, height) + b''.join(rows)
def panes(client):
serials = [int(row.split()[0]) for row in client.read('/index').decode().splitlines() if row.strip()]
return {serial: client.read(f'/pane/{serial}/tag').decode(errors='replace') for serial in serials}
def status(root):
"""The window's (step, frames captured, idle), or None before the first."""
try:
step, frames, idle = (root / 'status').read_text().split()[:3]
return int(step), int(frames), idle == '1'
except (FileNotFoundError, ValueError):
return None
def settled(root):
"""The capture once the window says it is idle: a loop step past the one
current when this was asked, with nothing owing a frame (the window's
status file, gui.zig writeTestStatus). Every command before this call has
been served by then (a 9P write returns once the core has it), so the
frame is the scene's however loaded the machine is; the digest of it."""
deadline = time.monotonic() + 60
start = None
while time.monotonic() < deadline:
now = status(root)
if now is not None:
if start is None:
start = now[0]
elif now[0] >= start + 2 and now[2] and now[1] > 0:
data = (root / 'latest.ppm').read_bytes()
if data.startswith(b'P6'):
return hashlib.sha256(data).hexdigest()
time.sleep(.02)
raise RuntimeError('the window never went idle')
def run(args):
results = []
# A fixed directory: the paths it holds are drawn in the tags.
root = Path(tempfile.gettempdir()) / 'pardes-gui-golden'
shutil.rmtree(root, ignore_errors=True)
root.mkdir(mode=0o700)
# Captures go outside the session's own directory: a file written there
# every frame is a change in the directory its panes look at, which
# would ask for another frame, and so on for ever.
captures = Path(tempfile.gettempdir()) / 'pardes-gui-golden-captures'
shutil.rmtree(captures, ignore_errors=True)
captures.mkdir(mode=0o700)
name = 'gui-golden'
config = root / 'config/pardes'
config.mkdir(parents=True)
# No linger: a dismissed message is gone, not caught part way, when a
# scene settles.
(config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\n')
source = root / 'shapes.zig'
source.write_bytes(ZIG)
notes = root / 'notes.txt'
notes.write_bytes(b'Pardes GUI golden\n\nA second document, opened by Look.\n' * 4)
wide = root / 'wide.txt'
# One of the two alignments puts a wide glyph across the body's right edge.
wide.write_bytes(('x' + '\u754c' * 80 + '\n' + '\u754c' * 80 + '\n').encode())
image = root / 'ramp.ppm'
image.write_bytes(ppm(96, 64))
# The session points XDG_RUNTIME_DIR at its own root, so the caller's
# Wayland socket is passed as an absolute path.
display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0'))
if not display.is_absolute():
display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display
# The virtual clock: a transition's frames are the same frames on any
# machine, so one partway through can be hashed too.
inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40',
'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(captures),
'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()),
'--9p=' + name, str(source)]
handles = {}
with session('/bin/sh', root, name, socket_name=name, tty=True,
launch=launch, inherited=inherited, terminal=handles) as (client, _):
first = require(wait_for(lambda: next((s for s, tag in panes(client).items()
if 'shapes.zig' in tag), None)), 'source pane missing')
def scene(label, frame='latest.ppm'):
settled(captures)
results.append((label, hashlib.sha256((captures / frame).read_bytes()).hexdigest()))
if args.keep:
args.keep.mkdir(parents=True, exist_ok=True)
shutil.copyfile(captures / frame, args.keep / (label + '.ppm'))
print(label, results[-1][1][:16], flush=True)
def mouse(kind, button, x, y):
# The test shell's stdin takes window-pixel mouse events.
os.write(handles['input_fd'], f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode())
time.sleep(.1)
def serial_of(word):
return require(wait_for(lambda: next((s for s, tag in panes(client).items() if word in tag), None)),
word + ' missing')
scene('01-boot')
client.write(f'/pane/{first}/look', f'{notes}\n'.encode())
notes_pane = serial_of('notes.txt')
scene('02-second-document')
client.write(f'/pane/{first}/ctl', b'Msg a golden notice\n')
scene('03-notice')
client.write(f'/pane/{first}/ctl', b'TreeContext\n')
client.write(f'/pane/{first}/dot', b'45\n')
scene('04-context-rows-mid-thumb')
client.write(f'/pane/{first}/dot', b'40,43\n')
scene('05-body-selection')
# A press and a drag along the source's tag: the tag takes the
# keyboard with a selection and a caret. The pointer then rests on
# empty body space, far right.
mouse('down', 1, 500, 87)
mouse('motion', 1, 620, 87)
mouse('up', 1, 620, 87)
mouse('motion', 0, 1880, 1300)
scene('06-tag-focus-selection')
os.write(handles['input_fd'], b'\x1b')
time.sleep(.2)
scratch = int(client.read('/pane/new'))
client.write(f'/pane/{scratch}/exec', b'Save\n')
scene('07-prompt-notice')
os.write(handles['input_fd'], b'\x1b')
time.sleep(.2)
client.write(f'/pane/{scratch}/exec', b'Del\n')
client.write(f'/pane/{first}/look', f'{wide}\n'.encode())
wide_pane = serial_of('wide.txt')
client.write(f'/pane/{wide_pane}/name', ('x' + '\u754c' * 70 + '\n').encode())
client.write('/ctl', b'Wrap off\n')
scene('08-wide-glyph-edges')
client.write('/ctl', b'Wrap on\n')
client.write(f'/pane/{wide_pane}/exec', b'Del\n')
client.write('/ctl', b'FocusTint on\n')
client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n')
scene('09-collapsed-focus-tint')
client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n')
client.write('/ctl', b'Tagbottom\n')
scene('10-tagbottom')
client.write('/ctl', b'Tagbottom\n')
client.write(f'/pane/{first}/tag', b' Second\nthird line of the tag')
scene('12-multiline-tag')
client.write(f'/pane/{first}/look', f'{image}\n'.encode())
serial_of('ramp.ppm')
scene('13-image')
client.write('/ctl', b'Theme ink\n')
scene('14-theme-ink')
client.write('/ctl', b'Theme acme\n')
scene('15-theme-acme-light')
client.write('/ctl', b'Debug\n')
scene('16-debug')
client.write('/ctl', b'Debug\n')
# A terminal pane: raw rows, ANSI colours, a hidden prompt.
before = set(panes(client))
client.write(f'/pane/{first}/exec', b'Tty\n')
terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Tty missing')
client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r")
require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing')
scene('17-terminal')
# Partway through a transition: the panes, the picture among them,
# sliding over for a new column, frame 6 of PanelSlide's 12. Its
# chrome slides with each pane.
client.write('/ctl', b'PanelSlide\n')
client.write(f'/pane/{first}/exec', b'Newcol\n')
require(wait_for(lambda: (captures / 'transition-6.ppm').exists()), 'transition frame 6 never drawn')
scene('18-mid-transition', 'transition-6.ppm')
lines = ''.join(f'{label} {digest}\n' for label, digest in results)
if args.update:
GOLDENS.write_text(lines)
print('updated', GOLDENS)
return
expected = dict(line.split() for line in GOLDENS.read_text().splitlines() if line.strip())
bad = [label for label, digest in results if expected.get(label) != digest]
require(not bad, 'frames changed: ' + ', '.join(bad))
print(f'{len(results)} GUI frames byte-identical')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('binary', type=Path)
parser.add_argument('--update', action='store_true')
parser.add_argument('--keep', type=Path, help='copy each hashed frame here as <scene>.ppm')
run(parser.parse_args())
|