diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 20:03:34 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | a8b59e683e9713a790daf7d9423f386e25b976fe (patch) | |
| tree | 16f8a28410f97c77ff155f8ba67e1985675b79d1 /test | |
| parent | 0fd908eea63d04886b269438aa7529d3dd422256 (diff) | |
| download | pardes-a8b59e683e9713a790daf7d9423f386e25b976fe.tar.gz pardes-a8b59e683e9713a790daf7d9423f386e25b976fe.zip | |
The GUI monkey lives in test/ and runs as zig build monkey-gui
It was a scratch tool (.scratch/render/tools/gui_monkey.py) that the gates rely on and that imported fs.py from a workspace since removed. It is test/gui_monkey.py now, importing from its own directory, and zig build monkey-gui -Dplatform=gui runs it on the built window, the same hidden test windows; it exits 1 when a combination fails, and its outdir is optional.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'test')
| -rw-r--r-- | test/gui_monkey.py | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/test/gui_monkey.py b/test/gui_monkey.py new file mode 100644 index 00000000..2b32f050 --- /dev/null +++ b/test/gui_monkey.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden +test window gets a random burst of clicks, drags, wheels, keys, new and +joined columns, opened files and theme changes, then must settle (its +frames stop changing) with no crash record and its process alive. + +gui_monkey.py <pardes-gui> [outdir] [--seed N] [--steps N]; `zig build +monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built +window. Exits 1 when any combination failed; what failed is in outdir. +""" +import argparse +import hashlib +import itertools +import os +import random +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fs import session # noqa: E402 + +MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful'] +LIFTS = ['off', 'shadow', 'rim', 'auto'] +BLOOMS = ['0', '2'] +THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak'] +W, H = 1280, 840 + + +def settled(cap, timeout=20): + latest = cap / 'latest.ppm' + previous = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(.4) + try: + data = latest.read_bytes() + except FileNotFoundError: + continue + digest = hashlib.sha256(data).hexdigest() + if digest == previous: + return True + previous = digest + return False + + +def run(binary, out, motion, lift, bloom, seed, steps): + rng = random.Random(seed) + root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-')) + cap = root / 'cap' + cap.mkdir() + (root / 'config/pardes').mkdir(parents=True) + (root / 'config/pardes/init').write_text( + f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n') + src = root / 'a.zig' + src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200))) + for name in ('b.txt', 'c.txt'): + (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80))) + display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0') + env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0', + 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} + name = 'monkey' + launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)] + handles = {} + verdict = 'ok' + log = [] + try: + with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _): + fd = handles['input_fd'] + time.sleep(1.0) + + def mouse(kind, button, x, y): + os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode()) + + def first(): + rows = client.read('/index').decode().splitlines() + return int(rows[0].split()[0]) if rows else None + + for _ in range(steps): + x, y = rng.randrange(W), rng.randrange(H) + action = rng.randrange(12) + try: + if action in (0, 1): + mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}') + elif action == 2: + x2, y2 = rng.randrange(W), rng.randrange(H) + mouse('down', 1, x, y) + for i in range(1, 6): + mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5) + mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}') + elif action == 3: + amount = rng.choice([-6, -2, 2, 6]) + os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}') + elif action == 4: + mouse('motion', 0, x, y); log.append(f'hover {x},{y}') + elif action == 5: + client.write('/ctl', b'Newcol\n'); log.append('Newcol') + elif action == 6: + serial = first() + if serial is not None: + target = root / rng.choice(['b.txt', 'c.txt', 'a.zig']) + client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}') + elif action == 7: + serial = first() + if serial is not None: + # Delcol too: the last column's going leaves an + # empty window now (acme's), not a quit. + word = rng.choice(['Joincol', 'Collapse', 'Delcol']) + try: + client.write(f'/pane/{serial}/exec', (word + '\n').encode()) + except OSError: + pass + log.append(word) + else: + # An empty window: pane/new makes its column and a pane. + try: + client.read('/pane/new') + except OSError: + pass + log.append('pane/new (empty window)') + elif action == 8: + os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b'])) + log.append('keys') + elif action == 9: + theme = rng.choice(THEMES) + client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}') + elif action == 10: + mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}') + else: + time.sleep(rng.choice([0, .05, .2])); log.append('pause') + except OSError as why: + log.append(f'refused: {why}') + time.sleep(.03) + if not settled(cap): + verdict = 'never settled' + try: + os.kill(handles['pid'], 0) + except OSError: + verdict = 'process gone' + client.read('/index') + except Exception as why: # noqa: BLE001 + verdict = f'failed: {type(why).__name__}: {str(why)[:300]}' + crashes = list((root / 'config').rglob('*crash*')) + if crashes: + verdict = 'crash record: ' + ', '.join(str(c) for c in crashes) + label = f'{motion}-{lift}-bloom{bloom}' + if verdict != 'ok': + (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n') + for c in crashes: + shutil.copy(c, out / f'{label}-{c.name}') + if (cap / 'latest.ppm').exists(): + shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm') + shutil.rmtree(root, ignore_errors=True) + return label, verdict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('binary', type=Path) + ap.add_argument('out', type=Path, nargs='?') + ap.add_argument('--seed', type=int, default=1) + ap.add_argument('--steps', type=int, default=60) + args = ap.parse_args() + if args.out is None: + args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-')) + args.out.mkdir(parents=True, exist_ok=True) + bad = 0 + for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)): + label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps) + print(f'{label}: {verdict}', flush=True) + bad += verdict != 'ok' + print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True) + sys.exit(1 if bad else 0) + + +if __name__ == '__main__': + main() |
