src/tui/wall_test_pump.zig
Ref: Size: 29.5 KiB History
//! One tile's pump thread (wall_pump.zig).
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const interact = @import("interact.zig");
const TmpDir = @import("testtmp").TmpDir;
const askpass = @import("client").askpass;
const fixture = @import("wall_test_harness.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const AgentLocal = wall_pump.AgentLocal;
const ClaimStep = wall_pump.ClaimStep;
const Shared = wv.Shared;
const SwallowTransport = fixture.SwallowTransport;
const Tile = wv.Tile;
test "claimAllowed: a stale arm does not claim even when the sink would now admit it" {
const alloc = std.testing.allocator;
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 = 80, .rows = 24 }, .is_tty = true };
// The focus has moved to tile 1; tile 2 is still holding the arm it was
// given before the popup went up.
shared.sel = 1;
var t = fixture.claimBench(&shared, 2);
t.claim_pending.store(true, .release);
var core = try interact.Core.initSized(alloc, -1, pipe[1], .{ .cols = 80, .rows = 24 });
defer core.deinit();
core.is_tty = true;
// The whole of this test: the picker has CLOSED, so the default sink
// admits every paint and `claimTerminal` would SUCCEED. Judging the
// claim's refusal cannot see that — there is no refusal left to judge —
// so the focus test has to run first, and `claimFocus` is the one door
// in this file that puts the two in that order.
const rect: proto.Size = .{ .cols = 80, .rows = 24 };
_ = t.claim_pending.swap(false, .acq_rel);
try std.testing.expectEqual(ClaimStep.dropped, wall_pump.claimFocus(&t, &core, rect));
// Nothing claimed, and nothing on the terminal: `session_claim` is the
// mouse modes, and a second tile setting them is what leaves one
// session's modes on the screen with nothing left to take them off.
try std.testing.expectEqual(interact.Claim.none, core.claim);
var buf: [256]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), std.posix.read(pipe[0], &buf) catch 0);
// The control: the same call on the tile that DOES hold the focus takes
// it, so the guard is a focus test and not a blanket refusal.
shared.sel = 2;
try std.testing.expectEqual(ClaimStep.done, wall_pump.claimFocus(&t, &core, rect));
try std.testing.expectEqual(interact.Claim.session, core.claim);
try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0);
}
test "afterClaim: a claim the picker refused is armed again for this tile" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
// Off-origin: tile 2 of a wall, not tile 0, so an index compared
// against a hard-coded 0 would pass here and nowhere else.
shared.sel = 2;
var t = fixture.claimBench(&shared, 2);
try std.testing.expectEqual(ClaimStep.rearmed, wall_pump.afterClaim(&t, false, false, true));
try std.testing.expect(t.claim_pending.load(.acquire));
}
test "afterClaim: an arm does not outlive the focus that earned it" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var t = fixture.claimBench(&shared, 2);
// The keyboard moved the focus while the popup was up — a poller's tile
// arriving, or the birth the Enter made. A re-arm that survived it
// fires after the close: two tiles' modes on one terminal, the cursor
// resting on the loser, and the release already consumed so nothing
// takes either off.
shared.sel = 1;
try std.testing.expectEqual(ClaimStep.dropped, wall_pump.afterClaim(&t, false, false, true));
try std.testing.expect(!t.claim_pending.load(.acquire));
}
test "afterClaim: only the SINK's refusal is worth another pass" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
shared.sel = 2;
var t = fixture.claimBench(&shared, 2);
// A claim that landed owes nothing.
try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, true, true, true));
// A Core that ALREADY holds it answers false too, and re-arming that
// would re-take the focus notice on every pass, forever.
try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, true, true));
// No terminal, nothing to hold: a piped `mux` is one tile and no claim.
try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, false, false));
// None of the three armed anything.
try std.testing.expect(!t.claim_pending.load(.acquire));
}
test "tilePaintBegin: no tile paints while the picker owns the screen" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var t = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
// The gate the whole popup rests on, and the gate that makes a focus
// CLAIM fail under it (`Core.claimTerminal` writes through the same
// sink) — which is why the pump re-arms `claim_pending` instead of
// treating a false return as "already claimed".
try std.testing.expect(wall_pump.tilePaintBegin(&t));
wall_pump.tilePaintEnd(&t);
shared.picker_open.store(true, .release);
try std.testing.expect(!wall_pump.tilePaintBegin(&t));
// Refused WITHOUT holding the lock: a begin that returned false and
// kept `paint_mu` would wedge the keyboard on its next paint.
try std.testing.expect(shared.paint_mu.tryLock());
shared.paint_mu.unlock();
}
test "tilePaintBegin: a pass a relayout has superseded paints nothing; the next pass paints" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 44 }, .is_tty = true };
var t = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
.rect = .{ .top = 10, .left = 0, .rows = 8, .cols = 80 },
.shared = &shared,
.idx = 3,
.wake_r = -1,
.wake_w = -1,
};
// The pump paints from the pass it took at the top of its loop. Seen
// on a wall of five when `x` ended a neighbour: the pump took its pass,
// the keyboard's relayout cleared the screen and moved every rect, and
// the pump's repaint then put this session's rows at the OLD offsets —
// over the bar its own label had just painted at the NEW row. Nothing
// repaints a bar after that, so the tile is gone from the final grid.
const before = wall_pump.takePass(&t);
try std.testing.expect(wall_pump.tilePaintBegin(&t));
wall_pump.tilePaintEnd(&t);
// What relayout does after re-cutting: the generation moves.
t.shared.paint_mu.lock();
t.rect = .{ .top = 12, .left = 0, .rows = 10, .cols = 80 };
t.resize_pending = true;
_ = shared.repaint_gen.fetchAdd(1, .release);
t.shared.paint_mu.unlock();
try std.testing.expect(!wall_pump.tilePaintBegin(&t));
// ...and, as above, refused without the lock held.
try std.testing.expect(shared.paint_mu.tryLock());
shared.paint_mu.unlock();
// The next pass is the relayout's: new rect, the resize it owes, and a
// generation the pump has not painted yet — the repaint that puts the
// tile back happens against THIS pass, which the sink admits.
const after = wall_pump.takePass(&t);
try std.testing.expect(after.gen != before.gen);
try std.testing.expect(after.resize);
try std.testing.expectEqual(@as(u16, 12), after.top);
try std.testing.expect(wall_pump.tilePaintBegin(&t));
wall_pump.tilePaintEnd(&t);
}
test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" {
try std.testing.expectEqualStrings(
"[no such session on that daemon]",
wall_pump.endRefusal(proto.end_reason.no_session),
);
try std.testing.expectEqualStrings(
"[the daemon could not read the end request]",
wall_pump.endRefusal(proto.end_reason.bad_frame),
);
// Anything the daemon does not say: an OSC title set, a CSI, or 260
// bytes of nothing. All three used to reach the terminal verbatim.
try std.testing.expectEqualStrings(
"[the daemon refused to end this session]",
wall_pump.endRefusal("\x1b]0;pwned\x07"),
);
try std.testing.expectEqualStrings(
"[the daemon refused to end this session]",
wall_pump.endRefusal("\x1b[2J"),
);
try std.testing.expectEqualStrings("[the daemon refused to end this session]", wall_pump.endRefusal(""));
}
test "a copy too big for OSC 52 is said out loud rather than dropped" {
// The notice path is the whole claim. A reply that rounds to ok on the
// wire but outruns OSC 52 is neither trimmed nor dropped: a half-copied
// selection is worse than none, because the user finds out when they
// paste it. The drag is per-tile now, on the Core the pump owns, so the
// copy goes through that Core — no claim is taken, the copy reads the
// drag and not the terminal's modes.
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var t = Tile{
.r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
defer core.deinit();
core.rep.history_rows = 0;
core.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
core.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
_ = core.drag.release();
try std.testing.expect(core.drag.range() != null);
var tr: SwallowTransport = .{};
const held = core.drag.range() orelse return error.NoDrag;
try core.requestSelection(&tr, held);
// Base64 of this length overruns clipboard_base64_max: the daemon sends
// it as ok, and OSC 52 cannot carry it.
const fits = proto.clipboard_base64_max / 4 * 3;
const big = try alloc.alloc(u8, fits + 1);
defer alloc.free(big);
@memset(big, 'x');
const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + big.len);
defer alloc.free(rbuf);
const payload = fixture.replyBytes(rbuf, core.sel_id, .ok, 0, big);
wall_pump.copySelection(&t, alloc, &core, payload);
var buf: [8192]u8 = undefined;
const said = fixture.readAvail(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
// Refused, not trimmed: a half-copied selection is worse than none,
// because the user finds out when they paste it.
try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
}
test "a reconnect drops the highlight over its own tile, and only its own" {
// Core.reattached clears that Core's drag — a resync renames the
// absolute row space, so a highlight kept across one would invert rows
// nobody selected. The drag is per-tile, so one tile's reconnect says
// nothing about another's selection: the neighbour's highlight stays.
const alloc = std.testing.allocator;
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles: [2]Tile = undefined;
tiles[0] = Tile{
.r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
.rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
tiles[1] = Tile{
.r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
.rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
};
// Each tile's pump owns its own Core, and each Core its own drag. A
// drag over tile 0 and one over tile 1, anchored in their own tiles.
var core0 = try interact.Core.initSized(alloc, -1, -1, shared.size);
defer core0.deinit();
var core1 = try interact.Core.initSized(alloc, -1, -1, shared.size);
defer core1.deinit();
core0.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
core0.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
core1.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 1, .row = 0, .col = 4 });
core1.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 1, .row = 2, .col = 9 });
try std.testing.expectEqual(@as(?usize, 0), core0.drag.on());
try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
// Tile 0 reconnects: its row space is about to be renamed, so its
// highlight comes off. Tile 1's never went away.
core0.reattached();
try std.testing.expect(core0.drag.range() == null);
try std.testing.expect(core0.drag.on() == null);
try std.testing.expect(core1.drag.range() != null);
try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
}
test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
// The pump's table at the size its refusal path needs. The e2e proves
// one channel end to end and will never fill eight of them.
var locals: [3]?AgentLocal = @splat(null);
// Real fds, not stand-ins: a channel here is the write end of a pipe, so
// "the table closed it" is answered by the READER seeing EOF rather than
// by trusting the table's own bookkeeping. Non-blocking, so a channel
// that was NOT closed reports EAGAIN and fails the assertion instead of
// parking the suite on a read that never returns.
var peers: [4]std.posix.fd_t = undefined;
var chans: [4]std.posix.fd_t = undefined;
for (0..4) |i| {
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
peers[i] = p[0];
chans[i] = p[1];
}
defer for (peers) |fd| std.posix.close(fd);
for (0..3) |i| try std.testing.expectEqual(
@as(?usize, i),
wall_pump.storeLocal(&locals, @intCast(10 + i), chans[i]),
);
// The full table the pump answers with `agent_close`. The fourth fd stays
// this test's to close: a refused store never took ownership of it.
try std.testing.expectEqual(@as(?usize, null), wall_pump.storeLocal(&locals, 99, chans[3]));
std.posix.close(chans[3]);
try std.testing.expectEqual(@as(?usize, 1), wall_pump.findLocal(&locals, 11));
try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 99));
// A pipe for the link: the transport holds the WRITE end, since these
// tests only ever send, and reads the pipe themselves. The frame that
// comes back out of it is the daemon's only notice that this end hung
// up on the channel.
const link = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(link[0]);
defer std.posix.close(link[1]);
var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
wall_pump.closeLocal(&locals, 1, &transport);
try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]);
try std.testing.expect(fixture.peerClosed(peers[1]));
const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAgentCloseOnLocalClose;
defer frame.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
try std.testing.expectEqual(@as(u32, 11), try proto.decodeAgentId(frame.payload));
// The redial's sweep: every channel still open goes, and nothing is said
// — the connection that owned them is the one that just died.
wall_pump.dropLocals(&locals);
for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c);
try std.testing.expect(fixture.peerClosed(peers[0]));
try std.testing.expect(fixture.peerClosed(peers[2]));
}
test "agent channels: an oversize frame hangs the channel up, a full one lands" {
var locals: [2]?AgentLocal = @splat(null);
const chan = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(chan[0]);
_ = wall_pump.storeLocal(&locals, 5, chan[1]);
const link = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(link[0]);
defer std.posix.close(link[1]);
var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
// The ordinary case first, so the refusal below is a refusal and not a
// delivery path that never worked.
var small: [proto.agent_id_len + 2]u8 = undefined;
small[0..proto.agent_id_len].* = proto.encodeAgentId(5);
@memcpy(small[proto.agent_id_len..], "hi");
try std.testing.expect(wall_pump.deliverAgentData(&locals, &small, &transport));
var got: [8]u8 = undefined;
try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got));
try std.testing.expectEqualSlices(u8, "hi", got[0..2]);
// One byte past the cap. The daemon never sends this; a peer that is not
// the daemon we shipped can, and `writeAllFd` would block on it.
const over = try std.testing.allocator.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
defer std.testing.allocator.free(over);
@memset(over, 'x');
over[0..proto.agent_id_len].* = proto.encodeAgentId(5);
try std.testing.expect(wall_pump.deliverAgentData(&locals, over, &transport));
try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]);
try std.testing.expect(fixture.peerClosed(chan[0]));
// And the daemon is told, because it is holding the far socket open for
// an answer this end will never write.
// A payload too short to name a channel: false, and the pump abandons the
// batch on it rather than skipping one frame, because a stream that has
// lost frame alignment is not one to keep reading.
try std.testing.expect(!wall_pump.deliverAgentData(&locals, "ab", &transport));
const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAgentCloseOnOversize;
defer frame.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload));
}
test "agent forwarding is per tile: no -A offers nothing and opens nothing" {
// A REAL agent socket on this machine, listening. Without one, a tile
// that never asked for forwarding and a tile that did both refuse — for
// different reasons — and the consent gate could be deleted under a
// green suite.
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});
const addr = try std.net.Address.initUnix(sock);
var listener = try addr.listen(.{});
defer listener.deinit();
var locals: [2]?AgentLocal = @splat(null);
defer wall_pump.dropLocals(&locals);
// The gate, both ways round, against the same reachable agent.
try std.testing.expect(!wall_pump.openAgentChan(&locals, 1, false, sock));
try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 1));
try std.testing.expect(wall_pump.openAgentChan(&locals, 1, true, sock));
try std.testing.expect(wall_pump.findLocal(&locals, 1) != null);
// And the daemon does not get told about an agent the tile never
// offered — the offer is what the daemon routes on, so a stray one
// makes this client the answerer for a session it never armed.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var t = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
.rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
const link = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(link[0]);
defer std.posix.close(link[1]);
var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
try wall_pump.sendAttach(&t, &transport, 0, 0);
const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAttach;
defer first.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.attach, first.type);
// Nothing else on the pipe at all: an empty non-blocking read is the
// only proof that no offer followed, since a frame reader would just
// block waiting for one.
var spare: [1]u8 = undefined;
try std.testing.expectError(error.WouldBlock, std.posix.read(link[0], &spare));
// The positive control on the same pipe: with `-A` the offer follows the
// attach, so the silence above is the gate and not an unwritten frame.
t.r.agent = true;
try wall_pump.sendAttach(&t, &transport, 0, 0);
const attach = (try proto.readFrame(std.testing.allocator, link[0])).?;
defer attach.deinit(std.testing.allocator);
const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAgentOffer;
defer offer.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.agent_offer, offer.type);
}
test "a view tile's attach makes no size claim; a tile the user asked for does" {
// The daemon reads attach-or-create off the attach's size claim: 0x0
// joins, a real size creates. So a view tile (creates = false) must
// put 0x0 on the wire and leave its rect to the resize doorbell, while
// a tile the user asked for (creates = true) claims its rect in the
// attach itself.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
// A piped wall draws no bar, so viewRows is the whole stripe.
var t = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
const link = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(link[0]);
defer std.posix.close(link[1]);
var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
// A view tile: 0x0 on the wire, and the rect owed to the doorbell.
t.creates = false;
t.resize_pending = false;
try wall_pump.sendAttach(&t, &transport, 0, 0);
const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAttach;
defer view.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.attach, view.type);
const view_req = try proto.decodeAttach(view.payload);
try std.testing.expectEqual(@as(u16, 0), view_req.cols);
try std.testing.expectEqual(@as(u16, 0), view_req.rows);
try std.testing.expect(t.resize_pending);
// The same tile, now one the user asked for: the rect is in the attach,
// and no doorbell is owed.
t.creates = true;
t.resize_pending = false;
try wall_pump.sendAttach(&t, &transport, 0, 0);
const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAttach;
defer entry.deinit(std.testing.allocator);
try std.testing.expectEqual(proto.MsgType.attach, entry.type);
const entry_req = try proto.decodeAttach(entry.payload);
try std.testing.expectEqual(@as(u16, 80), entry_req.cols);
try std.testing.expectEqual(@as(u16, 24), entry_req.rows);
try std.testing.expect(!t.resize_pending);
}
test "a newborn tile's first claim is its real stripe, not the placeholder" {
// A chord-born tile (creates = true) puts its rect on the attach frame,
// and the daemon refuses creates under min_session_rows. A 2-row
// stripe under a label bar is 1 content row — below the floor — so a
// placeholder stripe silently freezes the session on a stale grid.
// The tile's stripe must be what relayout would give it BEFORE the
// pump's first sendAttach.
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
// Real stripe for the second tile of a 2-tile wall on 24 rows.
var t = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
.rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
};
t.creates = true;
const link = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(link[0]);
defer std.posix.close(link[1]);
var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
try wall_pump.sendAttach(&t, &transport, 0, 0);
const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse
return error.NoAttach;
defer fr.deinit(std.testing.allocator);
const req = try proto.decodeAttach(fr.payload);
// The real stripe's viewRows is 11 (12 - 1 bar), never the 1 a
// placeholder rows=2 stripe would put on the wire.
try std.testing.expectEqual(@as(u16, 80), req.cols);
try std.testing.expectEqual(@as(u16, 11), req.rows);
try std.testing.expect(req.rows >= proto.min_session_rows);
}
test "the cursor sleeps in the focused tile, whoever painted last" {
// Every painter ends by showing the cursor where its own stripe sits, so
// without an owner the visible cursor lands on whichever pump painted
// last. The fix: a focused paint records its screen cursor in Shared, and
// an unfocused paint's last act is to put the cursor back there. A
// focused tile with no core (the unit-test fixture) records nothing and
// restores nothing — the core is what knows the cursor.
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
var tiles: [2]Tile = undefined;
tiles[0] = Tile{
.r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
.rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
};
tiles[1] = Tile{
.r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
.rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
.shared = &shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
};
shared.sel = 0;
shared.cursor = .{ .x = 3, .y = 5 };
var buf: [512]u8 = undefined;
// An UNFOCUSED paint (tile 1, core null) ends by restoring the shared
// cursor: hide, CUP to the focused tile's stored position, then show
// again so the cursor rests visible in the focused tile until its own
// next paint — without the trailing show the cursor stays hidden.
_ = wall_pump.tilePaintBegin(&tiles[1]);
wall_pump.tilePaintEnd(&tiles[1]);
try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", fixture.readAvail(p[0], &buf));
// A FOCUSED paint (tile 0, core null) records nothing and restores
// nothing: no core means the shared cursor is left untouched, and the
// pipe receives no new bytes.
_ = wall_pump.tilePaintBegin(&tiles[0]);
wall_pump.tilePaintEnd(&tiles[0]);
try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len);
}
test "askOn: a wall dial carries the popup's socket, and every other target is untouched" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
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 = 80, .rows = 24 }, .is_tty = true };
shared.kb_w = pipe[1];
const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
// No listener: the OLD behaviour, and the visible one — ssh reads
// /dev/tty. A piped `mux` and a box with no runtime dir are both here.
const bare = wall_pump.askOn(hand, &shared);
try std.testing.expect(bare.hand.ask_sock == null);
try std.testing.expect(bare.hand.askpassFor() == null);
const l = try askpass.Listener.start(alloc, tmp.path(), .{
.ctx = &shared,
.wake = struct {
fn nop(_: *anyopaque) void {}
}.nop,
});
defer l.stop();
shared.prompts = l;
shared.prompt_exe = "/opt/mux";
const armed = wall_pump.askOn(hand, &shared);
const ask = armed.hand.askpassFor() orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings(l.path, ask.sock);
try std.testing.expectEqualStrings("/opt/mux", ask.exe);
// A socket or a `--via` dial spawns no ssh, so there is nothing to
// point anywhere — and a target arm added later must not silently
// acquire a field this one cannot use.
const sock = wall_pump.askOn(.{ .sock = "/tmp/d.sock" }, &shared);
try std.testing.expect(sock == .sock);
}
test "askpassFor: a socket with no binary to carry it is no askpass at all" {
// SSH_ASKPASS pointing at nothing makes ssh FAIL every prompt rather
// than ask one, which is worse than the tty read this replaces.
const half = client.HandoffTarget{
.host = "box",
.ssh_argv = &.{"ssh"},
.cache_path = null,
.ask_sock = "/run/ask.sock",
};
try std.testing.expect(half.askpassFor() == null);
}