#!/usr/bin/env python3 """Capture a read-only Agave review in an isolated SDL or TTY session. python3 test/ui_review.py zig-out/bin/pardes-gui /tmp/pardes-review \\ --project /path/to/agave --theme orchard Requires rust-analyzer for LSP, an X11 display and ImageMagick for SDL PNGs. The project files are never written. Cargo build output stays in --cargo-target (or OUTPUT/cargo-target). Use --skip-lsp when reviewing additional themes. TTY captures are the actual core grid and styles, not emulator screenshots. """ import argparse import json import os from pathlib import Path import shutil import subprocess import time from fs import session def panes(client): return {int(row.split(maxsplit=5)[0]): row.split(maxsplit=5)[5] for row in client.read('/index').decode().splitlines()} def wait_for(fn, seconds=12): deadline = time.monotonic() + seconds while time.monotonic() < deadline: result = fn() if result: return result time.sleep(.1) return None def command(client, serial, text): """Execute a temporary tag command without changing source contents.""" tag = f'/pane/{serial}/tag' before = client.read(tag).rstrip(b'\n') tail_start = before.index(b'Save Tty Del') while tail_start and before[tail_start - 1:tail_start] == b' ': tail_start -= 1 client.write(tag, b' ' + text.encode()) current = client.read(tag) start = current.rfind(text.encode()) client.write(f'/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode()) client.write(f'/pane/{serial}/tag', b'', truncate=True) client.write(tag, before[tail_start:]) def new_output(client, before): return next((serial for serial in panes(client) if serial not in before and client.read(f'/pane/{serial}/body').strip()), None) def require(value, description): if not value: raise AssertionError(description) return value def capture(client, root, label, gui): time.sleep(.4) screen = client.screen() (root / (label + '.json')).write_text(json.dumps(screen)) cols = screen['cols'] text = '\n'.join(''.join(cell[0] for cell in screen['cells'][at:at+cols]) for at in range(0, len(screen['cells']), cols)) (root / (label + '.txt')).write_text(text) (root / (label + '-index.txt')).write_text(client.read('/index').decode()) if gui: require(wait_for(lambda: (root / 'latest.ppm').exists(), 8), 'GPU capture missing') def convert_frame(): shutil.copyfile(root / 'latest.ppm', root / (label + '.ppm')) return subprocess.run(['magick', str(root / (label + '.ppm')), str(root / (label + '.png'))], stderr=subprocess.DEVNULL).returncode == 0 if not wait_for(convert_frame, 8): raise RuntimeError('GPU capture did not contain a complete image') print(label, flush=True) def run(args): project = args.project.resolve() source_path = project / 'gossip/src/crds_value.rs' original = source_path.read_bytes() root = args.output.resolve() root.mkdir(parents=True, exist_ok=True, mode=0o700) root.chmod(0o700) report = {'find': False, 'grep': False, 'terminal': False, 'source_unchanged': False, 'lsp': 'skipped' if args.skip_lsp else 'requested'} config = root / 'config/pardes' config.mkdir(parents=True, exist_ok=True) (config / 'init').write_text('Shell /bin/sh\n' + (f'Theme {args.theme}\n' if args.theme else '')) name = 'ui-review-' + str(os.getpid()) rust = (str(args.rust_analyzer.resolve()) if args.rust_analyzer else subprocess.check_output(['rustup', 'which', 'rust-analyzer'], text=True).strip()) if not args.skip_lsp else '' launch = ['PARDES_LSP_RS=' + rust, 'CARGO_HOME=' + os.path.expanduser('~/.cargo'), 'RUSTUP_HOME=' + os.path.expanduser('~/.rustup'), 'CARGO_TARGET_DIR=' + str((args.cargo_target or root / 'cargo-target').resolve()), 'CARGO_NET_OFFLINE=true', str(args.binary.resolve()), '--9p=' + name, str(source_path)] inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CAPTURE_DIR': str(root), 'PARDES_TEST_COLS': '150', 'PARDES_TEST_ROWS': '48', 'SDL_VIDEODRIVER': 'x11'} if not args.tty else {} launch = ['-c', 'stty cols 150 rows 48; exec "$@"', 'ui-review', '/usr/bin/env', *launch] terminal_handle = {} with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=inherited, terminal=terminal_handle) as (client, address): print('socket', address, flush=True) capture(client, root, '01-source', not args.tty) source = next(serial for serial, tag in panes(client).items() if 'crds_value.rs' in tag) before = panes(client) command(client, source, 'Find crds_value') find_pane = require(wait_for(lambda: new_output(client, before)), 'Find did not return results') require(b'crds_value.rs' in client.read(f'/pane/{find_pane}/body'), 'Find result missing source') report['find'] = True capture(client, root, '02-find', not args.tty) if find_pane: (root / 'find-results.txt').write_bytes(client.read(f'/pane/{find_pane}/body')) client.write(f'/pane/{find_pane}/exec', b'Del\n') before = panes(client) command(client, source, 'Grep CrdsValueLabel') grep_pane = require(wait_for(lambda: new_output(client, before)), 'Grep did not return results') require(b'CrdsValueLabel' in client.read(f'/pane/{grep_pane}/body'), 'Grep result missing match') report['grep'] = True if grep_pane: (root / 'grep-results.txt').write_bytes(client.read(f'/pane/{grep_pane}/body')) capture(client, root, '03-grep', not args.tty) command(client, source, 'Tty') terminal = require(wait_for(lambda: next((serial for serial in panes(client) if 'pty' in client.list(f'/pane/{serial}')), None)), 'Tty did not create a terminal') if terminal: client.write(f'/pane/{terminal}/pty/data', b"printf '\\033[1;36mAgave workspace\\033[0m\\n'; pwd; printf '\\033[32mRead-only UI review ready\\033[0m\\n'\r") require(wait_for(lambda: b'\nRead-only UI review ready\n' in client.read(f'/pane/{terminal}/body').replace(b'\r', b'')), 'Terminal command output was not observed') report['terminal'] = True capture(client, root, '04-terminal', not args.tty) if not args.skip_lsp: before = panes(client) command(client, source, 'Symbols') symbols = wait_for(lambda: new_output(client, before), args.lsp_wait) report['symbols'] = 'returned results' if symbols else 'no output before deadline' if symbols: (root / 'symbols-results.txt').write_bytes(client.read(f'/pane/{symbols}/body')) capture(client, root, '05-symbols', not args.tty) if symbols: client.write(f'/pane/{symbols}/exec', b'Del\n') offset = original.index(b'CrdsValue {') client.write(f'/pane/{source}/addr', f'#{offset},#{offset}'.encode()) client.write(f'/pane/{source}/dot', client.read(f'/pane/{source}/addr')) before = panes(client) command(client, source, 'Hover') hover = wait_for(lambda: new_output(client, before), args.lsp_wait) report['hover'] = 'returned results' if hover else 'no output before deadline' if hover: (root / 'hover-results.txt').write_bytes(client.read(f'/pane/{hover}/body')) capture(client, root, '06-hover', not args.tty) # Look focuses the source without changing it; gd then travels through # the host's real input parser, unlike tag builtin dispatch above. line = original[:original.index(b'impl Sanitize for CrdsValue')].count(b'\n') + 1 client.write(f'/pane/{source}/look', f'{source_path}:{line}:19\n'.encode()) os.write(terminal_handle['input_fd'], b'gd') time.sleep(1) capture(client, root, '07-definition', not args.tty) command(client, source, 'Lspinfo') lsp = wait_for(lambda: next((serial for serial, tag in panes(client).items() if '+Lsp' in tag), None)) if lsp: info = client.read(f'/pane/{lsp}/body') (root / 'lsp-info.txt').write_bytes(info) report['lsp'] = info.decode() capture(client, root, '08-lsp-info', not args.tty) if lsp: client.write(f'/pane/{lsp}/exec', b'Del\n') for theme in args.themes: command(client, source, 'Theme ' + theme) capture(client, root, 'theme-' + theme, not args.tty) for serial, tag in panes(client).items(): if any(word in tag for word in ['+Hover', '+Search']): (root / f'pane-{serial}.txt').write_bytes(client.read(f'/pane/{serial}/body')) assert client.read(f'/pane/{source}/body') == original == source_path.read_bytes() report['source_unchanged'] = True if terminal: client.write(f'/pane/{terminal}/pty/data', b'exit\r') (root / 'report.json').write_text(json.dumps(report, indent=2)) print('artifacts', root, flush=True) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('binary', type=Path) parser.add_argument('output', type=Path) parser.add_argument('--project', required=True, type=Path, help='Agave checkout (read only)') parser.add_argument('--cargo-target', type=Path, help='reuse an isolated cargo target directory') parser.add_argument('--rust-analyzer', type=Path, help='server executable; defaults to rustup which') parser.add_argument('--tty', action='store_true') parser.add_argument('--theme') parser.add_argument('--themes', nargs='*', default=[], help='capture each theme after exercising the workspace') parser.add_argument('--skip-lsp', action='store_true') parser.add_argument('--lsp-wait', type=float, default=25) run(parser.parse_args())