diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /test | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test')
55 files changed, 11314 insertions, 1779 deletions
diff --git a/test/agent_session.py b/test/agent_session.py new file mode 100644 index 00000000..b920b006 --- /dev/null +++ b/test/agent_session.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +from pathlib import Path +import shlex +import signal +import sys +import tempfile +import time +import uuid + +from fs import session + + +def screen_text(client): + screen = client.screen() + cols = screen['cols'] + return '\n'.join(''.join(cell[0] for cell in screen['cells'][at:at + cols]) + for at in range(0, len(screen['cells']), cols)) + + +def expired(signum, frame): + raise TimeoutError('session deadline exceeded') + + +def wait_ready(client, ready): + previous = None + while True: + text = screen_text(client) + if ready in text and text == previous: + return text + previous = text + time.sleep(.05) + + +def run(args, command, status): + temporary = tempfile.TemporaryDirectory(prefix='pardes-agent-session-') + try: + root = Path(temporary.name) + config = root / 'config' / 'pardes' + config.mkdir(parents=True) + (config / 'init').write_text('Shell /bin/sh\n') + script = root / 'command.sh' + script.write_text('exec ' + shlex.join(command) + '\n') + old_handler = signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, args.timeout) + try: + context = session(str(args.binary.resolve()), root, 'agent-session', tty=True, + socket_name='agent-session', launch=['--tty', '--9p=agent-session'], + inherited={'PARDES_TEST_GRID': '1'} if args.gui_grid else {}) + client, _ = context.__enter__() + try: + try: + while not client.read('/self/pane/1/body').strip(): + time.sleep(.01) + started = time.monotonic() + client.write('/self/pane/1/pty/data', shlex.join(['/bin/sh', str(script)]).encode() + b'\r') + status['phase'] = 'readiness' + wait_ready(client, args.ready) + if args.prepare_key is not None: + status['phase'] = 'preparation' + client.write('/self/pane/1/pty/data', args.prepare_key) + wait_ready(client, args.ready) + ready_ms = (time.monotonic() - started) * 1000 + status.update(phase='history', required_body_rows=args.min_rows) + while True: + started = time.monotonic() + body = client.read('/self/pane/1/body') + body_read_ms = (time.monotonic() - started) * 1000 + rows = body.count(b'\n') + int(bool(body) and not body.endswith(b'\n')) + status.update(body_bytes=len(body), body_rows=rows) + if rows >= args.min_rows: + break + time.sleep(.05) + status['phase'] = 'settling' + before = wait_ready(client, args.ready) + status['expected_visible_text_sha256'] = hashlib.sha256(before.encode()).hexdigest() + probe = 'p' + uuid.uuid4().hex[:12] + if probe in before: + raise AssertionError('probe is already visible') + status['phase'] = 'probe-visible' + started = time.monotonic() + client.write('/self/pane/1/pty/data', probe.encode()) + while probe not in screen_text(client).replace('\n', ''): + time.sleep(.01) + write_ms = (time.monotonic() - started) * 1000 + status['phase'] = 'probe-cleared' + started = time.monotonic() + client.write('/self/pane/1/pty/data', b'\x15') + while True: + observed = screen_text(client) + status['observed_visible_text_sha256'] = hashlib.sha256(observed.encode()).hexdigest() + if observed == before: + break + time.sleep(.01) + status['metrics'] = { + 'ready_ms': ready_ms, 'body_read_ms': body_read_ms, + 'ninep_write_to_observation_ms': write_ms, + 'ninep_clear_to_observation_ms': (time.monotonic() - started) * 1000, + 'visible_text_sha256': hashlib.sha256(before.encode()).hexdigest(), + 'body_bytes': len(body), 'body_rows': rows, + 'body_sha256': hashlib.sha256(body).hexdigest(), + } + finally: + previous_phase = status['phase'] + status['phase'] = 'interrupt-cleanup' + signal.setitimer(signal.ITIMER_REAL, 0) + client.socket.settimeout(.25) + for _ in range(2): + try: + client.write('/self/pane/1/pty/data', b'\x03') + except (OSError, EOFError): + pass + time.sleep(.05) + status['phase'] = previous_phase + finally: + previous_phase = status['phase'] + status['phase'] = 'session-cleanup' + context.__exit__(*sys.exc_info()) + status['phase'] = previous_phase + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + finally: + previous_phase = status['phase'] + status['phase'] = 'files-cleanup' + temporary.cleanup() + status['phase'] = previous_phase + return status.pop('metrics') + + +def main(argv): + parser = argparse.ArgumentParser(description='Check an unsubmitted input probe through an owned Pardes 9P session.') + parser.add_argument('binary', type=Path) + parser.add_argument('--ready', required=True, help='visible application readiness text') + parser.add_argument('--timeout', type=float, default=60, help='interaction deadline in seconds, at most 600') + parser.add_argument('--min-rows', type=int, default=0) + parser.add_argument('--prepare-key', help='one hex control byte before history/probe checks; never CR or LF') + parser.add_argument('--gui-grid', action='store_true', help='use the GUI build headless grid host') + if '--' not in argv: + parser.error('supply the interactive command after --') + split = argv.index('--') + args = parser.parse_args(argv[:split]) + command = argv[split + 1:] + if not command or not command[0] or not args.ready.strip(): + parser.error('a command and nonempty --ready text are required') + if not 0 < args.timeout <= 600 or args.min_rows < 0: + parser.error('--timeout must be in (0, 600] and --min-rows must be nonnegative') + if args.prepare_key is not None: + try: + args.prepare_key = bytes.fromhex(args.prepare_key) + except ValueError: + parser.error('--prepare-key must be one hex control byte') + if len(args.prepare_key) != 1 or not 0 < args.prepare_key[0] < 32 or args.prepare_key[0] in (10, 13): + parser.error('--prepare-key must be one control byte other than CR or LF') + status = {'phase': 'startup'} + try: + metrics = run(args, command, status) + except (OSError, EOFError, AssertionError, ValueError) as error: + status['error'] = type(error).__name__ + if isinstance(error, OSError): + status['errno'] = error.errno + print(json.dumps(status), file=sys.stderr) + return 1 + print(json.dumps(metrics)) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/test/agent_session_test.py b/test/agent_session_test.py new file mode 100644 index 00000000..ba20b5a9 --- /dev/null +++ b/test/agent_session_test.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +import contextlib +import argparse +import errno +import io +import json +from pathlib import Path +import subprocess +import tempfile +import time + +import agent_session + + +def check(binary, options, command, expected, phase=None, cleanup_failure=False): + children = [] + original_popen = subprocess.Popen + + def launch(*args, **kwargs): + child = original_popen(*args, **kwargs) + children.append(child) + return child + + with tempfile.TemporaryDirectory(prefix='pardes-agent-driver-test-') as directory: + original_cleanup = tempfile.TemporaryDirectory.cleanup + + def fail_cleanup(temporary): + original_cleanup(temporary) + raise OSError(errno.ENOTEMPTY, 'injected cleanup failure') + + previous_tempdir = tempfile.tempdir + tempfile.tempdir = directory + subprocess.Popen = launch + if cleanup_failure: + tempfile.TemporaryDirectory.cleanup = fail_cleanup + stdout, stderr = io.StringIO(), io.StringIO() + started = time.monotonic() + try: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + result = agent_session.main([binary, *options, '--', *command]) + except SystemExit as error: + result = error.code + finally: + subprocess.Popen = original_popen + tempfile.tempdir = previous_tempdir + tempfile.TemporaryDirectory.cleanup = original_cleanup + assert result == expected, (result, expected, stderr.getvalue()) + assert all(child.poll() is not None for child in children), 'owned Pardes still running' + assert not list(Path(directory).iterdir()), 'owned session files remain' + if expected == 2: + assert not children, 'invalid arguments started Pardes' + elif expected == 0: + metrics = json.loads(stdout.getvalue()) + assert metrics['body_bytes'] > 0 and metrics['body_rows'] > 0 + assert len(metrics['visible_text_sha256']) == len(metrics['body_sha256']) == 64 + assert metrics['ninep_write_to_observation_ms'] >= 0 + assert metrics['ninep_clear_to_observation_ms'] >= 0 + assert 'AGENT_READY' not in stdout.getvalue() + else: + assert not stdout.getvalue() + error = json.loads(stderr.getvalue()) + assert error['phase'] == phase + if cleanup_failure: + assert error['error'] == 'OSError' and error['errno'] == errno.ENOTEMPTY + assert error['metrics']['body_rows'] > 0 + assert error['expected_visible_text_sha256'] == error['observed_visible_text_sha256'] + else: + assert error['error'] == 'TimeoutError' + assert 'AGENT_READY' not in stderr.getvalue() + assert time.monotonic() - started < 8, 'timeout did not bound cleanup' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('binary', type=Path) + parser.add_argument('--gui-grid', action='store_true') + args = parser.parse_args() + binary = str(args.binary.resolve()) + ready = ['--ready', 'AGENT_READY', *(['--gui-grid'] if args.gui_grid else [])] + command = ['/bin/sh', '-c', 'printf "AGENT_READY> "; read answer'] + for options, argv in [ + ([], command), + (ready + ['--timeout', 'nan'], command), + (ready + ['--timeout', '0'], command), + (ready + ['--timeout', '601'], command), + (ready + ['--min-rows', '-1'], command), + (ready + ['--prepare-key', '0a'], command), + (ready + ['--prepare-key', '0d'], command), + (ready + ['--prepare-key', '41'], command), + (ready + ['--prepare-key', '0f0f'], command), + (ready, []), + ]: + check(binary, options, argv, 2) + check(binary, ready + ['--timeout', '10'], command, 0) + check(binary, ready + ['--timeout', '10', '--min-rows', '1000'], + ['/bin/sh', '-c', 'printf "AGENT_READY> "; sleep .3; seq 1 3000; printf "AGENT_READY> "; read answer'], 0) + check(binary, ready + ['--timeout', '10', '--min-rows', '1000', '--prepare-key', '0f'], + ['/bin/sh', '-c', 'stty raw -echo; printf "AGENT_READY> "; dd bs=1 count=1 of=/dev/null 2>/dev/null; ' + 'stty sane; seq 1 3000; printf "AGENT_READY> "; read answer'], 0) + check(binary, ready + ['--timeout', '2', '--min-rows', '1000'], command, 1, 'history') + check(binary, ready + ['--timeout', '1'], + ['/bin/sh', '-c', ': AGENT_READY; sleep 30'], 1, 'readiness') + check(binary, ready + ['--timeout', '10'], command, 1, 'files-cleanup', cleanup_failure=True) + print('agent-session: 16 functional argument, interaction, timeout and cleanup checks passed') + + +if __name__ == '__main__': + main() diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index e08e59f2..f7f380df 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -14,7 +14,7 @@ extern "c" fn forkpty( ) c_int; extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; /// Monotonic milliseconds; the harness's only clock (std.time lost /// milliTimestamp in 0.16). @@ -88,18 +88,12 @@ pub const Harness = struct { argv[0] = exe; for (args, 0..) |a, i| argv[i + 1] = a; - // The app under test is a fresh outer session even when this harness - // itself is running in a pardes pane. Unlike --nested, the boundary - // still lets pane shells below that app detect and talk to it, which - // nested.snap exercises directly. - var boundary_buf: [32:0]u8 = undefined; - const boundary = try std.fmt.bufPrintSentinel(&boundary_buf, "{d}", .{@as(u32, @intCast(libc.getpid()))}, 0); - if (setenv("PARDES_NESTED_BOUNDARY_PID", boundary, 1) != 0) return error.SetEnvFailed; - var master: c_int = undefined; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { + for ([_][*:0]const u8{ "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }) |name| + if (unsetenv(name) != 0) _exit(126); _ = execvp(exe, argv.ptr); _exit(127); } diff --git a/test/fs.py b/test/fs.py new file mode 100644 index 00000000..9fc5bcc6 --- /dev/null +++ b/test/fs.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 +import contextlib +import fcntl +import json +import os +from pathlib import Path +import subprocess +import struct +import sys +import tempfile +import termios +import threading +import time + +from ninep import Client + + +def session(binary, root, name, *options, socket_name=None, file=None, tty=False, network=None, + launch=None, inherited=None): + env = {key: value for key, value in os.environ.items() + if not key.startswith('PARDES_')} + env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), + XDG_CONFIG_HOME=str(root / 'config'), PARDES_NOTIME='1', + PARDES_DUMP=str(root / (name + '.dump.zon'))) + env.update(inherited or {}) + for language in ['RS', 'C', 'GO', 'TS', 'PY']: + env['PARDES_LSP_' + language] = '' + fixture = root / (name + '.txt') + fixture.write_bytes(b'initial\n') + address = root / ('pardes-9p-' + (socket_name or name) + '.sock') + child = None + master = slave = None + reader = None + try: + args = [binary, '--detach=' + name, *options, str(file or fixture)] + preexec = None + if tty: + master, slave = os.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + env['TERM'] = 'xterm-256color' + args = [binary, '--tty', '--9p=' + (socket_name or name), *options, str(file or fixture)] + preexec = lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0) + if launch is not None: + args = [binary, *launch] + child = subprocess.Popen(args, cwd=root, env=env, stdin=slave, + stdout=slave if tty else subprocess.DEVNULL, + stderr=subprocess.PIPE, start_new_session=True, + preexec_fn=preexec) + if launch is not None and socket_name is None: + address = root / f'pardes-9p-{child.pid}.sock' + if slave is not None: + os.close(slave) + slave = None + def drain(): + try: + while os.read(master, 65536): + pass + except OSError: + pass + reader = threading.Thread(target=drain, daemon=True) + reader.start() + deadline = time.monotonic() + 10 + while not address.exists(): + if child.poll() is not None: + raise AssertionError((args, child.returncode, child.stderr.read().decode())) + if time.monotonic() > deadline: + raise TimeoutError('default 9P socket did not appear') + time.sleep(.005) + with Client(address) as client: + if network is not None: + for dial in client.read('/self/listeners').decode().splitlines(): + parts = dial.split('!') + if parts[0] in ['tcp', 'quic']: + assert len(parts) == 3 and 0 < int(parts[2]) < 65536, dial + network[parts[0].upper()] = int(parts[2]) + assert set(network) == {'TCP', 'QUIC'}, network + yield client, address + finally: + if child is not None: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + child.stderr.close() + if slave is not None: + os.close(slave) + if master is not None: + os.close(master) + if reader is not None: + reader.join(timeout=1) + assert not reader.is_alive(), 'terminal reader did not stop' + + +def new_pane(client, contents): + serial = int(client.read('/self/new/ctl').split()[0]) + client.write(f'/self/pane/{serial}/body', contents) + return serial + + +def look(client, path, source=None): + serial = source if source is not None else new_pane(client, b'') + client.write(f'/self/pane/{serial}/body', path.encode(), truncate=True) + client.write(f'/self/pane/{serial}/event', f'ML0 {len(path.encode())}\n'.encode()) + rows = client.read('/self/index').splitlines() + opened = int(rows[-1].split()[0]) + assert opened != serial, f'Look did not open {path}' + return opened + + +def execute(client, serial, command): + text = command.encode() + client.write(f'/self/pane/{serial}/body', text, truncate=True) + client.write(f'/self/pane/{serial}/event', f'MX0 {len(text)}\n'.encode()) + + +def test(binary, quic=False): + started = time.monotonic() + for options, message in [ + (['--fs'], b'no such option'), + (['--fs9'], b'no such option'), + (['--9p'], b'--9p needs a socket name'), + (['--9p='], b'invalid 9P socket name'), + (['--9p=a/b'], b'invalid 9P socket name'), + (['--attach=work', '--9p=work'], b'--attach has none'), + (['--9p-tcp'], b'--9p-tcp needs'), + (['--9p-tcp='], b'--9p-tcp needs'), + (['--9p-tcp=tcp!localhost!5640'], b'--9p-tcp needs'), + (['--9p-tcp=quic!127.0.0.1!5640'], b'--9p-tcp needs'), + (['--9p-tcp=tcp!127.0.0.1!5640', '--9p-tcp=tcp!127.0.0.1!5641'], b'specified twice'), + (['--attach=work', '--9p-tcp=tcp!127.0.0.1!5640'], b'--attach has none'), + (['--9p-quic'], b'--9p-quic needs'), + (['--9p-quic='], b'--9p-quic needs'), + (['--9p-quic=tcp!127.0.0.1!5640'], b'--9p-quic needs'), + (['--mount=os=work'], b'invalid mount name'), + (['--mount=peer='], b'invalid mount name or dial'), + (['--mount=peer=one', '--mount=peer=two'], b'duplicate mount'), + (['--mount=peer=tcp!127.0.0.1!0'], b'invalid mount dial'), + (['--attach=work', '--mount=peer=one'], b'--attach has none'), + ]: + result = subprocess.run([binary, *options], capture_output=True, timeout=5) + assert result.returncode == 1 and message in result.stderr, (options, result) + with tempfile.TemporaryDirectory(prefix='pardes-fs-') as directory: + root = Path(directory) + with session(binary, root, 'renamed', '--9p=explicit', socket_name='explicit') as (named, named_address): + assert named_address.name == 'pardes-9p-explicit.sock' + assert not (root / 'pardes-9p-renamed.sock').exists() + assert named.read('/self/pane/1/body') == b'initial\n' + with session(binary, root, 'first') as (client, address): + assert set(client.list('/')) == {'os', 'self'} + assert {'pane', 'index', 'new'} <= set(client.list('/self')) + assert client.read('/self/pane/1/body') == b'initial\n' + assert client.read('/os' + str(root / 'first.txt')) == b'initial\n' + assert 'first.txt' in client.list('/os' + str(root)) + assert client.read('/self/pane/1/../1/body') == b'initial\n' + assert client.read('/os' + str(root) + '/../' + root.name + '/first.txt') == b'initial\n' + + listing = root / 'listing' + listing.mkdir() + (listing / 'broken').symlink_to(listing / 'missing') + for number in range(24): + (listing / f'entry-{number:02}').touch() + with Client(address, msize=256) as small: + assert set(small.list('/os' + str(listing))) == { + f'entry-{number:02}' for number in range(24)} + + frozen = client.open('/self/screen') + before = bytearray(client.read_fid(frozen, count=31)) + client.write('/self/pane/1/body', b'screen changed\n') + while chunk := client.read_fid(frozen, len(before)): + before.extend(chunk) + client.close(frozen) + old_screen = json.loads(before) + old_text = ''.join(cell[0] for cell in old_screen['cells']) + assert 'initial' in old_text and 'screen changed' not in old_text + assert 'screen changed' in ''.join(cell[0] for cell in client.screen()['cells']) + + payload = ('const value = "λ";\n' * 3000).encode() + serial = new_pane(client, payload) + body = f'/self/pane/{serial}/body' + assert client.read(body) == payload + client.write(body, b'replaced\n', truncate=True) + assert client.read(body) == b'replaced\n' + + event = client.open(f'/self/pane/{serial}/event') + client.write(body, b'event\n') + record = client.read_fid(event) + assert record.startswith(b'EI') and b'event\n' in record, record + client.close(event) + + os_path = '/os' + str(root / 'first.txt') + client.write(os_path, b'written through 9P\n', truncate=True) + assert (root / 'first.txt').read_bytes() == b'written through 9P\n' + assert client.read(os_path) == b'written through 9P\n' + + for index in range(12): + with Client(address) as other: + assert other.read(body) == b'replaced\nevent\n', index + + missing = False + try: + client.read('/self/pane/4294967295/body') + except OSError: + missing = True + assert missing + assert client.read(body) == b'replaced\nevent\n' + + source = look(client, '/virtual/src/pardes.zig') + assert b'pub const Pardes' in client.read(f'/self/pane/{source}/body') + + (root / 'index').write_bytes(b'OS takes precedence\n') + opened = look(client, 'index') + assert client.read(f'/self/pane/{opened}/body') == b'OS takes precedence\n' + opened = look(client, '/n/self/index') + index = client.read(f'/self/pane/{opened}/body') + assert index.split()[0] == b'1' and b'first.txt' in index, index + (root / 'explicit.txt').write_bytes(b'explicit OS mount\n') + opened = look(client, '/n/os' + str(root / 'explicit.txt')) + assert client.read(f'/self/pane/{opened}/body') == b'explicit OS mount\n' + + own = root / 'pardes-9p-second.sock' + with session(binary, root, 'second', '--mount=peer=' + str(address), + '--mount=own=' + str(own)) as (remote, _): + opened = look(remote, '/n/own/self/pane/1/body') + assert remote.read(f'/self/pane/{opened}/body') == b'initial\n' + opened = look(remote, '/n/peer/self/pane/' + str(serial) + '/body') + assert remote.read(f'/self/pane/{opened}/body') == b'replaced\nevent\n' + remote.write(f'/self/pane/{opened}/body', b'saved to peer\n', truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert client.read(body) == b'saved to peer\n' + + large = b'mounted file contents\n' * 60000 + large_path = root / 'large.txt' + large_path.write_bytes(large) + opened = look(remote, '/n/peer/os' + str(large_path)) + assert remote.read(f'/self/pane/{opened}/body') == large + edited = large[:-3] + b'edited\n' + remote.write(f'/self/pane/{opened}/body', edited, truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert large_path.read_bytes() == edited + assert client.read('/os' + str(large_path)) == edited + + with session(binary, root, 'runtime') as (remote, own_address): + control = new_pane(remote, b'') + execute(remote, control, 'Mount peer ' + str(address)) + opened = look(remote, '/n/peer/self/pane/1/body', source=control) + assert remote.read(f'/self/pane/{opened}/body') == client.read('/self/pane/1/body') + execute(remote, control, 'Mount peer ' + str(own_address)) + execute(remote, control, 'Unmount peer') + remote.write(f'/self/pane/{opened}/body', b'runtime mounted Save\n', truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert client.read('/self/pane/1/body') == b'runtime mounted Save\n' + assert remote.read('/self/pane/1/body') == b'initial\n' + roots = look(remote, '/n', source=control) + assert b'/n/peer/\n' in remote.read(f'/self/pane/{roots}/body') + remote.write(f'/self/pane/{opened}/ctl', b'delete\n') + execute(remote, control, 'Unmount peer') + remote.write(f'/self/pane/{roots}/ctl', b'get\n') + assert remote.read(f'/self/pane/{roots}/body') == b'/n/os/\n/n/self/\n' + execute(remote, control, 'Mount peer ' + str(own_address)) + opened = look(remote, '/n/peer/self/pane/1/body', source=control) + assert remote.read(f'/self/pane/{opened}/body') == b'initial\n' + + with session(binary, root, 'mounted-startup', '--mount=peer=' + str(address), + file='/n/peer/self/pane/1/body:1') as (remote, _): + assert remote.read('/self/pane/1/body') == b'runtime mounted Save\n' + + client.write(f'/self/pane/{serial}/ctl', b'delete\n') + assert str(serial) not in client.list('/self/pane') + + for tty in [False, True]: + name = 'restore tty space' if tty else 'restore detached space' + with session(binary, root, name, tty=tty) as (old, address): + old.write('/self/pane/1/body', b'dumped state\n', truncate=True) + control = new_pane(old, b'') + execute(old, control, 'Restore ' + str(root / 'missing dump.zon')) + assert old.read('/self/pane/1/body') == b'dumped state\n' + execute(old, control, 'Mount own ' + str(address)) + execute(old, control, 'Dump') + saved = root / (name + '.dump.zon') + deadline = time.monotonic() + 5 + while not saved.exists(): + assert time.monotonic() < deadline, 'Dump did not write its state' + time.sleep(.005) + held_body = old.open('/self/pane/1/body') + held_screen = old.open('/self/screen') + old_ids = {int(row.split()[0]) for row in old.read('/self/index').splitlines()} + assert old.read_fid(held_body) == b'dumped state\n' + assert old.read_fid(held_screen, count=1) == b'{' + old.write('/self/pane/1/body', b'changed after dump\n', truncate=True) + try: + execute(old, control, 'Restore ' + str(saved)) + except (EOFError, ConnectionResetError, BrokenPipeError): + pass + try: + old.read_fid(held_body) + except (EOFError, ConnectionResetError, BrokenPipeError): + pass + else: + raise AssertionError('Restore left an old body fid usable') + with Client(address) as restored: + restored_ids = [int(row.split()[0]) for row in restored.read('/self/index').splitlines()] + assert len(restored_ids) == 2 and old_ids.isdisjoint(restored_ids), restored_ids + file_id, control_id = restored_ids + contents = restored.read(f'/self/pane/{file_id}/body') + assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/self/index')) + assert restored.read(f'/self/pane/{control_id}/body') == b'Dump' + assert restored.screen()['cols'] == 80 + opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id) + assert restored.read(f'/self/pane/{opened}/body') == b'initial\n' + + with session(binary, root, 'forwarding') as (client, address): + child_dir = root / 'child-working-directory' + child_dir.mkdir() + spaced = child_dir / 'space name.txt' + spaced.write_bytes(b'first line\nsecond line\n') + env = os.environ.copy() + env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_FORWARD_LOOK='1') + for word, expected, selected, reuse in [('space name.txt:2:4', spaced.read_bytes(), [14, 14], False), + ('/n/self/pane/1/body', b'initial\n', None, False), + ('/virtual/pane/1/body:1:2-4', b'initial\n', [1, 4], True)]: + before = {int(row.split()[0]) for row in client.read('/self/index').splitlines()} + result = subprocess.run([binary, '--tty', word], cwd=child_dir, env=env, + capture_output=True, timeout=3) + assert result.returncode == 0, (word, result.stderr) + after = {int(row.split()[0]) for row in client.read('/self/index').splitlines()} + if reuse: + assert after == before + else: + opened, = after - before + assert client.read(f'/self/pane/{opened}/body') == expected + assert client.read('/self/pane/1/body') == b'initial\n' + if selected is not None: + addr = client.open(f'/self/pane/{opened}/addr') + client.write(f'/self/pane/{opened}/ctl', b'addr=dot\n') + actual = list(map(int, client.read_fid(addr).split())) + client.close(addr) + assert actual == selected, (word, actual, selected) + addr = client.open('/self/pane/1/addr', 2) + ctl = client.open('/self/pane/1/ctl', 1) + for fid, text in [(addr, b'#0,#3'), (ctl, b'limit=addr\n'), (addr, b'#0')]: + client.rpc(118, struct.pack('<IQI', fid, 0, len(text)) + text) + try: + for _ in range(8): + result = subprocess.run([binary], cwd=child_dir, env=env, + capture_output=True, timeout=3) + assert result.returncode == 1 and b'already inside pardes' in result.stderr + text = b'/tial/' + try: + client.rpc(118, struct.pack('<IQI', addr, 0, len(text)) + text) + except OSError: + pass + else: + raise AssertionError('no-argument child cleared the parent address limit') + finally: + client.close(addr) + client.close(ctl) + client.read('/self/pane/1/ctl') + + control = new_pane(client, b'') + execute(client, control, 'Dump') + saved = root / 'forwarding.dump.zon' + deadline = time.monotonic() + 3 + while not saved.exists(): + assert time.monotonic() < deadline, 'forwarding fixture Dump did not finish' + time.sleep(.005) + before = client.read('/self/index') + inherited = {key: env[key] for key in ['PARDES_9P', 'PARDES_PANE', 'PARDES_FORWARD_LOOK']} + cases = [ + ('mount', ['--mount=peer=' + str(address), '/n/peer/self/pane/1/body'], None), + ('name', ['--9p=forwarded-name'], 'forwarded-name'), + ('tcp', ['--9p-tcp=tcp!127.0.0.1!0'], None), + ('load', ['-l', str(saved)], None), + ('shells', ['-n', '3'], None), + ('toggle', ['--tty-toggle=q'], None), + ('toggle-arg', ['--tty-toggle', 'q'], None), + ] + if quic: + cases.append(('quic', ['--9p-quic=quic!127.0.0.1!0'], None)) + for name, options, socket_name in cases: + with session(binary, root, 'forward-' + name, tty=True, socket_name=socket_name, + launch=[*([] if name == 'shells' else ['--tty']), *options], + inherited=inherited) as (local, local_address): + assert local_address != address + index = local.read('/self/index').splitlines() + if name == 'mount': + assert len(index) == 1 and b'/n/peer/' in index[0], index + assert local.read('/self/pane/1/body') == b'initial\n' + elif name == 'load': + assert local.read('/self/index') == before + assert local.read('/self/pane/1/body') == b'initial\n' + else: + assert len(index) == (3 if name == 'shells' else 1), index + if name in ['tcp', 'quic']: + assert (name + '!127.0.0.1!').encode() in local.read('/self/listeners') + assert client.read('/self/index') == before + assert client.read('/self/pane/1/body') == b'initial\n' + + for name, identity, word in [ + ('stale-file', dict(inherited, PARDES_9P=str(root / 'absent.sock')), str(spaced)), + ('stale-missing', dict(inherited, PARDES_9P=str(root / 'absent.sock')), 'missing-child-file.txt'), + ('stale-noarg', dict(inherited, PARDES_9P=str(root / 'absent.sock')), None), + ('stale-pane', dict(inherited, PARDES_PANE='4294967295'), str(spaced)), + ('disabled', dict(inherited, PARDES_FORWARD_LOOK='0'), str(spaced)), + ]: + with session(binary, root, name, tty=True, inherited=identity, + launch=['--tty', *([word] if word else [])]) as (local, local_address): + assert local_address != address + if word == str(spaced): + assert local.read('/self/pane/1/body') == spaced.read_bytes() + elif word: + assert word.encode() in local.read('/self/index') or word in ''.join( + cell[0] for cell in local.screen()['cells']) + assert client.read('/self/index') == before + + for tty in [False, True]: + name = 'lsp-tty' if tty else 'lsp-detached' + with session(binary, root, name, tty=tty) as (client, _): + control = new_pane(client, b'') + execute(client, control, 'Lspinfo') + deadline = time.monotonic() + 3 + while True: + matches = [int(row.split()[0]) for row in client.read('/self/index').splitlines() + if b'+Lsp' in row] + if matches: + break + assert time.monotonic() < deadline, (name, 'Lspinfo did not complete') + time.sleep(.005) + assert len(matches) == 1, matches + result = client.read(f'/self/pane/{matches[0]}/body') + assert result.startswith(b'backend: zls-inproc+lsp-client\n'), result + assert b'protocol servers (lsp-client):' in result, result + assert client.read('/self/pane/1/body') == b'initial\n' + assert '+Lsp' in ''.join(cell[0] for cell in client.screen()['cells']) + + with session(binary, root, 'highlight') as (client, _): + client.write('/self/pane/1/ctl', b'name keywords.zig\n') + source = b'// padding\n' * 9999 + b'fn check() void {\n if (true) return;\n}\n' + client.write('/self/pane/1/body', source, truncate=True) + screen = client.screen() + assert len(screen['cells']) == screen['cols'] * screen['rows'] + found = set() + for row in range(screen['rows']): + cells = screen['cells'][row * screen['cols']:(row + 1) * screen['cols']] + text = ''.join(cell[0] for cell in cells) + for word in ['fn', 'if']: + start = text.find(word + ' ') + if start < 0: + continue + styles = [screen['styles'][cell[1]] for cell in cells[start:start + len(word)]] + assert all(style['bold'] and style['fg'] == styles[0]['fg'] for style in styles), (word, styles) + found.add(word) + assert found == {'fn', 'if'}, screen + + with session(binary, root, 'long-names') as (client, _): + directory = root / ('directory-' + 'd' * 54) + directory.mkdir() + for length in [29, 63, 127, 255]: + name = 'f' * (length - 4) + '.txt' + path = directory / name + path.write_bytes(b'long filename\n') + wire_path = '/os' + str(path) + assert name in client.list('/os' + str(directory)) + assert client.read(wire_path) == b'long filename\n' + client.write(wire_path, b'written through long filename\n', truncate=True) + assert path.read_bytes() == b'written through long filename\n' + opened = look(client, '/n/os' + str(path)) + assert client.read(f'/self/pane/{opened}/body') == path.read_bytes() + opened = look(client, '/n/os' + str(directory)) + listing = client.read(f'/self/pane/{opened}/body') + for path in directory.iterdir(): + assert ('/n/os' + str(path)).encode() in listing + if quic: + quic_test(binary) + print(f'9P integration: passed ({time.monotonic() - started:.3f}s)') + + +def quic_test(binary): + for tty in [False, True]: + for host in ['127.0.0.1', '::1']: + with tempfile.TemporaryDirectory(prefix='pardes-quic-fs-') as directory: + root = Path(directory) + ports = {} + with session(binary, root, 'server', '--9p-tcp=tcp!' + host + '!0', + '--9p-quic=quic!' + host + '!0', tty=tty, network=ports) as (unix, address): + assert set(unix.read('/self/listeners').decode().splitlines()) == { + 'unix!' + str(address), 'tcp!' + host + '!' + str(ports['TCP']), + 'quic!' + host + '!' + str(ports['QUIC'])} + with Client((host, ports['TCP'])) as tcp: + unix_body = unix.open('/self/pane/1/body') + tcp_body = tcp.open('/self/pane/1/body') + with session(binary, root, 'client') as (remote, _): + control = new_pane(remote, b'') + dial = 'quic!' + host + '!' + str(ports['QUIC']) + execute(remote, control, 'Mount peer ' + dial) + opened = look(remote, '/n/peer/self/pane/1/body', source=control) + body = f'/self/pane/{opened}/body' + assert remote.read(body) == b'initial\n' + payload = 'saved café λ\n'.encode() + b'x' * (3 * 8192 + 27) + b'\n' + remote.write(body, payload, truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert unix.read_fid(unix_body) == payload[:unix.msize - 11] + assert tcp.read('/self/pane/1/body') == payload + tcp.write('/self/pane/1/body', b'TCP to QUIC Get\n', truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'get\n') + assert remote.read(body) == b'TCP to QUIC Get\n' + assert unix.read_fid(unix_body) == b'TCP to QUIC Get\n' + assert tcp.read_fid(tcp_body) == b'TCP to QUIC Get\n' + screen_pane = look(remote, '/n/peer/self/screen', source=control) + screen = json.loads(remote.read(f'/self/pane/{screen_pane}/body')) + assert len(screen['cells']) == screen['cols'] * screen['rows'] + assert 'TCP to QUIC Get' in ''.join(cell[0] for cell in screen['cells']) + large = ('normal λ line\n' * 20000).encode() + fixture = root / 'large.txt' + fixture.write_bytes(large) + large_pane = look(remote, '/n/peer/os' + str(fixture), source=control) + assert remote.read(f'/self/pane/{large_pane}/body') == large + edited = b'QUIC file saved\n' + remote.write(f'/self/pane/{large_pane}/body', edited, truncate=True) + remote.write(f'/self/pane/{large_pane}/ctl', b'put\n') + assert fixture.read_bytes() == edited + for pane in [opened, screen_pane, large_pane]: + remote.write(f'/self/pane/{pane}/ctl', b'delete\n') + execute(remote, control, 'Unmount peer') + roots = look(remote, '/n', source=control) + assert remote.read(f'/self/pane/{roots}/body') == b'/n/os/\n/n/self/\n' + assert unix.read_fid(unix_body) == b'TCP to QUIC Get\n' + assert tcp.read_fid(tcp_body) == b'TCP to QUIC Get\n' + control = new_pane(unix, b'') + execute(unix, control, 'Mount own ' + dial) + own = look(unix, '/n/own/self/pane/1/body', source=control) + assert unix.read(f'/self/pane/{own}/body') == b'TCP to QUIC Get\n' + unix.write(f'/self/pane/{own}/body', b'same-session QUIC Save\n', truncate=True) + unix.write(f'/self/pane/{own}/ctl', b'put\n') + assert unix.read_fid(unix_body) == b'same-session QUIC Save\n' + assert tcp.read_fid(tcp_body) == b'same-session QUIC Save\n' + unix.write(f'/self/pane/{own}/ctl', b'delete\n') + execute(unix, control, 'Unmount own') + unix.close(unix_body) + tcp.close(tcp_body) + + +if __name__ == '__main__': + if len(sys.argv) not in [2, 3] or (len(sys.argv) == 3 and sys.argv[2] != '--quic'): + raise SystemExit('usage: fs.py <pardes> [--quic]') + test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3) diff --git a/test/fs_bench.zig b/test/fs_bench.zig index 7315676a..7fe49734 100644 --- a/test/fs_bench.zig +++ b/test/fs_bench.zig @@ -1,39 +1,15 @@ -//! THE FILESYSTEM SCOREBOARD: what one acme-fs request costs the core, and -//! what serving one costs an editor that nobody is scripting. -//! -//! zig build fs-bench -- the table -//! zig build fs-bench -- --json -- the same, machine-readable -//! zig build fs-bench -- --reps 200000 -- more samples per row -//! -//! There is no FUSE here, and no thread: `acmefs.handle` IS the transaction -//! (src/acmefs.zig), so driving it directly is measuring the whole of what the -//! core does per request. That is the point of the split — a transport adds a -//! `read(2)`, a `write(2)` and a wake, and those are the kernel's numbers, not -//! ours (the mounted end-to-end figures are measured with real clients; see -//! examples/README.md). -//! -//! THREE QUESTIONS THIS ANSWERS. -//! -//! 1. Is a request cheap enough to serve thousands per frame? Each row is -//! one `handle` call, median of `--reps`. -//! 2. Does the steady state ALLOCATE? Every row is measured through a -//! counting allocator and the table prints the allocation count. A -//! non-zero number in a read row is a bug: reads answer with a range of -//! the pane's live text (`Payload.region`) or with the staging buffer, -//! and the staging buffer is cleared, never freed. -//! 3. What does an editor with NO script attached pay? The last two rows are -//! the same keystroke with zero listeners and with one. Zero listeners -//! must be indistinguishable from an editor with no filesystem compiled -//! in at all: one branch in `file_pane.setContent`. const std = @import("std"); const pardes = @import("pardes"); -const acmefs = pardes.acmefs; +const filesystem = pardes.filesystem; pub const std_options: std.Options = .{ .log_level = .err }; const backing = std.heap.page_allocator; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("fs_bench.zig"))}); +}; -/// std.time.Timer is gone in 0.16; clock_gettime is what test/perf.zig uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -43,40 +19,43 @@ fn nowNs() u64 { const Row = struct { name: []const u8, ns: u64, + reps: usize, allocs: usize, note: []const u8 = "", }; var rows: std.ArrayList(Row) = .empty; -fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) void { - rows.append(backing, .{ +fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) !void { + try rows.append(backing, .{ .name = name, - .ns = total_ns / @max(1, reps), + .ns = total_ns / reps, + .reps = reps, .allocs = counting.allocations, .note = note, - }) catch {}; + }); } -/// A session with something to measure against: one big file pane, one shell, -/// and a scripted pane whose event queue has records waiting. const Session = struct { core: *pardes.Pardes, counting: *std.testing.FailingAllocator, file_id: usize, file_serial: u32, + body_bytes: usize, + tag: []u8, fn init(counting: *std.testing.FailingAllocator, body_bytes: usize) !Session { const gpa = counting.allocator(); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 120, .rows = 40 }); + errdefer core.deinit(); while (core.nextEffect()) |_| {} - // A body big enough that a copy would show up in the numbers. const line = "the quick brown fox jumps over the lazy dog\n"; var content: std.ArrayList(u8) = .empty; - while (content.items.len < body_bytes) try content.appendSlice(backing, line); - const pane = try core.hxOpenFileContent(content.items); - content.deinit(backing); + defer content.deinit(backing); + while (content.items.len < body_bytes) + try content.appendSlice(backing, line[0..@min(line.len, body_bytes - content.items.len)]); + const pane = try core.setTestFile(content.items); const id = core.active; while (core.nextEffect()) |_| {} return .{ @@ -84,120 +63,208 @@ const Session = struct { .counting = counting, .file_id = id, .file_serial = pane.serial, + .body_bytes = pane.file.?.content.len, + .tag = try core.tagText(backing, pane), }; } fn deinit(s: *Session) void { + backing.free(s.tag); s.core.deinit(); } - fn node(s: *const Session, file: acmefs.PaneFile) u64 { - return acmefs.Node.of(s.file_serial, file); + fn node(s: *const Session, file: filesystem.PaneFile) u64 { + return filesystem.Node.of(s.file_serial, file); } }; -/// One row: run `req` `reps` times and report the mean cost plus how many -/// allocations the whole run made. -/// -/// The per-update scratch arena is reset each rep because that is what the -/// real path does — `Pardes.update` resets it at the end of every event, and -/// `handle` called bare would otherwise let one arena grow across a hundred -/// thousand requests and count its CHUNKS as allocations. Resetting here -/// measures the request, not the harness. -fn bench(s: *Session, name: []const u8, reps: usize, req: acmefs.Req, note: []const u8) void { - // warm the staging buffer and any lazy index the first call builds +fn checkNumbers(bytes: []const u8, expected: []const usize) ![]const u8 { + var rest = bytes; + for (expected) |value| { + rest = std.mem.trimStart(u8, rest, " "); + const end = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.IncorrectReply; + const got = std.fmt.parseInt(usize, rest[0..end], 10) catch return error.IncorrectReply; + if (got != value) return error.IncorrectReply; + rest = rest[end + 1 ..]; + } + return std.mem.trimStart(u8, rest, " "); +} + +fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void { + const event_read = req.op == .read and req.node == s.node(.event); + const expected_status: filesystem.Status = if (event_read) .again else .ok; + if (reply.tag != req.tag or reply.errno != 0 or reply.status != expected_status) + return error.IncorrectReply; + const body = s.core.panes[s.file_id].?.file.?.content; + switch (req.op) { + .getattr => if (reply.attr.node != req.node or reply.attr.size != body.len or reply.attr.dir) return error.IncorrectReply, + .lookup => if (reply.attr.node != s.node(.ctl) or reply.attr.dir) return error.IncorrectReply, + .read => { + if (event_read) { + if (reply.payload != .none) return error.IncorrectReply; + } else if (req.node == s.node(.body)) { + if (reply.payload != .region) return error.IncorrectReply; + const region = reply.payload.region; + if (region.pane != s.file_id or region.serial != s.file_serial or region.off != req.off or region.len != req.size) + return error.IncorrectReply; + } else { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const pane = s.core.panes[s.file_id].?; + const rest = try checkNumbers(bytes, &.{ s.file_serial, s.tag.len, body.len, 0, @intFromBool(pane.file.?.revision != pane.file.?.saved_revision) }); + if (req.node == s.node(.ctl)) { + const tail = try checkNumbers(rest, &.{pane.cols}); + if (!std.mem.startsWith(u8, tail, "default ")) return error.IncorrectReply; + if ((try checkNumbers(tail[8..], &.{pardes.config.tab_width})).len != 0) + return error.IncorrectReply; + } else if (req.node == @intFromEnum(filesystem.SelfFile.index)) { + const tag = s.tag[0 .. std.mem.indexOfScalar(u8, s.tag, '\n') orelse s.tag.len]; + if (rest.len != tag.len + 1 or rest[rest.len - 1] != '\n' or !std.mem.eql(u8, rest[0..tag.len], tag)) + return error.IncorrectReply; + } else return error.UnmeasuredOperation; + } + }, + .readdir => { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const names = [_][]const u8{ "index", "cons", "new", "pane", "screen", "listeners" }; + const nodes = [_]u64{ @intFromEnum(filesystem.SelfFile.index), @intFromEnum(filesystem.SelfFile.cons), @intFromEnum(filesystem.SelfFile.new), filesystem.namespace_panes, @intFromEnum(filesystem.SelfFile.screen), @intFromEnum(filesystem.SelfFile.listeners) }; + var off: usize = 0; + var entry: usize = 0; + while (off < bytes.len) : (entry += 1) { + if (bytes.len - off < 10) return error.IncorrectReply; + const node = std.mem.readInt(u64, bytes[off..][0..8], .little); + const dir = bytes[off + 8]; + const size: usize = bytes[off + 9]; + off += 10; + if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply; + if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry == 2 or entry == 3) or + !std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply; + off += size; + } + if (entry < names.len) return error.IncorrectReply; + }, + .write => if (reply.written != req.data.len or !std.mem.endsWith(u8, body, req.data)) return error.IncorrectReply, + else => return error.UnmeasuredOperation, + } +} + +fn bench(s: *Session, name: []const u8, reps: usize, req: filesystem.Req, note: []const u8) !void { + std.debug.assert(reps > 0); + const initial_bytes = s.core.panes[s.file_id].?.file.?.content.len; + const initial_hash = if (req.op == .write) std.hash.Wyhash.hash(0, s.core.panes[s.file_id].?.file.?.content) else 0; for (0..64) |_| { - _ = acmefs.handle(s.core, req); + try checkReply(s, req, filesystem.handle(s.core, req)); _ = s.core.scratch.reset(.retain_capacity); } s.counting.allocations = 0; + const expected_status: filesystem.Status = if (req.op == .read and req.node == s.node(.event)) .again else .ok; + var valid = true; + var reply: filesystem.Reply = undefined; const start = nowNs(); for (0..reps) |_| { - const reply = acmefs.handle(s.core, req); - std.mem.doNotOptimizeAway(reply.status); + reply = filesystem.handle(s.core, req); + valid = valid and reply.tag == req.tag and reply.status == expected_status and reply.errno == 0; + std.mem.doNotOptimizeAway(reply); _ = s.core.scratch.reset(.retain_capacity); } - record(name, nowNs() - start, reps, s.counting, note); + const elapsed = nowNs() - start; + if (!valid) return error.IncorrectReply; + try checkReply(s, req, reply); + if (req.op == .write) { + const body = s.core.panes[s.file_id].?.file.?.content; + if (body.len != initial_bytes + (64 + reps) * req.data.len or + std.hash.Wyhash.hash(0, body[0..initial_bytes]) != initial_hash or + !std.mem.allEqual(u8, body[initial_bytes..], 'x')) return error.MissingWrite; + } + try record(name, elapsed, reps, s.counting, note); } -pub fn main(init: std.process.Init) !void { - const args = try init.minimal.args.toSlice(init.arena.allocator()); - var reps: usize = 100_000; - var json = false; - var i: usize = 1; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--json")) { - json = true; - } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) { - i += 1; - reps = try std.fmt.parseInt(usize, args[i], 10); +const Options = struct { + reps: usize = 100_000, + json: bool = false, + + fn parse(args: []const []const u8) !Options { + var result: Options = .{}; + var seen_reps = false; + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--json") and !result.json) { + result.json = true; + } else if (std.mem.eql(u8, args[i], "--reps") and !seen_reps) { + i += 1; + if (i == args.len) return error.MissingRepetitions; + result.reps = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidRepetitions; + if (result.reps == 0) return error.InvalidRepetitions; + seen_reps = true; + } else return error.InvalidArgument; } + return result; } +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const options = try Options.parse(args[1..]); + const reps = options.reps; + defer rows.deinit(backing); - // std.testing.FailingAllocator with the default options never induces a - // failure and counts every allocation, which is the whole of what this - // benchmark wanted from a wrapper. var counting: std.testing.FailingAllocator = .init(backing, .{}); - var s = try Session.init(&counting, 1 << 20); // a 1 MiB body + var s = try Session.init(&counting, 1 << 20); defer s.deinit(); - // ---- the three shapes of request ------------------------------------- - bench(&s, "getattr body", reps, .{ + try bench(&s, "getattr body", reps, .{ .tag = 1, .op = .getattr, .node = s.node(.body), }, "stat of a 1 MiB body"); - bench(&s, "lookup ctl", reps, .{ + try bench(&s, "lookup ctl", reps, .{ .tag = 2, .op = .lookup, - .node = acmefs.Node.of(s.file_serial, .dir), + .node = filesystem.Node.of(s.file_serial, .dir), .data = "ctl", }, "name -> node"); - bench(&s, "read body 4K", reps, .{ + try bench(&s, "read body 4K", reps, .{ .tag = 3, .op = .read, .node = s.node(.body), .off = 4096, .size = 4096, }, "must be zero-copy"); - bench(&s, "read body 1M", reps / 10, .{ + try bench(&s, "read body 1M", @max(1, reps / 10), .{ .tag = 4, .op = .read, .node = s.node(.body), .off = 0, .size = 1 << 20, }, "same cost as 4K if truly zero-copy"); - bench(&s, "read ctl", reps, .{ + try bench(&s, "read ctl", reps, .{ .tag = 5, .op = .read, .node = s.node(.ctl), .size = 256, }, "formatted into the staging buffer"); - bench(&s, "read index", reps, .{ + try bench(&s, "read index", reps, .{ .tag = 6, .op = .read, - .node = @intFromEnum(acmefs.TopFile.index), + .node = @intFromEnum(filesystem.SelfFile.index), .size = 4096, }, "one line per pane"); - bench(&s, "readdir root", reps, .{ + try bench(&s, "readdir self", reps, .{ .tag = 7, .op = .readdir, - .node = @intFromEnum(acmefs.TopFile.root), + .node = @intFromEnum(filesystem.SelfFile.root), .size = 4096, }, "staged dirents"); - bench(&s, "read event (empty)", reps, .{ + try bench(&s, "read event (empty)", reps, .{ .tag = 8, .op = .read, .node = s.node(.event), .size = 256, }, "Status.again — the blocking primitive"); - // Appending GROWS the fixture, and every append is a whole-body swap plus - // an undo snapshot (that is the core's edit model, not this filesystem's), - // so this row is quadratic in its own rep count against a 1 MiB body. - // Bounded on purpose: the question is what one write costs, and 200 of - // them answer it without spending a quarter of an hour proving that - // appending ten megabytes a kilobyte at a time is slow. - bench(&s, "write body 1K", @min(reps, 200), .{ + try bench(&s, "write body 1K", @min(reps, 200), .{ .tag = 9, .op = .write, .node = s.node(.body), @@ -205,25 +272,15 @@ pub fn main(init: std.process.Init) !void { .size = 1024, }, "append: whole-body swap + undo snapshot"); - // ---- what an unscripted editor pays ---------------------------------- - // The same keystroke, twice: with nobody listening and with one listener. - // The first number is the honest answer to "what does this feature cost a - // session that never uses it", and the pair is what recording costs. - // - // A FRESH SESSION PER ROW, and this matters: typing inserts at the cursor, - // so the line under it grows by one character per rep, and the core's - // per-keystroke cost is dominated by walking that line's grapheme widths - // (measured: `file_pane.graphemeDisplayWidth` is 65% of this benchmark's - // cycles). Reusing one session made the second row type into a body the - // first had already lengthened, and reported a 2.8x "overhead" that was - // entirely the fixture. Small bodies for the same reason: on the megabyte - // fixture a keystroke costs ~40 ms whatever this filesystem does. const key_reps = @min(reps, 2000); + var key_body_bytes: usize = 0; for ([_]bool{ false, true }) |scripted| { var keys = try Session.init(&counting, 32 * 1024); defer keys.deinit(); const pane = keys.core.panes[keys.file_id].?; pane.mode = .insert; + const initial_bytes = pane.file.?.content.len; + key_body_bytes = initial_bytes; if (scripted) { keys.core.fs.panes[keys.file_id].readers = 1; keys.core.fs.listeners = 1; @@ -234,9 +291,20 @@ pub fn main(init: std.process.Init) !void { keys.core.update(.{ .key = .{ .cp = 'x', .text = "x" } }); while (keys.core.nextEffect()) |_| {} } - record( + const elapsed = nowNs() - start; + if (pane.file.?.content.len != initial_bytes + key_reps or pane.cur_col != key_reps or + !std.mem.allEqual(u8, pane.file.?.content[0..key_reps], 'x')) return error.MissingKeystroke; + const events = &keys.core.fs.panes[keys.file_id].events; + if (scripted) for (0..key_reps) |i| { + var buf: [64]u8 = undefined; + const expected = try std.fmt.bufPrint(&buf, "KI{d} {d} 0 1 x\n", .{ i, i + 1 }); + if (!std.mem.eql(u8, expected, events.peek() orelse return error.MissingEvent)) return error.IncorrectEvent; + events.pop(); + }; + if (!events.empty()) return error.IncorrectEvent; + try record( if (scripted) "keystroke, 1 listener" else "keystroke, no listener", - nowNs() - start, + elapsed, key_reps, &counting, if (scripted) "diff + record" else "one branch", @@ -246,11 +314,11 @@ pub fn main(init: std.process.Init) !void { var out: std.Io.Writer.Allocating = .init(backing); defer out.deinit(); const w = &out.writer; - if (json) { - try w.writeAll("{\"rows\":["); + if (options.json) { + try w.print("{{\"harness\":\"{s}\",\"body_bytes\":{d},\"key_body_bytes\":{d},\"rows\":[", .{ harness_id, s.body_bytes, key_body_bytes }); for (rows.items, 0..) |r, n| { if (n > 0) try w.writeAll(","); - try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.allocs }); + try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"reps\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.reps, r.allocs }); } try w.writeAll("]}\n"); } else { @@ -261,3 +329,20 @@ pub fn main(init: std.process.Init) !void { } try std.Io.File.stdout().writeStreamingAll(init.io, out.written()); } + +test "fs benchmark rejects empty workloads and ambiguous options" { + try std.testing.expectEqual(@as(usize, 100_000), (try Options.parse(&.{})).reps); + try std.testing.expectEqualDeep(Options{ .reps = 1, .json = true }, try Options.parse(&.{ "--json", "--reps", "1" })); + try std.testing.expectError(error.MissingRepetitions, Options.parse(&.{"--reps"})); + for ([_][]const u8{ "0", "-1", "no", "99999999999999999999999999" }) |value| + try std.testing.expectError(error.InvalidRepetitions, Options.parse(&.{ "--reps", value })); + for ([_][]const []const u8{ &.{"--typo"}, &.{ "--json", "--json" }, &.{ "--reps", "1", "--reps", "2" } }) |args| + try std.testing.expectError(error.InvalidArgument, Options.parse(args)); +} + +test "fs benchmark rejects wrong or incomplete staged metadata" { + try std.testing.expectEqualStrings("/test.txt\n", try checkNumbers(" 2 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1023 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("3 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1024 ", &.{ 2, 9, 1024, 0, 0 })); +} diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig new file mode 100644 index 00000000..c856148d --- /dev/null +++ b/test/fs_namespace.zig @@ -0,0 +1,333 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const filesystem = pardes.filesystem; + +test "runtime Mount and Unmount copy command arguments and change Look resolution" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + var command = "Mount peer /tmp/a socket.sock".*; + try std.testing.expect(p.executeBuiltinLine(0, &command)); + @memset(&command, 'x'); + try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len); + try std.testing.expectEqualStrings("peer", p.fs.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial); + var path: [4096]u8 = undefined; + try std.testing.expectEqualStrings("/n/peer/self/index", filesystem.resolve(p, "/n/peer/self/index", "/", &path).?.path); + try std.testing.expect(p.executeBuiltinLine(0, "Mount peer /tmp/other.sock")); + try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len); + try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial); + try std.testing.expect(p.executeBuiltinLine(0, "Unmount peer")); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + try std.testing.expect(filesystem.resolve(p, "/n/peer/self/index", "/", &path) == null); + for ([_][]const u8{ "Mount", "Mount name", "Mount os /tmp/peer.sock", "Mount peer tcp!127.0.0.1!0", "Unmount", "Unmount name extra" }) |invalid| { + try std.testing.expect(p.executeBuiltinLine(0, invalid)); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + } +} + +test "mounts survive dumps without borrowing inputs or silently overriding a saved target" { + const gpa = std.testing.allocator; + var name = "peer".*; + var dial = "/tmp/peer.sock".*; + const startup = [_]filesystem.Mount{.{ .name = &name, .dial = &dial }}; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .mounts = &startup }); + defer p.deinit(); + @memset(&name, 'x'); + @memset(&dial, 'x'); + try std.testing.expectEqual(@as(usize, 0), p.opts.mounts.len); + const file = try p.setTestFile("saved remote body\n"); + file.file.?.revision = 3; + gpa.free(file.file.?.path); + file.file.?.path = try gpa.dupe(u8, "/n/peer/os/file.txt"); + try p.dumpState(); + const restored = try pardes.Pardes.initFromDump(gpa, .{}, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, 1), restored.fs.mounts.items.len); + try std.testing.expectEqualStrings("peer", restored.fs.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/peer.sock", restored.fs.mounts.items[0].dial); + try std.testing.expectEqualStrings("/n/peer/os/file.txt", restored.panes[0].?.file.?.path); + try std.testing.expectEqualStrings("saved remote body\n", restored.panes[0].?.file.?.content); + try std.testing.expect(restored.panes[0].?.file.?.revision != restored.panes[0].?.file.?.saved_revision); + try std.testing.expectError(error.MountInUse, filesystem.unmount(restored, "peer")); + const same = try pardes.Pardes.initFromDump(gpa, .{ .mounts = &.{.{ .name = "peer", .dial = "/tmp/peer.sock" }} }, p.dump_out.?); + defer same.deinit(); + try std.testing.expectEqual(@as(usize, 1), same.fs.mounts.items.len); + try std.testing.expectError(error.MountConflict, pardes.Pardes.initFromDump(gpa, .{ + .mounts = &.{.{ .name = "peer", .dial = "/tmp/different.sock" }}, + }, p.dump_out.?)); + const added = try pardes.Pardes.initFromDump(gpa, .{ .mounts = &.{.{ .name = "other", .dial = "/tmp/other.sock" }} }, p.dump_out.?); + defer added.deinit(); + try std.testing.expectEqual(@as(usize, 2), added.fs.mounts.items.len); +} + +test "removed startup mounts are not resurrected by dump restore" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ + .tty_only = true, + .mounts = &.{.{ .name = "peer", .dial = "/tmp/peer.sock" }}, + }); + defer p.deinit(); + try filesystem.unmount(p, "peer"); + try p.dumpState(); + const restored = try pardes.Pardes.initFromDump(gpa, p.opts, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, 0), restored.fs.mounts.items.len); +} + +test "transactional restore preserves host capabilities and advances pane identities" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 91, .rows = 37 }); + defer p.deinit(); + const pane = try p.setTestFile("before\n"); + p.lsp_seq = 41; + p.pipe_seq = 73; + if (@hasField(pardes.CellPixels, "w")) p.cell_pixels = .{ .w = 9, .h = 18 }; + p.native_images = true; + p.fs.socket_path = "/tmp/session.sock"; + p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; + p.fs.quic_address = .{ .ip6 = .loopback(5641) }; + try p.dumpState(); + const restored = try p.restore(p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(u32, 41), restored.lsp_seq); + try std.testing.expectEqual(@as(u32, 73), restored.pipe_seq); + try std.testing.expectEqual(p.cell_pixels, restored.cell_pixels); + try std.testing.expect(restored.native_images); + try std.testing.expect(restored.panes[0].?.serial > p.next_serial); + try std.testing.expect(restored.paneBySerial(pane.serial) == null); + try std.testing.expectEqual(p.screen_w, restored.screen_w); + try std.testing.expectEqual(p.screen_h, restored.screen_h); + try std.testing.expectEqualStrings(p.fs.socket_path, restored.fs.socket_path); + try std.testing.expectEqualDeep(p.fs.tcp_address, restored.fs.tcp_address); + try std.testing.expectEqualDeep(p.fs.quic_address, restored.fs.quic_address); + try std.testing.expect(p.panes[0] == pane); + try std.testing.expectEqualStrings("before\n", pane.file.?.content); + try std.testing.expectError(error.BadDumpMagic, p.restore( + ".{ .magic = \"wrong\", .screen = .{.cols = 80, .rows = 24} }", + )); + try std.testing.expect(p.panes[0] == pane); + try std.testing.expectEqualStrings("before\n", pane.file.?.content); + try std.testing.expectEqual(@as(u32, 41), p.lsp_seq); + try std.testing.expectEqualStrings("/tmp/session.sock", p.fs.socket_path); +} + +test "same-session identity is available before an initial mounted file is opened" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file.txt", .data = "no socket required\n" }); + var buf: [4096]u8 = undefined; + const len = try tmp.dir.realPathFile(std.testing.io, "file.txt", &buf); + const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{buf[0..len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ + .file = path, + .mounts = &.{.{ .name = "own", .dial = "/tmp/not-listening.sock" }}, + .ninep_identity = .{ .socket_path = "/tmp/not-listening.sock" }, + }); + defer p.deinit(); + try std.testing.expectEqualStrings(path, p.panes[0].?.file.?.path); + try std.testing.expectEqualStrings("no socket required\n", p.panes[0].?.file.?.content); + try std.testing.expectEqualStrings("", p.opts.ninep_identity.socket_path); +} + +test "filesystem resolves OS first then virtual with explicit mounts and parent paths" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "index", .data = "OS INDEX\n" }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const directory = directory_buffer[0..directory_len]; + const os_index = try std.fmt.allocPrint(gpa, "{s}/index", .{directory}); + defer gpa.free(os_index); + const explicit_os = try std.fmt.allocPrint(gpa, "/n/os{s}", .{os_index}); + defer gpa.free(explicit_os); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("SELF BODY\n"); + const explicit_self = try std.fmt.allocPrint(gpa, "/n/self/pane/{d}/body", .{pane.serial}); + defer gpa.free(explicit_self); + const virtual_self = try std.fmt.allocPrint(gpa, "/virtual/pane/{d}/body", .{pane.serial}); + defer gpa.free(virtual_self); + const parent_self = try std.fmt.allocPrint(gpa, "/n/self/pane/{d}/../{d}/body", .{ pane.serial, pane.serial }); + defer gpa.free(parent_self); + + for ([_]struct { word: []const u8, path: []const u8, bytes: []const u8 }{ + .{ .word = "index", .path = os_index, .bytes = "OS INDEX\n" }, + .{ .word = os_index, .path = os_index, .bytes = "OS INDEX\n" }, + .{ .word = explicit_os, .path = explicit_os, .bytes = "OS INDEX\n" }, + .{ .word = explicit_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + .{ .word = virtual_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + .{ .word = parent_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + }) |case| { + var resolved_buffer: [4096]u8 = undefined; + const found = filesystem.resolve(p, case.word, directory, &resolved_buffer) orelse return error.UnresolvedPath; + try std.testing.expectEqualStrings(case.path, found.path); + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(case.bytes, bytes); + } + + for ([_][]const u8{ "/n/self/index", "/virtual/index" }) |word| { + var resolved_buffer: [4096]u8 = undefined; + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.UnresolvedPath; + try std.testing.expectEqualStrings("/virtual/index", found.path); + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "/test.txt") != null); + } + try tmp.dir.deleteFile(std.testing.io, "index"); + var resolved_buffer: [4096]u8 = undefined; + const fallback = filesystem.resolve(p, "index", directory, &resolved_buffer) orelse return error.MissingVirtualFallback; + try std.testing.expectEqualStrings("/virtual/index", fallback.path); + + for ([_][]const u8{ "src/pardes.zig", "/virtual/src/pardes.zig", "/virtual/src/../src/pardes.zig" }) |word| { + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.MissingEmbeddedSource; + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "pub const Pardes") != null); + } + for ([_][]const u8{ "/virtual/src", "/n/self/src" }) |word| { + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.MissingEmbeddedDirectory; + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "/virtual/src/pardes.zig\n") != null); + } +} + +test "explicit OS file opens and restores keep their names and request watches" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "watched.zig", .data = "const disk = 1;\n" }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/watched.zig", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const opened = p.freeSlot().?; + const pane = try pardes.panes.File.open(p, opened, path, 0); + try std.testing.expectEqualStrings(path, pane.file.?.path); + try std.testing.expectEqualStrings("const disk = 1;\n", pane.file.?.content); + const restored = p.freeSlot().?; + const copy = try pardes.panes.File.restore(p, restored, .{ + .kind = .file, + .tag = path, + .body = "", + .file = .{ .path = path, .content = "const unsaved = 2;\n" }, + }); + try std.testing.expectEqualStrings(path, copy.file.?.path); + try std.testing.expectEqualStrings("const unsaved = 2;\n", copy.file.?.content); + var watched_open = false; + var watched_restore = false; + while (p.nextEffect()) |effect| if (effect == .watch and effect.watch.on) { + if (effect.watch.pane == opened) watched_open = true; + if (effect.watch.pane == restored) watched_restore = true; + }; + try std.testing.expect(watched_open and watched_restore); +} + +test "virtual file opens do not request OS watches" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const free = p.freeSlot().?; + _ = try pardes.panes.File.open(p, free, "/virtual/src/pardes.zig", 0); + while (p.nextEffect()) |effect| if (effect == .watch) return error.VirtualFileWatch; +} + +test "Look at the self factory reserves its own pane and failed opens release it" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.lookAt(0, "/virtual/new/ctl"); + var control_count: usize = 0; + var scratch_count: usize = 0; + for (p.panes) |slot| { + const pane = slot orelse continue; + const file = pane.file orelse continue; + if (std.mem.eql(u8, file.path, "/virtual/new/ctl")) control_count += 1; + if (file.output) |output| if (output.from == .cmd and output.from.cmd == .New) { + scratch_count += 1; + }; + } + try std.testing.expectEqual(@as(usize, 1), control_count); + try std.testing.expectEqual(@as(usize, 1), scratch_count); + const free = p.freeSlot().?; + try std.testing.expectError(error.FileNotFound, pardes.panes.File.open(p, free, "/n/self/does-not-exist", 0)); + try std.testing.expect(p.panes[free] == null); + try std.testing.expect(!p.reserved_slots[free]); + _ = try p.newDocPane(free); +} + +test "owned cwd failed file opens never publish a pane or retain a reservation" { + var succeeded = false; + for (0..16) |failure| { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try pardes.Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("source body\n"); + p.setCwd(0, "/source/directory"); + while (p.nextEffect()) |_| {} + var path: [128]u8 = undefined; + const name = try std.fmt.bufPrint(&path, "/virtual/pane/{d}/body", .{source.serial}); + const slot = p.freeSlot().?; + const serial = p.next_serial; + const columns = p.col_panes; + const counts = p.col_n; + allocator.fail_index = allocator.alloc_index + failure; + const opened = pardes.panes.File.open(p, slot, name, 0) catch |err| { + try std.testing.expect(err == error.OutOfMemory or err == error.ReadFailed or err == error.WriteFailed); + try std.testing.expect(p.panes[slot] == null); + try std.testing.expect(!p.reserved_slots[slot]); + try std.testing.expectEqual(serial, p.next_serial); + try std.testing.expectEqual(slot, p.freeSlot().?); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expectEqualStrings("/source/directory", source.cwdSlice()); + allocator.fail_index = std.math.maxInt(usize); + _ = try p.newDocPane(slot); + continue; + }; + try std.testing.expect(!p.reserved_slots[slot]); + try std.testing.expectEqualStrings("source body\n", opened.file.?.content); + succeeded = true; + break; + } + try std.testing.expect(succeeded); +} + +test "images decode and dump through explicit filesystem mounts" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + pardes.image.start(std.testing.io, gpa); + defer pardes.image.stop(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = "P6\n1 1\n255\n\x0d\x4d\xe7"; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pixel.ppm", .data = bytes }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/pixel.ppm", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .file = path }); + defer p.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + _ = try p.render(arena.allocator()); + const pane = p.panes[0].?; + const state = pane.image.?; + try std.testing.expectEqual(@as(usize, 1), state.iw); + try std.testing.expectEqual(@as(usize, 1), state.ih); + const saved = try pardes.panes.Image.dumpPane(p, arena.allocator(), pane, "", "", 0, path, &.{}); + const decoded = try pardes.dump.decodeBytes(gpa, saved.image.?.bytes_b64); + defer gpa.free(decoded); + try std.testing.expectEqualStrings(bytes, decoded); +} diff --git a/test/fs_soak.py b/test/fs_soak.py new file mode 100644 index 00000000..1201fda2 --- /dev/null +++ b/test/fs_soak.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from pathlib import Path +import random +import socket +import struct +import tempfile +import time + +from fs import new_pane, session +from ninep import Client + + +BODY_LIMIT = 4096 +SCREEN_LIMIT = 4 * 1024 * 1024 +FRAGMENTS = [ + b'const value = 42;\n', b'fn check() void { if (true) return; }\n', + b'\tspaces and tabs\n', 'λ 界 e\u0301 👩\u200d🚀\n'.encode(), b'x', +] +OPERATIONS = ['append', 'range', 'replace', 'read', 'save', 'reload', + 'cycle', 'reconnect', 'screen'] + + +def emit(**record): + print(json.dumps(record, separators=(',', ':')), flush=True) + + +def payload(rng, limit=256): + result = bytearray() + for _ in range(rng.randint(1, 12)): + fragment = rng.choice(FRAGMENTS) + if len(result) + len(fragment) <= limit: + result.extend(fragment) + return bytes(result) or b'x'[:limit] + + +def check_bytes(actual, expected, label): + if actual == expected: + return + at = next((i for i, pair in enumerate(zip(actual, expected)) + if pair[0] != pair[1]), min(len(actual), len(expected))) + raise AssertionError(f'{label}: mismatch at byte {at}; ' + f'length {len(actual)} != {len(expected)}; ' + f'{actual[at:at + 32]!r} != {expected[at:at + 32]!r}') + + +def read_fid(client, fid, limit, count): + result = bytearray() + while chunk := client.read_fid(fid, len(result), count): + result.extend(chunk) + if len(result) > limit: + raise AssertionError(f'read exceeded fixture limit {limit}') + return bytes(result) + + +def check_body(client, pane, count=4096): + fid = client.open(f'/self/pane/{pane["serial"]}/body') + try: + actual = read_fid(client, fid, BODY_LIMIT, count) + finally: + client.close(fid) + check_bytes(actual, pane['body'], f'pane {pane["serial"]}') + + +def check_screen(data): + screen = json.loads(data) + cols, rows = screen['cols'], screen['rows'] + assert cols > 0 and rows > 0 and cols * rows <= 65536, (cols, rows) + assert len(screen['cells']) == cols * rows + styles = screen['styles'] + assert styles + for cell in screen['cells']: + assert len(cell) == 2 and isinstance(cell[0], str), cell + assert isinstance(cell[1], int) and 0 <= cell[1] < len(styles), cell + + +def session_pid(client): + if not hasattr(socket, 'SO_PEERCRED'): + return None + pid, uid, _ = struct.unpack('3i', client.socket.getsockopt( + socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))) + assert pid > 0 and uid == os.getuid(), (pid, uid) + return pid + + +def check_session(client, control, pid): + assert session_pid(control) == pid + if pid is not None: + actual = (session_pid(client) if client.socket.family == socket.AF_UNIX + else int(client.read('/os/proc/self/stat').split()[0])) + assert actual == pid, (actual, pid) + + +def memory(pid): + result = {'rss_bytes': None, 'cumulative_peak_rss_bytes': None} + if pid is None: + return result + try: + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + fields = line.split() + if fields[0] == 'VmRSS:': + result['rss_bytes'] = int(fields[1]) * 1024 + elif fields[0] == 'VmHWM:': + result['cumulative_peak_rss_bytes'] = int(fields[1]) * 1024 + except OSError: + pass + return result + + +def run(args): + rng = random.Random(args.seed) + started = time.monotonic() + deadline = None + batch = 0 + operation = 'start' + operation_count = 0 + pid = None + try: + with tempfile.TemporaryDirectory(prefix='pardes-soak-') as directory: + root = Path(directory) + options = ['--9p-tcp=tcp!127.0.0.1!0'] if args.transport == 'tcp' else [] + with session(str(args.binary.resolve()), root, 'soak', *options, + tty=args.tty) as (control, unix_address): + pid = session_pid(control) + address = unix_address + if args.transport == 'tcp': + listeners = [line.split('!') for line in + control.read('/self/listeners').decode().splitlines() + if line.startswith('tcp!')] + assert len(listeners) == 1, listeners + transport, host, port = listeners[0] + assert transport == 'tcp' and host == '127.0.0.1' and 0 < int(port) < 65536, listeners + address = (host, int(port)) + client = Client(address) + panes = [] + try: + check_session(client, control, pid) + for slot in range(4): + body = payload(rng) + serial = 1 if slot == 0 else new_pane(client, body) + base = f'/self/pane/{serial}' + if slot == 0: + client.write(base + '/body', body, truncate=True) + path = root / f'pane-{slot}-0.zig' + client.write(base + '/ctl', f'name {path}\nput\n'.encode()) + panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0}) + emit(kind='start', seed=args.seed, session_pid=pid, + transport=args.transport, host='tty' if args.tty else 'detached', + batches=args.batches, duration_seconds=args.duration, + interval_seconds=args.interval, body_limit_bytes=BODY_LIMIT, + live_panes=len(panes), **memory(pid)) + if args.duration is not None: + deadline = time.monotonic() + args.duration + while (args.batches is None or batch < args.batches) and ( + deadline is None or time.monotonic() < deadline): + batch_started = time.monotonic() + operations = OPERATIONS * 2 + rng.shuffle(operations) + for operation in operations: + slot = rng.randrange(len(panes)) + pane = panes[slot] + base = f'/self/pane/{pane["serial"]}' + if operation == 'append': + addition = payload(rng, min(256, BODY_LIMIT - len(pane['body']))) + client.write(base + '/body', addition) + pane['body'] += addition + elif operation == 'range': + body = pane['body'] + # Keep range endpoints outside combining and ZWJ clusters. + boundaries = [0] + [i for i in range(1, len(body)) + if body[i - 1] < 128 and body[i] < 128] + [len(body)] + lo, hi = sorted((rng.choice(boundaries), rng.choice(boundaries))) + replacement = payload(rng, min(256, BODY_LIMIT - len(body) + hi - lo)) + if replacement: + client.write(base + '/addr', f'#{lo},#{hi}'.encode()) + client.write(base + '/data', replacement) + pane['body'] = body[:lo] + replacement + body[hi:] + elif operation == 'replace': + pane['body'] = payload(rng, BODY_LIMIT) + client.write(base + '/body', pane['body'], truncate=True) + elif operation == 'read': + check_body(client, pane, rng.choice([97, 251, 1024, 4096])) + elif operation == 'save': + pane['variant'] ^= 1 + pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig' + client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode()) + check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file') + check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount') + elif operation == 'reload': + pane['body'] = payload(rng) + pane['path'].write_bytes(pane['body']) + client.write(base + '/ctl', b'get\n') + elif operation == 'cycle': + old_serial = pane['serial'] + client.write(base + '/ctl', b'delete\n') + pane['body'] = payload(rng) + pane['serial'] = new_pane(client, pane['body']) + assert pane['serial'] != old_serial + base = f'/self/pane/{pane["serial"]}' + client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode()) + elif operation == 'reconnect': + client.socket.close() + client = Client(address) + check_session(client, control, pid) + check_body(control, pane) + elif operation == 'screen': + frozen = client.open('/self/screen') + try: + before = read_fid(client, frozen, SCREEN_LIMIT, 4096) + check_screen(before) + addition = payload(rng, min(64, BODY_LIMIT - len(pane['body']))) + client.write(base + '/body', addition) + pane['body'] += addition + after = read_fid(client, frozen, SCREEN_LIMIT, rng.choice([251, 1024])) + check_bytes(after, before, 'frozen screen') + finally: + client.close(frozen) + fresh = client.open('/self/screen') + try: + check_screen(read_fid(client, fresh, SCREEN_LIMIT, 4096)) + finally: + client.close(fresh) + operation_count += 1 + check_body(client, pane) + expected = {str(pane['serial']) for pane in panes} + assert set(client.list('/self/pane')) == expected + assert set(control.list('/self/pane')) == expected + for pane in panes: + check_body(client, pane) + check_body(control, pane) + batch += 1 + emit(kind='batch', seed=args.seed, batch=batch, + duration_seconds=time.monotonic() - batch_started, + elapsed_seconds=time.monotonic() - started, + operations=len(operations), total_operations=operation_count, + model_bytes=sum(len(pane['body']) for pane in panes), **memory(pid)) + delay = args.interval - (time.monotonic() - batch_started) + if deadline is not None: + delay = min(delay, deadline - time.monotonic()) + if delay > 0: + time.sleep(delay) + finally: + client.socket.close() + emit(kind='summary', seed=args.seed, batches=batch, + transport=args.transport, host='tty' if args.tty else 'detached', + operations=operation_count, duration_seconds=time.monotonic() - started, + status='passed') + except BaseException as error: + emit(kind='failure', seed=args.seed, batch=batch + 1, operation=operation, + transport=args.transport, host='tty' if args.tty else 'detached', + operations=operation_count, session_pid=pid, + elapsed_seconds=time.monotonic() - started, error=str(error)) + raise + + +def main(): + parser = argparse.ArgumentParser(description='Deterministic valid-operation editor/9P soak; writes JSONL.') + parser.add_argument('binary', type=Path) + parser.add_argument('--seed', type=int, default=4200) + parser.add_argument('--transport', choices=['unix', 'tcp'], default='unix') + parser.add_argument('--tty', action='store_true', help='run an owned TTY host instead of a detached host') + limit = parser.add_mutually_exclusive_group() + limit.add_argument('--batches', type=int) + limit.add_argument('--duration', type=float, help='seconds, stopping after the current batch') + parser.add_argument('--interval', type=float, default=0, help='minimum seconds between batch starts') + args = parser.parse_args() + if args.batches is None and args.duration is None: + args.batches = 100 + if args.batches is not None and args.batches < 1: + parser.error('--batches must be positive') + if args.duration is not None and not 0 < args.duration < float('inf'): + parser.error('--duration must be finite and positive') + if not 0 <= args.interval < float('inf'): + parser.error('--interval must be finite and nonnegative') + run(args) + + +if __name__ == '__main__': + main() diff --git a/test/history.zig b/test/history.zig new file mode 100644 index 00000000..5ce45239 --- /dev/null +++ b/test/history.zig @@ -0,0 +1,502 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Dir = std.Io.Dir; +const schema_version = 2; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("history.zig"))}); +}; + +const Measurement = struct { + schema: u32 = 0, + harness: []const u8 = "", + zig_version: []const u8 = "", + revision: []const u8, + cwd: []const u8, + command: []const []const u8, + first_ns: u64, + median_ns: u64, + samples_ns: []const u64, + stdout: []const u8, + stderr: []const u8, + stable_stdout: bool, + stable_stderr: bool = false, + passed: bool, +}; + +const Compare = struct { + snapshots: bool = false, + benchmarks: bool = false, + max_ratio: ?f64 = null, +}; + +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const io = init.io; + const args = try init.minimal.args.toSlice(arena); + if (args.len < 2) return usage(io); + if (std.mem.eql(u8, args[1], "run")) { + const immutable = args.len > 2 and std.mem.eql(u8, args[2], "--allow-immutable"); + const pos: usize = if (immutable) 3 else 2; + if (args.len < pos + 4 or !std.mem.eql(u8, args[pos + 2], "--")) return usage(io); + try Dir.cwd().createDirPath(io, args[pos + 1]); + const output = try Dir.cwd().realPathFileAlloc(io, args[pos + 1], arena); + const exe = try std.process.executablePathAlloc(io, arena); + var command: std.ArrayList([]const u8) = .empty; + try command.appendSlice(arena, &.{ "jj", "--ignore-working-copy", "--no-integrate-operation" }); + if (immutable) try command.append(arena, "--ignore-immutable"); + try command.appendSlice(arena, &.{ "run", "--root", "-j", "1", "-r", args[pos], "--", exe, "record", output, "--" }); + try command.appendSlice(arena, args[pos + 3 ..]); + const result = try std.process.run(arena, io, .{ .argv = command.items }); + try std.Io.File.stdout().writeStreamingAll(io, result.stdout); + try std.Io.File.stderr().writeStreamingAll(io, result.stderr); + if (result.term != .exited or result.term.exited != 0) return error.HistoryRunFailed; + } else if (std.mem.eql(u8, args[1], "record")) { + if (args.len < 5 or !std.mem.eql(u8, args[3], "--")) return usage(io); + const command = args[4..]; + try Dir.cwd().createDirPath(io, args[2]); + const output = try Dir.cwd().realPathFileAlloc(io, args[2], arena); + const revision = init.environ_map.get("JJ_COMMIT_ID") orelse "working"; + const encoded_command = try std.json.Stringify.valueAlloc(arena, command, .{}); + const stem = try std.fmt.allocPrint(arena, "{s}/{s}-{x}", .{ + output, revision, std.hash.Wyhash.hash(0, encoded_command), + }); + try record(io, arena, stem, revision, command); + } else if (std.mem.eql(u8, args[1], "compare")) { + if (args.len < 4) return usage(io); + var options: Compare = .{}; + for (args[4..]) |arg| { + if (std.mem.eql(u8, arg, "--snapshots")) { + options.snapshots = true; + } else if (std.mem.eql(u8, arg, "--benchmarks")) { + options.benchmarks = true; + } else if (options.max_ratio == null) { + const ratio = std.fmt.parseFloat(f64, arg) catch return usage(io); + if (!std.math.isFinite(ratio) or ratio <= 0) return error.InvalidRatio; + options.max_ratio = ratio; + } else return usage(io); + } + if (options.snapshots and options.benchmarks) return usage(io); + var values: [2]Measurement = undefined; + for (&values, args[2..4]) |*value, path| { + const bytes = try Dir.cwd().readFileAlloc(io, path, arena, .limited(1024 * 1024)); + value.* = (try std.json.parseFromSlice(Measurement, arena, bytes, .{})).value; + } + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + const result = compare(io, arena, values, options, &report.writer); + try std.Io.File.stdout().writeStreamingAll(io, report.written()); + try result; + } else return usage(io); +} + +fn writeNew(io: std.Io, path: []const u8, bytes: []const u8) !void { + const file = try Dir.cwd().createFile(io, path, .{ .exclusive = true }); + defer file.close(io); + try file.writeStreamingAll(io, bytes); +} + +fn record(io: std.Io, arena: std.mem.Allocator, stem: []const u8, revision: []const u8, command: []const []const u8) !void { + const reservation = try std.fmt.allocPrint(arena, "{s}.reserved", .{stem}); + writeNew(io, reservation, "") catch |err| return if (err == error.PathAlreadyExists) error.MeasurementExists else err; + for ([_][]const u8{ ".json", ".0.stdout", ".0.stderr", ".1.stdout", ".1.stderr", ".2.stdout", ".2.stderr", ".3.stdout", ".3.stderr" }) |suffix| { + const path = try std.fmt.allocPrint(arena, "{s}{s}", .{ stem, suffix }); + _ = Dir.cwd().statFile(io, path, .{ .follow_symlinks = false }) catch |err| { + if (err == error.FileNotFound) continue; + return err; + }; + return error.MeasurementExists; + } + var times: [4]u64 = undefined; + var first_output: []const u8 = ""; + var first_error: []const u8 = ""; + var stable_stdout = true; + var stable_stderr = true; + var passed = true; + for (×, 0..) |*elapsed, index| { + const started = std.Io.Clock.awake.now(io); + const result = try std.process.run(arena, io, .{ + .argv = command, + .stdout_limit = .limited(64 * 1024 * 1024), + .stderr_limit = .limited(64 * 1024 * 1024), + }); + elapsed.* = @intCast(started.durationTo(std.Io.Clock.awake.now(io)).toNanoseconds()); + passed = passed and result.term == .exited and result.term.exited == 0; + if (index == 0) { + first_output = result.stdout; + first_error = result.stderr; + } else { + stable_stdout = stable_stdout and std.mem.eql(u8, first_output, result.stdout); + stable_stderr = stable_stderr and std.mem.eql(u8, first_error, result.stderr); + } + try writeNew(io, try std.fmt.allocPrint(arena, "{s}.{d}.stdout", .{ stem, index }), result.stdout); + try writeNew(io, try std.fmt.allocPrint(arena, "{s}.{d}.stderr", .{ stem, index }), result.stderr); + } + var warmed = times[1..].*; + std.mem.sort(u64, &warmed, {}, std.sort.asc(u64)); + const measurement: Measurement = .{ + .schema = schema_version, + .harness = harness_id, + .zig_version = builtin.zig_version_string, + .revision = revision, + .cwd = try std.process.currentPathAlloc(io, arena), + .command = command, + .first_ns = times[0], + .median_ns = warmed[1], + .samples_ns = ×, + .stdout = try std.fmt.allocPrint(arena, "{s}.0.stdout", .{stem}), + .stderr = try std.fmt.allocPrint(arena, "{s}.0.stderr", .{stem}), + .stable_stdout = stable_stdout, + .stable_stderr = stable_stderr, + .passed = passed, + }; + const json = try std.json.Stringify.valueAlloc(arena, measurement, .{ .whitespace = .indent_2 }); + const path = try std.fmt.allocPrint(arena, "{s}.json", .{stem}); + try writeNew(io, path, json); + const report = try std.fmt.allocPrint(arena, "{s}: first {d:.3}s, warm median {d:.3}s, passed={}\n", .{ + path, @as(f64, @floatFromInt(times[0])) / 1e9, @as(f64, @floatFromInt(warmed[1])) / 1e9, passed, + }); + try std.Io.File.stdout().writeStreamingAll(io, report); + if (!passed) return error.CommandFailed; +} + +fn compare(io: std.Io, arena: std.mem.Allocator, values: [2]Measurement, options: Compare, report: *std.Io.Writer) !void { + for (values) |value| if (value.schema != schema_version) return error.UnsupportedMeasurementVersion; + if (!std.mem.eql(u8, values[0].harness, values[1].harness) or + !std.mem.eql(u8, values[0].zig_version, values[1].zig_version)) return error.DifferentHarness; + if (!values[0].passed or !values[1].passed) return error.FailedMeasurement; + const a = try std.json.Stringify.valueAlloc(arena, values[0].command, .{}); + const b = try std.json.Stringify.valueAlloc(arena, values[1].command, .{}); + if (!std.mem.eql(u8, a, b)) return error.DifferentCommands; + const ratio = @as(f64, @floatFromInt(values[1].median_ns)) / @as(f64, @floatFromInt(@max(1, values[0].median_ns))); + const before = try Dir.cwd().readFileAlloc(io, values[0].stdout, arena, .limited(64 * 1024 * 1024)); + const after = try Dir.cwd().readFileAlloc(io, values[1].stdout, arena, .limited(64 * 1024 * 1024)); + const before_error = try Dir.cwd().readFileAlloc(io, values[0].stderr, arena, .limited(64 * 1024 * 1024)); + const after_error = try Dir.cwd().readFileAlloc(io, values[1].stderr, arena, .limited(64 * 1024 * 1024)); + const same_stdout = std.mem.eql(u8, before, after); + const same_stderr = std.mem.eql(u8, before_error, after_error); + const stable = values[0].stable_stdout and values[1].stable_stdout and values[0].stable_stderr and values[1].stable_stderr; + try report.print("warm runtime {d:.3}x; stdout {s}; stderr {s}; repeated captures stable={}\n", .{ + ratio, if (same_stdout) "identical" else "DIFFERS", if (same_stderr) "identical" else "DIFFERS", stable, + }); + if (options.snapshots and (!same_stdout or !same_stderr or !stable)) return error.SnapshotMismatch; + if (options.benchmarks) { + const before_runs = try readBenchmarkRuns(io, arena, values[0]); + const after_runs = try readBenchmarkRuns(io, arena, values[1]); + if (try compareBenchmarks(before_runs, after_runs, options.max_ratio, report)) return error.PerformanceRegression; + } + if (options.max_ratio) |limit| if (ratio > limit) return error.PerformanceRegression; +} + +const Benchmark = struct { + case: []const u8, + bytes: u64, + median_ns: u64, + cold_ns: u64, + allocations: u64, + allocated_bytes: u64, +}; + +fn readBenchmarks(arena: std.mem.Allocator, text: []const u8) ![]Benchmark { + var result: std.ArrayList(Benchmark) = .empty; + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + if (std.mem.trim(u8, line, " \t\r").len == 0) continue; + const value = (try std.json.parseFromSlice(Benchmark, arena, line, .{ .ignore_unknown_fields = true })).value; + if (value.case.len == 0) return error.EmptyBenchmarkName; + for (result.items) |old| if (std.mem.eql(u8, old.case, value.case)) return error.DuplicateBenchmark; + try result.append(arena, value); + } + if (result.items.len == 0) return error.NoBenchmarks; + return result.toOwnedSlice(arena); +} + +fn readBenchmarkRuns(io: std.Io, arena: std.mem.Allocator, value: Measurement) ![4][]Benchmark { + const suffix = ".0.stdout"; + if (value.samples_ns.len != 4 or !std.mem.endsWith(u8, value.stdout, suffix)) return error.InvalidBenchmarkCaptures; + const stem = value.stdout[0 .. value.stdout.len - suffix.len]; + var runs: [4][]Benchmark = undefined; + for (&runs, 0..) |*run, index| { + const path = try std.fmt.allocPrint(arena, "{s}.{d}.stdout", .{ stem, index }); + const bytes = try Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)); + run.* = try readBenchmarks(arena, bytes); + } + return runs; +} + +fn compareBenchmarks(before: [4][]Benchmark, after: [4][]Benchmark, max_ratio: ?f64, report: *std.Io.Writer) !bool { + const runs = [2][4][]Benchmark{ before, after }; + for (runs) |side| for (side) |run| { + if (run.len != before[0].len) return error.DifferentBenchmarks; + }; + var regressed = false; + for (before[0]) |first| { + var matched: [2][4]Benchmark = undefined; + for (runs, &matched) |side, *samples| for (side, samples) |run, *sample| { + sample.* = for (run) |value| { + if (std.mem.eql(u8, first.case, value.case)) break value; + } else return error.DifferentBenchmarks; + if (sample.bytes != first.bytes) return error.DifferentBenchmarkInputs; + }; + var medians = [2]Benchmark{ first, first }; + for (matched, &medians) |samples, *median| { + inline for (.{ "cold_ns", "median_ns", "allocations", "allocated_bytes" }) |field| { + var warm: [3]u64 = undefined; + for (samples[1..], &warm) |sample, *value| value.* = @field(sample, field); + std.mem.sort(u64, &warm, {}, std.sort.asc(u64)); + @field(median, field) = warm[1]; + } + } + const old = medians[0]; + const current = medians[1]; + const first_cold = @as(f64, @floatFromInt(matched[1][0].cold_ns)) / @as(f64, @floatFromInt(@max(1, matched[0][0].cold_ns))); + const cold = @as(f64, @floatFromInt(current.cold_ns)) / @as(f64, @floatFromInt(@max(1, old.cold_ns))); + const median = @as(f64, @floatFromInt(current.median_ns)) / @as(f64, @floatFromInt(@max(1, old.median_ns))); + const case_regression = if (max_ratio) |limit| cold > limit or median > limit else false; + regressed = regressed or case_regression; + try report.print("{s}: first cold {d:.3}x; cold {d:.3}x; median {d:.3}x; allocations {d}->{d}; bytes {d}->{d}{s}\n", .{ + old.case, + first_cold, + cold, + median, + old.allocations, + current.allocations, + old.allocated_bytes, + current.allocated_bytes, + if (case_regression) " REGRESSION" else "", + }); + } + return regressed; +} + +fn usage(io: std.Io) !void { + try std.Io.File.stderr().writeStreamingAll(io, "history run [--allow-immutable] REVSET OUT -- COMMAND...\n" ++ + "history record OUT -- COMMAND...\n" ++ + "history compare BEFORE.json AFTER.json [MAX_RATIO] [--snapshots|--benchmarks]\n" ++ + "run uses isolated jj workspaces without integrating history changes.\n" ++ + "Commands still have normal filesystem and network access.\n"); + return error.InvalidArguments; +} + +test "history output collisions preserve completed and partial recordings before command dispatch" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const gpa = arena.allocator(); + const io = std.testing.io; + for ([_][]const u8{ ".reserved", ".json", ".0.stdout", ".0.stderr", ".1.stdout", ".1.stderr", ".2.stdout", ".2.stderr", ".3.stdout", ".3.stderr" }) |suffix| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const output = try tmp.dir.realPathFileAlloc(io, ".", gpa); + const stem = try std.fmt.allocPrint(gpa, "{s}/measurement", .{output}); + const path = try std.fmt.allocPrint(gpa, "{s}{s}", .{ stem, suffix }); + try writeNew(io, path, "previous recording\n"); + const missing = try std.fmt.allocPrint(gpa, "{s}/must-not-be-dispatched", .{output}); + try std.testing.expectError(error.MeasurementExists, record(io, gpa, stem, "fixture", &.{missing})); + const preserved = try Dir.cwd().readFileAlloc(io, path, gpa, .limited(1024)); + try std.testing.expectEqualStrings("previous recording\n", preserved); + try std.testing.expectError(error.MeasurementExists, record(io, gpa, stem, "fixture", &.{missing})); + } +} + +const measurement_fixture: Measurement = .{ + .schema = schema_version, + .harness = harness_id, + .zig_version = builtin.zig_version_string, + .revision = "fixture", + .cwd = "", + .command = &.{ "fixture", "--argument" }, + .first_ns = 100, + .median_ns = 100, + .samples_ns = &.{ 100, 100, 100, 100 }, + .stdout = "", + .stderr = "", + .stable_stdout = true, + .stable_stderr = true, + .passed = true, +}; + +test "history rejects incomparable and failed measurements before reading captures" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + var values = [2]Measurement{ measurement_fixture, measurement_fixture }; + + values[1].schema = 0; + try std.testing.expectError(error.UnsupportedMeasurementVersion, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[0].schema = schema_version + 1; + try std.testing.expectError(error.UnsupportedMeasurementVersion, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[0] = measurement_fixture; + values[1].harness = "other harness"; + try std.testing.expectError(error.DifferentHarness, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].zig_version = "other compiler"; + try std.testing.expectError(error.DifferentHarness, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].command = &.{"fixture --argument"}; + try std.testing.expectError(error.DifferentCommands, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].passed = false; + try std.testing.expectError(error.FailedMeasurement, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[0].passed = false; + try std.testing.expectError(error.FailedMeasurement, compare(std.testing.io, arena, values, .{}, &report.writer)); + try std.testing.expectEqualStrings("", report.written()); +} + +const benchmark_fixture = + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"last","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} +; + +test "history capture and performance gates reject false passes" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "before.{d}.stdout", .{run}); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = benchmark_fixture }); + } + try tmp.dir.writeFile(io, .{ .sub_path = "stderr", .data = "diagnostic\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "changed.0.stdout", .data = "changed\n" }); + var original = measurement_fixture; + original.stdout = try tmp.dir.realPathFileAlloc(io, "before.0.stdout", arena); + original.stderr = try tmp.dir.realPathFileAlloc(io, "stderr", arena); + const changed = try tmp.dir.realPathFileAlloc(io, "changed.0.stdout", arena); + var values = [2]Measurement{ original, original }; + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + + try compare(io, arena, values, .{ .snapshots = true }, &report.writer); + values[1].stdout = changed; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + values[1] = original; + values[1].stderr = changed; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + for (0..2) |index| { + values = .{ original, original }; + values[index].stable_stdout = false; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + values[index].stable_stdout = true; + values[index].stable_stderr = false; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + try compare(io, arena, values, .{}, &report.writer); + } + values = .{ original, original }; + values[1].median_ns = 126; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .max_ratio = 1.25 }, &report.writer)); + values[1].median_ns = 125; + try compare(io, arena, values, .{ .max_ratio = 1.25 }, &report.writer); + + const slower_cold = + \\{"case":"last","bytes":20,"median_ns":50,"cold_ns":50,"allocations":5,"allocated_bytes":500} + \\{"case":"first","bytes":10,"median_ns":50,"cold_ns":126,"allocations":5,"allocated_bytes":500} + ; + const slower_warm = + \\{"case":"first","bytes":10,"median_ns":126,"cold_ns":50,"allocations":5,"allocated_bytes":500} + \\{"case":"last","bytes":20,"median_ns":50,"cold_ns":50,"allocations":5,"allocated_bytes":500} + ; + values[1].median_ns = 50; + values[1].stdout = changed; + for ([_][]const u8{ slower_cold, slower_warm }) |text| { + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "changed.{d}.stdout", .{run}); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = text }); + } + const begin = report.written().len; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); + const output = report.written()[begin..]; + try std.testing.expect(std.mem.indexOf(u8, output, "warm runtime 0.500x") != null); + try std.testing.expect(std.mem.indexOf(u8, output, " REGRESSION\nlast:") != null); + try compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.26 }, &report.writer); + try compare(io, arena, values, .{ .benchmarks = true }, &report.writer); + } + values[1] = original; + values[1].median_ns = 200; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); +} + +test "history benchmark cases are nonempty unique and matched by name" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + try std.testing.expectError(error.NoBenchmarks, readBenchmarks(arena, " \n\t\n")); + try std.testing.expectError(error.DuplicateBenchmark, readBenchmarks(arena, benchmark_fixture ++ "\n" ++ benchmark_fixture)); + try std.testing.expectError(error.MissingField, readBenchmarks(arena, "{\"case\":\"first\"}")); + const before = try readBenchmarks(arena, benchmark_fixture); + const missing = try readBenchmarks(arena, "{\"case\":\"first\",\"bytes\":10,\"median_ns\":100,\"cold_ns\":100,\"allocations\":10,\"allocated_bytes\":1000}"); + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, .{missing} ** 4, null, &report.writer)); + const renamed = + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"renamed","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + ; + const different = try readBenchmarks(arena, renamed); + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, .{different} ** 4, null, &report.writer)); + const reordered = + \\{"case":"last","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + ; + const same = try readBenchmarks(arena, reordered); + try std.testing.expect(!try compareBenchmarks(.{before} ** 4, .{same} ** 4, 1, &report.writer)); + var samples = [4][]Benchmark{ before, before, before, different }; + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, samples, null, &report.writer)); + samples[3] = try arena.dupe(Benchmark, before); + samples[3][0].bytes += 1; + try std.testing.expectError(error.DifferentBenchmarkInputs, compareBenchmarks(.{before} ** 4, samples, null, &report.writer)); +} + +test "history benchmark gate uses repeated process captures rather than the first process alone" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var values = [2]Measurement{ measurement_fixture, measurement_fixture }; + const root = try tmp.dir.realPathFileAlloc(io, ".", arena); + for (&values, 0..) |*value, side| { + value.stdout = try std.fmt.allocPrint(arena, "{s}/{d}.0.stdout", .{ root, side }); + value.stderr = try std.fmt.allocPrint(arena, "{s}/{d}.0.stderr", .{ root, side }); + try writeNew(io, value.stderr, ""); + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "{s}/{d}.{d}.stdout", .{ root, side, run }); + const duration: u64 = if (side == 1 and run != 0) 150 else 100; + const line = try std.fmt.allocPrint( + arena, + "{{\"case\":\"one\",\"bytes\":10,\"median_ns\":{d},\"cold_ns\":{d},\"allocations\":1,\"allocated_bytes\":10}}\n", + .{ duration, duration }, + ); + try writeNew(io, path, line); + } + } + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); + try std.testing.expect(std.mem.indexOf(u8, report.written(), "first cold 1.000x; cold 1.500x; median 1.500x") != null); + for ([_]u64{ 1000, 1000, 100, 100 }, 0..) |duration, run| { + const path = try std.fmt.allocPrint(arena, "1.{d}.stdout", .{run}); + const line = try std.fmt.allocPrint( + arena, + "{{\"case\":\"one\",\"bytes\":10,\"median_ns\":{d},\"cold_ns\":{d},\"allocations\":1,\"allocated_bytes\":10}}\n", + .{ duration, duration }, + ); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = line }); + } + const begin = report.written().len; + try compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer); + try std.testing.expect(std.mem.indexOf(u8, report.written()[begin..], "first cold 10.000x; cold 1.000x; median 1.000x") != null); + try tmp.dir.deleteFile(io, "1.3.stdout"); + try std.testing.expectError(error.FileNotFound, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); + values[1].samples_ns = &.{ 100, 100, 100 }; + try std.testing.expectError(error.InvalidBenchmarkCaptures, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); + values[1].samples_ns = measurement_fixture.samples_ns; + values[1].stdout = values[1].stderr; + try std.testing.expectError(error.InvalidBenchmarkCaptures, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); +} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 1a6fd269..39a6a7c9 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -1,13 +1,13 @@ -{"name":"ctrl-b-page","reason":"not a parity bug: Ctrl-b IS pardes's tty toggle (Options.tty_toggle = 'b'), so on a shell pane it enters raw tty mode instead of paging the view. Deliberate binding, same class as the alt-c-window-op waiver in the helix suite; Ctrl-u/Ctrl-d and PageUp cover half/full page scrolling on terminals."} -{"name":"ctrl-f-page","reason":"viewport geometry: a file pane's view scrolls anywhere inside its line count, a terminal's is the vt's and cannot move below the live grid bottom, so a full-page move clamps and the cursor snaps into a different scrolloff band. The edit model is not involved - no text differs, only where the view landed."} -{"name":"ctrl-f-small","reason":"viewport geometry: same as ctrl-f-page with a document shorter than the pane, where the terminal's view cannot scroll at all and the cursor snaps to the top scrolloff margin instead of the last line."} -{"name":"pgdn-basic","reason":"viewport geometry: PageDown is the same view scroll as Ctrl-f, and clamps at the terminal's live grid bottom. Text identical, view position differs."} -{"name":"z-pgdn-alias","reason":"viewport geometry: z-prefixed PageDown aliases the Ctrl-f view scroll, so it clamps at the terminal's live grid bottom exactly like pgdn-basic."} -{"name":"ins-pgdn","reason":"viewport geometry: insert-mode PageDown is bound to the same view scroll as normal mode, so it clamps at the terminal's live grid bottom. Text identical, view position differs."} -{"name":"zj-snaps-cursor","reason":"viewport geometry: zj scrolls the view one row and snaps the cursor back into the scrolloff band; on a terminal whose view is already at the vt's bottom the scroll is a no-op, so the cursor snaps from a different band."} -{"name":"zj-noop-in-band","reason":"viewport geometry: same as zj-snaps-cursor - the terminal's view cannot scroll past the live grid bottom, so the cursor's band differs by one row."} -{"name":"zk-bottom-snap","reason":"viewport geometry: zk scrolls the view up one row and snaps the cursor; the terminal's starting view offset differs because it could not scroll as far down in the first place."} -{"name":"unindent-tab","reason":"the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and never the tab the file pane unindents. Not an editing divergence - unindent-basic, unindent-partial, unindent-realign and unindent-cursor all match; only a byte a terminal cannot hold verbatim."} -{"name": "comment-tab-indent", "reason": "the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and the comment token goes in at a different column. Exactly the unindent-tab waiver's class - not an editing divergence; comment-indent, comment-uncomment-deep and the rest of the comment-* family match."} -{"name": "comment-noeol", "reason": "the case text is not representable in a pty pane: it is the one case in the corpus with NO trailing newline (that is the point - it pins helix's last-line handling), and the tty harness re-adds the newline ghostty's dump trims off the cursor row. The comment itself is identical on both sides; only the missing final byte differs."} -{"name": "comment-only-blank", "reason": "the case text is not representable in a pty pane: a literal TAB (unindent-tab's class) plus an all-whitespace last row, which the terminal dump trims off entirely. Both panes correctly change nothing - toggle_comments skips blank lines - so the only difference is what the pty could hold in the first place."} +{"name":"ctrl-b-page","reason":"Ctrl-b enters raw terminal mode instead of paging the view.","reference":{"name":"ctrl-b-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":18,"col":0},"anchor":{"row":18,"col":0}},"expected":{"name":"ctrl-b-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"tty","cursor":{"row":20,"col":0},"anchor":{"row":20,"col":0}}} +{"name":"ctrl-f-page","reason":"Terminal viewports cannot scroll below the live grid.","reference":{"name":"ctrl-f-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"ctrl-f-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"ctrl-f-small","reason":"Terminal viewports clamp short documents to the live grid.","reference":{"name":"ctrl-f-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}},"expected":{"name":"ctrl-f-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}}} +{"name":"pgdn-basic","reason":"Terminal page scrolling clamps to the live grid.","reference":{"name":"pgdn-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"pgdn-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"z-pgdn-alias","reason":"Terminal page scrolling clamps to the live grid.","reference":{"name":"z-pgdn-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"z-pgdn-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"ins-pgdn","reason":"Terminal insert-mode page scrolling clamps to the live grid.","reference":{"name":"ins-pgdn","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"ins-pgdn","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"zj-snaps-cursor","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"zj-snaps-cursor","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},"expected":{"name":"zj-snaps-cursor","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"zj-noop-in-band","reason":"Scrolling at the terminal grid bottom is a no-op.","reference":{"name":"zj-noop-in-band","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},"expected":{"name":"zj-noop-in-band","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}}} +{"name":"zk-bottom-snap","reason":"Terminal view offsets start at the live grid's scroll boundary.","reference":{"name":"zk-bottom-snap","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":19,"col":0},"anchor":{"row":19,"col":0}},"expected":{"name":"zk-bottom-snap","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":20,"col":0},"anchor":{"row":20,"col":0}}} +{"name":"unindent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"unindent-tab","text":"x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"unindent-tab","text":" x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"comment-tab-indent","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"comment-tab-indent","text":"\t# alpha\n\t# beta\n","mode":"normal","cursor":{"row":1,"col":7},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-tab-indent","text":" # alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":14},"anchor":{"row":0,"col":0}}} +{"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} +{"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} diff --git a/test/hxcases/regen.sh b/test/hxcases/regen.sh deleted file mode 100755 index 308cc9d2..00000000 --- a/test/hxcases/regen.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# Regenerate test/hxcases/goldens.jsonl by running the helix reference harness -# over cases.jsonl. The harness is hx-harness on the pardes-harness branch of -# the genizah helix checkout (see docs/helix-keys.md, "Differential testing"): -# cd ~/05-genizah/helix && git switch pardes-harness && \ -# cargo build --release -p helix-term --features helix-term/integration --bin hx-harness -# Override the binary with $HX_HARNESS. helix ignores pane:"tty" — a tty -# twin's golden is helix on the same text, which IS the oracle for tty -# navigation parity. -set -eu -cd "$(dirname "$0")" -HX_HARNESS="${HX_HARNESS:-$HOME/05-genizah/helix/target/release/hx-harness}" -[ -x "$HX_HARNESS" ] || { echo "regen.sh: no hx-harness at $HX_HARNESS (set \$HX_HARNESS)" >&2; exit 1; } -"$HX_HARNESS" cases.jsonl > goldens.jsonl.tmp -mv goldens.jsonl.tmp goldens.jsonl -wc -l goldens.jsonl diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index ca1b8ec5..82da2a08 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,6 +1,6 @@ -{"name":"wiX-edit-drops-sel","reason":"helix maps the selection through every insert-mode edit; pardes drops it on the first edit instead (anchor-only divergence, text+cursor+mode match). Re-judged phase 5 and kept: mapping the anchor through all ~23 mutation sites in handleInsert (multi-line inserts, EOL joins, kill runs) is genuinely invasive, and nothing in pardes consumes an implicit selection after insert-mode typing - the acme chords need explicit selections, which never enter insert."} -{"name":"alt-c-window-op","reason":"deliberate pardes binding: Alt-c is the move-pane-to-fresh-column window op in ANY mode (do-not-touch contract); helix Alt-c is change-noyank. Alt-d + i covers the helix behavior; the buffer is untouched on the pardes side."} -{"name":"msel-append","reason":"multiple cursors + `a`: pardes restores the appended-over span for the PRIMARY only, because Pane.append_at (where the append session began) is a single field and the other ranges have nowhere to keep their own origin. Every cursor lands on the right cell and the text matches; only the secondaries' anchors differ, so they read as bare cursors instead of spanning what was typed. Same family as wiX-edit-drops-sel: pardes does not map selections through insert-mode edits. Fix = an origin per SelRange."} -{"name":"msel-yank-paste","reason":"pardes has ONE yank register; helix's holds one VALUE PER RANGE and pastes value[i] back at range[i]. A multi-range `y` in pardes therefore joins the ranges' text with newlines and `p` puts the whole newline-joined blob at every cursor (which also makes it linewise). Deliberate: a per-range register is its own feature, not part of the selection model. Single-range yank/paste is unaffected and covered by the yank-* / paste-* cases."} -{"name": "sel-regex-dot-newline", "reason": "mvzr's `.` matches ANY byte, newlines included; helix builds its pattern with the Rust regex crate, where `.` excludes \\n unless asked. So `%s.` selects one range per byte in pardes and one per non-newline byte in helix. Deliberate: closing it means rewriting `.` to `[^\\n]` inside the pattern, and doing that correctly needs a real parse (`\\.` is a literal dot, a `.` inside `[...]` is already literal) - a regex parser written to work around the regex engine. `[^\\n]` typed at the prompt LOOKS like the workaround and is not one: the live preview compiles every prefix, and the prefix `[^\\` panics mvzr (index out of bounds in parseCharSet) before the pattern can be finished. Guarding the compile is what would make it typable."} -{"name": "sel-regex-caret", "reason": "helix compiles the `s`/`S` pattern with multi_line(true), so `^` and `$` are LINE anchors; mvzr has no such flag and asserts them against the START OF THE SLICE it is handed, which pardes advances to each match's end. `%s^a` therefore finds one match in pardes and one per line in helix. Same root cause for `$`. Fixing it means either searching line by line - which changes which ranges come out for every OTHER pattern, since a match may not span a line then - or an engine with multi-line assertions. Every other anchor-free pattern agrees, including \\b (sel-regex-boundary)."} +{"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}} +{"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}} +{"name":"msel-yank-paste","reason":"Pardes has one combined yank register, not one value per selection.","reference":{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-yank-paste","text":"aa\ncb\nca\ncd\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1}}],"primary":1}} +{"name":"sel-regex-dot-newline","reason":"The regex engine includes newlines in dot matches.","reference":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}],"primary":0},"expected":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}],"primary":0}} +{"name":"sel-regex-caret","reason":"The regex engine lacks Helix's multiline anchors.","reference":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0},"expected":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 94ee226e..22d8067d 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -1,62 +1,7 @@ -// pardes-hxdiff: the pardes half of the helix differential harness. Drives -// the core (src/pardes.zig) headlessly over JSON-Lines cases and -// prints one contract result line per case on stdout, input order — the same -// case/result contract helix's hx-harness speaks (pardes-harness branch in -// the genizah checkout), so the two editors diff key-for-key. -// -// case: {"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"} -// result: {"name":"w-basic","text":"alpha beta\n","mode":"normal", -// "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} -// -// MULTIPLE CURSORS extend that line with two more fields, and only when there -// is more than one selection: -// -// ...,"sels":[{"cursor":…,"anchor":…},…],"primary":1 -// -// `sels` is EVERY range in document order, each end measured exactly like the -// `cursor`/`anchor` pair above, and `primary` is the index of the one those -// two fields describe. Omitted entirely at one selection — which is why every -// golden written before multiple cursors existed is still byte-for-byte valid. -// -// zig build hxdiff (diff the checked-in corpus) -// zig build hxdiff -- <cases.jsonl> (results to stdout, no diff) -// zig build hxparity (file-vs-pty parity, both corpora) -// ./zig-out/bin/pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]] -// ./zig-out/bin/pardes-hxdiff --parity [--waivers w.jsonl] <cases.jsonl>... -// -// DIFF MODE (goldens given): every result is compared field-by-field -// (text/mode/cursor/anchor) against the same-named golden line. Waivers -// ({"name":...,"reason":...}) exempt named cases — each must carry a reason. -// Any unwaivered mismatch prints a per-case report and exits 1. -// -// PARITY MODE (--parity): no goldens at all. Each case is run TWICE, once in -// a file pane and once in a pty pane, over the same starting text and the -// same keys, and the two result lines must be identical. That is the whole -// contract of "editing a shell pane behaves like editing a text file": the -// oracle is the file pane itself, so it cannot drift. The case's own "pane" -// field is ignored, and SEVERAL case files may be given — the gate runs the -// whole helix corpus, not just the editing cases, because a parity suite -// that only covers what its author remembered cannot catch the next -// regression. Divergences that are not editing (pardes key bindings, pty -// viewport geometry, text a terminal cannot hold verbatim) are waived by -// name in test/hxcases/parity-waivers.jsonl, each with its reason. -// -// Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body -// rows (topbar + tag take two; helix: statusline + commandline take two). -// pane "file" swaps the boot pane for a file pane holding the case text -// (Pardes.hxOpenFileContent, the test-only entry). pane "tty" keeps the -// tty_only boot shell — no real pty, the spawn effect is ignored — feeds the -// text as pty output with \n cooked to \r\n (ONLCR), then leaves tty mode -// with Ctrl-<tty_toggle> exactly like a live session and parks the cursor at -// (0,0), mirroring the helix harness's initial Selection::single(0,1). The -// final tty text is the motion surface (paneCursorLines) re-joined, plus the -// trailing '\n' ghostty's dump trims off the cursor's empty row — the case -// text always ends in one, so this restores byte-for-byte comparability. -// Keys are helix notation: printables verbatim, <esc> <ret> <tab> <space> -// <backspace> <del> <up/down/left/right> <home/end> <pageup/pagedown> -// <C-x> <A-x> <lt> <gt>. +// JSONL cases and results shared with Helix's hx-harness. +// Default mode compares named goldens; --parity compares file and terminal panes. +// --strict checks complete corpus coverage. Waivers pin both sides of a known difference. const std = @import("std"); -const libc = std.c; const pardes = @import("pardes"); // Its own root, so src/main.zig's logFn does not apply here: the core it @@ -86,63 +31,92 @@ pub fn main(init: std.process.Init) !void { defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len == 2 and std.mem.eql(u8, args[1], "--self-test")) return testCli(init); var parity = false; + var verbose = false; + var strict = false; + var pane_override: ?[]const u8 = null; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; - const case_paths = arena.alloc([]const u8, args.len - 1) catch fatal("oom", .{}); - var n_case_paths: usize = 0; + const paths = try arena.alloc([]const u8, args.len - 1); + var n_paths: usize = 0; var want_waivers = false; for (args[1..]) |a| { if (want_waivers) { + if (std.mem.startsWith(u8, a, "--")) return error.MissingWaiverPath; waivers_path = a; want_waivers = false; } else if (std.mem.eql(u8, a, "--parity")) { parity = true; + } else if (std.mem.eql(u8, a, "--verbose")) { + verbose = true; + } else if (std.mem.eql(u8, a, "--strict")) { + strict = true; + } else if (std.mem.startsWith(u8, a, "--pane=")) { + const kind = a["--pane=".len..]; + if (!std.mem.eql(u8, kind, "file") and !std.mem.eql(u8, kind, "tty")) return error.UnknownPane; + pane_override = kind; } else if (std.mem.eql(u8, a, "--waivers")) { + if (waivers_path != null) return error.DuplicateWaiverPath; want_waivers = true; - } else if (parity or n_case_paths == 0) { - // diff mode is positional (cases, goldens, waivers); parity mode - // takes any number of case files and nothing else - case_paths[n_case_paths] = a; - n_case_paths += 1; - } else if (goldens_path == null) { - goldens_path = a; - } else if (waivers_path == null) { - waivers_path = a; - } else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{}); + } else if (std.mem.startsWith(u8, a, "--")) { + return error.UnknownOption; + } else { + paths[n_paths] = a; + n_paths += 1; + } } - if (n_case_paths == 0) + if (n_paths == 0 or want_waivers) fatal("usage: pardes-hxdiff [--parity] <cases.jsonl>... [goldens.jsonl [waivers.jsonl]]", .{}); + const case_paths = paths[0..if (parity) n_paths else 1]; + if (!parity) { + if (n_paths > 3) return error.TooManyPaths; + if (n_paths >= 2) goldens_path = paths[1]; + if (n_paths == 3) { + if (waivers_path != null) return error.DuplicateWaiverPath; + waivers_path = paths[2]; + } + } + if (parity and pane_override != null) return error.ConflictingPaneModes; + if (!parity and goldens_path == null and waivers_path != null) return error.WaiversNeedComparison; // goldens + waivers, keyed by name (diff mode) var goldens: std.StringHashMap(Result) = .init(arena); if (goldens_path) |gp| { - const gsrc = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) }); + const gsrc = std.Io.Dir.cwd().readFileAlloc(init.io, gp, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) }); var glines = std.mem.splitScalar(u8, gsrc, '\n'); var lno: usize = 0; while (glines.next()) |raw| { lno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) }); - goldens.put(g.name, g) catch fatal("oom", .{}); + try validateResult(g); + const entry = try goldens.getOrPut(g.name); + if (g.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate golden name {s}", .{ gp, lno, g.name }); + entry.value_ptr.* = g; } } - var waivers: std.StringHashMap([]const u8) = .init(arena); + var waivers: std.StringHashMap(Waiver) = .init(arena); if (waivers_path) |wp| { - const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) }); + const wsrc = std.Io.Dir.cwd().readFileAlloc(init.io, wp, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) }); var wlines = std.mem.splitScalar(u8, wsrc, '\n'); var lno: usize = 0; while (wlines.next()) |raw| { lno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad waiver line: {s}", .{ wp, lno, @errorName(e) }); if (w.reason.len == 0) fatal("{s}:{d}: waiver {s} MUST carry a reason", .{ wp, lno, w.name }); - waivers.put(w.name, w.reason) catch fatal("oom", .{}); + try validateResult(w.expected); + try validateResult(w.reference); + if (!std.mem.eql(u8, w.name, w.expected.name) or !std.mem.eql(u8, w.name, w.reference.name)) return error.WaiverNameMismatch; + const entry = try waivers.getOrPut(w.name); + if (w.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate waiver name {s}", .{ wp, lno, w.name }); + entry.value_ptr.* = w; } } @@ -150,51 +124,62 @@ pub fn main(init: std.process.Init) !void { var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; - for (case_paths[0..n_case_paths]) |path| { - const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); + var names: std.StringHashMap(void) = .init(arena); + for (case_paths) |path| { + const src = std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); var lines = std.mem.splitScalar(u8, src, '\n'); var lineno: usize = 0; while (lines.next()) |raw| { lineno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) }); + const entry = try names.getOrPut(case.name); + if (case.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate case name {s}", .{ path, lineno, case.name }); n_cases += 1; - if (parity) { - // the file pane IS the oracle: same text, same keys, both kinds - const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) }); - const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) }); - const diff = diffResult(arena, want, got, "file", "pty"); - if (diff.len == 0) continue; - if (waivers.get(case.name)) |reason| { - n_waived += 1; - std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); - continue; - } - n_bad += 1; - std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); - continue; - } - const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); - if (goldens_path == null) { - // null sels/primary are DROPPED, not written: that is what - // keeps a one-selection line identical to the old contract - const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); - try stdout.writeStreamingAll(init.io, jl); + const reference: ?Result = if (parity) + try runCase(arena, case, "file") + else if (goldens_path != null) + goldens.get(case.name) orelse fatal("no golden for case {s}", .{case.name}) + else + null; + const actual = try runCase(arena, case, if (parity) "tty" else pane_override orelse case.pane); + try validateResult(actual); + const want = reference orelse { + const row = try std.json.Stringify.valueAlloc(arena, actual, .{ .emit_null_optional_fields = false }); + try stdout.writeStreamingAll(init.io, row); try stdout.writeStreamingAll(init.io, "\n"); continue; - } - const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name}); - const diff = diffResult(arena, g, res, "helix", "pardes"); - if (diff.len == 0) continue; - if (waivers.get(case.name)) |reason| { - n_waived += 1; - std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); + }; + try validateResult(want); + const reference_name = if (parity) "file" else "helix"; + const actual_name = if (parity) "pty" else "pardes"; + const difference = try diffResult(arena, want, actual, reference_name, actual_name); + if (difference.len == 0) { + if (strict and waivers.contains(case.name)) fatal("stale waiver: {s}", .{case.name}); continue; } + if (waivers.get(case.name)) |waiver| { + const reference_change = try diffResult(arena, waiver.reference, want, "waiver", reference_name); + const actual_change = try diffResult(arena, waiver.expected, actual, "waiver", actual_name); + if (reference_change.len == 0 and actual_change.len == 0) { + n_waived += 1; + if (verbose) std.debug.print("WAIVED {s}: {s}\n", .{ case.name, waiver.reason }); + continue; + } + std.debug.print("CHANGED WAIVER {s}\n{s}{s}", .{ case.name, reference_change, actual_change }); + } n_bad += 1; - std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); + std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, difference }); + } + } + if (n_cases == 0) return error.NoCases; + if (strict) { + if (goldens_path != null and n_cases != goldens.count()) return error.UnusedReferenceCases; + var entries = waivers.keyIterator(); + while (entries.next()) |name| { + if (!names.contains(name.*)) fatal("unused waiver: {s}", .{name.*}); } } if (!parity and goldens_path == null) return; @@ -229,91 +214,155 @@ const Result = struct { const Waiver = struct { name: []const u8, reason: []const u8, + reference: Result, + expected: Result, }; -/// field-by-field compare; returns a human-readable report ("" = match). -/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty). -fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 { - var parts: [5][]const u8 = undefined; - var n: usize = 0; - if (!std.mem.eql(u8, want.text, got.text)) { - parts[n] = std.fmt.allocPrint(arena, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch ""; - n += 1; - } - if (!std.mem.eql(u8, want.mode, got.mode)) { - parts[n] = std.fmt.allocPrint(arena, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch ""; - n += 1; - } - if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }) catch ""; - n += 1; +fn validateResult(result: Result) !void { + if (result.name.len == 0) return error.EmptyCaseName; + if (!std.mem.eql(u8, result.mode, "normal") and !std.mem.eql(u8, result.mode, "insert") and + !std.mem.eql(u8, result.mode, "select") and !std.mem.eql(u8, result.mode, "tty")) return error.UnknownMode; + try validatePosition(result.text, result.cursor); + try validatePosition(result.text, result.anchor); + const sels = result.sels orelse { + if (result.primary != null) return error.PrimaryWithoutSelections; + return; + }; + if (sels.len < 2) return error.NotMultipleSelections; + const primary = result.primary orelse return error.MissingPrimary; + if (primary >= sels.len) return error.InvalidPrimary; + if (!std.meta.eql(sels[primary].cursor, result.cursor) or + !std.meta.eql(sels[primary].anchor, result.anchor)) return error.PrimaryMismatch; + for (sels) |selection| { + try validatePosition(result.text, selection.cursor); + try validatePosition(result.text, selection.anchor); } - if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { - parts[n] = std.fmt.allocPrint(arena, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }) catch ""; - n += 1; +} + +fn validatePosition(text: []const u8, position: Pos) !void { + if (position.row < 0 or position.col < 0) return error.InvalidPosition; + var lines = std.mem.splitScalar(u8, text, '\n'); + var row: usize = 0; + while (lines.next()) |line| : (row += 1) { + if (row != @as(usize, @intCast(position.row))) continue; + if (@as(usize, @intCast(position.col)) > line.len) return error.InvalidPosition; + return; } - // the whole selection: absent on both sides = one range, and the two - // fields above already said everything about it - const ws = want.sels orelse &.{}; - const gs = got.sels orelse &.{}; - var sel_diff = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); - if (!sel_diff) for (ws, gs) |a, b| { - if (a.cursor.row != b.cursor.row or a.cursor.col != b.cursor.col or - a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; + return error.InvalidPosition; +} + +test "differential result positions include byte-length gaps and the empty EOF row" { + const cases = [_]struct { text: []const u8, position: Pos, valid: bool }{ + .{ .text = "", .position = .{ .row = 0, .col = 0 }, .valid = true }, + .{ .text = "", .position = .{ .row = 0, .col = 1 }, .valid = false }, + .{ .text = "abc", .position = .{ .row = 0, .col = 3 }, .valid = true }, + .{ .text = "abc", .position = .{ .row = 1, .col = 0 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 0, .col = 3 }, .valid = true }, + .{ .text = "abc\n", .position = .{ .row = 1, .col = 0 }, .valid = true }, + .{ .text = "abc\n", .position = .{ .row = 1, .col = 1 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 2, .col = 0 }, .valid = false }, + .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 9 }, .valid = true }, + .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 10 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = -1, .col = 0 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 0, .col = -1 }, .valid = false }, }; - if (sel_diff) { - parts[n] = std.fmt.allocPrint(arena, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }) catch ""; - n += 1; + for (cases) |case| { + const result: Result = .{ + .name = "position", + .text = case.text, + .mode = "normal", + .cursor = case.position, + .anchor = case.position, + }; + if (case.valid) try validateResult(result) else try std.testing.expectError(error.InvalidPosition, validateResult(result)); } - return concatParts(arena, parts[0..n]); } -/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to -/// one pair when they are the same cell), the primary starred -fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { - if (sels.len == 0) return "<one>"; - var part_count: usize = 2; - for (sels, 0..) |s, i| { - part_count += 1; - if (i > 0) part_count += 1; - if (i == (primary orelse 0)) part_count += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) part_count += 1; - } - const parts = arena.alloc([]const u8, part_count) catch return ""; - var n: usize = 0; - parts[n] = std.fmt.allocPrint(arena, "{d} of [", .{sels.len}) catch ""; - n += 1; - for (sels, 0..) |s, i| { - if (i > 0) { - parts[n] = " "; - n += 1; - } - if (i == (primary orelse 0)) { - parts[n] = "*"; - n += 1; - } - parts[n] = std.fmt.allocPrint(arena, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }) catch ""; - n += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }) catch ""; - n += 1; +test "differential result positions validate secondary selections and anchors" { + const start: Pos = .{ .row = 0, .col = 0 }; + const end: Pos = .{ .row = 1, .col = 0 }; + var selections = [_]Sel{ + .{ .cursor = start, .anchor = start }, + .{ .cursor = end, .anchor = start }, + }; + var result: Result = .{ + .name = "multiple-positions", + .text = "λ界😀\n", + .mode = "normal", + .cursor = start, + .anchor = start, + .sels = &selections, + .primary = 0, + }; + try validateResult(result); + selections[1].cursor.col = 1; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); + selections[1].cursor = end; + selections[1].anchor.row = -1; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); + selections[1].anchor = start; + result.anchor.col = 10; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); +} + +fn diffResult(allocator: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + if (!std.mem.eql(u8, want.text, got.text)) + out.writer.print(" text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch return error.OutOfMemory; + if (!std.mem.eql(u8, want.mode, got.mode)) + out.writer.print(" mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch return error.OutOfMemory; + if (!std.meta.eql(want.cursor, got.cursor)) + out.writer.print(" cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.cursor.row, want.cursor.col, gl, got.cursor.row, got.cursor.col }) catch return error.OutOfMemory; + if (!std.meta.eql(want.anchor, got.anchor)) + out.writer.print(" anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.anchor.row, want.anchor.col, gl, got.anchor.row, got.anchor.col }) catch return error.OutOfMemory; + const ws = want.sels orelse &.{}; + const gs = got.sels orelse &.{}; + var selections_differ = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); + if (!selections_differ) for (ws, gs) |a, b| { + if (!std.meta.eql(a, b)) { + selections_differ = true; + break; } - } - parts[n] = "]"; - n += 1; - return concatParts(arena, parts[0..n]); + }; + if (selections_differ) + out.writer.print(" sels: {s} {f} primary={d} != {s} {f} primary={d}\n", .{ wl, std.json.fmt(ws, .{}), want.primary orelse 0, gl, std.json.fmt(gs, .{}), got.primary orelse 0 }) catch return error.OutOfMemory; + return out.toOwnedSlice(); } -fn concatParts(arena: std.mem.Allocator, parts: []const []const u8) []const u8 { - var len: usize = 0; - for (parts) |part| len = std.math.add(usize, len, part.len) catch return ""; - const out = arena.alloc(u8, len) catch return ""; - var offset: usize = 0; - for (parts) |part| { - @memcpy(out[offset..][0..part.len], part); - offset += part.len; - } - return out; +test "differential reports compare values and report every changed field" { + const allocator = std.testing.allocator; + const want: Result = .{ + .name = "case", + .text = "one\n", + .mode = "normal", + .cursor = .{ .row = 0, .col = 0 }, + .anchor = .{ .row = 0, .col = 1 }, + }; + var got = want; + got.text = try allocator.dupe(u8, want.text); + defer allocator.free(got.text); + const same = try diffResult(allocator, want, got, "want", "got"); + defer allocator.free(same); + try std.testing.expectEqualStrings("", same); + + const changed: Result = .{ + .name = "case", + .text = "two\n", + .mode = "insert", + .cursor = .{ .row = 1, .col = 2 }, + .anchor = .{ .row = 1, .col = 0 }, + .sels = &.{.{ .cursor = .{ .row = 1, .col = 2 }, .anchor = .{ .row = 1, .col = 0 } }}, + }; + const Check = struct { + fn run(a: std.mem.Allocator, before: Result, after: Result) !void { + const difference = try diffResult(a, before, after, "want", "got"); + defer a.free(difference); + for ([_][]const u8{ "text:", "mode:", "cursor:", "anchor:", "sels:" }) |field| + try std.testing.expect(std.mem.indexOf(u8, difference, field) != null); + } + }; + try std.testing.checkAllAllocationFailures(allocator, Check.run, .{ want, changed }); } fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result { @@ -325,7 +374,7 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result pump(core); if (std.mem.eql(u8, pane_kind, "file")) { - _ = try core.hxOpenFileContent(case.text); + _ = try core.setTestFile(case.text); } else if (std.mem.eql(u8, pane_kind, "tty")) { // the harness is the pty here: cook \n to \r\n the way ONLCR would const cooked_len = std.math.add(usize, case.text.len, std.mem.count(u8, case.text, "\n")) catch return error.OutOfMemory; @@ -372,8 +421,8 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result name = name[2..]; } else break; } - if (name.len == 1) { - key.cp = name[0]; + if (name.len == 1 or (name.len > 1 and name.len <= 4 and name[0] >= 0x80)) { + key.cp = std.unicode.utf8Decode(name) catch return error.InvalidKey; if (!key.ctrl and !key.alt) key.text = name; } else if (std.mem.eql(u8, name, "esc")) { key.cp = pardes.Key.escape; @@ -417,9 +466,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result key.cp = pardes.Key.page_down; } else fatal("case {s}: unknown key <{s}>", .{ case.name, name }); } else { - key.cp = case.keys[ki]; - key.text = case.keys[ki .. ki + 1]; - ki += 1; + const len = std.unicode.utf8ByteSequenceLength(case.keys[ki]) catch return error.InvalidKey; + if (len > case.keys.len - ki) return error.InvalidKey; + const text = case.keys[ki..][0..len]; + key.cp = std.unicode.utf8Decode(text) catch return error.InvalidKey; + key.text = text; + ki += len; } core.update(.{ .key = key }); pump(core); @@ -436,12 +488,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // the cursor's empty row below the output (see Pardes.shellRows), so // its line list is exactly a file content's — join it and you have the // same bytes, trailing newline included, with nothing to patch up. - const pl = try core.paneCursorLines(pane); - var text_len: usize = if (pl.lines.len == 0) 0 else pl.lines.len - 1; - for (pl.lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; + const lines = try core.paneCursorLines(pane); + var text_len: usize = if (lines.len == 0) 0 else lines.len - 1; + for (lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; const joined = try arena.alloc(u8, text_len); var offset: usize = 0; - for (pl.lines, 0..) |ln, i| { + for (lines, 0..) |ln, i| { if (i > 0) { joined[offset] = '\n'; offset += 1; @@ -506,38 +558,131 @@ fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } -fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 { - const path_z = try arena.dupeZ(u8, path); - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return error.OpenFailed; - defer _ = libc.close(fd); - const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; - const size = std.math.cast(usize, end) orelse return error.FileTooLarge; - const buf = try arena.alloc(u8, size); - var offset: usize = 0; - while (offset < buf.len) { - const n = libc.read(fd, buf[offset..].ptr, buf.len - offset); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) return buf[0..offset]; - offset += @intCast(n); - } - var extra: [1]u8 = undefined; - while (true) { - const n = libc.read(fd, &extra, extra.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); + std.process.exit(1); +} + +fn testCli(init: std.process.Init) !void { + const io = init.io; + const arena = init.arena.allocator(); + const exe = try std.process.executablePathAlloc(io, arena); + var random: [12]u8 = undefined; + io.random(&random); + const path = try std.fmt.allocPrint(arena, "hxdiff-{x}", .{random}); + var parent = try std.Io.Dir.cwd().createDirPathOpen(io, ".zig-cache/tmp", .{}); + defer parent.close(io); + try parent.createDir(io, path, .default_dir); + defer parent.deleteTree(io, path) catch {}; + var dir = try parent.openDir(io, path, .{}); + defer dir.close(io); + + const case_a = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\"}\n"; + const case_b = "{\"name\":\"b\",\"text\":\"b\\n\",\"keys\":\"\"}\n"; + const result_tail = ",\"mode\":\"normal\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}"; + const result_a = "{\"name\":\"a\",\"text\":\"a\\n\"" ++ result_tail; + const result_b = "{\"name\":\"b\",\"text\":\"b\\n\"" ++ result_tail; + const different = "{\"name\":\"a\",\"text\":\"different\\n\"" ++ result_tail; + const changed = "{\"name\":\"a\",\"text\":\"changed\\n\"" ++ result_tail; + const waiver = "{\"name\":\"a\",\"reason\":\"fixture\",\"reference\":" ++ different ++ ",\"expected\":" ++ result_a ++ "}\n"; + const missing_waiver = "{\"name\":\"b\",\"reason\":\"fixture\",\"reference\":" ++ result_b ++ ",\"expected\":" ++ result_b ++ "}\n"; + const Fixture = struct { + name: []const u8, + cases: []const u8 = case_a, + reference: []const u8 = result_a, + waivers: []const u8 = "", + args: []const []const u8 = &.{ "--strict", "cases", "reference", "waivers" }, + message: []const u8 = "", + }; + const fixtures = [_]Fixture{ + .{ .name = "equal" }, + .{ .name = "subset", .reference = result_a ++ "\n" ++ result_b, .args = &.{ "cases", "reference" } }, + .{ .name = "missing reference", .cases = case_a ++ case_b, .message = "no golden for case b" }, + .{ .name = "unused reference", .reference = result_a ++ "\n" ++ result_b, .message = "UnusedReferenceCases" }, + .{ .name = "empty corpus", .cases = "\n", .reference = "", .message = "NoCases" }, + .{ .name = "duplicate cases", .cases = case_a ++ case_a, .message = "duplicate case name" }, + .{ .name = "duplicate references", .reference = result_a ++ "\n" ++ result_a, .message = "duplicate golden name" }, + .{ .name = "empty name", .cases = "{\"name\":\"\",\"text\":\"a\",\"keys\":\"\"}\n", .message = "empty or duplicate case name" }, + .{ .name = "mismatch", .reference = different, .message = "MISMATCH a" }, + .{ .name = "pinned waiver", .reference = different, .waivers = waiver }, + .{ .name = "changed actual", .cases = "{\"name\":\"a\",\"text\":\"changed\\n\",\"keys\":\"\"}\n", .reference = different, .waivers = waiver, .message = "CHANGED WAIVER a" }, + .{ .name = "changed reference", .reference = changed, .waivers = waiver, .message = "CHANGED WAIVER a" }, + .{ .name = "stale waiver", .waivers = waiver, .message = "stale waiver: a" }, + .{ .name = "unused waiver", .waivers = missing_waiver, .message = "unused waiver: b" }, + .{ .name = "duplicate waivers", .reference = different, .waivers = waiver ++ waiver, .message = "duplicate waiver name" }, + .{ .name = "unknown case field", .cases = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\",\"typo\":true}\n", .message = "UnknownField" }, + .{ .name = "unknown reference field", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"typo\":true" ++ result_tail, .message = "UnknownField" }, + .{ .name = "unknown mode", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"mode\":\"unknown\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}\n", .message = "UnknownMode" }, + .{ .name = "primary without selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"primary\":0" ++ result_tail, .message = "PrimaryWithoutSelections" }, + .{ .name = "empty selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"sels\":[]" ++ result_tail, .message = "NotMultipleSelections" }, + .{ .name = "missing waiver argument", .args = &.{ "cases", "--waivers" }, .message = "usage:" }, + .{ .name = "flag instead of waiver path", .args = &.{ "cases", "--waivers", "--strict" }, .message = "MissingWaiverPath" }, + .{ .name = "duplicate waiver option", .args = &.{ "cases", "reference", "--waivers", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, + .{ .name = "positional and named waiver", .args = &.{ "cases", "reference", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, + .{ .name = "unknown option", .args = &.{ "cases", "--typo" }, .message = "UnknownOption" }, + .{ .name = "too many paths", .args = &.{ "cases", "reference", "waivers", "extra" }, .message = "TooManyPaths" }, + .{ .name = "waivers without comparison", .args = &.{ "cases", "--waivers", "waivers" }, .message = "WaiversNeedComparison" }, + .{ .name = "conflicting pane modes", .args = &.{ "--parity", "--pane=file", "cases" }, .message = "ConflictingPaneModes" }, + .{ .name = "unknown pane", .args = &.{ "--pane=unknown", "cases" }, .message = "UnknownPane" }, + .{ .name = "parity", .args = &.{ "--parity", "--strict", "cases" } }, + .{ .name = "parity option after path", .args = &.{ "cases", "--strict", "--parity" } }, + .{ .name = "emit", .args = &.{"cases"} }, + }; + for (fixtures) |fixture| { + try dir.writeFile(io, .{ .sub_path = "cases", .data = fixture.cases }); + try dir.writeFile(io, .{ .sub_path = "reference", .data = fixture.reference }); + try dir.writeFile(io, .{ .sub_path = "waivers", .data = fixture.waivers }); + const argv = try arena.alloc([]const u8, fixture.args.len + 1); + argv[0] = exe; + @memcpy(argv[1..], fixture.args); + const result = try std.process.run(arena, io, .{ + .argv = argv, + .cwd = .{ .dir = dir }, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(5) } }, + }); + const passed = result.term == .exited and result.term.exited == 0; + if (result.term != .exited or passed != (fixture.message.len == 0) or + std.mem.indexOf(u8, result.stderr, fixture.message) == null) + { + std.debug.print("CLI fixture {s}: {s}\n{s}\n", .{ fixture.name, result.stdout, result.stderr }); + return error.DifferentialDriverTestFailed; } - if (n > 0) return error.FileChanged; - return buf; + if (std.mem.eql(u8, fixture.name, "emit") and !std.mem.eql(u8, result.stdout, result_a ++ "\n")) return error.WrongEmittedResult; } + std.debug.print("hxdiff: {d} CLI regression fixtures passed\n", .{fixtures.len}); } -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); - std.process.exit(1); +test "differential cases deliver each Unicode scalar as one key event" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const finds = [_]struct { keys: []const u8, col: i32 }{ + .{ .keys = "fλ", .col = 4 }, + .{ .keys = "f<λ>", .col = 4 }, + .{ .keys = "f界", .col = 7 }, + .{ .keys = "f<界>", .col = 7 }, + .{ .keys = "f😀", .col = 11 }, + .{ .keys = "f<😀>", .col = 11 }, + }; + for (finds) |case| { + const result = try runCase(arena, .{ .name = "unicode-find", .text = "one λ 界 😀 two\n", .keys = case.keys }, "file"); + try std.testing.expectEqualStrings("one λ 界 😀 two\n", result.text); + try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); + } + const inserts = [_]struct { keys: []const u8, mode: []const u8, col: i32 }{ + .{ .keys = "iλ界😀", .mode = "insert", .col = "λ界😀".len }, + .{ .keys = "i<λ><界><😀>", .mode = "insert", .col = "λ界😀".len }, + .{ .keys = "iλ界😀<esc>", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "i<λ><界><😀><esc>", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "iλ界😀<esc>h", .mode = "normal", .col = "λ界".len }, + .{ .keys = "i<λ><界><😀><esc>h", .mode = "normal", .col = "λ界".len }, + }; + for (inserts) |case| { + const result = try runCase(arena, .{ .name = "unicode-insert", .text = "\n", .keys = case.keys }, "file"); + try std.testing.expectEqualStrings("λ界😀\n", result.text); + try std.testing.expectEqualStrings(case.mode, result.mode); + try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); + } } diff --git a/test/lspbench.zig b/test/lspbench.zig index 964d3c8c..59cac155 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -1,33 +1,9 @@ -//! The language-backend scoreboard: one binary, three numbers. -//! -//! zig build lspbench -- latency + feature matrix over pardes's own src/ -//! zig build lspbench -- --json -- the same, machine-readable -//! -//! Every competing backend links the same harness against the same corpus and -//! the same query list, so the columns mean the same thing across all of them. -//! What it measures, in the order the evaluation weighs it: -//! -//! FEATURES which lsp.Kind values actually return rows. Claiming support in -//! `lsp.supports` and returning nothing is a FALSE claim and shows -//! up as `claimed-empty` — the harness trusts results, not flags. -//! LATENCY cold (first query, index build included) and warm (median of N) -//! per kind. Cold is what a keypress costs the first time; warm is -//! what it costs forever after. Both matter and they differ by -//! orders of magnitude for an indexing backend. -//! MEMORY peak RSS delta across the run, read from /proc. -//! -//! Lines of code is not measured here — it is `jj diff --stat` against the -//! base commit, which is the honest number (a backend that vendors a library -//! pays for what it vendors only in build time, not in code we maintain). const std = @import("std"); const libc = std.c; const lsp = @import("pardes").lsp; -// 0.16 slimmed std.fs (no cwd(), no realpathAlloc) and this repo goes through -// libc everywhere for exactly that reason — see look.zig. extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; -/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig already uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -36,53 +12,25 @@ fn nowNs() u64 { pub const std_options: std.Options = .{ .log_level = .err }; -/// One probe. The corpus is pardes's own source: real Zig, in this repo, that -/// every implementation can reach without a fixture tree to keep in sync. -/// -/// The cursor is pinned by SYMBOL, not by line — the needle is searched for at -/// startup — so editing pardes.zig cannot silently rot the bench into probing -/// a blank line and calling the result "no support". -/// -/// `expect` is a substring the rows must contain for the probe to count as -/// CORRECT rather than merely non-empty. A backend that returns a plausible -/// wrong location scores worse than one that returns nothing, and only this -/// field can tell those two apart. +// Coordinates are byte offsets within the first needle occurrence, not source line numbers. const Anchor = struct { file: []const u8, - /// the exact source text to put the cursor on; the FIRST occurrence wins needle: []const u8, - /// byte offset into `needle` where the cursor sits at: u32 = 0, kind: lsp.Kind, expect: []const u8 = "", }; const anchors = [_]Anchor{ - // a call to a function defined in the same file - .{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, - // a plain local/decl reference within one file + .{ .file = "src/pardes.zig", .needle = "modal.cursorLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" }, - // a std reference: needs the zig lib dir, which is the first thing a - // single-file backend cannot do .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" }, - // hover over the same symbol .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" }, - // the file's own symbols .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" }, .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" }, - // references to a symbol used in several places .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" }, - // Diagnostics and format probe a DELIBERATELY BROKEN fixture, never the - // real source. On a clean corpus the correct answer is nothing, which is - // byte-identical to "this backend has no diagnostics" — a blind spot that - // rewards emitting a filler row and punishes honesty. test/lspfixture has - // an unused local (semantic: needs a compiler front end, not a tokenizer) - // and a misformatted fn (needs a formatter), so both probes have real work. .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .diagnostics, .expect = "broken.zig" }, - // the remaining kinds, probed once each so the matrix is complete .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, - // cursor on `out`, whose type is `*std.Io.Writer` — a type_definition probe - // wants a NAME whose type must be resolved, not the type spelled out .{ .file = "src/lsp/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, @@ -91,131 +39,179 @@ const anchors = [_]Anchor{ .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, - // The three shapes a `.` can have, each with the cursor IMMEDIATELY AFTER - // the dot, which is where Tab asks from. - // a field access into std: `std.` lists what `std` declares, the same - // "can this backend reach the stdlib at all" test `gd` gets. .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 4, .kind = .completion, .expect = "std.zig" }, - // an enum literal in a FINISHED switch arm. The tree parses, so this - // probes the expected-type resolution and nothing else. .{ .file = "src/lsp/lsp_zls.zig", .needle = ".declaration => try rowForToken", .at = 1, .kind = .completion, .expect = "type_definition" }, - // the same thing on the BIGGEST file in the tree. This one is here for - // its latency column rather than its rows: a completion parses the - // buffer once per placeholder spelling it tries, so pardes.zig is where - // that shows up and where the figure quoted in docs/lsp.md comes from. .{ .file = "src/pardes.zig", .needle = ".definition => .definition,", .at = 1, .kind = .completion, .expect = "" }, - // an enum literal in a HALF-TYPED switch arm, which is what a real - // keypress looks like: the file does not parse and the switch is not in - // the tree at all. `verdigris` appears only on the enum member's own - // line, so a row carrying it came from the DEFINITION and not from the - // line the cursor sits on — and it has to appear TWICE, because a - // completion row carries the candidate's own name (the word that would go - // after the dot) before the declaration line it was read off. + // The repeated name proves the result includes the declaration, not just the cursor line. .{ .file = "test/lspfixture/dotcomplete.zig", .needle = "return switch (s) {\n .", .at = 29, .kind = .completion, .expect = "verdigris verdigris," }, - // ...and the same dot on a line that is ALSO missing its terminator, - // which is one parse error repaired or not. This is the probe that - // catches a repair chosen by error count rather than by whether the dot - // became reachable in the tree. - // (the needle carries its indent: dothalf.zig's own header explains the - // shape and would otherwise be the first match) + // Indentation keeps the fixture header from becoming the first needle match. .{ .file = "test/lspfixture/dothalf.zig", .needle = " const z: Shade = .", .at = 22, .kind = .completion, .expect = "verdigris" }, }; const Result = struct { kind: lsp.Kind, - /// no rows came back empty: bool = true, - /// rows came back AND contained `expect` (or nothing was expected) correct: bool = false, rows: usize = 0, cold_us: u64 = 0, warm_us: u64 = 0, + + fn accept(r: *Result, output: []const u8, expected: []const u8) void { + r.empty = output.len == 0; + r.rows = std.mem.count(u8, output, "\n"); + r.correct = !r.empty and (expected.len == 0 or std.mem.indexOf(u8, output, expected) != null); + } }; const warm_iters = 20; +const Options = struct { + json: bool = false, + check: bool = false, + root: []const u8 = ".", + + fn parse(args: []const []const u8) !Options { + var options: Options = .{}; + var has_root = false; + for (args) |arg| { + if (std.mem.eql(u8, arg, "--json")) { + options.json = true; + } else if (std.mem.eql(u8, arg, "--check")) { + options.check = true; + } else if (std.mem.startsWith(u8, arg, "-")) { + return error.UnknownArgument; + } else { + if (has_root) return error.MultipleRoots; + options.root = arg; + has_root = true; + } + } + return options; + } +}; + +test "lspbench options reject unknown flags and multiple roots before probing" { + const defaults = try Options.parse(&.{}); + try std.testing.expect(!defaults.json and !defaults.check); + try std.testing.expectEqualStrings(".", defaults.root); + for ([_][]const []const u8{ + &.{ "--json", "repo root", "--check" }, + &.{ "repo root", "--check", "--json" }, + }) |args| { + const options = try Options.parse(args); + try std.testing.expect(options.json and options.check); + try std.testing.expectEqualStrings("repo root", options.root); + } + for ([_][]const []const u8{ + &.{ "--reps", "3" }, + &.{ "--json", "--unknown" }, + &.{ "repo", "--check=true" }, + }) |args| try std.testing.expectError(error.UnknownArgument, Options.parse(args)); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "second" })); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "--check", "second" })); +} + pub fn main(init: std.process.Init) !void { - // page_allocator like the other harnesses: this measures the BACKEND, and - // a debug allocator's bookkeeping would land in every number. const gpa = std.heap.page_allocator; const args = try init.minimal.args.toSlice(init.arena.allocator()); - var json = false; - var root: []const u8 = "."; - for (args[1..]) |a| { - if (std.mem.eql(u8, a, "--json")) json = true else root = a; - } + const options = try Options.parse(args[1..]); const rss0 = rssKib(); var results: [anchors.len]Result = undefined; var result_count: usize = 0; for (anchors) |an| { - const path = try std.fs.path.join(gpa, &.{ root, an.file }); - defer gpa.free(path); - const src = readZ(gpa, path) catch continue; - defer gpa.free(src); - const off = std.mem.indexOf(u8, src, an.needle) orelse { - std.debug.print("bench: anchor not found in {s}: `{s}`\n", .{ an.file, an.needle }); + results[result_count] = probe(gpa, options.root, an) catch |err| { + std.debug.print("bench: {s} {s} `{s}`: {s}\n", .{ an.file, @tagName(an.kind), an.needle, @errorName(err) }); continue; }; - var realbuf: [4096]u8 = undefined; - var pz: [4096:0]u8 = undefined; - const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0) catch continue; - const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse continue); - const dir = std.fs.path.dirname(abs) orelse "/"; - const req: lsp.Req = .{ - .kind = an.kind, - .path = abs, - .source = src, - .offset = @intCast(off + an.at), - .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", - .root = dir, - }; - - var r: Result = .{ .kind = an.kind }; - // COLD: whatever the backend has to build the first time counts. - r.cold_us = runOnce(gpa, req, &r); - // WARM: median of warm_iters, so one scheduling hiccup cannot flatter - // or damn a backend. - var samples: [warm_iters]u64 = undefined; - for (&samples) |*s| { - var throwaway: Result = .{ .kind = an.kind }; - s.* = runOnce(gpa, req, &throwaway); - } - std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); - r.warm_us = samples[warm_iters / 2]; - r.correct = !r.empty and (an.expect.len == 0 or blk: { - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - lsp.query(gpa, arena.allocator(), req, &out.writer); - break :blk std.mem.indexOf(u8, out.written(), an.expect) != null; - }); - results[result_count] = r; result_count += 1; } const rss = rssKib() -| rss0; - if (json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.check) try checkResults(results[0..result_count], anchors.len); +} + +fn probe(gpa: std.mem.Allocator, root: []const u8, an: Anchor) !Result { + const path = try std.fs.path.join(gpa, &.{ root, an.file }); + defer gpa.free(path); + const src = try readZ(gpa, path); + defer gpa.free(src); + const off = std.mem.indexOf(u8, src, an.needle) orelse return error.MissingAnchor; + if (an.at > an.needle.len) return error.InvalidAnchor; + var realbuf: [4096]u8 = undefined; + var pz: [4096:0]u8 = undefined; + const pz_s = try std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0); + const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse return error.RealPathFailed); + const req: lsp.Req = .{ + .kind = an.kind, + .path = abs, + .source = src, + .offset = @intCast(off + an.at), + .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", + .root = std.fs.path.dirname(abs) orelse "/", + }; + var r: Result = .{ .kind = an.kind }; + r.cold_us = try runOnce(gpa, req, an.expect, &r); + var samples: [warm_iters]u64 = undefined; + for (&samples) |*sample| { + var warm: Result = .{ .kind = an.kind }; + sample.* = try runOnce(gpa, req, an.expect, &warm); + r.correct = r.correct and warm.correct; + } + std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); + r.warm_us = samples[warm_iters / 2]; + return r; +} + +fn checkResults(results: []const Result, required: usize) !void { + if (results.len != required) return error.MissingProbes; + for (results) |r| if (r.empty or !r.correct) return error.IncorrectProbe; } -fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 { +fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, expected: []const u8, r: *Result) !u64 { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const t0 = nowNs(); - lsp.query(gpa, arena.allocator(), req, &out.writer); + try lsp.query(gpa, arena.allocator(), req, &out.writer); const ns = nowNs() -| t0; - if (out.written().len > 0) { - r.empty = false; - r.rows = std.mem.count(u8, out.written(), "\n"); - } + r.accept(out.written(), expected); return ns / 1000; } +test "lspbench check rejects omitted empty and incorrect probes" { + var result: Result = .{ .kind = .definition }; + try std.testing.expectError(error.MissingProbes, checkResults(&.{}, 1)); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "expected.zig"); + try std.testing.expect(!result.empty); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "actual.zig"); + try checkResults(&.{result}, 1); + try std.testing.expectError(error.MissingProbes, checkResults(&.{result}, 2)); + result.accept("", ""); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); +} + +test "lspbench reports missing fixture and anchor before querying a backend" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_buf: [4096]u8 = undefined; + const root = path_buf[0..try tmp.dir.realPath(std.testing.io, &path_buf)]; + const anchor: Anchor = .{ .file = "source.zig", .needle = "pub const value", .kind = .definition }; + try std.testing.expectError(error.OpenFailed, probe(std.testing.allocator, root, anchor)); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = anchor.file, .data = "const value = 1;\n" }); + try std.testing.expectError(error.MissingAnchor, probe(std.testing.allocator, root, anchor)); + var invalid = anchor; + invalid.needle = "value"; + invalid.at = 6; + try std.testing.expectError(error.InvalidAnchor, probe(std.testing.allocator, root, invalid)); +} + fn reportText(rs: []const Result, rss: u64) void { const o = std.debug.print; o("backend: {s}\n\n", .{lsp.backend_name}); @@ -284,8 +280,6 @@ fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 { return buf[0..len :0]; } -/// VmHWM from /proc/self/status — the peak, not the current, so a backend that -/// frees its index before returning still pays for having built it. fn rssKib() u64 { const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); if (fd < 0) return 0; diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift index 800e2d43..49e0a7f2 100644 --- a/test/macos_e2e.swift +++ b/test/macos_e2e.swift @@ -386,21 +386,7 @@ private final class Driver: PardesViewDelegate { private func snap(_ label: String) { let frame = readFrame() output += "== snap \(label) grid=\(frame.cols)x\(frame.rows) cursor=\(frame.cursorX),\(frame.cursorY)\n" - for line in frame.lines { output += "|\(stableNames(line))\n" } - } - - /// `New` asks the shell for a temporary document and mkstemp picks six - /// random characters for it (src/temp_file.zig), so the pane tag holding - /// that name is different on every run. TMPDIR is already pinned inside the - /// hermetic world, which makes the DIRECTORY reproducible; this makes the - /// name reproducible. Masked rather than dropped, so a golden still shows - /// that a temp document is what got opened and where. - private func stableNames(_ line: String) -> String { - guard line.contains("pardes-") else { return line } - return line.replacingOccurrences( - of: "pardes-[A-Za-z0-9]{6}", - with: "pardes-XXXXXX", - options: .regularExpression) + for line in frame.lines { output += "|\(line)\n" } } /// Put the frame readFrame() just produced on screen — for a window that has @@ -957,17 +943,13 @@ private func buildWorld(stem: String) throws -> String { let work = "\(base)/cwd" let configHome = "\(home)/.config" let pardesConfig = "\(configHome)/pardes" - // `New` creates its document under TMPDIR (src/temp_file.zig). Left alone - // that is the per-user /var/folders/... path launchd hands out, which is - // different on every machine and lands verbatim in a pane tag — a golden - // that could only ever pass for whoever generated it. let tmp = "\(base)/tmp" for dir in [base, home, work, configHome, pardesConfig, tmp] { try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) } // The shells are started with `--rcfile /tmp/pardes-osc133.bash`, which - // sources $HOME/.bashrc (src/shell_bin.zig) — so this is what pins the + // sources $HOME/.bashrc (host_io.Shell) — so this is what pins the // prompt to `$ ` and keeps the developer's history out of the capture. try "PS1='$ '\nHISTFILE=\n".write(toFile: "\(home)/.bashrc", atomically: true, encoding: .utf8) // ...and the config is not empty, because the DEFAULT shell is fish, whose diff --git a/test/ninep.py b/test/ninep.py new file mode 100644 index 00000000..b128cc8a --- /dev/null +++ b/test/ninep.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import json +import os +import socket +import struct + + +def string(value): + data = value.encode() if isinstance(value, str) else value + return struct.pack('<H', len(data)) + data + + +class Client: + def __init__(self, address, timeout=5, msize=8192): + tcp = isinstance(address, tuple) + family = (socket.AF_INET6 if ':' in address[0] else socket.AF_INET) if tcp else socket.AF_UNIX + self.socket = socket.socket(family) + self.socket.settimeout(timeout) + if tcp: + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.tag = 0 + self.fid = 1 + self.msize = msize + try: + self.socket.connect(address if tcp else os.fspath(address)) + reply = self.rpc(100, struct.pack('<I', self.msize) + string('9P2000'), tag=65535) + self.msize = min(self.msize, struct.unpack_from('<I', reply)[0]) + if self.msize < 256 or reply[4:] != string('9P2000'): + raise OSError('unsupported 9P version or message size') + attached = self.rpc(104, struct.pack('<II', 1, 0xffffffff) + string('pardes') + string('')) + if len(attached) != 13: + raise OSError('invalid attach qid') + except BaseException: + self.socket.close() + raise + + def __enter__(self): + return self + + def __exit__(self, *args): + self.socket.close() + + def receive(self, count): + data = bytearray() + while len(data) < count: + chunk = self.socket.recv(count - len(data)) + if not chunk: + raise EOFError('9P peer closed the connection') + data.extend(chunk) + return bytes(data) + + def rpc(self, kind, payload, tag=None): + if tag is None: + self.tag = (self.tag + 1) % 65535 + tag = self.tag + packet = struct.pack('<IBH', 7 + len(payload), kind, tag) + payload + if len(packet) > self.msize: + raise ValueError('request exceeds negotiated message size') + self.socket.sendall(packet) + size, answer, received_tag = struct.unpack('<IBH', self.receive(7)) + if not 7 <= size <= self.msize or received_tag != tag: + raise OSError('invalid 9P reply header') + reply = self.receive(size - 7) + if answer == 107: + length = struct.unpack_from('<H', reply)[0] + raise OSError(reply[2:2 + length].decode(errors='replace')) + if answer != kind + 1: + raise OSError(f'expected reply {kind + 1}, received {answer}') + return reply + + def open(self, path, mode=0): + names = [part for part in os.fspath(path).split('/') if part] + self.fid += 1 + fid = self.fid + oldfid = 1 + try: + for start in range(0, max(1, len(names)), 16): + part = names[start:start + 16] + reply = self.rpc(110, struct.pack('<IIH', oldfid, fid, len(part)) + + b''.join(string(name) for name in part)) + walked = struct.unpack_from('<H', reply)[0] + if len(reply) != 2 + 13 * walked: + raise OSError('invalid walk qids') + if walked != len(part): + raise FileNotFoundError(path) + oldfid = fid + if len(self.rpc(112, struct.pack('<IB', fid, mode))) != 17: + raise OSError('invalid open reply') + return fid + except BaseException: + try: + self.close(fid) + except OSError: + pass + raise + + def close(self, fid): + self.rpc(120, struct.pack('<I', fid)) + + def read_fid(self, fid, offset=0, count=None): + count = min(count if count is not None else self.msize - 11, self.msize - 11) + reply = self.rpc(116, struct.pack('<IQI', fid, offset, count)) + size = struct.unpack_from('<I', reply)[0] + if size != len(reply) - 4 or size > count: + raise OSError('invalid 9P read length') + return reply[4:] + + def read(self, path): + fid = self.open(path) + try: + data = bytearray() + while chunk := self.read_fid(fid, len(data)): + data.extend(chunk) + return bytes(data) + finally: + self.close(fid) + + def screen(self): + return json.loads(self.read('/self/screen')) + + def write(self, path, data, truncate=False): + fid = self.open(path, 1 | (16 if truncate else 0)) + try: + offset = 0 + while offset < len(data): + chunk = data[offset:offset + self.msize - 23] + reply = self.rpc(118, struct.pack('<IQI', fid, offset, len(chunk)) + chunk) + count = struct.unpack('<I', reply)[0] + if not 0 < count <= len(chunk): + raise OSError('invalid 9P write length') + offset += count + finally: + self.close(fid) + + def list(self, path='/'): + data = self.read(path) + names = [] + offset = 0 + while offset < len(data): + size = struct.unpack_from('<H', data, offset)[0] + if size < 47 or offset + size + 2 > len(data): + raise OSError('invalid 9P directory entry') + length = struct.unpack_from('<H', data, offset + 41)[0] + if 43 + length > size + 2: + raise OSError('invalid directory name length') + names.append(data[offset + 43:offset + 43 + length].decode()) + offset += size + 2 + return names diff --git a/test/output.zig b/test/output.zig new file mode 100644 index 00000000..577b8e73 --- /dev/null +++ b/test/output.zig @@ -0,0 +1,855 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const config = pardes.config; +const builtins = pardes.builtins; +const modal = pardes.modal; + +const Pardes = pardes.Pardes; +const Key = pardes.Key; +const platform = pardes.platform; +const font_picker = pardes.font_picker; +const fonts = if (font_picker) pardes.fonts else struct {}; + +test "location results sort filenames and numeric positions while remapping their anchor" { + const before = "z.zig:1:1 z\nb.zig:10:2-3 ten\nb.zig:2:10-12 two-b\nb.zig:2:2-3 two-a\na.zig:99:1 a\nb.zig:2:2-3 duplicate\n"; + const after = "a.zig:99:1 a\nb.zig:2:2-3 two-a\nb.zig:2:2-3 duplicate\nb.zig:2:10-12 two-b\nb.zig:10:2-3 ten\nz.zig:1:1 z\n"; + for ([_]panes.Output.Origin{ .search, .{ .cmd = .Grep }, .{ .query = .references }, .{ .query = .diagnostics } }) |origin| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", origin, "", try p.gpa.dupe(u8, before), 2); + const results = p.panes[source.search_pane.?].?; + try std.testing.expectEqualStrings(after, results.file.?.content); + try std.testing.expectEqual(@as(usize, 3), source.search_row.?); + } +} + +test "ranked choices and prose retain their supplied order" { + const text = "z.zig:10:1 first\na.zig:2:1 second\n"; + for ([_]pardes.lsp.Kind{ .completion, .hover, .code_action }) |kind| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = kind }, "", try p.gpa.dupe(u8, text), null); + const result = for (p.panes) |slot| { + const pane = slot orelse continue; + const file = pane.file orelse continue; + if (file.output != null) break file; + } else return error.MissingResults; + try std.testing.expectEqualStrings(text, result.content); + } +} + +test "reference Look selects the next sorted row from the captured request and keeps navigation there" { + if (!pardes.lsp.supports.contains(.references)) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "m.zig", .data = "const item = 0;\n_ = item;\n_ = item;\n_ = item;\n_ = item;\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a.zig", .data = "a\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "z.zig", .data = "z\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/m.zig", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = p.panes[0].?; + const response = "m.zig:5:5-8 later\nz.zig:1:1 last-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\na.zig:1:1 first-file\nm.zig:4:5-8 next\n"; + const sorted = "a.zig:1:1 first-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\nm.zig:4:5-8 next\nm.zig:5:5-8 later\nz.zig:1:1 last-file\n"; + source.cur_row = 2; + source.cur_col = 4; + p.lspRequest(0, .references, ""); + const request = p.lsp_wait.?.id; + source.cur_row = 4; + p.lspResponse(request, response); + const rid = source.search_pane orelse return error.MissingResults; + const results = p.panes[rid].?; + try std.testing.expectEqualStrings(sorted, results.file.?.content); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(@as(i32, 3), source.cur_row); + try std.testing.expect(source.vsel.active); + try std.testing.expectEqual(@as(i32, 4), @min(source.cur_col, source.vsel.col)); + try std.testing.expectEqual(@as(i32, 7), @max(source.cur_col, source.vsel.col)); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); + try std.testing.expectEqual(@as(usize, 3), source.search_row.?); + try std.testing.expectEqual(results.serial, p.look_walk_owner.?); + try std.testing.expectEqual(@as(i32, 3), results.look_at.?.row); + const available = p.freeSlot(); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(@as(i32, 4), results.cur_row); + try std.testing.expectEqual(available, p.freeSlot()); + p.update(.{ .key = .{ .cp = 'N' } }); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); + + p.active = 0; + source.cur_row = 4; + source.cur_col = 4; + p.lspRequest(0, .references, ""); + p.lspResponse(p.lsp_wait.?.id, response); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/z.zig")); + try std.testing.expectEqual(@as(i32, 5), results.cur_row); + try std.testing.expectEqual(@as(usize, 5), source.search_row.?); + const last = p.active; + p.lspRequest(last, .references, ""); + p.lspResponse(p.lsp_wait.?.id, response); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/a.zig")); + const wrapped = p.panes[p.panes[last].?.search_pane.?].?; + try std.testing.expectEqual(@as(i32, 0), wrapped.cur_row); + try std.testing.expectEqual(wrapped.serial, p.look_walk_owner.?); +} + +test "location sorting leaves mixed prose alone and preserves newline shape" { + const cases = [_]struct { before: []const u8, after: []const u8, anchor: usize }{ + .{ + .before = "heading\nz.zig:10:1 z\nnot a location\nx file.zig:2:10 x\nx file.zig:2:2 y\n\n", + .after = "heading\nz.zig:10:1 z\nnot a location\nx file.zig:2:10 x\nx file.zig:2:2 y\n\n", + .anchor = 1, + }, + .{ + .before = "z.zig:10:1 z\nx file.zig:2:10 x\nx file.zig:2:2 y", + .after = "x file.zig:2:2 y\nx file.zig:2:10 x\nz.zig:10:1 z", + .anchor = 1, + }, + }; + for (cases) |case| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .search, "", try p.gpa.dupe(u8, case.before), 1); + try std.testing.expectEqualStrings(case.after, p.panes[source.search_pane.?].?.file.?.content); + try std.testing.expectEqual(case.anchor, source.search_row.?); + } +} + +test "a single language location opens the full spaced filename" { + if (!pardes.lsp.supports.contains(.references)) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "from.zig", .data = "const item = 0;\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "with space.zig", .data = "first\nsecond\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/from.zig", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path }); + defer p.deinit(); + p.lspRequest(0, .references, ""); + p.lspResponse(p.lsp_wait.?.id, "with space.zig:2:1 second\n"); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/with space.zig")); + try std.testing.expectEqual(@as(i32, 1), p.panes[p.active].?.cur_row); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |file| try std.testing.expect(file.output == null); + } +} + +test "result locations stop before preview text and retain full addresses" { + const cases = [_]struct { row: []const u8, path: []const u8, line: usize, col: usize, address: []const u8 }{ + .{ .row = "a.zig:2 see other.zig:10:1", .path = "a.zig", .line = 2, .col = 0, .address = "a.zig:2" }, + .{ .row = "a.zig:2:10-12 see other.zig:1:1", .path = "a.zig", .line = 2, .col = 10, .address = "a.zig:2:10-12" }, + .{ .row = "with space.zig:100:2 café", .path = "with space.zig", .line = 100, .col = 2, .address = "with space.zig:100:2" }, + .{ .row = "@p3:20:1 pane", .path = "@p3", .line = 20, .col = 1, .address = "@p3:20:1" }, + .{ .row = "/n/peer/file.zig:5-8 span", .path = "/n/peer/file.zig", .line = 5, .col = 0, .address = "/n/peer/file.zig:5-8" }, + }; + for (cases) |case| { + const got = panes.Output.location(case.row); + try std.testing.expectEqualStrings(case.path, got.path); + try std.testing.expectEqual(case.line, got.at.line); + try std.testing.expectEqual(case.col, got.at.col); + try std.testing.expectEqualStrings(case.address, case.row[0..got.end]); + } +} + +test "reordered identical results preserve the selected row and warm line index" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + const sorted = "a.zig:2:1 first\na.zig:10:1 second\n"; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "", try p.gpa.dupe(u8, sorted), null); + const result = p.panes[source.search_pane.?].?; + const file = &result.file.?; + const index = try panes.File.lineIndex(p.gpa, file); + const content = file.content.ptr; + const revision = file.revision; + result.cur_row = 1; + result.cur_col = 0; + result.vsel = .{ .active = true, .explicit = true, .row = 1, .col = 9 }; + result.select = true; + const selected = result.vsel; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "new request", try p.gpa.dupe(u8, "a.zig:10:1 second\na.zig:2:1 first\n"), 0); + try std.testing.expectEqualStrings(sorted, file.content); + try std.testing.expectEqual(content, file.content.ptr); + try std.testing.expectEqual(index.ptr, file.line_starts.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expect(result.select); + try std.testing.expectEqual(@as(i32, 1), result.cur_row); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); +} + +test "result sorting allocation failures preserve the previous answer and navigation" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "a.zig:1:1 keep\n"), 0); + const id = source.search_pane.?; + const result = p.panes[id].?; + const content = result.file.?.content.ptr; + const revision = result.file.?.revision; + const owner = p.look_walk_owner; + var preview: [4096]u8 = @splat('x'); + for (0..2) |failure| { + var failing = std.testing.FailingAllocator.init(p.gpa, .{ .fail_index = failure }); + const original_scratch = p.scratch; + p.scratch = .init(failing.allocator()); + defer { + p.scratch.deinit(); + p.scratch = original_scratch; + } + const incoming = try std.fmt.allocPrint(p.gpa, "z.zig:2:1 {s}\na.zig:1:1 {s}\n", .{ &preview, &preview }); + try std.testing.expectError(error.OutOfMemory, panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "after", incoming, 1)); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqual(content, result.file.?.content.ptr); + try std.testing.expectEqual(revision, result.file.?.revision); + try std.testing.expectEqualStrings("before", result.file.?.output.?.arg()); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 0), source.search_row.?); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(owner, p.look_walk_owner); + } +} + +test "Find sorts whole filenames and next location uses numeric columns" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile(""); + try panes.Output.fillResults(p, 0, "/tmp", .{ .cmd = .Find }, "", try p.gpa.dupe(u8, "x:2\nx:10\n"), 0); + const result = p.panes[source.search_pane.?].?; + try std.testing.expectEqualStrings("x:10\nx:2\n", result.file.?.content); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + const rows = "with space.zig:2:2-3 first\nwith space.zig:2:10-12 next\nwith space.zig:10:1 later"; + try std.testing.expectEqual(@as(usize, 1), panes.Output.nextResult(rows, "with space.zig", .{ .line = 2, .col = 3 })); + try std.testing.expectEqual(@as(usize, 2), panes.Output.nextResult(rows, "with space.zig", .{ .line = 2, .col = 10 })); + try std.testing.expectEqual(@as(usize, 0), panes.Output.nextResult(rows, "with space.zig", .{ .line = 10, .col = 1 })); +} + +test "result stepping reaches an unterminated last row" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("one\ntwo\n"); + const rows = try std.fmt.allocPrint(p.gpa, "{s}:1:1 first\n{s}:2:1 last", .{ source.file.?.path, source.file.?.path }); + try panes.Output.fillResults(p, 0, "/tmp", .search, "", rows, 0); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + const result = p.panes[source.search_pane.?].?; + try std.testing.expectEqual(@as(i32, 1), result.cur_row); +} + +test "Mini uses OS source precedence and refreshes one independent snapshot" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "build.zig", .data = "x\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try p.panes[0].?.setOwnedCwd(dir); + try std.testing.expect(p.executeBuiltinLine(0, "Mini build.zig")); + const id = p.active; + try std.testing.expect(id != 0); + const mini = p.panes[id].?; + try std.testing.expect(mini.terminal == null); + try std.testing.expectEqualStrings("⠁\n", mini.file.?.content); + try std.testing.expect(std.mem.startsWith(u8, mini.file.?.mini.?.source, dir)); + const next = p.freeSlot(); + const body = mini.file.?.content.ptr; + try std.testing.expect(p.executeBuiltinLine(id, "Mini build.zig")); + try std.testing.expectEqual(id, p.active); + try std.testing.expectEqual(body, mini.file.?.content.ptr); + try std.testing.expectEqual(next, p.freeSlot()); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "build.zig", .data = "xx\n" }); + try std.testing.expectEqualStrings("⠁\n", mini.file.?.content); + try std.testing.expect(p.executeBuiltinLine(id, "Mini build.zig")); + try std.testing.expectEqual(id, p.active); + try std.testing.expectEqualStrings("⠉\n", mini.file.?.content); + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| try std.testing.expect(watch.pane != id), + else => {}, + }; + try std.testing.expect(p.executeBuiltinLine(id, "Save")); + try std.testing.expect(mini.prompt == .save); +} + +test "Mini dump preserves its exact virtual snapshot and body writes detach colors" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("pub fn start() void {}\n"); + var command_buf: [128]u8 = undefined; + const command = try std.fmt.bufPrint(&command_buf, "Mini /virtual/pane/{d}/body", .{source.serial}); + try std.testing.expect(p.executeBuiltinLine(0, command)); + const id = p.active; + const before = p.panes[id].?.file.?; + try std.testing.expect(before.mini != null); + try p.dumpState(); + panes.File.setContent(p, &source.file.?, try p.gpa.dupe(u8, "changed\n")); + const restored = try Pardes.initFromDump(std.testing.allocator, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + const mini = restored.panes[restored.active].?; + try std.testing.expectEqualStrings(before.content, mini.file.?.content); + try std.testing.expectEqualStrings(before.mini.?.source, mini.file.?.mini.?.source); + try std.testing.expectEqualSlices(u8, before.mini.?.colors, mini.file.?.mini.?.colors); + try std.testing.expect(before.content.ptr != mini.file.?.content.ptr); + while (restored.nextEffect()) |effect| switch (effect) { + .watch => |watch| try std.testing.expect(watch.pane != restored.active), + else => {}, + }; + var body_buf: [128]u8 = undefined; + const body_path = try std.fmt.bufPrint(&body_buf, "/n/self/pane/{d}/body", .{mini.serial}); + const body = try pardes.filesystem.read(restored, body_path); + defer restored.gpa.free(body); + try std.testing.expectEqualStrings(before.content, body); + try pardes.filesystem.write(restored, body_path, "replacement\n"); + try std.testing.expectEqualStrings("replacement\n", mini.file.?.content); + try std.testing.expect(mini.file.?.mini == null); +} + +test "Mini syntax colors survive toggles themes and rendering without source access" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "demo.zig", .data = "pub fn main() void {\n if (true) return;\n}\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + try p.panes[0].?.setOwnedCwd(dir); + p.settings.colors = false; + try panes.Mini.open(p, 0, "demo.zig"); + const id = p.active; + const mini = p.panes[id].?; + const keyword = std.mem.indexOfScalar(u8, mini.file.?.mini.?.colors, @intFromEnum(pardes.syntax.Syn.keyword)) orelse return error.MissingKeywordColor; + const position = modal.positionAt(mini.file.?.content, keyword); + const column = panes.File.displayWidth(modal.lineSlice(mini.file.?.content, position.row)[0..position.col]); + try tmp.dir.deleteFile(std.testing.io, "demo.zig"); + const colors = mini.file.?.mini.?.colors.ptr; + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for ([_]bool{ false, true, false, true }) |enabled| { + p.settings.colors = enabled; + mini.file.?.syntax_dirty = true; + const surface = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(mini) + @as(u16, @intCast(column)); + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(position.row)); + if (enabled) try std.testing.expectEqualDeep(pardes.Color{ .rgb = p.theme().kw }, surface.at(x, y).style.fg); + try std.testing.expectEqual(colors, mini.file.?.mini.?.colors.ptr); + try std.testing.expectEqual(@as(usize, 0), mini.file.?.highlights.len); + _ = frame.reset(.retain_capacity); + try std.testing.expect(p.executeBuiltinLine(id, "NextColor")); + } +} + +test "Mini restore rejects inconsistent metadata before publishing a pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const id = p.freeSlot().?; + const serial = p.next_serial; + for ([_]pardes.dump.File{ + .{ .content = "x", .mini_source = "/source.zig", .mini_colors_b64 = "AA==" }, + .{ .content = "x", .origin = "Mini", .mini_source = "/source.zig", .mini_colors_b64 = "" }, + .{ .content = "x", .origin = "Mini", .mini_source = "/source.zig", .mini_colors_b64 = "BQ==" }, + .{ .content = "x", .origin = "Mini", .mini_colors_b64 = "AA==" }, + }) |saved| { + try std.testing.expectError(error.InvalidMini, panes.File.restore(p, id, .{ .kind = .file, .tag = "", .body = "", .file = saved })); + try std.testing.expect(p.panes[id] == null); + try std.testing.expectEqual(serial, p.next_serial); + } +} + +test "Mini failures leave its existing snapshot and focus unchanged" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("abc\n"); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{source.serial}); + try panes.Mini.open(p, 0, path); + const id = p.active; + const mini = p.panes[id].?; + const body = mini.file.?.content.ptr; + const next = p.freeSlot(); + try std.testing.expectError(error.FileNotFound, panes.Mini.open(p, 0, "/virtual/does-not-exist")); + try std.testing.expectEqual(id, p.active); + try std.testing.expectEqual(body, mini.file.?.content.ptr); + try std.testing.expectEqual(next, p.freeSlot()); +} + +test "Config prints the startup path and refreshes its one output" { + const path = "/home/pardes-test/.config/pardes/init"; + const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + const opened = p.active; + const out = p.panes[opened].?.file.?; + for ([_][]const u8{ + "Startup config: " ++ path ++ "\n", + "Theme: helix\n", + "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n", + "Shell effective (last spawn): (none)\n", + "Shell pending: on\n", + }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null); + try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path)); + try std.testing.expectEqual(panes.Output.Origin{ .cmd = .Config }, out.output.?.from); + + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + try std.testing.expectEqual(opened, p.active); + var buffers: usize = 0; + for (p.panes) |slot| { + const f = (slot orelse continue).file orelse continue; + const origin = (f.output orelse continue).from; + buffers += @intFromBool(std.meta.eql(origin, panes.Output.Origin{ .cmd = .Config })); + } + try std.testing.expectEqual(@as(usize, 1), buffers); +} + +test "Config reports the absence of a per-user config path" { + const p = try Pardes.init(std.testing.allocator, .{}); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + const report = p.panes[p.active].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, report, "no per-user config path") != null); +} + +test "EffectCode lists readable virtual sources used by this backend" { + if (comptime !builtins.EffectCode.enabled) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide")); + const out = p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path)); + try std.testing.expectEqual(panes.Output.Origin{ .cmd = .EffectCode }, out.output.?.from); + try std.testing.expectEqualStrings("PanelSlide", out.output.?.arg()); + try std.testing.expect(out.content.len < 1024); + try std.testing.expect(std.mem.indexOf(u8, out.content, "/virtual/src/layout.zig\n") != null); + const backend_source = switch (platform) { + .tty => "/virtual/src/tty/panel_compositor.zig", + .gui => if (@import("pardes_config").gui_shader_sources_prebuilt) + "/virtual/shaders/prebuilt/ui.vert.glsl" + else + "/virtual/shaders/ui.vert.glsl", + .macos => "/virtual/src/macos/Sources/ScenePostprocessor.swift", + .web, .esp32p4 => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null); + var paths = std.mem.tokenizeScalar(u8, out.content, '\n'); + var count: usize = 0; + while (paths.next()) |path| { + if (!std.mem.startsWith(u8, path, "/virtual/")) continue; + const expected = pardes.filesystem.sourceBytes(path["/virtual/".len..]) orelse return error.MissingEffectSource; + const bytes = try pardes.filesystem.read(p, path); + defer p.gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + count += 1; + } + try std.testing.expect(count >= 3); + const report = p.active; + p.lookAt(report, backend_source); + try std.testing.expect(p.active != report); + const source = p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings(backend_source, source.path); + try std.testing.expectEqualStrings(pardes.filesystem.sourceBytes(backend_source["/virtual/".len..]).?, source.content); +} + +test "every enabled setting builtin mutates the State Config reports" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent); + + var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer font_arena.deinit(); + var chosen_font: ?[]const u8 = null; + var buf: [512]u8 = undefined; + for (config.Runtime.settings) |setting| { + if (!setting.enabled(builtins.capabilities)) continue; + const command: []const u8 = switch (setting.action) { + .theme => try std.fmt.bufPrint(&buf, "{s} acme", .{setting.word}), + .shell => try std.fmt.bufPrint(&buf, "{s} fish", .{setting.word}), + .tagline_size => try std.fmt.bufPrint(&buf, "{s} 67", .{setting.word}), + .font => continue, + else => setting.word, + }; + try std.testing.expect(p.executeBuiltinLine(p.active, command)); + } + if (comptime font_picker) { + const before_invalid = p.settings.font.tagline_percent; + try std.testing.expect(p.executeBuiltinLine(p.active, "TaglineSize 0")); + try std.testing.expectEqual(before_invalid, p.settings.font.tagline_percent); + const installed = fonts.list(font_arena.allocator(), null); + if (installed.len > 0) { + chosen_font = installed[0].name; + const command = try std.fmt.bufPrint(&buf, "Font {s}", .{installed[0].name}); + try std.testing.expect(p.executeBuiltinLine(p.active, command)); + } + } + + try std.testing.expect(p.executeBuiltinLine(p.active, "Config")); + const report = p.panes[p.active].?.file.?.content; + for ([_][]const u8{ + "Colors: off\n", + "Wrap: off\n", + "Tagbottom: on\n", + "Debug: on\n", + "Theme: acme\n", + "Shell requested (new panes): fish\n", + }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null); + const transition = if (builtins.capabilities.panel_transitions) + try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{ + config.Runtime.findAction(.{ .transition = p.settings.panel_transition }).?.word, + }) + else + "Panel transition: unsupported\n"; + try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); + const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; + for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status }); + try std.testing.expect(std.mem.indexOf(u8, report, line) != null); + } + const tagline = if (!builtins.capabilities.tagline_font_size) + "TaglineSize: unsupported\n" + else + try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{ + p.settings.font.tagline_percent, + if (builtins.capabilities.font_picker) "" else " (build-time only)", + }); + try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null); + if (chosen_font) |name| { + try std.testing.expect(std.mem.indexOf(u8, report, name) != null); + try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null); + } +} + +fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pattern: []const u8) !usize { + const rows = try p.gpa.dupe(u8, + \\build.zig:1:1 first + \\(mise.toml) and build.zig.zon:3:2-9 two on one row + \\nothing look-able on this row at all + \\uucode_config.zig:7:1 last + \\ + ); + try panes.Output.fillResults(p, id, cwd, .search, pattern, rows, null); + return p.panes[id].?.search_pane orelse error.MissingResults; +} + +const ProjectPaths = struct { cwd: []const u8, boot: []const u8 }; + +fn projectPaths(cwd_buf: *[4096]u8, path_buf: *[4096]u8) !ProjectPaths { + const raw = std.c.getcwd(cwd_buf, cwd_buf.len) orelse return error.GetCwdFailed; + const cwd = std.mem.span(@as([*:0]u8, @ptrCast(raw))); + return .{ .cwd = cwd, .boot = try std.fmt.bufPrint(path_buf, "{s}/mise.toml", .{cwd}) }; +} + +fn selectedOutputText(pane: *const pardes.Pane) ?[]const u8 { + const file = pane.file orelse return null; + if (!pane.vsel.active or pane.vsel.row != pane.cur_row or pane.cur_row < 0) return null; + const line = modal.lineSlice(file.content, @intCast(pane.cur_row)); + const lo: usize = @intCast(@max(0, @min(pane.vsel.col, pane.cur_col))); + const hi: usize = @intCast(@max(0, @max(pane.vsel.col, pane.cur_col))); + if (lo >= line.len) return ""; + return line[lo..@min(line.len, hi + 1)]; +} + +test "n/N selects one output location per row and opens nothing" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + const rid = try walkFixture(p, p.active, paths.cwd, "one"); + const results = p.panes[rid].?; + const panes_before = p.freeSlot(); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(panes_before, p.freeSlot()); + try std.testing.expect(results.vsel.active and results.vsel.explicit); + try std.testing.expectEqualStrings("build.zig:1:1", selectedOutputText(results) orelse ""); + try std.testing.expectEqual(panes.Output.Grain.line, panes.Output.grain(results.file.?.output)); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 1), results.cur_row); + try std.testing.expectEqualStrings("mise.toml", selectedOutputText(results) orelse ""); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); // row 2 is not look-able + try std.testing.expectEqualStrings("uucode_config.zig:7:1", selectedOutputText(results) orelse ""); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 0), results.cur_row); // ring seam + + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(p.freeSlot() != panes_before); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig")); + try std.testing.expectEqual(panes.Output.Grain.word, panes.Output.grain(p.panes[p.active].?.file.?.output)); +} + +test "n/N resumes the result output whose Look moved focus away" { + if (platform == .web) return; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "look-owner.txt", + .data = "alpha target\nbeta target\ngamma target\n", + }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "Look target")); + const rid = p.panes[0].?.search_pane orelse return error.MissingResults; + const results = p.panes[rid].?; + const first = results.cur_row; + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expect(results.look_at != null); + try std.testing.expectEqual(results.serial, p.look_walk_owner orelse return error.MissingLookOwner); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(first + 1, results.cur_row); + p.update(.{ .key = .{ .cp = 'N' } }); + try std.testing.expectEqual(first, results.cur_row); + + // A later no-match answer has no position to resume and therefore cannot + // steal the provenance established by the Look above. + const owner = results.serial; + const dir = std.fs.path.dirname(path) orelse "."; + try panes.Output.fillResults( + p, + 0, + dir, + .search, + "no-such-result", + try p.gpa.dupe(u8, ""), + null, + ); + try std.testing.expectEqual(owner, p.look_walk_owner.?); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(first + 1, results.cur_row); + + // Emptying the owner itself keeps its identity as the ring's starting + // point. Deleting it then leaves a stale serial, never an id that can bind + // to the unrelated pane subsequently allocated in the same slot. + try panes.Output.fillResults(p, 0, dir, .search, "target", try p.gpa.dupe(u8, ""), null); + try std.testing.expectEqual(owner, p.look_walk_owner.?); + try std.testing.expect(p.executeBuiltinLine(rid, "Del")); + try std.testing.expect(p.paneBySerial(owner) == null); + const replacement = try p.newShell(rid, ""); + try std.testing.expect(replacement.serial != owner); + try std.testing.expect(p.paneBySerial(owner) == null); +} + +test "N exactly reverses n across output panes and the ring seam" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + const first = try walkFixture(p, p.active, paths.cwd, "one"); + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); + const second = try walkFixture(p, p.active, paths.cwd, "two"); + try std.testing.expect(first != second); + + const Mark = struct { pane: usize, row: i32, col: i32 }; + const here = struct { + fn at(pp: *Pardes) Mark { + const pane = pp.panes[pp.active].?; + return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col }; + } + }.at; + p.update(.{ .key = .{ .cp = 'n' } }); + const base = here(p); + var trail: [12]Mark = undefined; + for (&trail) |*mark| { + p.update(.{ .key = .{ .cp = 'n' } }); + mark.* = here(p); + } + var i = trail.len; + while (i > 0) { + i -= 1; + p.update(.{ .key = .{ .cp = 'N' } }); + const want = if (i == 0) base else trail[i - 1]; + try std.testing.expectEqual(want, here(p)); + } + var saw_first = false; + var saw_second = false; + for (trail) |mark| { + saw_first = saw_first or mark.pane == first; + saw_second = saw_second or mark.pane == second; + } + try std.testing.expect(saw_first and saw_second); +} + +test "n/N selects command outputs by whole row" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + + try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel")); + const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList; + const themes = p.panes[tid].?; + try std.testing.expect(panes.Output.fileTraits(themes.file.?.output).commands); + p.update(.{ .key = .{ .cp = 'n' } }); + const row0 = std.mem.trimEnd(u8, modal.lineSlice(themes.file.?.content, 0), " \t\r"); + try std.testing.expectEqualStrings(row0, selectedOutputText(themes) orelse ""); + const theme_before = p.settings.theme; + p.update(.{ .key = .{ .cp = 'n' } }); + p.update(.{ .key = .{ .cp = Key.tab } }); + try std.testing.expect(p.settings.theme != theme_before); + + try std.testing.expect(p.executeBuiltinLine(0, "Look build.zig")); + p.update(.tick); + try std.testing.expect(p.active != 0); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(usize, 0), p.active); +} + +test "identical result refresh keeps warm content caches without allocation" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + p.settings.colors = true; + const text = "a.zig:1:1 pub fn first() void {}\na.zig:2:1 if (true) return;\n"; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, text), 0); + const id = source.search_pane orelse return error.MissingResults; + const result = p.panes[id].?; + const file = &result.file.?; + result.cur_row = 1; + result.cur_col = 12; + result.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 2 }; + result.select = true; + file.scroll = 1; + result.hscroll = 3; + panes.File.refreshHighlights(p); + const index = try panes.File.lineIndex(p.gpa, file); + const styles = try std.testing.allocator.dupe(u8, file.highlights); + defer std.testing.allocator.free(styles); + if (@import("pardes_config").syntax_zig_grammar) try std.testing.expect(styles.len > 0); + const revision = file.revision; + const saved_revision = file.saved_revision; + const body_ptr = file.content.ptr; + const style_ptr = file.highlights.ptr; + const highlight_start = file.highlight_start; + const selected = result.vsel; + const event_node = pardes.filesystem.Node.of(result.serial, .event); + const opened = pardes.filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + try std.testing.expectEqual(.ok, opened.status); + defer _ = pardes.filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle }); + + for (0..32) |_| { + const incoming = try p.gpa.dupe(u8, text); + const allocations = allocator.allocations; + const freed = allocator.freed_bytes; + const deallocations = allocator.deallocations; + allocator.fail_index = allocator.alloc_index; + defer allocator.fail_index = std.math.maxInt(usize); + p.active = id; + source.search_pane = null; + source.search_row = null; + p.look_walk_owner = null; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "after", incoming, 1); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(saved_revision, file.saved_revision); + try std.testing.expectEqual(body_ptr, file.content.ptr); + try std.testing.expectEqual(index.ptr, file.line_starts.ptr); + try std.testing.expectEqual(style_ptr, file.highlights.ptr); + try std.testing.expectEqual(highlight_start, file.highlight_start); + try std.testing.expectEqualSlices(u8, styles, file.highlights); + try std.testing.expect(!file.syntax_dirty); + panes.File.refreshHighlights(p); + try std.testing.expectEqual(allocations, allocator.allocations); + try std.testing.expectEqual(freed + text.len, allocator.freed_bytes); + try std.testing.expectEqual(deallocations + 1, allocator.deallocations); + try std.testing.expect(!allocator.has_induced_failure); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expect(result.select); + try std.testing.expectEqual(@as(i32, 1), result.cur_row); + try std.testing.expectEqual(@as(i32, 12), result.cur_col); + try std.testing.expectEqual(@as(usize, 1), file.scroll); + try std.testing.expectEqual(@as(i32, 3), result.hscroll); + try std.testing.expectEqualStrings("after", file.output.?.arg()); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + try std.testing.expectEqual(result.serial, p.look_walk_owner.?); + try std.testing.expect(p.fs.panes[id].events.empty()); + } + + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "changed", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expectEqual(revision + 1, file.revision); + try std.testing.expectEqualStrings("short\n", file.content); + try std.testing.expectEqualStrings("changed", file.output.?.arg()); + try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + try std.testing.expectEqual(@as(usize, 0), file.highlights.len); + try std.testing.expect(file.syntax_dirty); + try std.testing.expect(!result.vsel.active and !result.select); + try std.testing.expectEqual(@as(i32, 0), result.cur_row); + try std.testing.expectEqual(@as(i32, 0), result.cur_col); + try std.testing.expectEqual(@as(usize, 0), file.scroll); + try std.testing.expectEqual(@as(i32, 0), result.hscroll); + try std.testing.expect(source.search_row == null); + const events = &p.fs.panes[id].events; + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "KD")); + events.pop(); + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "KI")); + events.pop(); + try std.testing.expect(events.empty()); +} + +test "rejected result refresh frees incoming text and leaves the existing output intact" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "keep\n"), 1); + const id = source.search_pane.?; + const result = p.panes[id].?; + const file = &result.file.?; + const body_ptr = file.content.ptr; + const revision = file.revision; + const free_slot = p.freeSlot(); + var oversized: [panes.Output.max_arg + 1]u8 = @splat('x'); + for ([_]bool{ false, true }) |oom| { + const incoming = try p.gpa.dupe(u8, "discard\n"); + const freed = allocator.freed_bytes; + const deallocations = allocator.deallocations; + if (oom) allocator.fail_index = allocator.alloc_index; + defer allocator.fail_index = std.math.maxInt(usize); + try std.testing.expectError(if (oom) error.OutOfMemory else error.ArgumentTooLong, panes.Output.fillResults( + p, + 0, + "/tmp", + if (oom) .search else .{ .query = .references }, + if (oom) "new output" else &oversized, + incoming, + 0, + )); + try std.testing.expectEqual(freed + "discard\n".len, allocator.freed_bytes); + try std.testing.expectEqual(deallocations + 1, allocator.deallocations); + try std.testing.expectEqual(body_ptr, file.content.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqualStrings("keep\n", file.content); + try std.testing.expectEqualStrings("before", file.output.?.arg()); + try std.testing.expectEqual(free_slot, p.freeSlot()); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + try std.testing.expectEqual(@as(usize, 0), p.active); + } + try std.testing.expect(allocator.has_induced_failure); +} diff --git a/test/panes.zig b/test/panes.zig new file mode 100644 index 00000000..7d7a9ab7 --- /dev/null +++ b/test/panes.zig @@ -0,0 +1,2743 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const layout = pardes.layout; +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const Key = pardes.Key; +const config = pardes.config; +const modal = pardes.modal; +const dump = pardes.dump; +const image = pardes.image; +const syntax = pardes.syntax; +const filesystem = pardes.filesystem; + +test "terminal overlay recoloring never materializes unrelated history" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 32, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + for (0..1000) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32mcaf\xc3\xa9 \xe7\x95\x8c\x1b[0m\r\n" } }); + pane.mode = .normal; + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "edited history") }; + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + try testing.expect(p.shell_rows.pane == null); + + pane.ovl.?.row = panes.Terminal.gridOffset(pane); + p.gpa.free(pane.ovl.?.text); + pane.ovl.?.text = try p.gpa.dupe(u8, "caf\xc3\xa9 \xe7\x95\x8c"); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + try testing.expect(p.shell_rows.pane == null); + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const cell = surface.at(rect.x + config.GUTTER, body_y); + try testing.expectEqualStrings("c", cell.grapheme()); + try testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg); +} + +test "terminal output keeps following new rows after scrollback eviction" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + pane.tty_filter = false; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + var evictions: usize = 0; + for (0..3) |phase| { + if (phase > 0) { + pane.mode = .normal; + panes.Terminal.scrollGrid(pane, -30); + try std.testing.expect(pages.scrollbar().offset < pages.scrollbar().total - pane.rows); + panes.Terminal.enterTty(p, 0); + } + for (phase * 20_000..(phase + 1) * 20_000) |n| { + var buf: [64]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "\x1b[32mrow-{d:0>5}\x1b[0m\r\n", .{n}); + const previous = pages.scrollbar().total; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + if (pages.scrollbar().total < previous) evictions += 1; + } + try std.testing.expect(evictions > phase); + try std.testing.expect(pages.page_size <= pages.maxSize()); + const sb = pages.scrollbar(); + try std.testing.expectEqual(sb.total - pane.rows, sb.offset); + const full = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(full); + var last_buf: [16]u8 = undefined; + const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{(phase + 1) * 20_000 - 1}); + try std.testing.expect(std.mem.indexOf(u8, full, "row-00000") == null); + try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, full, "\n"), last)); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER; + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pane.rows - 2; + for (last, 0..) |byte, col| { + const cell = surface.at(x + @as(u16, @intCast(col)), y); + try std.testing.expectEqualStrings(&.{byte}, cell.grapheme()); + try std.testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg); + } + while (p.nextEffect()) |_| {} + p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const effect = p.nextEffect() orelse return error.MissingTerminalInput; + try std.testing.expectEqual(.write, std.meta.activeTag(effect)); + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("f", effect.write.bytes.slice()); + try std.testing.expect(p.nextEffect() == null); + } + const replay = &pane.terminal.?.replay; + try std.testing.expectEqual(replay.bytes.len, replay.len); + try std.testing.expect(replay.head > 0); +} + +test "terminal edit stays on its surviving row when old scrollback is evicted" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var next: usize = 0; + while (next < 3000) { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next}); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + next += 1; + if (pages.scrollbar().total > pages.pages.first.?.rows() + pane.rows + 10) break; + } + try std.testing.expect(next < 3000); + var last_buf: [16]u8 = undefined; + const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{next - 1}); + pane.mode = .normal; + const original_row: i32 = @intCast(pages.scrollbar().total - 2); + pane.ovl = .{ .row = original_row, .rows = 1, .text = try gpa.dupe(u8, "my unsent edit") }; + pane.cur_row = original_row; + pane.cur_col = 3; + pane.cur_pinned = true; + var removed: usize = 0; + for (next..next + 3000) |n| { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{n}); + const previous = pages.scrollbar().total; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + if (pages.scrollbar().total < previous) { + removed = previous + 1 - pages.scrollbar().total; + break; + } + } + try std.testing.expect(removed > 0 and removed < original_row); + const full = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(full); + const row = original_row - @as(i32, @intCast(removed)); + try std.testing.expectEqualStrings(last, modal.lineSlice(full, @intCast(row))); + try std.testing.expectEqual(row, pane.ovl.?.row); + try std.testing.expectEqual(row, pane.cur_row); + try std.testing.expectEqualStrings("my unsent edit", pane.ovl.?.text); +} + +test "terminal edits and undo survive partial history clearing and whole-screen loss" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const Action = enum { clear_history, partial_clear_history, reset_primary, reset_alternate, evict_all, hidden_undo }; + for (std.enums.values(Action)) |action| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + if (action == .reset_alternate) + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h" } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "old row\r\n" ** 40 } }); + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + const row: i32 = @as(i32, @intCast(pages.total_rows - pane.rows)) + + @as(i32, if (action == .partial_clear_history) -1 else 2); + pane.mode = .normal; + pane.ovl = .{ .row = row, .rows = if (action == .partial_clear_history) 3 else 2, .text = try gpa.dupe(u8, "edit one\nedit two") }; + pane.cur_row = row + 1; + pane.cur_col = 4; + pane.cur_pinned = true; + pane.vsel = .{ .active = true, .row = row, .col = 1 }; + panes.Terminal.pushUndo(p, pane); + pane.ed_redo[0] = panes.Terminal.snap(p, pane) orelse return error.SnapshotFailed; + pane.ed_redo_len = 1; + if (action == .hidden_undo) { + gpa.free(pane.ovl.?.text); + pane.ovl = null; + panes.Terminal.enterTty(p, 0); + } + const bytes = switch (action) { + .clear_history, .partial_clear_history => "\x1b[3J", + .reset_primary, .reset_alternate => "\x1bc", + .evict_all, .hidden_undo => "new row\r\n" ** 4000, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + const expected: i32 = if (action == .clear_history) 2 else 0; + if (pane.ovl) |edit| { + try std.testing.expectEqual(expected, edit.row); + try std.testing.expectEqual(@as(i32, if (action == .clear_history or action == .partial_clear_history) 2 else 1), edit.rows); + try std.testing.expectEqualStrings("edit one\nedit two", edit.text); + try std.testing.expectEqual(expected + 1, pane.cur_row); + try std.testing.expectEqual(expected, pane.vsel.row); + } + for ([_]panes.Terminal.Snapshot{ pane.ed_undo[0], pane.ed_redo[0] }) |snapshot| { + try std.testing.expectEqual(expected, snapshot.ovl.?.row); + try std.testing.expectEqual(expected + 1, snapshot.cur_row); + try std.testing.expectEqualStrings("edit one\nedit two", snapshot.ovl.?.text); + } + try std.testing.expectEqual(@as(usize, 2), pane.terminal.?.vt.screens.active.pages.countTrackedPins()); + panes.Terminal.undo(p, pane); + try std.testing.expectEqualStrings("edit one\nedit two", pane.ovl.?.text); + try std.testing.expectEqual(expected, pane.ovl.?.row); + } +} + +test "terminal lower scroll regions do not move edits above them" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "top\r\nsecond\r\nthird" } }); + pane.ovl = .{ .row = 1, .rows = 1, .text = try gpa.dupe(u8, "unsent") }; + pane.cur_row = 1; + pane.cur_pinned = true; + panes.Terminal.pushUndo(p, pane); + const total = panes.Terminal.scrollbar(pane).total; + var setup: [64]u8 = undefined; + const bytes = try std.fmt.bufPrint(&setup, "\x1b[4;{d}r\x1b[{d};1H", .{ pane.rows, pane.rows }); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "tail\r\n" ** 50 } }); + try std.testing.expectEqual(total, panes.Terminal.scrollbar(pane).total); + try std.testing.expectEqual(@as(i32, 1), pane.ovl.?.row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.ed_undo[0].ovl.?.row); + const full = try panes.Terminal.screenTextAlloc(pane, gpa); + defer gpa.free(full); + try std.testing.expect(std.mem.startsWith(u8, full, "top\nsecond\nthird\n")); +} + +test "terminal eviction and lower-region scrolling in one read preserve a surviving edit" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var next: usize = 0; + while (next < 3000) { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next}); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + next += 1; + if (pages.pages.first != pages.pages.last and + pages.pages.last.?.rows() == pages.pages.last.?.capacity().rows) break; + } + try std.testing.expect(next < 3000); + const removed = pages.pages.first.?.rows(); + const old_row: i32 = @intCast(removed + 2); + pane.mode = .normal; + pane.ovl = .{ .row = old_row, .rows = 1, .text = try gpa.dupe(u8, "kept edit") }; + pane.cur_row = old_row; + pane.cur_pinned = true; + panes.Terminal.pushUndo(p, pane); + const old_total = pages.total_rows; + var buf: [128]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "new\r\n\x1b[4;{d}r\x1b[{d};1Hregion\r\n", .{ pane.rows, pane.rows }); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + try std.testing.expectEqual(old_total + 1 - removed, pages.total_rows); + try std.testing.expectEqual(@as(i32, 2), pane.ovl.?.row); + try std.testing.expectEqual(@as(i32, 2), pane.cur_row); + try std.testing.expectEqual(@as(i32, 2), pane.ed_undo[0].ovl.?.row); + try std.testing.expectEqualStrings("kept edit", pane.ovl.?.text); +} + +test "tag prompt entry and cancellation preserve exact text and cursor state" { + const Case = struct { key: ?Key = null, save: bool = false, marker: []const u8 }; + const cases = [_]Case{ + .{ .marker = config.search_marker }, + .{ .save = true, .marker = config.save_marker ++ "/dír space/" }, + .{ .key = .{ .cp = '|' }, .marker = config.pipe_marker }, + .{ .key = .{ .cp = '|', .alt = true }, .marker = config.pipe_marker_to }, + .{ .key = .{ .cp = '!' }, .marker = config.pipe_marker_insert }, + .{ .key = .{ .cp = '!', .alt = true }, .marker = config.pipe_marker_append }, + }; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo\n"); + try pane.setOwnedCwd("/dír space"); + const tail = " keep"; + for (cases) |case| { + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; + pane.tag_init = true; + pane.tag_sel = true; + pane.tag_col = 1; + pane.cur_row = 1; + pane.cur_col = 2; + pane.cur_pinned = true; + if (case.key) |key| { + p.update(.{ .key = key }); + } else if (case.save) { + p.startPrompt(pane, .save); + } else { + p.startPrompt(pane, .{ .search = config.search_marker }); + } + try std.testing.expect(pane.tag_edit); + try std.testing.expect(!pane.tag_sel); + try std.testing.expectEqual(.insert, pane.mode); + try std.testing.expectEqualDeep(modal.Normal.State{}, pane.normal); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..tail.len]); + try std.testing.expectEqualStrings(case.marker, pane.tag_tail[tail.len..pane.tag_tail_len]); + try std.testing.expectEqual(pane.file.?.path.len + pane.tag_tail_len, pane.tag_col); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + try std.testing.expectEqual(@as(i32, 2), pane.cur_col); + try std.testing.expectEqualStrings("one\ntwo\n", pane.file.?.content); + } +} + +test "tag prompt capacity refusal leaves the existing tail and input state untouched" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + @memset(&pane.tag_tail, 'x'); + pane.tag_tail_len = pane.tag_tail.len - 1; + pane.tag_init = true; + pane.tag_col = 17; + pane.tag_sel = true; + for (0..3) |case| { + switch (case) { + 0 => p.startPrompt(pane, .{ .search = config.search_marker }), + 1 => p.startPrompt(pane, .save), + else => p.update(.{ .key = .{ .cp = '|' } }), + } + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit); + try std.testing.expect(pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + try std.testing.expectEqual(pane.tag_tail.len - 1, pane.tag_tail_len); + for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte); + } +} + +test "generated output refuses every pipe prompt before seeding the tag" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const content = try p.gpa.dupe(u8, "generated\n"); + const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content); + p.active = 1; + for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| { + p.update(.{ .key = key }); + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_init); + try std.testing.expect(!pane.tag_edit); + try std.testing.expectEqualStrings("generated\n", pane.file.?.content); + } +} + +test "tag prompt owns the pipe behavior through cancellation and submission" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + for (std.enums.values(modal.Normal.PipeBehavior)) |how| { + p.startPrompt(pane, .{ .pipe = .append }); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.startPrompt(pane, .{ .pipe = how }); + try std.testing.expectEqual(how, pane.prompt.pipe.how); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const pending = p.pipe_wait orelse return error.MissingPipe; + try std.testing.expectEqual(how, pending.how); + try std.testing.expectEqualStrings("cat", pending.command); + try std.testing.expectEqual(@as(usize, 1), pending.inputs.len); + try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes); + try std.testing.expect(pane.prompt == .none); + } +} + +test "tag prompt cancellation restores the selection before regex previews" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one two one"); + const cases = [_]struct { key: u21, pattern: []const u8, secondary: u8 }{ + .{ .key = 's', .pattern = "one", .secondary = 1 }, + .{ .key = 'S', .pattern = " ", .secondary = 2 }, + }; + for (cases) |case| { + pane.cur_row = 0; + pane.cur_col = 10; + pane.cur_pinned = true; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + pane.nsel = 0; + p.update(.{ .key = .{ .cp = case.key } }); + try std.testing.expect(pane.prompt == .search); + p.update(.{ .key = .{ .cp = case.pattern[0], .text = case.pattern } }); + try std.testing.expectEqual(case.secondary, pane.nsel); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(u8, 0), pane.nsel); + try std.testing.expectEqual(@as(u8, 0), pane.nsel_snap); + try std.testing.expectEqual(@as(i32, 10), pane.cur_col); + try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); + try std.testing.expect(pane.vsel.active and pane.vsel.explicit); + try std.testing.expectEqualStrings("one two one", pane.file.?.content); + } +} + +test "tag prompt prefix allocation failure preserves an already seeded prompt" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + const tail = " keep /prior"; + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; + pane.tag_init = true; + pane.prompt = .{ .search = 5 }; + pane.tag_edit = true; + pane.tag_sel = true; + pane.tag_col = 14; + pane.tag_anchor = 7; + pane.normal = .{ .count = 9, .prefix = .goto }; + const previous_prompt = pane.prompt; + const previous_normal = pane.normal; + _ = p.scratch.reset(.free_all); + failing.fail_index = failing.alloc_index; + p.startPrompt(pane, request); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqualDeep(previous_prompt, pane.prompt); + try std.testing.expectEqualDeep(previous_normal, pane.normal); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]); + try std.testing.expect(pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqual(@as(u16, 14), pane.tag_col); + try std.testing.expectEqual(@as(u16, 7), pane.tag_anchor); + } +} + +test "tag prompt Save requires room for its complete directory prefix" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + for ([_][]const u8{ "/dír space", "/dír space/", "" }) |dir| { + try pane.setOwnedCwd(dir); + const slash: usize = @intFromBool(dir.len == 0 or dir[dir.len - 1] != '/'); + const needed = config.save_marker.len + dir.len + slash; + for ([_]usize{ 1, 0 }) |shortfall| { + @memset(&pane.tag_tail, 'x'); + pane.tag_tail_len = pane.tag_tail.len - needed + shortfall; + pane.tag_init = true; + pane.tag_edit = false; + pane.tag_sel = true; + pane.tag_col = 17; + const start = pane.tag_tail_len; + p.startPrompt(pane, .save); + if (shortfall != 0) { + try std.testing.expect(pane.prompt == .none); + try std.testing.expectEqual(start, pane.tag_tail_len); + try std.testing.expect(!pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte); + } else { + try std.testing.expect(pane.prompt == .save); + try std.testing.expectEqual(pane.tag_tail.len, pane.tag_tail_len); + try std.testing.expectEqualStrings(config.save_marker, pane.tag_tail[start..][0..config.save_marker.len]); + try std.testing.expectEqualStrings(dir, pane.tag_tail[start + config.save_marker.len ..][0..dir.len]); + try std.testing.expectEqual(@as(u8, '/'), pane.tag_tail[pane.tag_tail_len - 1]); + p.update(.{ .key = .{ .cp = Key.escape } }); + } + } + } +} + +test "tag prompt successful seeded entry needs one scratch allocation" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + pane.tag_tail_len = 0; + pane.tag_init = true; + _ = p.scratch.reset(.free_all); + const before = failing.alloc_index; + p.startPrompt(pane, request); + try std.testing.expectEqual(before + 1, failing.alloc_index); + try std.testing.expect(pane.tag_edit and pane.mode == .insert); + } +} + +test "tag prompt failure may initialize only the default tail" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var saw_seeded_failure = false; + var succeeded = false; + for (0..16) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + const path = try p.gpa.dupe(u8, "/" ++ "a" ** 2048 ++ ".zig"); + p.gpa.free(pane.file.?.path); + pane.file.?.path = path; + p.seedTail(pane); + try std.testing.expect(pane.tag_init); + const default_tail = pane.tag_tail; + const default_len = pane.tag_tail_len; + pane.tag_init = false; + pane.tag_tail_len = 0; + pane.tag_col = 17; + pane.tag_sel = true; + pane.normal = .{ .count = 9, .prefix = .goto }; + const previous_normal = pane.normal; + _ = p.scratch.reset(.free_all); + failing.fail_index = failing.alloc_index + failure; + p.startPrompt(pane, request); + if (!failing.has_induced_failure) { + try std.testing.expect(pane.tag_edit and pane.mode == .insert); + succeeded = true; + break; + } + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqualDeep(previous_normal, pane.normal); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + if (pane.tag_init) { + saw_seeded_failure = true; + try std.testing.expectEqualStrings(default_tail[0..default_len], pane.tag_tail[0..pane.tag_tail_len]); + } else try std.testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + } + try std.testing.expect(succeeded and saw_seeded_failure); + } +} + +test "file history construction failures preserve existing panes" { + const Case = enum { open, restore, output, tutor, replace }; + for (std.enums.values(Case)) |case| { + var succeeded = false; + for (0..32) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = try p.setTestFile("original\n"); + const old_history = original.file.?.history; + const serial = p.next_serial; + var path_buffer: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{original.serial}); + var incoming: ?[]u8 = if (case == .output) try p.gpa.dupe(u8, "generated\n") else null; + defer if (incoming) |bytes| p.gpa.free(bytes); + failing.fail_index = failing.alloc_index + failure; + const result = switch (case) { + .open => panes.File.open(p, 1, path, 0), + .restore => panes.File.restore(p, 1, .{ + .kind = .file, + .tag = "", + .body = "", + .file = .{ .path = "/restored.zig", .content = "restored\n" }, + }), + .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?), + .tutor => p.openTutorView(1), + .replace => p.setTestFile("replacement\n"), + }; + if (result) |pane| { + if (case == .output) incoming = null; + try std.testing.expect(pane.file.?.history != old_history); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len); + succeeded = true; + break; + } else |err| { + try std.testing.expect(err == error.OutOfMemory or (case == .open and err == error.WriteFailed and failing.has_induced_failure)); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqual(old_history, original.file.?.history); + try std.testing.expectEqualStrings("original\n", original.file.?.content); + try std.testing.expectEqual(serial, p.next_serial); + try std.testing.expect(p.panes[1] == null); + try std.testing.expect(!p.reserved_slots[1]); + } + } + try std.testing.expect(succeeded); + } +} + +test "file history owns bounded snapshot storage without per-edit bookkeeping allocations" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("initial"); + const file = &pane.file.?; + const history = file.history; + for (0..pardes.memory.limits.undo_max + 2) |index| { + const next = try std.fmt.allocPrint(p.gpa, "{d}", .{index}); + const before = failing.alloc_index; + panes.File.pushUndo(p, pane); + try std.testing.expectEqual(before + 1, failing.alloc_index); + panes.File.setContent(p, file, next); + try std.testing.expectEqual(history, file.history); + } + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqualStrings("1", history.undo[0].content); + const current = file.content; + failing.fail_index = failing.alloc_index; + panes.File.pushUndo(p, pane); + panes.File.undo(p, pane); + try std.testing.expectEqual(current.ptr, file.content.ptr); + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqual(@as(usize, 0), history.redo_len); + failing.fail_index = std.math.maxInt(usize); + const before = failing.alloc_index; + panes.File.undo(p, pane); + try std.testing.expectEqual(before + 2, failing.alloc_index); + try std.testing.expectEqual(@as(usize, 1), file.line_starts.len); + try std.testing.expectEqual(@as(usize, 1), history.redo_len); + const undone = file.content; + failing.fail_index = failing.alloc_index; + panes.File.redo(p, pane); + try std.testing.expectEqual(undone.ptr, file.content.ptr); + try std.testing.expectEqual(@as(usize, 1), history.redo_len); + failing.fail_index = std.math.maxInt(usize); + const before_redo = failing.alloc_index; + panes.File.redo(p, pane); + try std.testing.expectEqual(before_redo + 2, failing.alloc_index); + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqual(@as(usize, 0), history.redo_len); +} + +test "file history allocation precedes a filesystem read with side effects" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = p.panes[0].?; + const serial = p.next_serial; + failing.fail_index = failing.alloc_index + 2; + try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new/ctl", 0)); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqual(serial, p.next_serial); + for (p.panes[1..], p.reserved_slots[1..]) |pane, reserved| { + try std.testing.expect(pane == null); + try std.testing.expect(!reserved); + } +} + +test "file history survives PDF fallback restoration and allocation failures" { + const saved = + \\.{ + \\ .screen = .{ .cols = 40, .rows = 12 }, + \\ .columns = .{.{ .panes = .{0} }}, + \\ .panes = .{.{ + \\ .kind = .image, .tag = "", .body = "", + \\ .image = .{ .path = "/virtual/missing-history.pdf", .bytes_b64 = "cmF3" }, + \\ }}, + \\} + ; + var succeeded = false; + for (0..256) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = failure }); + if (Pardes.initFromDump(failing.allocator(), .{ .tty_only = true }, saved)) |p| { + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expectEqualStrings("raw", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len); + if (!failing.has_induced_failure) { + succeeded = true; + break; + } + } else |err| { + try std.testing.expect(failing.has_induced_failure); + try std.testing.expect(err == error.OutOfMemory or err == error.WriteFailed); + } + } + try std.testing.expect(succeeded); +} + +test "Save rearms renamed file watches only after a successful write" { + const Host = struct { + const Result = enum { success, failure, edited, replaced, closed }; + core: *Pardes, + result: Result, + watches: usize = 0, + err: ?anyerror = null, + + fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const h: *@This() = @ptrCast(@alignCast(ctx.?)); + switch (h.result) { + .success => {}, + .failure => h.core.saveFailed(id, "save", error.PermissionDenied), + .edited => h.core.panes[id].?.file.?.revision +%= 1, + .replaced => { + const replacement = h.core.setTestFile("replacement\n") catch |err| { + h.err = err; + return; + }; + replacement.file.?.watch_after_save = true; + replacement.file.?.saved_revision -%= 1; + }, + .closed => { + h.core.deinitPane(h.core.panes[id].?) catch |err| { + h.err = err; + return; + }; + h.core.panes[id] = null; + }, + } + } + + fn watch(ctx: ?*anyopaque, _: u8, _: []const u8, on: bool, _: pardes.WatchMode) void { + const h: *@This() = @ptrCast(@alignCast(ctx.?)); + if (on) h.watches += 1; + } + }; + const Case = struct { result: Host.Result, path: []const u8 = "/renamed.zig", watches: usize = 0 }; + for ([_]Case{ + .{ .result = .success, .watches = 1 }, + .{ .result = .success, .path = "/n/os/renamed.zig", .watches = 1 }, + .{ .result = .success, .path = "/virtual/pane/0/body" }, + .{ .result = .success, .path = "/n/remote/file.zig" }, + .{ .result = .failure }, + .{ .result = .edited }, + .{ .result = .replaced }, + .{ .result = .closed }, + }) |case| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = try p.setTestFile("edited\n"); + p.gpa.free(original.file.?.path); + original.file.?.path = try p.gpa.dupe(u8, case.path); + original.file.?.saved_revision -%= 1; + original.file.?.watch_after_save = true; + while (p.nextEffect()) |_| {} + var host: Host = .{ .core = p, .result = case.result }; + p.host = .{ .ctx = &host, .vtable = &.{ .write_file = Host.write, .watch_file = Host.watch } }; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + if (host.err) |err| return err; + try std.testing.expectEqual(case.watches, host.watches); + if (p.panes[0]) |pane| { + const file = &pane.file.?; + try std.testing.expectEqual(case.result != .success, file.watch_after_save); + try std.testing.expectEqual(case.result == .success, file.revision == file.saved_revision); + } else try std.testing.expectEqual(.closed, case.result); + if (case.result == .failure) { + host.result = .success; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expectEqual(@as(usize, 1), host.watches); + try std.testing.expect(!original.file.?.watch_after_save); + try std.testing.expectEqual(original.file.?.revision, original.file.?.saved_revision); + } + } +} + +test "Save on a scratch does not watch a path the host could not create" { + const Refusing = struct { + fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const p: *Pardes = @ptrCast(@alignCast(ctx.?)); + p.saveFailed(id, "save", error.PermissionDenied); + } + }; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(0, "New")); + const id = p.active; + while (p.nextEffect()) |_| {} + p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.write } }; + try std.testing.expect(p.executeBuiltinLine(id, "Save /new-file.txt")); + while (p.nextEffect()) |effect| p.perform(effect); + const file = &p.panes[id].?.file.?; + try std.testing.expect(file.output == null); + try std.testing.expect(file.watch_after_save); + try std.testing.expect(file.revision != file.saved_revision); + try std.testing.expect(!p.fallback.watched[id]); + p.host = .{}; + try std.testing.expect(p.executeBuiltinLine(id, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expect(!file.watch_after_save); + try std.testing.expectEqual(file.revision, file.saved_revision); + try std.testing.expect(p.fallback.watched[id]); +} + +test "unplaced terminal panes keep their allocated grid until placement" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + while (p.nextEffect()) |_| {} + const id = p.freeSlot().?; + const pane = try p.newShell(id, ""); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "unplaced\r\n" } }); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + try std.testing.expectEqual(@as(u16, 100), pane.cols); + try std.testing.expectEqual(@as(u16, 30), pane.rows); + try std.testing.expectEqual(pane.cols, pane.terminal.?.vt.cols); + try std.testing.expectEqual(pane.rows, pane.terminal.?.vt.rows); + while (p.nextEffect()) |effect| { + if (effect == .resize_pty and effect.resize_pty.pane == id) return error.ResizedUnplacedPane; + } + pardes.layout.insert(p, 0, 1, id); + p.update(.tick); + try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > pardes.BOX_H); + try std.testing.expectEqual(p.rects[id].w - config.GUTTER, pane.cols); + try std.testing.expectEqual(p.rects[id].h - pardes.BOX_H, pane.rows); +} + +test "document panes ignore terminal output without allocating terminal state" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("document body\n"); + while (p.nextEffect()) |_| {} + const revision = pane.file.?.revision; + const allocations = allocator.alloc_index; + const resizes = allocator.resize_index; + allocator.fail_index = allocations; + allocator.resize_fail_index = resizes; + p.update(.{ .output = .{ .pane = 0, .bytes = "unrelated PTY output\r\n" } }); + try std.testing.expect(!allocator.has_induced_failure); + try std.testing.expectEqual(allocations, allocator.alloc_index); + try std.testing.expectEqual(resizes, allocator.resize_index); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqualStrings("document body\n", pane.file.?.content); +} + +test "removed and recycled pane slots have no stale geometry" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + const id = p.freeSlot().?; + const original = try p.newDocPane(id); + pardes.layout.insert(p, 0, 1, id); + layout.compute(p); + try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > 0); + layout.removePane(p, id); + layout.compute(p); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + + try p.deinitPane(original); + p.panes[id] = null; + p.rects[id] = .{ .x = 10, .y = 5, .w = 20, .h = 10 }; + const replacement = try p.newDocPane(id); + try std.testing.expect(replacement.serial != original.serial); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + pardes.layout.insert(p, 0, 1, id); + + for ([_]u16{ 1, 2, 3, 10 }) |size| { + p.update(.{ .resize = .{ .cols = size, .rows = size } }); + for (p.rects) |rect| { + try std.testing.expect(rect.x + rect.w <= size); + try std.testing.expect(rect.y + rect.h <= size); + } + } + p.ncol = 0; + layout.compute(p); + for (p.rects) |rect| try std.testing.expectEqual(pardes.Rect{}, rect); +} + +const FileTests = struct { + const graphemeDisplayWidth = panes.File.graphemeDisplayWidth; + const displayWidth = panes.File.displayWidth; + const byteAtDisplay = panes.File.byteAtDisplay; + const rawDisplayCol = panes.File.rawDisplayCol; + const rawAtDisplay = panes.File.rawAtDisplay; + const VisualRow = panes.File.VisualRow; + const visualRow = panes.File.visualRow; + + test "display columns map complete Unicode graphemes" { + const text = "é界e\u{301}x"; + try std.testing.expectEqual(@as(usize, 5), displayWidth(text)); + try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2)); + try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4)); + try std.testing.expectEqual(text.len, byteAtDisplay(text, 5)); + try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5)); + try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀")); + } + + test "visual rows partition a line at the breaks the body renders" { + const line = "abcdefgh"; + try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3)); + try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3)); + // Past the end (a cursor on the newline) names the LAST row, and a short + // line is one row however narrow the pane is. + try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3)); + // A grapheme wider than the row still occupies exactly one row. + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1)); + } +}; + +const OutputTests = struct { + const Origin = panes.Output.Origin; + const max_arg = panes.Output.max_arg; + const State = panes.Output.State; + const setArg = panes.Output.setArg; + const fromWord = panes.Output.fromWord; + const fillResults = panes.Output.fillResults; + + test "replacing generated Help clears stale selection before executing a new row" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + p.presentation.enabled = false; + try std.testing.expect(p.executeBuiltinLine(0, "Help")); + const id = p.active; + const help = p.panes[id].?; + for ("gevkk") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expect(help.vsel.active); + try std.testing.expect(help.vsel.row > 5); + try std.testing.expect(help.cur_row != help.vsel.row); + for (" h?") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expectEqual(id, p.active); + const content = help.file.?.content; + try std.testing.expect(std.mem.startsWith(u8, content, "pardes builtins under SPC h\n")); + const at = std.mem.indexOf(u8, content, "Tutor") orelse return error.MissingTutorRow; + const row = std.mem.count(u8, content[0..at], "\n"); + const line_start = if (std.mem.lastIndexOfScalar(u8, content[0..at], '\n')) |nl| nl + 1 else 0; + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(help) + @as(u16, @intCast(at - line_start + 2)); + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(row)); + p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = x, .row = y } }); + try std.testing.expect(p.active != id); + const tutor = p.panes[p.active].?.file orelse return error.MissingTutor; + try std.testing.expect(std.mem.indexOf(u8, tutor.content, "PARDES TUTOR") != null); + } + + test "generated result refills preserve unchanged selections and reset changed text" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + const content = "first\nsecond\nthird\n"; + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null); + const id = for (p.panes, 0..) |slot, id| { + if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id; + } else return error.MissingResults; + const result = p.panes[id].?; + p.active = id; + for ("Cv3") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expect(result.nsel > 0); + try std.testing.expect(result.select); + try std.testing.expect(result.vsel.active); + try std.testing.expect(result.normal.count > 0); + result.file.?.scroll = 1; + result.hscroll = 2; + const selected = result.vsel; + const selections = result.nsel; + const cursor_row = result.cur_row; + const cursor_col = result.cur_col; + + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expectEqual(selections, result.nsel); + try std.testing.expectEqual(cursor_row, result.cur_row); + try std.testing.expectEqual(cursor_col, result.cur_col); + try std.testing.expectEqual(@as(usize, 1), result.file.?.scroll); + try std.testing.expectEqual(@as(i32, 2), result.hscroll); + try std.testing.expect(result.normal.count > 0); + + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expectEqualStrings("short\n", result.file.?.content); + try std.testing.expect(!result.vsel.active and !result.msel.active and !result.select); + try std.testing.expectEqual(@as(u8, 0), result.nsel); + try std.testing.expectEqual(@as(i32, 0), result.cur_row); + try std.testing.expectEqual(@as(i32, 0), result.cur_col); + try std.testing.expectEqual(@as(usize, 0), result.file.?.scroll); + try std.testing.expectEqual(@as(i32, 0), result.hscroll); + try std.testing.expectEqual(modal.Normal.State{}, result.normal); + } + + test "generated result replacement cancels an in-progress body selection" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + p.presentation.enabled = false; + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "first\nsecond\nthird\n"), null); + const id = for (p.panes, 0..) |slot, id| { + if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id; + } else return error.MissingResults; + const result = p.panes[id].?; + p.update(.tick); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(&.{}); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(result); + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y + 1 } }); + try std.testing.expect(p.drag == .select); + try std.testing.expectEqual(.dragging, result.sel[@intFromEnum(config.select_button)].state); + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expect(p.drag == .none); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y + 1 } }); + for (result.sel) |selection| try std.testing.expectEqual(.none, selection.state); + try std.testing.expect(!result.vsel.active and !result.msel.active); + } + + test "dump origins accept only builtins that actually own output panes" { + try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); + try std.testing.expect(fromWord("Kill") == null); + try std.testing.expect(fromWord("Theme") == null); + } + + test "result refill identity retains the full bounded argument" { + const p = try Pardes.init(std.testing.allocator, .{ + .tty_only = true, + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + + var first: [max_arg]u8 = @splat('a'); + var second = first; + first[200] = 'x'; + second[200] = 'y'; + + try fillResults( + p, + 0, + "/tmp", + .search, + &first, + try p.gpa.dupe(u8, "first\n"), + null, + ); + const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; + const next_slot = p.freeSlot(); + + try fillResults( + p, + 0, + "/tmp", + .search, + &first, + try p.gpa.dupe(u8, "refilled\n"), + null, + ); + try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); + try std.testing.expectEqual(next_slot, p.freeSlot()); + try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); + + try fillResults( + p, + 0, + "/tmp", + .search, + &second, + try p.gpa.dupe(u8, "second\n"), + null, + ); + try std.testing.expect(p.panes[0].?.search_pane.? != first_id); + + var output: State = .{ .from = .search }; + var oversized: [max_arg + 1]u8 = @splat('z'); + try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); + const slot_before_error = p.freeSlot(); + try std.testing.expectError( + error.ArgumentTooLong, + fillResults( + p, + 0, + "/tmp", + .search, + &oversized, + try p.gpa.dupe(u8, "must be freed\n"), + null, + ), + ); + try std.testing.expectEqual(slot_before_error, p.freeSlot()); + } +}; + +const ImageTests = struct { + const State = panes.Image.State; + const restore = panes.Image.restore; + const toggleGlyphArt = panes.Image.toggleGlyphArt; + const togglePalette = panes.Image.togglePalette; + const toggleAscii = panes.Image.toggleAscii; + const tagPrefix = panes.Image.tagPrefix; + const legacySavedPrefix = panes.Image.legacySavedPrefix; + + test "pane-local renderer choices are visible in the image tag" { + var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; + toggleGlyphArt(&state); + togglePalette(&state); + toggleAscii(&state); + const tag = try tagPrefix(std.testing.allocator, &state); + defer std.testing.allocator.free(tag); + try std.testing.expectEqualStrings( + config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", + tag, + ); + try std.testing.expectEqualStrings( + "img /tmp/picture.ppm", + legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, + ); + } + + test "dump restore propagates malformed embedded image bytes" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try std.testing.expect(p.panes[1] == null); + try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{ + .kind = .image, + .tag = "img /tmp/bad.ppm", + .body = "", + .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" }, + })); + try std.testing.expect(p.panes[1] == null); + } +}; + +const PdfTests = struct { + const enabled = panes.Pdf.enabled; + const pdf = panes.Pdf.pdf; + const Point = panes.Pdf.Point; + const Document = panes.Pdf.Document; + const isPath = panes.Pdf.isPath; + const legacySavedPrefix = panes.Pdf.legacySavedPrefix; + const State = panes.Pdf.State; + const probeWord = panes.Pdf.probeWord; + const pointAtGeometry = panes.Pdf.pointAtGeometry; + const SectionRows = panes.Pdf.SectionRows; + + test "PDF dump paths are recognized independent of MuPDF support" { + try std.testing.expect(isPath("manual.pdf")); + try std.testing.expect(isPath("MANUAL.PDF")); + try std.testing.expect(!isPath("manual.pdf.txt")); + try std.testing.expect(!isPath("pdf")); + } + + test "legacy PDF prefix is delimited by its stable marker and exact path" { + const path = "/tmp/a document.pdf"; + const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del"; + try std.testing.expectEqualStrings( + "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path, + legacySavedPrefix(path, tag).?, + ); + } + + test "feature-off PDF state is zero-sized" { + if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State)); + } + + test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { + if (!enabled) return; + const entries = [_]pdf.OutlineEntry{ + .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } }, + .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } }, + .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } }, + .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } }, + .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } }, + }; + const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries); + defer std.testing.allocator.free(rows); + try std.testing.expectEqualStrings( + "manual.pdf:3:1 [untitled]\n" ++ + "manual.pdf:3:2 Child Title Café Next\n" ++ + "manual.pdf:1:3 [empty title]\n" ++ + "https://example.com/manual External\n" ++ + "https://example.com/deep Linked branch\n" ++ + "https://example.com/deep Deep link\n", + rows, + ); + try std.testing.expect(SectionRows.resolve(&entries, 4) == null); + try std.testing.expect(SectionRows.resolve(&entries, 5) == null); + const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); + defer std.testing.allocator.free(resolved); + for (entries, 0..) |_, ordinal| { + const single = SectionRows.resolve(&entries, ordinal); + if (resolved[ordinal] == std.math.maxInt(usize)) { + try std.testing.expect(single == null); + } else { + const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse + return error.MissingBulkPdfSectionDestination; + try std.testing.expect(single != null); + try std.testing.expect(std.meta.eql(single.?, bulk)); + } + } + + // Every allocation site in the ordinal table and output growth remains + // atomic: the testing allocator sees each induced failure cleaned up + // before the first index at which the whole render can succeed. + var rendered = false; + for (0..64) |fail_index| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ + .fail_index = fail_index, + }); + const failure_gpa = failing.allocator(); + const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + continue; + }; + failure_gpa.free(attempt); + rendered = true; + break; + } + try std.testing.expect(rendered); + } + + test "bulk PDF section resolution matches single Look policy across DFS boundaries" { + if (!enabled) return; + const entries = [_]pdf.OutlineEntry{ + .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } }, + .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } }, + .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } }, + .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } }, + }; + const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); + defer std.testing.allocator.free(bulk); + const none = std.math.maxInt(usize); + try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk); + for (entries, 0..) |_, ordinal| { + const single = SectionRows.resolve(&entries, ordinal); + if (bulk[ordinal] == none) { + try std.testing.expect(single == null); + } else { + const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse + return error.MissingBoundaryPdfSectionDestination; + try std.testing.expect(single != null); + try std.testing.expect(std.meta.eql(single.?, destination)); + } + } + } + + test "PDF word probe owns quads and text without a selection object" { + if (comptime !enabled) return; + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + var found = try document.search(std.testing.allocator, 0, "Pardes"); + defer found.deinit(std.testing.allocator); + const quad = found.quads[0].quad; + const point = Point{ + .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, + .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, + }; + + // Exactly one shared probe exercises the primitive used by both UI paths; + // click-vs-hover equivalence belongs to production structure, not a second + // test that can only restate this MuPDF result. + var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse + return error.MissingPdfWordProbe; + defer probe.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), probe.page); + try std.testing.expect(probe.quads.len > 0); + try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null); + } + + test "PDF point mapping restores band origin and fractional placement" { + if (comptime !enabled) return; + const point = pointAtGeometry( + .{ + .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 }, + .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 }, + }, + -0.5, + 200, + 200, + 80, + 120, + 34, + false, + ) orelse return error.MissingPdfMappedPoint; + // x: src 30; y: band 80 + local src 14. The returned coordinates address + // pixel centers, matching MuPDF's normalized page-space contract. + try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001); + try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001); + } +}; + +const TerminalTests = struct { + const gridOffset = panes.Terminal.gridOffset; + const scrollGrid = panes.Terminal.scrollGrid; + const promptInputReady = panes.Terminal.promptInputReady; + const forwardKey = panes.Terminal.forwardKey; + const enterTty = panes.Terminal.enterTty; + + // Align normal-mode glyphs with the VT grid and compare their styles. + fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize { + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const rows: usize = r.h - pardes.BOX_H; + const cols: usize = r.w -| config.GUTTER; + + const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle }; + const glyphAt = struct { + fn f(sn: Snap, i: usize) []const u8 { + return sn.text[i][0..sn.len[i]]; + } + }.f; + var shot: [2]Snap = undefined; + for (&shot) |*sn| { + sn.text = try gpa.alloc([7]u8, rows * cols); + sn.len = try gpa.alloc(u8, rows * cols); + sn.style = try gpa.alloc(pardes.CellStyle, rows * cols); + } + defer for (&shot) |*sn| { + gpa.free(sn.text); + gpa.free(sn.len); + gpa.free(sn.style); + }; + + for ([_]pardes.Pane.Mode{ .tty, .normal }, 0..) |mode, i| { + pane.mode = mode; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const s = try p.render(frame.allocator()); + for (0..rows) |row| for (0..cols) |col| { + const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + shot[i].text[row * cols + col] = cell.text; + shot[i].len[row * cols + col] = cell.len; + shot[i].style[row * cols + col] = cell.style; + }; + } + + var diffs: usize = 0; + for (0..rows) |row| { + const base = row * cols; + // The glyph run normal mode shows, and where it ends. + var last: ?usize = null; + for (0..cols) |col| { + if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col; + } + const end = last orelse continue; // blank row: nothing to align + + // Recover the shift: the first offset at which tty mode spells the same + // run. Zero for every row no prompt was hugged out of. + const shift = shift: { + var s: usize = 0; + while (s + end < cols) : (s += 1) { + var all = true; + for (0..end + 1) |col| { + if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) { + all = false; + break; + } + } + if (all) break :shift s; + } + var tty_row: [256]u8 = undefined; + var nrm_row: [256]u8 = undefined; + var tn: usize = 0; + var nn: usize = 0; + for (0..cols) |col| { + const tg = glyphAt(shot[0], base + col); + const ng = glyphAt(shot[1], base + col); + if (tn + tg.len < tty_row.len) { + @memcpy(tty_row[tn..][0..tg.len], tg); + tn += tg.len; + } + if (nn + ng.len < nrm_row.len) { + @memcpy(nrm_row[nn..][0..ng.len], ng); + nn += ng.len; + } + } + std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] }); + diffs += 1; + break :shift null; + } orelse continue; + + // Every column the shift can reach, not just the ones holding a glyph: + // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded + // table cell) carries colour too, and is exactly what a shell paints + // most of. + for (0..cols - shift) |col| { + const want = shot[0].style[base + col + shift]; + const got = shot[1].style[base + col]; + if (std.meta.eql(want, got)) continue; + if (diffs < 6) std.debug.print( + "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}", + .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul }, + ); + diffs += 1; + } + } + if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs }); + return diffs; + } + + test "fresh-shell greeting waits for OSC 133 B input phase" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} // initial spawn + + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforeOutput, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); + try std.testing.expect(!promptInputReady(pane)); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + try std.testing.expect(promptInputReady(pane)); + var greeted = false; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), + else => {}, + }; + try std.testing.expect(greeted); + try std.testing.expect(!pane.greet); + } + + test "fresh-shell commands preserve order and wait for OSC 133 B" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet + + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforeFork, + else => {}, + }; + + p.acknowledgeShell(0, "/bin/bash", true); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + var sent: [64]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| { + const bytes = write.bytes.slice(); + @memcpy(sent[sent_len..][0..bytes.len], bytes); + sent_len += bytes.len; + }, + else => {}, + }; + try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); + try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); + } + + test "unmarked fresh shells omit the automatic greeting" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{}); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} + try std.testing.expect(pane.greet); + + p.acknowledgeShell(0, "/bin/sh", false); + try std.testing.expect(!pane.greet); + try std.testing.expectEqual(.none, pane.pending_command.wait); + p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.UnmarkedGreetingEscaped, + else => {}, + }; + } + + test "raw terminal keys encode text controls and special sequences" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const Case = struct { key: Key, expected: ?[]const u8 }; + const cases = [_]Case{ + .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, + .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, + .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, + .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, + .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, + .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, + .{ .key = .{ .cp = Key.home }, .expected = null }, + }; + for (cases) |case| { + forwardKey(p, 0, case.key); + const expected = case.expected orelse { + try std.testing.expect(p.nextEffect() == null); + continue; + }; + const effect = p.nextEffect() orelse return error.MissingWriteEffect; + switch (effect) { + .write => |write| { + try std.testing.expectEqual(@as(u8, 0), write.pane); + try std.testing.expectEqualStrings(expected, write.bytes.slice()); + }, + else => return error.UnexpectedEffect, + } + try std.testing.expect(p.nextEffect() == null); + } + } + + test "an edit buffer slides shell rows and their colors together" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const green: pardes.Color = .{ .index = 2 }; + const blue: pardes.Color = .{ .index = 4 }; + + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + try testing.expectEqual(red, before.at(tx, body_y).style.fg); + try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg); + try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); + + // Four lines of typed text standing in for the ONE shell row `AAA` was: + // every row below slides down by three, and `surfRow` is the arithmetic + // that says so. The colours have to take the same three rows, or `BBB` + // would be painted green three rows above where it is now drawn. + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + + try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme()); + try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme()); + try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg); + try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg); + + // ...and the rows the user typed are the user's own text: no shell row + // sits under them, so nothing projects a colour onto them. + for (0..4) |i| { + const cell = after.at(tx, body_y + @as(u16, @intCast(i))); + try testing.expect(!std.meta.eql(red, cell.style.fg)); + try testing.expect(!std.meta.eql(green, cell.style.fg)); + try testing.expect(!std.meta.eql(blue, cell.style.fg)); + } + } + + test "a combining mark in the prompt keeps the command and its colors aligned" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC` + // typed at it. The cell walk that finds the prompt's end must step ONE + // grapheme for that cell, not one per stored codepoint: stepping twice ate + // the `A`, and left every colour a cell to the left of its glyph with the + // last one stranded on a blank. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme()); + try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme()); + try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg); + // ...and no colour past the end of what the row actually says + try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg)); + } + + test "colors are never taken from shell rows below the viewport" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + // Raw palette, so a leaked background reads back as `.index` — the theme + // filter would repaint every blank cell and hide the evidence. + pane.tty_filter = false; + pane.mode = .normal; + + // Sixty rows, each a distinct background, so a leaked colour names the row + // it leaked from. + for (0..60) |i| { + var buf: [32]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + scrollGrid(pane, -20); + + // ONE buffer line standing in for SIX shell rows: everything below slides + // UP five, so the last rows of the body resolve past the viewport's bottom + // edge. `PageList.pin` answers for those rows anyway — it walks down the + // pagelist, not the viewport — so without a bound of its own this pass + // painted the scrollback's colours onto rows the text pass left blank. + const anchor = gridOffset(pane); + pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_h = r.h - pardes.BOX_H; + + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + // A body row the text pass left blank has no shell row under it, so no + // ANSI background may have reached it. Every colour in the payload above is + // an indexed one, so a leak is exactly an `.index` background on a blank row. + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + var blank = true; + var c: u16 = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false; + } + if (!blank) continue; + c = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + const bg = s.at(tx + c, body_y + vr).style.bg; + try testing.expect(std.meta.activeTag(bg) != .index); + } + } + } + + test "a row the edit buffer only swallowed keeps its color" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const green: pardes.Color = .{ .index = 2 }; + const blue: pardes.Color = .{ .index = 4 }; + + // The buffer only ever grows, so after a few edits it covers rows nobody + // touched. Here it spans all three and only the MIDDLE line differs: the + // first and last are still byte-identical to the shell rows they were + // seeded from, so they still stand over them and keep their colours. + pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme()); + try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(red, s.at(tx, body_y).style.fg); + try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg); + // ...and the line that actually changed is the user's own text now + try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg)); + } + + test "an edit buffer reaching past the dumped rows colors nothing from row zero" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + + // Covers far more rows than the grid was ever dumped for, so the anchor + // table cannot be built and answers "no shell row" for every line. The + // zeroed table must not read as "the last line sits on the buffer's first + // row", which claimed row zero's colour and underflowed on every line after. + pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme()); + var i: u16 = 1; + while (i <= 3) : (i += 1) { + try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg)); + } + } + + test "a prompted session keeps every glyph's color in normal mode" { + const testing = std.testing; + const payload = + "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++ + "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++ + "\x1b[31merror: nope\x1b[0m\r\n" ++ + "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++ + "\x1b[38;5;208mhi\x1b[0m\r\n"; + + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); + const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off"); + try testing.expectEqual(@as(usize, 0), diffs); + } + } + + test "an emoji prompt neither eats the command nor slides its colors" { + const testing = std.testing; + // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this + // surface can BOTH be wrong about a cluster's width, and then a differential + // agrees with itself while the user sees the wrong thing. What is typed at + // the prompt is what must appear, each character wearing its own colour. + // + // Ghostty splits these clusters across cells and spells each one in the row + // dump, so the cell walk and the byte walk only agree if the byte walk is + // driven by what each CELL contributed. `👨💻` is two wide cells, `👨👩👧` + // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here. + const prompts = [_][]const u8{ + "plain", + "\u{1F468}\u{200D}\u{1F4BB}", // technologist + "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family + "\u{1F1FA}\u{1F1F8}", // flag + "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone + "\u{2764}\u{FE0F}", // heart, VS16 + "\u{0031}\u{FE0F}\u{20E3}", // keycap + "\u{754C}", // CJK wide + "e\u{301}", // NFD + }; + for (prompts) |prompt| { + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = .normal; + + var buf: [256]u8 = undefined; + const bytes = try std.fmt.bufPrint( + &buf, + "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m", + .{prompt}, + ); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| { + const cell = s.at(tx + @as(u16, @intCast(i)), body_y); + testing.expectEqualStrings(want, cell.grapheme()) catch |err| { + std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i }); + return err; + }; + } + // `ab` was printed red and `cd` blue, so whatever the theme does + // with those two runs, the pair boundary has to fall between `b` + // and `c`. A prompt that cost the row a character shows up here as + // the boundary sliding onto the wrong glyph. + const fg = [_]pardes.Color{ + s.at(tx, body_y).style.fg, + s.at(tx + 1, body_y).style.fg, + s.at(tx + 2, body_y).style.fg, + s.at(tx + 3, body_y).style.fg, + }; + errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg }); + try testing.expect(std.meta.eql(fg[0], fg[1])); + try testing.expect(std.meta.eql(fg[2], fg[3])); + try testing.expect(!std.meta.eql(fg[1], fg[2])); + if (!filter) { + try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]); + try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]); + } + } + } + } + + test "background-only cells keep their color through the prompt hug" { + const testing = std.testing; + // Backgrounds with no glyph under them are most of what a shell paints: + // erase-to-end-of-line after a colour is set, padded table cells, and a + // selected row. They have no text to align on, so they are the cells a + // column translation is most likely to lose. + const payload = + "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++ + "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++ + "a\x1b[42m \x1b[0mb\r\n" ++ + "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++ + "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++ + "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n"; + + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); + const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off"); + try testing.expectEqual(@as(usize, 0), diffs); + } + } + + test "a leftover edit buffer does not move what tty mode shows" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + + for (0..60) |i| { + var buf: [40]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const rows: usize = r.h - pardes.BOX_H; + const cols: usize = r.w -| config.GUTTER; + + // What tty mode shows with nothing left behind: the reference. + p.shell_rows.stale = true; + const clean = try p.render(frame.allocator()); + const want_text = try testing.allocator.alloc([7]u8, rows * cols); + defer testing.allocator.free(want_text); + const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols); + defer testing.allocator.free(want_fg); + for (0..rows) |row| for (0..cols) |col| { + const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + want_text[row * cols + col] = cell.text; + want_fg[row * cols + col] = cell.style.fg; + }; + + // `enterTty` clears every other modal remnant but leaves the edit buffer, so + // a buffer whose covered span STRADDLES the viewport top is an ordinary + // state. tty mode does not apply the buffer, so it must not be moved by one + // either — and `surfRow`/`gridRow` are not inverses across that span. + const anchor = gridOffset(pane); + pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + + for (0..rows) |row| for (0..cols) |col| { + const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + try testing.expectEqualStrings( + std.mem.sliceTo(&want_text[row * cols + col], 0), + std.mem.sliceTo(&cell.text, 0), + ); + try testing.expectEqual(want_fg[row * cols + col], cell.style.fg); + }; + } + + test "a background after a row-final wide glyph lands on the right columns" { + const testing = std.testing; + // A CJK glyph then a coloured erase-to-end-of-line, with a second colour + // partway. The glyph's grid tail spells no bytes, so the pairing walk used + // to stop ON it and pair every later column with the cell before it: an + // unpainted hole beside the glyph and every boundary one column right. + // + // ABSOLUTE assertions: both modes were wrong identically here, so a + // tty/normal differential says nothing. + for ([_]bool{ false, true }) |filter| { + for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = mode; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme()); + // The glyph covers columns 0-1; red runs from 2 up to the second + // erase at column 6 (1-based 7), blue from there to the edge. + const red = s.at(tx + 3, body_y).style.bg; + const blue = s.at(tx + 9, body_y).style.bg; + errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg }); + try testing.expect(!std.meta.eql(red, blue)); + for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); + for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); + } + } + } + + test "a colored row reaches its last column when a wide glyph did not fit" { + const testing = std.testing; + // Thirteen cells of red background, then a wide glyph with one column left: + // ghostty leaves a `spacer_head` in that last column, carrying the row's + // background, and wraps the glyph to the next row. A head OWNS its column, + // so skipping it the way a tail is skipped left the row's final column bare. + for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = mode; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + const red: pardes.Color = .{ .index = 1 }; + var c: u16 = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg }); + try testing.expectEqual(red, s.at(tx + c, body_y).style.bg); + } + } + } + + test "tty colours survive a scrollback deeper than the pane" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + for (0..40) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const s = try p.render(frame.allocator()); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + var bad: usize = 0; + for (0..r.h -| pardes.BOX_H) |vr| { + var buf: [16]u8 = undefined; + var n: usize = 0; + for (0..10) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + if (g.len != 1) break; + buf[n] = g[0]; + n += 1; + } + const txt = buf[0..n]; + if (!std.mem.startsWith(u8, txt, "line-")) continue; + const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue; + const want = pardes.Color{ .index = @intCast(20 + num) }; + const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; + if (!std.meta.eql(want, got)) { + bad += 1; + std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got }); + } + } + try testing.expectEqual(@as(usize, 0), bad); + } + + test "reverse video swaps the default colors with the filter off too" { + const testing = std.testing; + // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to + // be honoured on BOTH colour paths. The theme filter folds it into its own + // palette; the raw path resolves a `.none` colour by role, and simply + // dropped reverse video altogether. + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = .normal; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } }); + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + const plain = before.at(tx, body_y).style; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + const reversed = after.at(tx, body_y).style; + + errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg }); + try testing.expectEqual(plain.fg, reversed.bg); + try testing.expectEqual(plain.bg, reversed.fg); + } + } + + test "untouched lines between two edits keep their colors" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + for (0..6) |i| { + var buf: [40]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + // The state two ordinary edits reach: one at the bottom, one that split a + // line further up. The buffer now spans rows 2..6 and diverges at BOTH + // ends, with three untouched lines in the middle. Matching a leading and a + // trailing run stops at the first divergence and drains exactly those three; + // each line carries its own evidence, so each is anchored on its own. + pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05` + for (0..3) |k| { + const vr = @as(u16, @intCast(4 + k)); + var buf: [8]u8 = undefined; + const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable; + const cell = s.at(tx, body_y + vr); + errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg }); + try testing.expectEqualStrings(want_text[0..1], cell.grapheme()); + try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg); + } + } + + test "an emptied edit buffer does not shift the colors below it" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + // The state three keystrokes reach on any blank shell row: type a character + // and delete it, and the buffer holds NO text while still standing in for + // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one + // line, so anything deriving the slide from the former puts every colour + // below here one row too far down — and drops the bottom row's entirely. + pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme()); + try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme()); + try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg); + try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg); + // ...and the user's own empty line takes no colour from the row beneath it + try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg)); + } + + test "an edit overlay never changes tty-mode ansi colors" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const blue: pardes.Color = .{ .index = 4 }; + + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); + + // A lingering multi-line edit overlay must not move any shell row's colour. + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); + } + + test "an edited row keeps the colours of the bytes the edit did not touch" { + const testing = std.testing; + // The loudest colour bug this editor had: one keystroke anywhere in a + // coloured row turned EVERY column of it grey, because an anchor was all or + // nothing. The row's own bytes survive at both ends of what was typed, and + // being the same bytes they keep the same colours; only the typed character + // has no cell under it and so takes none. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..6) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + // One `Z` typed into the middle of row 3's own text. + pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + const want = pardes.Color{ .index = 33 }; + var seen = false; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [15]u8 = undefined; + for (0..15) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue; + seen = true; + for (0..15) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got }); + // Column 9 is the typed `Z`; every other column is row 3's own. + if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got); + } + } + try testing.expect(seen); + } + + test "joining two rows leaves the rows below them their colours" { + const testing = std.testing; + // A join removes a buffer line while the buffer's covered span GROWS, so the + // two counts cancel at `lines == covered`. Anchoring that only counts down + // from the buffer's top and up from its bottom then resolves both ways to + // the SAME row, one short of where the lines below live, and every untouched + // row under the join went plain. This is the state four keystrokes reach + // (Enter, then a backspace two rows up), taken from the fuzzer that found it. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + pane.ovl = .{ + .row = 23, + .rows = 4, + .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"), + }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + var seen = false; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [12]u8 = undefined; + for (0..12) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue; + seen = true; + // The join is above it and its own text is untouched, so every column + // still carries row 25's own colour. + for (0..12) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got }); + try testing.expectEqual(pardes.Color{ .index = 45 }, got); + } + } + try testing.expect(seen); + } + + test "an untouched row always carries the colour its own text names" { + const testing = std.testing; + // Random editing, absolute oracle: every row's own text names the colour it + // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing + // anything else. This is what found the join above, and the empty line that + // claimed a blank row far below it and took every coloured row in between + // out of reach of the lines that owned them. + var seed: u64 = 0; + while (seed < 40) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); + + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + // The raw palette, so a row's text names its exact colour instead of one + // this test would have to re-derive from the theme. + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_h = r.h -| pardes.BOX_H; + + var step: usize = 0; + while (step < 12) : (step += 1) { + _ = frame.reset(.retain_capacity); + const s = try p.render(frame.allocator()); + for (0..body_h) |vr| { + var buf: [24]u8 = undefined; + for (0..24) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + const txt = std.mem.trimEnd(u8, buf[0..24], " "); + if (txt.len != 12) continue; + if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue; + const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue; + const want = pardes.Color{ .index = @intCast(20 + num) }; + for (0..txt.len) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got }); + try testing.expectEqual(want, got); + } + } + + switch (rand.intRangeAtMost(u8, 0, 10)) { + 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }), + 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }), + 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }), + 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }), + 4 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 5 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.enter } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 6 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.backspace } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 7 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); + p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }), + 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }), + else => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.delete } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + } + while (p.nextEffect()) |_| {} + } + } + } + + test "a new empty line does not take the colours of the rows below it" { + const testing = std.testing; + // Splitting a row makes an EMPTY buffer line, and empty equals every blank + // row in the buffer's span - including the one under the last output. Left + // free to look ahead for a row spelling the same bytes, that line claimed + // the blank row far below and put every coloured row in between out of + // reach of the lines that owned them. Two keystrokes (Home, Enter) got here. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + // A newline typed at column 0 of row 24, and `WW` typed on the blank row + // below the output: the span covers rows 24, 25 and that blank row. + pane.ovl = .{ + .row = 24, + .rows = 3, + .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"), + }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + var seen: usize = 0; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [12]u8 = undefined; + for (0..12) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.startsWith(u8, &buf, "row-2")) continue; + const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue; + if (num != 24 and num != 25) continue; + seen += 1; + const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got }); + try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got); + } + try testing.expectEqual(@as(usize, 2), seen); + } +}; + +test { + _ = @import("output.zig"); + _ = @import("pdf.zig"); + _ = @import("fs_namespace.zig"); + _ = @import("hxdiff.zig"); + _ = FileTests; + _ = OutputTests; + _ = ImageTests; + _ = PdfTests; + _ = TerminalTests; +} + +test "an empty counted paste leaves no count for the next motion" { + for ([_]bool{ false, true }) |empty_register| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("abcdefgh\n"); + if (empty_register) p.yank = try p.gpa.dupe(u8, ""); + for ("3p") |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqualStrings("abcdefgh\n", pane.file.?.content); + } +} + +test "cancelled normal gestures leave no count or subprefix after mouse and focus changes" { + const Gesture = enum { body, tag, focus, last }; + for ([_][]const u8{ "3", "3g", "mr(" }) |keys| { + for ([_]Gesture{ .body, .tag, .focus, .last }) |gesture| { + errdefer std.debug.print("\nkeys={s} gesture={s}\n", .{ keys, @tagName(gesture) }); + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 16 }); + defer p.deinit(); + const pane = try p.setTestFile("abcdefghij\n"); + p.settings.colors = false; + p.presentation.enabled = false; + try std.testing.expect(p.executeBuiltinLine(0, "New")); + const other = p.active; + try std.testing.expect(layout.splitColumn(p, other, other, false)); + p.active = 0; + p.update(.tick); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + for (keys) |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal)); + switch (gesture) { + .body, .tag => { + const rect = p.rects[0]; + const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const x = rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3; + const y = if (gesture == .tag) tag_y else body_y; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x, .row = y } }); + if (gesture == .tag) { + try std.testing.expect(pane.tag_edit); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); + } + }, + .focus, .last => { + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(other, p.active); + if (gesture == .last) { + p.update(.{ .key = .{ .cp = Key.escape } }); + } else { + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'h' } }); + } + try std.testing.expectEqual(@as(usize, 0), p.active); + }, + } + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + const col = pane.cur_col; + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(col + 1, pane.cur_col); + try std.testing.expectEqualStrings("abcdefghij\n", pane.file.?.content); + } + } +} + +test "cancelled normal gestures leave no count or subprefix through raw tty mode" { + for ([_][]const u8{ "3", "3g", "mr(" }) |keys| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 12 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdefghij\r\n" } }); + for (keys) |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal)); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(pardes.Pane.Mode.tty, pane.mode); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(pardes.Pane.Mode.normal, pane.mode); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + } +} + +test "Colors restores syntax after rendering disabled files and disabled edits" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |edit_while_disabled| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("pub fn first() void { if (true) {} }\n"); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/colors.zig"); + p.settings.colors = edit_while_disabled; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + if (edit_while_disabled) { + try std.testing.expect(file.highlights.len > 0); + p.update(.{ .command = "Colors" }); + panes.File.setContent(p, file, try gpa.dupe(u8, "pub fn edited() void { if (false) {} }\n")); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + } + try std.testing.expect(!p.settings.colors); + try std.testing.expectEqual(@as(usize, 0), file.highlights.len); + try std.testing.expect(!file.syntax_dirty); + const rect = p.rects[0]; + const cols = pane.cols; + const rows = pane.rows; + p.update(.{ .command = "Colors" }); + try std.testing.expectEqual(rect, p.rects[0]); + try std.testing.expectEqual(cols, pane.cols); + try std.testing.expectEqual(rows, pane.rows); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + try std.testing.expect(p.settings.colors); + try std.testing.expect(file.highlights.len > 0); + try std.testing.expect(!file.syntax_dirty); + var seen: usize = 0; + for (surface.cells[0 .. surface.cells.len - 1], 0..) |*first, i| { + const second = &surface.cells[i + 1]; + if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and + !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue; + seen += 1; + for ([_]*pardes.Cell{ first, second }) |cell| { + try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + try std.testing.expectEqual(@as(usize, 2), seen); + } +} + +test "growing a file pane colors every newly visible Zig keyword" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 8 }); + defer p.deinit(); + const pane = try p.setTestFile("pub fn f() void { if (true) {} }\n" ** 100); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/resize.zig"); + p.settings.colors = true; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const initial_end = file.highlight_start + file.highlights.len; + for ([_]u16{ 50, 12, 70 }) |rows| { + p.update(.{ .resize = .{ .cols = 80, .rows = rows } }); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + var seen: usize = 0; + for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { + const first = surface.at(@intCast(x), @intCast(y)); + const second = surface.at(@intCast(x + 1), @intCast(y)); + if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and + !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue; + seen += 1; + for ([_]*pardes.Cell{ first, second }) |cell| { + try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + } + try std.testing.expect(seen >= 2 * (rows - 4)); + try std.testing.expect(file.highlight_start + file.highlights.len > initial_end); + try std.testing.expect(!file.syntax_dirty); + } +} + +test "Zig keywords keep every rendered byte colored across line-number widths" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const samples = [_][]const u8{ + "pub fn main() void { if (true) {} }\n", + "\tpub fn main() void { if (true) {} }\n", + "const text = \"λ界\"; pub fn main() void { if (true) {} }\n", + }; + for ([_]usize{ 9999, 10000, 99999, 100000 }) |line| { + for (samples) |sample| { + for ([_]struct { cols: u16, wrap: bool, horizontal: i32 }{ + .{ .cols = 96, .wrap = false, .horizontal = 0 }, + .{ .cols = 96, .wrap = false, .horizontal = 2 }, + .{ .cols = 38, .wrap = true, .horizontal = 0 }, + }) |view| { + const source = try gpa.alloc(u8, line - 1 + sample.len); + defer gpa.free(source); + @memset(source[0 .. line - 1], '\n'); + @memcpy(source[line - 1 ..], sample); + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = view.cols, .rows = 10 }); + defer p.deinit(); + const pane = try p.setTestFile(source); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/highlight.zig"); + file.scroll = line - 1; + pane.cur_row = @intCast(line - 1); + pane.cur_col = @intCast(std.mem.indexOf(u8, sample, "fn").?); + pane.hscroll = view.horizontal; + p.settings.colors = true; + p.settings.wrap = view.wrap; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + var seen: usize = 0; + var if_position: ?struct { x: u16, y: u16 } = null; + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { + const first = surface.at(@intCast(x), @intCast(y)); + const second = surface.at(@intCast(x + 1), @intCast(y)); + const keyword = (std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) or + (std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f")); + if (!keyword) continue; + seen += 1; + if (std.mem.eql(u8, first.grapheme(), "f")) { + try std.testing.expectEqual(@as(u16, @intCast(x)), surface.cursor.?.x); + try std.testing.expectEqual(@as(u16, @intCast(y)), surface.cursor.?.y); + } else if_position = .{ .x = @intCast(x), .y = @intCast(y) }; + for ([_]*pardes.Cell{ first, second }) |cell| { + const expected: pardes.Color = .{ .rgb = p.theme().kw }; + if (!std.meta.eql(expected, cell.style.fg)) std.debug.print( + "\nline={d} wrap={} horizontal={d} sample={s} cell={s}\n", + .{ line, view.wrap, view.horizontal, sample, cell.grapheme() }, + ); + try std.testing.expectEqual(expected, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + } + try std.testing.expectEqual(@as(usize, 2), seen); + const clicked = if_position.?; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = clicked.x, .row = clicked.y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = clicked.x, .row = clicked.y } }); + try std.testing.expectEqual(@as(i32, @intCast(line - 1)), pane.cur_row); + try std.testing.expectEqual(@as(i32, @intCast(std.mem.indexOf(u8, sample, "if").?)), pane.cur_col); + } + } + } +} + +test "terminal overlays preserve trailing blank styles without coloring inserted rows" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + p.update(.{ .output = .{ .pane = 0, .bytes = "A\r\n\x1b[41m\x1b[2K\r\n\x1b[42m\x1b[2K\r\n\x1b[44m\x1b[2K\x1b[0m" } }); + pane.mode = .normal; + pane.ovl = .{ .row = 0, .rows = 3, .text = try gpa.dupe(u8, "A\n\n") }; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER; + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const colors = [_]pardes.Color{ .{ .index = 1 }, .{ .index = 2 }, .{ .index = 4 } }; + const original = try p.render(frame.allocator()); + for (colors, 0..) |color, row| { + const cell = original.at(x, y + 1 + @as(u16, @intCast(row))); + try std.testing.expectEqualStrings(" ", cell.grapheme()); + try std.testing.expectEqual(color, cell.style.bg); + } + try std.testing.expect(p.shell_rows.pane == null); + + gpa.free(pane.ovl.?.text); + pane.ovl.?.text = try gpa.dupe(u8, "A\n\n\n"); + _ = frame.reset(.retain_capacity); + const extended = try p.render(frame.allocator()); + for (colors[0..2], 0..) |color, row| + try std.testing.expectEqual(color, extended.at(x, y + 1 + @as(u16, @intCast(row))).style.bg); + const inserted = extended.at(x, y + 3); + try std.testing.expectEqualStrings(" ", inserted.grapheme()); + for (colors) |color| try std.testing.expect(!std.meta.eql(color, inserted.style.bg)); + try std.testing.expectEqual(colors[2], extended.at(x, y + 4).style.bg); + try std.testing.expect(p.shell_rows.pane == null); +} diff --git a/test/pdf.zig b/test/pdf.zig new file mode 100644 index 00000000..b2d03bb8 --- /dev/null +++ b/test/pdf.zig @@ -0,0 +1,1867 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const look = pardes.look; +const pdf_impl = panes.Pdf.pdf; +const image = pardes.image; +const config = pardes.config; +const builtins = pardes.builtins; +const layout = pardes.layout; +const dump = pardes.dump; + +const Pardes = pardes.Pardes; +const Builtin = builtins.registry.Builtin(); +const Key = pardes.Key; +const Event = pardes.Event; +const Mode = pardes.Pane.Mode; +const Surface = pardes.Surface; +const ImagePlace = pardes.ImagePlace; +const PdfFitMode = panes.Pdf.FitMode; +const PdfTintMode = panes.Pdf.TintMode; +const pdf_enabled = panes.Pdf.enabled; +const platform = pardes.platform; +const themes = pardes.themes; +const pdf_raster_policy = pardes.pdf_raster_policy; +const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX; +const BOX_H = pardes.BOX_H; +const sel_slot = @intFromEnum(config.select_button); +const pane_tail = " " ++ config.pane_builtins_str; + +fn hasPdf(pane: *const pardes.Pane) bool { + return if (comptime pdf_enabled) pane.pdf != null else false; +} + +test "PDF mounted bytes own their source after the caller frees it" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + const source = try pardes.filesystem.readFile(gpa, "docs/design.pdf"); + var document = pdf_impl.Document.openBytes(source) catch |err| { + gpa.free(source); + return err; + }; + @memset(source, 0); + gpa.free(source); + defer document.deinit(); + + var original = try pdf_impl.Document.open("docs/design.pdf"); + defer original.deinit(); + try std.testing.expectEqual(original.pages, document.pages); + try std.testing.expectEqual(try original.pageSize(0), try document.pageSize(0)); + const raster = try document.render(gpa, 0); + defer gpa.free(raster.rgba); + try std.testing.expect(raster.rgba.len > 0); +} + +test "explicit OS PDF opens and reloads keep their namespace path" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const original = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(original); + const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(replacement); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/live.pdf", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expectEqualStrings(path, pane.pdf.?.path); + try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); + var watched = false; + while (p.nextEffect()) |effect| { + if (effect == .watch and effect.watch.pane == 0 and effect.watch.on) watched = true; + } + try std.testing.expect(watched); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqualStrings(path, pane.pdf.?.path); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page_count); +} + +test "PDF feature gates keep argv and builtin behavior coherent" { + const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); + const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint"); + const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections"); + try std.testing.expectEqual(pdf_enabled, maybe_fit != null); + try std.testing.expectEqual(pdf_enabled, maybe_tint != null); + try std.testing.expectEqual(pdf_enabled, maybe_sections != null); + if (pdf_enabled) { + try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?); + try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?); + try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?); + } else if (platform != .web) { + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); + } +} + +test "PDF dump fallback remains a byte-preserving file" { + const gpa = std.testing.allocator; + const path = "/definitely/missing/pardes-dump-fallback.PDF"; + const source = "%PDF embedded fallback bytes\x00\xff"; + const encoded = try dump.encodeBytes(gpa, source); + defer gpa.free(encoded); + const ids = [_]usize{0}; + const columns = [_]dump.Column{.{ .panes = &ids }}; + const saved_panes = [_]dump.Pane{.{ + .kind = .image, + .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del", + .body = "", + .scroll = 2, + .cols = 80, + .rows = 24, + .image = .{ .path = path, .bytes_b64 = encoded }, + }}; + const state = dump.State{ + .screen = .{ .cols = 80, .rows = 24 }, + .columns = &columns, + .panes = &saved_panes, + }; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); + + const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written()); + defer restored.deinit(); + const pane = restored.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); + try std.testing.expectEqualStrings(path, pane.file.?.path); + try std.testing.expectEqualSlices(u8, source, pane.file.?.content); + try std.testing.expect(pane.tag_init); + try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]); +} + +test "PdfSections Look follows the exact owning PDF, not an equal path" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + const first = p.panes[0].?; + // Bare normal-mode `f` on a PDF dispatches the PdfSections builtin. + p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput; + const first_output = p.panes[first_output_id].?; + try std.testing.expect(panes.Pdf.isSectionsOutput(first_output)); + try std.testing.expect(first_output.file.?.content.len > 0); + + // A clean result is re-armed in place without rebuilding it. + const first_revision = first_output.file.?.revision; + panes.Pdf.openSections(p, 0); + try std.testing.expectEqual(first_output_id, first.search_pane.?); + try std.testing.expectEqual(first_revision, first_output.file.?.revision); + + const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; + const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0); + p.placeDoc(0, duplicate_id, duplicate); + layout.compute(p); + panes.Pdf.openSections(p, duplicate_id); + const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; + + const first_row = std.mem.sliceTo(first_output.file.?.content, '\n'); + const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len]; + p.lookAt(first_output_id, target); + try std.testing.expectEqual(@as(usize, 0), p.active); + p.lookAt(duplicate_output_id, target); + try std.testing.expectEqual(duplicate_id, p.active); + + // Once the original document is gone, its output cannot reinterpret an + // old ordinal against the still-open equal-path duplicate. + try std.testing.expect(pardes.test_api.runBuiltin(p, "Del", 0, "", null)); + p.active = first_output_id; + p.lookAt(first_output_id, target); + try std.testing.expectEqual(first_output_id, p.active); +} + +test "PdfSections caches an empty outline output" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path}); + const p = try Pardes.init(gpa, .{ .file = path }); + defer p.deinit(); + panes.Pdf.openSections(p, 0); + const pane = p.panes[0].?; + const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections; + try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len); + const serial = p.panes[output_id].?.serial; + panes.Pdf.openSections(p, 0); + try std.testing.expectEqual(output_id, pane.search_pane.?); + try std.testing.expectEqual(serial, p.panes[output_id].?.serial); +} + +test "SDL PDF raster policy is materially denser than Kitty" { + try std.testing.expect( + pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2, + ); + try std.testing.expect( + pardes.sdl_pdf_raster_policy.max_dimension > + pardes.kitty_pdf_raster_policy.max_dimension * 3, + ); + try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport); + try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport); +} + +test "watched PDF reload replaces MuPDF state and preserves the reading view" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const original = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(original); + const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(replacement); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint( + &path_buf, + ".zig-cache/tmp/{s}/live.pdf", + .{tmp.sub_path}, + ); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + var watched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == 0 and watch.on) { + watched = true; + }, + else => {}, + }; + try std.testing.expect(watched); + + const pane = p.panes[0].?; + const state = &pane.pdf.?; + try std.testing.expectEqual(@as(usize, 3), state.page_count); + panes.Pdf.openSections(p, 0); + const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput; + const sections = p.panes[sections_id].?; + try std.testing.expect(sections.file.?.content.len > 0); + + p.native_images = true; + state.fit = .height; + state.tint = .full; + state.pan_x = 1234; + state.pan_y = 4321; + try state.setSearchQuery(p.pdf_gpa, "needle"); + panes.Pdf.setPage(p, pane, 2); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + state.text_scroll = 7; + state.search_hit = 4; + state.search_reveal_pending = false; + const reveal_viewport_w = state.reveal_viewport_w; + const reveal_viewport_h = state.reveal_viewport_h; + const reveal_fit = state.reveal_fit; + const reveal_viewport_valid = state.reveal_viewport_valid; + const old_revision = panes.Pdf.rasterForPage(state, 2).?.revision; + const old_revision_counter = state.next_raster_revision; + const anchor_fraction: f64 = 0.375; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2])); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + pane.cur_row = 9; + pane.cur_col = 8; + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + + try std.testing.expectEqual(@as(usize, 1), state.page_count); + try std.testing.expectEqual(@as(usize, 0), state.page); + try std.testing.expectEqual(PdfFitMode.height, state.fit); + try std.testing.expectEqual(PdfTintMode.full, state.tint); + try std.testing.expectEqual(@as(u16, 1234), state.pan_x); + try std.testing.expectEqual(@as(u16, 4321), state.pan_y); + try std.testing.expectEqual(@as(usize, 7), state.text_scroll); + try std.testing.expect(state.text_scroll_clamp_pending); + try std.testing.expectEqualStrings("needle", state.search_query); + try std.testing.expectEqual(@as(usize, 4), state.search_hit); + try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w); + try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h); + try std.testing.expectEqual(reveal_fit, state.reveal_fit); + try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid); + try std.testing.expectEqual(old_revision_counter, state.next_raster_revision); + try std.testing.expectEqual(@as(usize, 0), state.rasters_len); + try std.testing.expect(state.layout_anchor_pending); + try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page); + try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001); + try std.testing.expectEqual(@as(i32, 0), pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + _ = state.ensureText(p.pdf_gpa); + const text_lines = std.mem.count(u8, state.text, "\n") + 1; + try std.testing.expect(state.text_scroll < text_lines); + try std.testing.expect(!state.text_scroll_clamp_pending); + + // A clean generated outline is derived data: it refreshes in place and + // remains the remembered output. This replacement deliberately has none. + try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len); + try std.testing.expectEqual(sections_id, state.sections_output.?.pane); + try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision); + + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const fresh_raster = panes.Pdf.rasterForPage(state, 0).?; + try std.testing.expect(fresh_raster.revision != old_revision); + try std.testing.expect(fresh_raster.revision > old_revision_counter); + const expected_scroll = anchor_fraction * + @as(f64, @floatFromInt(state.page_heights[0])); + const viewport = panes.Pdf.paneViewport(p, pane).?; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001); + + // Reopen is transactional: malformed replacement bytes leave the last + // good MuPDF handle and every durable setting untouched. + const good_handle = state.document.handle; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" }); + try std.testing.expect(!p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(good_handle, state.document.handle); + try std.testing.expectEqual(@as(usize, 1), state.page_count); + try std.testing.expectEqualStrings("needle", state.search_query); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null); + + // A user edit breaks the generated-revision token. A later valid reload + // updates the document but leaves those user-owned output bytes alone. + panes.File.setContent(p, §ions.file.?, try gpa.dupe(u8, "edited sections\n")); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(@as(usize, 3), state.page_count); + try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content); + + // The watch follows the PDF payload's lifetime just like a text file's; + // emit the stop while the slot still identifies the disappearing pane. + try p.deinitPane(pane); + p.panes[0] = null; + var unwatched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == 0 and !watch.on) { + unwatched = true; + }, + else => {}, + }; + try std.testing.expect(unwatched); +} + +test "PDF reload does not re-center an already revealed matching search" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + const fixture = try pardes.filesystem.readFile(gpa, "docs/design.pdf"); + defer gpa.free(fixture); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path}); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const state = &pane.pdf.?; + try state.setSearchQuery(p.pdf_gpa, "Pardes"); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + try std.testing.expect(state.search_results != null); + try std.testing.expect(state.search_results.?.hit_count > 0); + try std.testing.expect(state.reveal_viewport_valid); + + // Reading moved on after the original reveal. A reload retains the query + // and rebuilds its quads, but it must not mistake that for a new request to + // jump back to the hit. + const anchor_page = state.page; + const anchor_fraction: f64 = 0.82; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page])); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + state.search_reveal_pending = false; + const reveal_w = state.reveal_viewport_w; + const reveal_h = state.reveal_viewport_h; + const reveal_fit = state.reveal_fit; + + // Same semantic document on a fresh generation keeps the expected anchor + // easy to state while still rebuilding every MuPDF-owned search object. + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(reveal_w, state.reveal_viewport_w); + try std.testing.expectEqual(reveal_h, state.reveal_viewport_h); + try std.testing.expectEqual(reveal_fit, state.reveal_fit); + try std.testing.expect(!state.search_reveal_pending); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + const page = @min(anchor_page, state.page_count - 1); + const anchored = @as(f64, @floatFromInt(state.page_starts[page])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); + const viewport = panes.Pdf.paneViewport(p, pane).?; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001); +} + +test "MuPDF pane renders, navigates, searches, and round-trips its page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(hasPdf(pane)); + try std.testing.expect(pane.pdf.?.page_count > 1); + try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); + try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); + const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); + try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); + + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const first = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), first.nimages); + const first_place = first.images[0].?; + try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); + try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); + const request = panes.Pdf.renderRequest( + panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport, + pdf_raster_policy, + ); + try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); + try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); + try std.testing.expectEqual( + request, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request, + ); + try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); + if (pdf_raster_policy.match_viewport) { + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); + try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); + try std.testing.expect(first_place.iw >= viewport.pixel_w or + @max(first_place.iw, first_place.ih) == request.max_dimension); + try std.testing.expect(first_place.ih >= viewport.pixel_h or + @max(first_place.iw, first_place.ih) == request.max_dimension); + } else { + try std.testing.expectEqual(@as(u32, 0), request.minimum_width); + try std.testing.expectEqual(@as(u32, 0), request.minimum_height); + } + + // SDL's raw dy path retains fractions in the placement itself; it does + // not leave native pixels fixed while only the underlying cells slide. + p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } }); + try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y); + _ = frame.reset(.retain_capacity); + const fractional = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision); + try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y); + pane.pdf.?.document_scroll_y = 0; + + // The default reading view moves one exact display-cell distance without + // replacing page pixels. Document placement, not texture identity, moves. + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y); + try std.testing.expectEqual( + first_place.native.revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + _ = frame.reset(.retain_capacity); + const panned = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); + try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y); + + const row_scroll = pane.pdf.?.document_scroll_y; + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y); + + // Counts survive a shared multi-key prefix. An invalid continuation is + // consumed and clears both prefix and count before the following action. + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '4' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = '?' } }); + try std.testing.expectEqual(.none, pane.normal.prefix); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); + + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const half_scroll = pane.pdf.?.document_scroll_y; + try std.testing.expect(half_scroll > row_scroll); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const counted_half = pane.pdf.?.document_scroll_y; + try std.testing.expectEqual(half_scroll * 2, counted_half); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); + try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4; + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + const key_viewport = panes.Pdf.paneViewport(p, pane).?; + const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h)); + const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll); + try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); + try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y); + try std.testing.expectEqual( + first_place.native.revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + + // At a page boundary both page rasters coexist, the gap remains uncovered, + // and a row step crosses it without snapping either page to an edge. + // + // This lands by ASSIGNMENT, which is a jump and not a fling — and the + // travel counter has to say so: the keys above scrolled two screenfuls + // without any frame in between to spend that distance, which no shell does + // (every wheel batch is followed by a draw). Left unspent it would make the + // frame below the first frame of a fling and hand it bands. + const viewport = panes.Pdf.paneViewport(p, pane).?; + pane.pdf.?.scroll_travel = 0; + pane.pdf.?.document_scroll_y = @floatFromInt( + pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, + ); + _ = frame.reset(.retain_capacity); + const boundary = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 2), boundary.nimages); + try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page); + try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page); + const first_bottom = @as(f32, @floatFromInt( + boundary.images[0].?.native.geometry.?.dst.y + + boundary.images[0].?.native.geometry.?.dst.h, + )) + boundary.images[0].?.native.pixel_offset_y; + const second_top = @as(f32, @floatFromInt( + boundary.images[1].?.native.geometry.?.dst.y, + )) + boundary.images[1].?.native.pixel_offset_y; + const visible_gap = second_top - first_bottom; + try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX)); + try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1)); + const before_boundary_scroll = pane.pdf.?.document_scroll_y; + const page0_revision = boundary.images[0].?.native.revision; + const page1_revision = boundary.images[1].?.native.revision; + + // A scroll can activate the already-cached neighbor before the shell's + // next draw. Every consumer resolves the active page's resident raster, + // so a queued click cannot accidentally use page zero's dimensions. + pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5; + try std.testing.expectEqual( + page0_revision, + panes.Pdf.rasterForPage(&pane.pdf.?, 0).?.revision, + ); + try std.testing.expect(panes.Pdf.scrollPane(p, pane, 1)); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + try std.testing.expectEqual( + page1_revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + panes.Pdf.activatePage(p, pane, 0, false); + pane.pdf.?.document_scroll_y = before_boundary_scroll; + + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 2), second.nimages); + try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision); + try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); + + // Once page zero is wholly outside the viewport, both its owned RGBA and + // backend placement disappear; returning later renders a new raster. What + // does NOT go back is the memory: the departing page's bytes are parked in + // the relay and the arriving page of the same size takes them, so a fling + // never asks the allocator (or the kernel's fault handler) for megabytes it + // just gave up. + panes.Pdf.setPage(p, pane, 1); + _ = frame.reset(.retain_capacity); + const away = try p.render(frame.allocator()); + try std.testing.expect(away.nimages > 0); + for (away.images[0..away.nimages]) |maybe| if (maybe) |place| + try std.testing.expect(place.native.page != 0); + try std.testing.expect(panes.Pdf.rasterForPage(&pane.pdf.?, 0) == null); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); + const retired = pane.pdf.?.spare[0]; + panes.Pdf.setPage(p, pane, 0); + _ = frame.reset(.retain_capacity); + const returned = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page); + try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr); + try std.testing.expect(returned.images[0].?.native.revision != page0_revision); + + // PdfFit exists as a real builtin in this build. It resets placement but + // preserves the current page pixels; fit-height j/k remains continuous in + // the same document-pixel coordinate space. + const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse + return error.MissingPdfFitBuiltin; + const revision_before_toggle = panes.Pdf.rasterForPage( + &pane.pdf.?, + pane.pdf.?.page, + ).?.revision; + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(fit_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual( + revision_before_toggle, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + const height_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); + for (p.panes) |slot| { + const other = slot orelse continue; + if (hasPdf(other)) continue; + panes.Pdf.toggleFit(other); // pane-scoped and deliberately inert here + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + break; + } + + // A fit-height landscape page exposes horizontal overflow to a horizontal + // wheel without rerasterizing. Use synthetic dimensions only for the + // geometry check; no frame is drawn while they differ from the buffer. + const active_raster = panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?; + const saved_iw = active_raster.iw; + const saved_ih = active_raster.ih; + active_raster.iw = 2000; + active_raster.ih = 500; + panes.Pdf.horizontalWheel(p, pane, 1); + try std.testing.expect(pane.pdf.?.pan_x > 0); + try std.testing.expectEqual(revision_before_toggle, active_raster.revision); + pane.pdf.?.pan_x = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expect(pane.pdf.?.pan_x > 0); + p.update(.{ .key = .{ .cp = '$' } }); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16)); + p.update(.{ .key = .{ .cp = '0' } }); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + active_raster.iw = saved_iw; + active_raster.ih = saved_ih; + pane.pdf.?.pan_x = 0; + + const before_height_scroll = pane.pdf.?.document_scroll_y; + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); + + const revision_before_search = active_raster.revision; + try p.runSearch(0, "Pardes", .text, .top); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + try std.testing.expectEqual(revision_before_search, active_raster.revision); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const results = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); + // n SELECTS the first result row and opens nothing: the walk's only list + // here is the unlooked +Search buffer, so focus lands THERE. Enter is what + // jumps, and the document query survives both. + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(results_id, p.active); + const results_pane = p.panes[results_id].?; + try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit); + try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col); + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + + // Search state is owned and untruncated, and changing pages invalidates + // only page-local state while retaining the document query. + const long_query = "a query deliberately longer than any tag display budget: " ++ + "012345678901234567890123456789012345678901234567890123456789" ++ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; + panes.Pdf.setPage(p, pane, next_page); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + + // PDF dumps intentionally retain the existing image-compatible schema: + // page/path and the exact editable tail are restored, while pane-local + // tint starts from the fresh-PDF default rather than being serialized. + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint); + pane.pdf.?.fit = .height; + const custom_tail = " Keep Del"; + @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail); + pane.tag_tail_len = custom_tail.len; + pane.tag_init = true; + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); + defer restored.deinit(); + try std.testing.expect(hasPdf(restored.panes[0].?)); + try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); + try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit); + try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); + const restored_pane = restored.panes[0].?; + try std.testing.expect(restored_pane.tag_init); + try std.testing.expectEqualStrings( + custom_tail, + restored_pane.tag_tail[0..restored_pane.tag_tail_len], + ); +} + +test "a fling's banded pages show the reader exactly what whole pages would" { + if (!pdf_enabled or platform == .web) return; + + // The contract fast scrolling is allowed to change: HOW pixels are carried + // (a strip of a page instead of the page) but never WHICH pixels arrive. So + // the same frame is drawn twice — once at fling speed, once at reading + // speed — and every pixel inside every source rectangle must match, along + // with where on screen it goes. + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + // Land mid-page-boundary so the frame carries TWO pages, each showing a + // fraction of itself — the shape a fling actually produces. + const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); + const Shot = struct { + page: u32, + dst: image.PixelRect, + pixels: []u8, + }; + var shots: [8]Shot = undefined; + var shots_len: usize = 0; + defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); + + // A fling: one frame's worth of wheel travel carrying the viewport more + // than a screenful, delivered through the real scroll path so the distance + // is counted the way a wheel batch counts it. + pv.document_scroll_y = 0; + pv.scroll_travel = 0; + try std.testing.expect(panes.Pdf.scrollPane(p, pane, landing)); + _ = frame.reset(.retain_capacity); + const flung = try p.render(frame.allocator()); + try std.testing.expect(flung.nimages >= 2); + var banded = false; + for (flung.images[0..flung.nimages]) |maybe| { + const place = maybe orelse continue; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + if (raster.band_h < raster.ih) banded = true; + try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); + shots[shots_len] = .{ + .page = place.native.page, + .dst = geometry.dst, + .pixels = try copySourceRect(gpa, place, geometry.src), + }; + shots_len += 1; + } + // ...and it really did band, or the comparison below is two identical + // whole-page renders agreeing with each other. + try std.testing.expect(banded); + // ...at reading speed: no travel at all since the frame above, so every + // page is rasterized whole again, and that is the picture the banded frame + // has to have matched. + _ = frame.reset(.retain_capacity); + const rested = try p.render(frame.allocator()); + try std.testing.expectEqual(shots_len, rested.nimages); + for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { + const place = maybe orelse return error.MissingPdfPlacement; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest + try std.testing.expectEqual(shot.page, place.native.page); + try std.testing.expectEqual(shot.dst.x, geometry.dst.x); + try std.testing.expectEqual(shot.dst.y, geometry.dst.y); + try std.testing.expectEqual(shot.dst.w, geometry.dst.w); + try std.testing.expectEqual(shot.dst.h, geometry.dst.h); + const whole = try copySourceRect(gpa, place, geometry.src); + defer gpa.free(whole); + try std.testing.expectEqualSlices(u8, shot.pixels, whole); + } +} + +/// The pixels a backend samples out of one placement: the source rectangle, +/// row by row, at the texture's own stride. Test-only, and the one operation +/// that makes "same picture" mean something when the textures differ in shape. +fn copySourceRect( + gpa: std.mem.Allocator, + place: ImagePlace, + src: image.PixelRect, +) ![]u8 { + const stride = place.iw * 4; + const row_len = @as(usize, src.w) * 4; + const out = try gpa.alloc(u8, row_len * src.h); + errdefer gpa.free(out); + var row: usize = 0; + while (row < src.h) : (row += 1) { + const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; + @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); + } + return out; +} + +test "PDF normal adapter consumes unsupported actions and navigates page fallback" { + if (!pdf_enabled or platform == .web) return; + + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + try std.testing.expect(pv.page_count > 3); + p.native_images = false; + + // Every vertical vocabulary falls back to counted page changes when the + // shell cannot place native pixels. + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + + // Prefix actions and the counted text goto-line action map to pages. + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'e' } }); + try std.testing.expectEqual(pv.page_count - 1, pv.page); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'G' } }); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + // Editing/selection actions are consumed no-ops: parser state clears, + // placeholder cells never acquire a range, and PDF state stays intact. + const page_before_noop = pv.page; + const revision_before_noop = pv.next_raster_revision; + p.update(.{ .key = .{ .cp = '4' } }); + p.update(.{ .key = .{ .cp = 'd' } }); + p.update(.{ .key = .{ .cp = 'v' } }); + p.update(.{ .key = .{ .cp = '|' } }); + try std.testing.expectEqual(page_before_noop, pv.page); + try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + try std.testing.expectEqual(.none, pane.normal.prefix); + try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0); + try std.testing.expect(!pane.tag_edit); + + // Cross-pane BODY-NORMAL actions keep their established shared paths. + p.update(.{ .key = .{ .cp = ':' } }); + try std.testing.expect(pane.tag_edit); + try std.testing.expectEqual(Mode.normal, pane.mode); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); + p.update(.{ .key = .{ .cp = ' ' } }); + try std.testing.expect(p.leader_on); + p.update(.{ .key = .{ .cp = '!' } }); + try std.testing.expect(!p.leader_on); + p.update(.{ .key = .{ .cp = '/' } }); + try std.testing.expect(pane.tag_edit); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); +} + +test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 28, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + // A second pane, then focus it and come back: `Last` has somewhere to go + // and the PDF is where the keys land. + panes.Pdf.openSections(p, 0); + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + + // Everything a reader can see over the page: a search overlay and a live + // word selection. + try pv.setSearchQuery(p.pdf_gpa, "Pardes"); + var found = try pv.document.search(gpa, pv.page, "Pardes"); + defer found.deinit(gpa); + try std.testing.expect(found.quads.len > 0); + const quad = found.quads[0].quad; + try std.testing.expectEqual( + panes.Pdf.SelectionUpdate.changed, + pv.setSelection(p.pdf_gpa, quad.ul, quad.lr, false), + ); + p.update(.{ .key = .{ .cp = '3' } }); + const page_before = pv.page; + const scroll_before = pv.document_scroll_y; + + // Escape drops the overlay and the selection, keeps the reading position, + // and does NOT hand the keyboard to another pane. + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expect(pv.selection == null and pv.selection_text.len == 0); + try std.testing.expectEqual(@as(usize, 0), pv.search_query.len); + try std.testing.expect(pv.search_results == null); + try std.testing.expect(!pv.highlights_dirty and !pv.search_reveal_pending); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + try std.testing.expectEqual(.none, pane.normal.prefix); + // A second Escape on a bare document is inert rather than an exit. + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + + // Shift-Escape is the way out, and it leaves the document as it found it. + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); +} + +test "PDF continuous strip renders every intersecting short page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + try std.testing.expect(pv.page_count > 3); + p.native_images = true; + + // A legal wide MediaBox can make more than three pages intersect one + // viewport. Seed tiny matching rasters so this tests transport/cache + // cardinality without spending the unit test rendering fake page sizes. + for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 }; + pv.layout_valid = false; + pv.scroll_to_page_pending = true; + const viewport = panes.Pdf.ensurePaneLayout(p, pane).?; + const visible = panes.Pdf.visiblePages(pv, viewport); + try std.testing.expectEqual(pv.page_count, visible.len); + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy); + try std.testing.expect(pv.page_count <= pv.rasters.len); + for (0..pv.page_count) |page| { + const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); + @memset(rgba, @intCast(page)); + pv.rasters[pv.rasters_len] = .{ + .valid = true, + .page = page, + .rgba = rgba, + .iw = viewport.pixel_w, + .ih = 1, + .request = request, + .request_valid = true, + .tried = true, + .tint_key = .{ .mode = pv.tint, .colors = panes.Pdf.tintColors(p) }, + .revision = @intCast(page + 1), + }; + pv.rasters_len += 1; + } + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + try std.testing.expectEqual(pv.page_count, surface.nimages); + try std.testing.expectEqual(pv.page_count, pv.rasters_len); + for (surface.images[0..surface.nimages], 0..) |maybe, page| { + const place = maybe orelse return error.MissingShortPdfPage; + try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); + try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h); + } +} + +test "PdfTint cycles pane-local state and exposes it in the live PDF tag" { + if (!pdf_enabled or platform == .web) return; + + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse + return error.MissingPdfTintBuiltin; + + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf( + u8, + initial_tag, + "width PdfFit filtered PdfTint", + ) != null); + + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + const full_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null); + + // Running the same pane-scoped word in a terminal cannot mutate the PDF + // next to it. A FILE boot is one pane now, so that terminal is asked for + // here rather than inherited from init. + _ = try p.newShell(1, ""); + _ = layout.splitColumn(p, 0, 1, false); + pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did + try std.testing.expect(!hasPdf(p.panes[1].?)); + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 1, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); + const disabled_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null); + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + try std.testing.expectEqual(PdfFitMode.width, pv.fit); + + // `dark` intentionally leaves page bg/fg null. PDF tint resolves those + // deterministically to its chrome colors rather than host defaults. + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + p.setThemeIndex(dark_index); + const colors = panes.Pdf.tintColors(p); + try std.testing.expectEqual(p.theme().tag_bg, colors.background); + try std.testing.expectEqual(p.theme().tag_fg, colors.foreground); +} + +test "PDF tint and tinted theme changes rebuild every visible raster only" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + _ = try p.render(frame.allocator()); + const viewport = panes.Pdf.paneViewport(p, pane).?; + pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2); + _ = frame.reset(.retain_capacity); + const default_surface = try p.render(frame.allocator()); + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + try std.testing.expectEqual(@as(usize, 2), default_surface.nimages); + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); + + const Snapshot = struct { + page: u32, + revision: u32, + geometry: image.NativeGeometry, + pixel_offset_y: f32, + iw: usize, + ih: usize, + checksum: u64, + }; + const Capture = struct { + fn get(surface: *const Surface) ![2]Snapshot { + if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount; + var out: [2]Snapshot = undefined; + for (&out, 0..) |*snapshot, i| { + const place = surface.images[i] orelse return error.MissingVisiblePdf; + snapshot.* = .{ + .page = place.native.page, + .revision = place.native.revision, + .geometry = place.native.geometry orelse return error.MissingPdfGeometry, + .pixel_offset_y = place.native.pixel_offset_y, + .iw = place.iw, + .ih = place.ih, + .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba), + }; + } + return out; + } + + fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void { + for (before, after) |old, new| { + try std.testing.expectEqual(old.page, new.page); + try std.testing.expectEqual(old.geometry, new.geometry); + try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y); + try std.testing.expectEqual(old.iw, new.iw); + try std.testing.expectEqual(old.ih, new.ih); + } + } + }; + const default_filtered = try Capture.get(default_surface); + for (pv.rasters[0..pv.rasters_len]) |raster| + try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode); + + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + p.setThemeIndex(dark_index); + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); + _ = frame.reset(.retain_capacity); + const themed_surface = try p.render(frame.allocator()); + const themed = try Capture.get(themed_surface); + try Capture.expectGeometry(default_filtered, themed); + for (default_filtered, themed) |old, new| { + try std.testing.expect(new.revision > old.revision); + try std.testing.expect(new.checksum != old.checksum); + } + + // Chrome animation reads a separate palette. Its ticks must never disturb + // the target-theme tint key or ask MuPDF for the same pixels again. + try std.testing.expect(p.animationActive()); + for (0..layout.Animation.transition_steps) |_| { + p.update(.tick); + _ = frame.reset(.retain_capacity); + const tick_surface = try p.render(frame.allocator()); + const ticked = try Capture.get(tick_surface); + try Capture.expectGeometry(themed, ticked); + for (themed, ticked) |once, after_tick| { + try std.testing.expectEqual(once.revision, after_tick.revision); + try std.testing.expectEqual(once.checksum, after_tick.checksum); + } + } + try std.testing.expect(!p.animationActive()); + + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + _ = frame.reset(.retain_capacity); + const full_surface = try p.render(frame.allocator()); + const full = try Capture.get(full_surface); + try Capture.expectGeometry(themed, full); + for (themed, full) |old, new| { + try std.testing.expect(new.revision > old.revision); + } + for (pv.rasters[0..pv.rasters_len]) |raster| + try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode); + + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); + _ = frame.reset(.retain_capacity); + const disabled_surface = try p.render(frame.allocator()); + const disabled = try Capture.get(disabled_surface); + try Capture.expectGeometry(full, disabled); + for (full, disabled) |old, source| { + try std.testing.expect(source.revision > old.revision); + try std.testing.expect(source.checksum != old.checksum); + } + + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); + p.setThemeIndex(acme_index); + // Disabled keys ignore theme colors, so neither explicit invalidation nor + // the per-raster key comparison asks MuPDF for replacement pixels. + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); + _ = frame.reset(.retain_capacity); + const unchanged_surface = try p.render(frame.allocator()); + const unchanged = try Capture.get(unchanged_surface); + try Capture.expectGeometry(disabled, unchanged); + for (disabled, unchanged) |old, new| { + try std.testing.expectEqual(old.revision, new.revision); + try std.testing.expectEqual(old.checksum, new.checksum); + } + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); +} + +test "PDF resize preserves page-relative document position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + panes.Pdf.setPage(p, pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.375; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.layout_valid); + try std.testing.expect(pv.layout_anchor_pending); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const resized_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + const held = pv.document_scroll_y; + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expectEqual(held, pv.document_scroll_y); + try std.testing.expect(pv.layout_valid); +} + +test "PDF pane geometry change preserves its page-relative position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + // A FILE boot is one pane now and a lone column always fills the window, + // so the divider drag below needs a second column to take the width from. + _ = try p.newShell(1, ""); + _ = layout.splitColumn(p, 0, 1, false); + pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + panes.Pdf.setPage(p, pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.625; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + const old_width = pv.layout_viewport_w; + + // A divider/split changes rects through computeGeom without emitting a + // shell resize. panes.Pdf.ensurePaneLayout anchors against the old layout + // before rebuilding it for this wider pane. + const sibling_weight = p.col_weight[1]; + p.col_weight[0] = sibling_weight * 6; + layout.compute(p); + try std.testing.expect(panes.Pdf.paneViewport(p, pane).?.pixel_w != old_width); + try std.testing.expect(pv.layout_valid); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const changed_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + // An explicit pending reveal wins over an implicit layout anchor. + pv.page = 2; + pv.scroll_to_page_pending = true; + p.col_weight[0] = sibling_weight * 2; + layout.compute(p); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual( + @as(f64, @floatFromInt(pv.page_starts[2])), + pv.document_scroll_y, + ); +} + +test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 120, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + var page_hits = try pv.document.search(gpa, 0, "Pardes"); + defer page_hits.deinit(gpa); + try std.testing.expect(page_hits.hit_count >= 3); + + try p.runSearch(0, "Pardes", .text, .top); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const rows = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); + + // Exaggerate the cell aspect only to make the three fixture hits occupy + // distinct fit-width crops. The search/reveal math must use the same + // reported metrics as placement, whatever a backend reports. + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ .w = 16, .h = 4 }, + } }); + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 0), pv.search_hit); + const first = try p.render(frame.allocator()); + const revision = first.images[0].?.native.revision; + const raster = panes.Pdf.rasterForPage(pv, pv.page).?; + const first_scroll = pv.document_scroll_y; + const first_results = pv.search_results orelse return error.MissingPdfPageSearch; + const first_quad = for (first_results.quads) |item| { + if (item.hit == 0) break item.quad; + } else return error.MissingFirstPdfHit; + const first_y = panes.Pdf.normalizedPixel( + (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, + raster.ih, + ); + const first_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(first_y >= first_geometry.src.y and + first_y < first_geometry.src.y + first_geometry.src.h); + + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 1), pv.search_hit); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, second.images[0].?.native.revision); + + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 2), pv.search_hit); + _ = frame.reset(.retain_capacity); + const third = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, third.images[0].?.native.revision); + try std.testing.expect(pv.document_scroll_y != first_scroll); + const third_results = pv.search_results orelse return error.MissingPdfPageSearch; + const third_quad = for (third_results.quads) |item| { + if (item.hit == 2) break item.quad; + } else return error.MissingThirdPdfHit; + const third_y = panes.Pdf.normalizedPixel( + (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, + raster.ih, + ); + const third_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(third_y >= third_geometry.src.y and + third_y < third_geometry.src.y + third_geometry.src.h); +} + +test "PDF native mouse selection, Look, and highlights share page geometry" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + // A host without Kitty/native pixels keeps the projected-text contract: + // j/k can still change pages and a body drag remains a generic selection. + const fallback = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), fallback.nimages); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + const fallback_rect = p.rects[0]; + const fallback_col = fallback_rect.x + config.GUTTER + 1; + const fallback_row = fallback_rect.y + BOX_H + 1; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); + try std.testing.expectEqual(.done, pane.sel[sel_slot].state); + try std.testing.expect(pane.pdf.?.selection == null); + pane.sel[sel_slot].state = .none; + + p.native_images = true; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + const plain_revision = plain.images[0].?.native.revision; + + var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); + defer found.deinit(gpa); + const quad = found.quads[0].quad; + const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; + const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; + const pv = &pane.pdf.?; + const raster = panes.Pdf.rasterForPage(pv, pv.page).?; + const source_x: u32 = @intCast(@min( + raster.iw - 1, + @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))), + )); + const source_y: u32 = @intCast(@min( + raster.ih - 1, + @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))), + )); + + // Bring the known word into the fit-width crop, then invert the shared + // source/destination geometry to the nearest body cell. + var geometry = panes.Pdf.paneGeometry(p, pane).?; + if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { + const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h; + const wanted = @min(overflow, source_y -| geometry.src.h / 2); + pv.pan_y = if (overflow == 0) 0 else @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + geometry = panes.Pdf.paneGeometry(p, pane).?; + } + const pixel_x = geometry.dst.x + @as(u32, @intCast( + @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, + )); + const pixel_y = geometry.dst.y + @as(u32, @intCast( + @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, + )); + const r = p.rects[0]; + const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); + const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); + + var selected_col: ?u16 = null; + var selected_row: u16 = 0; + const nearby = [_]i32{ 0, -1, 1, -2, 2 }; + find_word: for (nearby) |dy| for (nearby) |dx| { + const col: u16 = @intCast(std.math.clamp( + base_col + dx, + @as(i32, r.x + config.GUTTER), + @as(i32, r.x + r.w - 1), + )); + const row: u16 = @intCast(std.math.clamp( + base_row + dy, + @as(i32, r.y + BOX_H), + @as(i32, r.y + r.h - 1), + )); + if (!panes.Pdf.beginSelection(p, pane, col, row)) continue; + if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { + selected_col = col; + selected_row = row; + break :find_word; + } + }; + const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; + try std.testing.expect(std.ascii.indexOfIgnoreCase( + pardes.test_api.heldSelection(p, 0).?, + "Pardes", + ) != null); + + _ = frame.reset(.retain_capacity); + const selected_frame = try p.render(frame.allocator()); + const selected_revision = selected_frame.images[0].?.native.revision; + try std.testing.expect(selected_revision != plain_revision); + + // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the + // same texture generation instead of retransmitting identical pixels. + try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row)); + try std.testing.expect(raster.tried); + _ = frame.reset(.retain_capacity); + const duplicate = try p.render(frame.allocator()); + try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); + + // Delayed native hover retains independent quads/text and changes only + // presentation state. In particular it must not borrow the click path, + // whose page activation and persistent selection are intentional effects + // of a gesture rather than observation. + if (config.look_preview_delay_frames) |delay| { + const active_before = p.active; + const page_before = pv.page; + const scroll_before = pv.document_scroll_y; + const sels_before = pane.sel; + const cursor_before = .{ pane.cur_row, pane.cur_col }; + const modal_before = .{ pane.msel, pane.vsel, pane.nsel }; + const selection_before = pv.selection orelse return error.MissingPdfSelection; + const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads); + defer gpa.free(selection_quads_before); + const selection_text_before = try gpa.dupe(u8, pv.selection_text); + defer gpa.free(selection_text_before); + const selection_anchor_before = pv.selection_anchor; + const selection_head_before = pv.selection_head; + const query_before = try gpa.dupe(u8, pv.search_query); + defer gpa.free(query_before); + const search_hit_before = pv.search_hit; + const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; + const effects_before = p.effects_len; + try std.testing.expect(p.drag == .none); + + const hover_motion = Event{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = word_col, + .row = selected_row, + } }; + p.update(hover_motion); + try std.testing.expect(p.look_hover_wait != null); + for (0..delay) |_| p.update(.tick); + const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview; + try std.testing.expectEqual(@as(usize, 0), hover.probe.page); + try std.testing.expect(hover.probe.quads.len > 0); + try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null); + try std.testing.expect(p.look_hover_preview == null); + try std.testing.expect(p.look_hover_wait == null); + + const decorated = try panes.Pdf.buildHighlights( + pv, + p.scratch.allocator(), + panes.Pdf.highlightInput(p, 0, pane), + ); + const page_highlights = decorated.forPage(pv.page, pv.page); + try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind); + try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind); + _ = frame.reset(.retain_capacity); + const hovered_frame = try p.render(frame.allocator()); + const hovered_revision = hovered_frame.images[0].?.native.revision; + try std.testing.expect(hovered_revision != selected_revision); + + p.update(.pointer_leave); + try std.testing.expect(p.pdf_hover_preview == null); + _ = frame.reset(.retain_capacity); + const unhovered_frame = try p.render(frame.allocator()); + try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision); + + try std.testing.expectEqual(active_before, p.active); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); + try std.testing.expect(std.meta.eql(sels_before, pane.sel)); + try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); + try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel })); + const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection; + try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads); + try std.testing.expectEqualStrings(selection_text_before, pv.selection_text); + try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor)); + try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head)); + try std.testing.expectEqualStrings(query_before, pv.search_query); + try std.testing.expectEqual(search_hit_before, pv.search_hit); + try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); + try std.testing.expectEqual(effects_before, p.effects_len); + try std.testing.expect(p.drag == .none); + } + + // A native right-click resolves the same MuPDF-snapped word and feeds it + // to Look. Search highlights precede selection highlights so the live + // selection remains visually authoritative where they overlap. + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); + pv.resolveSearch(p.pdf_gpa); + const highlights = (try panes.Pdf.buildHighlights( + pv, + p.scratch.allocator(), + panes.Pdf.highlightInput(p, 0, pane), + )).items; + try std.testing.expect(highlights.len > 1); + try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); + try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); + const revision_before_reveal = raster.revision; + const max_document_scroll = @as(f64, @floatFromInt( + pv.document_height -| panes.Pdf.paneViewport(p, pane).?.pixel_h, + )); + pv.document_scroll_y = max_document_scroll; + panes.Pdf.revealSearch(pv, panes.Pdf.paneViewport(p, pane).?, panes.Pdf.paneGeometry(p, pane)); + const revealed_scroll = pv.document_scroll_y; + try std.testing.expect(revealed_scroll != max_document_scroll); + try std.testing.expectEqual(revision_before_reveal, raster.revision); + pv.document_scroll_y = max_document_scroll; + panes.Pdf.revealSearch( + pv, + panes.Pdf.paneViewport(p, pane).?, + panes.Pdf.paneGeometry(p, pane), + ); // one shot: subsequent manual pan stays put + try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y); + pv.document_scroll_y = revealed_scroll; + _ = frame.reset(.retain_capacity); + const searched = try p.render(frame.allocator()); + try std.testing.expect(searched.images[0].?.native.revision != selected_revision); + + // Fit and viewport changes can crop a hit which was already revealed. + // Fit remains placement-only. SDL's physical-viewport quality request + // changes with a resize and therefore replaces pixels; Kitty's fixed, + // bandwidth-conscious request retains them. + const revision_before_geometry_change = searched.images[0].?.native.revision; + pv.search_reveal_pending = false; + panes.Pdf.toggleFit(pane); + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + panes.Pdf.toggleFit(pane); // restore the reading-width geometry + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + const original_cell_pixels = p.cell_pixels; + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.search_reveal_pending); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ + .w = original_cell_pixels.w, + .h = original_cell_pixels.h + 1, + }, + } }); + try std.testing.expect(pv.search_reveal_pending); + _ = frame.reset(.retain_capacity); + const geometry_changed = try p.render(frame.allocator()); + if (pdf_raster_policy.match_viewport) + try std.testing.expect(geometry_changed.images[0].?.native.revision != + revision_before_geometry_change) + else + try std.testing.expectEqual( + revision_before_geometry_change, + geometry_changed.images[0].?.native.revision, + ); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = original_cell_pixels, + } }); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + // Unsupported text-selection actions are consumed by the PDF adapter: + // they neither invent a range over placeholder cells nor disturb the + // native MuPDF selection. The select-button's no-drag click still clears. + p.update(.{ .key = .{ .cp = 'v' } }); + try std.testing.expect(pv.selection != null); + try std.testing.expect(!pane.vsel.active); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(pv.selection == null); + try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row)); + + // Native drag events update MuPDF quads and copied text immediately, but + // keep the already transmitted page generation stable until release. + _ = frame.reset(.retain_capacity); + const before_drag = try p.render(frame.allocator()); + const before_drag_revision = before_drag.images[0].?.native.revision; + const drag_col = @min(r.x + r.w - 1, word_col +| 12); + const drag_row = @min(r.y + r.h - 1, selected_row +| 4); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .press, + .col = word_col, + .row = selected_row, + } }); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = @min(drag_col, word_col +| 4), + .row = @min(drag_row, selected_row +| 2), + } }); + _ = frame.reset(.retain_capacity); + const during_first_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_first_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(p.drag.select.pdf.selection_changed); + _ = frame.reset(.retain_capacity); + const during_second_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_second_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .release, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(!raster.tried); + _ = frame.reset(.retain_capacity); + const committed_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision + 1, + committed_drag.images[0].?.native.revision, + ); + _ = frame.reset(.retain_capacity); + const stable_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + committed_drag.images[0].?.native.revision, + stable_drag.images[0].?.native.revision, + ); + + const saved_query = try gpa.dupe(u8, pv.search_query); + defer gpa.free(saved_query); + panes.Pdf.setPage(p, pane, 1); + try std.testing.expect(pv.selection == null); + try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); + try std.testing.expectEqualStrings(saved_query, pv.search_query); +} + +test "Esc back into a PDF keeps the offset within its page" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 28 } }); + const doc = p.active; + const dp = p.panes[doc].?; + try std.testing.expect(dp.pdf != null); + pardes.test_api.sync(p); + + // Read a little way DOWN the page you are on, then step away. + const pv = &dp.pdf.?; + pv.document_scroll_y += 137; + const mid = pv.document_scroll_y; + pv.scroll_to_page_pending = false; + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + pardes.test_api.sync(p); + const shell = p.active; + try std.testing.expect(shell != doc); + + p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF + pardes.test_api.sync(p); + try std.testing.expectEqual(doc, p.active); + + // The jumps stack records a PDF as its PAGE, so returning revealed the page + // you were already on — and a reveal sets `document_scroll_y` to that page's + // start, throwing away where you had read to inside it. + try std.testing.expectEqual(mid, pv.document_scroll_y); + // Same reveal, the other half: with no valid layout yet it only ARMS the + // snap, so a test that watched the offset alone would not see it coming. + try std.testing.expect(!pv.scroll_to_page_pending); +} diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index baf89443..b08af552 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -173,7 +173,7 @@ fn measureCachedReopen(path: []const u8, config: Config) !Result { // memory clobber makes each production call's reads and writes // independently observable to the optimizer. compilerBarrier(); - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); compilerBarrier(); observed +%= observeReopen(prepared); } @@ -207,7 +207,7 @@ fn measureCachedControl(path: []const u8, config: Config) !Result { observed +%= observeReopen(prepared); } const elapsed = nowNs() -| started; - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); var identity = sectionsOutputIdentity(core); identity.checksum = mix(identity.checksum, observed); try verifyIdentity("sections-perturb-observe-control", &expected, identity, round); @@ -269,7 +269,7 @@ fn preparedCore(path: []const u8) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); errdefer core.deinit(); drainEffects(core); - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); drainEffects(core); _ = sectionsOutputIdentity(core); return core; diff --git a/test/perf.zig b/test/perf.zig index 171bbfbc..5aa5c4d4 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -1,65 +1,29 @@ -//! The editing scoreboard: how long one user gesture costs against file size. -//! -//! zig build perf -- the table -//! zig build perf -- --json -- the same, machine-readable -//! zig build perf -- --base old.json -- table + a before/after delta column -//! zig build perf -- --reps 60 -- more samples (default 25) -//! -//! It drives the core directly — feed an Event, drain the effects, -//! call render — exactly like test/hxdiff.zig, so there is no pty, no shell -//! and no scheduler between the clock and the code under test. A run is -//! self-contained: the fixtures are generated into /tmp/pardes-perf here and -//! rewritten every run, so the numbers do not move when this repo's own -//! sources do and there is no stale input to chase. -//! -//! WHAT A CELL MEANS. Every input row is the WHOLE gesture: update(event), -//! drain the effect queue, render one frame into a fresh arena — what a user -//! waits for between pressing a key and seeing the screen. `render` on its own -//! is the redraw with nothing changed (a resize, a refresh), which is the floor -//! every other row sits on. -//! -//! WHERE THE CURSOR IS matters more than anything else here: several of the -//! core's line lookups are scans from byte 0, so a measurement taken at line 3 -//! of a 200k-line file reports a cost the user never pays. Each sample seeks to -//! a different position spread across the whole file BEFORE the clock starts, -//! so the median is the cost at a typical place in the document, not at its -//! top. +//! End-to-end gesture latency: update, effects, render. +//! Each timed sample starts at a deterministic position across its fixture. +//! Compare builds with the same harness, geometry, fixtures and repetition count. const std = @import("std"); const libc = std.c; const pardes = @import("pardes"); +const build_identity = @import("perf_options").identity; +const BuildIdentity = @TypeOf(build_identity); -// ghostty-vt narrates every sequence it does not implement, and the fixture -// text goes nowhere near a terminal here — cut the libraries to errors so the -// table is the only thing on the screen. pub const std_options: std.Options = .{ .log_level = .err }; const gpa = std.heap.page_allocator; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("perf.zig"))}); +}; -/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig and -/// test/lspbench.zig already use. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec)); } -/// The viewport every fixture is measured in. Bigger than the 80x24 the helix -/// harness pins (that one matches helix's own harness; this one wants a screen -/// somebody actually works in), and fixed, because half of these numbers scale -/// with the number of cells on the screen. -/// The viewport every measurement runs in. Overridable, because the SHAPE of the -/// screen is one of the things this table exists to hold constant while something -/// else varies - and the ESP32-P4 firmware runs a 40x12 grid, where a render costs -/// 47x what it costs here for a tenth of the cells. Profiling that needs the same -/// geometry, not a scaled guess. var screen_cols: u16 = 120; var screen_rows: u16 = 40; -/// The four shapes a file comes in. `lines` x `cols` is the generated body; -/// the point of the pair is that "big" has two different meanings and they -/// break different code — a 200k-line file is long in the LINE index, an -/// 8000-column file is long inside ONE line, and a scan that is fine for one -/// is quadratic for the other. const Fixture = struct { name: []const u8, lines: usize, @@ -67,36 +31,20 @@ const Fixture = struct { }; const fixtures = [_]Fixture{ - // the control: a normal source file. Anything that shows up here is a - // constant cost, not a scaling one. .{ .name = "small", .lines = 1_000, .cols = 60 }, .{ .name = "medium", .lines = 50_000, .cols = 60 }, - // "a few MB, hundreds of thousands of lines" .{ .name = "large", .lines = 300_000, .cols = 60 }, - // the other failure mode: few lines, each one wider than any screen .{ .name = "longline", .lines = 400, .cols = 8_000 }, - // The ESP32-P4 firmware's actual document: a handful of lines, one of them wider than its - // 40-column screen. Here because a profile taken on `small` is a profile of a 1,000-line line - // index, and the board has twelve lines - so the costs that dominate there are not the costs - // that dominate it. Pair with `--cols 40 --rows 12`. .{ .name = "esp32p4", .lines = 12, .cols = 240 }, }; const Op = enum { - /// Look at the path: disk read, pane creation, layout, first frame. open, - /// one redraw, nothing changed render, - /// `j` — one row of cursor movement key_down, - /// `l` — one column. On `longline` the cursor sits deep in the line, so - /// this is the horizontal-scroll path; elsewhere it is a plain step. key_right, - /// PageDown page_down, - /// one mouse wheel tick wheel, - /// one printable typed in insert mode edit_char, fn label(o: Op) []const u8 { @@ -112,46 +60,23 @@ const Op = enum { } }; -// ---- the terminal scoreboard ---- -// -// The other axis pardes scales on, and the one a file table cannot see. A -// terminal pane's state is a ghostty-vt emulator with a 16 MiB scrollback, and -// three of the costs below walk ALL of it rather than the viewport: a COLUMN -// change reflows every page in the list (a row change does not — ghostty skips -// reflow when the width is unchanged, which is why both are here), and the -// motion surface is built from a dump of the whole history. -// -// The case this exists for is a coding agent printing a long transcript into a -// shell pane: `resize-cols` is what one frame of a window drag costs, and -// `key-down` is what one press of `j` costs afterwards. - const TermFixture = struct { name: []const u8, - /// KiB of pty output fed into the pane before any clock starts kb: usize, }; const term_fixtures = [_]TermFixture{ - // a shell you just opened: the control. Anything here is constant cost. .{ .name = "sb-64k", .kb = 64 }, - // a build log — also where the 1 MiB raw-byte replay ring fills up .{ .name = "sb-1m", .kb = 1024 }, - // an agent transcript: half the scrollback ceiling .{ .name = "sb-8m", .kb = 8 * 1024 }, }; const TermOp = enum { - /// one redraw, nothing changed — the floor the rest sit on render, - /// one 4 KiB pty read arrives: parse, replay ring, sync, frame output, - /// the window got one row shorter: resize WITHOUT reflow resize_rows, - /// the window got one column narrower: a full page-list reflow resize_cols, - /// `j` in the pane's body — builds the motion surface from the grid key_down, - /// one printable typed into the pane's edit buffer edit_char, fn label(o: TermOp) []const u8 { @@ -166,9 +91,6 @@ const TermOp = enum { } }; -/// One pty read's worth of output, built once and replayed by the `output` -/// row. Blocks are generated, not captured, for the same reason the file -/// fixtures are. var term_chunk: []const u8 = &.{}; const Cell = struct { @@ -178,67 +100,239 @@ const Cell = struct { max_us: u64 = 0, }; +const Allocations = struct { + backing: std.mem.Allocator = gpa, + account: ?*Allocations = null, + calls: usize = 0, + bytes: usize = 0, + live: usize = 0, + peak: usize = 0, + + fn allocator(self: *Allocations) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawAlloc(len, alignment, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live += len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) bool { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + if (!wrapper.backing.rawResize(memory, alignment, len, ret)) return false; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawRemap(memory, alignment, len, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret: usize) void { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + self.live -= memory.len; + wrapper.backing.rawFree(memory, alignment, ret); + } +}; + +fn measureCreation(io: std.Io, reps: usize, json: bool) !void { + if (reps == 0 or screen_cols == 0 or screen_rows == 0) return error.InvalidMeasurement; + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const deinit_samples = try gpa.alloc(u64, reps); + defer gpa.free(deinit_samples); + for ([_]bool{ false, true }) |terminal| { + if (terminal and !pardes.terminal_panes) continue; + var cold_ns: u64 = 0; + var cold_deinit_ns: u64 = 0; + var calls: usize = 0; + var bytes: usize = 0; + var live: usize = 0; + var peak: usize = 0; + for (0..reps + 1) |index| { + var counter: Allocations = .{}; + const allocator = counter.allocator(); + const begin = nowNs(); + const pane = if (terminal) + try pardes.panes.Terminal.create(allocator, screen_cols, screen_rows) + else + try pardes.panes.Terminal.createDoc(allocator, screen_cols, screen_rows); + const constructed = nowNs(); + const retained = counter.live; + pardes.panes.Terminal.deinitEmulator(pane, allocator); + allocator.destroy(pane); + const destroyed = nowNs(); + if (counter.live != 0) return error.LeakedPaneMemory; + if (index == 0) { + cold_ns = constructed - begin; + cold_deinit_ns = destroyed - constructed; + } else { + samples[index - 1] = constructed - begin; + deinit_samples[index - 1] = destroyed - constructed; + calls += counter.calls; + bytes += counter.bytes; + live += retained; + peak = @max(peak, counter.peak); + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, deinit_samples, {}, std.sort.asc(u64)); + const result = .{ + .build = build_identity, + .harness = harness_id, + .case = if (terminal) "create-terminal" else "create-document", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .pane_bytes = @sizeOf(pardes.Pane), + .cold_ns = cold_ns, + .median_ns = samples[reps / 2], + .p95_ns = samples[@min(reps - 1, reps * 95 / 100)], + .cold_deinit_ns = cold_deinit_ns, + .deinit_median_ns = deinit_samples[reps / 2], + .allocations = calls / reps, + .allocated_bytes = bytes / reps, + .live_bytes = live / reps, + .peak_bytes = peak, + .live_after_deinit = @as(usize, 0), + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "{s}: pane {d} B, {d} allocations / {d} B, retained {d} B, create {d} ns, deinit {d} ns, balanced", .{ + result.case, result.pane_bytes, result.allocations, result.allocated_bytes, result.live_bytes, result.median_ns, result.deinit_median_ns, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } +} + +const Options = struct { + json: bool = false, + reps: usize = 25, + cols: u16 = 120, + rows: u16 = 40, + base: ?[]const u8 = null, + only: ?[]const u8 = null, + creation: bool = false, + mini: bool = false, + terminal_mib: ?usize = null, + self_test: bool = false, + + fn parse(args: []const []const u8) !Options { + var opts: Options = .{}; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (!std.mem.startsWith(u8, arg, "--")) return error.UnknownOption; + const option = std.meta.stringToEnum(enum { json, creation, mini, @"terminal-mib", @"self-test", reps, cols, rows, base, only }, arg[2..]) orelse + return error.UnknownOption; + switch (option) { + .json => opts.json = true, + .creation => opts.creation = true, + .mini => opts.mini = true, + .@"self-test" => opts.self_test = true, + else => { + i += 1; + if (i == args.len) return error.MissingValue; + const value = args[i]; + switch (option) { + .reps => opts.reps = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + .cols => opts.cols = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .rows => opts.rows = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .base => opts.base = value, + .only => opts.only = value, + .@"terminal-mib" => opts.terminal_mib = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + else => unreachable, + } + }, + } + } + if (opts.reps == 0 or opts.reps > std.math.maxInt(usize) / 100 or opts.cols == 0 or opts.rows == 0) + return error.InvalidMeasurement; + if (!opts.creation and (opts.cols < 8 or opts.rows < 5)) return error.InvalidMeasurement; + if (opts.terminal_mib) |mib| { + if (mib == 0 or mib > 256 or opts.cols < 32 or opts.cols > 512 or opts.rows < 8 or opts.rows > 256) + return error.InvalidMeasurement; + if (opts.creation or opts.only != null or opts.base != null) return error.IncompatibleOptions; + } + if (opts.base) |path| if (path.len == 0) return error.MissingValue; + if (opts.mini and (opts.creation or opts.terminal_mib != null or opts.only != null or opts.base != null)) + return error.IncompatibleOptions; + if (opts.only) |name| { + for (fixtures) |fixture| { + if (std.mem.eql(u8, name, fixture.name)) break; + } else return error.UnknownFixture; + } + if ((opts.creation and (opts.base != null or opts.only != null)) or + (opts.json and opts.base != null) or (opts.self_test and args.len != 1)) return error.IncompatibleOptions; + return opts; + } +}; + pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); - var json = false; - var reps: usize = 25; - var base_path: ?[]const u8 = null; - var only: ?[]const u8 = null; - var i: usize = 1; - while (i < args.len) : (i += 1) { - const a = args[i]; - if (std.mem.eql(u8, a, "--json")) { - json = true; - } else if (std.mem.eql(u8, a, "--reps") and i + 1 < args.len) { - i += 1; - reps = std.fmt.parseInt(usize, args[i], 10) catch reps; - } else if (std.mem.eql(u8, a, "--base") and i + 1 < args.len) { - i += 1; - base_path = args[i]; - } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) { - i += 1; - screen_cols = std.fmt.parseInt(u16, args[i], 10) catch screen_cols; - } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { - i += 1; - screen_rows = std.fmt.parseInt(u16, args[i], 10) catch screen_rows; - } else if (std.mem.eql(u8, a, "--only") and i + 1 < args.len) { - i += 1; - only = args[i]; - } else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json] [--cols N] [--rows N] [--only NAME]", .{}); - } + const opts = try Options.parse(args[1..]); + if (opts.self_test) return selfTest(); + screen_cols = opts.cols; + screen_rows = opts.rows; + const reps = opts.reps; + const only = opts.only; + if (opts.creation) return measureCreation(init.io, reps, opts.json); + if (opts.terminal_mib) |mib| return measureTerminalSession(init.io, mib, reps, opts.json); + if (opts.mini) return measureMini(init.io, init.gpa, reps, opts.json); - // fixtures live in a temp dir and are rewritten every run: they are inputs - // to a measurement, and a stale one silently changes what the table means. - // .zig extensions on purpose — that is what puts tree-sitter in the frame. - const dir = "/tmp/pardes-perf"; - _ = libc.mkdir(dir, 0o755); // EEXIST is fine + var random: [12]u8 = undefined; + init.io.random(&random); + const dir = try std.fmt.allocPrint(init.arena.allocator(), "/tmp/pardes-perf-{x}", .{random}); + try std.Io.Dir.createDirAbsolute(init.io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(init.io, dir) catch {}; var paths: [fixtures.len][]const u8 = undefined; var bytes: [fixtures.len]usize = undefined; + var created: usize = 0; + defer for (paths[0..created]) |path| gpa.free(path); for (fixtures, 0..) |fx, fi| { const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name }); + paths[fi] = path; + created += 1; const text = try generate(fx); + defer gpa.free(text); try writeFile(path, text); - paths[fi] = path; - // what got WRITTEN, not lines*cols: a line whose code is already wider - // than `cols` is left alone rather than truncated, so the nominal - // product would understate the file the editor actually opens bytes[fi] = text.len; - gpa.free(text); } + const base = if (opts.base) |path| blk: { + const source = try readFileAlloc(path); + defer gpa.free(source); + break :blk try parseBase(source, opts, &bytes); + } else null; - // `--only` leaves the other cells zeroed rather than reshaping the table. Its purpose is - // profiling, not reporting: under `perf record` a single 63 ms cell on the largest fixture - // swamps the samples, and the question "what does ONE keystroke on a small document spend its - // time in" cannot be answered from a profile dominated by a different one. var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { if (only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; - cells[oi][fi] = try measure(op, fx, paths[fi], reps); + cells[oi][fi] = try measure(op, fx, paths[fi], bytes[fi], reps); } } term_chunk = try buildTermText(4 * 1024); + defer gpa.free(term_chunk); var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { @@ -247,24 +341,16 @@ pub fn main(init: std.process.Init) !void { } } - if (json) return reportJson(init.io, &cells, &term_cells, &bytes, reps); - reportText(&cells, &term_cells, &bytes, reps, base_path); + if (opts.json) return reportJson(init.io, &cells, &term_cells, &bytes, opts); + reportText(&cells, &term_cells, &bytes, reps, opts.base, base); } -// ---- the measured gestures ---- - -/// One (op, fixture) cell: `reps` timed samples plus three warmup rounds, -/// reported as min / median / p90 / max. The spread between median and p90 is -/// the run's noise, and reportText prints the worst one so a reader knows how -/// big a difference has to be before it is real. -fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { +fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: usize) !Cell { const warmup = 3; const samples = try gpa.alloc(u64, reps); defer gpa.free(samples); if (op == .open) { - // fresh core per sample: opening is a one-shot, and the second Look at - // the same path only refocuses the pane already holding it. for (0..warmup + reps) |n| { const core = try boot(); defer core.deinit(); @@ -273,6 +359,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + const id = filePane(core) orelse return error.OpenDidNotOpenFile; + const file = &core.panes[id].?.file.?; + if (!std.mem.eql(u8, file.path, path) or file.content.len != expected_bytes) return error.WrongOpenedFile; if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); @@ -282,13 +371,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { defer core.deinit(); core.lookAt(0, path); pump(core); - const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path}); + const id = filePane(core) orelse return error.OpenDidNotOpenFile; core.active = id; const pane = core.panes[id].?; _ = try frame(core); - // insert mode is entered ONCE: `i` is a mode change, not a keystroke of - // typing, and measuring it inside every edit sample would report the wrong - // thing entirely. if (op == .edit_char) { core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); pump(core); @@ -297,6 +383,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { for (0..warmup + reps) |n| { seek(pane, fx, n); _ = try frame(core); // settle the view at the new spot, untimed + const before = .{ .row = pane.cur_row, .col = pane.cur_col, .scroll = pane.scroll(), .len = pane.file.?.content.len }; + const rect = core.rects[id]; + const wheel_col = rect.x + pardes.config.GUTTER; + const wheel_row = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; const t0 = nowNs(); switch (op) { .open => unreachable, @@ -304,21 +394,34 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { .key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }), .key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }), .page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }), - .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = 4, .row = 8 } }), + .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }), .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), } pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .open => unreachable, + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or pane.file.?.content.len != before.len) + return error.RenderChangedDocument, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .key_right => if (pane.cur_row != before.row or pane.cur_col != before.col + 1) return error.RightDidNotMove, + .page_down => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.PageDidNotScroll, + .wheel => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.WheelDidNotScroll, + .edit_char => { + if (pane.file.?.content.len != before.len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.panes.File.sourceLine(pane, before.row); + if (line[@intCast(before.col)] != 'x') return error.CharacterWasNotInserted; + }, + } if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); } -/// One (op, fixture) cell of the terminal table. Same contract as `measure` — -/// fresh core per cell, warmups, median of `reps` — with no `seek`: a terminal -/// has one cursor, and everything measured here scales with the size of the -/// HISTORY rather than with where in it you are standing. fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { const warmup = 3; const samples = try gpa.alloc(u64, reps); @@ -326,26 +429,42 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { const core = try bootTerm(fx); defer core.deinit(); - const pane = core.panes[0] orelse fatal("terminal boot produced no pane", .{}); - // tty mode hands every key straight to the shell, so the two gesture rows - // would otherwise measure one queued write effect and nothing else + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; if (op == .key_down or op == .edit_char) pane.mode = .normal; if (op == .edit_char) { core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); pump(core); } _ = try frame(core); + const motion_rows = if (op == .key_down) (try core.paneCursorLines(pane)).len else 0; + if (op == .key_down and motion_rows < 3) return error.EmptyTerminalFixture; var cols = screen_cols; var rows = screen_rows; for (0..warmup + reps) |n| { + if (op == .key_down) { + const span = @max(1, motion_rows * 8 / 10); + pane.cur_row = @intCast(@min(motion_rows - 2, motion_rows / 10 + (n *% 7919) % span)); + pane.cur_col = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + _ = try frame(core); + } + const before = .{ + .row = pane.cur_row, + .col = pane.cur_col, + .edit_len = if (pane.ovl) |overlay| overlay.text.len else 0, + .replay_head = state.replay.head, + .replay_len = state.replay.len, + .cols = pane.cols, + .rows = pane.rows, + }; const t0 = nowNs(); switch (op) { .render => {}, .output => core.update(.{ .output = .{ .pane = 0, .bytes = term_chunk } }), - // one cell of window drag. Alternating rather than sweeping so - // every sample does a real resize and the fixture never drifts - // away from the size the other rows are measured at. .resize_rows => { rows = if (rows == screen_rows) screen_rows - 1 else screen_rows; core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); @@ -360,16 +479,45 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or + state.replay.head != before.replay_head or state.replay.len != before.replay_len) + return error.RenderChangedTerminal, + .output => { + const capacity = state.replay.bytes.len; + if (term_chunk.len == 0 or term_chunk.len >= capacity) return error.InvalidOutputFixture; + const overflow = term_chunk.len -| (capacity - before.replay_len); + if (state.replay.len != @min(capacity, before.replay_len + term_chunk.len) or + state.replay.head != (before.replay_head + overflow) % capacity) + return error.OutputWasNotRecorded; + const start = (state.replay.head + state.replay.len - term_chunk.len) % capacity; + const first = @min(term_chunk.len, capacity - start); + if (!std.mem.eql(u8, term_chunk[0..first], state.replay.bytes[start..][0..first]) or + !std.mem.eql(u8, term_chunk[first..], state.replay.bytes[0 .. term_chunk.len - first])) + return error.WrongTerminalOutput; + }, + .resize_rows => if (pane.rows == before.rows or pane.cols != before.cols or + state.vt.screens.active.pages.rows != pane.rows) return error.TerminalDidNotResize, + .resize_cols => if (pane.cols == before.cols or pane.rows != before.rows or + state.vt.screens.active.pages.cols != pane.cols) return error.TerminalDidNotResize, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .edit_char => { + const overlay = pane.ovl orelse return error.CharacterWasNotInserted; + if (overlay.text.len != before.edit_len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.modal.lineSlice(overlay.text, @intCast(pane.cur_row - overlay.row)); + if (before.col < 0 or before.col >= line.len or line[@intCast(before.col)] != 'x') + return error.CharacterWasNotInserted; + }, + } if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); } -/// A shell pane carrying `fx.kb` KiB of history. The bytes go in as 8 KiB -/// reads, which is both what a pty delivers and what the flood costs: one -/// core update per chunk. fn bootTerm(fx: TermFixture) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + errdefer core.deinit(); core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } }); pump(core); const text = try buildTermText(fx.kb * 1024); @@ -384,10 +532,6 @@ fn bootTerm(fx: TermFixture) !*pardes.Pardes { return core; } -/// Plausible pty output, at least `want` bytes of it: an OSC 133-marked -/// prompt, the command, and a run of coloured result lines. The prompt markers -/// are the point — the motion surface blanks prompt rows, so a history without -/// them measures a branch no real shell ever takes. fn buildTermText(want: usize) ![]const u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); @@ -407,14 +551,505 @@ fn buildTermText(want: usize) ![]const u8 { return out.toOwnedSlice(); } -/// Park the cursor and the view at sample `n`'s position, walked across the -/// file by a prime stride so consecutive samples land nowhere near each other -/// and a whole run covers the document rather than one neighbourhood of it. -/// Rows sit in the middle 80% (a position at the very top or bottom measures -/// the clamp, not the work), and on a wide fixture the column is deep inside -/// the line so `key-right` exercises the hscroll cut rather than column 1. -/// The sequence depends only on `n`, so two builds see the same positions in -/// the same order and their cells are comparable one for one. +fn terminalBatch(buffer: []u8, batch: usize, cols: u16) ![]const u8 { + var out: std.Io.Writer = .fixed(buffer); + try out.writeAll("\r\x1b[2K"); + var record: usize = 0; + while (out.end < 8192) : (record += 1) { + try out.print("\x1b[36mturn {d} tool {d}\x1b[0m reading src/example.zig\r\n", .{ batch, record }); + try out.writeAll("\x1b[90m reviewing: \x1b[0m"); + for (0..@as(usize, cols) * 3 / 16 + 1) |_| try out.writeAll("text λ界 result "); + try out.writeAll("\r\n\x1b[33mprogress 20%\x1b[0m\r\x1b[2K\x1b[32mprogress 100% ok\x1b[0m\r\n"); + } + try out.print("\x1b[32mTURN {d:0>10} READY\x1b[0m", .{batch}); + return out.buffered(); +} + +fn checkTerminalTail(core: *pardes.Pardes, surface: *pardes.Surface, batch: usize) !void { + const pane = core.panes[0] orelse return error.MissingTerminal; + const rect = core.rects[0]; + const x = rect.x + pardes.config.GUTTER; + const y = (if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pardes.panes.Terminal.gridCursor(pane).y; + var expected_buffer: [32]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buffer, "TURN {d:0>10} READY", .{batch}); + for (expected, 0..) |byte, col| { + const cell = surface.at(x + @as(u16, @intCast(col)), y); + if (!std.mem.eql(u8, cell.grapheme(), &.{byte})) return error.LatestTerminalTailMissing; + if (!std.meta.eql(pardes.Color{ .index = 2 }, cell.style.fg)) return error.LatestTerminalTailStyleWrong; + } +} + +fn processPeakRss() ?usize { + const os = @import("builtin").os.tag; + if (comptime os != .linux and !os.isDarwin()) return null; + if (comptime os == .linux) { + const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + var buffer: [8192]u8 = undefined; + const n = libc.read(fd, &buffer, buffer.len); + if (n <= 0) return null; + var lines = std.mem.splitScalar(u8, buffer[0..@intCast(n)], '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "VmHWM:")) continue; + var words = std.mem.tokenizeAny(u8, line[6..], " \t"); + const kib = std.fmt.parseInt(usize, words.next() orelse return null, 10) catch return null; + return std.math.mul(usize, kib, 1024) catch null; + } + return null; + } + var usage: libc.rusage = undefined; + if (libc.getrusage(libc.rusage.SELF, &usage) != 0 or usage.maxrss < 0) return null; + return @intCast(usage.maxrss); +} + +const UndoOutput = struct { + depth: usize, + input_bytes: usize, + updates: usize, + history_bytes: usize, + update_median_ns: u64, + update_p95_ns: u64, + batch_median_ns: u64, + batch_p95_ns: u64, + allocator_calls: usize, + allocator_live_before: usize, + allocator_live_after: usize, + anchor_evicted: bool, +}; + +fn measureUndoOutput(core: *pardes.Pardes, counter: *Allocations, arena: *std.heap.ArenaAllocator, samples: []u64) ![3]UndoOutput { + const pane = core.panes[0].?; + const pages = &pane.terminal.?.vt.screens.active.pages; + if (pane.ed_undo_len != 0 or pane.ed_redo_len != 0) return error.UnexpectedTerminalHistory; + if (pane.ovl) |overlay| core.gpa.free(overlay.text); + pane.ovl = null; + var result: [3]UndoOutput = undefined; + const updates = try gpa.alloc(u64, samples.len * 4); + defer gpa.free(updates); + var buffer: [16 * 1024]u8 = undefined; + for ([_]usize{ 0, 1, 64 }, 0..) |depth, phase| { + const first: i32 = @intCast(@min(128, pages.total_rows - 2)); + for (0..depth) |index| { + pane.ovl = .{ .row = first, .rows = 2, .text = try std.fmt.allocPrint(core.gpa, "saved edit {d}\nsecond row", .{index}) }; + pane.cur_row = first + 1; + pane.cur_col = 1; + pane.vsel = .{ .active = true, .row = first, .col = 0 }; + pardes.panes.Terminal.pushUndo(core, pane); + core.gpa.free(pane.ovl.?.text); + pane.ovl = null; + if (pane.ed_undo_len != index + 1) return error.TerminalUndoSnapshotFailed; + } + defer { + for (pane.ed_undo[0..pane.ed_undo_len]) |snapshot| if (snapshot.ovl) |overlay| core.gpa.free(overlay.text); + pane.ed_undo_len = 0; + } + pardes.panes.Terminal.enterTty(core, 0); + pump(core); + const tracked_before = pages.countTrackedPins(); + const start = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first) } }).?); + defer pages.untrackPin(start); + const end = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first + 1) } }).?); + defer pages.untrackPin(end); + const calls_before = counter.calls; + const live_before = counter.live; + const history_bytes = pages.page_size; + var sent: usize = 0; + var nupdates: usize = 0; + for (samples, 0..) |*sample, batch| { + const bytes = try terminalBatch(&buffer, batch, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const stop = @min(offset + 4096, bytes.len); + const update_begin = nowNs(); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..stop] } }); + pump(core); + updates[nupdates] = nowNs() - update_begin; + nupdates += 1; + offset = stop; + } + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + sent += bytes.len; + try checkTerminalTail(core, surface, batch); + if (pages.countTrackedPins() != tracked_before + 2) return error.TerminalPinsLeaked; + const expected: i32 = if (start.garbage) 0 else @intCast((pages.pointFromPin(.screen, start.*) orelse return error.TerminalAnchorLost).screen.y); + const last: i32 = if (end.garbage) 0 else @intCast((pages.pointFromPin(.screen, end.*) orelse return error.TerminalAnchorLost).screen.y); + for (pane.ed_undo[0..pane.ed_undo_len], 0..) |snapshot, index| { + const overlay = snapshot.ovl orelse return error.TerminalUndoTextLost; + var expected_buffer: [64]u8 = undefined; + const text = try std.fmt.bufPrint(&expected_buffer, "saved edit {d}\nsecond row", .{index}); + if (!std.mem.eql(u8, text, overlay.text)) return error.TerminalUndoTextChanged; + if (overlay.row != expected or overlay.rows != @max(1, last - expected + 1) or + snapshot.cur_row != expected + 1 or snapshot.cur_col != 1 or + !snapshot.vsel.active or snapshot.vsel.row != expected or snapshot.vsel.col != 0) + return error.TerminalUndoAnchorWrong; + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, updates[0..nupdates], {}, std.sort.asc(u64)); + result[phase] = .{ + .depth = depth, + .input_bytes = sent, + .updates = nupdates, + .history_bytes = history_bytes, + .update_median_ns = updates[nupdates / 2], + .update_p95_ns = updates[(nupdates * 95 + 99) / 100 - 1], + .batch_median_ns = samples[samples.len / 2], + .batch_p95_ns = samples[(samples.len * 95 + 99) / 100 - 1], + .allocator_calls = counter.calls - calls_before, + .allocator_live_before = live_before, + .allocator_live_after = counter.live, + .anchor_evicted = start.garbage, + }; + } + return result; +} + +fn measureTerminalSession(io: std.Io, mib: usize, reps: usize, json: bool) !void { + if (comptime !pardes.terminal_panes) return error.TerminalsUnavailable; + const requested_bytes = mib * 1024 * 1024; + const samples = try gpa.alloc(u64, requested_bytes / 8192 + 1); + defer gpa.free(samples); + const modal_samples = try gpa.alloc(u64, reps); + defer gpa.free(modal_samples); + var counter: Allocations = .{}; + const result = blk: { + const core = try pardes.Pardes.init(counter.allocator(), .{ .tty_only = true, .cols = screen_cols, .rows = screen_rows }); + defer core.deinit(); + pump(core); + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; + pane.mode = .tty; + pane.tty_filter = false; + var arena: std.heap.ArenaAllocator = .init(counter.allocator()); + defer arena.deinit(); + _ = try core.render(arena.allocator()); + var buffer: [16 * 1024]u8 = undefined; + var sent: usize = 0; + var batches: usize = 0; + var update_render_ns: u64 = 0; + var row_count_decreases: usize = 0; + var previous_rows: usize = 0; + const stream_begin = nowNs(); + while (sent < requested_bytes) : (batches += 1) { + const bytes = try terminalBatch(&buffer, batches, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const end = @min(offset + 4096, bytes.len); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..end] } }); + pump(core); + offset = end; + } + const surface = try core.render(arena.allocator()); + samples[batches] = nowNs() - begin; + update_render_ns += samples[batches]; + try checkTerminalTail(core, surface, batches); + const scrollbar = state.vt.screens.active.pages.scrollbar(); + if (scrollbar.offset != scrollbar.total - pane.rows) return error.TerminalStoppedFollowing; + if (scrollbar.total < previous_rows) row_count_decreases += 1; + previous_rows = scrollbar.total; + sent += bytes.len; + } + const stream_wall_ns = nowNs() - stream_begin; + const live_after_output = counter.live; + const first_row = @as(i32, @intCast(state.vt.screens.active.pages.scrollbar().offset)) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y); + _ = arena.reset(.retain_capacity); + const modal_begin = nowNs(); + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + core.update(.{ .key = .{ .cp = 'k', .text = "k" } }); + pump(core); + _ = try core.render(arena.allocator()); + const first_modal_ns = nowNs() - modal_begin; + if (pane.mode != .normal or !pane.cur_pinned or pane.cur_row != first_row - 1) return error.FirstModalKeyDidNotMove; + for (modal_samples, 0..) |*sample, index| { + const down = index % 2 == 0; + const before = pane.cur_row; + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + core.update(.{ .key = .{ .cp = if (down) 'j' else 'k' } }); + pump(core); + _ = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + if (pane.cur_row != before + @as(i32, if (down) 1 else -1)) return error.ModalKeyDidNotMove; + } + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + pump(core); + _ = arena.reset(.retain_capacity); + try checkTerminalTail(core, try core.render(arena.allocator()), batches - 1); + core.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const input = core.nextEffect() orelse return error.TerminalInputMissing; + if (input != .write or input.write.pane != 0 or !std.mem.eql(u8, "f", input.write.bytes.slice())) + return error.TerminalInputWrong; + if (core.nextEffect() != null) return error.UnexpectedTerminalEffect; + std.mem.sort(u64, samples[0..batches], {}, std.sort.asc(u64)); + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + const modal_median_ns = modal_samples[reps / 2]; + const modal_p95_ns = modal_samples[(reps * 95 + 99) / 100 - 1]; + const live_after_modal = counter.live; + var overlay_median_ns: [2]u64 = undefined; + var overlay_p95_ns: [2]u64 = undefined; + pane.mode = .normal; + pane.cur_pinned = true; + pane.ovl = .{ .row = 0, .rows = 1, .text = try core.gpa.dupe(u8, "edited output") }; + for (0..2) |phase| { + for (modal_samples, 0..) |*sample, index| { + const tail_id = batches + phase * reps + index; + var update_buffer: [64]u8 = undefined; + const update = try std.fmt.bufPrint(&update_buffer, "\r\n\x1b[32mTURN {d:0>10} READY\x1b[0m", .{tail_id}); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + core.update(.{ .output = .{ .pane = 0, .bytes = update } }); + pump(core); + const cursor_row = pardes.panes.Terminal.gridOffset(pane) + @as(i32, pardes.panes.Terminal.gridCursor(pane).y); + pane.ovl.?.row = if (phase == 0) 0 else cursor_row - 1; + pane.cur_row = pane.ovl.?.row; + pane.cur_col = 0; + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + try checkTerminalTail(core, surface, tail_id); + } + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + overlay_median_ns[phase] = modal_samples[reps / 2]; + overlay_p95_ns[phase] = modal_samples[(reps * 95 + 99) / 100 - 1]; + } + const live_after_overlay = counter.live; + const undo_output = try measureUndoOutput(core, &counter, &arena, modal_samples); + break :blk .{ + .build = build_identity, + .harness = harness_id, + .case = "terminal-session", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .requested_bytes = requested_bytes, + .input_bytes = sent, + .batches = batches, + .update_render_ns = update_render_ns, + .stream_wall_ns = stream_wall_ns, + .mib_per_second = @as(f64, @floatFromInt(sent)) * 1_000_000_000 / 1048576 / @as(f64, @floatFromInt(@max(1, update_render_ns))), + .batch_median_ns = samples[batches / 2], + .batch_p95_ns = samples[(batches * 95 + 99) / 100 - 1], + .batch_max_ns = samples[batches - 1], + .first_modal_ns = first_modal_ns, + .modal_median_ns = modal_median_ns, + .modal_p95_ns = modal_p95_ns, + .offscreen_overlay_median_ns = overlay_median_ns[0], + .offscreen_overlay_p95_ns = overlay_p95_ns[0], + .visible_overlay_median_ns = overlay_median_ns[1], + .visible_overlay_p95_ns = overlay_p95_ns[1], + .undo_output = undo_output, + .history_rows = state.vt.screens.active.pages.scrollbar().total, + .history_bytes = state.vt.screens.active.pages.page_size, + .history_limit_bytes = state.vt.screens.active.pages.maxSize(), + .row_count_decreases = row_count_decreases, + .allocator_calls = counter.calls, + .allocator_live_after_output = live_after_output, + .allocator_live_after_modal = live_after_modal, + .allocator_live_after_overlay = live_after_overlay, + .allocator_peak_bytes = counter.peak, + .allocator_live_after_deinit = @as(usize, 0), + .process_peak_rss_bytes = processPeakRss(), + .process_rss_scope = "current image VmHWM on Linux; process rusage on Darwin", + .allocator_scope = "core and frame allocator; excludes VT page mappings", + }; + }; + if (counter.live != 0) return error.LeakedSessionMemory; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "terminal session: {d} bytes, {d} batches, {d:.2} MiB/s; p95 {d} ns, first modal {d} ns; history {d} rows / {d} B, allocator peak {d} B (excludes VT mappings), process peak {?d} B", .{ + result.input_bytes, result.batches, result.mib_per_second, result.batch_p95_ns, result.first_modal_ns, + result.history_rows, result.history_bytes, result.allocator_peak_bytes, result.process_peak_rss_bytes, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); +} + +const MiniProbe = enum { generate, highlight_generate, render }; +const MiniMeasurement = struct { + cold_ns: u64 = 0, + median_ns: u64 = 0, + p95_ns: u64 = 0, + allocations_total: usize = 0, + allocated_bytes_total: usize = 0, + warm_allocations_total: usize = 0, + warm_allocated_bytes_total: usize = 0, + peak_bytes: usize = 0, + live_after_warm: usize = 0, + live_after_deinit: usize = 0, +}; + +fn checkMini(actual: pardes.panes.Mini.Result, expected: pardes.panes.Mini.Result) !void { + if (!std.mem.eql(u8, actual.content, expected.content) or !std.mem.eql(u8, actual.colors, expected.colors)) + return error.MiniOutputMismatch; +} + +fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.Mini.Result, reps: usize) !MiniMeasurement { + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const allocators = pardes.memory.init(fallback); + defer pardes.memory.deinit(); + var counter: Allocations = .{}; + var core_counter: Allocations = .{ .backing = allocators.pardes, .account = &counter }; + var syntax_counter: Allocations = .{ .backing = allocators.tree_sitter, .account = &counter }; + var frame_counter: Allocations = .{ .backing = allocators.frame, .account = &counter }; + const allocator = core_counter.allocator(); + const syntax_allocator = syntax_counter.allocator(); + const frame_allocator = frame_counter.allocator(); + var result: MiniMeasurement = .{}; + { + if (probe != .generate) pardes.syntax.start(syntax_allocator); + defer if (probe != .generate) pardes.syntax.stop(); + const core = if (probe == .render) + try pardes.Pardes.init(allocator, .{ + .tty_only = true, + .cols = screen_cols, + .rows = screen_rows, + .tree_sitter_allocator = syntax_allocator, + .frame_allocator = frame_allocator, + }) + else + null; + defer if (core) |p| p.deinit(); + var arena: std.heap.ArenaAllocator = .init(frame_allocator); + defer arena.deinit(); + if (core) |p| { + pump(p); + try pardes.panes.Mini.open(p, 0, path); + for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id); + pump(p); + p.settings.colors = true; + const file = p.panes[p.active].?.file.?; + try checkMini(.{ .content = file.content, .colors = file.mini.?.colors }, expected); + } + var frame_hash: ?u64 = null; + for (0..reps + 1) |index| { + _ = arena.reset(.retain_capacity); + const calls = counter.calls; + const bytes = counter.bytes; + const begin = nowNs(); + if (core) |p| { + const surface = try p.render(arena.allocator()); + const elapsed = nowNs() - begin; + if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; + var hash: std.hash.Wyhash = .init(0); + var braille_cells: usize = 0; + for (surface.cells) |cell| { + const glyph = cell.grapheme(); + hash.update(glyph); + std.hash.autoHash(&hash, cell.style); + if (glyph.len == 3 and glyph[0] == 0xe2 and glyph[1] >= 0xa0 and glyph[1] <= 0xa3) + braille_cells += 1; + } + if (braille_cells == 0) return error.MiniNotRendered; + if (frame_hash) |old| { + if (old != hash.final()) return error.MiniRenderChanged; + } else frame_hash = hash.final(); + } else { + const highlighted = if (probe == .highlight_generate) + try pardes.syntax.highlightFileRange(syntax_allocator, path, source, 0, source.len) + else + styles; + defer if (probe == .highlight_generate) syntax_allocator.free(highlighted); + const converted = try pardes.panes.Mini.generate(allocator, source, highlighted); + const elapsed = nowNs() - begin; + defer converted.deinit(allocator); + if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; + if (!std.mem.eql(u8, highlighted, styles)) return error.MiniHighlightMismatch; + try checkMini(converted, expected); + } + if (index != 0) { + result.warm_allocations_total += counter.calls - calls; + result.warm_allocated_bytes_total += counter.bytes - bytes; + } + } + result.live_after_warm = counter.live; + } + if (counter.live != 0) return error.LeakedMiniMemory; + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + result.median_ns = samples[reps / 2]; + result.p95_ns = samples[(reps * 95 + 99) / 100 - 1]; + result.allocations_total = counter.calls; + result.allocated_bytes_total = counter.bytes; + result.peak_bytes = counter.peak; + result.live_after_deinit = counter.live; + return result; +} + +fn measureMini(io: std.Io, fallback: std.mem.Allocator, reps: usize, json: bool) !void { + var random: [12]u8 = undefined; + io.random(&random); + const dir = try std.fmt.allocPrint(gpa, "/tmp/pardes-perf-mini-{x}", .{random}); + defer gpa.free(dir); + try std.Io.Dir.createDirAbsolute(io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(io, dir) catch {}; + for ([_]Fixture{ + .{ .name = "mini-small", .lines = 256, .cols = 72 }, + .{ .name = "mini-1m", .lines = 14_400, .cols = 72 }, + .{ .name = "mini-longline", .lines = 1, .cols = 1024 * 1024 }, + .{ .name = "mini-unicode-tabs", .lines = 4096, .cols = 0 }, + }) |fixture| { + const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fixture.name }); + defer gpa.free(path); + const unicode_line = "\tconst label = \"界 café 🙂\";\n\t// é 漢字\tcomment\n"; + const source = if (fixture.cols == 0) blk: { + const bytes = try gpa.alloc(u8, unicode_line.len * fixture.lines); + for (0..fixture.lines) |i| @memcpy(bytes[i * unicode_line.len ..][0..unicode_line.len], unicode_line); + break :blk bytes; + } else try generate(fixture); + defer gpa.free(source); + try writeFile(path, source); + const styles = blk: { + pardes.syntax.start(gpa); + defer pardes.syntax.stop(); + break :blk try pardes.syntax.highlightFileRange(gpa, path, source, 0, source.len); + }; + defer gpa.free(styles); + if (styles.len != source.len or std.mem.indexOfScalar(u8, styles, @intFromEnum(pardes.syntax.Syn.comment)) == null) + return error.MiniSyntaxUnavailable; + const expected = try pardes.panes.Mini.generate(gpa, source, styles); + defer expected.deinit(gpa); + if (expected.content.len == 0 or expected.content.len != expected.colors.len or !std.unicode.utf8ValidateSlice(expected.content)) + return error.InvalidMiniOutput; + for (std.enums.values(MiniProbe)) |probe| { + const measurement = try miniMeasurement(fallback, probe, path, source, styles, expected, reps); + const row = .{ + .build = build_identity, + .harness = harness_id, + .case = fixture.name, + .probe = @tagName(probe), + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .input_bytes = source.len, + .input_hash = std.hash.Wyhash.hash(0, source), + .input_style_hash = std.hash.Wyhash.hash(0, styles), + .output_bytes = expected.content.len, + .output_hash = std.hash.Wyhash.hash(0, expected.content), + .output_style_hash = std.hash.Wyhash.hash(0, expected.colors), + .measurement = measurement, + .allocator = "native memory.init with process allocator fallback; core, syntax and frame pools reset per probe", + .allocation_scope = "requested probe allocations, including render setup; excludes fixed pool storage, VT mappings, fixture, reference, samples and reporting", + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, row, .{}) + else + try std.fmt.allocPrint(gpa, "{s} {s}: {d} -> {d} bytes, median {d} ns, peak {d} B, warm allocations {d}/{d} samples, balanced", .{ + row.case, row.probe, row.input_bytes, row.output_bytes, measurement.median_ns, measurement.peak_bytes, measurement.warm_allocations_total, reps, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } + } +} + fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void { const f = &pane.file.?; const span = @max(1, fx.lines * 8 / 10); @@ -435,13 +1070,10 @@ fn boot() !*pardes.Pardes { return core; } -/// one frame into a throwaway arena — the shell's per-frame arena, which is -/// what keeps the retained-render contract honest (vaxis stores slices into -/// whatever it was handed, so the frame's text must outlive vx.render) -fn frame(core: *pardes.Pardes) !*pardes.Surface { +fn frame(core: *pardes.Pardes) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - return core.render(arena.allocator()); + _ = try core.render(arena.allocator()); } fn filePane(core: *pardes.Pardes) ?usize { @@ -452,8 +1084,6 @@ fn filePane(core: *pardes.Pardes) ?usize { return null; } -/// Drain queued effects, all ignored (spawn, write, watch, ...): there is no -/// shell here, and nothing measured depends on one answering. fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } @@ -464,18 +1094,11 @@ fn summarize(samples: []u64) Cell { return .{ .min_us = samples[0], .med_us = samples[samples.len / 2], - .p90_us = samples[(samples.len * 9) / 10 -| 1], + .p90_us = samples[(samples.len * 9 + 9) / 10 - 1], .max_us = samples[samples.len - 1], }; } -// ---- the fixture generator ---- - -/// Plausible Zig, so the tree-sitter pass has real nodes to walk rather than -/// one giant error node: a repeating four-line shape padded to `cols`. Long -/// lines get their width from a comment tail — the alternative (an enormous -/// string literal) makes the whole file one token and flatters every scan that -/// looks for a newline. fn generatedPrefixLen(n: usize) usize { return switch (n % 4) { 0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), @@ -528,17 +1151,15 @@ fn writeFile(path: []const u8, text: []const u8) !void { } } -// ---- reporting ---- - fn reportText( cells: *const [std.enums.values(Op).len][fixtures.len]Cell, term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize, base_path: ?[]const u8, + base: ?Base, ) void { const o = std.debug.print; - const base = if (base_path) |bp| readBase(bp, reps) else null; o("pardes perf — {d}x{d} viewport, {d} samples/cell, median us\n\n", .{ screen_cols, screen_rows, reps }); o("{s:<12}", .{"fixture"}); @@ -551,14 +1172,10 @@ fn reportText( for (bytes) |b| o(" {d:>10} KB", .{b / 1024}); o("\n\n", .{}); - // one row per gesture. `open` is a whole Look; every other row is - // update+effects+one frame, which is the latency a user actually sees. o("{s:<12}", .{"op"}); for (fixtures) |fx| { if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); } - // 12 for the op name + one column per fixture, wider when a baseline adds - // its ratio to each cell o("\n{s}\n", .{if (base != null) "-" ** (12 + fixtures.len * 20) else "-" ** (12 + fixtures.len * 13)}); var worst_jitter: f64 = 0; for (std.enums.values(Op), 0..) |op, oi| { @@ -582,7 +1199,6 @@ fn reportText( o("\n", .{}); } - // second table, same shape: the terminal costs, against SCROLLBACK o("\n{s:<12}", .{"scrollback"}); for (term_fixtures) |fx| { if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); @@ -608,20 +1224,12 @@ fn reportText( } o("\n", .{}); } - // The spread is not only scheduler noise: samples are taken at DIFFERENT - // places in the file on purpose (see seek), so a cost that still depends on - // where the cursor is shows up here as well. Both are reasons not to - // believe a small difference, and the sample positions are identical from - // run to run, so two builds are still comparable cell for cell. o("\nnoise: worst cell p90 is {d:.0}% over its median (scheduler + the spread\n", .{worst_jitter * 100}); o("of sample positions through the file). Treat a difference smaller than\n", .{}); o("that as nothing, and re-run before believing a small win.\n", .{}); if (base_path) |bp| o("baseline: {s} (x column = now / then; under 1.00 is faster)\n", .{bp}); } -/// Real stdout, not std.debug.print's stderr: this is the form `--base` reads -/// back, and `zig build perf -- --json > runs/old.json` writing an empty file -/// would make the next comparison silently print no ratios at all. const json_report_max_bytes = 32 * 1024; fn reportJson( @@ -629,38 +1237,53 @@ fn reportJson( cells: *const [std.enums.values(Op).len][fixtures.len]Cell, term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, bytes: *const [fixtures.len]usize, - reps: usize, -) void { + opts: Options, +) !void { var storage: [json_report_max_bytes]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); - out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return; + try writeJson(&out, cells, term_cells, bytes, opts); + try std.Io.File.stdout().writeStreamingAll(io, out.buffered()); +} + +fn writeJson( + out: *std.Io.Writer, + cells: *const [std.enums.values(Op).len][fixtures.len]Cell, + term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, + bytes: *const [fixtures.len]usize, + opts: Options, +) !void { + try out.print("{{\"harness\":\"{s}\",\"build\":", .{harness_id}); + try std.json.Stringify.value(build_identity, .{}, out); + try out.print(",\"cols\":{d},\"rows\":{d},\"reps\":{d},\"only\":", .{ opts.cols, opts.rows, opts.reps }); + try std.json.Stringify.value(opts.only, .{}, out); + try out.writeAll(",\"fixtures\":["); for (fixtures, 0..) |fx, fi| { - out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ + try out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi], - }) catch return; + }); } - out.writeAll("],\"cells\":[") catch return; + try out.writeAll("],\"cells\":["); var first = true; for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; const c = cells[oi][fi]; - out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + try out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, - }) catch return; + }); first = false; } } - for (std.enums.values(TermOp), 0..) |op, oi| { + if (opts.only == null) for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { const c = term_cells[oi][fi]; - out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + try out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, - }) catch return; + }); first = false; } - } - out.writeAll("]}\n") catch return; - std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; + }; + try out.writeAll("]}\n"); } const Base = struct { @@ -668,50 +1291,242 @@ const Base = struct { term_med: [std.enums.values(TermOp).len][term_fixtures.len]u64, }; -/// A previous --json run, reduced to the medians this table compares against. -/// Cells the old run did not have stay 0 and print as "-": the op list may -/// have grown since, and a missing number is not a regression. An unreadable -/// file is fatal rather than silently ratio-less — a comparison you asked for -/// and did not get is worse than no comparison. -/// -/// So is one that is quietly WRONG, which is why `reps` has to match. `seek` -/// walks sample n to `n *% 7919 % span`, so two runs with different counts -/// measure different PLACES in the file, and every file row comes out 20-50% -/// apart with nothing whatever having changed. -fn readBase(path: []const u8, reps: usize) ?Base { - const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path}); - defer gpa.free(src); - var b: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; - const parsed = std.json.parseFromSlice(struct { - reps: usize = 0, - cells: []const struct { - op: []const u8, - fixture: []const u8, - med_us: u64, - }, - }, gpa, src, .{ .ignore_unknown_fields = true }) catch return null; +const Report = struct { + harness: []const u8 = "", + build: BuildIdentity, + cols: u16, + rows: u16, + reps: usize, + only: ?[]const u8 = null, + fixtures: []const struct { name: []const u8, lines: usize, cols: usize, bytes: usize }, + cells: []const struct { + op: []const u8, + fixture: []const u8, + min_us: u64, + med_us: u64, + p90_us: u64, + max_us: u64, + }, +}; + +fn parseBase(src: []const u8, opts: Options, bytes: *const [fixtures.len]usize) !Base { + const parsed = std.json.parseFromSlice(Report, gpa, src, .{}) catch return error.InvalidBaseline; defer parsed.deinit(); - if (parsed.value.reps != reps) fatal( - "--base: {s} was taken with --reps {d}, this run is --reps {d}. Same count or no comparison.", - .{ path, parsed.value.reps, reps }, - ); - for (parsed.value.cells) |c| { - for (std.enums.values(Op), 0..) |op, oi| { - if (!std.mem.eql(u8, op.label(), c.op)) continue; - for (fixtures, 0..) |fx, fi| { - if (std.mem.eql(u8, fx.name, c.fixture)) b.med[oi][fi] = c.med_us; - } + const report = parsed.value; + if (!std.mem.eql(u8, report.harness, harness_id)) return error.BaselineHarnessMismatch; + inline for (std.meta.fields(BuildIdentity)) |field| { + const before = @field(report.build, field.name); + const after = @field(build_identity, field.name); + const same = if (field.type == []const u8) std.mem.eql(u8, before, after) else before == after; + if (!same) return error.BaselineBuildMismatch; + } + if (report.cols != opts.cols or report.rows != opts.rows or report.reps != opts.reps or + ((report.only == null) != (opts.only == null))) return error.BaselineMismatch; + if (opts.only) |name| if (!std.mem.eql(u8, name, report.only.?)) return error.BaselineMismatch; + if (report.fixtures.len != fixtures.len) return error.BaselineCoverage; + + var shapes: [fixtures.len]bool = @splat(false); + for (report.fixtures) |shape| { + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, shape.name, fixture.name)) break index; + } else return error.BaselineCoverage; + if (shapes[fi]) return error.BaselineCoverage; + shapes[fi] = true; + if (shape.lines != fixtures[fi].lines or shape.cols != fixtures[fi].cols or shape.bytes != bytes[fi]) + return error.BaselineMismatch; + } + + var base: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; + var file_seen: [std.enums.values(Op).len][fixtures.len]bool = @splat(@splat(false)); + var term_seen: [std.enums.values(TermOp).len][term_fixtures.len]bool = @splat(@splat(false)); + for (report.cells) |cell| { + if (cell.min_us > cell.med_us or cell.med_us > cell.p90_us or cell.p90_us > cell.max_us) + return error.InvalidBaseline; + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else null; + if (fi) |index| { + if (opts.only) |name| if (!std.mem.eql(u8, name, cell.fixture)) return error.BaselineCoverage; + const oi = for (std.enums.values(Op), 0..) |op, op_index| { + if (std.mem.eql(u8, cell.op, op.label())) break op_index; + } else return error.BaselineCoverage; + if (file_seen[oi][index]) return error.BaselineCoverage; + file_seen[oi][index] = true; + base.med[oi][index] = cell.med_us; + } else { + if (opts.only != null) return error.BaselineCoverage; + const ti = for (term_fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else return error.BaselineCoverage; + const oi = for (std.enums.values(TermOp), 0..) |op, index| { + if (std.mem.eql(u8, cell.op, op.label())) break index; + } else return error.BaselineCoverage; + if (term_seen[oi][ti]) return error.BaselineCoverage; + term_seen[oi][ti] = true; + base.term_med[oi][ti] = cell.med_us; } - // op labels repeat across the two tables ("render", "key-down"); the - // fixture names never do, so the pair still names exactly one cell - for (std.enums.values(TermOp), 0..) |op, oi| { - if (!std.mem.eql(u8, op.label(), c.op)) continue; - for (term_fixtures, 0..) |fx, fi| { - if (std.mem.eql(u8, fx.name, c.fixture)) b.term_med[oi][fi] = c.med_us; - } + } + for (fixtures, 0..) |fixture, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fixture.name)) continue; + for (file_seen) |row| if (!row[fi]) return error.BaselineCoverage; + } + if (opts.only == null) for (term_seen) |row| { + for (row) |seen| if (!seen) return error.BaselineCoverage; + }; + return base; +} + +fn selfTest() !void { + const testing = std.testing; + for ([_][]const []const u8{ + &.{ "--reps", "bad" }, &.{ "--reps", "-1" }, &.{ "--cols", "65536" }, &.{ "--rows", "" }, &.{ "--terminal-mib", "bad" }, + }) |args| try testing.expectError(error.InvalidNumber, Options.parse(args)); + for ([_][]const []const u8{ + &.{ "--reps", "0" }, &.{ "--cols", "0" }, &.{ "--rows", "0" }, + &.{ "--cols", "7" }, &.{ "--rows", "4" }, &.{ "--terminal-mib", "0" }, + &.{ "--terminal-mib", "257" }, &.{ "--terminal-mib", "1", "--cols", "31" }, &.{ "--terminal-mib", "1", "--rows", "257" }, + }) |args| try testing.expectError(error.InvalidMeasurement, Options.parse(args)); + for ([_][]const []const u8{ + &.{"--reps"}, &.{"--cols"}, &.{"--rows"}, &.{"--only"}, &.{"--base"}, &.{"--terminal-mib"}, + }) |args| try testing.expectError(error.MissingValue, Options.parse(args)); + try testing.expectError(error.UnknownOption, Options.parse(&.{"--typo"})); + try testing.expectError(error.UnknownFixture, Options.parse(&.{ "--only", "typo" })); + for ([_][]const []const u8{ + &.{ "--json", "--base", "old.json" }, &.{ "--creation", "--only", "small" }, + &.{ "--creation", "--base", "old.json" }, &.{ "--self-test", "--json" }, + &.{ "--terminal-mib", "1", "--only", "small" }, &.{ "--terminal-mib", "1", "--creation" }, + &.{ "--terminal-mib", "1", "--base", "old.json" }, &.{ "--mini", "--creation" }, + &.{ "--mini", "--terminal-mib", "1" }, &.{ "--mini", "--base", "old.json" }, + &.{ "--mini", "--only", "small" }, + }) |args| try testing.expectError(error.IncompatibleOptions, Options.parse(args)); + const selected = try Options.parse(&.{ "--only", "small", "--reps", "2", "--cols", "80", "--rows", "24" }); + try testing.expectEqual(@as(usize, 2), selected.reps); + try testing.expectEqual(@as(u16, 80), selected.cols); + try testing.expectEqual(@as(u16, 24), selected.rows); + try testing.expectEqualStrings("small", selected.only.?); + _ = try Options.parse(&.{ "--cols", "8", "--rows", "5" }); + _ = try Options.parse(&.{ "--creation", "--cols", "1", "--rows", "1" }); + const terminal = try Options.parse(&.{ "--terminal-mib", "64", "--json", "--reps", "3" }); + try testing.expectEqual(@as(usize, 64), terminal.terminal_mib.?); + const mini = try Options.parse(&.{ "--mini", "--json", "--reps", "1" }); + try testing.expect(mini.mini and mini.json and mini.reps == 1); + for ([_]u16{ 32, 120, 512 }) |cols| { + var buffer: [16 * 1024]u8 = undefined; + const bytes = try terminalBatch(&buffer, 37, cols); + try testing.expect(bytes.len >= 8192 and bytes.len <= buffer.len); + try testing.expect(std.unicode.utf8ValidateSlice(bytes)); + try testing.expect(std.mem.indexOf(u8, bytes, "λ界") != null); + try testing.expect(std.mem.endsWith(u8, bytes, "\x1b[32mTURN 0000000037 READY\x1b[0m")); + try testing.expectError(error.WriteFailed, terminalBatch(buffer[0..16], 37, cols)); + } + + var samples = [_]u64{ 2, 1 }; + const stats = summarize(&samples); + try testing.expectEqual(@as(u64, 2), stats.p90_us); + try testing.expect(stats.med_us <= stats.p90_us); + const cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const terms: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const bytes: [fixtures.len]usize = @splat(1024); + for ([_]Options{ .{}, selected }) |opts| { + var storage: [json_report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + try writeJson(&out, &cells, &terms, &bytes, opts); + const valid = out.buffered(); + const base = try parseBase(valid, opts, &bytes); + try testing.expectEqual(@as(u64, 2), base.med[0][0]); + var different = opts; + different.cols += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.reps += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.only = if (opts.only == null) "small" else null; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + var changed_bytes = bytes; + changed_bytes[0] += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, opts, &changed_bytes)); + try testing.expectError(error.InvalidBaseline, parseBase(valid[0 .. valid.len - 3], opts, &bytes)); + + const parsed = try std.json.parseFromSlice(Report, gpa, valid, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + var report = parsed.value; + report.cells = report.cells[1..]; + var short: std.Io.Writer = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + report = parsed.value; + const saved = report.cells[1]; + @constCast(report.cells)[1] = report.cells[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[1] = saved; + const first = report.cells[0]; + @constCast(report.cells)[0].op = "unknown"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + @constCast(report.cells)[0].p90_us = 0; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + const shape = report.fixtures[1]; + @constCast(report.fixtures)[1] = report.fixtures[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.fixtures)[1] = shape; + report.harness = "another harness"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineHarnessMismatch, parseBase(short.buffered(), opts, &bytes)); + + short = .fixed(&storage); + try std.json.Stringify.value(parsed.value, .{}, &short); + const raw = try std.json.parseFromSlice(std.json.Value, gpa, short.buffered(), .{ .allocate = .alloc_always }); + defer raw.deinit(); + var root = raw.value.object; + const identity = root.get("build").?; + try testing.expect(root.swapRemove("build")); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + for ([_]std.json.Value{ .null, .{ .string = "wrong" }, .{ .object = .empty } }) |invalid| { + try root.put(raw.arena.allocator(), "build", invalid); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + } + try root.put(raw.arena.allocator(), "build", identity); + const identity_fields = &root.getPtr("build").?.object; + for (std.meta.fieldNames(BuildIdentity)) |name| { + const value = identity_fields.get(name).?; + identity_fields.getPtr(name).?.* = switch (value) { + .bool => |v| .{ .bool = !v }, + .string => .{ .string = "different" }, + else => unreachable, + }; + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.BaselineBuildMismatch, parseBase(short.buffered(), opts, &bytes)); + try testing.expect(identity_fields.swapRemove(name)); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + try identity_fields.put(raw.arena.allocator(), name, value); } + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + _ = try parseBase(short.buffered(), opts, &bytes); } - return b; + if (!@import("builtin").is_test) std.debug.print("perf options, build identity, percentiles and baseline coverage: pass\n", .{}); +} + +test "perf options, percentiles and baseline coverage" { + try selfTest(); } fn readFileAlloc(path: []const u8) ![]u8 { @@ -737,8 +1552,3 @@ fn readFileAlloc(path: []const u8) ![]u8 { } return buf; } - -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-perf: " ++ fmt ++ "\n", args); - std.process.exit(1); -} diff --git a/test/snapshot.zig b/test/snapshot.zig index 5dc2e6e9..a7a1ba48 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -48,6 +48,7 @@ // resize <rows> <cols> // snap <label> capture grid text + cursor // snapstyle <label> capture per-cell style runs (fg,bg,attrs) +// snap9p <label> read core cells/styles over the child's 9P socket // // Any <col> above may be `@Word`, `@Word#2` or `@Word+4` instead of a number: // the column of a word on that row of the live grid (see resolveCol). Chrome @@ -62,6 +63,7 @@ const std = @import("std"); const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const eh = @import("e2e_harness.zig"); +const Client = @import("9p_io").Client; // This harness parses the app's output with its OWN ghostty-vt, which narrates // every sequence it does not implement — vaxis's startup capability probes @@ -74,6 +76,7 @@ pub const std_options: std.Options = .{ .log_level = .err }; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn system(cmd: [*:0]const u8) c_int; +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; const SNAP_BASE = "/tmp/pardes-snap"; var trace_stable = false; @@ -82,6 +85,7 @@ var careful = false; /// abs path of the deterministic mock language server the build handed over /// (--lspmock=); null leaves the protocol client fully disabled for the run var lspmock_bin: ?[:0]const u8 = null; +var record_dir: ?[]const u8 = null; const gpa = std.heap.page_allocator; pub fn main(init: std.process.Init) !void { @@ -89,6 +93,7 @@ pub fn main(init: std.process.Init) !void { defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len == 2 and std.mem.eql(u8, args[1], "--self-test")) return testCli(init); // argv: exe-under-test, then flags/scripts. All paths made absolute up // front because runScript chdirs into a per-script workdir. @@ -98,6 +103,7 @@ pub fn main(init: std.process.Init) !void { var exe: ?[]const u8 = null; var update = false; + var retry_failures = true; // Slight oversubscription: a script spends most of its life waiting on a // shell, not computing. Measured on 16 cores: 16 jobs 4.4s, 20 jobs 3.9s, // 24 jobs 3.7s but losing the idle-probe bet often enough that the re-run @@ -109,10 +115,14 @@ pub fn main(init: std.process.Init) !void { for (args[1..]) |a| { if (std.mem.eql(u8, a, "--update")) { update = true; + } else if (std.mem.eql(u8, a, "--no-retry")) { + retry_failures = false; } else if (std.mem.eql(u8, a, "--trace-stable")) { trace_stable = true; } else if (std.mem.startsWith(u8, a, "--lspmock=")) { lspmock = try absPath(arena, orig_cwd, a["--lspmock=".len..]); + } else if (std.mem.startsWith(u8, a, "--record=")) { + record_dir = try absPath(arena, orig_cwd, a["--record=".len..]); } else if (std.mem.startsWith(u8, a, "--jobs=")) { jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10)); } else if (exe == null) { @@ -134,8 +144,10 @@ pub fn main(init: std.process.Init) !void { var saw_exe = false; for (args[1..]) |a| { if (std.mem.eql(u8, a, "--update") or + std.mem.eql(u8, a, "--no-retry") or std.mem.eql(u8, a, "--trace-stable") or std.mem.startsWith(u8, a, "--lspmock=") or + std.mem.startsWith(u8, a, "--record=") or std.mem.startsWith(u8, a, "--jobs=")) continue; if (!saw_exe) { saw_exe = true; @@ -157,6 +169,16 @@ pub fn main(init: std.process.Init) !void { }.lt); } if (scripts.len == 0) fatal("no .snap scripts found", .{}); + for (scripts, 0..) |script, i| for (scripts[0..i]) |previous| { + if (std.mem.eql(u8, std.fs.path.stem(script), std.fs.path.stem(previous))) { + std.debug.print("snapshot scripts share a work directory: {s} and {s}\n", .{ previous, script }); + return error.DuplicateScriptName; + } + }; + if (record_dir) |dir| { + if (update) fatal("--record and --update are separate modes", .{}); + try mkdir(arena, dir, true); + } try mkdir(arena, SNAP_BASE, true); // one run at a time (this is the flock that used to be typed by hand) @@ -165,20 +187,23 @@ pub fn main(init: std.process.Init) !void { const codes = try arena.alloc(u8, scripts.len); if (update) { careful = true; - try runBatch(arena, exe_z, toBatch(arena, scripts), true, jobs, codes); + try runBatch(arena, exe_z, try toBatch(arena, scripts), true, jobs, codes); + var update_failed = false; + for (scripts, codes) |script, code| if (code != 0) { + update_failed = true; + const report = readFileAlloc(arena, try reportPath(arena, script)) catch "child died without a report\n"; + std.debug.print("{s}", .{report}); + }; + if (update_failed) return error.SnapshotRegenerationFailed; careful = false; std.debug.print("verifying {d} regenerated golden(s)\n", .{scripts.len}); } - try runBatch(arena, exe_z, toBatch(arena, scripts), false, jobs, codes); + try runBatch(arena, exe_z, try toBatch(arena, scripts), false, jobs, codes); - // A failure in the parallel pass is more often contention than regression: - // the app can lose a race with a kworker while 20 siblings fight for the - // cpu. So a failure is re-run alone before it counts — first at the normal - // probe settings, which is usually all an idle machine needs, and only if - // it fails AGAIN with the probes widened (beCareful), which is slow but - // decides. A wholesale failure is not flakiness, so past a handful there is - // nothing to re-check. - if (jobs > 1) { + const attempts = try arena.alloc(u8, scripts.len); + @memset(attempts, 0); + var retry_dir: ?[]const u8 = null; + if (jobs > 1 and retry_failures) { var round: usize = 0; while (round < 2) : (round += 1) { var retry_count: usize = 0; @@ -186,10 +211,18 @@ pub fn main(init: std.process.Init) !void { if (c != 0) retry_count += 1; } if (retry_count == 0 or retry_count > 10) break; + if (retry_dir == null) { + var template = "/tmp/pardes-snap-retry-XXXXXX".* ++ [_:0]u8{0}; + const path = mkdtemp(&template) orelse return error.TempDirectoryFailed; + retry_dir = try arena.dupe(u8, std.mem.span(path)); + std.debug.print("retry captures: {s}\n", .{retry_dir.?}); + } const retry = try arena.alloc(usize, retry_count); var retry_i: usize = 0; for (codes, 0..) |c, i| { if (c != 0) { + try archiveAttempt(init.io, arena, retry_dir.?, scripts[i], attempts[i]); + attempts[i] += 1; retry[retry_i] = i; retry_i += 1; } @@ -210,7 +243,11 @@ pub fn main(init: std.process.Init) !void { } var failed: usize = 0; - for (scripts, codes) |script_path, code| { + for (scripts, codes, attempts) |script_path, code, attempt| { + if (attempt != 0) { + try archiveAttempt(init.io, arena, retry_dir.?, script_path, attempt); + std.debug.print("{s} required {d} retr{s}\n", .{ std.fs.path.stem(script_path), attempt, if (attempt == 1) "y" else "ies" }); + } const report = readFileAlloc(arena, reportPath(arena, script_path) catch "") catch ""; std.debug.print("{s}", .{report}); if (code != 0) { @@ -230,6 +267,120 @@ pub fn main(init: std.process.Init) !void { std.debug.print("all {d} snapshot scripts ok\n", .{scripts.len}); } +fn testCli(init: std.process.Init) !void { + const io = init.io; + const arena = init.arena.allocator(); + const exe = try std.process.executablePathAlloc(io, arena); + var template = "/tmp/pardes-snap-selftest-XXXXXX".* ++ [_:0]u8{0}; + const root = std.mem.span(mkdtemp(&template) orelse return error.TempDirectoryFailed); + const prefix = std.fs.path.basename(root); + var dir = try std.Io.Dir.cwd().openDir(io, root, .{}); + defer dir.close(io); + var archives: std.ArrayList([]const u8) = .empty; + var passed = false; + defer { + if (passed) { + for (archives.items) |path| std.Io.Dir.cwd().deleteTree(io, path) catch {}; + for ([_][]const u8{ "bad", "good", "error", "unwritable" }) |name| { + const work = std.fmt.allocPrint(arena, "{s}/{s}-{s}", .{ SNAP_BASE, prefix, name }) catch continue; + std.Io.Dir.cwd().deleteTree(io, work) catch {}; + const report = std.fmt.allocPrint(arena, "{s}.report", .{work}) catch continue; + std.Io.Dir.cwd().deleteFile(io, report) catch {}; + } + std.Io.Dir.cwd().deleteTree(io, root) catch {}; + } else std.debug.print("snapshot driver test evidence: {s}\n", .{root}); + } + var scripts: [4][]const u8 = undefined; + for ([_][]const u8{ "bad", "good", "error", "unwritable" }, 0..) |name, i| { + const path = try std.fmt.allocPrint(arena, "{s}-{s}.snap", .{ prefix, name }); + try dir.writeFile(io, .{ .sub_path = path, .data = if (i == 2) "run false\n" else "file marker content\n" }); + scripts[i] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root, path }); + const golden = try std.fmt.allocPrint(arena, "{s}-{s}.golden", .{ prefix, name }); + try dir.writeFile(io, .{ .sub_path = golden, .data = if (i == 0 or i == 3) "expected capture\n" else "" }); + } + try dir.createDir(io, "other", .default_dir); + const duplicate = try std.fmt.allocPrint(arena, "{s}/other/{s}", .{ root, std.fs.path.basename(scripts[1]) }); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = duplicate, .data = "file marker content\n" }); + const stale_actual = try std.fmt.allocPrint(arena, "{s}/{s}-error.actual", .{ root, prefix }); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = stale_actual, .data = "older verification evidence\n" }); + const unwritable = try std.fmt.allocPrint(arena, "{s}-unwritable.actual", .{prefix}); + try dir.createDir(io, unwritable, .default_dir); + const record_arg = try std.fmt.allocPrint(arena, "--record={s}/recorded", .{root}); + const fixtures = [_]struct { + name: []const u8, + args: []const []const u8, + status: u8 = 1, + message: []const u8, + archived: ?usize = null, + actual: bool = false, + }{ + .{ .name = "retry", .args = &.{ scripts[0], scripts[1], "--jobs=2" }, .message = "required 2 retries", .archived = 0, .actual = true }, + .{ .name = "no-retry", .args = &.{ scripts[0], scripts[1], "--jobs=2", "--no-retry" }, .message = "1/2 snapshot scripts FAILED" }, + .{ .name = "duplicate", .args = &.{ scripts[1], duplicate, "--jobs=2" }, .message = "DuplicateScriptName" }, + .{ .name = "record", .args = &.{ scripts[2], scripts[1], "--jobs=2", record_arg }, .message = "1/2 snapshot scripts FAILED", .archived = 2 }, + .{ .name = "update", .args = &.{ scripts[2], "--jobs=2", "--update", "--no-retry" }, .message = "SnapshotRegenerationFailed" }, + .{ .name = "actual-write", .args = &.{ scripts[3], "--no-retry" }, .message = "cannot write" }, + .{ .name = "pass", .args = &.{ scripts[1], "--jobs=2" }, .status = 0, .message = "all 1 snapshot scripts ok" }, + }; + for (fixtures) |fixture| { + const argv = try arena.alloc([]const u8, fixture.args.len + 2); + argv[0] = exe; + argv[1] = exe; + @memcpy(argv[2..], fixture.args); + const result = try std.process.run(arena, io, .{ + .argv = argv, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(30) } }, + }); + try dir.writeFile(io, .{ .sub_path = try std.fmt.allocPrint(arena, "{s}.log", .{fixture.name}), .data = result.stderr }); + if (result.term != .exited or result.term.exited != fixture.status or std.mem.indexOf(u8, result.stderr, fixture.message) == null) { + std.debug.print("snapshot CLI fixture {s}:\n{s}\n", .{ fixture.name, result.stderr }); + return error.SnapshotDriverTestFailed; + } + const archive_marker = "retry captures: "; + const archive_start = std.mem.indexOf(u8, result.stderr, archive_marker); + if (fixture.archived) |script| { + const rest = result.stderr[(archive_start orelse return error.MissingRetryArchive) + archive_marker.len ..]; + const archive = rest[0 .. std.mem.indexOfScalar(u8, rest, '\n') orelse return error.MissingRetryArchive]; + const archive_prefix = "/tmp/pardes-snap-retry-"; + if (!std.mem.startsWith(u8, archive, archive_prefix) or archive.len == archive_prefix.len or + std.mem.indexOfScalar(u8, archive[archive_prefix.len..], '/') != null) + return error.IncorrectRetryArchive; + try archives.append(arena, archive); + if (std.mem.count(u8, result.stderr, "re-running ") != 2) return error.IncorrectRetryCount; + for (0..3) |attempt| { + const report = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.report", .{ archive, std.fs.path.stem(scripts[script]), attempt }); + const report_bytes = try std.Io.Dir.cwd().readFileAlloc(io, report, arena, .limited(1024 * 1024)); + const failure = try std.fmt.allocPrint(arena, "FAIL {s}:", .{std.fs.path.stem(scripts[script])}); + if (std.mem.indexOf(u8, report_bytes, failure) == null) return error.MissingAttemptReport; + const actual = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.actual", .{ archive, std.fs.path.stem(scripts[script]), attempt }); + const actual_bytes = std.Io.Dir.cwd().readFileAlloc(io, actual, arena, .limited(1024 * 1024)) catch |err| { + if (err == error.FileNotFound and !fixture.actual) continue; + return err; + }; + if (!fixture.actual or actual_bytes.len != 0) return error.IncorrectAttemptActual; + } + } else if (archive_start != null or std.mem.indexOf(u8, result.stderr, "re-running ") != null) return error.UnexpectedRetry; + if (std.mem.eql(u8, fixture.name, "update") and std.mem.indexOf(u8, result.stderr, "verifying ") != null) + return error.VerifiedFailedRegeneration; + if (std.mem.eql(u8, fixture.name, "actual-write") and std.mem.indexOf(u8, result.stderr, "actual written") != null) + return error.FalseActualReport; + if (std.mem.eql(u8, fixture.name, "retry") or std.mem.eql(u8, fixture.name, "no-retry")) { + const good = try std.fmt.allocPrint(arena, "PASS {s}", .{std.fs.path.stem(scripts[1])}); + if (std.mem.indexOf(u8, result.stderr, good) == null) return error.OmittedPassingScript; + } + if (std.mem.eql(u8, fixture.name, "record")) { + const good = try std.fmt.allocPrint(arena, "RECORDED {s}", .{std.fs.path.stem(scripts[1])}); + if (std.mem.indexOf(u8, result.stderr, good) == null) return error.OmittedPassingScript; + const stale = try std.Io.Dir.cwd().readFileAlloc(io, stale_actual, arena, .limited(1024)); + if (!std.mem.eql(u8, stale, "older verification evidence\n")) return error.ChangedStaleActual; + } + } + passed = true; + std.debug.print("snapshot driver: {d} CLI regression fixtures passed\n", .{fixtures.len}); +} + /// Run `scripts` `jobs` at a time, one forked child each, filling `codes` with /// their exit codes. Forking rather than threading because runScript owns /// process-global state — chdir into its workdir, HOME/TERM/PARDES_DUMP in the @@ -243,8 +394,8 @@ pub fn main(init: std.process.Init) !void { // re-run pass costs more than the tail ever did (13-20s runs). Left alone. const Idx = struct { path: []const u8, code_idx: usize }; -fn toBatch(arena: std.mem.Allocator, scripts: []const []const u8) []const Idx { - const out = arena.alloc(Idx, scripts.len) catch return &.{}; +fn toBatch(arena: std.mem.Allocator, scripts: []const []const u8) ![]const Idx { + const out = try arena.alloc(Idx, scripts.len); for (scripts, out, 0..) |path, *o, i| o.* = .{ .path = path, .code_idx = i }; return out; } @@ -304,14 +455,20 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8 // before it starts; a new mismatch below writes it back. const actual_path = std.fmt.allocPrint(arena, "{s}.actual", .{base_path}) catch return 1; const actual_path_z = arena.dupeZ(u8, actual_path) catch return 1; - _ = libc.unlink(actual_path_z); - const golden: ?[]u8 = readFileAlloc(arena, golden_path) catch null; + if (record_dir == null) _ = libc.unlink(actual_path_z); + const golden: ?[]u8 = if (record_dir == null) readFileAlloc(arena, golden_path) catch null else null; const started = eh.nowMs(); const out = runScript(arena, exe_z, script_path, stem) catch |e| { - std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) }); + std.debug.print("FAIL {s}: script error {s} ({d}ms)\n", .{ stem, @errorName(e), eh.nowMs() - started }); return 1; }; const took = eh.nowMs() - started; + if (record_dir) |dir| { + const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}.snapshot", .{ dir, stem }, 0) catch return 1; + eh.writeFile(path, out) catch return 1; + std.debug.print("RECORDED {s}\n", .{stem}); + return 0; + } if (update) { eh.writeFile(arena.dupeZ(u8, golden_path) catch return 1, out) catch return 1; std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len }); @@ -325,8 +482,12 @@ fn runOne(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8 std.debug.print("PASS {s} ({d}ms)\n", .{ stem, took }); return 0; } - eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch {}; - std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path }); + eh.writeFile(arena.dupeZ(u8, actual_path) catch return 1, out) catch |err| { + std.debug.print("FAIL {s}: differs from golden; cannot write {s}: {s} ({d}ms)\n", .{ stem, actual_path, @errorName(err), took }); + printFirstDiff(golden.?, out); + return 1; + }; + std.debug.print("FAIL {s}: differs from golden (actual written to {s}, {d}ms)\n", .{ stem, actual_path, took }); printFirstDiff(golden.?, out); return 1; } @@ -335,6 +496,25 @@ fn reportPath(arena: std.mem.Allocator, script_path: []const u8) ![]const u8 { return std.fmt.allocPrint(arena, "{s}/{s}.report", .{ SNAP_BASE, std.fs.path.stem(script_path) }); } +fn archiveAttempt(io: std.Io, arena: std.mem.Allocator, dir: []const u8, script_path: []const u8, attempt: u8) !void { + const stem = std.fs.path.stem(script_path); + for ([_][]const u8{ "report", "actual" }) |suffix| { + if (record_dir != null and std.mem.eql(u8, suffix, "actual")) continue; + const source = if (std.mem.eql(u8, suffix, "report")) + try reportPath(arena, script_path) + else + try std.fmt.allocPrint(arena, "{s}.actual", .{script_path[0 .. script_path.len - ".snap".len]}); + const bytes = std.Io.Dir.cwd().readFileAlloc(io, source, arena, .limited(64 * 1024 * 1024)) catch |err| { + if (err == error.FileNotFound) continue; + return err; + }; + const path = try std.fmt.allocPrint(arena, "{s}/{s}.{d}.{s}", .{ dir, stem, attempt, suffix }); + const file = try std.Io.Dir.cwd().createFile(io, path, .{ .exclusive = true }); + defer file.close(io); + try file.writeStreamingAll(io, bytes); + } +} + /// Exclusive flock on `path`, announcing the wait if someone else holds it. /// The fd is deliberately leaked: the lock lives until the process exits. /// @@ -383,7 +563,7 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const // a hostname and whose greeting carries a version, so a golden taken // against it would be a golden for this machine. Every script here runs // bash — the .bashrc above pins its prompt to `$ ` — and the fish path is - // covered by shell_bin.zig's unit tests instead. This is an ordinary + // covered by host_io.Shell's unit tests instead. This is an ordinary // config init going through the ordinary startup-config path, so it also // proves `Shell <name>` is dispatchable from one. try mkdir(arena, config_home, false); @@ -538,6 +718,8 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const try appendChunk(chunks, &chunk_len, try snapText(arena, live(&h), tok.rest(), &prev)); } else if (std.mem.eql(u8, cmd, "snapstyle")) { try appendChunk(chunks, &chunk_len, try snapStyle(arena, live(&h), tok.rest(), &prev)); + } else if (std.mem.eql(u8, cmd, "snap9p")) { + try appendChunk(chunks, &chunk_len, try snap9p(arena, live(&h), tok.rest(), &prev)); } else { std.debug.print("unknown command {s}\n", .{cmd}); return error.BadScript; @@ -556,6 +738,7 @@ fn countCaptures(src: []const u8) usize { const cmd = tok.next().?; if (std.mem.eql(u8, cmd, "snap") or std.mem.eql(u8, cmd, "snapstyle") or + std.mem.eql(u8, cmd, "snap9p") or std.mem.eql(u8, cmd, "snapfile")) count += 1; } return count; @@ -921,14 +1104,141 @@ fn wordByte(c: u8) bool { const Prev = struct { snap: []const []const u8 = &.{}, style: []const []const u8 = &.{}, + ninep: []const []const u8 = &.{}, // per kind: one shared width let a `snapstyle` between a `resize` and the // next `snap` re-arm the guard, and the post-resize frame then came out as // a delta against rows of the old width - dropping every row whose text is // width-invariant, which is every left-aligned chrome row there is. snap_cols: u16 = 0, style_cols: u16 = 0, + ninep_cols: u16 = 0, +}; + +const Screen9p = struct { + cols: u16, + rows: u16, + cursor: ?struct { x: u16, y: u16, bar: bool }, + cells: []const struct { []const u8, usize }, + styles: []const struct { + fg: Color, + bg: Color, + bold: bool, + dim: bool, + italic: bool, + blink: bool, + reverse: bool, + invisible: bool, + strikethrough: bool, + ul: enum { off, single, double, curly, dotted, dashed }, + font_role: enum { body, tagline }, + }, + + const Color = union(enum) { + default, + index: u8, + rgb: [3]u8, + + fn text(color: Color, buf: *[16]u8) []const u8 { + return switch (color) { + .default => "d", + .index => |index| std.fmt.bufPrint(buf, "p{d}", .{index}) catch unreachable, + .rgb => |rgb| std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb[0], rgb[1], rgb[2] }) catch unreachable, + }; + } + }; }; +fn snap9p(arena: std.mem.Allocator, h: *eh.Harness, label: []const u8, prev: *Prev) ![]u8 { + var dial_buf: [24]u8 = undefined; + const dial = try std.fmt.bufPrint(&dial_buf, "{d}", .{h.pid}); + const bytes = try Client.read(gpa, dial, "/self/screen", "/self/screen"); + defer gpa.free(bytes); + const parsed = try std.json.parseFromSlice(Screen9p, gpa, bytes, .{}); + defer parsed.deinit(); + const screen = parsed.value; + if (screen.cols != h.cols or screen.rows != h.rows or screen.cols == 0 or screen.rows == 0 or + screen.cells.len != @as(usize, screen.cols) * screen.rows or screen.styles.len == 0) + return error.Screen9pDimensions; + if (screen.cursor) |cursor| { + if (cursor.x >= screen.cols or cursor.y >= screen.rows) return error.Screen9pCursor; + } + for (screen.cells) |cell| { + if (cell[1] >= screen.styles.len or !std.unicode.utf8ValidateSlice(cell[0])) return error.Screen9pCell; + } + + const keys = try arena.alloc([]const u8, screen.styles.len); + for (screen.styles, keys) |style, *key| { + var flags: [8]u8 = undefined; + var len: usize = 0; + inline for (.{ "bold", "dim", "italic", "blink", "reverse", "invisible", "strikethrough" }, "bfikrvx") |field, flag| { + if (@field(style, field)) { + flags[len] = flag; + len += 1; + } + } + if (style.ul != .off) { + flags[len] = switch (style.ul) { + .off => unreachable, + .single => 'u', + .double => 'U', + .curly => '~', + .dotted => '.', + .dashed => '-', + }; + len += 1; + } + var fg_buf: [16]u8 = undefined; + var bg_buf: [16]u8 = undefined; + key.* = try std.fmt.allocPrint(arena, "{s},{s},{s},{s}", .{ + style.fg.text(&fg_buf), style.bg.text(&bg_buf), flags[0..len], @tagName(style.font_role), + }); + } + + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== 9p {s} grid={d}x{d} cursor=", .{ label, screen.cols, screen.rows })); + try out.appendSlice(arena, if (screen.cursor) |c| + try std.fmt.allocPrint(arena, "{d},{d},{s}\n", .{ c.x, c.y, if (c.bar) "bar" else "block" }) + else + "none\n"); + + const rows = try arena.alloc([]const u8, screen.rows); + const delta = prev.ninep.len == rows.len and prev.ninep_cols == screen.cols; + for (rows, 0..) |*row, y| { + var text_row: std.ArrayList(u8) = .empty; + const cells = screen.cells[y * screen.cols ..][0..screen.cols]; + for (cells) |cell| try text_row.appendSlice(arena, cell[0]); + var capture: std.ArrayList(u8) = .empty; + const trimmed = std.mem.trimEnd(u8, text_row.items, " "); + try capture.appendSlice(arena, if (trimmed.len == 0) + try std.fmt.allocPrint(arena, "|{d}:\n", .{y}) + else + try std.fmt.allocPrint(arena, "|{d}: {s}\n", .{ y, trimmed })); + var annotated = false; + for (cells, 0..) |cell, x| { + if (cell[0].len == 1) continue; + if (!annotated) try capture.appendSlice(arena, try std.fmt.allocPrint(arena, "+{d}:", .{y})); + const glyph = try std.json.Stringify.valueAlloc(arena, cell[0], .{}); + try capture.appendSlice(arena, try std.fmt.allocPrint(arena, " {d}={s}", .{ x, glyph })); + annotated = true; + } + if (annotated) try capture.append(arena, '\n'); + try capture.appendSlice(arena, try std.fmt.allocPrint(arena, "@{d}:", .{y})); + var start: usize = 0; + for (1..cells.len + 1) |x| { + const key = keys[cells[start][1]]; + if (x != cells.len and std.mem.eql(u8, key, keys[cells[x][1]])) continue; + try capture.appendSlice(arena, try std.fmt.allocPrint(arena, " {d}-{d} {s}", .{ start, x - 1, key })); + start = x; + } + try capture.append(arena, '\n'); + row.* = capture.items; + if (!delta or !std.mem.eql(u8, row.*, prev.ninep[y])) try out.appendSlice(arena, row.*); + } + prev.ninep = rows; + prev.ninep_cols = screen.cols; + return out.items; +} + /// `== snap <label> grid=CxR cursor=X,Y` then either the whole screen as /// `|<row text>` (first capture of a script, or after a resize) or just the /// rows that changed as `|<n>: <row text>`. An indexed row is a patch against diff --git a/test/snapshots/acmefs-event.golden b/test/snapshots/acmefs-event.golden deleted file mode 100644 index 58488f1e..00000000 --- a/test/snapshots/acmefs-event.golden +++ /dev/null @@ -1,15 +0,0 @@ -== snap after-suppressed-click grid=100x30 cursor=7,5 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -|$ /tmp/pardes-snap/acmefs-event/cwd Save New Newtty Del Filter -| $ -| /tmp/pardes-snap/acmefs-event/cwd/+New * Save New Newtty Del -| 1 Newcol -| 2 -== file events -MX0 6 1 6 Newcol| - -== end file -== snap after-unsuppressed-click grid=100x30 cursor=57,2 -|1: $ /tmp/pardes-snap/acmefs-event/cwd Save New Newtt /tmp/pardes-snap/acmefs-event/cwd/+New Save New -|2: $ 1 -|3: /tmp/pardes-snap/acmefs-event/cwd/+New * Save Ne diff --git a/test/snapshots/acmefs-event.snap b/test/snapshots/acmefs-event.snap deleted file mode 100644 index 33f8b01c..00000000 --- a/test/snapshots/acmefs-event.snap +++ /dev/null @@ -1,39 +0,0 @@ -# The acme INVERSION, end to end: while a script holds a pane's `event` file -# open, a middle click in that pane is REPORTED to the script instead of being -# executed by the editor. That is how an acme client defines its own commands — -# `Step`, `Run`, `Clear` in examples/acmefs/life.py — and it is the one piece of -# this filesystem that changes what a click MEANS. -# -# The clicked word is `Newcol`, a real pardes builtin, so the two halves of the -# test cannot be confused with each other: with a reader attached the screen -# must not change and the record must arrive; with the reader gone the same -# click must open a column. It sits on the +New pane's first body line — row 5, -# and the line-number gutter is 7 cells wide, so the word starts at column 8. -run sh -c 'fusermount3 -u -z /tmp/pardes-acmefs-ev 2>/dev/null; rm -rf /tmp/pardes-acmefs-ev; exit 0' -start 30 100 --fs=/tmp/pardes-acmefs-ev -wait 8000 New Newcol -stable 700 20000 -run sh -c 'M=/tmp/pardes-acmefs-ev; id=$(cut -c1-11 < $M/new/ctl | tr -d " "); echo $id > id.txt; printf "Newcol\n" >> $M/$id/body' -wait 8000 New Newcol -stable 700 15000 -# a reader arrives: from here on, buttons 2 and 3 in this pane are its business -run sh -c 'M=/tmp/pardes-acmefs-ev; id=$(cat id.txt); (cat $M/$id/event > events.txt & echo $! > cat.pid); sleep 1' -press middle 9 5 -release middle 9 5 -stable 700 15000 -# The screen must be UNCHANGED: no column opened, because the click belonged to -# the reader. An editor that had performed it would have split the layout. -snap after-suppressed-click -# The reader's death is asynchronous: the kernel sends RELEASE when the process -# closes, and pardes drains filesystem requests in its frame poll, AFTER input. -# A click delivered in the same wakeup as that RELEASE is therefore still the -# reader's. One second of sleep is more than the frame this takes. -run sh -c 'sleep 1; kill $(cat cat.pid) 2>/dev/null; sleep 1; sed -e "s/$/|/" events.txt > ev.txt; exit 0' -snapfile events ev.txt -# ...and with the reader gone, the same click is an ordinary Exec again: this -# one opens the column. -press middle 9 5 -release middle 9 5 -stable 700 15000 -snap after-unsuppressed-click -run sh -c 'fusermount3 -u -z /tmp/pardes-acmefs-ev 2>/dev/null; rm -rf /tmp/pardes-acmefs-ev; exit 0' diff --git a/test/snapshots/acmefs.golden b/test/snapshots/acmefs.golden deleted file mode 100644 index 8e2e37f4..00000000 --- a/test/snapshots/acmefs.golden +++ /dev/null @@ -1,30 +0,0 @@ -== file tree -1 -cons -index -new - -== end file -== snap fs-body-written grid=100x30 cursor=7,5 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -|$ /tmp/pardes-snap/acmefs/cwd Save New Newtty Del Filter -| $ -| /tmp/pardes-snap/acmefs/cwd/+New * Save New Newtty Del -| 1 hello from outside -| 2 -== file body -hello from outside - -== end file -== file ctlid -2 - -== end file -== file firstid -1 - -== end file -== snap fs-event-exec grid=100x30 cursor=57,2 -|1: $ /tmp/pardes-snap/acmefs/cwd Save New Newtty Del /tmp/pardes-snap/acmefs/cwd/+New Save New Newtty -|2: $ 1 -|3: /tmp/pardes-snap/acmefs/cwd/+New * diff --git a/test/snapshots/acmefs.snap b/test/snapshots/acmefs.snap deleted file mode 100644 index e338245b..00000000 --- a/test/snapshots/acmefs.snap +++ /dev/null @@ -1,40 +0,0 @@ -# acme's control filesystem, end to end: a process OUTSIDE pardes opens a pane, -# fills its body, reads it back through the same tree, and finally drives a -# builtin by writing an event record — the acme remote-control door. -# -# Everything here runs through a real FUSE mount served by the real binary, so -# a failure means the mount, the protocol, the transaction or the semantics — -# there is nothing else in the path. -# -# The mount lives OUTSIDE this script's own directory, and the script unmounts -# it at both ends. Two reasons, both about how this harness works: it kills the -# app with SIGKILL, so pardes never gets to unmount (that is what -# fuse.sweepStale exists for, but a `--fs=<dir>` this explicit is not ours to -# sweep), and a stale ENOTCONN mount inside the per-script directory would make -# the next run's `rm -rf` — and therefore its mkdir — fail. -run sh -c 'fusermount3 -u -z /tmp/pardes-acmefs-snap 2>/dev/null; rm -rf /tmp/pardes-acmefs-snap; exit 0' -start 30 100 --fs=/tmp/pardes-acmefs-snap -wait 8000 New Newcol -stable 700 20000 -# the tree acme(4) describes, served by pardes -run sh -c 'ls /tmp/pardes-acmefs-snap > tree.txt 2>&1' -snapfile tree tree.txt -# `new` creates a pane on ACCESS, and its ctl reads back the pane's own id -# (field 1 of acme's five). Writing body appends, so the pane holds the bytes. -run sh -c 'M=/tmp/pardes-acmefs-snap; id=$(cut -c1-11 < $M/new/ctl | tr -d " "); echo $id > id.txt; printf "hello from outside\n" >> $M/$id/body' -wait 8000 hello from outside -stable 700 15000 -snap fs-body-written -# ...and reading it back is the same filesystem answering about live state -run sh -c 'M=/tmp/pardes-acmefs-snap; id=$(cat id.txt); cat $M/$id/body > body.txt; cut -c1-11 < $M/$id/ctl | tr -d " " > ctlid.txt; sed -n 1p $M/index | cut -c1-11 | tr -d " " > firstid.txt' -snapfile body body.txt -snapfile ctlid ctlid.txt -snapfile firstid firstid.txt -# THE DOOR: put a builtin in the pane's tag, then write an event record naming -# its byte range. pardes executes it exactly as a middle click would — which is -# how an acme client has always run editor commands, and is code execution by -# design (see examples/README.md). -run sh -c 'M=/tmp/pardes-acmefs-snap; id=$(cat id.txt); q0=$(wc -c < $M/$id/tag); printf " Newcol" >> $M/$id/tag; q1=$(wc -c < $M/$id/tag); printf "Mx%d %d\n" $((q0 + 1)) $q1 > $M/$id/event' -stable 700 15000 -snap fs-event-exec -run sh -c 'fusermount3 -u -z /tmp/pardes-acmefs-snap 2>/dev/null; rm -rf /tmp/pardes-acmefs-snap; exit 0' diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden index a9a34616..5c9ab060 100644 --- a/test/snapshots/builtins.golden +++ b/test/snapshots/builtins.golden @@ -8,73 +8,8 @@ | 5 line 5 | 6 line 6 | 7 -== snap index grid=120x66 cursor=7,35 +== snap window-group grid=120x66 cursor=7,35 |34: /tmp/pardes-snap/builtins/cwd/+Help Save New Newtty Del -|35: 1 pardes builtins, and how to run each: -|36: 2 SPC and its keys, a chord, a button, the -|37: 3 topbar - or the name, executed anywhere. -|38: 4 -|39: 5 SPC ? Help topbar -|40: 6 SPC P ClipPasteBefore -|41: 7 SPC R ClipReplace -|42: 8 SPC Y ClipYankMain -|43: 9 SPC a a PanelAscii -|44: 10 SPC a c PanelCurtain -|45: 11 SPC a d PanelDissolve -|46: 12 SPC a e PanelEdges -|47: 13 SPC a f PanelFall -|48: 14 SPC a r PanelScramble -|49: 15 SPC a s PanelSlide -|50: 16 SPC a t PanelType -|51: 17 SPC a v PanelVertical -|52: 18 SPC a w PanelWave -|53: 19 SPC a z PanelZoom -|54: 20 SPC c d Delcol -|55: 21 SPC c j Joincol topbar -|56: 22 SPC c n Newcol topbar -|57: 23 SPC d Del -|58: 24 SPC f c Config -|59: 25 SPC f f Find topbar -|60: 26 SPC f g Grep topbar -|61: 27 SPC f n New topbar -|62: 28 SPC f s Save -|63: 29 SPC h c Changelog topbar -|64: 30 SPC h m Messages -|65: 31 SPC h t Tutor topbar -== snap index-tail grid=120x66 cursor=7,64 -|35: 67 SPC t w Wrap -|36: 68 SPC t z PdfFit -|37: 69 SPC w h Left C-w h, C-w left -|38: 70 SPC w j Down C-w j, C-w down -|39: 71 SPC w k Up C-w k, C-w up -|40: 72 SPC w l Right C-w l, C-w right -|41: 73 SPC y ClipYank -|42: 74 Look enter, right-click -|43: 75 Exec tab, middle-click -|44: 76 Msg -|45: 77 ThemeFile -|46: 78 DumpThemes -|47: 79 Filter -|48: 80 EffectCode -|49: 81 9p -|50: 82 Theme -|51: 83 Shell -|52: 84 -|53: 85 language keys (motions, not words): -|54: 86 gd gD gy gi gr goto: definition, -|55: 87 declaration, type-def, -|56: 88 implementation, refs -|57: 89 ]d [d ]D [D diagnostics: next, -|58: 90 prev, last, first -|59: 91 = format (applies, one -|60: 92 undo step) -|61: 93 Tab after a . completion, in insert -|62: 94 C-left-click definition, by mouse -|63: 95 SPC l ... hover, rename, symbols, -|64: 96 calls, types: above -|65: 97 -== snap not-a-picker grid=120x66 cursor=19,61 -== snap window-group grid=120x66 cursor=19,35 |35: 1 pardes builtins under SPC w |36: 2 |37: 3 SPC w h Left C-w h, C-w left @@ -82,30 +17,13 @@ |39: 5 SPC w k Up C-w k, C-w up |40: 6 SPC w l Right C-w l, C-w right |41: 7 -|42: -|43: -|44: -|45: -|46: -|47: -|48: -|49: -|50: -|51: -|52: -|53: -|54: -|55: -|56: -|57: -|58: -|59: -|60: -|61: -|62: -|63: -|64: -|65: +== snap docs-group grid=120x66 cursor=7,35 +|35: 1 pardes builtins under SPC h +|37: 3 SPC h c Changelog topbar +|38: 4 SPC h m Messages +|39: 5 SPC h t Tutor topbar +|40: 6 +|41: == snap tutor-from-index grid=120x66 cursor=7,19 |18: /Tutor Save New Newtty Del |19: 1 @@ -123,34 +41,3 @@ |31: 13 |32: 14 A pane is just text on screen. There is no separate "buffer" type — |33: 15 what you see is what you edit. Some panes happen to be live terminals; -|35: 1 pardes builtins, and how to run each: -|36: 2 SPC and its keys, a chord, a button, the -|37: 3 topbar - or the name, executed anywhere. -|38: 4 -|39: 5 SPC ? Help topbar -|40: 6 SPC P ClipPasteBefore -|41: 7 SPC R ClipReplace -|42: 8 SPC Y ClipYankMain -|43: 9 SPC a a PanelAscii -|44: 10 SPC a c PanelCurtain -|45: 11 SPC a d PanelDissolve -|46: 12 SPC a e PanelEdges -|47: 13 SPC a f PanelFall -|48: 14 SPC a r PanelScramble -|49: 15 SPC a s PanelSlide -|50: 16 SPC a t PanelType -|51: 17 SPC a v PanelVertical -|52: 18 SPC a w PanelWave -|53: 19 SPC a z PanelZoom -|54: 20 SPC c d Delcol -|55: 21 SPC c j Joincol topbar -|56: 22 SPC c n Newcol topbar -|57: 23 SPC d Del -|58: 24 SPC f c Config -|59: 25 SPC f f Find topbar -|60: 26 SPC f g Grep topbar -|61: 27 SPC f n New topbar -|62: 28 SPC f s Save -|63: 29 SPC h c Changelog topbar -|64: 30 SPC h m Messages -|65: 31 SPC h t Tutor topbar diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 95db3fd9..aebb2a18 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -1,72 +1,35 @@ -# RULE: Help is THE INDEX of the builtins — every one of them, and every way -# to run it — not a listing of leader paths. Three columns, all derived at -# comptime from config.zig's own tables: the SPC path (leader_path), the name, -# and every other shortcut bound to that name (window_keys and jump_keys' `cmd` -# columns, look_cmd/exec_cmd with their keys and mouse buttons, a word in -# topbar_str). A builtin added tomorrow is a row here with nothing to edit. -# -# The cases this exists for are the ones a leader listing could not show: -# Look and Exec have NO SPC path — their shortcuts are Enter/Tab and the two -# mouse buttons — Theme has no shortcut at all, because a key path can never -# carry the name of a theme, and Filter is reached from terminal tags rather -# than the leader. All are rows, and their blank columns are the information. -# -# `SPC ?` mid-chord is the SAME array read with a non-empty prefix, which is -# why there is one builtin here and not two: the group filter drops the -# path-less rows by itself, and the reference page is the filter's empty case. lines notes.txt 6 start 66 120 notes.txt wait 8000 notes.txt stable 700 20000 snap boot -# SPC ? at the ROOT. Focus follows the listing — you asked to read it. + +# The full index includes commands without leader paths. key space ? -wait 10000 SPC c n -stable 700 15000 -snap index -# The tail of the same listing: the window group carries BOTH spellings of -# each Ctrl-w chord (the table carries both), and past the last path come the -# builtins SPC cannot reach. `ge` walks the buffer's own body to the end. +wait 10000 pardes builtins, and how to run each: +wait 10000 ClipPasteBefore key g e +wait 10000 Look enter, right-click +wait 10000 Exec tab, middle-click +wait 10000 Filter +wait 10000 Theme stable 700 15000 -snap index-tail -# n/N here run NOTHING, and no longer could: they are a MOTION, and the only -# thing they do is move the selection onto look-able text. A row here is a key -# path and a NAME — this list holds Kill, Del and Delcol — and a name is not a -# path, so the ring finds nothing to stop on in this buffer or in the file -# above it. Three presses, and the panes and the listing are all where they -# were. Picking a command is executing its row, two snaps down. -key n -key n -key n -stable 700 15000 -snap not-a-picker -# and the mid-chord filter is untouched: `SPC w ?` still answers "what can w -# still reach", rewriting the same +Help buffer with just that group — the four -# directional moves, each now also showing the Ctrl-w chord that is the other -# way in, and none of the path-less builtins, which `w` cannot reach. + +# n moves through look-able text; it must not execute command names. +key n n n +wait 10000 Look enter, right-click +wait 10000 Exec tab, middle-click key space w ? wait 10000 Right stable 700 15000 snap window-group -# back to the whole index, and a NAME in it is live text like anywhere else: -# middle-click `Tutor` and the tutor opens. That is how a list of commands is -# picked from — you run the row you want and the ones you passed stay unrun, -# which is the whole reason n/N were never allowed to be the picker. -# -# The one place in this suite a builtin cannot be added for free: the click is -# a SCREEN COORDINATE. The rows above `SPC h t` in the listing move Tutor and -# this number with them — the five clipboard builtins put three there. And -# since the language-keys footer made the page longer than the pane, the -# re-opened view RETAINS the scroll the `ge` above left behind, which is why -# `gg` now pins the top before the click: an exec aimed one row into the -# footer would run "declaration," in a shell instead of opening the tutor. -key space ? -wait 10000 SPC c n -key g g + +key space h ? +wait 10000 Tutor stable 700 15000 -press middle 20 66 -release middle 20 66 +snap docs-group +press middle @Tutor 40 +release middle @Tutor 40 wait 10000 PARDES TUTOR stable 700 15000 snap tutor-from-index diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden index 74ac0c57..fbbfd92b 100644 --- a/test/snapshots/dump.golden +++ b/test/snapshots/dump.golden @@ -63,5 +63,6 @@ .image = null, }, }, + .mounts = .{}, } == end file diff --git a/test/snapshots/dump.snap b/test/snapshots/dump.snap index 02803525..ddac01a2 100644 --- a/test/snapshots/dump.snap +++ b/test/snapshots/dump.snap @@ -1,6 +1,4 @@ -# the Dump builtin serializes the session to $DUMP (this script's own dump -# file under /tmp/pardes-snap/dump/); the ZON must -# be byte-identical between old and new (golden generated from the OLD binary) +# Dump serializes the session to this fixture's $DUMP path. file d.txt x start 30 100 -n 3 wait 8000 d.txt diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden index d2068e8d..fd4fbf98 100644 --- a/test/snapshots/leader.golden +++ b/test/snapshots/leader.golden @@ -44,45 +44,14 @@ == snap abandoned grid=100x40 cursor=7,2 |39: == snap unmapped grid=100x40 cursor=7,2 -== snap help-word grid=100x40 cursor=7,22 -|21: /tmp/pardes-snap/leader/cwd/+Help Save New Newtty Del -|22: 1 pardes builtins, and how to run each: -|23: 2 SPC and its keys, a chord, a button, the -|24: 3 topbar - or the name, executed anywhere. -|25: 4 -|26: 5 SPC ? Help topbar -|27: 6 SPC P ClipPasteBefore -|28: 7 SPC R ClipReplace -|29: 8 SPC Y ClipYankMain -|30: 9 SPC a a PanelAscii -|31: 10 SPC a c PanelCurtain -|32: 11 SPC a d PanelDissolve -|33: 12 SPC a e PanelEdges -|34: 13 SPC a f PanelFall -|35: 14 SPC a r PanelScramble -|36: 15 SPC a s PanelSlide -|37: 16 SPC a t PanelType -|38: 17 SPC a v PanelVertical -|39: 18 SPC a w PanelWave == snap help-group grid=100x40 cursor=7,22 +|21: /tmp/pardes-snap/leader/cwd/+Help Save New Newtty Del |22: 1 pardes builtins under SPC h |23: 2 |24: 3 SPC h c Changelog topbar |25: 4 SPC h m Messages |26: 5 SPC h t Tutor topbar |27: 6 -|28: -|29: -|30: -|31: -|32: -|33: -|34: -|35: -|36: -|37: -|38: -|39: == snap tutor grid=100x40 cursor=7,12 |11: /Tutor Save New Newtty Del |12: 1 @@ -105,44 +74,6 @@ |18: |19: |20: -== snap help-all grid=100x40 cursor=7,22 -|22: 1 pardes builtins, and how to run each: -|23: 2 SPC and its keys, a chord, a button, the -|24: 3 topbar - or the name, executed anywhere. -|25: 4 -|26: 5 SPC ? Help topbar -|27: 6 SPC P ClipPasteBefore -|28: 7 SPC R ClipReplace -|29: 8 SPC Y ClipYankMain -|30: 9 SPC a a PanelAscii -|31: 10 SPC a c PanelCurtain -|32: 11 SPC a d PanelDissolve -|33: 12 SPC a e PanelEdges -|34: 13 SPC a f PanelFall -|35: 14 SPC a r PanelScramble -|36: 15 SPC a s PanelSlide -|37: 16 SPC a t PanelType -|38: 17 SPC a v PanelVertical -|39: 18 SPC a w PanelWave -== snap help-toggles grid=100x40 cursor=7,22 -|22: 1 pardes builtins under SPC t -|23: 2 -|24: 3 SPC t a Ascii -|25: 4 SPC t b Tagbottom -|26: 5 SPC t c Colors -|27: 6 SPC t d Debug topbar -|28: 7 SPC t i PdfTint -|29: 8 SPC t l Palette -|30: 9 SPC t n NextColor topbar -|31: 10 SPC t p Petscii -|32: 11 SPC t s PdfSections -|33: 12 SPC t t ThemeSel -|34: 13 SPC t w Wrap -|35: 14 SPC t z PdfFit -|36: 15 -|37: -|38: -|39: == snap help-window grid=100x40 cursor=7,22 |22: 1 pardes builtins under SPC w |24: 3 SPC w h Left C-w h, C-w left @@ -150,14 +81,6 @@ |26: 5 SPC w k Up C-w k, C-w up |27: 6 SPC w l Right C-w l, C-w right |28: 7 -|29: -|30: -|31: -|32: -|33: -|34: -|35: -|36: == snap help-language grid=100x40 cursor=7,22 |22: 1 pardes builtins under SPC l |24: 3 SPC l C Callees diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap index f6eae3ea..eb253b37 100644 --- a/test/snapshots/leader.snap +++ b/test/snapshots/leader.snap @@ -1,106 +1,60 @@ -# The SPC leader: every builtin also has a KEY PATH, and SPC walks it. While a -# path is pending the active pane's tag shows it at its RIGHT edge (the `/` -# input's rule — state you can see — right-aligned so it never collides with -# the tail's builtins). `?` at any depth opens the Help builtin, listing what -# the prefix typed so far can still reach, into a +Help OUTPUT BUFFER (a file -# pane with no file behind it, like +Search). The paths, the Help lines and -# the execute dispatch are all folded out of ONE enum at comptime — so -# executing the WORD `Help` runs the same builtin the leader does. file cmds.txt Help start 40 100 cmds.txt wait 8000 New Newcol wait 8000 cmds.txt stable 700 20000 -# SPC arms the leader: the indicator appears on the active pane's tag + key space stable 400 5000 snap spc -# ... and grows as the path is typed (`h` = the docs group) key h stable 400 5000 snap spc-h -# Esc abandons the sequence: indicator gone, nothing ran, cursor untouched key esc settle 100 stable 400 5000 snap abandoned -# an UNMAPPED key abandons too, rather than waiting: a typo must not leave the -# next keystroke armed at a builtin that closes panes -key space -key q +key space q stable 400 5000 snap unmapped -# executing the WORD `Help` (middle-click) runs the builtin — the enum IS the -# lookup, so a name works anywhere text lives. Every builtin, with its keys. -press middle 9 3 -release middle 9 3 -# New builtins can push most of the index below this short pane, so this waits -# on a row ABOVE the growth: the clipboard builtins sort first (uppercase paths) -# and only the whole-index listing shows them, which is exactly the fact under -# test — the word `Help` opened the full registry, not a group. + +# Executing Help as text opens the full index, not a filtered group. +press middle @Help 3 +release middle @Help 3 +wait 10000 pardes builtins, and how to run each: wait 10000 ClipPasteBefore -stable 700 15000 -snap help-word -# `SPC h ?`: only the `h` group, rewritten into the SAME +Help buffer -key space -key h -key ? +key space h ? wait 10000 under SPC h stable 700 15000 snap help-group -# `SPC h t`: the Tutor builtin, zero arguments. It runs from the +Help pane, -# but that is an output pane and not a document — so the tutor lands under -# cmds.txt, the real doc, with the listing left at the foot of the column. -key space -key h -key t + +key space h t wait 10000 PARDES TUTOR stable 700 15000 snap tutor -# `SPC d`: Del is pane-scoped — it closes the tutor, focus falls back -key space -key d +key space d stable 700 15000 snap deleted -# `SPC ?` at the root lists everything again. Waits on an EARLY row: the full -# listing outgrew this pane when the language group landed, and the panel -# effects pushed `SPC c n` off the bottom too. The clipboard paths sort first -# and appear only in the whole-index listing, so they stay a valid witness. -key space -key ? + +key space ? +wait 10000 pardes builtins, and how to run each: wait 10000 ClipPasteBefore -stable 700 15000 -snap help-all -# `SPC t ?`: the TTY toggle group, including the image-renderer and PDF -# switches that became builtins instead of tag words. Native scene shaders are -# capability-gated out of this build, so Crt/Ripple/Glitch are absent here. -key space -key t -key ? + +# PDF commands depend on the build; these toggles do not. +key space t ? +wait 10000 under SPC t +wait 10000 Ascii +wait 10000 Palette wait 10000 Petscii -stable 700 15000 -snap help-toggles -# `SPC w ?`: the window group, whose five builtins are the tail of the full -# listing above and fall off the bottom of this pane there -key space -key w -key ? +wait 10000 Wrap +key space w ? wait 10000 Right stable 700 15000 snap help-window -# `SPC l ?`: the LANGUAGE group — every LSP command lives here rather than on -# the bare `<space>` letters helix uses, so `d` stays Del and `k` stays Kill. -# Each one keeps helix's own letter INSIDE the group (hover k, rename r, code -# action a, symbols s/S, diagnostics d/D, select-refs h), plus the two -# introspection builtins that are ours: i and w. -key space -key l -key ? +key space l ? wait 10000 WsDiagnostics stable 700 15000 snap help-language -# Output buffers close through the same explicit Del builtin as every other -# pane. Body-normal Esc is now Last and deliberately keeps +Help alive. key space d settle 100 stable 700 15000 diff --git a/test/snapshots/load.golden b/test/snapshots/load.golden index 16a07805..bc5d5ddd 100644 --- a/test/snapshots/load.golden +++ b/test/snapshots/load.golden @@ -1,7 +1,7 @@ == snap loaded grid=100x30 cursor=4,15 |New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill | /tmp/pardes-snap/load/cwd Save New Newtty Del Fi /tmp/pardes-snap/load/cwd Save New Newtty Del Fi -| echo LOAD''MARK; seq 1 40 ls +| echo LOAD''MARK; seq 1 40; echo E''ND ls | LOADMARK d.txt | 1 | 2 diff --git a/test/snapshots/load.snap b/test/snapshots/load.snap index c224e2fb..8ae63b28 100644 --- a/test/snapshots/load.snap +++ b/test/snapshots/load.snap @@ -8,9 +8,9 @@ wait 8000 d.txt stable 700 20000 key c-b stable 600 8000 -text echo LOAD''MARK; seq 1 40 +text echo LOAD''MARK; seq 1 40; echo E''ND key enter -wait 8000 LOADMARK +wait 8000 END stable 700 10000 key c-b stable 600 8000 diff --git a/test/snapshots/lsp-client.golden b/test/snapshots/lsp-client.golden index a203cc7c..d937889c 100644 --- a/test/snapshots/lsp-client.golden +++ b/test/snapshots/lsp-client.golden @@ -18,10 +18,17 @@ | 3 a + b | 4 } | 5 -== snap gr-list grid=100x24 cursor=16,15 +== snap gr-list grid=100x24 cursor=21,5 |12: |20: /tmp/pardes-snap/lsp-client/cwd/+Search Save New Newtty Del -|21: 1 main.rs:4:13-15 let x = add(1, 2); -|22: 2 main.rs:5:13-15 let y = add(3, 4); -|23: 3 lib.rs:2:8-10 pub fn add(a: i32, b: i32) -> i32 { +|21: 1 lib.rs:2:8-10 pub fn add(a: i32, b: i32) -> i32 { +|22: 2 main.rs:4:13-15 let x = add(1, 2); +|23: 3 main.rs:5:13-15 let y = add(3, 4); == snap gr-opened grid=100x24 cursor=21,6 +|21: 2 main.rs:4:13-15 let x = add(1, 2); +|22: 3 main.rs:5:13-15 let y = add(3, 4); +|23: 4 +== snap gr-back grid=100x24 cursor=21,5 +|21: 1 lib.rs:2:8-10 pub fn add(a: i32, b: i32) -> i32 { +|22: 2 main.rs:4:13-15 let x = add(1, 2); +|23: 3 main.rs:5:13-15 let y = add(3, 4); diff --git a/test/snapshots/lsp-client.snap b/test/snapshots/lsp-client.snap index bc5267cc..91c65975 100644 --- a/test/snapshots/lsp-client.snap +++ b/test/snapshots/lsp-client.snap @@ -6,7 +6,7 @@ # gd on a cross-file Location -> a look: lib.rs opens with `add` selected, # and the message row still carries the server's settled state # ("lsp lspmock: ready") because nothing has cleared it yet. -# gr -> three rows with column SPANS, n arms the walk, Enter opens one. +# gr -> sorted rows, selecting the next occurrence; n/N continue there. file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n start 24 100 main.rs @@ -26,9 +26,12 @@ key r wait 10000 add(3, 4) stable 700 15000 snap gr-list -# step the list and open the second hit: main.rs with the span selected -key n +# step from the selected middle row to the last hit, then return key n key enter stable 700 15000 snap gr-opened +key N +key enter +stable 700 15000 +snap gr-back diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden index cd9ac0c7..4dd7748e 100644 --- a/test/snapshots/lspdebug.golden +++ b/test/snapshots/lspdebug.golden @@ -14,31 +14,5 @@ | 11 == snap ctrlclick grid=120x44 cursor=15,4 == snap plainclick grid=120x44 cursor=21,9 -== snap why grid=120x44 cursor=7,4 -|34: /tmp/pardes-snap/lspdebug/cwd/+Lsp Save New Newtty Del -|35: 1 lsp explain — the definition query at byte 29 of z.zig -|36: 2 -|37: 3 zig lib dir: /usr/lib/zig (open) — `std` imports resolve -|38: 4 parsed 122 bytes, 0 syntax error(s) -|39: 5 position context: .empty -|40: 6 STOP: nothing to resolve in a .empty context — `gd` only answers on -|41: 7 an identifier, a field access, a label, an enum literal or an @import string. -|42: 8 -|43: 9 0 row(s) -== snap info grid=120x44 cursor=7,4 -|18: /tmp/pardes-snap/lspdebug/cwd/+Lsp Save New Newtty Del -|19: 1 backend: zls-inproc+lsp-client -|20: 2 zls: 0.16.1-dev+3e0d0820 (compiled in — no server process, no JSON-RPC) -|21: 3 zig lib dir: /usr/lib/zig [OK] -|22: 4 offsets: utf-8 walk caps: 512 files, 2000 rows -|23: 5 -|24: 6 dependency imports gd can follow (8): -|25: 7 zls /home/goblin/00-projects/0x4200.cafe/02-pardes-code/zig-pkg/zls-0.16.1-dev-rmm5ftfOJgAzIdWUGVB0↩ -|26: 8ycFbud2O3viKnGF1HZgfKZJ/src/zls.zig -|27: 8 tree-sitter /home/goblin/00-projects/0x4200.cafe/02-pardes-code/zig-pkg/tree_sitter-0.26.0-8heIf3CaAQB4SSum↩ -|28: oGzq3gLSz2rkvXxcKFyskRWqDGBz/src/root.zig -|29: 9 zstbi /home/goblin/00-projects/0x4200.cafe/02-pardes-code/zig-pkg/zstbi-0.11.0-dev-L0Ea_3GWBwAh7m54XX↩ -|30: 055vwLKPTPij5hyqyeq92MXPE2/src/zstbi.zig -|31: 10 mvzr /home/goblin/00-projects/0x4200.cafe/02-pardes-code/zig-pkg/mvzr-0.3.9-ZSOky6t2AQCA7efmm5EYbPb5↩ -|32: sOdbXa4EFsUgYbQ6hZv9/src/mvzr.zig -|33: 11 mupdf /home/goblin/00-projects/0x4200.cafe/02-pardes-code/src/pdf.zig +== snap why-closed grid=120x44 cursor=7,4 +== snap report-closed grid=120x44 cursor=7,4 diff --git a/test/snapshots/lspdebug.snap b/test/snapshots/lspdebug.snap index a2160408..514b24da 100644 --- a/test/snapshots/lspdebug.snap +++ b/test/snapshots/lspdebug.snap @@ -1,52 +1,46 @@ -# The mouse chord and the two introspection builtins. -# -# Ctrl-left-click IS `gd`. It rides the ordinary left-click drag rather than -# firing on the press, because a click does not place the modal cursor until -# RELEASE — asking at press time would answer about wherever the cursor -# happened to be sitting, which is the bug this test would not have caught. -# -# `SPC l w` and `SPC l i` exist because this backend never fails loudly: a -# thrown analyser must not take the editor with it, but that makes a broken -# backend and a correct one that found nothing look identical. These two tell -# them apart, so they are worth a test that proves they say something TRUE. file z.zig const std = @import("std");\n\nfn helper() u32 {\n return 3;\n}\n\npub fn main() void {\n const v = helper();\n _ = v;\n}\n start 44 120 z.zig wait 8000 fn helper stable 700 20000 snap opened -# Ctrl-click on the `helper` of the CALL (row 10 = file line 8, col 22 = the -# `h`). One definition, so it jumps rather than listing: the cursor lands on -# line 3, the `helper` of `fn helper()`, in this same pane. + +# Ctrl-click jumps to the definition; plain click only moves the cursor. press ctrl-left 22 10 release ctrl-left 22 10 stable 700 15000 snap ctrlclick -# A PLAIN left click in the same place must NOT jump — the modifier is the -# whole difference, and without this the test above would pass on a bug that -# made every click a goto. press left 22 10 release left 22 10 stable 700 15000 snap plainclick -# `SPC l w` on a keyword: `gd` finds nothing there, and the point of Lspwhy is -# that it says WHY rather than leaving you with a no-op key. It narrates the -# real resolution path — the position context is the analyser's own answer, -# not a guess reconstructed beside it. + text 3gg1g| stable 400 10000 -key space -key l -key w -wait 10000 position context +key space l w +wait 10000 parsed 122 bytes, 0 syntax error(s) +wait 10000 position context: .empty +wait 10000 STOP: nothing to resolve +wait 10000 0 row(s) +key space w j +key space d stable 700 15000 -snap why -# `SPC l i`: which ZLS, which stdlib, what it answers, and the query log — -# which by now holds the two gotos and the explain above, with their timings. -# It answers from any pane, including one with no file, because it is about -# the BACKEND. -key space -key l -key i -wait 10000 zls-inproc +snap why-closed + +# Check the report's answers, not installed compiler paths or dependency counts. +key space l i +wait 10000 backend: zls-inproc+lsp-client +wait 10000 compiled in +wait 10000 [OK] +wait 10000 offsets: utf-8 +key space w j +key / +text last queries +key enter +wait 10000 last queries +wait 10000 definition z.zig +wait 10000 explain z.zig +key space d +key space w j +key space d stable 700 15000 -snap info +snap report-closed diff --git a/test/snapshots/mini.golden b/test/snapshots/mini.golden new file mode 100644 index 00000000..16e170f6 --- /dev/null +++ b/test/snapshots/mini.golden @@ -0,0 +1,183 @@ +== snap opened grid=90x22 cursor=7,13 +|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/mini/cwd/commands.txt Save New Newtty Del +| 1 @`Mini demo.zig` +| +| +| +| +| +| +| +| +| +| /tmp/pardes-snap/mini/cwd/Mini demo.zig Save New Newtty Del +| 1 ⠭⠍⣥⢩⣭⡭⣭⣬⣌⢭⣭⠌⠬⠅⠍⠁ +| 2 ⣂⢀⣉⣉⢉⣈⢁⣀⣀⢀⣀⣀⣀⢀⣀⣀⣀⣀⣀ +| 3 ⠉⠉⠁⠉⠉⠉⠈⠈⠉⠉⠉⠉⠉⠉ +| 4 +== style opened grid=90x22 +|0: 0-74 #949494,#121212, 75-89 d,#121212, +|1: 0-1 #ffffff,#262626, 2-69 #949494,#121212, 70-89 d,#121212, +|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-89 d,#080808, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|12: 0-1 #ffffff,#626262, 2-69 #949494,#121212, 70-89 d,#121212, +|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #bdbdbd,#080808, 9-9 #cf87e8,#080808,b 10-12 #bdbdbd,#080808, 13-15 #cf87e8,#080808,b 16-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-89 d,#080808, +|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #ffffff,#080808,b 9-11 #cf87e8,#080808,b 12-25 #ffffff,#080808,b 26-89 d,#080808, +|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #bdbdbd,#080808, 18-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-89 d,#080808, +|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +== 9p opened grid=90x22 cursor=7,13,block +|0: New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +@0: 0-74 #949494,#121212,,tagline 75-89 d,#121212,,tagline +|1: /tmp/pardes-snap/mini/cwd/commands.txt Save New Newtty Del +@1: 0-1 #ffffff,#262626,,tagline 2-69 #949494,#121212,,tagline 70-89 d,#121212,,tagline +|2: 1 @`Mini demo.zig` +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-22 #bdbdbd,#080808,,body 23-89 d,#080808,,body +|3: +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|4: +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|5: +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|6: +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|7: +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|8: +@8: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|9: +@9: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|10: +@10: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|11: +@11: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|12: /tmp/pardes-snap/mini/cwd/Mini demo.zig Save New Newtty Del +@12: 0-1 #ffffff,#626262,,tagline 2-69 #949494,#121212,,tagline 70-89 d,#121212,,tagline +|13: 1 ⠭⠍⣥⢩⣭⡭⣭⣬⣌⢭⣭⠌⠬⠅⠍⠁ ++13: 7="⠭" 8="⠍" 9="⣥" 10="⢩" 11="⣭" 12="⡭" 13="⣭" 14="⣬" 15="⣌" 16="⢭" 17="⣭" 18="⠌" 19="⠬" 20="⠅" 21="⠍" 22="⠁" +@13: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-8 #bdbdbd,#080808,,body 9-9 #cf87e8,#080808,b,body 10-12 #bdbdbd,#080808,,body 13-15 #cf87e8,#080808,b,body 16-21 #e3c78a,#080808,,body 22-22 #bdbdbd,#080808,,body 23-89 d,#080808,,body +|14: 2 ⣂⢀⣉⣉⢉⣈⢁⣀⣀⢀⣀⣀⣀⢀⣀⣀⣀⣀⣀ ++14: 7="⣂" 8="⢀" 9="⣉" 10="⣉" 11="⢉" 12="⣈" 13="⢁" 14="⣀" 15="⣀" 16="⢀" 17="⣀" 18="⣀" 19="⣀" 20="⢀" 21="⣀" 22="⣀" 23="⣀" 24="⣀" 25="⣀" +@14: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-8 #ffffff,#080808,b,body 9-11 #cf87e8,#080808,b,body 12-25 #ffffff,#080808,b,body 26-89 d,#080808,,body +|15: 3 ⠉⠉⠁⠉⠉⠉⠈⠈⠉⠉⠉⠉⠉⠉ ++15: 7="⠉" 8="⠉" 9="⠁" 10="⠉" 11="⠉" 12="⠉" 13="⠈" 14="⠈" 15="⠉" 16="⠉" 17="⠉" 18="⠉" 19="⠉" 20="⠉" +@15: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-17 #bdbdbd,#080808,,body 18-19 #e3c78a,#080808,,body 20-20 #bdbdbd,#080808,,body 21-89 d,#080808,,body +|16: 4 +@16: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|17: +@17: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|18: +@18: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|19: +@19: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|20: +@20: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|21: +@21: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +== style colors-off grid=90x22 +|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-22 #bdbdbd,#080808, 23-89 d,#080808, +|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-89 d,#080808, +|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-20 #bdbdbd,#080808, 21-89 d,#080808, +== 9p colors-off grid=90x22 cursor=7,13,block +|13: 1 ⠭⠍⣥⢩⣭⡭⣭⣬⣌⢭⣭⠌⠬⠅⠍⠁ ++13: 7="⠭" 8="⠍" 9="⣥" 10="⢩" 11="⣭" 12="⡭" 13="⣭" 14="⣬" 15="⣌" 16="⢭" 17="⣭" 18="⠌" 19="⠬" 20="⠅" 21="⠍" 22="⠁" +@13: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-22 #bdbdbd,#080808,,body 23-89 d,#080808,,body +|14: 2 ⣂⢀⣉⣉⢉⣈⢁⣀⣀⢀⣀⣀⣀⢀⣀⣀⣀⣀⣀ ++14: 7="⣂" 8="⢀" 9="⣉" 10="⣉" 11="⢉" 12="⣈" 13="⢁" 14="⣀" 15="⣀" 16="⢀" 17="⣀" 18="⣀" 19="⣀" 20="⢀" 21="⣀" 22="⣀" 23="⣀" 24="⣀" 25="⣀" +@14: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-25 #bdbdbd,#080808,,body 26-89 d,#080808,,body +|15: 3 ⠉⠉⠁⠉⠉⠉⠈⠈⠉⠉⠉⠉⠉⠉ ++15: 7="⠉" 8="⠉" 9="⠁" 10="⠉" 11="⠉" 12="⠉" 13="⠈" 14="⠈" 15="⠉" 16="⠉" 17="⠉" 18="⠉" 19="⠉" 20="⠉" +@15: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-20 #bdbdbd,#080808,,body 21-89 d,#080808,,body +== style colors-on grid=90x22 +|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-8 #bdbdbd,#080808, 9-9 #cf87e8,#080808,b 10-12 #bdbdbd,#080808, 13-15 #cf87e8,#080808,b 16-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-89 d,#080808, +|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-8 #ffffff,#080808,b 9-11 #cf87e8,#080808,b 12-25 #ffffff,#080808,b 26-89 d,#080808, +|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #bdbdbd,#080808, 18-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-89 d,#080808, +== snap resized grid=105x26 cursor=7,15 +|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/mini/cwd/commands.txt Save New Newtty Del +| 1 @`Mini demo.zig` +| +| +| +| +| +| +| +| +| +| +| +| /tmp/pardes-snap/mini/cwd/Mini demo.zig Save New Newtty Del +| 1 ⠭⠍⣥⢩⣭⡭⣭⣬⣌⢭⣭⠌⠬⠅⠍⠁ +| 2 ⣂⢀⣉⣉⢉⣈⢁⣀⣀⢀⣀⣀⣀⢀⣀⣀⣀⣀⣀ +| 3 ⠉⠉⠁⠉⠉⠉⠈⠈⠉⠉⠉⠉⠉⠉ +| 4 +== 9p resized grid=105x26 cursor=7,15,block +|0: New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +@0: 0-74 #949494,#121212,,tagline 75-104 d,#121212,,tagline +|1: /tmp/pardes-snap/mini/cwd/commands.txt Save New Newtty Del +@1: 0-1 #ffffff,#262626,,tagline 2-84 #949494,#121212,,tagline 85-104 d,#121212,,tagline +|2: 1 @`Mini demo.zig` +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-22 #bdbdbd,#080808,,body 23-104 d,#080808,,body +|3: +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|4: +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|5: +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|6: +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|7: +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|8: +@8: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|9: +@9: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|10: +@10: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|11: +@11: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|12: +@12: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|13: +@13: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|14: /tmp/pardes-snap/mini/cwd/Mini demo.zig Save New Newtty Del +@14: 0-1 #ffffff,#626262,,tagline 2-84 #949494,#121212,,tagline 85-104 d,#121212,,tagline +|15: 1 ⠭⠍⣥⢩⣭⡭⣭⣬⣌⢭⣭⠌⠬⠅⠍⠁ ++15: 7="⠭" 8="⠍" 9="⣥" 10="⢩" 11="⣭" 12="⡭" 13="⣭" 14="⣬" 15="⣌" 16="⢭" 17="⣭" 18="⠌" 19="⠬" 20="⠅" 21="⠍" 22="⠁" +@15: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-8 #bdbdbd,#080808,,body 9-9 #cf87e8,#080808,b,body 10-12 #bdbdbd,#080808,,body 13-15 #cf87e8,#080808,b,body 16-21 #e3c78a,#080808,,body 22-22 #bdbdbd,#080808,,body 23-104 d,#080808,,body +|16: 2 ⣂⢀⣉⣉⢉⣈⢁⣀⣀⢀⣀⣀⣀⢀⣀⣀⣀⣀⣀ ++16: 7="⣂" 8="⢀" 9="⣉" 10="⣉" 11="⢉" 12="⣈" 13="⢁" 14="⣀" 15="⣀" 16="⢀" 17="⣀" 18="⣀" 19="⣀" 20="⢀" 21="⣀" 22="⣀" 23="⣀" 24="⣀" 25="⣀" +@16: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-8 #ffffff,#080808,b,body 9-11 #cf87e8,#080808,b,body 12-25 #ffffff,#080808,b,body 26-104 d,#080808,,body +|17: 3 ⠉⠉⠁⠉⠉⠉⠈⠈⠉⠉⠉⠉⠉⠉ ++17: 7="⠉" 8="⠉" 9="⠁" 10="⠉" 11="⠉" 12="⠉" 13="⠈" 14="⠈" 15="⠉" 16="⠉" 17="⠉" 18="⠉" 19="⠉" 20="⠉" +@17: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-17 #bdbdbd,#080808,,body 18-19 #e3c78a,#080808,,body 20-20 #bdbdbd,#080808,,body 21-104 d,#080808,,body +|18: 4 +@18: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|19: +@19: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|20: +@20: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|21: +@21: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|22: +@22: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|23: +@23: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|24: +@24: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|25: +@25: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body diff --git a/test/snapshots/mini.snap b/test/snapshots/mini.snap new file mode 100644 index 00000000..eacd86b9 --- /dev/null +++ b/test/snapshots/mini.snap @@ -0,0 +1,23 @@ +file commands.txt @`Mini demo.zig` +file demo.zig const greeting = "café λ 界 é";\n\npub fn choose(ok: bool) u32 {\n if (ok) return 42;\n return 7;\n}\n\n// space and color survive compression\nconst answer = choose(true);\n +start 22 90 commands.txt +wait 8000 Mini demo.zig +stable 400 10000 +press middle @Mini 3 +release middle @Mini 3 +wait 8000 /Mini demo.zig +stable 500 10000 +snap opened +snapstyle opened +snap9p opened +key space t c +stable 300 8000 +snapstyle colors-off +snap9p colors-off +key space t c +stable 300 8000 +snapstyle colors-on +resize 26 105 +stable 400 10000 +snap resized +snap9p resized diff --git a/test/snapshots/nested-optout.snap b/test/snapshots/nested-optout.snap index 27e1ba3d..973b8817 100644 --- a/test/snapshots/nested-optout.snap +++ b/test/snapshots/nested-optout.snap @@ -1,22 +1,6 @@ -# `--nested` is the documented escape hatch out of the hand-the-file-outward -# mechanism, and nested.snap covers only the mechanism. The flag says two -# things at once and both are asserted here: an instance carrying it keeps its -# own argument instead of forwarding it, AND it is not an outer instance for -# anything launched inside IT (tty.zig binds no socket when opts.nested). -# -# The same trick nested.snap uses to get a genuine descendant: a pane shell's -# parent IS the app under test, so `$(readlink /proc/$PPID/exe)` in a pane is -# this exact binary. What discriminates is the GRID — a second topbar drawn -# INSIDE the pane, with the outer instance still one shell wide. -# -# Long waits on purpose: each arm forks and execs whole further pardes. file nested.txt optout-marker\nsecond-optout-line\n dirmk innerdir file innerdir/deeper.txt deepest-marker\n -# --- a file argument, WITH the flag: the inner keeps it --- -# Without --nested this is nested.snap's first arm and the outer grows a -# file pane beside the shell. Here the outer's layout never changes: one -# shell, and a whole second pardes drawn inside it holding nested.txt. start 30 100 wait 8000 $ stable 700 20000 @@ -25,12 +9,6 @@ key enter wait 20000 optout-marker stable 700 20000 snap optout-file -# --- ...and it is not an outer instance either --- -# The subshell is what makes the wait unambiguous: the inner boots in -# `innerdir` while the outer pane's own shell stays in cwd, so `cwd/innerdir` -# on the grid can only be the inner instance's tagline. --nested with no -# argument is still a bare launch, so the inner comes up in tty mode and -# keystrokes travel through both layers to its shell. start 30 100 wait 8000 $ stable 700 20000 @@ -39,12 +17,6 @@ key enter wait 20000 cwd/innerdir stable 700 20000 snap inner-shell -# A third pardes, launched from inside the inner one. Its nearest pardes -# ancestor is the INNER instance, which bound no socket, so the send fails and -# it opens its own session where it stands — three topbars. The assertion is -# the row it did NOT appear on: the outer instance is still one shell pane, and -# a walk that had skipped past the opted-out instance would have put -# deeper.txt up there beside it. text $(readlink /proc/$PPID/exe) deeper.txt key enter wait 20000 deepest-marker diff --git a/test/snapshots/nested.snap b/test/snapshots/nested.snap index 0beeabf8..790b9b0c 100644 --- a/test/snapshots/nested.snap +++ b/test/snapshots/nested.snap @@ -1,34 +1,14 @@ -# a pardes launched INSIDE a pardes hands its argument to the outer one. -# -# No harness support is needed to get a genuinely nested launch: a pane -# shell's parent IS the app under test, so `$(readlink /proc/$PPID/exe)` in a -# pane is this exact binary, and running it from there makes a real -# descendant — the same process tree a person typing `pardes foo.txt` in a -# pane produces. Each arm gets its own `start` so the Look never has to be -# aimed at a pane a previous arm moved. -# -# What discriminates is the GOLDEN GRID, not the `wait` below it: a nested -# pardes that opened its own UI in the pane would show the same file, so the -# content wait passes either way. The layout is what differs — an outer file -# pane beside the shell versus a whole second topbar inside it — and the -# `cursor=` on the first arm's header moves the moment that changes. -# -# Long waits on purpose: this forks and execs a whole second pardes. dirmk nestdir file nestdir/inside-nested.txt marker file nested.txt hello-from-nested\nsecond-nested-line\n -# --- a FILE argument: a file pane appears in the OUTER instance --- start 30 100 wait 8000 $ stable 700 20000 text $(readlink /proc/$PPID/exe) nested.txt key enter -# the CONTENT, not the border: the shell only ever echoed the file's NAME -# (see above for why the grid, not this line, is the real assertion) wait 15000 hello-from-nested stable 700 20000 snap file-arg -# --- no argument: the spicy message, in the pane, and no second UI --- start 30 100 wait 8000 $ stable 700 20000 @@ -37,7 +17,6 @@ key enter wait 15000 already inside pardes stable 700 20000 snap no-arg -# --- a DIRECTORY argument: a shell there, greeted with ls --- start 30 100 wait 8000 $ stable 700 20000 diff --git a/test/snapshots/ninep.golden b/test/snapshots/ninep.golden new file mode 100644 index 00000000..c0e90e3f --- /dev/null +++ b/test/snapshots/ninep.golden @@ -0,0 +1,195 @@ +== snap opened grid=90x14 cursor=7,2 +|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/ninep/cwd/demo.zig Save New Newtty Del +| 1 const greeting = "café λ 界 é"; +| 2 +| 3 pub fn choose(ok: bool) u32 { +| 4 if (ok) return 42; +| 5 return 7; +| 6 } +| 7 +== style opened grid=90x14 +|0: 0-74 #949494,#121212, 75-89 d,#121212, +|1: 0-1 #ffffff,#626262, 2-69 #949494,#121212, 70-89 d,#121212, +|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-23 #bdbdbd,#080808, 24-32 #e3c78a,#080808, 33-33 d,d, 34-36 #e3c78a,#080808, 37-37 #bdbdbd,#080808, 38-89 d,#080808, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-35 #bdbdbd,#080808, 36-89 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-18 #bdbdbd,#080808, 19-24 #cf87e8,#080808,b 25-25 #bdbdbd,#080808, 26-27 #e3c78a,#080808, 28-28 #bdbdbd,#080808, 29-89 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-16 #cf87e8,#080808,b 17-17 #bdbdbd,#080808, 18-18 #e3c78a,#080808, 19-19 #bdbdbd,#080808, 20-89 d,#080808, +|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-89 d,#080808, +|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +== 9p opened grid=90x14 cursor=7,2,block +|0: New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +@0: 0-74 #949494,#121212,,tagline 75-89 d,#121212,,tagline +|1: /tmp/pardes-snap/ninep/cwd/demo.zig Save New Newtty Del +@1: 0-1 #ffffff,#626262,,tagline 2-69 #949494,#121212,,tagline 70-89 d,#121212,,tagline +|2: 1 const greeting = "café λ 界 é"; ++2: 28="é" 30="λ" 32="界" 33="" 35="é" +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-23 #bdbdbd,#080808,,body 24-36 #e3c78a,#080808,,body 37-37 #bdbdbd,#080808,,body 38-89 d,#080808,,body +|3: 2 +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|4: 3 pub fn choose(ok: bool) u32 { +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-35 #bdbdbd,#080808,,body 36-89 d,#080808,,body +|5: 4 if (ok) return 42; +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-18 #bdbdbd,#080808,,body 19-24 #cf87e8,#080808,b,body 25-25 #bdbdbd,#080808,,body 26-27 #e3c78a,#080808,,body 28-28 #bdbdbd,#080808,,body 29-89 d,#080808,,body +|6: 5 return 7; +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-16 #cf87e8,#080808,b,body 17-17 #bdbdbd,#080808,,body 18-18 #e3c78a,#080808,,body 19-19 #bdbdbd,#080808,,body 20-89 d,#080808,,body +|7: 6 } +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-7 #bdbdbd,#080808,,body 8-89 d,#080808,,body +|8: 7 +@8: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|9: +@9: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|10: +@10: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|11: +@11: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|12: +@12: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|13: +@13: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +== style colors-off grid=90x14 +|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-32 #bdbdbd,#080808, 33-33 d,d, 34-37 #bdbdbd,#080808, 38-89 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-35 #bdbdbd,#080808, 36-89 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-28 #bdbdbd,#080808, 29-89 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-19 #bdbdbd,#080808, 20-89 d,#080808, +== 9p colors-off grid=90x14 cursor=7,2,block +|2: 1 const greeting = "café λ 界 é"; ++2: 28="é" 30="λ" 32="界" 33="" 35="é" +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-37 #bdbdbd,#080808,,body 38-89 d,#080808,,body +|4: 3 pub fn choose(ok: bool) u32 { +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-35 #bdbdbd,#080808,,body 36-89 d,#080808,,body +|5: 4 if (ok) return 42; +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-28 #bdbdbd,#080808,,body 29-89 d,#080808,,body +|6: 5 return 7; +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-19 #bdbdbd,#080808,,body 20-89 d,#080808,,body +== snap edited-with-colors-off grid=90x14 cursor=7,3 +|1: /tmp/pardes-snap/ninep/cwd/demo.zig * Save New Newtty Del +|2: 1 // café λ 界 +|3: 2 const greeting = "café λ 界 é"; +|4: 3 +|5: 4 pub fn choose(ok: bool) u32 { +|6: 5 if (ok) return 42; +|7: 6 return 7; +|8: 7 } +|9: 8 +== 9p edited-with-colors-off grid=90x14 cursor=7,3,block +|1: /tmp/pardes-snap/ninep/cwd/demo.zig * Save New Newtty Del +@1: 0-1 #ffffff,#626262,,tagline 2-69 #949494,#121212,,tagline 70-89 d,#121212,,tagline +|2: 1 // café λ 界 ++2: 13="é" 15="λ" 17="界" 18="" +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-18 #bdbdbd,#080808,,body 19-89 d,#080808,,body +|3: 2 const greeting = "café λ 界 é"; ++3: 28="é" 30="λ" 32="界" 33="" 35="é" +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-37 #bdbdbd,#080808,,body 38-89 d,#080808,,body +|4: 3 +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +|5: 4 pub fn choose(ok: bool) u32 { +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-35 #bdbdbd,#080808,,body 36-89 d,#080808,,body +|6: 5 if (ok) return 42; +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-28 #bdbdbd,#080808,,body 29-89 d,#080808,,body +|7: 6 return 7; +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-19 #bdbdbd,#080808,,body 20-89 d,#080808,,body +|8: 7 } +@8: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-7 #bdbdbd,#080808,,body 8-89 d,#080808,,body +|9: 8 +@9: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-89 d,#080808,,body +== style colors-on grid=90x14 +|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #ffffff,#080808,b 18-18 d,d, 19-89 d,#080808, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-23 #bdbdbd,#080808, 24-32 #e3c78a,#080808, 33-33 d,d, 34-36 #e3c78a,#080808, 37-37 #bdbdbd,#080808, 38-89 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-89 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-35 #bdbdbd,#080808, 36-89 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-18 #bdbdbd,#080808, 19-24 #cf87e8,#080808,b 25-25 #bdbdbd,#080808, 26-27 #e3c78a,#080808, 28-28 #bdbdbd,#080808, 29-89 d,#080808, +|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-16 #cf87e8,#080808,b 17-17 #bdbdbd,#080808, 18-18 #e3c78a,#080808, 19-19 #bdbdbd,#080808, 20-89 d,#080808, +|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-89 d,#080808, +== 9p colors-on grid=90x14 cursor=7,3,block +|2: 1 // café λ 界 ++2: 13="é" 15="λ" 17="界" 18="" +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-18 #ffffff,#080808,b,body 19-89 d,#080808,,body +|3: 2 const greeting = "café λ 界 é"; ++3: 28="é" 30="λ" 32="界" 33="" 35="é" +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-23 #bdbdbd,#080808,,body 24-36 #e3c78a,#080808,,body 37-37 #bdbdbd,#080808,,body 38-89 d,#080808,,body +|5: 4 pub fn choose(ok: bool) u32 { +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-35 #bdbdbd,#080808,,body 36-89 d,#080808,,body +|6: 5 if (ok) return 42; +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-18 #bdbdbd,#080808,,body 19-24 #cf87e8,#080808,b,body 25-25 #bdbdbd,#080808,,body 26-27 #e3c78a,#080808,,body 28-28 #bdbdbd,#080808,,body 29-89 d,#080808,,body +|7: 6 return 7; +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-16 #cf87e8,#080808,b,body 17-17 #bdbdbd,#080808,,body 18-18 #e3c78a,#080808,,body 19-19 #bdbdbd,#080808,,body 20-89 d,#080808,,body +== style resized grid=105x19 +|0: 0-74 #949494,#121212, 75-104 d,#121212, +|1: 0-1 #ffffff,#626262, 2-84 #949494,#121212, 85-104 d,#121212, +|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #ffffff,#080808,b 18-18 d,d, 19-104 d,#080808, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-23 #bdbdbd,#080808, 24-32 #e3c78a,#080808, 33-33 d,d, 34-36 #e3c78a,#080808, 37-37 #bdbdbd,#080808, 38-104 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-35 #bdbdbd,#080808, 36-104 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-12 #cf87e8,#080808,b 13-18 #bdbdbd,#080808, 19-24 #cf87e8,#080808,b 25-25 #bdbdbd,#080808, 26-27 #e3c78a,#080808, 28-28 #bdbdbd,#080808, 29-104 d,#080808, +|7: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-16 #cf87e8,#080808,b 17-17 #bdbdbd,#080808, 18-18 #e3c78a,#080808, 19-19 #bdbdbd,#080808, 20-104 d,#080808, +|8: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #bdbdbd,#080808, 8-104 d,#080808, +|9: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|10: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-104 d,#080808, +== 9p resized grid=105x19 cursor=7,3,block +|0: New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +@0: 0-74 #949494,#121212,,tagline 75-104 d,#121212,,tagline +|1: /tmp/pardes-snap/ninep/cwd/demo.zig * Save New Newtty Del +@1: 0-1 #ffffff,#626262,,tagline 2-84 #949494,#121212,,tagline 85-104 d,#121212,,tagline +|2: 1 // café λ 界 ++2: 13="é" 15="λ" 17="界" 18="" +@2: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-18 #ffffff,#080808,b,body 19-104 d,#080808,,body +|3: 2 const greeting = "café λ 界 é"; ++3: 28="é" 30="λ" 32="界" 33="" 35="é" +@3: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #949494,#121212,,body 7-23 #bdbdbd,#080808,,body 24-36 #e3c78a,#080808,,body 37-37 #bdbdbd,#080808,,body 38-104 d,#080808,,body +|4: 3 +@4: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|5: 4 pub fn choose(ok: bool) u32 { +@5: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-35 #bdbdbd,#080808,,body 36-104 d,#080808,,body +|6: 5 if (ok) return 42; +@6: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-12 #cf87e8,#080808,b,body 13-18 #bdbdbd,#080808,,body 19-24 #cf87e8,#080808,b,body 25-25 #bdbdbd,#080808,,body 26-27 #e3c78a,#080808,,body 28-28 #bdbdbd,#080808,,body 29-104 d,#080808,,body +|7: 6 return 7; +@7: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-10 #bdbdbd,#080808,,body 11-16 #cf87e8,#080808,b,body 17-17 #bdbdbd,#080808,,body 18-18 #e3c78a,#080808,,body 19-19 #bdbdbd,#080808,,body 20-104 d,#080808,,body +|8: 7 } +@8: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-7 #bdbdbd,#080808,,body 8-104 d,#080808,,body +|9: 8 +@9: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|10: +@10: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|11: +@11: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|12: +@12: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|13: +@13: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|14: +@14: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|15: +@15: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|16: +@16: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|17: +@17: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +|18: +@18: 0-0 d,#444444,,body 1-1 d,#080808,,body 2-6 #626262,#080808,,body 7-104 d,#080808,,body +== snap resized grid=105x19 cursor=7,3 +|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill +| /tmp/pardes-snap/ninep/cwd/demo.zig * Save New Newtty Del +| 1 // café λ 界 +| 2 const greeting = "café λ 界 é"; +| 3 +| 4 pub fn choose(ok: bool) u32 { +| 5 if (ok) return 42; +| 6 return 7; +| 7 } +| 8 +== 9p unchanged grid=105x19 cursor=7,3,block diff --git a/test/snapshots/ninep.snap b/test/snapshots/ninep.snap new file mode 100644 index 00000000..5c9da02a --- /dev/null +++ b/test/snapshots/ninep.snap @@ -0,0 +1,28 @@ +file demo.zig const greeting = "café λ 界 é";\n\npub fn choose(ok: bool) u32 {\n if (ok) return 42;\n return 7;\n}\n +start 14 90 demo.zig +wait 8000 choose +stable 400 10000 +snap opened +snapstyle opened +snap9p opened +key space t c +stable 300 8000 +snapstyle colors-off +snap9p colors-off +key g g i +send // café λ 界\n +key esc +settle 100 +stable 300 8000 +snap edited-with-colors-off +snap9p edited-with-colors-off +key space t c +stable 300 8000 +snapstyle colors-on +snap9p colors-on +resize 19 105 +stable 400 10000 +snapstyle resized +snap9p resized +snap resized +snap9p unchanged diff --git a/test/snapshots/shellset.snap b/test/snapshots/shellset.snap index 45c94dee..e50d29bc 100644 --- a/test/snapshots/shellset.snap +++ b/test/snapshots/shellset.snap @@ -10,7 +10,7 @@ # # The rest of the feature is unit-tested rather than pinned here, on purpose. # Which family a binary belongs to, what argv each one gets and where the -# lookup searches are in shell_bin.zig's own tests; the fish end of it cannot +# lookup searches are in host_io.Shell's own tests; the fish end of it cannot # be a golden at all, because fish's prompt carries a hostname and its greeting # a version number. (Every script in this suite runs bash, pinned through an # ordinary config file the harness writes — see snapshot.zig.) diff --git a/test/syntax.zig b/test/syntax.zig new file mode 100644 index 00000000..deaad7de --- /dev/null +++ b/test/syntax.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const syntax = @import("syntax"); +const gpa = std.heap.c_allocator; + +const Case = struct { + name: []const u8, + path: []const u8, + source: []const u8, + locations: bool = false, + start: usize = 0, +}; + +const cases = [_]Case{ + .{ .name = "zig", .path = "a.zig", .source = "const answer: u32 = 42;\npub fn main() void { // note\n _ = \"hello\";\n}\n" }, + .{ .name = "markdown", .path = "a.md", .source = "# Heading *slant*\n\n**bold** and `code`\n\n```zig\nconst n = 42;\n```\n" }, + .{ .name = "markdown-nested", .path = "a.md", .source = "> # Heading *slant*\n>\n> Prose with **bold** and [a link](https://example.org).\n\n- `code` with <https://example.org>\n- plain text\n\n```zig\nfn main() void { return; }\n```\n" }, + .{ .name = "c-comment-window", .path = "a.c", .source = "/* opening\nstill a comment 42\n*/\nint n = 7;\n", .start = 11 }, + .{ .name = "markdown-indent", .path = "a.md", .source = " # code, not a heading\n" }, + .{ .name = "result-markdown", .path = "+Grep", .source = "a.md:1 # Heading *slant*\na.md:3 **bold** and `code`\n", .locations = true }, + .{ .name = "result-indent", .path = "+Grep", .source = "a.md:1 # code, not a heading\n", .locations = true }, + .{ .name = "result-zig", .path = "+Grep", .source = "a.zig:1:1 const answer: u32 = 42;\na.zig:2:1 pub fn main() void { // note\na.zig:3:1 _ = \"hello\";\na.zig:4:1 }\n", .locations = true }, + .{ .name = "prose", .path = "+Help", .source = "A document without code locations.\nRead a.md for help.\n", .locations = true }, +}; + +fn highlight(case: Case) ![]u8 { + if (comptime @hasDecl(syntax, "highlightLocations")) { + if (case.locations) return syntax.highlightLocations(gpa, case.source, case.start, case.source.len); + } + return syntax.highlightFileRange(gpa, case.path, case.source, case.start, case.source.len); +} + +fn out(comptime format: []const u8, args: anytype) !void { + var buffer: [4096]u8 = undefined; + const text = try std.fmt.bufPrint(&buffer, format, args); + var offset: usize = 0; + while (offset < text.len) { + const n = std.c.write(1, text.ptr + offset, text.len - offset); + if (n <= 0) return error.WriteFailed; + offset += @intCast(n); + } +} + +fn now() u64 { + var time: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &time); + return @as(u64, @intCast(time.sec)) * 1_000_000_000 + @as(u64, @intCast(time.nsec)); +} + +const Counter = struct { + calls: usize = 0, + bytes: usize = 0, + live: usize = 0, + peak: usize = 0, + + fn allocator(self: *Counter) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret: usize) ?[*]u8 { + const self: *Counter = @ptrCast(@alignCast(ctx)); + const ptr = gpa.rawAlloc(len, alignment, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live += len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) bool { + const self: *Counter = @ptrCast(@alignCast(ctx)); + if (!gpa.rawResize(memory, alignment, len, ret)) return false; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) ?[*]u8 { + const self: *Counter = @ptrCast(@alignCast(ctx)); + const ptr = gpa.rawRemap(memory, alignment, len, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret: usize) void { + const self: *Counter = @ptrCast(@alignCast(ctx)); + self.live -= memory.len; + gpa.rawFree(memory, alignment, ret); + } +}; + +fn benchmark(case: Case, reps: usize) !void { + var counter: Counter = .{}; + syntax.start(counter.allocator()); + const cold_start = now(); + gpa.free(try highlight(case)); + const cold = now() - cold_start; + const retained = counter.live; + const calls = counter.calls; + const bytes = counter.bytes; + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + for (samples) |*sample| { + const before = now(); + gpa.free(try highlight(case)); + sample.* = now() - before; + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + const leaked = counter.live - retained; + try out("{{\"case\":\"{s}\",\"bytes\":{d},\"cold_ns\":{d},\"median_ns\":{d},\"p95_ns\":{d},\"allocations\":{d},\"allocated_bytes\":{d},\"retained_growth\":{d}}}\n", .{ + case.name, case.source.len, cold, samples[reps / 2], samples[@min(reps - 1, reps * 95 / 100)], + (counter.calls - calls) / reps, (counter.bytes - bytes) / reps, leaked, + }); + syntax.stop(); + if (counter.live != 0) return error.LeakedSyntaxMemory; +} + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + var bench = false; + var reps: usize = 21; + var i: usize = 1; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--bench")) { + bench = true; + } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) { + i += 1; + reps = try std.fmt.parseInt(usize, args[i], 10); + if (reps == 0) return error.InvalidRepetitions; + } else return error.InvalidArgument; + } + if (!bench) { + syntax.start(gpa); + defer syntax.stop(); + for (cases) |case| { + try out("{s}\n", .{case.name}); + const styles = try highlight(case); + defer gpa.free(styles); + for (case.source[case.start..], 0..) |byte, at| { + if (byte == '\n') { + try out("\n", .{}); + } else { + const style = if (styles.len == 0) 0 else styles[at]; + try out("{c}", .{".ksnc"[style]}); + } + } + } + return; + } + for (cases) |case| try benchmark(case, reps); + var results: std.ArrayList(u8) = .empty; + defer results.deinit(gpa); + for (0..512) |_| try results.appendSlice(gpa, "src/a.zig:12:1 const value: u32 = 42; // result\n"); + try benchmark(.{ .name = "results-512", .path = "+Grep", .source = results.items, .locations = true }, reps); + results.clearRetainingCapacity(); + for (0..128) |_| try results.appendSlice(gpa, "a.zig:1 const value = 42;\nb.c:1 int value = 42;\nc.md:1 **bold** and `code`\nd.rs:1 let value = 42;\n"); + try benchmark(.{ .name = "results-mixed-512", .path = "+Grep", .source = results.items, .locations = true }, reps); + results.clearRetainingCapacity(); + var row_buffer: [512]u8 = undefined; + for (0..128) |line| { + const rows = try std.fmt.bufPrint(&row_buffer, "a.zig:{d} const value_{d} = {d};\nb.c:{d} int value_{d} = {d};\nc.md:{d} **item {d}** and `code {d}`\nd.rs:{d} let value_{d} = {d};\n", .{ line + 1, line, line, line + 1, line, line, line + 1, line, line, line + 1, line, line }); + try results.appendSlice(gpa, rows); + } + try benchmark(.{ .name = "results-varied-512", .path = "+Grep", .source = results.items, .locations = true }, reps); + results.clearRetainingCapacity(); + for (0..512) |line| { + const row = try std.fmt.bufPrint(&row_buffer, "notes.md:{d} Ordinary prose for item {d}, without inline markup.\n", .{ line + 1, line }); + try results.appendSlice(gpa, row); + } + try benchmark(.{ .name = "results-prose-512", .path = "+Grep", .source = results.items, .locations = true }, reps); + results.clearRetainingCapacity(); + for (0..128) |line| { + const block = try std.fmt.bufPrint(&row_buffer, "# Heading {d}\n\nPlain paragraph {d}.\n\n**Bold {d}** and `code`.\n\n```zig\nconst value_{d} = {d};\n```\n\n", .{ line, line, line, line, line }); + try results.appendSlice(gpa, block); + } + try benchmark(.{ .name = "markdown-blocks-128", .path = "notes.md", .source = results.items }, reps); +} diff --git a/test/unit_profile.zig b/test/unit_profile.zig new file mode 100644 index 00000000..59794df9 --- /dev/null +++ b/test/unit_profile.zig @@ -0,0 +1,247 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Server = std.zig.Server.Message; +const Client = std.zig.Client.Message; +const Memory = struct { rss_after_bytes: ?usize = null, cumulative_peak_rss_bytes: ?usize = null }; + +fn send(io: std.Io, file: std.Io.File, tag: Client.Tag, body: []const u8, deadline: std.Io.Timeout) !void { + var header: [8]u8 = undefined; + std.mem.writeInt(u32, header[0..4], @intFromEnum(tag), .little); + std.mem.writeInt(u32, header[4..8], @intCast(body.len), .little); + for ([_][]const u8{ &header, body }) |part| { + var offset: usize = 0; + while (offset < part.len) { + const n = try (try io.operateTimeout(.{ .file_write_streaming = .{ + .file = file, + .data = &.{part[offset..]}, + } }, deadline)).file_write_streaming; + if (n == 0) return error.EndOfStream; + offset += n; + } + } +} + +fn readAll(io: std.Io, file: std.Io.File, bytes: []u8, deadline: std.Io.Timeout) !void { + var offset: usize = 0; + while (offset < bytes.len) { + const n = try (try io.operateTimeout(.{ .file_read_streaming = .{ + .file = file, + .data = &.{bytes[offset..]}, + } }, deadline)).file_read_streaming; + if (n == 0) return error.EndOfStream; + offset += n; + } +} + +fn receive(io: std.Io, file: std.Io.File, gpa: std.mem.Allocator, deadline: std.Io.Timeout) !struct { tag: Server.Tag, body: []u8 } { + var header: [8]u8 = undefined; + try readAll(io, file, &header, deadline); + const tag: Server.Tag = @enumFromInt(std.mem.readInt(u32, header[0..4], .little)); + const len = std.mem.readInt(u32, header[4..8], .little); + if (len > 16 * 1024 * 1024) return error.MessageTooLarge; + const bytes = try gpa.alloc(u8, len); + errdefer gpa.free(bytes); + try readAll(io, file, bytes, deadline); + return .{ .tag = tag, .body = bytes }; +} + +fn wait(io: std.Io, child: *std.process.Child, deadline: std.Io.Timeout) !std.process.Child.Term { + const Result = union(enum) { exited: std.process.Child.WaitError!std.process.Child.Term, timeout: std.Io.Cancelable!void }; + var results: [2]Result = undefined; + var select = std.Io.Select(Result).init(io, &results); + defer select.cancelDiscard(); + try select.concurrent(.exited, std.process.Child.wait, .{ child, io }); + select.async(.timeout, std.Io.Timeout.sleep, .{ deadline, io }); + return switch (try select.await()) { + .exited => |result| result, + .timeout => |result| { + try result; + return error.Timeout; + }, + }; +} + +fn memory(io: std.Io, gpa: std.mem.Allocator, pid: std.process.Child.Id) !Memory { + if (comptime builtin.os.tag != .linux) return .{}; + var path_buffer: [64]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "/proc/{d}/status", .{pid}); + const file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + var reader = file.readerStreaming(io, &.{}); + const status = try reader.interface.allocRemaining(gpa, .limited(8192)); + defer gpa.free(status); + var value: Memory = .{}; + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "VmRSS:") and !std.mem.startsWith(u8, line, "VmHWM:")) continue; + var words = std.mem.tokenizeAny(u8, line, " \t"); + const kind = words.next().?; + const bytes = try std.fmt.parseInt(usize, words.next() orelse return error.InvalidMemoryReport, 10); + if (std.mem.eql(u8, kind, "VmRSS:")) value.rss_after_bytes = bytes * 1024 else value.cumulative_peak_rss_bytes = bytes * 1024; + } + return value; +} + +fn queryTests(io: std.Io, child: *std.process.Child, gpa: std.mem.Allocator, deadline: std.Io.Timeout) ![]u8 { + const version = try receive(io, child.stdout.?, gpa, deadline); + defer gpa.free(version.body); + if (version.tag != .zig_version or !std.mem.eql(u8, version.body, builtin.zig_version_string)) + return error.DifferentZigVersion; + try send(io, child.stdin.?, .query_test_metadata, "", deadline); + const metadata = try receive(io, child.stdout.?, gpa, deadline); + errdefer gpa.free(metadata.body); + if (metadata.tag != .test_metadata or metadata.body.len < 8) return error.InvalidMetadata; + const strings = std.mem.readInt(u32, metadata.body[0..4], .little); + const count = std.mem.readInt(u32, metadata.body[4..8], .little); + const table_bytes = try std.math.mul(usize, count, 8); + if (metadata.body.len != 8 + table_bytes + strings) return error.InvalidMetadata; + return metadata.body; +} + +fn testName(metadata: []const u8, index: usize) ![]const u8 { + const count = std.mem.readInt(u32, metadata[4..8], .little); + if (index >= count) return error.InvalidMetadata; + const offset = std.mem.readInt(u32, metadata[8 + index * 4 ..][0..4], .little); + const strings = metadata[8 + @as(usize, count) * 8 ..]; + if (offset >= strings.len) return error.InvalidMetadata; + const end = std.mem.indexOfScalar(u8, strings[offset..], 0) orelse return error.InvalidMetadata; + return strings[offset..][0..end]; +} + +fn checkFilter(io: std.Io, gpa: std.mem.Allocator, filter: []const u8, binaries: []const []const u8) !void { + var matches: usize = 0; + for (binaries) |binary| { + var child = try std.process.spawn(io, .{ + .argv = &.{ binary, "--listen=-" }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .inherit, + }); + errdefer child.kill(io); + const deadline: std.Io.Timeout = (std.Io.Timeout{ .duration = .{ .clock = .awake, .raw = .fromSeconds(30) } }).toDeadline(io); + const metadata = try queryTests(io, &child, gpa, deadline); + defer gpa.free(metadata); + const count = std.mem.readInt(u32, metadata[4..8], .little); + for (0..count) |index| { + const name = try testName(metadata, index); + if (std.mem.indexOf(u8, name, ".test.") == null) if (std.mem.lastIndexOf(u8, name, ".test_")) |at| { + const suffix = name[at + ".test_".len ..]; + const unnamed = for (suffix) |c| { + if (!std.ascii.isDigit(c)) break false; + } else true; + if (suffix.len > 0 and unnamed) continue; + }; + if (std.mem.indexOf(u8, name, filter) != null) matches += 1; + } + try send(io, child.stdin.?, .exit, "", deadline); + const result = try wait(io, &child, deadline); + if (result != .exited or result.exited != 0) return error.TestRunnerFailed; + } + if (matches == 0) { + std.debug.print("test filter '{s}' matched no named tests in {d} test program(s)\n", .{ filter, binaries.len }); + return error.NoMatchingTests; + } + std.debug.print("test filter '{s}': {d} named match(es) in {d} test program(s)\n", .{ filter, matches, binaries.len }); +} + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const gpa = init.gpa; + const args = try init.minimal.args.toSlice(gpa); + defer gpa.free(args); + if (args.len > 1 and std.mem.eql(u8, args[1], "--check-filter")) { + if (args.len < 4) return error.ExpectedFilterAndTestBinaries; + return checkFilter(io, gpa, args[2], args[3..]); + } + if (args.len < 2) return error.ExpectedTestBinaryAndOptionalFilter; + var filter: ?[]const u8 = null; + var timeout_ms: u32 = 30_000; + for (args[2..]) |arg| { + if (std.mem.startsWith(u8, arg, "--timeout-ms=")) { + timeout_ms = try std.fmt.parseInt(u32, arg["--timeout-ms=".len..], 10); + if (timeout_ms == 0) return error.InvalidTimeout; + } else if (filter == null) { + filter = arg; + } else return error.ExpectedTestBinaryAndOptionalFilter; + } + const timeout: std.Io.Timeout = .{ .duration = .{ .clock = .awake, .raw = .fromMilliseconds(timeout_ms) } }; + const process_begin = std.Io.Clock.Timestamp.now(io, .awake); + var child = try std.process.spawn(io, .{ + .argv = &.{ args[1], "--listen=-" }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .inherit, + .request_resource_usage_statistics = true, + }); + errdefer child.kill(io); + var output_buffer: [4096]u8 = undefined; + var output = std.Io.File.stdout().writerStreaming(io, &output_buffer); + const startup_timeout: std.Io.Timeout = .{ .duration = .{ .clock = .awake, .raw = .fromMilliseconds(@max(timeout_ms, 1500)) } }; + const startup_deadline = startup_timeout.toDeadline(io); + const metadata = try queryTests(io, &child, gpa, startup_deadline); + defer gpa.free(metadata); + const count = std.mem.readInt(u32, metadata[4..8], .little); + var passed: usize = 0; + var failed: usize = 0; + var skipped: usize = 0; + var request_to_result_ns: i128 = 0; + var observed_peak_rss: ?usize = null; + for (0..count) |i| { + const name = try testName(metadata, i); + if (filter) |needle| if (std.mem.indexOf(u8, name, needle) == null) continue; + var id: [4]u8 = undefined; + std.mem.writeInt(u32, &id, @intCast(i), .little); + const begin = std.Io.Clock.Timestamp.now(io, .awake); + const deadline: std.Io.Timeout = .{ .deadline = begin.addDuration(timeout.duration) }; + errdefer std.debug.print("unit-profile: running {s}\n", .{name}); + try send(io, child.stdin.?, .run_test, &id, deadline); + const started = try receive(io, child.stdout.?, gpa, deadline); + defer gpa.free(started.body); + if (started.tag != .test_started or started.body.len != 0) return error.UnexpectedReply; + const result = try receive(io, child.stdout.?, gpa, deadline); + defer gpa.free(result.body); + const elapsed = begin.untilNow(io).raw.nanoseconds; + if (result.tag != .test_results or result.body.len != @sizeOf(Server.TestResults)) return error.UnexpectedReply; + if (std.mem.readInt(u32, result.body[0..4], .little) != i) return error.UnexpectedTestIndex; + const bits = std.mem.readInt(u64, result.body[4..12], .little); + if (bits & 3 == 3) return error.InvalidTestStatus; + const flags: Server.TestResults.Flags = @bitCast(bits); + const bad = flags.status == .fail or flags.log_err_count != 0 or flags.leak_count != 0; + if (bad) failed += 1 else if (flags.status == .skip) skipped += 1 else passed += 1; + request_to_result_ns += elapsed; + const usage = memory(io, gpa, child.id.?) catch Memory{}; + if (usage.cumulative_peak_rss_bytes) |peak| observed_peak_rss = @max(observed_peak_rss orelse 0, peak); + try std.json.Stringify.value(.{ + .kind = "case", + .name = name, + .request_to_result_ns = elapsed, + .rss_after_bytes = usage.rss_after_bytes, + .cumulative_peak_rss_bytes = usage.cumulative_peak_rss_bytes, + .status = if (bad) Server.TestResults.Status.fail else flags.status, + .log_errors = flags.log_err_count, + .leaks = flags.leak_count, + }, .{}, &output.interface); + try output.interface.writeByte('\n'); + try output.interface.flush(); + } + const exit_deadline = timeout.toDeadline(io); + try send(io, child.stdin.?, .exit, "", exit_deadline); + const result = try wait(io, &child, exit_deadline); + const process_wall_ns = process_begin.untilNow(io).raw.nanoseconds; + if (child.resource_usage_statistics.getMaxRss()) |peak| observed_peak_rss = @max(observed_peak_rss orelse 0, peak); + try std.json.Stringify.value(.{ + .kind = "summary", + .passed = passed, + .failed = failed, + .skipped = skipped, + .request_to_result_ns = request_to_result_ns, + .process_wall_ns = process_wall_ns, + .process_peak_rss_bytes = observed_peak_rss, + .exit = result, + }, .{}, &output.interface); + try output.interface.writeByte('\n'); + try output.interface.flush(); + if (result != .exited or result.exited != 0) return error.TestRunnerFailed; + if (passed + skipped + failed == 0) return error.NoMatchingTests; + if (failed != 0) return error.TestFailed; +} diff --git a/test/unit_profile_test.zig b/test/unit_profile_test.zig new file mode 100644 index 00000000..721f1212 --- /dev/null +++ b/test/unit_profile_test.zig @@ -0,0 +1,236 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const expect = std.testing.expect; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const gpa = init.arena.allocator(); + const args = try init.minimal.args.toSlice(gpa); + if (args.len == 2 and std.mem.eql(u8, args[1], "--listen=-")) return fixture(init); + if (args.len != 2) return error.ExpectedProfilerPath; + const self = try std.process.executablePathAlloc(io, gpa); + const cases = .{ + .{ "pass", "", "", 2, 0, 0 }, + .{ "delayed-start", "", "", 2, 0, 0 }, + .{ "skip", "", "", 1, 0, 1 }, + .{ "fail", "", "TestFailed", 1, 1, 0 }, + .{ "log", "", "TestFailed", 1, 1, 0 }, + .{ "leak", "", "TestFailed", 1, 1, 0 }, + .{ "pass", "fixture.two", "", 1, 0, 0 }, + .{ "pass", "absent", "NoMatchingTests", 0, 0, 0 }, + .{ "version", "", "DifferentZigVersion", 0, 0, 0 }, + .{ "metadata", "", "InvalidMetadata", 0, 0, 0 }, + .{ "index", "", "UnexpectedTestIndex", 0, 0, 0 }, + .{ "status", "", "InvalidTestStatus", 0, 0, 0 }, + .{ "oversized", "", "MessageTooLarge", 0, 0, 0 }, + .{ "truncated", "", "EndOfStream", 0, 0, 0 }, + .{ "hang-version", "", "Timeout", 0, 0, 0 }, + .{ "hang-metadata", "", "Timeout", 0, 0, 0 }, + .{ "hang-start", "", "Timeout", 0, 0, 0 }, + .{ "hang-result", "", "Timeout", 0, 0, 0 }, + .{ "hang-body", "", "Timeout", 0, 0, 0 }, + .{ "split-deadline", "", "Timeout", 0, 0, 0 }, + .{ "hang-exit", "", "Timeout", 0, 0, 0 }, + .{ "hang-exit-closed", "", "Timeout", 0, 0, 0 }, + .{ "exit", "", "TestRunnerFailed", 2, 0, 0 }, + }; + inline for (cases) |case| { + try init.environ_map.put("PARDES_PROFILE_FIXTURE", case[0]); + const result = try std.process.run(gpa, io, .{ + .argv = &.{ args[1], self, case[1], if (std.mem.eql(u8, case[2], "Timeout")) "--timeout-ms=150" else "--timeout-ms=1500" }, + .environ_map = init.environ_map, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(5) } }, + }); + errdefer std.debug.print("profile fixture {s}: {s}\n{s}\n", .{ case[0], result.stdout, result.stderr }); + const success = result.term == .exited and result.term.exited == 0; + try expect(success == (case[2].len == 0)); + if (case[2].len != 0) try expect(std.mem.indexOf(u8, result.stderr, "error: " ++ case[2]) != null); + if (std.mem.eql(u8, case[2], "Timeout")) { + const phase = if (std.mem.eql(u8, case[0], "hang-version")) + "fixture reached startup" + else if (std.mem.eql(u8, case[0], "hang-metadata")) + "fixture reached metadata" + else if (std.mem.startsWith(u8, case[0], "hang-exit")) + "fixture reached exit" + else + "running fixture.one"; + try expect(std.mem.indexOf(u8, result.stderr, phase) != null); + } + var lines = std.mem.tokenizeScalar(u8, result.stdout, '\n'); + var sum: i128 = 0; + var count: usize = 0; + var summary = false; + while (lines.next()) |line| { + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, line, .{}); + const object = parsed.value.object; + if (std.mem.eql(u8, object.get("kind").?.string, "case")) { + const elapsed = object.get("request_to_result_ns").?.integer; + try expect(elapsed > 0); + if (std.mem.eql(u8, case[0], "delayed-start")) try expect(elapsed >= 60 * std.time.ns_per_ms); + sum += elapsed; + count += 1; + } else { + try expect(!summary); + summary = true; + try expect(object.get("passed").?.integer == case[3]); + try expect(object.get("failed").?.integer == case[4]); + try expect(object.get("skipped").?.integer == case[5]); + try expect(object.get("request_to_result_ns").?.integer == sum); + try expect(object.get("process_wall_ns").?.integer >= sum); + } + } + if (std.mem.startsWith(u8, case[0], "hang-exit")) { + try expect(count == 2 and !summary); + } else if (case[3] + case[4] + case[5] != 0 or std.mem.eql(u8, case[2], "NoMatchingTests")) { + try expect(summary); + try expect(count == case[3] + case[4] + case[5]); + } else try expect(!summary); + } + var random: [12]u8 = undefined; + io.random(&random); + const path = try std.fmt.allocPrint(gpa, "profile-{x}", .{random}); + var parent = try std.Io.Dir.cwd().createDirPathOpen(io, ".zig-cache/tmp", .{}); + defer parent.close(io); + try parent.createDir(io, path, .default_dir); + defer parent.deleteTree(io, path) catch {}; + var dir = try parent.openDir(io, path, .{}); + defer dir.close(io); + try dir.symLink(io, self, "empty", .{}); + try dir.symLink(io, self, "zero", .{}); + const absolute = try dir.realPathFileAlloc(io, ".", gpa); + const empty = try std.fmt.allocPrint(gpa, "{s}/empty", .{absolute}); + const zero = try std.fmt.allocPrint(gpa, "{s}/zero", .{absolute}); + try init.environ_map.put("PARDES_PROFILE_FIXTURE", "check"); + const checks = [_]struct { filter: []const u8, binaries: []const []const u8, passes: bool }{ + .{ .filter = "fixture.one", .binaries = &.{self}, .passes = true }, + .{ .filter = "fixture.one", .binaries = &.{ empty, self }, .passes = true }, + .{ .filter = "fixture.one", .binaries = &.{ zero, self }, .passes = true }, + .{ .filter = "named.test_0", .binaries = &.{self}, .passes = true }, + .{ .filter = "absent", .binaries = &.{self}, .passes = false }, + .{ .filter = "", .binaries = &.{empty}, .passes = false }, + .{ .filter = "", .binaries = &.{zero}, .passes = false }, + .{ .filter = "test_", .binaries = &.{empty}, .passes = false }, + }; + for (checks) |case| { + var command: std.ArrayList([]const u8) = .empty; + try command.appendSlice(gpa, &.{ args[1], "--check-filter", case.filter }); + try command.appendSlice(gpa, case.binaries); + const result = try std.process.run(gpa, io, .{ + .argv = command.items, + .environ_map = init.environ_map, + .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(5) } }, + }); + errdefer std.debug.print("filter fixture '{s}': {s}\n{s}\n", .{ case.filter, result.stdout, result.stderr }); + try expect((result.term == .exited and result.term.exited == 0) == case.passes); + try expect(result.stdout.len == 0); + if (!case.passes) try expect(std.mem.indexOf(u8, result.stderr, "error: NoMatchingTests") != null); + } + std.debug.print("unit-profile: {d} protocol, timing and filter fixtures passed\n", .{cases.len + checks.len}); +} + +fn stall(io: std.Io) !void { + try io.sleep(.fromSeconds(2), .awake); + return error.FixtureWasNotTerminated; +} + +fn fixture(init: std.process.Init) !void { + const io = init.io; + const mode = init.environ_map.get("PARDES_PROFILE_FIXTURE") orelse return error.ExpectedFixtureMode; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const empty = std.mem.eql(u8, std.fs.path.basename(args[0]), "empty"); + const zero = std.mem.eql(u8, std.fs.path.basename(args[0]), "zero"); + if (std.mem.eql(u8, mode, "hang-version")) { + std.debug.print("fixture reached startup\n", .{}); + return stall(io); + } + var read_buffer: [1024]u8 = undefined; + var write_buffer: [1024]u8 = undefined; + var reader = std.Io.File.stdin().readerStreaming(io, &read_buffer); + var writer = std.Io.File.stdout().writerStreaming(io, &write_buffer); + var server = try std.zig.Server.init(.{ + .in = &reader.interface, + .out = &writer.interface, + .zig_version = if (std.mem.eql(u8, mode, "version")) "wrong-version" else builtin.zig_version_string, + }); + while (true) { + const request = try server.receiveMessage(); + switch (request.tag) { + .query_test_metadata => { + if (std.mem.eql(u8, mode, "hang-metadata")) { + std.debug.print("fixture reached metadata\n", .{}); + return stall(io); + } + if (std.mem.eql(u8, mode, "metadata")) { + try server.serveStringMessage(.test_metadata, "bad"); + continue; + } + try server.serveTestMetadata(.{ + .names = if (zero) &.{} else if (empty) &.{ 1, 16 } else &.{ 1, 13 }, + .expected_panic_msgs = if (zero) &.{} else &.{ 0, 0 }, + .string_bytes = if (zero) + "\x00" + else if (empty) + "\x00fixture.test_0\x00fixture.test_11\x00" + else if (std.mem.eql(u8, mode, "check")) + "\x00fixture.one\x00fixture.test.named.test_0\x00" + else + "\x00fixture.one\x00fixture.two\x00", + }); + }, + .run_test => { + if (std.mem.eql(u8, mode, "check")) return error.TestWasRunDuringMatchValidation; + const index = try server.receiveBody_u32(); + if (std.mem.eql(u8, mode, "hang-start")) return stall(io); + if (std.mem.eql(u8, mode, "delayed-start")) try io.sleep(.fromMilliseconds(70), .awake); + if (std.mem.eql(u8, mode, "split-deadline")) try io.sleep(.fromMilliseconds(100), .awake); + try server.serveStringMessage(.test_started, ""); + if (std.mem.eql(u8, mode, "hang-result")) return stall(io); + if (std.mem.eql(u8, mode, "split-deadline")) try io.sleep(.fromMilliseconds(100), .awake); + if (std.mem.eql(u8, mode, "oversized")) { + try server.serveMessageHeader(.{ .tag = .test_results, .bytes_len = 16 * 1024 * 1024 + 1 }); + try writer.interface.flush(); + return stall(io); + } + if (std.mem.eql(u8, mode, "truncated") or std.mem.eql(u8, mode, "hang-body")) { + try server.serveMessageHeader(.{ .tag = .test_results, .bytes_len = 12 }); + try writer.interface.writeAll("oops"); + try writer.interface.flush(); + if (std.mem.eql(u8, mode, "hang-body")) return stall(io); + return; + } + if (std.mem.eql(u8, mode, "status")) { + var bytes: [12]u8 = @splat(0); + bytes[4] = 3; + try server.serveStringMessage(.test_results, &bytes); + continue; + } + var flags: std.zig.Server.Message.TestResults.Flags = .{ + .status = .pass, + .fuzz = false, + .log_err_count = 0, + .leak_count = 0, + }; + if (index == 0) { + if (std.mem.eql(u8, mode, "fail")) flags.status = .fail; + if (std.mem.eql(u8, mode, "skip")) flags.status = .skip; + if (std.mem.eql(u8, mode, "log")) flags.log_err_count = 1; + if (std.mem.eql(u8, mode, "leak")) flags.leak_count = 1; + } + try server.serveTestResults(.{ .index = if (std.mem.eql(u8, mode, "index")) index + 1 else index, .flags = flags }); + }, + .exit => { + if (std.mem.startsWith(u8, mode, "hang-exit")) std.debug.print("fixture reached exit\n", .{}); + if (std.mem.eql(u8, mode, "hang-exit-closed")) { + std.Io.File.stdout().close(io); + return stall(io); + } + if (std.mem.eql(u8, mode, "hang-exit")) return stall(io); + if (std.mem.eql(u8, mode, "exit")) std.process.exit(7); + return; + }, + else => return error.UnexpectedRequest, + } + } +} diff --git a/test/web-snapshots/README.md b/test/web-snapshots/README.md index 3b121949..a7cd865e 100644 --- a/test/web-snapshots/README.md +++ b/test/web-snapshots/README.md @@ -1,25 +1,7 @@ -# Web browser snapshots +# Browser interaction tests -`source-list.dump.zon` is a byte-for-byte dump from a running Pardes instance. -It is not assembled or normalized by a fixture generator. - -To recapture it: - -1. Build and launch the native TTY in this checkout with `PARDES_DUMP` pointing - at a temporary file. -2. Enter raw TTY mode, run `clear`, then run - `git ls-files --cached --others --exclude-standard -- '*.zig' | sort`. - This is the archive generator's tracked-plus-new/nonignored selection in its - deterministic display order. -3. Leave raw TTY mode, press `gg` so the listing starts at scrollback row zero, - and middle-click Pardes's `Dump` builtin. -4. Copy the resulting dump into `source-list.dump.zon` without editing it. - -The visible `.body` is the first terminal viewport; `.terminal.stream` must -contain the command plus every path reported by Git, and every listed path must -exist. The base64 PTY stream is the real interaction and must contain the -command itself. `source-list.snap` then drives the freestanding WASM + DOM build -through Chromium: top and bottom launcher snapshots jointly cover the complete -listing, then it taps `build.zig`, snapshots packed cell styles (including -Tree-sitter's Zig highlighting), resizes the pane separator, and finally -verifies body scrolling. +`zig build web-snap` loads `touch.dump.zon` and runs `touch.mjs` in Chromium. +The fixed source fixture exercises complete keyword styles, touch Look through +the self filesystem, separator resizing, and scrolling without another Look. +Assertions locate current pane geometry; no live project source or author paths +are captured in goldens. diff --git a/test/web-snapshots/source-list.dump.zon b/test/web-snapshots/source-list.dump.zon deleted file mode 100644 index 63d7d67d..00000000 --- a/test/web-snapshots/source-list.dump.zon +++ /dev/null @@ -1,27 +0,0 @@ -.{ - .magic = "pardes-dump", - .version = 1, - .screen = .{ .cols = 100, .rows = 30 }, - .active = 0, - .topbar = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kill", - .theme = "helix", - .columns = .{.{ .weight = 1, .panes = .{0} }}, - .panes = .{.{ - .kind = .terminal, - .tag = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code New Del", - .body = "git ls-files --cached --others --exclude-standard -- '*.zig' | sort\nbuild.zig\nbuild/snap.zig\nmupdf.zig\nsrc/allocators.zig\nsrc/animation.zig\nsrc/builtins.zig\nsrc/config.zig\nsrc/dump.zig\nsrc/effect_sources.zig\nsrc/file_pane.zig\nsrc/fonts.zig\nsrc/grammar_manifest.zig\nsrc/gui/crt.zig\nsrc/gui/deck.zig\nsrc/gui/gui.zig\nsrc/image.zig\nsrc/image_pane.zig\nsrc/look.zig\nsrc/lsp/lsp.zig\nsrc/lsp/lsp_zls.zig\nsrc/macos.zig\nsrc/main.zig\nsrc/message.zig\nsrc/modal.zig\nsrc/nested.zig\nsrc/normal_input.zig\nsrc/output_pane.zig", - .scroll = 0, - .cols = 98, - .rows = 28, - .vweight = 1, - .tag_tail = null, - .terminal = .{ - .cwd = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code", - .stream = "git ls-files --cached --others --exclude-standard -- '*.zig' | sort\nbuild.zig\nbuild/snap.zig\nmupdf.zig\nsrc/allocators.zig\nsrc/animation.zig\nsrc/builtins.zig\nsrc/config.zig\nsrc/dump.zig\nsrc/effect_sources.zig\nsrc/file_pane.zig\nsrc/fonts.zig\nsrc/grammar_manifest.zig\nsrc/gui/crt.zig\nsrc/gui/deck.zig\nsrc/gui/gui.zig\nsrc/image.zig\nsrc/image_pane.zig\nsrc/look.zig\nsrc/lsp/lsp.zig\nsrc/lsp/lsp_zls.zig\nsrc/macos.zig\nsrc/main.zig\nsrc/message.zig\nsrc/modal.zig\nsrc/nested.zig\nsrc/normal_input.zig\nsrc/output_pane.zig\nsrc/panel_animation.zig\nsrc/pardes.zig\nsrc/pdf.zig\nsrc/pdf_pane.zig\nsrc/pdf_pane_integration_test.zig\nsrc/petscii.zig\nsrc/runtime_config.zig\nsrc/selection_pipe.zig\nsrc/shell_bin.zig\nsrc/syntax.zig\nsrc/temp_file.zig\nsrc/term_pane.zig\nsrc/themes/acme.zig\nsrc/themes/dark.zig\nsrc/themes/helix.zig\nsrc/tracy.zig\nsrc/tty/panel_compositor.zig\nsrc/tty/tty.zig\nsrc/user_config.zig\nsrc/web.zig\nsrc/web/zstbi.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/image_harness.zig\ntest/lspbench.zig\ntest/lspfixture/broken.zig\ntest/lspfixture/dotcomplete.zig\ntest/lspfixture/dothalf.zig\ntest/pdf_bench.zig\ntest/pdf_scroll_bench.zig\ntest/pdf_sections_bench.zig\ntest/perf.zig\ntest/snapshot.zig\ntools/embed_zig_sources.zig\ntools/gen_themes.zig\nuucode_config.zig\n", - .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9zb3VyY2UtbGlzdC1jYXB0dXJlL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2NkIC9ob21lL2dvYmxpbi8wMC1wcm9qZWN0cy8weDQyMDAuY2FmZS8wMi1wYXJkZXMtY29kZQ0KG1s/MjAwNGwNG10xMzM7QwcbXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCBxtbS2NsZWFyDQobWz8yMDA0bA0bXTEzMztDBxtbSBtbMkobWzNKG10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgdnaXQgbHMtZmlsZXMgLS1jYWNoZWQgLS1vdGhlcnMgLS1leGNsdWRlLXN0YW5kYXJkIC0tICcqLnppZycgfCBzb3J0DQobWz8yMDA0bA0bXTEzMztDBxtdMTMzO0MHYnVpbGQuemlnDQpidWlsZC9zbmFwLnppZw0KbXVwZGYuemlnDQpzcmMvYWxsb2NhdG9ycy56aWcNCnNyYy9hbmltYXRpb24uemlnDQpzcmMvYnVpbHRpbnMuemlnDQpzcmMvY29uZmlnLnppZw0Kc3JjL2R1bXAuemlnDQpzcmMvZWZmZWN0X3NvdXJjZXMuemlnDQpzcmMvZmlsZV9wYW5lLnppZw0Kc3JjL2ZvbnRzLnppZw0Kc3JjL2dyYW1tYXJfbWFuaWZlc3QuemlnDQpzcmMvZ3VpL2NydC56aWcNCnNyYy9ndWkvZGVjay56aWcNCnNyYy9ndWkvZ3VpLnppZw0Kc3JjL2ltYWdlLnppZw0Kc3JjL2ltYWdlX3BhbmUuemlnDQpzcmMvbG9vay56aWcNCnNyYy9sc3AvbHNwLnppZw0Kc3JjL2xzcC9sc3BfemxzLnppZw0Kc3JjL21hY29zLnppZw0Kc3JjL21haW4uemlnDQpzcmMvbWVzc2FnZS56aWcNCnNyYy9tb2RhbC56aWcNCnNyYy9uZXN0ZWQuemlnDQpzcmMvbm9ybWFsX2lucHV0LnppZw0Kc3JjL291dHB1dF9wYW5lLnppZw0Kc3JjL3BhbmVsX2FuaW1hdGlvbi56aWcNCnNyYy9wYXJkZXMuemlnDQpzcmMvcGRmLnppZw0Kc3JjL3BkZl9wYW5lLnppZw0Kc3JjL3BkZl9wYW5lX2ludGVncmF0aW9uX3Rlc3QuemlnDQpzcmMvcGV0c2NpaS56aWcNCnNyYy9ydW50aW1lX2NvbmZpZy56aWcNCnNyYy9zZWxlY3Rpb25fcGlwZS56aWcNCnNyYy9zaGVsbF9iaW4uemlnDQpzcmMvc3ludGF4LnppZw0Kc3JjL3RlbXBfZmlsZS56aWcNCnNyYy90ZXJtX3BhbmUuemlnDQpzcmMvdGhlbWVzL2FjbWUuemlnDQpzcmMvdGhlbWVzL2RhcmsuemlnDQpzcmMvdGhlbWVzL2hlbGl4LnppZw0Kc3JjL3RyYWN5LnppZw0Kc3JjL3R0eS9wYW5lbF9jb21wb3NpdG9yLnppZw0Kc3JjL3R0eS90dHkuemlnDQpzcmMvdXNlcl9jb25maWcuemlnDQpzcmMvd2ViLnppZw0Kc3JjL3dlYi96c3RiaS56aWcNCnRlc3QvZGVjay9yZXBsYXkuemlnDQp0ZXN0L2RlY2svc2NoZW1hLnppZw0KdGVzdC9lMmVfaGFybmVzcy56aWcNCnRlc3QvaHhkaWZmLnppZw0KdGVzdC9pbWFnZV9oYXJuZXNzLnppZw0KdGVzdC9sc3BiZW5jaC56aWcNCnRlc3QvbHNwZml4dHVyZS9icm9rZW4uemlnDQp0ZXN0L2xzcGZpeHR1cmUvZG90Y29tcGxldGUuemlnDQp0ZXN0L2xzcGZpeHR1cmUvZG90aGFsZi56aWcNCnRlc3QvcGRmX2JlbmNoLnppZw0KdGVzdC9wZGZfc2Nyb2xsX2JlbmNoLnppZw0KdGVzdC9wZGZfc2VjdGlvbnNfYmVuY2guemlnDQp0ZXN0L3BlcmYuemlnDQp0ZXN0L3NuYXBzaG90LnppZw0KdG9vbHMvZW1iZWRfemlnX3NvdXJjZXMuemlnDQp0b29scy9nZW5fdGhlbWVzLnppZw0KdXVjb2RlX2NvbmZpZy56aWcNChtdMTMzO0MHG10xMzM7RAcbXTEzMztDBxtdMDtnb2JsaW5AcGFyZGVzOi9ob21lL2dvYmxpbi8wMC1wcm9qZWN0cy8weDQyMDAuY2FmZS8wMi1wYXJkZXMtY29kZQcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHG1tL", - .cursor = .{ .col = 2, .row = 27 }, - }, - .file = null, - .image = null, - }}, -} diff --git a/test/web-snapshots/source-list.golden b/test/web-snapshots/source-list.golden deleted file mode 100644 index 12af692c..00000000 --- a/test/web-snapshots/source-list.golden +++ /dev/null @@ -1,228 +0,0 @@ -== snap launcher-bottom grid=120x37 cursor=4,36 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -| /home/goblin/00-projects/0x4200.cafe/02-pardes-code Save New Newtty Del Filter -| src/pdf_pane_integration_test.zig -| src/petscii.zig -| src/runtime_config.zig -| src/selection_pipe.zig -| src/shell_bin.zig -| src/syntax.zig -| src/temp_file.zig -| src/term_pane.zig -| src/themes/acme.zig -| src/themes/dark.zig -| src/themes/helix.zig -| src/tracy.zig -| src/tty/panel_compositor.zig -| src/tty/tty.zig -| src/user_config.zig -| src/web.zig -| src/web/zstbi.zig -| test/deck/replay.zig -| test/deck/schema.zig -| test/e2e_harness.zig -| test/hxdiff.zig -| test/image_harness.zig -| test/lspbench.zig -| test/lspfixture/broken.zig -| test/lspfixture/dotcomplete.zig -| test/lspfixture/dothalf.zig -| test/pdf_bench.zig -| test/pdf_scroll_bench.zig -| test/pdf_sections_bench.zig -| test/perf.zig -| test/snapshot.zig -| tools/embed_zig_sources.zig -| tools/gen_themes.zig -| uucode_config.zig -| -== snap launcher-top grid=120x37 cursor=4,36 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -| /home/goblin/00-projects/0x4200.cafe/02-pardes-code Save New Newtty Del Filter -| git ls-files --cached --others --exclude-standard -- '*.zig' | sort -| build.zig -| build/snap.zig -| mupdf.zig -| src/allocators.zig -| src/animation.zig -| src/builtins.zig -| src/config.zig -| src/dump.zig -| src/effect_sources.zig -| src/file_pane.zig -| src/fonts.zig -| src/grammar_manifest.zig -| src/gui/crt.zig -| src/gui/deck.zig -| src/gui/gui.zig -| src/image.zig -| src/image_pane.zig -| src/look.zig -| src/lsp/lsp.zig -| src/lsp/lsp_zls.zig -| src/macos.zig -| src/main.zig -| src/message.zig -| src/modal.zig -| src/nested.zig -| src/normal_input.zig -| src/output_pane.zig -| src/panel_animation.zig -| src/pardes.zig -| src/pdf.zig -| src/pdf_pane.zig -| src/pdf_pane_integration_test.zig -| src/petscii.zig -| src/runtime_config.zig -== snap source-open grid=120x37 cursor=7,2 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -| build.zig Save New Newtty Del -| 1 const std = @import("std"); -| 2 const builtin = @import("builtin"); -| 3 const mupdf_build = @import("mupdf.zig"); -| 4 const snap_build = @import("build/snap.zig"); -| 5 const grammar_manifest = @import("src/grammar_manifest.zig"); -| 6 /// The package manifest, imported UNTYPED on purpose — see the `version` -| 7 /// option in `shellOptions` for why that word is load-bearing. -| 8 const zon = @import("build.zig.zon"); -| 9 -| 10 /// `esp32p4` is not a shell in this package at all: it is one freestanding -| 11 /// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware -| 12 /// package links beside its own `_start`. See the esp32p4 branch below. -| 13 pub const Platform = enum { tty, gui, web, macos, esp32p4 }; -| 14 -| 15 /// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things -| 16 /// have to agree about it or the bundle is a lie: the target this build gives -| 17 /// the static library, the `-target` the app's swiftc link is given (which is -| /home/goblin/00-projects/0x4200.cafe/02-pardes-code Save New Newtty Del Filter -| git ls-files --cached --others --exclude-standard -- '*.zig' | sort -| build.zig -| build/snap.zig -| mupdf.zig -| src/allocators.zig -| src/animation.zig -| src/builtins.zig -| src/config.zig -| src/dump.zig -| src/effect_sources.zig -| src/file_pane.zig -| src/fonts.zig -| src/grammar_manifest.zig -| src/gui/crt.zig -| src/gui/deck.zig -| src/gui/gui.zig -| src/image.zig -== style source-open -|0: 0-74 #949494,#121212, 75-119 d,#121212, -|1: 0-1 #ffffff,#626262, 2-99 #949494,#121212, 100-119 d,#121212, -|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #949494,#121212, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-31 #e3c78a,#080808, 32-33 #bdbdbd,#080808, 34-119 d,#080808, -|3: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-29 #cf87e8,#080808,b 30-30 #bdbdbd,#080808, 31-39 #e3c78a,#080808, 40-41 #bdbdbd,#080808, 42-119 d,#080808, -|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-26 #bdbdbd,#080808, 27-33 #cf87e8,#080808,b 34-34 #bdbdbd,#080808, 35-45 #e3c78a,#080808, 46-47 #bdbdbd,#080808, 48-119 d,#080808, -|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-32 #cf87e8,#080808,b 33-33 #bdbdbd,#080808, 34-49 #e3c78a,#080808, 50-51 #bdbdbd,#080808, 52-119 d,#080808, -|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-31 #bdbdbd,#080808, 32-38 #cf87e8,#080808,b 39-39 #bdbdbd,#080808, 40-65 #e3c78a,#080808, 66-67 #bdbdbd,#080808, 68-119 d,#080808, -|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-79 #ffffff,#080808,b 80-119 d,#080808, -|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-69 #ffffff,#080808,b 70-119 d,#080808, -|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-18 #bdbdbd,#080808, 19-25 #cf87e8,#080808,b 26-26 #bdbdbd,#080808, 27-41 #e3c78a,#080808, 42-43 #bdbdbd,#080808, 44-119 d,#080808, -|10: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-119 d,#080808, -|11: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-81 #ffffff,#080808,b 82-119 d,#080808, -|12: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-80 #ffffff,#080808,b 81-119 d,#080808, -|13: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-78 #ffffff,#080808,b 79-119 d,#080808, -|14: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-27 #bdbdbd,#080808, 28-31 #cf87e8,#080808,b 32-66 #bdbdbd,#080808, 67-119 d,#080808, -|15: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-119 d,#080808, -|16: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-82 #ffffff,#080808,b 83-119 d,#080808, -|17: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-84 #ffffff,#080808,b 85-119 d,#080808, -|18: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-84 #ffffff,#080808,b 85-119 d,#080808, -|19: 0-1 #ffffff,#262626, 2-99 #949494,#121212, 100-119 d,#121212, -|20: 0-0 d,#444444, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|21: 0-0 d,#444444, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|22: 0-0 d,#444444, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|23: 0-0 d,#444444, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|24: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|25: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|26: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|27: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|28: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|29: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|30: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|31: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|32: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|33: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|34: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|35: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -|36: 0-0 d,#121212, 1-1 d,#080808, 2-119 #bdbdbd,#080808, -== snap separator-resized grid=120x37 cursor=7,2 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -| build.zig Save New Newtty Del -| 1 const std = @import("std"); -| 2 const builtin = @import("builtin"); -| 3 const mupdf_build = @import("mupdf.zig"); -| 4 const snap_build = @import("build/snap.zig"); -| 5 const grammar_manifest = @import("src/grammar_manifest.zig"); -| 6 /// The package manifest, imported UNTYPED on purpose — see the `version` -| 7 /// option in `shellOptions` for why that word is load-bearing. -| 8 const zon = @import("build.zig.zon"); -| 9 -| 10 /// `esp32p4` is not a shell in this package at all: it is one freestanding -| 11 /// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware -| 12 /// package links beside its own `_start`. See the esp32p4 branch below. -| 13 pub const Platform = enum { tty, gui, web, macos, esp32p4 }; -| 14 -| 15 /// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things -| 16 /// have to agree about it or the bundle is a lie: the target this build gives -| 17 /// the static library, the `-target` the app's swiftc link is given (which is -| /home/goblin/00-projects/0x4200.cafe/02-pardes-code Save New Newtty Del Filter -| src/allocators.zig -| src/animation.zig -| src/builtins.zig -| src/config.zig -| src/dump.zig -| src/effect_sources.zig -| src/file_pane.zig -| src/fonts.zig -| src/grammar_manifest.zig -| src/gui/crt.zig -| src/gui/deck.zig -| src/gui/gui.zig -| src/image.zig -| src/image_pane.zig -| src/look.zig -| src/lsp/lsp.zig -| src/lsp/lsp_zls.zig -== snap source-scrolled grid=120x37 cursor=7,2 -|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill -| build.zig Save New Newtty Del -| 1 const std = @import("std"); -| 2 const builtin = @import("builtin"); -| 3 const mupdf_build = @import("mupdf.zig"); -| 4 const snap_build = @import("build/snap.zig"); -| 5 const grammar_manifest = @import("src/grammar_manifest.zig"); -| 6 /// The package manifest, imported UNTYPED on purpose — see the `version` -| 7 /// option in `shellOptions` for why that word is load-bearing. -| 8 const zon = @import("build.zig.zon"); -| 9 -| 10 /// `esp32p4` is not a shell in this package at all: it is one freestanding -| 11 /// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware -| 12 /// package links beside its own `_start`. See the esp32p4 branch below. -| 13 pub const Platform = enum { tty, gui, web, macos, esp32p4 }; -| 14 -| 15 /// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things -| 16 /// have to agree about it or the bundle is a lie: the target this build gives -| 17 /// the static library, the `-target` the app's swiftc link is given (which is -| /home/goblin/00-projects/0x4200.cafe/02-pardes-code Save New Newtty Del Filter -| src/fonts.zig -| src/grammar_manifest.zig -| src/gui/crt.zig -| src/gui/deck.zig -| src/gui/gui.zig -| src/image.zig -| src/image_pane.zig -| src/look.zig -| src/lsp/lsp.zig -| src/lsp/lsp_zls.zig -| src/macos.zig -| src/main.zig -| src/message.zig -| src/modal.zig -| src/nested.zig -| src/normal_input.zig -| src/output_pane.zig diff --git a/test/web-snapshots/source-list.snap b/test/web-snapshots/source-list.snap deleted file mode 100644 index 136e583c..00000000 --- a/test/web-snapshots/source-list.snap +++ /dev/null @@ -1,42 +0,0 @@ -# Browser-only .snap commands. The page is built from a dump made by a real -# Pardes TTY after running the same tracked-plus-new/nonignored source selection -# as the web archive generator; touch events below use Chrome's browser path. -wait 10000 uucode_config.zig -stable 100 5000 -snap launcher-bottom - -# Replayed terminal state follows its live cursor at the bottom. Scroll to the -# first half as an actual browser gesture: together these two snapshots cover -# every path instead of silently testing only a stale suffix. The dump command -# is sorted like the archive, leaving build.zig at row 3 once we reach the top. -swipe 4 60 2 60 34 40 -wait 10000 build.zig -stable 100 5000 -snap launcher-top - -# A little sub-slop movement is still a tap. On web one-finger taps are LOOK, -# so this opens build.zig from the working-tree archive generated from Git's -# tracked-plus-new/nonignored source view. -touch down 1 5 3 -touch move 1 5.2 3.1 -touch up 1 5.2 3.1 -# The editable path and the right-aligned command tail deliberately have -# layout-owned padding between them. Check the two semantic facts separately. -wait 10000 Save New Newtty Del -wait 10000 build.zig -stable 100 5000 -snap source-open -snapstyle source-open - -# A touch that begins on the horizontal pane separator is a latched left-mouse -# resize gesture. Crossing the body-scroll threshold must never turn it into a -# wheel gesture. -swipe 2 60 23 60 19 8 -stable 100 5000 -snap separator-resized - -# Travel well outside the tap slop becomes natural one-finger scrolling and -# must not also click/LOOK at the release point. -swipe 3 60 34 60 27 12 -stable 100 5000 -snap source-scrolled diff --git a/test/web-snapshots/touch.dump.zon b/test/web-snapshots/touch.dump.zon new file mode 100644 index 00000000..230d29e7 --- /dev/null +++ b/test/web-snapshots/touch.dump.zon @@ -0,0 +1,73 @@ +.{ + .screen = .{ .cols = 100, .rows = 30 }, + .theme = "helix", + .columns = .{.{ .panes = .{0} }}, + .panes = .{.{ + .kind = .file, + .tag = "touch-fixture.zig", + .body = "", + .file = .{ + .path = "touch-fixture.zig", + .content = + \\pub fn choose(value: u32) []const u8 { + \\ if (value == 42) return "touch fixture"; + \\ return "/virtual/pane/1/body"; + \\} + \\ + \\pub const rows = [_]u32{ + \\ 0, + \\ 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, + \\}; + , + }, + }}, +} diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs new file mode 100644 index 00000000..1a233e68 --- /dev/null +++ b/test/web-snapshots/touch.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; + +function tags(frame) { + return frame.styleRows.flatMap((cells, y) => y > 0 && cells.some((cell) => cell[2] & 128) ? [y] : []); +} + +function pane(frame, name) { + const rows = tags(frame); + const top = rows.find((row) => frame.lines[row].includes(name)); + assert.notEqual(top, undefined, `missing pane ${name}`); + const bottom = rows.find((row) => row > top) ?? frame.rows; + return { top, bottom, lines: frame.lines.slice(top + 1, bottom) }; +} + +function styled(frame) { + const source = pane(frame, "touch-fixture.zig"); + for (const [line, word, fg, bold] of [ + ["pub fn choose", "fn", 0xcf87e8, true], + ["pub fn choose", "choose", 0xbdbdbd, false], + ["if (value", "if", 0xcf87e8, true], + ["if (value", "42", 0xe3c78a, false], + ["if (value", '"touch fixture"', 0xe3c78a, false], + ]) { + const row = source.lines.findIndex((text) => text.includes(line)); + assert(row >= 0, `missing source line ${line}`); + const col = source.lines[row].indexOf(word); + for (let x = col; x < col + word.length; x++) { + const style = frame.styleRows[source.top + 1 + row][x]; + assert.equal(style[0], fg, `${word} cell ${x - col} foreground`); + assert.equal(Boolean(style[2] & 1), bold, `${word} cell ${x - col} bold`); + } + } + for (const [row, line] of source.lines.entries()) { + const number = /\b(\d+),/.exec(line); + if (!number) continue; + for (let x = number.index; x < number.index + number[1].length; x++) { + assert.equal(frame.styleRows[source.top + 1 + row][x][0], 0xe3c78a, `row ${row} number foreground`); + } + } +} + +export default async function ({ grid, touch, wait, stable, delay }) { + await stable(100, 5000); + let frame = await grid(); + assert.equal(tags(frame).length, 1); + styled(frame); + const link = "/virtual/pane/1/body"; + const row = frame.lines.findIndex((text) => text.includes(link)); + const col = frame.lines[row].indexOf(link) + 5; + let finger = await touch("down", 1, col, row, null); + finger = await touch("move", 1, col + 0.2, row + 0.1, finger); + await touch("up", 1, col + 0.2, row + 0.1, finger); + await wait(async () => tags(await grid()).length === 2, 5000, "Look opening the reflected body"); + await stable(100, 5000); + frame = await grid(); + styled(frame); + const reflected = pane(frame, link); + assert(reflected.lines.some((text) => text.includes("pub fn choose"))); + const original = pane(frame, "touch-fixture.zig"); + const sharedRows = Math.min(reflected.lines.length, original.lines.length); + assert(sharedRows >= 6); + assert.deepEqual(reflected.lines.slice(0, sharedRows), original.lines.slice(0, sharedRows), "Look must read the source pane body"); + + const center = Math.floor(frame.cols / 2); + const separator = Math.max(original.top, reflected.top) - 1; + const distance = original.top < reflected.top ? 4 : -4; + finger = await touch("down", 2, center, separator, null); + for (let step = 1; step <= 8; step++) { + finger = await touch("move", 2, center, separator + distance * step / 8, finger); + await delay(20); + } + await touch("up", 2, center, separator + distance, finger); + await stable(100, 5000); + frame = await grid(); + assert.equal(tags(frame).length, 2); + const resized = pane(frame, "touch-fixture.zig"); + assert(resized.lines.length >= original.lines.length + 3, "separator drag must resize, not scroll the body"); + assert.equal(resized.lines[0], original.lines[0], "resizing must preserve the source viewport"); + styled(frame); + + const beforeScroll = resized.lines[0]; + const reflectedBeforeScroll = pane(frame, link).lines; + const from = resized.bottom - 3; + const to = Math.max(resized.top + 2, from - 7); + finger = await touch("down", 3, center, from, null); + for (let step = 1; step <= 12; step++) { + finger = await touch("move", 3, center, from + (to - from) * step / 12, finger); + await delay(20); + } + await touch("up", 3, center, to, finger); + await stable(100, 5000); + frame = await grid(); + assert.equal(tags(frame).length, 2, "scroll must not also Look"); + const scrolled = pane(frame, "touch-fixture.zig"); + assert.equal(scrolled.top, resized.top, "body scrolling must not resize"); + assert.notEqual(scrolled.lines[0], beforeScroll, "source must scroll"); + assert(Number(scrolled.lines[0].trim().split(/\s+/)[0]) > 1, "scroll advances source line numbers"); + assert.deepEqual(pane(frame, link).lines, reflectedBeforeScroll, "scrolling the source must leave its neighbor alone"); +} diff --git a/test/web_driver_test.mjs b/test/web_driver_test.mjs new file mode 100644 index 00000000..5484d763 --- /dev/null +++ b/test/web_driver_test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import net from "node:net"; +import test from "node:test"; +import { Cdp, stopChrome } from "./web_snapshot.mjs"; + +class Socket extends EventTarget { + readyState = WebSocket.OPEN; + sent = []; + closes = 0; + + send(data) { + this.sent.push(JSON.parse(data)); + } + + reply(data) { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(data) })); + } + + close() { + this.readyState = WebSocket.CLOSED; + this.closes += 1; + this.dispatchEvent(new Event("close")); + } +} + +test("CDP replies clear deadlines and preserve protocol errors", { timeout: 2_000 }, async () => { + const socket = new Socket(); + const client = new Cdp(socket, [], 20); + try { + const reply = client.send("Runtime.enable"); + socket.reply({ id: socket.sent[0].id, result: { enabled: true } }); + assert.deepEqual(await reply, { enabled: true }); + const error = assert.rejects(client.send("Runtime.bad"), /Runtime.bad: unknown method/); + socket.reply({ id: socket.sent[1].id, error: { message: "unknown method" } }); + await error; + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(client.pending.size, 0); + assert.equal(client.error, null); + assert.equal(socket.closes, 0); + } finally { + client.close(); + } +}); + +test("CDP timeout rejects all pending and future commands", { timeout: 2_000 }, async () => { + const socket = new Socket(); + const client = new Cdp(socket, [], 20); + await Promise.all([ + assert.rejects(client.send("Runtime.evaluate"), /Runtime.evaluate timed out/), + assert.rejects(client.send("Page.enable"), /Runtime.evaluate timed out/), + ]); + await assert.rejects(client.send("Log.enable"), /Runtime.evaluate timed out/); + assert.equal(client.pending.size, 0); + assert.equal(socket.closes, 1); + assert.equal(socket.sent.length, 2); +}); + +for (const event of ["close", "error"]) { + test(`CDP ${event} immediately rejects pending commands`, { timeout: 2_000 }, async () => { + const socket = new Socket(); + const client = new Cdp(socket, [], 10_000); + const pending = Promise.all([ + assert.rejects(client.send("Runtime.evaluate"), /DevTools connection/), + assert.rejects(client.send("Page.enable"), /DevTools connection/), + ]); + socket.dispatchEvent(new Event(event)); + await pending; + assert.equal(client.pending.size, 0); + assert.equal(socket.closes, 1); + }); +} + +test("CDP rejects malformed replies and synchronous send failures", { timeout: 2_000 }, async () => { + const socket = new Socket(); + const client = new Cdp(socket, []); + const pending = assert.rejects(client.send("Runtime.evaluate"), /invalid DevTools message/); + socket.dispatchEvent(new MessageEvent("message", { data: "not json" })); + await pending; + assert.equal(client.pending.size, 0); + assert.equal(socket.closes, 1); + + const broken = new Socket(); + broken.send = () => { throw new Error("broken transport"); }; + const failed = new Cdp(broken, []); + await assert.rejects(failed.send("Page.enable"), /send DevTools command Page.enable/); + assert.equal(failed.pending.size, 0); + assert.equal(broken.closes, 1); +}); + +test("closing CDP cancels commands once", { timeout: 2_000 }, async () => { + const socket = new Socket(); + const client = new Cdp(socket, []); + const pending = assert.rejects(client.send("Runtime.evaluate"), /DevTools client closed/); + client.close(); + client.close(); + await pending; + assert.equal(client.pending.size, 0); + assert.equal(socket.closes, 1); +}); + +test("CDP closes a stalled connection after its deadline", { timeout: 2_000 }, async (t) => { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.resume(); + socket.on("close", () => sockets.delete(socket)); + }); + t.after(async () => { + const closed = new Promise((resolve) => server.close(resolve)); + for (const socket of sockets) socket.destroy(); + await closed; + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + await assert.rejects(Cdp.connect(`ws://127.0.0.1:${server.address().port}`, [], 40), /connection timed out/); + const deadline = Date.now() + 1_000; + while (sockets.size && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(sockets.size, 0, "timed-out connection must close its TCP socket"); +}); + +test("Chrome cleanup escalates and reaps a process ignoring SIGTERM", { timeout: 2_000 }, async (t) => { + const child = spawn(process.execPath, ["--input-type=module", "-e", ` + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1000); + console.log("ready"); + `], { stdio: ["ignore", "pipe", "ignore"] }); + t.after(() => stopChrome(child, 10)); + await once(child.stdout, "data"); + await stopChrome(child, 20); + assert.equal(child.signalCode, "SIGKILL"); + await stopChrome(child, 20); +}); diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs index a1912f15..a9cd8dff 100644 --- a/test/web_snapshot.mjs +++ b/test/web_snapshot.mjs @@ -1,19 +1,6 @@ #!/usr/bin/env node -// Drive the freestanding WASM + DOM build in a real headless Chromium using -// .snap scripts. This is an end-to-end browser test: Chrome creates genuine -// touch events and the JS shell translates them into core events. -// Shared commands retain test/snapshot.zig's spelling, and a capture retains -// its header and row shape — but every capture here is a whole frame, where -// the tty runner emits later captures as row deltas (`|<n>: <row>`): -// wait <ms> <text> | stable <quiet-ms> <timeout-ms> -// snap <label> | snapstyle <label> -// Browser touch additions: -// touch <down|move|up> <id> <col> <row> -// swipe <id> <from-col> <from-row> <to-col> <to-row> <steps> -// -// No npm package is needed: Node's WebSocket connects directly to Chrome's -// DevTools protocol. Set PARDES_CHROME when Chromium is not on PATH or in the -// normal Playwright cache. +// Native touch events through Chromium's DevTools protocol, without npm. +// PARDES_CHROME overrides the Chromium executable. import childProcess from "node:child_process"; import fs from "node:fs"; import fsp from "node:fs/promises"; @@ -21,21 +8,10 @@ import http from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; const VIEWPORT = { width: 1120, height: 720 }; -const args = process.argv.slice(2); -const update = args.includes("--update"); -const positional = args.filter((arg) => arg !== "--update"); -if (positional.length !== 2) { - console.error("usage: node test/web_snapshot.mjs <zig-out/web> <script.snap> [--update]"); - process.exit(2); -} - -const webDir = path.resolve(positional[0]); -const scriptPath = path.resolve(positional[1]); -const goldenPath = scriptPath.replace(/\.snap$/, ".golden"); -if (goldenPath === scriptPath) throw new Error(`not a .snap path: ${scriptPath}`); - +let scriptPath; let server; let chrome; let client; @@ -44,7 +20,21 @@ let chromeStderr = ""; const browserLog = []; async function main() { + const args = process.argv.slice(2); + const update = args.includes("--update"); + const positional = args.filter((arg) => arg !== "--update"); + if (positional.length !== 2) { + console.error("usage: node test/web_snapshot.mjs <zig-out/web> <test.mjs|script.snap> [--update]"); + process.exitCode = 2; + return; + } + const webDir = path.resolve(positional[0]); + scriptPath = path.resolve(positional[1]); + const scenarioModule = scriptPath.endsWith(".mjs"); + const goldenPath = scriptPath.replace(/\.snap$/, ".golden"); try { + if (!scenarioModule && goldenPath === scriptPath) throw new Error(`not a test module or .snap path: ${scriptPath}`); + if (scenarioModule && update) throw new Error("semantic browser tests have no golden to update"); const served = await serveDirectory(webDir); server = served.server; const debugPort = await unusedPort(); @@ -63,6 +53,7 @@ async function main() { `--user-data-dir=${userDataDir}`, "about:blank", ], { stdio: ["ignore", "ignore", "pipe"] }); + chrome.on("error", (error) => { chromeStderr += `\n${error.message}`; }); chrome.stderr.setEncoding("utf8"); chrome.stderr.on("data", (chunk) => { chromeStderr = (chromeStderr + chunk).slice(-16_384); @@ -71,7 +62,7 @@ async function main() { await waitForDevTools(debugPort, chrome); const targetResponse = await fetch( `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(`http://127.0.0.1:${served.port}/index.html`)}`, - { method: "PUT" }, + { method: "PUT", signal: AbortSignal.timeout(10_000) }, ); if (!targetResponse.ok) throw new Error(`create Chrome target: HTTP ${targetResponse.status}`); const target = await targetResponse.json(); @@ -89,6 +80,12 @@ async function main() { await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); await waitForRuntime(20_000); + if (scenarioModule) { + const scenario = await import(pathToFileURL(scriptPath)); + await scenario.default({ grid, touch: dispatchTouch, wait: waitUntil, stable: waitStable, delay }); + console.error(`web test ok: ${path.relative(process.cwd(), scriptPath)}`); + return; + } const output = await runScript(await fsp.readFile(scriptPath, "utf8")); if (update) { await fsp.writeFile(goldenPath, output); @@ -105,19 +102,21 @@ async function main() { } } catch (error) { console.error(error?.stack ?? error); + if (scenarioModule && client) { + const frame = await grid().catch(() => null); + if (frame) console.error(formatSnapshot("failure", frame)); + } if (browserLog.length) console.error(`browser log:\n${browserLog.join("\n")}`); if (chromeStderr.trim()) console.error(`chromium stderr (tail):\n${chromeStderr.trim()}`); process.exitCode = 1; } finally { client?.close(); - if (chrome && chrome.exitCode === null) { - chrome.kill("SIGTERM"); - await Promise.race([ - new Promise((resolve) => chrome.once("exit", resolve)), - delay(2_000), - ]); + if (chrome) await stopChrome(chrome); + if (server) { + const closed = new Promise((resolve) => server.close(resolve)); + server.closeAllConnections(); + await closed; } - if (server) await new Promise((resolve) => server.close(resolve)); if (userDataDir) await fsp.rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } } @@ -282,13 +281,12 @@ function attrsKey(attrs) { } async function waitStable(quiet, timeout) { - let elapsed = 0; + const deadline = Date.now() + timeout; let previous = JSON.stringify(await grid()); - while (elapsed < timeout) { - await delay(quiet); - elapsed += quiet; + while (Date.now() < deadline) { + await delay(Math.min(quiet, deadline - Date.now())); const current = JSON.stringify(await grid()); - if (current === previous) return; + if (current === previous && Date.now() <= deadline) return; previous = current; } throw new Error(`screen did not stabilize within ${timeout}ms`); @@ -312,7 +310,8 @@ async function waitForRuntime(timeout) { tagRect.width === bodyRect.width && tagRect.height === bodyRect.height && parseFloat(getComputedStyle(tagline).fontSize) < parseFloat(getComputedStyle(body).fontSize); })()`); - } catch { + } catch (error) { + if (client.error) throw error; return false; } }, timeout, "Pardes web runtime"); @@ -322,7 +321,8 @@ async function waitUntil(predicate, timeout, description) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { if (await predicate()) return; - if (chrome?.exitCode !== null) throw new Error(`Chromium exited with ${chrome.exitCode}`); + if (chrome && (chrome.exitCode !== null || chrome.signalCode !== null)) + throw new Error(`Chromium exited with ${chrome.exitCode ?? chrome.signalCode}`); await delay(50); } throw new Error(`timed out waiting for ${description}`); @@ -338,51 +338,110 @@ async function evaluate(expression) { return response.result.value; } -class Cdp { - static async connect(url, log) { +export class Cdp { + static async connect(url, log, timeout = 10_000) { const socket = new WebSocket(url); - await new Promise((resolve, reject) => { - socket.addEventListener("open", resolve, { once: true }); - socket.addEventListener("error", () => reject(new Error(`connect DevTools websocket ${url}`)), { once: true }); - }); - return new Cdp(socket, log); + const client = new Cdp(socket, log, timeout); + try { + await new Promise((resolve, reject) => { + const finish = (error) => { + clearTimeout(timer); + socket.removeEventListener("open", opened); + socket.removeEventListener("close", failed); + socket.removeEventListener("error", failed); + if (error) reject(error); else resolve(); + }; + const opened = () => finish(); + const failed = () => finish(client.error ?? new Error(`connect DevTools websocket ${url}`)); + const timer = setTimeout(() => finish(new Error(`DevTools connection timed out after ${timeout}ms`)), timeout); + socket.addEventListener("open", opened, { once: true }); + socket.addEventListener("close", failed, { once: true }); + socket.addEventListener("error", failed, { once: true }); + }); + return client; + } catch (error) { + client.close(); + throw error; + } } - constructor(socket, log) { + constructor(socket, log, timeout = 10_000) { this.socket = socket; - this.log = log; + this.timeout = timeout; this.nextId = 1; this.pending = new Map(); + this.error = null; + socket.addEventListener("close", (event) => this.fail(new Error(`DevTools connection closed (${event.code ?? "unknown"})`))); + socket.addEventListener("error", () => this.fail(new Error("DevTools connection error"))); socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.id) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`)); - else pending.resolve(message.result ?? {}); - return; + if (this.error) return; + try { + const message = JSON.parse(event.data); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`)); + else pending.resolve(message.result ?? {}); + return; + } + if (message.method === "Runtime.exceptionThrown") + log.push(`exception: ${message.params.exceptionDetails.text}`); + else if (message.method === "Runtime.consoleAPICalled") + log.push(`console ${message.params.type}: ${message.params.args.map((arg) => arg.value ?? arg.description).join(" ")}`); + else if (message.method === "Log.entryAdded") + log.push(`${message.params.entry.level}: ${message.params.entry.text}`); + } catch (error) { + this.fail(new Error("invalid DevTools message", { cause: error })); } - if (message.method === "Runtime.exceptionThrown") - log.push(`exception: ${message.params.exceptionDetails.text}`); - else if (message.method === "Runtime.consoleAPICalled") - log.push(`console ${message.params.type}: ${message.params.args.map((arg) => arg.value ?? arg.description).join(" ")}`); - else if (message.method === "Log.entryAdded") - log.push(`${message.params.entry.level}: ${message.params.entry.text}`); }); } send(method, params = {}) { + if (this.error) return Promise.reject(this.error); + if (this.socket.readyState !== WebSocket.OPEN) { + this.fail(new Error("DevTools connection is not open")); + return Promise.reject(this.error); + } const id = this.nextId++; return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject, method }); - this.socket.send(JSON.stringify({ id, method, params })); + const timer = setTimeout(() => this.fail(new Error(`${method} timed out after ${this.timeout}ms`)), this.timeout); + this.pending.set(id, { resolve, reject, method, timer }); + try { + this.socket.send(JSON.stringify({ id, method, params })); + } catch (error) { + this.fail(new Error(`send DevTools command ${method}`, { cause: error })); + } }); } - close() { + fail(error) { + if (this.error) return; + this.error = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); this.socket.close(); } + + close() { + this.fail(new Error("DevTools client closed")); + } +} + +export async function stopChrome(child, timeout = 2_000) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill("SIGKILL"), timeout); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }); } async function serveDirectory(root) { @@ -429,9 +488,10 @@ async function unusedPort() { async function waitForDevTools(port, child) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error(`Chromium exited with ${child.exitCode}`); + if (!child.pid || child.exitCode !== null || child.signalCode !== null) + throw new Error(`Chromium exited with ${child.exitCode ?? child.signalCode}`); try { - const response = await fetch(`http://127.0.0.1:${port}/json/version`); + const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) }); if (response.ok) return; } catch {} await delay(50); @@ -486,4 +546,4 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -await main(); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); |
