src/tui/wall_test_picker.zig
Ref: Size: 62.9 KiB History
//! The host picker popup (wall_picker.zig).
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const interact = @import("interact.zig");
const layout = @import("client").layout;
const fixture = @import("wall_test_harness.zig");
const wall_host = @import("wall_host.zig");
const wall_picker = @import("wall_picker.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const PickerAuto = wall_picker.PickerAuto;
const PickerBody = wall_picker.PickerBody;
const Shared = wv.Shared;
const Tile = wv.Tile;
const TmpDir = @import("testtmp").TmpDir;
const WallScreen = fixture.WallScreen;
test "paintPicker: the box is centred on the terminal, not pinned to the origin" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
// Off-origin is the BASELINE here: a fixture on a terminal the box
// happens to fill is blind to every centring arithmetic mistake.
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
var table = [_]Host{
fixture.testHost(&shared, "a", "/tmp/a.sock"),
fixture.testHost(&shared, "b", "/tmp/b.sock"),
fixture.testHost(&shared, "c", "/tmp/c.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
var buf: [8192]u8 = undefined;
const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
// Five rows (header + three hosts + footer) on forty: top = (40-5)/2.
// The box is the terminal's width here, so left is 0 and the columns
// are 1-based CUP.
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[18;1H") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[19;1H") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[22;1H") != null);
// ...and nothing above or below the box.
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[17;1H") == null);
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[23;1H") == null);
// The cursor goes with the popup: a caret blinking in a tile says the
// keys are going there.
try std.testing.expect(std.mem.startsWith(u8, frame, "\x1b[?25l"));
}
test "paintPicker: a box wider than its cap is centred in the columns" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
// Wider than `picker_row_max`, so the box is capped and the leftover
// columns are split: (200 - 128) / 2.
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 200, .rows = 10 }, .is_tty = true };
var table = [_]Host{fixture.testHost(&shared, "a", "/tmp/a.sock")};
fixture.setList(&table[0], "");
table[0].applied = true;
var buf: [8192]u8 = undefined;
const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[4;37H") != null);
}
test "paintPicker: a terminal too small for a box still says which popup has the keys" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 20, .rows = 3 }, .is_tty = true };
var table = [_]Host{
fixture.testHost(&shared, "a", "/tmp/a.sock"),
fixture.testHost(&shared, "b", "/tmp/b.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
var buf: [8192]u8 = undefined;
const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
try std.testing.expect(std.mem.indexOf(u8, frame, "hosts") != null);
// Under the minimum the header is the WHOLE popup: no rows, no legend.
// A box that does not fit is worse than a line saying which one it is.
try std.testing.expect(std.mem.indexOf(u8, frame, "Enter/c new session") == null);
try std.testing.expect(std.mem.indexOf(u8, frame, " 1 a") == null);
}
test "paintPicker: the window scrolls so the selected row is always on screen" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
// Six rows for the box: header, footer, and four hosts of the eight.
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 6 }, .is_tty = true };
const names = [_][]const u8{ "h0", "h1", "h2", "h3", "h4", "h5", "h6", "h7" };
var table: [8]Host = undefined;
for (&table, names) |*h, name| {
h.* = fixture.testHost(&shared, name, "/tmp/x.sock");
fixture.setList(h, "");
h.applied = true;
}
var buf: [8192]u8 = undefined;
const top = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
try std.testing.expect(std.mem.indexOf(u8, top, " 1> h0") != null);
try std.testing.expect(std.mem.indexOf(u8, top, "h7") == null);
// The last row: the window has to follow the selection, or `j` walks
// off the bottom of a box that never moves.
var buf2: [8192]u8 = undefined;
const bottom = fixture.pickerFrame(&shared, pipe[0], &table, 7, &buf2);
try std.testing.expect(std.mem.indexOf(u8, bottom, " 8> h7") != null);
try std.testing.expect(std.mem.indexOf(u8, bottom, "h0") == null);
}
test "paintPicker: a frame the stamp refuses to write does not eat the notice with it" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
var table = [_]Host{fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")};
fixture.setList(&table[0], "");
table[0].applied = true;
wall_picker.paintPicker(fixture.hostWall(&shared, &table), 0, .{}, null);
// A notice whose footer is byte-for-byte the legend already on the
// screen: the stamp refuses the write, and taking the notice ahead of
// that check is a sentence consumed by a frame nobody was sent.
wv.setNotice(&shared, " Enter sessions c new session x forget a add Esc");
wall_picker.paintPicker(fixture.hostWall(&shared, &table), 0, .{}, null);
var buf: [96]u8 = undefined;
try std.testing.expectEqualStrings(
" Enter sessions c new session x forget a add Esc",
wv.takeNotice(&shared, &buf),
);
}
test "paintPicker: replayed into an engine, the popup covers its box and NOT one cell more" {
const alloc = std.testing.allocator;
const cols: u16 = 160;
const rows: u16 = 20;
var screen = try WallScreen.init(alloc, cols, rows);
defer screen.deinit();
// Off-origin in BOTH axes: `picker_row_max` caps the width at 128, so
// left = (160-128)/2, and three hosts make a five-row box at
// top = (20-5)/2. A grep over the frame's CUP sequences cannot see a
// paint that reaches a cell by another route — a different CUP form, a
// wider pad — so this one judges the GRID.
const w: u16 = @intCast(wall_picker.picker_row_max);
const left: u16 = (cols - w) / 2;
const height: u16 = 5;
const top: u16 = (rows - height) / 2;
// A background every untouched cell can be recognised by.
var bg: [cols]u8 = undefined;
@memset(&bg, '.');
var r: u16 = 0;
while (r < rows) : (r += 1) {
var cup: [16]u8 = undefined;
screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
screen.eng.feed(&bg);
}
screen.eng.feed("\x1b[H");
var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{}, null);
screen.drain();
const dump = try screen.eng.dumpPlain(alloc);
defer alloc.free(dump);
var i: u16 = 0;
while (i < rows) : (i += 1) {
const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
try std.testing.expectEqual(@as(usize, cols), l.len);
if (i >= top and i < top + height) {
// The rails and every tile beside the box are outside it.
try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
try std.testing.expect(!std.mem.eql(u8, bg[0..w], l[left..][0..w]));
} else {
try std.testing.expectEqualStrings(&bg, l);
}
}
// The selected row is the one the popup says it is, in the grid.
const sel_row = WallScreen.line(dump, top + 2) orelse return error.NoSuchRow;
try std.testing.expect(std.mem.indexOf(u8, sel_row, " 2> ") != null);
}
test "paintPicker: replayed into an engine, a shorter box erases the rows the taller one left" {
const alloc = std.testing.allocator;
const cols: u16 = 160;
const rows: u16 = 20;
var screen = try WallScreen.init(alloc, cols, rows);
defer screen.deinit();
// Off-origin in both axes, the sibling test's reason. FOUR hosts and
// then ONE session: the box goes from six rows to three, so the outer
// rows of the tall box are the ones nothing would repaint — tiles do
// not draw while the popup is up, and the close is what used to be the
// first thing to repair them.
const w: u16 = @intCast(wall_picker.picker_row_max);
const left: u16 = (cols - w) / 2;
const tall: u16 = 6;
const short: u16 = 3;
const tall_top: u16 = (rows - tall) / 2;
const short_top: u16 = (rows - short) / 2;
var bg: [cols]u8 = undefined;
@memset(&bg, '.');
var r: u16 = 0;
while (r < rows) : (r += 1) {
var cup: [16]u8 = undefined;
screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
screen.eng.feed(&bg);
}
screen.eng.feed("\x1b[H");
var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"),
fixture.testHost(&shared, "--sock /tmp/d.sock", "/tmp/d.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
// The selected host's own list, so the descent is a real one: host 1,
// not host 0, and one session on it.
fixture.setList(&table[1], "only\n");
wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{}, null);
screen.drain();
wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{ .level = .sessions, .row = 0 }, null);
screen.drain();
const dump = try screen.eng.dumpPlain(alloc);
defer alloc.free(dump);
var blank: [w]u8 = undefined;
@memset(&blank, ' ');
var i: u16 = 0;
while (i < rows) : (i += 1) {
const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
try std.testing.expectEqual(@as(usize, cols), l.len);
// Whatever happened inside the box, the wall either side of it is
// untouched: the clear is span-bounded ECH over the OLD box's own
// columns, never a line-wide erase that would reach a neighbouring
// tile's cells or a rail.
try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
if (i >= short_top and i < short_top + short) {
try std.testing.expect(!std.mem.eql(u8, &blank, l[left..][0..w]));
} else if (i >= tall_top and i < tall_top + tall) {
try std.testing.expectEqualStrings(&blank, l[left..][0..w]);
} else {
try std.testing.expectEqualStrings(&bg, l);
}
}
}
test "paintPicker: a box that grows clears nothing, and a shrinking one clears at its own columns" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 160, .rows = 20 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"),
fixture.testHost(&shared, "--sock /tmp/d.sock", "/tmp/d.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
fixture.setList(&table[1], "only\n");
var buf: [8192]u8 = undefined;
// The three-row session box first, then the six-row host box over it.
_ = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 0 }, &buf);
const grown = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{}, &buf);
try std.testing.expect(grown.len > 0);
// A box that covers every row of the one before it owes no clear, and
// an ECH here would erase a row this same frame is painting.
try std.testing.expect(std.mem.indexOf(u8, grown, "X") == null);
// ...and back down. Six rows at top (20-6)/2 = 7 and three at (20-3)/2
// = 8, so rows 7, 11 and 12 (CUP rows 8, 12, 13) are the uncovered
// ones, and the box's left edge is (160-128)/2 = 16 (CUP column 17).
const shrunk = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 0 }, &buf);
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[8;17H\x1b[128X") != null);
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[12;17H\x1b[128X") != null);
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[13;17H\x1b[128X") != null);
// The rows the new box writes itself are not cleared first.
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[9;17H\x1b[128X") == null);
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[10;17H\x1b[128X") == null);
try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[11;17H\x1b[128X") == null);
}
test "paintPicker: an unchanged frame is not written again" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 20 }, .is_tty = true };
var table = [_]Host{
fixture.testHost(&shared, "a", "/tmp/a.sock"),
fixture.testHost(&shared, "b", "/tmp/b.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
var buf: [8192]u8 = undefined;
try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf).len > 0);
// The pollers repaint this box once a second PER HOST. Rewriting an
// identical screen at that rate is a terminal that never goes quiet,
// which is also every `settle` in the e2e suite.
try std.testing.expectEqual(@as(usize, 0), fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf).len);
// A moved selection is a different frame, and is written.
try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
// ...and a cleared stamp is what a relayout leaves behind, so the box
// goes back onto a screen that was wiped under it.
shared.picker_frame = .{};
try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
}
test "PickerAuto: an empty wall opens the picker once, and a tile takes it back" {
var a: PickerAuto = .{};
// Empty and nothing open: the wall opens it itself, because a blank
// screen is no place to act from.
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
// ...ONCE. The Esc that closed it has to leave the one-line text
// standing rather than being reopened over.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
// A tile arrived: the wall has something to show and gets the screen.
try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
// ...and only once; the popup is already gone.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
}
test "PickerAuto: a spelling in progress keeps the popup a tile would have closed" {
var a: PickerAuto = .{};
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
// `prompting` layers OVER `picking`: closing on `picking` alone would
// take the editor off the screen and leave it eating every key, and its
// Enter would reach the main switch's dead `.add_tile` arm — no line,
// no notice, and no host added.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, true));
// The moment the line is submitted or cancelled, the close is owed again.
try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
}
test "PickerAuto: a picker the user opened is not closed by an arriving tile" {
var a: PickerAuto = .{};
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
// Esc, and the user opens it again by hand. Without `taken` the flag
// outlives the close and the next birth force-closes a popup they are
// choosing from.
a.taken();
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
}
test "PickerAuto: the Esc that closes the wall's own popup is not undone by the wall" {
var a: PickerAuto = .{};
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
// The close runs `taken` on EVERY exit, the wall's popup included, and
// the wall is still empty afterwards. One flag for both facts reopened
// the box over the one-line text the Esc had just asked for — and on
// that wall `Ctrl-\ d` never reached the keyboard again.
a.taken();
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
// A tile arrives and leaves again: THAT emptiness earns its own open.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
}
test "PickerAuto: one Esc leaves a user's picker that outlived the wall's last tile" {
var a: PickerAuto = .{};
// The user opens the popup on a wall that still has tiles, and the last
// of them ends underneath it. The emptiness the wall then sees is one
// whose popup is already up, so it is spent without ever opening.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, true, false));
a.taken();
// The one Esc that closed it is the whole of this: unspent, the wall
// answers it by opening the box straight back over the empty line.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
}
test "PickerAuto: a wall still dialling spends nothing, so its first known emptiness still opens" {
var a: PickerAuto = .{};
// `is_tty` carries `restore_tried`: the user can open the picker by hand
// while the hosts are still answering, and a wall not yet KNOWN to be
// empty must not spend the open it has not made.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, true, false));
a.taken();
try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
}
test "PickerAuto: a wall still dialling, and a wall with no terminal, open nothing" {
var a: PickerAuto = .{};
// The caller folds `restore_tried` into this argument: a wall that has
// not heard from its hosts yet is not KNOWN to be empty, and a popup
// flashed over tiles that are about to arrive is one nobody asked for.
try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, false, false));
// A piped mux has no screen to put a popup on.
var b: PickerAuto = .{};
try std.testing.expectEqual(PickerAuto.Step.leave, b.step(false, true, false, false));
}
test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" {
var nine_buf: [96]u8 = undefined;
var ten_buf: [96]u8 = undefined;
const nine = wall_picker.pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40);
const ten = wall_picker.pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40);
try std.testing.expectEqual(
std.mem.indexOf(u8, nine, "--sock").?,
std.mem.indexOf(u8, ten, "--sock").?,
);
// A table that never reaches 10 spends no column on a digit it has not
// got: the marker is already two wide for the same reason.
var narrow_buf: [96]u8 = undefined;
const narrow = wall_picker.pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40);
try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]);
}
test "pickerRows: every host says what its poller last answered" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
// Four hosts, one per state the column can report. A fixture of one
// reachable host is blind to three of them.
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
fixture.testHost(&shared, "box", "/tmp/b.sock"),
fixture.testHost(&shared, "quic://gate:4433", "/tmp/c.sock"),
fixture.testHost(&shared, "slow", "/tmp/d.sock"),
};
fixture.setList(&table[0], "0\nwork\ndev\n");
table[0].applied = true;
fixture.setList(&table[1], "");
table[1].poll.reachable.store(false, .release);
table[1].applied = true;
fixture.setList(&table[2], "");
table[2].applied = true;
// Never answered: `applied` is what a first list sets, so a host that
// has not reported once is `connecting` and not `no sessions` — the
// two send the user to opposite places.
var body: PickerBody = .{};
wall_picker.pickerRows(&body, &table, 2, 48);
try std.testing.expectEqual(@as(usize, 4), body.n);
try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0));
try std.testing.expectEqualStrings(" 2 box unreachable ", body.row(1));
// The selected row wears the same marker a focused tile's bar does.
try std.testing.expectEqualStrings(" 3> quic://gate:4433 no sessions ", body.row(2));
try std.testing.expectEqualStrings(" 4 slow connecting ", body.row(3));
}
test "pickerRows: one session is not 1 sessions" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/b.sock")};
fixture.setList(&table[0], "0\n");
table[0].applied = true;
var body: PickerBody = .{};
wall_picker.pickerRows(&body, &table, 0, 32);
try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0));
}
test "pickerRows: a reason never costs the row the host it names" {
// The regression a long state introduces. `pickerRow` cuts the
// SPELLING and never the state, so a state that grew from a word to a
// whole ssh sentence would take the host name's columns: at 80 with
// this 17-byte spelling and the 71-byte sentence below, the row had
// three characters of `noroute@127.0.0.1` left — and the spelling is
// the one column that says which machine Enter would act on.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var table = [_]Host{fixture.testHost(&shared, "noroute@127.0.0.1", "/tmp/b.sock")};
table[0].applied = true;
table[0].poll.reachable.store(false, .release);
table[0].poll.reason.feed("ssh: connect to host 10.255.255.1 port 22: No route to host\n");
var body: PickerBody = .{};
wall_picker.pickerRows(&body, &table, 0, 80);
const row = body.row(0);
try std.testing.expectEqual(@as(usize, 80), row.len);
// Whole, and where a row with a one-word state would have put it.
try std.testing.expectEqualStrings(" 1> noroute@127.0.0.1", row[0..21]);
// ...and the reason took the rest, cut rather than dropped: the head
// of an ssh diagnostic is the part that names the cause.
try std.testing.expect(std.mem.indexOf(u8, row, "unreachable: ssh: connect to host") != null);
try std.testing.expect(std.mem.indexOf(u8, row, "No route to host") == null);
// A row with no room for a reason says what it always said, in the
// same width, and cuts the spelling by exactly as much as it always
// did: the floor is the longest word the state could carry before
// reasons existed, so this row is byte-identical to the one this
// fixture drew at ad3765ca.
wall_picker.pickerRows(&body, &table, 0, 32);
try std.testing.expectEqualStrings(" 1> route@127.0.0.1 unreachable ", body.row(0));
}
test "pickerRows: a spelling wider than the box keeps its tail" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var table = [_]Host{fixture.testHost(
&shared,
"--sock /run/user/1000/mux/very/long/path/to/muxd.sock",
"/tmp/b.sock",
)};
fixture.setList(&table[0], "0\n");
table[0].applied = true;
var body: PickerBody = .{};
wall_picker.pickerRows(&body, &table, 0, 32);
// Cut from the LEFT: the head of a socket path is what every host on
// one machine has in common, and the tail is what tells them apart.
try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0));
}
test "pickBirth: Enter on an emptied popup births nothing, not a session on a host just forgotten" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
// TWO hosts, both forgotten: the wall the last `x` empties is the wall
// whose popup has no row left to move the selection to, so `sel` still
// indexes a slot — a one-host fixture proves nothing about the index.
var table = [_]Host{
fixture.testHost(&shared, "a", "/tmp/a.sock"),
fixture.testHost(&shared, "b", "/tmp/b.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
h.forgotten.store(true, .release);
}
const at = wall_picker.pickBirth(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1);
if (at != null) return error.BornOnAForgottenHost;
if (live != 0) return error.AForgottenHostTookASlot;
var buf: [96]u8 = undefined;
try std.testing.expectEqualStrings(
"[no hosts to start a session on - a adds one]",
wv.takeNotice(&shared, &buf),
);
}
test "pickBirth: Enter is an ask — the tile it births may start a daemon, the row it came from may not" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
// TWO hosts, and the birth on the second: a one-host fixture cannot
// see a birth that reaches for the wrong row.
var table = [_]Host{
.{
.spec = try client.resolveHost(alloc, "alpha", null, client.quic_idle_ms_default),
.shared = &shared,
},
.{
.spec = try client.resolveHost(alloc, "beta", null, client.quic_idle_ms_default),
.shared = &shared,
},
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
const at = wall_picker.pickBirth(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1) orelse
return error.EnterBornNothing;
// The row Enter lands on is often exactly the one the poller calls
// unreachable, and starting that machine's daemon is what choosing it
// means. Nothing else on the wall may: the spec below it is what the
// poller re-dials every second.
try std.testing.expect(tiles[at].r.target.hand.asked);
try std.testing.expect(!table[1].spec.target.hand.asked);
try std.testing.expect(!table[1].spec.poll_target.hand.asked);
}
test "pickerRows: a forgotten host is off the list, and the numbers close up" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var table = [_]Host{
fixture.testHost(&shared, "a", "/tmp/a.sock"),
fixture.testHost(&shared, "b", "/tmp/b.sock"),
fixture.testHost(&shared, "c", "/tmp/c.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
// The slot STAYS — a poller thread holds the pointer and `Tile.host`
// indexes it — so only the list closes up.
table[1].forgotten.store(true, .release);
var body: PickerBody = .{};
wall_picker.pickerRows(&body, &table, 2, 32);
try std.testing.expectEqual(@as(usize, 2), body.n);
try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0));
try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1));
}
test "pickerRepaint: a screen cleared under the spelling editor still owes a paint, carrying the half-typed line" {
var f = interact.PrefixFilter{};
var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
// Nothing is owed while the popup is not up, whatever else happened.
try std.testing.expect(!wall_picker.pickerRepaint(&buf, &f, true).due);
f.picking = true;
const closed = wall_picker.pickerRepaint(&buf, &f, false);
try std.testing.expect(!closed.due);
try std.testing.expectEqual(@as(?[]const u8, null), closed.line);
// The editor is open and a poll on some OTHER host vanishes a pane:
// `relayout` clears the whole screen and zeroes the stamp, which
// is the trigger. Skipping the paint here leaves the user typing into an
// editor that is not on the screen, and every key still goes to it.
f.prompting = true;
var typed = [_]u8{ 'b', 'o', 'x' };
for (&typed) |*c| _ = f.feed(c[0..1]);
const repair = wall_picker.pickerRepaint(&buf, &f, true);
try std.testing.expect(repair.due);
try std.testing.expectEqualStrings(": box_", repair.line orelse "");
}
test "hostState: the row counts the sessions the wall could show, not the runs in the reply" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var h = Host{
.spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
.shared = &shared,
};
var buf: [32]u8 = undefined;
// Never polled, so nothing is known: `no sessions` here would send the
// user to birth on a machine that is not answering.
try std.testing.expectEqualStrings("connecting", wall_picker.hostState(&buf, &h));
h.applied = true;
const listed = "a\nb\nc\n";
@memcpy(h.poll.list[0..listed.len], listed);
h.poll.list_len = listed.len;
try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
// A row saying `4 sessions` beside three tiles is the row lying about
// the wall: `planHostDiff` grades through `validSessionName`, so the
// count reads the reply through the same filter.
const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n";
@memcpy(h.poll.list[0..with_junk.len], with_junk);
h.poll.list_len = with_junk.len;
try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
h.poll.reachable.store(false, .release);
try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
}
test "hostState: a drifted daemon says so beside its count, within the row's buffer" {
var shared: Shared = undefined;
fixture.stoppedWall(std.testing.allocator, &shared);
defer shared.tree.deinit();
var h = Host{
.spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
.shared = &shared,
};
h.applied = true;
const listed = "a\n";
@memcpy(h.poll.list[0..listed.len], listed);
h.poll.list_len = listed.len;
// The keyboard's applied word, the same one the tiles wear: the row
// reads it rather than re-judging the payload, so popup and bar can
// never disagree about one host.
const word = "daemon 0.9 stale";
@memcpy(h.drift[0..word.len], word);
h.drift_len = word.len;
var buf: [32]u8 = undefined;
try std.testing.expectEqualStrings("1 session, daemon 0.9 stale", wall_picker.hostState(&buf, &h));
// The row's buffer is the bound, and truncation is the contract —
// never an overflow, never stack garbage.
var narrow: [12]u8 = undefined;
try std.testing.expectEqualStrings("1 session, d", wall_picker.hostState(&narrow, &h));
// A dark box keeps its reason: drift is news about a daemon that IS
// answering, and `unreachable` outranks it.
h.poll.reachable.store(false, .release);
try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
}
test "hostState: an unreachable host says what ssh said, cut to the row's buffer" {
// `unreachable` alone is a row the user cannot act on: a box that is
// off, a key that was refused and a host name that does not resolve
// all read the same. ssh said which, and the poll kept the line.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var h = Host{
.spec = .{ .spelling = "box", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
.shared = &shared,
};
h.applied = true;
h.poll.reachable.store(false, .release);
h.poll.reason.feed("ssh: connect to host box port 22: No route to host\n");
var wide: [wall_picker.picker_row_max]u8 = undefined;
try std.testing.expectEqualStrings(
"unreachable: ssh: connect to host box port 22: No route to host",
wall_picker.hostState(&wide, &h),
);
// The buffer is the ROW's, and a row is as wide as the terminal is.
// Truncation is the contract, not an error: a narrow buffer shortens
// the sentence rather than losing it or painting past its rect.
var narrow: [20]u8 = undefined;
try std.testing.expectEqualStrings("unreachable: ssh: co", wall_picker.hostState(&narrow, &h));
// No room for even the prefix: the old word, never a clipped one.
var tiny: [8]u8 = undefined;
try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&tiny, &h));
// A host that came back has nothing to explain.
h.poll.reachable.store(true, .release);
try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h));
}
test "askRows: a masked answer shows one star per byte and never the bytes" {
var body: wall_picker.AskBody = .{};
wall_picker.askRows(&body, "e2e@box's password: ", "hunter2", .secret, 60);
try std.testing.expectEqual(@as(usize, 2), body.n);
try std.testing.expectEqualStrings(" e2e@box's password: ", body.row(0));
// The count is the feedback: ssh will not echo, and a box that showed
// nothing at all reads as one that is not listening.
try std.testing.expectEqualStrings("> *******_", body.row(1));
// The claim this whole box exists to keep.
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "hunter2") == null);
}
test "askRows: a host-key answer is shown, because the user is comparing it" {
var body: wall_picker.AskBody = .{};
wall_picker.askRows(&body, "Are you sure you want to continue connecting (yes/no)? ", "yes", .confirm, 60);
try std.testing.expectEqualStrings("> yes_", body.row(body.n - 1));
}
test "askRows: a NOTICE has no input line, because ssh is not asking anything" {
// "Confirm user presence for key ..." is ssh telling the user to touch
// a key, and it takes no answer: ssh kills the helper when the touch
// lands. An input line under it would invite one nothing would read.
var body: wall_picker.AskBody = .{};
wall_picker.askRows(&body, "Confirm user presence for key ED25519-SK SHA256:xyz", "", .notice, 60);
try std.testing.expectEqual(@as(usize, 1), body.n);
try std.testing.expectEqualStrings(" Confirm user presence for key ED25519-SK SHA256:xyz", body.row(0));
}
test "askRows: a long question wraps to the width and stops at the cap" {
var body: wall_picker.AskBody = .{};
var long: [600]u8 = @splat('q');
wall_picker.askRows(&body, &long, "", .confirm, 42);
// Four rows of question plus the answer row: a prompt that filled the
// terminal would leave nowhere to type.
try std.testing.expectEqual(wall_picker.ask_rows_max + 1, body.n);
for (0..wall_picker.ask_rows_max) |i| {
// One space of margin, 40 of question: the row never exceeds the
// box, and `pickerLine` pads the rest.
try std.testing.expectEqual(@as(usize, 41), body.row(i).len);
}
try std.testing.expectEqualStrings("> _", body.row(wall_picker.ask_rows_max));
}
test "paintAsk: replayed into an engine, the box is centred and the answer is stars" {
const alloc = std.testing.allocator;
const cols: u16 = 160;
const rows: u16 = 20;
var screen = try WallScreen.init(alloc, cols, rows);
defer screen.deinit();
// Off-origin in both axes, `paintPicker`'s reason: a fixture on a
// terminal the box happens to fill is blind to every centring mistake.
var bg: [cols]u8 = undefined;
@memset(&bg, '.');
var r: u16 = 0;
while (r < rows) : (r += 1) {
var cup: [16]u8 = undefined;
screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
screen.eng.feed(&bg);
}
var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
var prefix: interact.PrefixFilter = .{};
prefix.askOpen(.secret);
var typed = "hunter2".*;
_ = prefix.feed(&typed);
wall_picker.paintAsk(&shared, "e2e@box's password: ", &prefix);
screen.drain();
const dump = try screen.eng.dumpPlain(alloc);
defer alloc.free(dump);
// The GRID, not the frame: a paint that reached a cell by another CUP
// form or a wider pad is invisible to a grep over the bytes.
try std.testing.expect(std.mem.indexOf(u8, dump, "e2e@box's password:") != null);
try std.testing.expect(std.mem.indexOf(u8, dump, "*******_") != null);
// The one thing that must never be on a screen anyone can scroll back.
try std.testing.expect(std.mem.indexOf(u8, dump, "hunter2") == null);
// ...and every tile is held off the terminal while it is up.
try std.testing.expect(wv.popupOpen(&shared));
const w: u16 = @intCast(wall_picker.picker_row_max);
const left: u16 = (cols - w) / 2;
const height: u16 = 3;
const top: u16 = (rows - height) / 2;
var i: u16 = 0;
while (i < rows) : (i += 1) {
const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
if (i >= top and i < top + height) {
try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
} else {
try std.testing.expectEqualStrings(&bg, l);
}
}
}
test "closeAsk: the picker comes back when the prompt box leaves, whichever end ended it" {
// The claim n1 found missing on one of the two ends. `relayout` CLEARS
// the screen, so a picker still open under the box owes a repaint —
// and a prompt CAN arrive over an open picker, which is why the answer
// has a buffer of its own. Without the repaint, `picking` is true on a
// blank screen until the next key.
const alloc = std.testing.allocator;
const cols: u16 = 100;
const rows: u16 = 30;
var screen = try WallScreen.init(alloc, cols, rows);
defer screen.deinit();
var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
defer shared.tree.deinit();
defer if (shared.last_flat) |*f| f.deinit(alloc);
defer if (shared.base_flat) |*f| f.deinit(alloc);
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
};
for (&table) |*h| {
fixture.setList(h, "");
h.applied = true;
}
var tiles: [1]Tile = undefined;
var present = [_]bool{false};
var live: usize = 0;
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// A notice over an open picker, which is exactly the pair that hangs
// up on its own: ssh kills the notifier helper when the touch lands.
var prefix: interact.PrefixFilter = .{};
prefix.picking = true;
prefix.askOpen(.notice);
shared.ask_open.store(true, .release);
screen.drain();
wv.closeAsk(w, &shared, &prefix, 0, 0);
screen.drain();
const dump = try screen.eng.dumpPlain(alloc);
defer alloc.free(dump);
try std.testing.expect(!prefix.asking);
try std.testing.expect(!shared.ask_open.load(.acquire));
// The GRID, not the frame: the box is gone and the rows the user was
// choosing from are back on it.
try std.testing.expect(std.mem.indexOf(u8, dump, "hosts") != null);
try std.testing.expect(std.mem.indexOf(u8, dump, "/tmp/b.sock") != null);
// ...and with no picker under it, nothing is painted in its place: the
// wall is what the screen goes back to. `closeAsk` leaves `picking`
// alone — the picker is not the box's to close — so the test says
// which wall this second half is.
prefix.picking = false;
prefix.askOpen(.secret);
shared.ask_open.store(true, .release);
wall_picker.paintAsk(&shared, "box's password: ", &prefix);
screen.drain();
wv.closeAsk(w, &shared, &prefix, 0, 0);
screen.drain();
const after = try screen.eng.dumpPlain(alloc);
defer alloc.free(after);
try std.testing.expect(std.mem.indexOf(u8, after, "/tmp/b.sock") == null);
try std.testing.expect(std.mem.indexOf(u8, after, "password") == null);
}
test "sessionRows: a host's sessions, marked when already on this wall, with the holder count when the daemon says" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
fixture.setList(&hosts_table[0], "0\nwork\n# holds 0 2\n# holds work 1\n# mux 0.0.1-18");
// Two panes, because a fixture with one is blind to a lookup that
// aliased every session onto the first tile it walked.
var tiles = [_]Tile{ fixture.claimBench(&shared, 0), fixture.claimBench(&shared, 1) };
tiles[0].host = 0;
tiles[0].r.session = "work";
tiles[1].host = 0;
tiles[1].r.session = "elsewhere";
var present = [_]bool{ true, true };
var live: usize = 2;
const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
var body = wall_picker.PickerBody{};
wall_picker.sessionRows(&body, w, 0, 1, 80);
try std.testing.expectEqual(@as(usize, 2), body.n);
try std.testing.expect(std.mem.indexOf(u8, body.row(0), " 0 ") != null);
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "2 clients") != null);
// Session 0 has no pane, so it is not marked; `work` does.
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") == null);
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "work") != null);
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "on this wall") != null);
// One holder is singular: the count is read as a number of people.
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "1 client,") == null);
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "1 client") != null);
// `sel_row` marks the row, not the host: the second one is selected.
try std.testing.expect(std.mem.indexOf(u8, body.row(1), "> ") != null);
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "> ") == null);
}
test "sessionRows: a pane on ANOTHER host does not mark this host's same-named session" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var hosts_table = [_]Host{
fixture.testHost(&shared, "box", "/b"),
fixture.testHost(&shared, "other", "/o"),
};
fixture.setList(&hosts_table[0], "work\n");
fixture.setList(&hosts_table[1], "work\n");
var tiles = [_]Tile{fixture.claimBench(&shared, 0)};
tiles[0].host = 1;
tiles[0].r.session = "work";
var present = [_]bool{true};
var live: usize = 1;
const w = fixture.wallOf(std.testing.allocator, &tiles, &present, &live, &shared, &hosts_table);
var body = wall_picker.PickerBody{};
wall_picker.sessionRows(&body, w, 0, 0, 80);
try std.testing.expectEqual(@as(usize, 1), body.n);
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") == null);
wall_picker.sessionRows(&body, w, 1, 0, 80);
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "on this wall") != null);
}
test "sessionRows: an old daemon's list shows no count rather than zero" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
fixture.setList(&hosts_table[0], "0\n");
const w = fixture.hostWall(&shared, &hosts_table);
var body = wall_picker.PickerBody{};
wall_picker.sessionRows(&body, w, 0, 0, 80);
try std.testing.expectEqual(@as(usize, 1), body.n);
// Never "0 clients": a daemon that cannot count said nothing, and a
// zero would read as a session nobody is in.
try std.testing.expect(std.mem.indexOf(u8, body.row(0), "client") == null);
}
test "sessionCount and rowStep: the row walks the host's own list and wraps" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var hosts_table = [_]Host{fixture.testHost(&shared, "box", "/b")};
fixture.setList(&hosts_table[0], "0\na\nb\n# holds 0 1");
try std.testing.expectEqual(@as(usize, 3), wall_picker.sessionCount(&hosts_table, 0));
// A selection with no host under it — an empty hosts file leaves one —
// counts zero rather than faulting.
try std.testing.expectEqual(@as(usize, 0), wall_picker.sessionCount(&hosts_table, 7));
try std.testing.expectEqual(@as(usize, 1), wall_picker.rowStep(0, 1, 3));
try std.testing.expectEqual(@as(usize, 0), wall_picker.rowStep(2, 1, 3));
try std.testing.expectEqual(@as(usize, 2), wall_picker.rowStep(0, -1, 3));
// An empty list has no row to rest on.
try std.testing.expectEqual(@as(usize, 0), wall_picker.rowStep(4, 1, 0));
}
test "pickAdd: a listed session becomes a pane once; a second add zooms to it" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
shared.size = .{ .cols = 120, .rows = 40 };
var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
fixture.setList(&hosts_table[0], "0\nwork\n");
var tiles: [wv.max_tiles]Tile = undefined;
var present = [_]bool{false} ** wv.max_tiles;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
const first = wall_picker.pickAdd(w, 0, 1).?;
try std.testing.expectEqualStrings("work", tiles[first].r.session);
// JOINED, never created: the session is already on that daemon, and a
// create would take a name the daemon has.
try std.testing.expect(!tiles[first].creates);
try std.testing.expectEqual(@as(?usize, 0), tiles[first].host);
try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
const again = wall_picker.pickAdd(w, 0, 1).?;
try std.testing.expectEqual(first, again);
try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
try std.testing.expectEqual(first, shared.sel);
// A row the list does not have adds nothing, and says so.
try std.testing.expectEqual(@as(?usize, null), wall_picker.pickAdd(w, 0, 9));
var buf: [96]u8 = undefined;
try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "no session") != null);
}
test "pickBirth: the name a restarted daemon calls free is already a pane, and the birth is refused to it" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
shared.size = .{ .cols = 120, .rows = 40 };
// TWO hosts and the pane on the SECOND: a one-host fixture cannot see a
// birth that matched a pane on the wrong machine, and two daemons may
// each call their own next name `0`.
var hosts_table = [_]Host{
fixture.testHost(&shared, "--sock /a", "/a"),
fixture.testHost(&shared, "--sock /b", "/b"),
};
fixture.setList(&hosts_table[0], "0\n");
fixture.setList(&hosts_table[1], "0\n");
var tiles: [wv.max_tiles]Tile = undefined;
var present = [_]bool{false} ** wv.max_tiles;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
const pane = wall_picker.pickAdd(w, 1, 0).?;
try std.testing.expectEqualStrings("0", tiles[pane].r.session);
// The daemon restarts: it answers an empty list, the pane it used to
// serve stands there wearing `gone`, and `nextFreeName` off that list
// hands back `0` — the name the pane already spells.
fixture.setList(&hosts_table[1], "");
var buf: [96]u8 = undefined;
_ = wv.takeNotice(&shared, &buf);
shared.sel = 0;
const at = wall_picker.pickBirth(w, 1);
// The pane the user meant, not a second tile onto one session: two
// panes spelling one leaf is a repeat, and `seedLayout` refuses a file
// with one WHOLE — the wall would be gone at the next start.
try std.testing.expectEqual(@as(?usize, pane), at);
try std.testing.expectEqual(@as(usize, 1), wv.presentCount(w.livePresent()));
try std.testing.expectEqual(pane, shared.sel);
const said = wv.takeNotice(&shared, &buf);
try std.testing.expect(std.mem.indexOf(u8, said, "already a pane") != null);
try std.testing.expect(std.mem.indexOf(u8, said, "0") != null);
}
test "paintPicker: the session level names the host and lists its sessions" {
const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(pipe[0]);
defer std.posix.close(pipe[1]);
var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "box", "/tmp/a.sock"),
fixture.testHost(&shared, "other", "/tmp/b.sock"),
};
for (&table) |*h| h.applied = true;
fixture.setList(&table[1], "0\nwork\n# holds 0 1");
fixture.setList(&table[0], "only\n");
var buf: [8192]u8 = undefined;
// Host 1, not host 0: an off-origin selection is the baseline, so a
// paint that read the FIRST host's list would be caught.
const frame = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 1 }, &buf);
try std.testing.expect(std.mem.indexOf(u8, frame, "sessions on other") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "work") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "1 client") != null);
// The other host's session is not in this box.
try std.testing.expect(std.mem.indexOf(u8, frame, "only") == null);
// The footer is the session level's keys, not the host level's.
try std.testing.expect(std.mem.indexOf(u8, frame, "x end") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "x forget") == null);
}
test "Shared.PickEnd: the second x forces only on the same host and row, inside the window" {
var end: wv.PickEnd = .{};
try std.testing.expect(!end.armedFor(0, "work", 1000));
end.arm(0, "work", 1000 + wv.end_arm_ms);
try std.testing.expect(end.armedFor(0, "work", 1500));
// A different row is a FIRST press: the count the user read was about
// another session.
try std.testing.expect(!end.armedFor(0, "other", 1500));
// ...and so is the same name on another daemon.
try std.testing.expect(!end.armedFor(1, "work", 1500));
// The window closes on the clock.
try std.testing.expect(!end.armedFor(0, "work", 1000 + wv.end_arm_ms + 1));
// An accepted end disarms: the window must not outlive its session.
end.arm(0, "work", 1000 + wv.end_arm_ms);
end.clear();
try std.testing.expect(!end.armedFor(0, "work", 1500));
}
/// A daemon stand-in for `pickEnd`: one connection per press, each answering
/// one `end_req` with a scripted verdict and recording the `force` bit that
/// arrived. A REAL socket, because `pickEnd`'s whole job is the side
/// connection — the verdicts are the daemon's to make and are pinned against
/// a real one in `server_test_session.zig`.
const EndFake = struct {
listener: std.net.Server,
replies: []const proto.EndReply,
force: [4]bool = @splat(false),
n: usize = 0,
fn serve(self: *EndFake) void {
const alloc = std.testing.allocator;
while (self.n < self.replies.len) {
const conn = self.listener.accept() catch return;
defer conn.stream.close();
const f = (proto.readFrame(alloc, conn.stream.handle) catch return) orelse return;
defer f.deinit(alloc);
if (f.type != .end_req or f.payload.len < proto.end_req_len) return;
const at = self.n;
self.force[at] = f.payload[0] != 0;
self.n += 1;
const r = self.replies[at];
var buf: [proto.end_reply_max_len]u8 = undefined;
proto.writeFrame(
conn.stream.handle,
.end_reply,
proto.encodeEndReply(&buf, r.accepted, r.others, r.reason),
) catch return;
}
}
};
test "pickEnd: the first x arms with the count, the second forces, and a refusal that is not others-attached arms nothing" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const sp = try std.fmt.allocPrint(alloc, "{s}/end.sock", .{tmp.path()});
defer alloc.free(sp);
const addr = try std.net.Address.initUnix(sp);
var fake = EndFake{
.listener = try addr.listen(.{}),
.replies = &.{
.{ .accepted = false, .others = 2, .reason = proto.end_reason.others_attached },
.{ .accepted = true, .others = 0, .reason = proto.end_reason.accepted },
.{ .accepted = false, .others = 0, .reason = proto.end_reason.no_session },
},
};
defer fake.listener.deinit();
const th = try std.Thread.spawn(.{}, EndFake.serve, .{&fake});
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
// TWO hosts, and the daemon on the SECOND: a one-host fixture cannot
// see a press that reached for the wrong row's machine. Host 0's socket
// has nothing on it, so a misdirected press fails loudly.
var table = [_]Host{
fixture.testHost(&shared, "nowhere", "/tmp/mux-no-such-socket"),
fixture.testHost(&shared, "box", sp),
};
// The two recipes deliberately DIFFER, and only the poller's reaches the
// fake: an end must ride the batch recipe (`BatchMode`, a connect
// timeout, `asked` false) and never the interactive one that would
// prompt on /dev/tty under the popup and start a daemon.
table[1].spec.target = .{ .sock = "/tmp/mux-no-such-socket-entry" };
// The holds lines are what say this daemon is new enough to be asked.
fixture.setList(&table[1], "0\nwork\n# holds 0 1\n# holds work 3");
const w = fixture.hostWall(&shared, &table);
var buf: [96]u8 = undefined;
// First press: refused with the count, and the window is armed on THIS
// host and THIS name.
wall_picker.pickEnd(w, 1, 1, 1000);
try std.testing.expect(shared.pick_end.armedFor(1, "work", 1500));
try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500));
try std.testing.expect(!shared.pick_end.armedFor(0, "work", 1500));
const first = wv.takeNotice(&shared, &buf);
try std.testing.expect(std.mem.indexOf(u8, first, "work") != null);
// The DAEMON's verdict (2 others), not the row's holder count (3): the
// `# holds` line counts every holder including the asker, and the two
// numbers are deliberately different in this fixture so a notice built
// from the wrong one would be caught.
try std.testing.expect(std.mem.indexOf(u8, first, "2 others attached") != null);
try std.testing.expect(std.mem.indexOf(u8, first, "3 other") == null);
try std.testing.expect(std.mem.indexOf(u8, first, "x again to end") != null);
// Second press inside the window: forced, accepted, and disarmed.
wall_picker.pickEnd(w, 1, 1, 1500);
try std.testing.expect(!shared.pick_end.armedFor(1, "work", 1600));
const second = wv.takeNotice(&shared, &buf);
try std.testing.expect(std.mem.indexOf(u8, second, "ending work on box") != null);
// A refusal that is NOT others-attached: the daemon's reason, in this
// client's words, and no arm — a next `x` must not force an end on a
// session the daemon just said it has not got.
wall_picker.pickEnd(w, 1, 1, 5000);
try std.testing.expect(!shared.pick_end.armedFor(1, "work", 5100));
const third = wv.takeNotice(&shared, &buf);
try std.testing.expect(std.mem.indexOf(u8, third, "work") != null);
try std.testing.expect(std.mem.indexOf(u8, third, "no such session") != null);
try std.testing.expect(std.mem.indexOf(u8, third, "x again") == null);
th.join();
// Three presses, three connections — and the force bit is the SECOND
// press and nothing else.
try std.testing.expectEqual(@as(usize, 3), fake.n);
try std.testing.expectEqual([3]bool{ false, true, false }, fake.force[0..3].*);
}
/// A daemon that accepts the connection and answers NOTHING: what a box
/// with no `end_req` arm does with a frame type it does not know. The
/// connection has to stay OPEN — a close would be a transport error, and
/// the fact under test is the reply budget running out.
const SilentFake = struct {
listener: std.net.Server,
stop: std.atomic.Value(bool) = .init(false),
fn serve(self: *SilentFake) void {
const conn = self.listener.accept() catch return;
defer conn.stream.close();
while (!self.stop.load(.acquire)) std.Thread.sleep(20 * std.time.ns_per_ms);
}
};
test "pickEnd: a daemon that answers nothing is the too-old sentence, and the holds line is not the gate" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const sp = try std.fmt.allocPrint(alloc, "{s}/silent.sock", .{tmp.path()});
defer alloc.free(sp);
const addr = try std.net.Address.initUnix(sp);
var fake = SilentFake{ .listener = try addr.listen(.{}) };
defer fake.listener.deinit();
const th = try std.Thread.spawn(.{}, SilentFake.serve, .{&fake});
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "new", "/tmp/mux-no-such-socket-a"),
fixture.testHost(&shared, "old", sp),
};
fixture.setList(&table[0], "0\n# holds 0 1");
// No `# holds` line: the absence used to refuse the press up front, and
// that was wrong about a real box — the released v0.0.1-16 daemon
// answers `end_req` and sends no holds line. The verdict comes off the
// WIRE now, so this list says nothing about whether the end is asked.
fixture.setList(&table[1], "0\nwork\n");
const w = fixture.hostWall(&shared, &table);
var buf: [96]u8 = undefined;
wall_picker.pickEnd(w, 1, 0, 1000);
fake.stop.store(true, .release);
th.join();
// The pump's own wording for the same fact about the box, and no arm:
// nothing was answered, so there is nothing to press again for.
try std.testing.expectEqualStrings("[daemon too old to end a session]", wv.takeNotice(&shared, &buf));
try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500));
}
test "pickEnd: a daemon with no holds line that ANSWERS ends the session" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const sp = try std.fmt.allocPrint(alloc, "{s}/end16.sock", .{tmp.path()});
defer alloc.free(sp);
const addr = try std.net.Address.initUnix(sp);
var fake = EndFake{
.listener = try addr.listen(.{}),
.replies = &.{.{ .accepted = true, .others = 0, .reason = proto.end_reason.accepted }},
};
defer fake.listener.deinit();
const th = try std.Thread.spawn(.{}, EndFake.serve, .{&fake});
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var table = [_]Host{
fixture.testHost(&shared, "nowhere", "/tmp/mux-no-such-socket-d"),
fixture.testHost(&shared, "box", sp),
};
// A v0.0.1-16 daemon's list, verbatim in shape: names and the meta
// line, no holds. It has the `end_req` arm all the same, and refusing
// it for the missing count refused an end that works.
fixture.setList(&table[1], "0\nwork\n# mux 0.0.1-16");
const w = fixture.hostWall(&shared, &table);
var buf: [96]u8 = undefined;
wall_picker.pickEnd(w, 1, 1, 1000);
th.join();
try std.testing.expectEqual(@as(usize, 1), fake.n);
try std.testing.expect(!fake.force[0]);
try std.testing.expectEqualStrings("[ending work on box]", wv.takeNotice(&shared, &buf));
}
test "pickEnd: a row the list does not have asks nothing" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/mux-no-such-socket-c")};
fixture.setList(&table[0], "0\n# holds 0 1");
const w = fixture.hostWall(&shared, &table);
var buf: [96]u8 = undefined;
wall_picker.pickEnd(w, 0, 4, 1000);
try std.testing.expectEqualStrings("[no session on that row]", wv.takeNotice(&shared, &buf));
}