#!/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 [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', 'lapis_plain', '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()