#!/usr/bin/env python3 import contextlib import fcntl import json import os import shutil import signal from pathlib import Path import subprocess import struct import sys import tempfile import termios import threading import time from ninep import Client @contextlib.contextmanager def session(binary, root, name, *options, socket_name=None, file=None, tty=False, network=None, launch=None, inherited=None, terminal=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 terminal is not None: # Borrowed handles for input-driving probes, valid inside this context. terminal.update(pid=child.pid, input_fd=master) 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('/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 terminal is not None: terminal.clear() 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 newest(client): """The newest pane's serial: /index is ordered by serial.""" return int(client.read('/index').splitlines()[-1].split()[0]) def new_pane(client, contents): """An open of /pane/new makes a pane; the read names it.""" serial = int(client.read('/pane/new')) if contents: client.write(f'/pane/{serial}/body', contents) return serial def look(client, path, source=None): """A right click on `path` from a pane (a fresh scratch by default).""" serial = source if source is not None else new_pane(client, b'') client.write(f'/pane/{serial}/look', f'{path}\n'.encode()) results = [int(row) for row in client.read('/look').split()] assert len(results) == 1 and results[0] != serial, (path, results) # The scratch it looked from goes: a column holds only so many panes, # each its tag and two rows. if source is None: client.remove(f'/pane/{serial}') return results[0] def execute(client, serial, command): """A middle click on `command` from a pane.""" client.write(f'/pane/{serial}/exec', f'{command}\n'.encode()) def walk_tree(client, path='/', skip=('/log', '/os', '/screen')): """Stat every entry below `path`, as `find` does, without opening anything.""" seen = [] for name in client.list(path): child = (path.rstrip('/') + '/' + name) info = client.stat(child) seen.append((child, info)) if info['dir'] and child not in skip: seen.extend(walk_tree(client, child, skip)) return seen def discovery(binary, embedded=False): with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory: root = Path(directory) with session(binary, root, 'discovery') as (client, address): top = client.list('/') # A set: which files the root holds, not the order a listing gives. assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'col', 'os'}, top assert 'self' not in top and 'cons' not in top and 'new' not in top, top assert ('src' in top) == embedded, (top, embedded) before = client.read('/index') guide = client.read('/README') assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'pane/new' in guide, guide assert client.stat('/README')['length'] == len(guide) # ls/stat/find over the whole tree, without opening, creates nothing. seen = walk_tree(client) names = {path for path, _ in seen} assert {'/index', '/status', '/look', '/exec', '/log', '/pane/1', '/pane/1/body', '/pane/1/name', '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event', '/pane/1/dot', '/pane/1/limit', '/pane/1/dirty', '/pane/1/mark', '/pane/1/scroll', '/pane/1/look', '/pane/1/exec'} <= names, sorted(names) assert '/pane/1/pty' not in names, 'the fixture file pane grew a pty/' assert not any(name.startswith('/self') or name.endswith('/rdsel') or name.endswith('/wrsel') for name in names), sorted(names) for path, info in seen: assert info['mode'] in (0o755, 0o644, 0o666, 0o444, 0o222, 0o555), (path, info) assert info['mtime'] > 0, (path, info) assert client.read('/index') == before, 'browsing created a pane' assert client.stat('/index')['length'] == len(before) > 0 index = client.stat('/index') assert index['mode'] == 0o444 and client.stat('/status')['mode'] == 0o444 assert client.stat('/exec')['mode'] == 0o666 assert client.stat('/pane/1/body')['mode'] == 0o666 and client.stat('/pane')['mode'] == 0o755 # A range file reads the pair it takes; a flag file reads 0 or 1. assert client.stat('/pane/1/addr')['length'] == 24 assert client.stat('/pane/1/limit')['length'] == 0 assert client.read('/pane/1/dirty') == b'0\n' assert client.read('/pane/1/scroll') == b'1\n' fixture = int(before.split()[0]) assert before.split()[1:3] == [b'text', b'0'] and before.rsplit(b' ', 1)[0].endswith(b'/discovery.txt'), before # /status reports the editor; /exec has nothing to report yet. status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) assert int(status['pid']) > 0 and status['version'] and int(status['panes']) >= 1, status assert client.read('/exec') == b'' # An open of /pane/new makes a pane and the read names it; each # open makes another. A stat makes none, which is why new can be # listed at all: ls -l stats every name a listing gave it. # /log answers the recent past and ends; `follow` written to the # same open makes its reads past that wait for new records. log = client.open('/log', 2) frozen = len(client.read_fid(log)) client.rpc(118, struct.pack(') deletes the addressed # range only, so the write after it replaces that range. scratch = new_pane(client, b'hello world\nsecond line\n') client.write(f'/pane/{scratch}/addr', b'#0,#5') client.write(f'/pane/{scratch}/data', b'HOWDY', truncate=True) assert client.read(f'/pane/{scratch}/body') == b'HOWDY world\nsecond line\n' # sam's regexps: ^ at any line; a miss says so and leaves no # address, so the data write after it refuses. client.write(f'/pane/{scratch}/addr', b'/^second/') assert client.read(f'/pane/{scratch}/addr') == b' 12 18 ' for path, data, why in ((f'/pane/{scratch}/addr', b'/nowhere/', 'no match for regexp'), (f'/pane/{scratch}/data', b'LOST', 'no address')): try: client.write(path, data) raise AssertionError(f'{path} took {data!r}') except OSError as refused: assert why in str(refused), refused assert client.read(f'/pane/{scratch}/body') == b'HOWDY world\nsecond line\n' # Through a mount both read as EINVAL; the log says which and why. assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log') client.remove(f'/pane/{scratch}') # A save the host cannot do fails the write that asked for it, # and the log's err record names the path and why. unsaved = new_pane(client, b'unsaved\n') try: client.write(f'/pane/{unsaved}/ctl', b'Save /nonexistent-pardes-dir/x.txt\n') raise AssertionError('a Save into a missing directory succeeded') except OSError as refused: assert 'Save /nonexistent-pardes-dir/x.txt: ' in str(refused), refused assert f'err {unsaved} ctl: Save /nonexistent-pardes-dir/x.txt: '.encode() in client.read('/log'), client.read('/log') client.remove(f'/pane/{unsaved}') # Sixteen connections fill the slots; the next is told why. extra = [Client(address) for _ in range(15)] try: try: Client(address).__exit__() raise AssertionError('a seventeenth connection was served') except OSError as refused: assert 'too many connections' in str(refused), refused finally: for other in extra: other.__exit__() deadline = time.monotonic() + 5 while b'err - 9p: too many connections' not in client.read('/log'): assert time.monotonic() < deadline, client.read('/log') time.sleep(.01) print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock, focus, the ctl split and commands behave') def run_file(binary): """pty/run: a line in, how it ended out, on the same open.""" def run(c, serial, line): fid = c.open(f'/pane/{serial}/pty/run', 2) try: c.rpc(118, struct.pack('= (3, 13))'], capture_output=True, text=True).stdout.strip() if version != 'True': return with tempfile.TemporaryDirectory(prefix='pardes-repl-') as directory: with session(binary, Path(directory), 'repl') as (client, _): def panes(client): return {int(row.split()[0]) for row in client.read('/index').splitlines()} before = panes(client) client.write('/pane/1/ctl', b'Tty\n') deadline = time.monotonic() + 10 while time.monotonic() < deadline and panes(client) == before: time.sleep(.05) tty = max(panes(client) - before) def body(): return client.read(f'/pane/{tty}/body').decode('utf-8', 'replace') def wait_for(text, seconds=15): deadline = time.monotonic() + seconds while time.monotonic() < deadline: if text in body(): return True time.sleep(.1) return False assert wait_for('$') or wait_for('>'), body() client.write(f'/pane/{tty}/pty/data', python.encode() + b' -q\r') assert wait_for('>>>'), body() client.write(f'/pane/{tty}/ctl', b'Repl python\n') # An exec on the REPL's own body goes to the REPL, as its event # record written back does: an empty range acts on its text. fid = client.open(f'/pane/{tty}/event', 2) try: record = b'MX0 0 0 10 print(6*7)\n' client.rpc(118, struct.pack('>>'), (code, body()[-200:]) finally: client.close(fid) def signal_cleans_up(binary): """A detached session told to stop by a signal unlinks its sockets.""" for sig in [signal.SIGTERM, signal.SIGINT, signal.SIGHUP]: with tempfile.TemporaryDirectory(prefix='pardes-sig-') as directory: root = Path(directory) seen = {} with session(binary, root, 'sig', terminal=seen) as (client, address): detached = root / 'pardes-detached-sig.sock' assert address.exists() and detached.exists() os.kill(seen['pid'], sig) deadline = time.monotonic() + 5 while (address.exists() or detached.exists()) and time.monotonic() < deadline: time.sleep(.01) assert not address.exists() and not detached.exists(), (sig, list(root.iterdir())) def cut_writes(binary): """A write bigger than a 9P message comes as several, cut anywhere: each command line runs once, whole, an Edit block once it ends, and a last line with no newline when the open closes. Never a fragment.""" with tempfile.TemporaryDirectory(prefix='pardes-cut-') as directory: with session(binary, Path(directory), 'cut') as (client, _): burst = b''.join(b'Msg burst-%04d\n' % i for i in range(1000)) assert len(burst) > client.msize client.write('/exec', burst) log = client.read('/log').decode() said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'burst-' in line] assert said == ['burst-%04d' % i for i in range(1000)], said[:3] + said[-3:] assert '\nrun ' not in log and 'exit 127' not in log, log[-400:] client.write('/exec', b'Msg no-newline-at-end') assert 'msg ' in client.read('/log').decode().split('no-newline-at-end')[0][-40:] serial = new_pane(client, b'') block = b'Edit ,a\n' + b''.join(b'line %05d of the block\n' % i for i in range(2500)) + b'.\n' assert len(block) > 50000 client.write(f'/pane/{serial}/ctl', block) body = client.read(f'/pane/{serial}/body').decode().splitlines() assert body == ['line %05d of the block' % i for i in range(2500)], (len(body), body[:2]) # A detached session with no frontend is 80x24 until told. screen = client.screen() assert (screen['cols'], screen['rows']) == (80, 24), (screen['cols'], screen['rows']) client.write('/ctl', b'size 132 50\n') screen = client.screen() assert (screen['cols'], screen['rows']) == (132, 50), (screen['cols'], screen['rows']) def test(binary, quic=False): started = time.monotonic() cut_writes(binary) run_file(binary) exit_answers(binary) unattended_messages_expire(binary) python_repl_runs_one_line(binary) signal_cleans_up(binary) 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('/pane/1/body') == b'initial\n' with session(binary, root, 'first') as (client, address): assert {'pane', 'index', 'status', 'look', 'exec', 'log', 'os'} <= set(client.list('/')) assert 'self' not in client.list('/') assert client.read('/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('/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('/screen') before = bytearray(client.read_fid(frozen, count=31)) client.write('/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'/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'/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('/pane/4294967295/body') except OSError: missing = True assert missing assert client.read(body) == b'replaced\nevent\n' if 'src' in client.list('/'): source = look(client, '/virtual/src/pardes.zig') assert b'pub const Pardes' in client.read(f'/pane/{source}/body') (root / 'index').write_bytes(b'OS takes precedence\n') opened = look(client, 'index') assert client.read(f'/pane/{opened}/body') == b'OS takes precedence\n' opened = look(client, '/n/self/index') index = client.read(f'/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'/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/pane/1/body') assert remote.read(f'/pane/{opened}/body') == b'initial\n' opened = look(remote, '/n/peer/pane/' + str(serial) + '/body') assert remote.read(f'/pane/{opened}/body') == b'replaced\nevent\n' remote.write(f'/pane/{opened}/body', b'saved to peer\n', truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\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'/pane/{opened}/body') == large edited = large[:-3] + b'edited\n' remote.write(f'/pane/{opened}/body', edited, truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\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/pane/1/body', source=control) assert remote.read(f'/pane/{opened}/body') == client.read('/pane/1/body') execute(remote, control, 'Mount peer ' + str(own_address)) execute(remote, control, 'Unmount peer') remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\n') assert client.read('/pane/1/body') == b'runtime mounted Save\n' assert remote.read('/pane/1/body') == b'initial\n' roots = look(remote, '/n', source=control) assert b'/n/peer/\n' in remote.read(f'/pane/{roots}/body') remote.write(f'/pane/{opened}/exec', b'Del\n') execute(remote, control, 'Unmount peer') remote.write(f'/pane/{roots}/ctl', b'get\n') assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n' execute(remote, control, 'Mount peer ' + str(own_address)) opened = look(remote, '/n/peer/pane/1/body', source=control) assert remote.read(f'/pane/{opened}/body') == b'initial\n' with session(binary, root, 'mounted-startup', '--mount=peer=' + str(address), file='/n/peer/pane/1/body:1') as (remote, _): assert remote.read('/pane/1/body') == b'runtime mounted Save\n' client.write(f'/pane/{serial}/exec', b'Del\n') assert str(serial) not in client.list('/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('/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('/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('/pane/1/body') held_screen = old.open('/screen') old_ids = {int(row.split()[0]) for row in old.read('/index').splitlines()} assert old.read_fid(held_body) == b'dumped state\n' assert old.read_fid(held_screen, count=1) == b'{' old.write('/pane/1/body', b'changed after dump\n', truncate=True) # Unsaved text is asked about once, as Exit asks; the second # Restore goes ahead, and is answered before the hang-up. try: execute(old, control, 'Restore ' + str(saved)) except OSError as refused: assert 'Modified' in str(refused), refused assert old.read('/pane/1/body') == b'changed after dump\n' execute(old, control, 'Restore ' + str(saved)) # Refused while the connections are cut, or cut already. try: old.read_fid(held_body) except (EOFError, OSError): pass else: raise AssertionError('Restore left an old body fid usable') with Client(address) as restored: # The new log names its panes, then where they came from. log = restored.read('/log') assert b'\nrestore ' + str(saved).encode() + b'\n' in log, log # then which serial each pane had: restored assert (b'\n'.join(b'restored %d %d' % (old, new) for old, new in zip(sorted(old_ids), sorted(int(r.split()[0]) for r in restored.read('/index').splitlines()))) + b'\nrestoredcol ') in log, log restored_ids = [int(row.split()[0]) for row in restored.read('/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'/pane/{file_id}/body') assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/index')) assert restored.read(f'/pane/{control_id}/body') == b'', 'commands ran through exec, not the body' assert restored.screen()['cols'] == 80 opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id) assert restored.read(f'/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() # PARDES_PID is what says "you are inside a pardes"; the socket # and the pane only say how to reach it. Without the pid the child # starts its own session, so this test used to pass only when the # runner itself happened to be running inside one. status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid']) env.pop('PARDES_FORWARD_LOOK', None) 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('/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('/index').splitlines()} if reuse: assert after == before else: opened, = after - before assert client.read(f'/pane/{opened}/body') == expected assert client.read('/pane/1/body') == b'initial\n' if selected is not None: # The selection is dot; reading it takes no command at all. actual = list(map(int, client.read(f'/pane/{opened}/dot').split())) assert actual == selected, (word, actual, selected) addr = client.open('/pane/1/addr', 2) limit = client.open('/pane/1/limit', 2) for fid, text in [(addr, b'#0,#3'), (limit, b'#0,#3'), (addr, b'#0')]: client.rpc(118, struct.pack(' [--quic | --discovery]') test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3)