summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/appearance_gallery.py8
-rw-r--r--test/fs.py223
-rw-r--r--test/gui-goldens.txt4
-rw-r--r--test/output.zig10
-rw-r--r--test/panes.zig70
-rw-r--r--test/snapshots/find.golden16
-rw-r--r--test/snapshots/shellset.golden2
-rw-r--r--test/snapshots/stepgrain.golden32
-rw-r--r--test/snapshots/themesel.golden2
-rw-r--r--test/snapshots/themesel.snap2
10 files changed, 325 insertions, 44 deletions
diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py
index bc9a7570..46b17dd9 100644
--- a/test/appearance_gallery.py
+++ b/test/appearance_gallery.py
@@ -51,7 +51,7 @@ Orchard: crisp woodland color
Dusk: intentionally soft and warm
Theme dusk
-ThemeSel: Pardes themes come first.
+Themes: Pardes themes come first.
'''
@@ -159,16 +159,16 @@ def run(args):
if args.theme_selector:
command(client, terminal, 'Collapse')
before = set(panes(client))
- command(client, right, 'ThemeSel')
+ command(client, right, 'Themes')
selector = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)),
- 'ThemeSel missing')
+ 'Themes missing')
body = client.read(f'/pane/{selector}/body').decode()
native, legacy = body.split('# Legacy and imported themes', 1)
for theme in ('forge_black', 'forge_soft', 'orchard_black'):
require('Theme ' + theme + '\n' in native, 'native contrast variant below legacy heading')
require('Theme helix\n' in legacy, 'legacy theme section missing')
require('Theme orchard' in ''.join(c[0] for c in client.screen()['cells']),
- 'ThemeSel did not open at native first entry')
+ 'Themes did not open at native first entry')
take_frame('theme-selector')
report['native_themes_first'] = True
client.write(f'/pane/{selector}/exec', b'Del\n')
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 [
diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt
index 01bdfc7c..2b364b37 100644
--- a/test/gui-goldens.txt
+++ b/test/gui-goldens.txt
@@ -5,7 +5,7 @@
05-body-selection 96a158adac982f4141526d10956e19828632edc0f6f60d1161d1353e34797118
06-tag-focus-selection 94aff8499a6e6f399761ffdd56369d526fbe65232eb21c88addd12973af611aa
07-prompt-notice 041603b841605b0a9456cbbb9b3e06e219b0a08a58e2d38229e1c81366577a87
-08-wide-glyph-edges 94b0beb7cc2f2a09259f4f7d57999001ef75ed53ea210a65a1510404bd161af9
+08-wide-glyph-edges 4f15245cb2e14f4190940a79c61751bbb6efbf21c1e3ab423fb1469e0466697e
09-collapsed-focus-tint 1d0e6e4b371bc3b2bba96d9177b91f240fa4bef38c62470dc078fa9feed119ac
10-tagbottom f7175dfb338b75c5ac3457c8422dc0e352c46d51b2c52533ce32ef143fcf6c6b
12-multiline-tag ddd7d775a246e91b88f0eff98b6f9bea8aa5d63dbfbe931bd830ccb9086bd5c4
@@ -14,4 +14,4 @@
15-theme-acme-light ca5252f5117ac8d6c679cef917c9f316c9ca311fc7a8dcbac2796b4fc04d3b64
16-debug 591bb7c45b4b9c46cd2458330b4fa3962afc6dac827fa6f5e20e927a72c586e6
17-terminal 38299427891e31de0b5597159501c161474620eb26fd7708a6396f0fd685209e
-18-mid-transition 80cf350f8736fa4b348225a3872572a799ce57c763939796b68e8c839418f8ee
+18-mid-transition 5d3e37d874988b460297e18df9508a77b602d5f675e28983c2c793871d4301f6
diff --git a/test/output.zig b/test/output.zig
index d9d41e28..4332fd13 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -952,13 +952,13 @@ test "N exactly reverses n across output panes and the ring seam" {
try std.testing.expect(saw_first and saw_second);
}
-test "ThemeSel lists the ring in sections and skips headings in both directions" {
+test "Themes lists the ring in sections and skips headings in both directions" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
defer p.deinit();
const source = try p.setTestFile("notes\n");
pardes.colors.setThemeIndex(p, pardes.themes.len - 1);
const original_theme = p.settings.theme;
- try std.testing.expect(p.executeBuiltinLine(0, "ThemeSel"));
+ try std.testing.expect(p.executeBuiltinLine(0, "Themes"));
const tid = source.search_pane orelse return error.MissingThemeList;
const list = p.panes[tid].?;
const content = list.file.?.content;
@@ -997,12 +997,12 @@ test "ThemeSel lists the ring in sections and skips headings in both directions"
for (pardes.themes[0 .. brk + 1]) |theme| {
p.update(.{ .key = .{ .cp = 'n' } });
try std.testing.expectEqual(tid, p.active);
- const selected = selectedOutputText(list) orelse return error.MissingThemeSelection;
+ const selected = selectedOutputText(list) orelse return error.MissingThemesection;
try std.testing.expectEqualStrings(theme.name, selected["Theme ".len..]);
try std.testing.expectEqual(original_theme, p.settings.theme);
}
p.update(.{ .key = .{ .cp = 'N' } });
- const previous = selectedOutputText(list) orelse return error.MissingThemeSelection;
+ const previous = selectedOutputText(list) orelse return error.MissingThemesection;
try std.testing.expectEqualStrings(pardes.themes[brk - 1].name, previous["Theme ".len..]);
p.update(.{ .key = .{ .cp = Key.tab } });
try std.testing.expectEqual(brk - 1, p.settings.theme);
@@ -1017,7 +1017,7 @@ test "n/N selects command outputs by whole row" {
defer p.deinit();
p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
- try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Themes"));
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);
diff --git a/test/panes.zig b/test/panes.zig
index 3d2821aa..c1ae1666 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -1622,7 +1622,8 @@ test "pane filename drafts commit and cancel without changing body or command ta
try std.testing.expectEqualStrings(tail, pardes.tagline.curTail(pane));
while (p.nextEffect()) |effect| try std.testing.expect(std.meta.activeTag(effect) != .save_file);
}
- try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
+ // A name alone is no edit: the pane stays clean.
+ try std.testing.expect(pane.file.?.revision == pane.file.?.saved_revision);
}
test "pane filename grapheme edits and invalid empty commits preserve the buffer" {
@@ -2500,21 +2501,48 @@ test "Save on a scratch does not watch a path the host could not create" {
const id = p.active;
while (p.nextEffect()) |_| {}
p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.write } };
+ const file = &p.panes[id].?.file.?;
+ const scratch_name = try std.testing.allocator.dupe(u8, file.path);
+ defer std.testing.allocator.free(scratch_name);
+ const was_dirty = file.revision != file.saved_revision;
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);
+ // Failed, it changed nothing: still the scratch, its name and dirty
+ // flag as they were, unwatched.
+ try std.testing.expect(file.output != null);
+ try std.testing.expectEqualStrings(scratch_name, file.path);
+ try std.testing.expectEqual(was_dirty, file.revision != file.saved_revision);
try std.testing.expect(!p.fallback.watched[id]);
p.host = .{};
- try std.testing.expect(p.executeBuiltinLine(id, "Save"));
+ try std.testing.expect(p.executeBuiltinLine(id, "Save /new-file.txt"));
while (p.nextEffect()) |effect| p.perform(effect);
- try std.testing.expect(!file.watch_after_save);
+ try std.testing.expect(file.output == null);
+ try std.testing.expectEqualStrings("/new-file.txt", file.path);
try std.testing.expectEqual(file.revision, file.saved_revision);
try std.testing.expect(p.fallback.watched[id]);
}
+test "a failed Save to another name leaves a clean named file clean" {
+ 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.AccessDenied);
+ }
+ };
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("clean\n");
+ while (p.nextEffect()) |_| {}
+ const file = &pane.file.?;
+ try std.testing.expectEqual(file.revision, file.saved_revision);
+ p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.write } };
+ try std.testing.expect(p.executeBuiltinLine(0, "Save /root/x.txt"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expectEqual(file.revision, file.saved_revision);
+ try std.testing.expectEqualStrings("/test.txt", file.path);
+ p.host = .{};
+}
+
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();
@@ -3438,14 +3466,14 @@ const TerminalTests = struct {
.write => return error.GreetedBeforePrompt,
else => {},
};
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A;aid=pardes\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" } });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B;aid=pardes\x07" } });
try std.testing.expect(promptInputReady(pane));
var greeted = false;
while (p.nextEffect()) |effect| switch (effect) {
@@ -3456,6 +3484,30 @@ const TerminalTests = struct {
try std.testing.expect(!pane.greet);
}
+ test "the greeting is dropped, never typed later, once something ran or was typed first" {
+ if (pardes.platform == .web) return;
+ for ([_][]const u8{
+ // A command ran before the first prompt was seen at rest.
+ "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07\x1b]133;C;aid=pardes\x07out\r\n\x1b]133;D;0;aid=pardes\x07\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07",
+ // Someone typed at the first prompt.
+ "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07vim",
+ }) |output| {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {}
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .output = .{ .pane = 0, .bytes = output } });
+ // and a later, quiet prompt changes nothing
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\r\n\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07" } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| if (std.mem.eql(u8, write.bytes.slice(), "ls\r")) return error.GreetedLate,
+ else => {},
+ };
+ 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 });
diff --git a/test/snapshots/find.golden b/test/snapshots/find.golden
index dec8ed1d..a6eae9cf 100644
--- a/test/snapshots/find.golden
+++ b/test/snapshots/find.golden
@@ -75,13 +75,17 @@
|3: 1 Find Find /
|4:
|13: 1 gamma body Last
-== snap nomatch grid=120x41 cursor=7,8
+== snap nomatch grid=120x41 cursor=7,3
|3: 1 Find
+|6: 1
+|7:
+|8:
|13: 1 gamma body
-|18: /tmp/pardes-snap/find/cwd/+Search Save Tty Collapse Del
-|19: 1
== snap refit grid=120x41 cursor=7,3
+|6: 1 sub/ZQMARK-beta.txt
+|7: 2 sub/nest/zqmark-gamma.txt
+|8: 3 zqmark-alpha.txt
== snap topbar grid=120x41 cursor=12,16
-|21: /tmp/pardes-snap/find/cwd/+Search Save Tty Collapse Del
-|22: 1 zqmark-alpha.txt
-|23: 2
+|6: 1 zqmark-alpha.txt
+|7: 2
+|8:
diff --git a/test/snapshots/shellset.golden b/test/snapshots/shellset.golden
index e2830801..db72bfc8 100644
--- a/test/snapshots/shellset.golden
+++ b/test/snapshots/shellset.golden
@@ -4,7 +4,7 @@
| /tmp/pardes-snap/shellset/cwd/cmd.txt Save Tty Collapse Del
| 1 Shell zznosuchshell
== snap builtin-ran grid=90x21 cursor=25,3
-|3: 1 Shel Shell: no shell "zznosuchshell" (a name on the usual paths, or a path to one)
+|3: hell: no shell "zznosuchshell", not found (a name on the usual paths, or a path to one)
== snap fallback-shell grid=90x21 cursor=49,6
|1: New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
|2: /tmp/pardes-snap/shellset/cwd/cmd.txt Save /tmp/pardes-snap/shellset/cwd Tty+bash Sav
diff --git a/test/snapshots/stepgrain.golden b/test/snapshots/stepgrain.golden
index ef3cc49b..7f18d246 100644
--- a/test/snapshots/stepgrain.golden
+++ b/test/snapshots/stepgrain.golden
@@ -51,17 +51,23 @@
|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-19 #f5f7ee,#3c614a, 20-149 d,#0d1410,
== snap step-row3 grid=150x31 cursor=7,10
== snap step-wrap grid=150x31 cursor=7,6
-== snap blank-in-path grid=150x31 cursor=7,13
-|12: /tmp/pardes-snap/stepgrain/cwd/+Search Save Tty Collapse Del
-|13: 1 two words.txt
-|14: 2
+== snap blank-in-path grid=150x31 cursor=7,6
+|5: /tmp/pardes-snap/stepgrain/cwd/+Search Save Tty Collapse Del
+|6: 1 two words.txt
+|7: 2
+|8:
+|9:
+|10:
+|11:
+|12:
== style blank-in-path grid=150x31
-|5: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-33 #b2c3b8,#1d2824, 34-40 #d3b0d9,#1d2824, 41-78 #b2c3b8,#1d2824, 79-149 d,#1d2824,
-|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-18 #f5f7ee,#3c614a, 19-149 d,#0d1410,
-|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-19 #59675c,#0d1410, 20-149 d,#0d1410,
-|11: 0-0 d,#152019, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #f7e4a5,#5e4923, 13-23 #e5eee5,#0d1410, 24-149 d,#0d1410,
-|12: 0-1 #000000,#add38a, 2-2 d,#314738, 3-33 #becfc4,#314738, 34-40 #dfbce5,#314738, 41-62 #becfc4,#314738, 63-149 d,#314738,
-|13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-19 #f5f7ee,#3c614a, 20-149 d,#0d1410,
-== snap blank-opened grid=150x31 cursor=7,16
-|15: /tmp/pardes-snap/stepgrain/cwd/two words.txt Save Tty Collapse Del
-|16: 1
+|5: 0-1 #000000,#add38a, 2-2 d,#314738, 3-33 #becfc4,#314738, 34-40 #dfbce5,#314738, 41-62 #becfc4,#314738, 63-149 d,#314738,
+|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-19 #f5f7ee,#3c614a, 20-149 d,#0d1410,
+|7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-149 d,#0d1410,
+|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-149 d,#0d1410,
+|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-149 d,#0d1410,
+|10: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-149 d,#0d1410,
+|11: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-149 d,#0d1410,
+== snap blank-opened grid=150x31 cursor=7,9
+|8: /tmp/pardes-snap/stepgrain/cwd/two words.txt Save Tty Collapse Del
+|9: 1
diff --git a/test/snapshots/themesel.golden b/test/snapshots/themesel.golden
index 75100f0f..f553fee9 100644
--- a/test/snapshots/themesel.golden
+++ b/test/snapshots/themesel.golden
@@ -31,7 +31,7 @@
| 27 line 27
| 28 line 28
== snap list grid=100x31 cursor=7,3
-|3: 1 line 1 ThemeSel
+|3: 1 line 1 Themes
|16: /tmp/pardes-snap/themesel/cwd/+Themes Save Tty Collapse Del
|17: 1 # Pardes themes
|18: 2 Theme orchard
diff --git a/test/snapshots/themesel.snap b/test/snapshots/themesel.snap
index fa34f030..01e4dc5e 100644
--- a/test/snapshots/themesel.snap
+++ b/test/snapshots/themesel.snap
@@ -1,7 +1,7 @@
# RULE: a results buffer is a list of things you can RUN, and its traits say
# what a ROW IS. Every list before this one held LOCATIONS — a row with a
# `path:LINE:COL` word inside it — so n/N select that word and Enter Looks it.
-# `SPC t t` (ThemeSel) opens the first one whose rows are COMMANDS: `Theme
+# `SPC t t` (Themes) opens the first one whose rows are COMMANDS: `Theme
# <name>`, exactly the line you would type, with no path in it to pick out, so
# n/N select the WHOLE ROW. One motion, two grains, and the same split either
# way — the step only SELECTS, and Tab (Exec, or the middle button, which is