#!/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 compare python3 -B test/gui_golden.py --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 .ppm') run(parser.parse_args())