summaryrefslogtreecommitdiff
path: root/test/fs.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/fs.py')
-rw-r--r--test/fs.py223
1 files changed, 221 insertions, 2 deletions
diff --git a/test/fs.py b/test/fs.py
index 89801a71..977b3e45 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -161,7 +161,7 @@ def discovery(binary, embedded=False):
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',
+ assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent',
'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
@@ -387,7 +387,7 @@ def discovery(binary, embedded=False):
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 'Save /nonexistent-pardes-dir/x.txt: no such directory' 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}')
@@ -494,6 +494,222 @@ def unattended_messages_expire(binary):
assert not shown(), 'an unattended message outlived MessageLinger'
+def new_terminals_named_once(binary):
+ """A terminal started outside the session's directory is named by that
+ directory from its `new` on: no rename to the session's and back."""
+ with tempfile.TemporaryDirectory(prefix='pardes-names-') as directory:
+ root = Path(directory)
+ (root / 'sub').mkdir()
+ with session(binary, root, 'names') as (client, _):
+ made = [look(client, str(root / 'sub'))]
+ before = {int(row.split()[0]) for row in client.read('/index').splitlines()}
+ client.write('/pane/1/ctl', b'Tty\n')
+ if shutil.which('fish'):
+ client.write('/pane/1/ctl', b'Tty fish\n')
+ made += [s for s in (int(row.split()[0]) for row in client.read('/index').splitlines()) if s not in before]
+ time.sleep(1.5) # their shells start and say where they are
+ # A shell started again by pty/ctl exec: named as it was.
+ client.write(f'/pane/{made[0]}/pty/ctl', b'exec\n')
+ time.sleep(1.5)
+ log = client.read('/log').decode()
+ for serial in made:
+ assert f'\nrename {serial} ' not in '\n' + log, (serial, log)
+ assert f'\nnew {serial} {root}' in '\n' + log, (serial, log)
+ # A shell that cannot start (its interpreter is not there): Tty
+ # fails the write with why and leaves no pane; pty/ctl exec
+ # fails with why and keeps its terminal.
+ bad = root / 'badsh'
+ bad.write_bytes(b'#!/nonexistent/interp\n')
+ bad.chmod(0o755)
+ panes = client.read('/index')
+ try:
+ client.write('/pane/1/ctl', f'Tty {bad}\n'.encode())
+ raise AssertionError('Tty of a shell that cannot start was taken')
+ except OSError as refused:
+ assert 'shell' in str(refused), refused
+ time.sleep(.3)
+ assert client.read('/index').count(b'\n') == panes.count(b'\n'), client.read('/index')
+ client.write('/ctl', f'Shell {bad}\n'.encode())
+ try:
+ client.write(f'/pane/{made[0]}/pty/ctl', b'exec\n')
+ raise AssertionError('pty/ctl exec of a shell that cannot start was taken')
+ except OSError as refused:
+ assert 'shell' in str(refused), refused
+ client.write('/ctl', b'Shell\n')
+ assert str(made[0]).encode() in b' '.join(r.split()[0] for r in client.read('/index').splitlines())
+
+
+def recent_survives_restart(binary):
+ """A file opened and closed is in /recent closed, and still is in the
+ next session: the list is kept under $XDG_STATE_HOME (here HOME's
+ .local/state) across restarts."""
+ with tempfile.TemporaryDirectory(prefix='pardes-recent-') as directory:
+ root = Path(directory)
+ kept = root / 'kept.txt'
+ kept.write_bytes(b'one\ntwo\n')
+ with session(binary, root, 'recent1') as (client, _):
+ serial = look(client, f'{kept}:2')
+ assert f'open {kept}\n' in client.read('/recent').decode()
+ client.write(f'/pane/{serial}/ctl', b'Del\n')
+ assert f'closed {kept}\n' in client.read('/recent').decode(), client.read('/recent')
+ assert (root / '.local' / 'state' / 'pardes' / 'recent').exists()
+ with session(binary, root, 'recent2') as (client, _):
+ listed = client.read('/recent').decode()
+ assert f'closed {kept}\n' in listed, listed
+ # Recent shows it, and a look at its row opens it again.
+ client.write('/pane/1/exec', b'Recent\n')
+ rows = int(client.read('/pane/1/exec').split()[-1])
+ row = next(r for r in client.read(f'/pane/{rows}/body').decode().splitlines() if r.startswith(f'{kept}:'))
+ client.write(f'/pane/{rows}/look', (row.split(' ')[0] + '\n').encode())
+ assert f'open {kept}\n' in client.read('/recent').decode()
+
+
+def workflow_canary(binary):
+ """One session driven as the dogfood agents drive it, asserting the log
+ and the read-backs at every step: the canary for the main paths (the
+ round-18 Grep that never found anything went through every narrower
+ test). Look files, Grep and Find hits and misses, a plain word looked
+ at, Edit in two files, Save, get, pty/run failing then passing, a
+ command pane and Kill, a REPL send, the layout and a column's files,
+ ask/answer, and Dump/Restore last."""
+ started = time.monotonic()
+ with tempfile.TemporaryDirectory(prefix='pardes-flow-') as directory:
+ root = Path(directory)
+ (root / 'a.txt').write_bytes(b'alpha one\nshared word\n')
+ (root / 'b.txt').write_bytes(b'beta two\nshared word\n')
+ (root / 's.sh').write_bytes(b'echo from-file\n')
+
+ def wait(ready, what, seconds=10):
+ deadline = time.monotonic() + seconds
+ while not ready():
+ assert time.monotonic() < deadline, what
+ time.sleep(.05)
+
+ with session(binary, root, 'flow') as (client, address):
+ def log():
+ return client.read('/log').decode('utf-8', 'replace')
+
+ def made_by(serial, line):
+ """The pane an exec at `serial` made, as exec reads it back."""
+ client.write(f'/pane/{serial}/exec', line)
+ return int(client.read(f'/pane/{serial}/exec').split()[-1])
+
+ # Look opens files, and the log says new.
+ a = look(client, str(root / 'a.txt'))
+ b = look(client, str(root / 'b.txt'))
+ assert client.read(f'/pane/{a}/body') == b'alpha one\nshared word\n'
+ assert f'new {a} {root}/a.txt\n' in log() and f'new {b} {root}/b.txt\n' in log()
+
+ # Grep and Find: a hit opens its +Search, rows and all; a miss
+ # fails ENOENT, opening nothing.
+ grep = made_by(a, b'Grep shared\n')
+ rows = client.read(f'/pane/{grep}/body').decode()
+ assert 'a.txt:2' in rows and 'b.txt:2' in rows, rows
+ client.remove(f'/pane/{grep}')
+ execute_refused(client, a, 'Grep zzqqnomatch', 'grep: no match')
+ found = made_by(a, b'Find b.txt\n')
+ assert 'b.txt' in client.read(f'/pane/{found}/body').decode()
+ client.remove(f'/pane/{found}')
+ execute_refused(client, a, 'Find zzqqnomatch', 'find: no match')
+
+ # A plain word looked at is found in the pane, after its dot.
+ client.write(f'/pane/{a}/dot', b' 0 0 ')
+ client.write(f'/pane/{a}/look', b'shared\n')
+ assert client.read(f'/pane/{a}/dot').split() == [b'10', b'16'], client.read(f'/pane/{a}/dot')
+
+ # Edit in two files, then Save each: on disk, and logged.
+ for pane in (a, b):
+ client.write(f'/pane/{pane}/ctl', b'Edit ,s/shared/SHARED/g\n')
+ assert client.read(f'/pane/{b}/body') == b'beta two\nSHARED word\n'
+ for pane in (a, b):
+ client.write(f'/pane/{pane}/exec', b'Save\n')
+ assert (root / 'a.txt').read_bytes() == b'alpha one\nSHARED word\n'
+ assert f'save {a} {root}/a.txt\n' in log() and f'save {b} {root}/b.txt\n' in log()
+
+ # get reloads what changed on disk.
+ (root / 'b.txt').write_bytes(b'from disk\n')
+ client.write(f'/pane/{b}/ctl', b'get\n')
+ assert client.read(f'/pane/{b}/body') == b'from disk\n'
+
+ # A terminal: pty/run failing then passing.
+ term = made_by(1, b'Tty\n')
+
+ def run(line):
+ fid = client.open(f'/pane/{term}/pty/run', 2)
+ try:
+ client.rpc(118, struct.pack('<IQI', fid, 0, len(line)) + line)
+ return client.read_fid(fid)
+ finally:
+ client.close(fid)
+ deadline = time.monotonic() + 10
+ while (answer := run(b'true\n')) != b'exit 0\n':
+ assert time.monotonic() < deadline, answer
+ time.sleep(.05)
+ assert run(b'false\n') == b'exit 1\n'
+ assert run(b'echo passed\n') == b'exit 0\npassed\n'
+ # (A run's answer is its record: the log has no err for these.)
+ assert f'err {term} pty/run' not in log(), log()
+
+ # A command pane, and Kill: its exit is logged.
+ sleeper = made_by(a, b'sleep 30\n')
+ assert f'run {sleeper} sleep 30\n' in log()
+ client.write('/ctl', b'Kill sleep\n')
+ wait(lambda: f'\nexit {sleeper} ' in log(), 'Kill: no exit logged')
+
+ # A REPL send: a program in the terminal (cat, which says back
+ # what it is sent; a nested shell reads as the shell itself)
+ # bound as bash's REPL, an exec on a .sh file's body (its event
+ # written back) goes there.
+ s = look(client, str(root / 's.sh'))
+ client.write(f'/pane/{term}/pty/data', b'cat\r')
+ wait(lambda: client.read(f'/pane/{term}/pty/status').split()[-1] == b'1', ('the REPL program did not start', client.read(f'/pane/{term}/body')[-300:]))
+ client.write(f'/pane/{term}/ctl', b'Repl bash\n')
+ fid = client.open(f'/pane/{s}/event', 2)
+ try:
+ record = b'MX0 0 0 14 echo from-file\n'
+ client.rpc(118, struct.pack('<IQI', fid, 0, len(record)) + record)
+ finally:
+ client.close(fid)
+ wait(lambda: f'\nsend {s} {term} ' in log(), ('no REPL send logged', log()[-800:]))
+ wait(lambda: client.read(f'/pane/{term}/body').count(b'echo from-file') >= 2, 'the REPL did not get it')
+ client.write(f'/pane/{term}/ctl', b'Repl -\n')
+ client.write(f'/pane/{term}/pty/data', b'\x04')
+ wait(lambda: run(b'true\n') == b'exit 0\n', 'the terminal did not come back to its shell')
+
+ # The layout and a column's files: Newcol, its tag, rmdir.
+ assert len(client.read('/layout').splitlines()) >= 2 # a column, then `active`
+ client.write('/ctl', b'Newcol\n')
+ columns = [row for row in client.read('/layout').splitlines() if not row.startswith(b'active')]
+ assert len(columns) == 2, columns
+ empty = columns[-1].split()[0].decode()
+ assert b'New' in client.read(f'/col/{empty}/tag')
+ client.remove(f'/col/{empty}')
+ assert len([row for row in client.read('/layout').splitlines() if not row.startswith(b'active')]) == 1
+
+ # ask/answer: Save on a terminal asks for a path over 9P.
+ client.write(f'/pane/{term}/exec', b'Save\n')
+ wait(lambda: f'ask {term} save path\n' in log(), 'no ask logged')
+ client.write(f'/pane/{term}/ctl', f'answer {root}/scroll.txt\n'.encode())
+ wait(lambda: (root / 'scroll.txt').exists(), 'the answered Save wrote nothing')
+ assert f'answer {term} {root}/scroll.txt\n' in log()
+
+ # Dump, then Restore it: the new session names its panes.
+ client.write('/ctl', b'Dump\n')
+ saved = root / 'flow.dump.zon'
+ wait(saved.exists, 'Dump wrote nothing')
+ assert f'dump {saved}\n' in log()
+ try:
+ client.write('/ctl', f'Restore {saved}\n'.encode())
+ except (EOFError, OSError):
+ pass
+ time.sleep(.5)
+ with Client(address) as restored:
+ wait(lambda: f'restore {saved}' in restored.read('/log').decode(), 'Restore not logged')
+ names = restored.read('/index').decode()
+ assert f'{root}/a.txt' in names and f'{root}/b.txt' in names, names
+ assert time.monotonic() - started < 30, 'the canary took too long'
+
+
def python_repl_runs_one_line(binary):
"""A one-line exec sent to Python's own REPL (3.13's pyrepl, which asks
for bracketed paste) runs at once, with no second Enter: the Enter
@@ -610,6 +826,9 @@ def test(binary, quic=False):
run_file(binary)
exit_answers(binary)
unattended_messages_expire(binary)
+ new_terminals_named_once(binary)
+ workflow_canary(binary)
+ recent_survives_restart(binary)
python_repl_runs_one_line(binary)
signal_cleans_up(binary)
for options, message in [