#!/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 @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('/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 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 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 discovery(binary): with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory: with session(binary, Path(directory), 'discovery') as (client, _): before = client.read('/self/index') entries = client.list('/self/new') assert {'README', 'ctl', 'body', 'addr', 'data', 'event'} <= set(entries) guide = client.read('/self/README') assert guide == client.read('/self/new/README') assert b'Each open creates a separate pane' in guide # Walk and stat each entry, as a filesystem browser does, without open. from ninep import string for name in entries: client.fid += 1 fid = client.fid client.rpc(110, struct.pack(' [--quic | --discovery]') test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3)