a73x

7fa72ef8

test: consolidate native scenarios and fixtures

a73x   2026-09-07 15:22

Commit message
test: consolidate native scenarios and fixtures

Reuse the shared native journey for ordinary picker, selection, scrolling and resize checks. Keep exceptional and platform probes on lean fixtures, consolidate Zig test setup, and clean successful native artifacts.

docs/development.md
Old New
@@ -68,3 +68,14 @@ additional custom sockets remains responsible for stopping those daemons.
68 68
69 Use `tools/run-logged.sh LOG COMMAND [ARG ...]` to capture a noisy check. It 69 Use `tools/run-logged.sh LOG COMMAND [ARG ...]` to capture a noisy check. It
70 prints the final 30 lines and returns the command's original exit status. 70 prints the final 30 lines and returns the command's original exit status.
71
72 Native tests keep ordinary pane, input, resize, clipboard and font checks in
73 `make native-e2e`'s single shared journey. Focused scripts retain exceptional
74 cases such as delayed replies, lifecycle failures, real SSH and compositor
75 scaling. Their workspace helpers create the required panes without replaying
76 the persistence scenario first.
77
78 Native fixture directories are removed after successful runs. Failures retain
79 their screenshots, state and logs; set `MUXG_TEST_KEEP_ARTIFACTS=1` to retain a
80 successful run too. Only the fixture's own temporary directory is removed;
81 the command log passed to `tools/run-logged.sh` is separate and remains available.
src/gui/frame.zig
Old New
@@ -842,6 +842,11 @@ test "test hook retains text and clipboard paths and maps paste" {
842 try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0); 842 try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0);
843 } 843 }
844 844
845 // Caller owns Runtime/Wake and must supply window/cache before using their paths.
846 fn testEvents(rt: *runtime.Runtime, wake: *Wake, metrics: model.Metrics, width: i32, height: i32) Events {
847 return .{ .win = undefined, .wake = wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = rt, .metrics = metrics, .fb_w = width, .fb_h = height, .wake_ctx = wake, .wake = Wake.discovery } };
848 }
849
845 test "delayed End refusal cannot install a hidden force menu over an active picker" { 850 test "delayed End refusal cannot install a hidden force menu over an active picker" {
846 const a = std.testing.allocator; 851 const a = std.testing.allocator;
847 var rt = runtime.Runtime.init(a, .{}); 852 var rt = runtime.Runtime.init(a, .{});
@@ -849,7 +854,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
849 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; 854 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
850 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics); 855 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
851 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 856 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
852 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; 857 var events = testEvents(&rt, &wake, metrics, 800, 600);
853 defer events.deinit(); 858 defer events.deinit();
854 try events.ui.relayout(); 859 try events.ui.relayout();
855 events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; 860 events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 };
@@ -882,7 +887,7 @@ test "delayed End refusal respects changed focus and recovery context names its
882 const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics); 887 const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics);
883 const key = rt.get(first).?.key; 888 const key = rt.get(first).?.key;
884 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 889 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
885 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; 890 var events = testEvents(&rt, &wake, metrics, 800, 600);
886 defer events.deinit(); 891 defer events.deinit();
887 try events.ui.relayout(); 892 try events.ui.relayout();
888 events.ui.pending_end = .{ .key = key, .request = 1 }; 893 events.ui.pending_end = .{ .key = key, .request = 1 };
@@ -907,25 +912,6 @@ test "delayed End refusal respects changed focus and recovery context names its
907 try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]); 912 try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]);
908 } 913 }
909 914
910 test "delayed End refusal does not open force menu during command mode" {
911 const a = std.testing.allocator;
912 var rt = runtime.Runtime.init(a, .{});
913 defer rt.deinit();
914 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
915 const id = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics);
916 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
917 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } };
918 defer events.deinit();
919 try events.ui.relayout();
920 const key = rt.get(id).?.key;
921 events.ui.pending_end = .{ .key = key, .request = 1 };
922 rt.get(id).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
923 try events.ui.pollEnd();
924 try std.testing.expect(events.ui.recovery == null);
925 try std.testing.expect(events.ui.pending_end == null);
926 try std.testing.expect(events.ui.command_mode);
927 }
928
929 test "held detach removes one pane and recovery Enter stays consumed after closing" { 915 test "held detach removes one pane and recovery Enter stays consumed after closing" {
930 const a = std.testing.allocator; 916 const a = std.testing.allocator;
931 var rt = runtime.Runtime.init(a, .{}); 917 var rt = runtime.Runtime.init(a, .{});
@@ -934,7 +920,8 @@ test "held detach removes one pane and recovery Enter stays consumed after closi
934 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); 920 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
935 _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); 921 _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
936 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 922 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
937 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } }; 923 var events = testEvents(&rt, &wake, metrics, 800, 600);
924 events.ui.command_mode = true;
938 defer events.deinit(); 925 defer events.deinit();
939 try events.ui.relayout(); 926 try events.ui.relayout();
940 var ev = std.mem.zeroes(c.SDL_Event); 927 var ev = std.mem.zeroes(c.SDL_Event);
@@ -976,7 +963,7 @@ test "pending End header stays with its origin and rejects stale generations" {
976 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); 963 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
977 const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); 964 const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
978 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 965 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
979 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; 966 var events = testEvents(&rt, &wake, metrics, 800, 600);
980 defer events.deinit(); 967 defer events.deinit();
981 try events.ui.relayout(); 968 try events.ui.relayout();
982 const key = rt.get(first).?.key; 969 const key = rt.get(first).?.key;
@@ -1158,7 +1145,7 @@ test "resize mode repeats move one cell without leaking held directions after ex
1158 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); 1145 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
1159 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); 1146 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
1160 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1147 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1161 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } }; 1148 var events = testEvents(&rt, &wake, metrics, 801, 601);
1162 defer events.deinit(); 1149 defer events.deinit();
1163 try events.ui.relayout(); 1150 try events.ui.relayout();
1164 try events.ui.command(.r); 1151 try events.ui.command(.r);
@@ -1229,7 +1216,7 @@ test "divider drag preserves grab offset clamps signed outside motion and cancel
1229 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); 1216 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
1230 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); 1217 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
1231 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1218 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1232 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } }; 1219 var events = testEvents(&rt, &wake, metrics, 801, 601);
1233 defer events.deinit(); 1220 defer events.deinit();
1234 try events.ui.relayout(); 1221 try events.ui.relayout();
1235 try events.ui.pointerDown(402, 100, 6, 6); 1222 try events.ui.pointerDown(402, 100, 6, 6);
@@ -1280,7 +1267,8 @@ test "multi-pane scale transitions resize every content claim and preserve stabl
1280 rt.workspace.arm(.beside); 1267 rt.workspace.arm(.beside);
1281 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); 1268 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics);
1282 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1269 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1283 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; 1270 var events = testEvents(&rt, &wake, metrics, 960, 600);
1271 events.cache = &cache;
1284 defer events.deinit(); 1272 defer events.deinit();
1285 try events.ui.relayout(); 1273 try events.ui.relayout();
1286 const initial = events.ui.layout; 1274 const initial = events.ui.layout;
@@ -1328,7 +1316,7 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1328 defer rt.deinit(); 1316 defer rt.deinit();
1329 // No live attachment is needed to test the actual SDL event routing state. 1317 // No live attachment is needed to test the actual SDL event routing state.
1330 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1318 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1331 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; 1319 var events = testEvents(&rt, &wake, .{ .cell_w = 8, .cell_h = 16 }, 960, 600);
1332 defer events.deinit(); 1320 defer events.deinit();
1333 var event = std.mem.zeroes(c.SDL_Event); 1321 var event = std.mem.zeroes(c.SDL_Event);
1334 event.key.type = c.SDL_EVENT_KEY_DOWN; 1322 event.key.type = c.SDL_EVENT_KEY_DOWN;
@@ -1380,49 +1368,34 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1380 try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); 1368 try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes));
1381 } 1369 }
1382 1370
1383 test "copy chord is exact while plain interrupt remains terminal input" { 1371 test "copy and paste shortcuts are platform-specific and exact; plain Ctrl stays terminal input" {
1384 var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent); 1372 for ([_]struct { key: u32, shortcut: NativeShortcut }{
1385 copy_event.key = c.SDLK_C; 1373 .{ .key = c.SDLK_C, .shortcut = .copy },
1386 copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; 1374 .{ .key = c.SDLK_V, .shortcut = .paste },
1387 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?); 1375 }) |case| {
1388 var interrupt_event = copy_event; 1376 for ([_]bool{ false, true }) |macos| {
1389 interrupt_event.mod = c.SDL_KMOD_CTRL; 1377 var event = std.mem.zeroes(c.SDL_KeyboardEvent);
1390 const interrupt = interactionKey(interrupt_event); 1378 event.key = case.key;
1391 try std.testing.expect(nativeShortcut(interrupt_event, false) == null); 1379 for ([_]u16{ c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| {
1392 try std.testing.expect(interrupt.terminal != null); 1380 event.mod = mods;
1393 for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| { 1381 try std.testing.expectEqual(case.shortcut, nativeShortcut(event, macos).?);
1394 copy_event.mod = mods; 1382 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
1395 try std.testing.expectEqual(NativeShortcut.copy, nativeShortcut(copy_event, false).?); 1383 event.mod = mods | extra;
1396 } 1384 try std.testing.expect(nativeShortcut(event, macos) == null);
1397 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { 1385 }
1398 var excluded = copy_event; 1386 event.mod = mods;
1399 excluded.mod |= extra; 1387 event.repeat = true;
1400 try std.testing.expect(nativeShortcut(excluded, false) == null); 1388 try std.testing.expect(nativeShortcut(event, macos) == null);
1401 } 1389 event.repeat = false;
1402 copy_event.repeat = true; 1390 }
1403 try std.testing.expect(nativeShortcut(copy_event, false) == null); 1391 event.mod = c.SDL_KMOD_CTRL;
1404 } 1392 try std.testing.expect(nativeShortcut(event, macos) == null);
1405 1393 try std.testing.expect(interactionKey(event).terminal != null);
1406 test "paste shortcuts are platform-specific and exact" { 1394 event.mod = c.SDL_KMOD_GUI;
1407 var event = std.mem.zeroes(c.SDL_KeyboardEvent); 1395 const gui_shortcut: ?NativeShortcut = if (macos and case.shortcut == .paste) .paste else null;
1408 event.key = c.SDLK_V; 1396 try std.testing.expectEqual(gui_shortcut, nativeShortcut(event, macos));
1409 const ctrl_shift: u16 = @intCast(c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT); 1397 }
1410 event.mod = ctrl_shift;
1411 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, false).?);
1412 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?);
1413 event.mod = c.SDL_KMOD_CTRL;
1414 try std.testing.expect(nativeShortcut(event, false) == null);
1415 try std.testing.expect(interactionKey(event).terminal != null);
1416 event.mod = c.SDL_KMOD_GUI;
1417 try std.testing.expect(nativeShortcut(event, false) == null);
1418 try std.testing.expectEqual(NativeShortcut.paste, nativeShortcut(event, true).?);
1419 for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
1420 event.mod = ctrl_shift | extra;
1421 try std.testing.expect(nativeShortcut(event, false) == null);
1422 } 1398 }
1423 event.mod = ctrl_shift;
1424 event.repeat = true;
1425 try std.testing.expect(nativeShortcut(event, true) == null);
1426 } 1399 }
1427 1400
1428 test "later pane atlas growth precedes earlier pane UV generation" { 1401 test "later pane atlas growth precedes earlier pane UV generation" {
@@ -1556,7 +1529,8 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1556 const picker = try a.create(picker_mod.Picker); 1529 const picker = try a.create(picker_mod.Picker);
1557 picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; 1530 picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null };
1558 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1531 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1559 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .opening = picker, .wake_ctx = &wake, .wake = Wake.discovery } }; 1532 var events = testEvents(&rt, &wake, metrics, 800, 600);
1533 events.ui.opening = picker;
1560 defer events.deinit(); 1534 defer events.deinit();
1561 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); 1535 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
1562 picker.level = .session_name; 1536 picker.level = .session_name;
src/gui/interaction.zig
Old New
@@ -880,10 +880,7 @@ test "controller preserves exact held keys across modal dismissal and text edges
880 try std.testing.expect(!ui.command_mode and ui.dirty and ui.suppress_text); 880 try std.testing.expect(!ui.command_mode and ui.dirty and ui.suppress_text);
881 try std.testing.expectEqualStrings("", ui.notice); 881 try std.testing.expectEqualStrings("", ui.notice);
882 try std.testing.expectEqual(@as(?u32, 115), ui.consumed_key); 882 try std.testing.expectEqual(@as(?u32, 115), ui.consumed_key);
883 try ui.keyDown(.{ .code = 115, .repeat = true });
884 try std.testing.expect(!ui.command_mode and ui.suppress_text);
885 ui.keyUp(115); 883 ui.keyUp(115);
886 try std.testing.expect(ui.consumed_key == null and !ui.suppress_text);
887 try ui.keyDown(.{ .code = 102, .prefix = true }); 884 try ui.keyDown(.{ .code = 102, .prefix = true });
888 try ui.keyDown(.{ .code = 114, .kind = .r }); 885 try ui.keyDown(.{ .code = 114, .kind = .r });
889 try std.testing.expect(ui.resize_mode and !ui.command_mode); 886 try std.testing.expect(ui.resize_mode and !ui.command_mode);
@@ -956,7 +953,7 @@ test "quick splits are nonmodal and cancellable; picker directions remain explic
956 try std.testing.expectEqualStrings("TooSmall", ui.notice); 953 try std.testing.expectEqualStrings("TooSmall", ui.notice);
957 } 954 }
958 955
959 test "beginEnd records pending request without opening an ending modal" { 956 test "beginEnd stays nonmodal and delayed refusal respects command mode" {
960 var rt = runtime.Runtime.init(std.testing.allocator, .{}); 957 var rt = runtime.Runtime.init(std.testing.allocator, .{});
961 defer rt.deinit(); 958 defer rt.deinit();
962 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; 959 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
@@ -969,7 +966,12 @@ test "beginEnd records pending request without opening an ending modal" {
969 try ui.beginEnd(rt.get(id).?.key, false); 966 try ui.beginEnd(rt.get(id).?.key, false);
970 try std.testing.expect(ui.recovery == null); 967 try std.testing.expect(ui.recovery == null);
971 try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); 968 try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request);
972 ui.pending_end = null; 969 ui.command_mode = true;
970 rt.get(id).?.status.ending = .{ .request = ui.pending_end.?.request, .phase = .refused, .others = 1 };
971 try ui.pollEnd();
972 try std.testing.expect(ui.recovery == null);
973 try std.testing.expect(ui.pending_end == null);
974 try std.testing.expect(ui.command_mode);
973 } 975 }
974 976
975 test "wheel retains fractional deltas and emits whole notches" { 977 test "wheel retains fractional deltas and emits whole notches" {
test/native_fonts.py
Old New
@@ -327,7 +327,7 @@ def main():
327 rig = LifecycleRig(args.mux, args.muxg) 327 rig = LifecycleRig(args.mux, args.muxg)
328 try: 328 try:
329 exercise(rig, family=args.family, nerd=args.nerd, output=args.output) 329 exercise(rig, family=args.family, nerd=args.nerd, output=args.output)
330 print('PASS: native fonts;', rig.root, flush=True) 330 print('PASS: native fonts', flush=True)
331 finally: 331 finally:
332 rig.close() 332 rig.close()
333 333
test/native_journey.py
Old New
@@ -15,11 +15,12 @@ import time
15 sys.dont_write_bytecode = True 15 sys.dont_write_bytecode = True
16 from native_fonts import exercise as exercise_fonts, fallback_oracle, font_match 16 from native_fonts import exercise as exercise_fonts, fallback_oracle, font_match
17 from native_lifecycle import start_workspace 17 from native_lifecycle import start_workspace
18 from native_picker import sessions 18 from native_picker import sessions, workflow as picker_workflow
19 from native_resize import (by_id, clamps, drag_and_keys, modal_leakage, 19 from native_resize import (by_id, clamps, drag_and_keys, modal_leakage,
20 pixels_and_batch, pty_sizes) 20 pixels_and_batch, pty_sizes)
21 from native_selection import SelectionRig, copy_shortcut, paste_shortcut 21 from native_selection import selection_basics
22 from native_tiling import DEADLINE, colour_counts, eventually, require 22 from native_tiling import DEADLINE, colour_counts, eventually, require
23 from native_wheel import WheelRig, shell_history
23 24
24 25
25 def release_binary(rig): 26 def release_binary(rig):
@@ -61,7 +62,11 @@ def basic_workspace(rig):
61 'b pane is not beside the original pane') 62 'b pane is not beside the original pane')
62 require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], 63 require(lower_rect['y'] >= right_rect['y'] + right_rect['h'],
63 'v pane is not below the focused pane') 64 'v pane is not below the focused pane')
65 require(abs(left_rect['w'] - right_rect['w']) <= 1 and
66 abs(right_rect['h'] - lower_rect['h']) <= 1,
67 'new splits did not divide the available space equally')
64 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) 68 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']])
69 rig.assert_cli_untouched()
65 70
66 rig.focus(right) 71 rig.focus(right)
67 rig.chord('h') 72 rig.chord('h')
@@ -264,12 +269,15 @@ def independent_failures(rig, refs):
264 269
265 def main(): 270 def main():
266 require(len(sys.argv) == 3, 'usage: native_journey.py RELEASE_MUX RELEASE_MUXG') 271 require(len(sys.argv) == 3, 'usage: native_journey.py RELEASE_MUX RELEASE_MUXG')
267 rig = SelectionRig(*sys.argv[1:]) 272 rig = WheelRig(*sys.argv[1:])
268 rig.test_label = 'journey' 273 rig.test_label = 'journey'
269 try: 274 try:
270 release_binary(rig) 275 release_binary(rig)
271 refs = basic_workspace(rig) 276 refs = basic_workspace(rig)
277 picker_workflow(rig, refs)
272 quick_panes(rig, refs) 278 quick_panes(rig, refs)
279 selection_basics(rig, refs)
280 shell_history(rig, list(refs))
273 state = rig.state() 281 state = rig.state()
274 identities = [(p['id'], p['generation']) for p in state['panes']] 282 identities = [(p['id'], p['generation']) for p in state['panes']]
275 drag_and_keys(rig, identities, refs) 283 drag_and_keys(rig, identities, refs)
@@ -277,9 +285,6 @@ def main():
277 clamps(rig, identities, refs) 285 clamps(rig, identities, refs)
278 pixels_and_batch(rig, identities, refs) 286 pixels_and_batch(rig, identities, refs)
279 flood_while_responsive(rig, refs) 287 flood_while_responsive(rig, refs)
280 target = list(refs)[1]
281 copy_shortcut(rig, target)
282 paste_shortcut(rig, target)
283 288
284 family = font_family() 289 family = font_family()
285 exercise_fonts(rig, refs, family=family) 290 exercise_fonts(rig, refs, family=family)
@@ -288,7 +293,7 @@ def main():
288 all(p['phase'] == 'attached' for p in s['panes'])) 293 all(p['phase'] == 'attached' for p in s['panes']))
289 raw_snapshot_and_terminal_peer(rig, refs) 294 raw_snapshot_and_terminal_peer(rig, refs)
290 independent_failures(rig, refs) 295 independent_failures(rig, refs)
291 print(f'PASS: native journey ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) 296 print(f'PASS: native journey ({rig.checkpoints} checkpoints)', flush=True)
292 except BaseException: 297 except BaseException:
293 rig.failure_artifacts() 298 rig.failure_artifacts()
294 raise 299 raise
test/native_lifecycle.py
Old New
@@ -19,6 +19,9 @@ from native_picker import create, end, region, sessions
19 19
20 20
21 class LifecycleRig(ResizeRig): 21 class LifecycleRig(ResizeRig):
22 float_size = (960, 600)
23 float_scale = None
24
22 def __init__(self, mux, muxg): 25 def __init__(self, mux, muxg):
23 super().__init__(mux, muxg) 26 super().__init__(mux, muxg)
24 self.saved = Path(self.env['XDG_STATE_HOME']) / 'mux/native-workspace.json' 27 self.saved = Path(self.env['XDG_STATE_HOME']) / 'mux/native-workspace.json'
@@ -26,46 +29,12 @@ class LifecycleRig(ResizeRig):
26 self.saved.parent.mkdir(exist_ok=True) 29 self.saved.parent.mkdir(exist_ok=True)
27 self.cli_bytes = b'CLI-LAYOUT-UNTOUCHED\n' 30 self.cli_bytes = b'CLI-LAYOUT-UNTOUCHED\n'
28 self.cli_layout.write_bytes(self.cli_bytes) 31 self.cli_layout.write_bytes(self.cli_bytes)
29 self.gui_cwd = None
30
31 def spawn(self, argv, label, env=None, cwd=None):
32 if cwd is None:
33 return super().spawn(argv, label, env)
34 log = (self.root / (label + '.log')).open('wb')
35 self.logs.append(log)
36 proc = subprocess.Popen(argv, env=self.env if env is None else env,
37 cwd=cwd, stdout=log, stderr=subprocess.STDOUT)
38 self.procs.append(proc)
39 return proc
40 32
41 def failure_artifacts(self): 33 def failure_artifacts(self):
42 print('Native lifecycle failure artifacts:', self.root, file=sys.stderr) 34 print('Native lifecycle failure artifacts:', self.root, file=sys.stderr)
43 for path in self.root.glob('gui*.log'): 35 for path in self.root.glob('gui*.log'):
44 print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr) 36 print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr)
45 37
46 def launch_gui(self, args, label, attached=True):
47 # An empty persistent workspace has no provisional pane. Rig's original
48 # launch predicate assumes one pane, so only process setup is repeated.
49 require(self.gui is None, 'close the previous fixture window before relaunch')
50 fifo = self.root / (label + '.fifo')
51 os.mkfifo(fifo)
52 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
53 self.env['MUXG_TEST_FIFO'] = str(fifo)
54 self.gui_log = self.root / (label + '.log')
55 self.gui = self.spawn([self.muxg, *args], label, cwd=self.gui_cwd)
56 state = self.state()
57 if self.env['SDL_VIDEO_DRIVER'] == 'wayland' and os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1':
58 command = (f'[pid={self.gui.pid}] floating enable, border none, '
59 'resize set width 960 px height 600 px')
60 result = subprocess.run(['swaymsg', '-r', command], env=self.env,
61 capture_output=True, text=True, check=True, timeout=3)
62 require(all(item['success'] for item in json.loads(result.stdout)), 'could not float test window')
63 state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (960, 600))
64 if attached:
65 state = self.wait_state(lambda s: bool(s['panes']) and
66 all(p['phase'] == 'attached' for p in s['panes']))
67 return state
68
69 def assert_cli_untouched(self): 38 def assert_cli_untouched(self):
70 require(self.cli_layout.read_bytes() == self.cli_bytes, 39 require(self.cli_layout.read_bytes() == self.cli_bytes,
71 'native workspace changed terminal mux layout state') 40 'native workspace changed terminal mux layout state')
@@ -199,8 +168,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
199 rig.wait_state(lambda s: s['pending'] is None) 168 rig.wait_state(lambda s: s['pending'] is None)
200 rig.host(second_target, 'beside') 169 rig.host(second_target, 'beside')
201 rig.new_session('right') 170 rig.new_session('right')
202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) 171 before_split = rig.wait_state(lambda s: len(s['panes']) == 2 and
172 all(p['phase'] == 'attached' for p in s['panes']))
203 if picker_checks: 173 if picker_checks:
174 rig.mark(before_split['panes'][1]['id'], b, 'right', 'PICKER-RIGHT')
204 rig.open_picker('stacked') 175 rig.open_picker('stacked')
205 armed = rig.picker('hosts') 176 armed = rig.picker('hosts')
206 require(armed['pending']['direction'] == 'stacked', 177 require(armed['pending']['direction'] == 'stacked',
@@ -212,6 +183,17 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
212 rig.choose('0') 183 rig.choose('0')
213 state = rig.wait_state(lambda s: len(s['panes']) == 3 and 184 state = rig.wait_state(lambda s: len(s['panes']) == 3 and
214 all(p['phase'] == 'attached' for p in s['panes'])) 185 all(p['phase'] == 'attached' for p in s['panes']))
186 if picker_checks:
187 require(state['panes'][0]['outer'] == before_split['panes'][0]['outer'],
188 'nested insertion rebalanced the unrelated pane')
189 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mPICKER-%s\\033[0m\\n' EXISTING")
190 rig.wait_marker(a, '0', 'PICKER-EXISTING')
191 def inserted_pixels():
192 pixels = rig.last_pixels()
193 green = colour_counts(pixels, state['panes'][1]['content'], 'green')
194 red = colour_counts(pixels, state['panes'][2]['content'], 'red')
195 return green[0] > 30 and red[0] > 30 and green[1] == red[1] == 0
196 eventually(inserted_pixels, 'nested insertion erased or misplaced session glyphs')
215 refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'), 197 refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'),
216 state['panes'][2]['id']: (a, '0')} 198 state['panes'][2]['id']: (a, '0')}
217 rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']] 199 rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']]
@@ -221,7 +203,6 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
221 expected_ids = set(refs) 203 expected_ids = set(refs)
222 eventually(lambda: {p['id'] for p in rig.saved_json()['tabs'][0]['panes']} == expected_ids, 204 eventually(lambda: {p['id'] for p in rig.saved_json()['tabs'][0]['panes']} == expected_ids,
223 'saved workspace did not converge on all three fixture panes') 205 'saved workspace did not converge on all three fixture panes')
224 rig.assert_cli_untouched()
225 return refs 206 return refs
226 207
227 208
@@ -343,7 +324,7 @@ def preservation_case(mux, muxg, label, original, unreadable=False):
343 require(rig.saved.read_bytes() == original, label + ' state was overwritten') 324 require(rig.saved.read_bytes() == original, label + ' state was overwritten')
344 require('unsaved' in sessions(sock), 'preservation fallback ended its live session on close') 325 require('unsaved' in sessions(sock), 'preservation fallback ended its live session on close')
345 rig.assert_cli_untouched() 326 rig.assert_cli_untouched()
346 print(f'lifecycle OK: {label} original bytes preserved;', rig.root, flush=True) 327 print(f'lifecycle OK: {label} original bytes preserved', flush=True)
347 except BaseException: 328 except BaseException:
348 rig.failure_artifacts() 329 rig.failure_artifacts()
349 raise 330 raise
@@ -380,7 +361,7 @@ def relative_target_reopen(mux, muxg):
380 rig.quit() 361 rig.quit()
381 rig.assert_cli_untouched() 362 rig.assert_cli_untouched()
382 require('relative-kept' in sessions(sock), 'relative target session did not survive reopen') 363 require('relative-kept' in sessions(sock), 'relative target session did not survive reopen')
383 print('lifecycle OK: relative socket survives actual changed-cwd reopen;', rig.root, flush=True) 364 print('lifecycle OK: relative socket survives actual changed-cwd reopen', flush=True)
384 except BaseException: 365 except BaseException:
385 rig.failure_artifacts() 366 rig.failure_artifacts()
386 raise 367 raise
@@ -515,7 +496,6 @@ def recovery_workflow(mux, muxg):
515 rig.assert_cli_untouched() 496 rig.assert_cli_untouched()
516 rig.ok('missing sessions stay missing; in-place recovery cancels old work and a returned host is usable') 497 rig.ok('missing sessions stay missing; in-place recovery cancels old work and a returned host is usable')
517 rig.quit() 498 rig.quit()
518 print('lifecycle recovery artifacts:', rig.root, flush=True)
519 except BaseException: 499 except BaseException:
520 rig.failure_artifacts() 500 rig.failure_artifacts()
521 raise 501 raise
@@ -659,7 +639,7 @@ def main():
659 lifecycle_actions(rig, refs) 639 lifecycle_actions(rig, refs)
660 rig.quit() 640 rig.quit()
661 valid_bytes = rig.saved.read_bytes() 641 valid_bytes = rig.saved.read_bytes()
662 print(f'native lifecycle main OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) 642 print(f'native lifecycle main OK ({rig.checkpoints} checkpoints)', flush=True)
663 except BaseException: 643 except BaseException:
664 rig.failure_artifacts() 644 rig.failure_artifacts()
665 raise 645 raise
test/native_mouse.py
Old New
@@ -12,7 +12,7 @@ import sys
12 import time 12 import time
13 13
14 sys.dont_write_bytecode = True 14 sys.dont_write_bytecode = True
15 from native_lifecycle import start_persistent 15 from native_lifecycle import start_workspace
16 from native_resize import by_id 16 from native_resize import by_id
17 from native_selection import SelectionRig 17 from native_selection import SelectionRig
18 from native_tiling import eventually, require 18 from native_tiling import eventually, require
@@ -190,13 +190,13 @@ def main():
190 require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG') 190 require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG')
191 rig = MouseRig(*sys.argv[1:]) 191 rig = MouseRig(*sys.argv[1:])
192 try: 192 try:
193 refs = start_persistent(rig) 193 refs = start_workspace(rig)
194 panes = list(refs) 194 panes = list(refs)
195 application_drag(rig, panes[1], panes[2]) 195 application_drag(rig, panes[1], panes[2])
196 rig.kernel_sizes() 196 rig.kernel_sizes()
197 rig.assert_cli_untouched() 197 rig.assert_cli_untouched()
198 rig.quit() 198 rig.quit()
199 print('Native mouse acceptance passed; artifacts:', rig.root, flush=True) 199 print('Native mouse acceptance passed', flush=True)
200 except BaseException: 200 except BaseException:
201 rig.failure_artifacts() 201 rig.failure_artifacts()
202 raise 202 raise
test/native_picker.py
Old New
@@ -8,7 +8,7 @@ import sys
8 import threading 8 import threading
9 import time 9 import time
10 10
11 from native_tiling import Rig, colour_counts, eventually, require 11 from native_tiling import eventually, require
12 12
13 13
14 def read_exact(stream, count): 14 def read_exact(stream, count):
@@ -130,12 +130,15 @@ class SlowHost:
130 "slow-host fixture leaked a worker") 130 "slow-host fixture leaked a worker")
131 131
132 132
133 def workflow(rig): 133 def workflow(rig, refs):
134 first, _ = rig.daemon("picker-a") 134 """Picker editing on the journey's already attached three-pane workspace."""
135 second, _ = rig.daemon("picker-b", quic=True) 135 left, right, _ = refs
136 first_target, second_target = "--sock " + first, rig.targets[second] 136 first, _ = refs[left]
137 rig.catalogue([first_target, second_target]) 137 second, _ = refs[right]
138 initial = rig.launch_gui(["--sock", first, "--session", "left"], "gui-picker") 138 first_target = "--sock " + first
139 second_target = rig.targets.get(second, "--sock " + second)
140 initial = rig.state()
141 rig.focus(right)
139 original_layout = [p["outer"] for p in initial["panes"]] 142 original_layout = [p["outer"] for p in initial["panes"]]
140 before = sessions(second) 143 before = sessions(second)
141 rig.host(second_target, "beside") 144 rig.host(second_target, "beside")
@@ -146,32 +149,12 @@ def workflow(rig):
146 require(sessions(second) == before, "cancelling picker created a session") 149 require(sessions(second) == before, "cancelling picker created a session")
147 require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout") 150 require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout")
148 require(state["pending"] is not None, "closing picker lost armed direction") 151 require(state["pending"] is not None, "closing picker lost armed direction")
149 require("CANCELLED-NAME" not in rig.dump(first, "left"), "editor text leaked to PTY") 152 require(all("CANCELLED-NAME" not in rig.dump(sock, session)
153 for sock, session in refs.values()), "editor text leaked to PTY")
150 rig.ok("host/editor back and cancel preserve layout, session table and armed split") 154 rig.ok("host/editor back and cancel preserve layout, session table and armed split")
151 155
152 rig.host(second_target) 156 rig.key("escape")
153 rig.new_session("right") 157 rig.wait_state(lambda s: s["pending"] is None)
154 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and not s.get("picker") and
155 all(p["phase"] == "attached" for p in s["panes"]))
156 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mPICKER-%s\\033[0m\\n' RIGHT")
157 rig.wait_marker(second, "right", "PICKER-RIGHT")
158 require("PICKER-RIGHT" not in rig.dump(first, "left"), "new pane input reached old pane")
159 untouched = state["panes"][0]["outer"]
160 rig.host(first_target, "stacked")
161 rig.choose("0")
162 state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and
163 all(p["phase"] == "attached" for p in s["panes"]))
164 require(state["panes"][0]["outer"] == untouched, "nested split rebalanced unrelated pane")
165 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mPICKER-%s\\033[0m\\n' EXISTING")
166 rig.wait_marker(first, "0", "PICKER-EXISTING")
167 rig.wait_state(lambda s: "PICKER-EXISTING" in s["panes"][2]["painted_text"])
168 pixels = rig.pixels()
169 require(colour_counts(pixels, state["panes"][2]["content"], "red")[0] > 30,
170 "existing session glyphs missing from nested pane framebuffer")
171 green = colour_counts(pixels, state["panes"][1]["content"], "green")
172 require(green[0] > 30 and green[1] == 0, "nested split erased or misplaced its neighbour's glyphs")
173 rig.ok("picker creates over QUIC and joins existing session in nested split with real input and pixels")
174
175 rig.host(first_target, "beside") 158 rig.host(first_target, "beside")
176 rig.choose("New session...") 159 rig.choose("New session...")
177 rig.picker("session_name") 160 rig.picker("session_name")
@@ -184,8 +167,15 @@ def workflow(rig):
184 require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree") 167 require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree")
185 rig.send("resize:960x600", "key:escape", "key:escape", "key:escape") 168 rig.send("resize:960x600", "key:escape", "key:escape", "key:escape")
186 rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker")) 169 rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker"))
170 rig.key("escape")
171 rig.wait_state(lambda s: s["pending"] is None)
187 rig.ok("window becoming too small during name entry refuses split before creating a remote shell") 172 rig.ok("window becoming too small during name entry refuses split before creating a remote shell")
188 173
174
175 def races(rig, refs):
176 first, left_session = refs[next(iter(refs))]
177 second, right_session = refs[list(refs)[1]]
178 first_target = "--sock " + first
189 # Change the server after the user has already entered a fresh name. 179 # Change the server after the user has already entered a fresh name.
190 rig.host(first_target, "beside") 180 rig.host(first_target, "beside")
191 rig.choose("New session...") 181 rig.choose("New session...")
@@ -211,7 +201,7 @@ def workflow(rig):
211 require("raced-name" not in sessions(first), "vanished selection recreated a shell") 201 require("raced-name" not in sessions(first), "vanished selection recreated a shell")
212 rig.ok("session disappearing after listing is never recreated by selection") 202 rig.ok("session disappearing after listing is never recreated by selection")
213 rig.quit() 203 rig.quit()
214 require("right" in sessions(second) and "left" in sessions(first), "GUI close ended a session") 204 require(right_session in sessions(second) and left_session in sessions(first), "GUI close ended a session")
215 205
216 206
217 def add_host(rig): 207 def add_host(rig):
@@ -332,14 +322,15 @@ def old_daemon(rig, binary):
332 322
333 323
334 def main(): 324 def main():
335 rig = Rig(*sys.argv[1:3]) 325 from native_lifecycle import LifecycleRig, start_workspace
326 rig = LifecycleRig(*sys.argv[1:3])
336 try: 327 try:
337 workflow(rig) 328 races(rig, start_workspace(rig, quic=True))
338 add_host(rig) 329 add_host(rig)
339 slow_and_legacy(rig) 330 slow_and_legacy(rig)
340 if binary := os.environ.get("MUXG_OLD_DAEMON"): 331 if binary := os.environ.get("MUXG_OLD_DAEMON"):
341 old_daemon(rig, binary) 332 old_daemon(rig, binary)
342 print(f"native picker OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}") 333 print(f"native picker OK ({rig.checkpoints} checkpoints)")
343 except BaseException: 334 except BaseException:
344 print("Native picker failure artifacts:", rig.root, file=sys.stderr) 335 print("Native picker failure artifacts:", rig.root, file=sys.stderr)
345 raise 336 raise
test/native_resize.py
Old New
@@ -1,12 +1,10 @@
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 """Real SDL divider input, three daemon PTYs, and nonforcing frame oracles.""" 2 """Real SDL divider input, three daemon PTYs, and nonforcing frame oracles."""
3 import fcntl 3 import fcntl
4 import json
5 import os 4 import os
6 from pathlib import Path 5 from pathlib import Path
7 import shlex 6 import shlex
8 import struct 7 import struct
9 import subprocess
10 import sys 8 import sys
11 import termios 9 import termios
12 10
@@ -15,29 +13,14 @@ from native_tiling import Rig, colour_counts, eventually, require
15 13
16 14
17 class ResizeRig(Rig): 15 class ResizeRig(Rig):
16 float_size = (1000, 650)
17 float_scale = 2
18
18 def __init__(self, mux, muxg): 19 def __init__(self, mux, muxg):
19 super().__init__(mux, muxg) 20 super().__init__(mux, muxg)
20 self.env['MUXG_TEST_RETAIN_FRAME'] = '1' 21 self.env['MUXG_TEST_RETAIN_FRAME'] = '1'
21 self.tty_paths = {} 22 self.tty_paths = {}
22 23
23 def launch_gui(self, args, label, attached=True):
24 state = super().launch_gui(args, label, attached)
25 if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1':
26 require(self.env['SDL_VIDEO_DRIVER'] == 'wayland', 'Sway floating requires Wayland')
27 # Remove only this fixture's compositor decorations so requested
28 # dimensions describe the SDL client area, as in the offscreen run.
29 command = (f'[pid={self.gui.pid}] floating enable, border none, '
30 'resize set width 1000 px height 650 px')
31 result = subprocess.run(['swaymsg', '-r', command], env=self.env,
32 capture_output=True, text=True, check=True, timeout=3)
33 require(all(item['success'] for item in json.loads(result.stdout)),
34 'could not float this test window')
35 state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (1000, 650))
36 require(state['width'] == state['logical_width'] * 2 and
37 state['height'] == state['logical_height'] * 2,
38 'Wayland resize acceptance requires the expected 200% density')
39 return state
40
41 def logical(self, state, x, y): 24 def logical(self, state, x, y):
42 return (x * state['logical_width'] / state['width'], 25 return (x * state['logical_width'] / state['width'],
43 y * state['logical_height'] / state['height']) 26 y * state['logical_height'] / state['height'])
@@ -131,33 +114,6 @@ def pty_sizes(rig, sessions, identities):
131 require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch') 114 require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch')
132 115
133 116
134 def build_nested(rig):
135 a, _ = rig.daemon('resize-left')
136 b, _ = rig.daemon('resize-upper')
137 c, _ = rig.daemon('resize-lower')
138 rig.catalogue(['--sock ' + sock for sock in (a, b, c)])
139 rig.launch_gui(['--sock', a, '--session', 'left'], 'gui-resize')
140 rig.host('--sock ' + b, direction='beside')
141 rig.new_session('upper')
142 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes']))
143 rig.host('--sock ' + c, direction='stacked')
144 rig.new_session('lower')
145 state = rig.wait_state(lambda s: len(s['panes']) == 3 and all(p['phase'] == 'attached' for p in s['panes']))
146 identities = [(p['id'], p['generation']) for p in state['panes']]
147 rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']]
148 sessions = {identities[0][0]: (a, 'left'), identities[1][0]: (b, 'upper'),
149 identities[2][0]: (c, 'lower')}
150 for pane_id in sessions:
151 rig.focus(pane_id)
152 path = rig.root / f'tty-{pane_id}'
153 rig.shell('tty > ' + shlex.quote(str(path)))
154 eventually(lambda: path.exists() and path.stat().st_size > 0, 'tty path did not arrive')
155 rig.tty_paths[pane_id] = path.read_text().strip()
156 pty_sizes(rig, sessions, identities)
157 rig.ok('three nested panes have stable identities and independently measured PTY sizes')
158 return identities, sessions
159
160
161 def drag_and_keys(rig, identities, sessions): 117 def drag_and_keys(rig, identities, sessions):
162 state = rig.state() 118 state = rig.state()
163 dx = state['cell_w'] * 5 119 dx = state['cell_w'] * 5
@@ -231,7 +187,7 @@ def clamps(rig, identities, sessions):
231 pty_sizes(rig, sessions, identities) 187 pty_sizes(rig, sessions, identities)
232 before_tiny = state 188 before_tiny = state
233 rig.send('resize:8x8') 189 rig.send('resize:8x8')
234 if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': 190 if rig.env['SDL_VIDEO_DRIVER'] == 'wayland':
235 state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100) 191 state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100)
236 if (state['logical_width'], state['logical_height']) != (8, 8): 192 if (state['logical_width'], state['logical_height']) != (8, 8):
237 print('resize note: Sway minimum clamped requested 8x8 to ' 193 print('resize note: Sway minimum clamped requested 8x8 to '
@@ -301,34 +257,24 @@ def pixels_and_batch(rig, identities, sessions):
301 pty_sizes(rig, sessions, identities) 257 pty_sizes(rig, sessions, identities)
302 rig.ok('batched drag release, window resize and input preserve final PTY size ordering') 258 rig.ok('batched drag release, window resize and input preserve final PTY size ordering')
303 259
304 260 rig.send('resize:640x400')
305 def main(): 261 rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (640, 400))
306 require(len(sys.argv) == 3, 'usage: native_resize.py MUX MUXG') 262 pty_sizes(rig, sessions, identities)
307 rig = ResizeRig(*sys.argv[1:3]) 263 eventually(clean_regions, 'window shrink misplaced coloured pane pixels')
308 try: 264 rig.focus(identities[0][0])
309 version = subprocess.run([rig.muxg, '--version'], capture_output=True, text=True, check=True) 265 rig.shell("printf '\\033[0m\\033[2J\\033[H'")
310 require(any(mode in version.stdout + version.stderr for mode in ('ReleaseSafe', 'ReleaseFast')), 266 def cleared():
311 'resize acceptance requires a release build') 267 state = rig.state()
312 identities, sessions = build_nested(rig) 268 capture = rig.last_pixels()
313 drag_and_keys(rig, identities, sessions) 269 if sum(colour_counts(capture, by_id(state)[identities[0][0]]['content'], 'red')):
314 modal_leakage(rig, identities) 270 return False
315 clamps(rig, identities, sessions) 271 for index, colour in ((1, 'green'), (2, 'blue')):
316 pixels_and_batch(rig, identities, sessions) 272 inside, outside = colour_counts(capture, by_id(state)[identities[index][0]]['content'], colour)
317 rig.quit() 273 if inside < 100 or outside:
318 for sock, session in sessions.values(): 274 return False
319 require(rig.command('a', 'status', '--sock', sock, '--session', session, 275 return True
320 '--timeout', '1000', check=False).returncode == 0, 276 eventually(cleared, 'clear after window shrink left old pixels or erased a neighbour')
321 'window close ended a resized session') 277 rig.send('resize:800x520')
322 rig.ok('window close preserves all three resized sessions') 278 rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520))
323 print(f'native resize OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) 279 pty_sizes(rig, sessions, identities)
324 except BaseException: 280 rig.ok('window shrink and terminal clear erase old pixels without erasing neighbours')
325 print('Native resize failure artifacts:', rig.root, file=sys.stderr)
326 for path in rig.root.glob('gui*.log'):
327 print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr)
328 raise
329 finally:
330 rig.close()
331
332
333 if __name__ == '__main__':
334 main()
test/native_scale.py
Old New
@@ -12,7 +12,7 @@ import subprocess
12 import sys 12 import sys
13 13
14 sys.dont_write_bytecode = True 14 sys.dont_write_bytecode = True
15 from native_resize import (ResizeRig, build_nested, drag_and_keys, pty_sizes, 15 from native_resize import (clamps, drag_and_keys, modal_leakage, pty_sizes,
16 pixels_and_batch, edge, one_divider) 16 pixels_and_batch, edge, one_divider)
17 from native_tiling import require 17 from native_tiling import require
18 18
@@ -24,6 +24,7 @@ def sway(*args):
24 24
25 25
26 def main(): 26 def main():
27 from native_lifecycle import LifecycleRig, start_workspace
27 parser = argparse.ArgumentParser(description=__doc__) 28 parser = argparse.ArgumentParser(description=__doc__)
28 parser.add_argument('mux') 29 parser.add_argument('mux')
29 parser.add_argument('muxg') 30 parser.add_argument('muxg')
@@ -42,16 +43,20 @@ def main():
42 'compositor refused scale change') 43 'compositor refused scale change')
43 44
44 os.environ['MUXG_VIDEODRIVER'] = 'wayland' 45 os.environ['MUXG_VIDEODRIVER'] = 'wayland'
45 rig = ResizeRig(args.mux, args.muxg) 46 rig = LifecycleRig(args.mux, args.muxg)
47 rig.float_size, rig.float_scale = (1000, 650), 2
46 try: 48 try:
47 scale(2) 49 scale(2)
48 identities, sessions = build_nested(rig) 50 sessions = start_workspace(rig)
51 identities = [(p['id'], p['generation']) for p in rig.state()['panes']]
49 command = (f'[pid={rig.gui.pid}] move container to output {args.output}, ' 52 command = (f'[pid={rig.gui.pid}] move container to output {args.output}, '
50 'floating enable, border none, resize set width 1000 px height 650 px') 53 'floating enable, border none, resize set width 1000 px height 650 px')
51 require(all(r['success'] for r in sway(command)), 'could not place owned window') 54 require(all(r['success'] for r in sway(command)), 'could not place owned window')
52 rig.wait_state(lambda s: s['logical_width'] == 1000 and s['logical_height'] == 650 55 rig.wait_state(lambda s: s['logical_width'] == 1000 and s['logical_height'] == 650
53 and s['width'] == 2 * s['logical_width']) 56 and s['width'] == 2 * s['logical_width'])
54 drag_and_keys(rig, identities, sessions) 57 drag_and_keys(rig, identities, sessions)
58 modal_leakage(rig, identities)
59 clamps(rig, identities, sessions)
55 pixels_and_batch(rig, identities, sessions) 60 pixels_and_batch(rig, identities, sessions)
56 for value in (1, 1.5, 2): 61 for value in (1, 1.5, 2):
57 state = rig.state() 62 state = rig.state()
@@ -72,12 +77,12 @@ def main():
72 (rig.root / 'scale-final.json').write_text(json.dumps(rig.state(), indent=2)) 77 (rig.root / 'scale-final.json').write_text(json.dumps(rig.state(), indent=2))
73 rig.quit() 78 rig.quit()
74 print('PASS: Wayland 200% -> 100% -> 150% -> 200%; drag cancellation, ' 79 print('PASS: Wayland 200% -> 100% -> 150% -> 200%; drag cancellation, '
75 'nested resize, PTYs and pixels;', rig.root, flush=True) 80 'nested resize, PTYs and pixels', flush=True)
76 finally: 81 finally:
77 try: 82 try:
78 rig.close()
79 finally:
80 scale(original_scale) 83 scale(original_scale)
84 finally:
85 rig.close()
81 86
82 87
83 if __name__ == '__main__': 88 if __name__ == '__main__':
test/native_selection.py
Old New
@@ -8,7 +8,7 @@ import sys
8 import time 8 import time
9 9
10 sys.dont_write_bytecode = True 10 sys.dont_write_bytecode = True
11 from native_lifecycle import LifecycleRig, start_persistent 11 from native_lifecycle import LifecycleRig, start_workspace
12 from native_resize import by_id 12 from native_resize import by_id
13 from native_theme import pixel 13 from native_theme import pixel
14 from native_tiling import eventually, require 14 from native_tiling import eventually, require
@@ -36,13 +36,13 @@ class SelectionRig(LifecycleRig):
36 else: 36 else:
37 super().send(line) 37 super().send(line)
38 38
39 def close(self): 39 def close(self, success=None):
40 try: 40 try:
41 if self.pointer is not None: 41 if self.pointer is not None:
42 self.pointer.close() 42 self.pointer.close()
43 self.pointer = None 43 self.pointer = None
44 finally: 44 finally:
45 super().close() 45 super().close(success=success)
46 46
47 def clipboard(self): 47 def clipboard(self):
48 value = self.artifact('clipboard', '.txt').read_text() 48 value = self.artifact('clipboard', '.txt').read_text()
@@ -205,7 +205,7 @@ def start_paste_reader(rig, pane):
205 ' with received.open("ab") as out: out.write(data)\n' 205 ' with received.open("ab") as out: out.write(data)\n'
206 'finally:\n' 206 'finally:\n'
207 ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n' 207 ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n'
208 ' print("\\r\\nPASTE-DONE", flush=True)\n') 208 ' print("\\033[?2004l\\r\\nPASTE-DONE", flush=True)\n')
209 rig.focus(pane) 209 rig.focus(pane)
210 rig.shell('python3 ' + shlex.quote(str(source))) 210 rig.shell('python3 ' + shlex.quote(str(source)))
211 rig.wait_state(lambda state: 'PASTE-READY' in by_id(state)[pane]['painted_text']) 211 rig.wait_state(lambda state: 'PASTE-READY' in by_id(state)[pane]['painted_text'])
@@ -370,15 +370,13 @@ def active_output(rig, pane):
370 rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text']) 370 rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text'])
371 371
372 372
373 def exercise(rig): 373 def selection_basics(rig, refs):
374 refs = start_persistent(rig) 374 """Nondestructive clipboard and pointer checks on the shared workspace."""
375 rig.drag('stacked', dy=rig.state()['cell_h'] * 3)
376 panes = list(refs) 375 panes = list(refs)
377 active_output(rig, panes[1])
378 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes}
379 target, neighbour = panes[1], panes[2] 376 target, neighbour = panes[1], panes[2]
380 copy_shortcut(rig, target) 377 copy_shortcut(rig, target)
381 paste_shortcut(rig, target) 378 paste_shortcut(rig, target)
379 wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes}
382 rig.focus(panes[0]) 380 rig.focus(panes[0])
383 state = rig.state() 381 state = rig.state()
384 before = {p: cell_background(rig, state, p, 1, 1) for p in panes} 382 before = {p: cell_background(rig, state, p, 1, 1) for p in panes}
@@ -455,6 +453,15 @@ def exercise(rig):
455 rig.select(target, (0, 1), (4, 1)) 453 rig.select(target, (0, 1), (4, 1))
456 rig.copied('alpha') 454 rig.copied('alpha')
457 455
456
457 def selection_races(rig, refs):
458 """Real paused-daemon replies, scale cancellation, and destructive detach."""
459 panes = list(refs)
460 target, neighbour = panes[1], panes[2]
461 active_output(rig, target)
462 specimen(rig, target, 'RACES')
463 rig.select(target, (0, 1), (4, 1))
464 rig.copied('alpha')
458 # Pause only this fixture's real daemon. The queued selection is still 465 # Pause only this fixture's real daemon. The queued selection is still
459 # decoded/extracted by that daemon when resumed, not by a protocol mock. 466 # decoded/extracted by that daemon when resumed, not by a protocol mock.
460 sock = refs[target][0] 467 sock = refs[target][0]
@@ -521,9 +528,14 @@ def main():
521 require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG') 528 require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG')
522 rig = SelectionRig(*sys.argv[1:]) 529 rig = SelectionRig(*sys.argv[1:])
523 try: 530 try:
524 exercise(rig) 531 refs = start_workspace(rig)
532 # Offscreen basics belong to the journey; Wayland also checks the
533 # compositor clipboard through an independent wl-paste client.
534 if rig.env['SDL_VIDEO_DRIVER'] == 'wayland':
535 selection_basics(rig, refs)
536 selection_races(rig, refs)
525 rig.quit() 537 rig.quit()
526 print('Native selection acceptance passed; artifacts:', rig.root, flush=True) 538 print('Native selection acceptance passed', flush=True)
527 except BaseException: 539 except BaseException:
528 rig.failure_artifacts() 540 rig.failure_artifacts()
529 raise 541 raise
test/native_selection_follow.py
Old New
@@ -11,7 +11,7 @@ import sys
11 import time 11 import time
12 12
13 sys.dont_write_bytecode = True 13 sys.dont_write_bytecode = True
14 from native_lifecycle import start_persistent 14 from native_lifecycle import start_workspace
15 from native_resize import by_id 15 from native_resize import by_id
16 from native_selection import SelectionRig, cell_background 16 from native_selection import SelectionRig, cell_background
17 from native_tiling import eventually, require 17 from native_tiling import eventually, require
@@ -211,8 +211,7 @@ def invalidation(rig, target, control, neighbour):
211 211
212 212
213 def exercise(rig): 213 def exercise(rig):
214 refs = start_persistent(rig) 214 refs = start_workspace(rig)
215 rig.drag('stacked', dy=rig.state()['cell_h'] * 3)
216 panes = list(refs) 215 panes = list(refs)
217 target, neighbour = panes[1], panes[2] 216 target, neighbour = panes[1], panes[2]
218 shell_primary_scroll(rig, target, neighbour) 217 shell_primary_scroll(rig, target, neighbour)
@@ -227,7 +226,7 @@ def main():
227 try: 226 try:
228 exercise(rig) 227 exercise(rig)
229 rig.quit() 228 rig.quit()
230 print('Native selection-follow acceptance passed; artifacts:', rig.root, flush=True) 229 print('Native selection-follow acceptance passed', flush=True)
231 except BaseException: 230 except BaseException:
232 rig.failure_artifacts() 231 rig.failure_artifacts()
233 raise 232 raise
test/native_ssh.py
Old New
@@ -244,7 +244,7 @@ def main():
244 ssh.stop() 244 ssh.stop()
245 finally: 245 finally:
246 r.close() 246 r.close()
247 print('Real SSH native checks passed; owned fixtures stopped. Evidence:', r.root, flush=True) 247 print('Real SSH native checks passed; owned fixtures stopped.', flush=True)
248 248
249 249
250 if __name__ == '__main__': 250 if __name__ == '__main__':
test/native_stress.py
Old New
@@ -15,9 +15,9 @@ import subprocess
15 import sys 15 import sys
16 import time 16 import time
17 17
18 from native_lifecycle import LifecycleRig, start_persistent 18 from native_lifecycle import LifecycleRig, start_workspace
19 from native_resize import by_id 19 from native_resize import by_id
20 from native_tiling import eventually, require 20 from native_tiling import eventually, output_flood, require
21 21
22 22
23 class StressRig(LifecycleRig): 23 class StressRig(LifecycleRig):
@@ -81,7 +81,8 @@ def main():
81 require('ReleaseSafe' in version or 'ReleaseFast' in version, 'stress requires release binaries') 81 require('ReleaseSafe' in version or 'ReleaseFast' in version, 'stress requires release binaries')
82 report['binaries'] = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() 82 report['binaries'] = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest()
83 for path in (r.mux, r.muxg)} 83 for path in (r.mux, r.muxg)}
84 refs = start_persistent(r) 84 refs = start_workspace(r)
85 output_flood(r, refs)
85 ids = list(refs) 86 ids = list(refs)
86 flooded, separate, shared = ids 87 flooded, separate, shared = ids
87 report['panes'] = {'flooded': flooded, 'separate_daemon': separate, 'shared_daemon': shared} 88 report['panes'] = {'flooded': flooded, 'separate_daemon': separate, 'shared_daemon': shared}
@@ -136,7 +137,7 @@ def main():
136 r.assert_cli_untouched() 137 r.assert_cli_untouched()
137 report['passed'] = True 138 report['passed'] = True
138 print(json.dumps(report, indent=2), flush=True) 139 print(json.dumps(report, indent=2), flush=True)
139 print('native stress OK; report:', report_path, flush=True) 140 print('native stress OK', flush=True)
140 except BaseException: 141 except BaseException:
141 r.failure_artifacts() 142 r.failure_artifacts()
142 raise 143 raise
@@ -147,9 +148,9 @@ def main():
147 except ProcessLookupError: 148 except ProcessLookupError:
148 pass 149 pass
149 try: 150 try:
150 r.close()
151 finally:
152 report_path.write_text(json.dumps(report, indent=2) + '\n') 151 report_path.write_text(json.dumps(report, indent=2) + '\n')
152 finally:
153 r.close()
153 154
154 155
155 if __name__ == '__main__': 156 if __name__ == '__main__':
test/native_theme.py
Old New
@@ -4,7 +4,7 @@ import shlex
4 import sys 4 import sys
5 5
6 sys.dont_write_bytecode = True 6 sys.dont_write_bytecode = True
7 from native_resize import ResizeRig, build_nested, by_id, pty_sizes 7 from native_resize import by_id, pty_sizes
8 from native_tiling import eventually, require 8 from native_tiling import eventually, require
9 9
10 10
@@ -101,11 +101,14 @@ def modal_samples(rig, kind):
101 101
102 102
103 def main(): 103 def main():
104 from native_lifecycle import LifecycleRig, start_workspace
104 require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG') 105 require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG')
105 rig = ResizeRig(*sys.argv[1:]) 106 rig = LifecycleRig(*sys.argv[1:])
107 rig.float_size, rig.float_scale = (1000, 650), 2
106 rig.env['MUXG_TEST_THEME'] = 'trial' 108 rig.env['MUXG_TEST_THEME'] = 'trial'
107 try: 109 try:
108 identities, sessions = build_nested(rig) 110 sessions = start_workspace(rig)
111 identities = [(p['id'], p['generation']) for p in rig.state()['panes']]
109 for pane_id, (sock, session) in sessions.items(): 112 for pane_id, (sock, session) in sessions.items():
110 paint_specimen(rig, pane_id, sock, session) 113 paint_specimen(rig, pane_id, sock, session)
111 for focused in sessions: 114 for focused in sessions:
@@ -155,7 +158,7 @@ def main():
155 'detaching removed the daemon session') 158 'detaching removed the daemon session')
156 rig.ok('empty workspace and Add pane are themed; detached sessions survive') 159 rig.ok('empty workspace and Add pane are themed; detached sessions survive')
157 rig.quit() 160 rig.quit()
158 print('PASS: native appearance;', rig.root, flush=True) 161 print('PASS: native appearance', flush=True)
159 finally: 162 finally:
160 rig.close() 163 rig.close()
161 164
test/native_theme_config.py
Old New
@@ -10,7 +10,7 @@ import sys
10 10
11 sys.dont_write_bytecode = True 11 sys.dont_write_bytecode = True
12 from native_fonts import write_config 12 from native_fonts import write_config
13 from native_lifecycle import LifecycleRig, start_persistent 13 from native_lifecycle import LifecycleRig, start_workspace
14 from native_resize import by_id 14 from native_resize import by_id
15 from native_theme import check_samples, edge_sample 15 from native_theme import check_samples, edge_sample
16 from native_tiling import eventually, require 16 from native_tiling import eventually, require
@@ -131,11 +131,10 @@ def main():
131 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs'])) 131 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
132 original_scale = next(o['scale'] for o in outputs if o['name'] == args.output) 132 original_scale = next(o['scale'] for o in outputs if o['name'] == args.output)
133 scale(2) 133 scale(2)
134 refs = start_persistent(rig) 134 refs = start_workspace(rig)
135 state = rig.state() 135 state = rig.state()
136 check_samples(rig, [edge_sample('default background', p['content'], '101010') 136 check_samples(rig, [edge_sample('default background', p['content'], '101010')
137 for p in state['panes']], 'fresh no-config legacy appearance') 137 for p in state['panes']], 'fresh no-config legacy appearance')
138 rig.drag('stacked', dy=state['cell_h']*4)
139 ids = shell_ids(rig, refs) 138 ids = shell_ids(rig, refs)
140 cfg = write_config(rig, '') 139 cfg = write_config(rig, '')
141 themes = cfg.parent / 'themes'; themes.mkdir() 140 themes = cfg.parent / 'themes'; themes.mkdir()
@@ -236,10 +235,13 @@ def main():
236 rig.assert_cli_untouched() 235 rig.assert_cli_untouched()
237 rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified') 236 rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified')
238 (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2)) 237 (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2))
239 print('PASS: native theme config;', rig.root, flush=True) 238 print('PASS: native theme config', flush=True)
240 finally: 239 finally:
241 rig.close() 240 try:
242 if original_scale is not None: scale(original_scale) 241 if original_scale is not None:
242 scale(original_scale)
243 finally:
244 rig.close()
243 245
244 246
245 if __name__ == '__main__': 247 if __name__ == '__main__':
test/native_tiling.py
Old New
@@ -1,5 +1,5 @@
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 """Two independent daemons through the native client's real SDL event paths. 2 """Shared real-SDL fixture and focused unslept output-flood acceptance.
3 3
4 The state hook describes geometry; daemon grids, PTY dimensions, and framebuffer 4 The state hook describes geometry; daemon grids, PTY dimensions, and framebuffer
5 pixels independently establish that input and rendering use that geometry. 5 pixels independently establish that input and rendering use that geometry.
@@ -9,6 +9,7 @@ import os
9 from pathlib import Path 9 from pathlib import Path
10 import re 10 import re
11 import shlex 11 import shlex
12 import shutil
12 import signal 13 import signal
13 import socket 14 import socket
14 import subprocess 15 import subprocess
@@ -37,9 +38,13 @@ def eventually(probe, message, seconds=DEADLINE):
37 38
38 39
39 class Rig: 40 class Rig:
41 float_size = (960, 600)
42 float_scale = None
43
40 def __init__(self, mux, muxg): 44 def __init__(self, mux, muxg):
41 self.mux, self.muxg = str(Path(mux).resolve()), str(Path(muxg).resolve()) 45 self.mux, self.muxg = str(Path(mux).resolve()), str(Path(muxg).resolve())
42 self.root = Path(tempfile.mkdtemp(prefix="muxg-tiling-")) 46 self.root = Path(tempfile.mkdtemp(prefix="muxg-tiling-"))
47 self._owned_root = self.root
43 self.env = os.environ.copy() 48 self.env = os.environ.copy()
44 # Preserve the compositor address before isolating runtime state. 49 # Preserve the compositor address before isolating runtime state.
45 self.env["WAYLAND_DISPLAY"] = str(Path(self.env.get("XDG_RUNTIME_DIR", "/tmp")) / 50 self.env["WAYLAND_DISPLAY"] = str(Path(self.env.get("XDG_RUNTIME_DIR", "/tmp")) /
@@ -54,16 +59,17 @@ class Rig:
54 self.procs, self.logs, self.daemons = [], [], [] 59 self.procs, self.logs, self.daemons = [], [], []
55 self.targets = {} 60 self.targets = {}
56 self.gui = None 61 self.gui = None
62 self.gui_cwd = None
57 self.fd = None 63 self.fd = None
58 self.serial = 0 64 self.serial = 0
59 self.checkpoints = 0 65 self.checkpoints = 0
60 self.test_label = "tiling" 66 self.test_label = "tiling"
61 67
62 def spawn(self, argv, label, env=None): 68 def spawn(self, argv, label, env=None, cwd=None):
63 log = (self.root / (label + ".log")).open("wb") 69 log = (self.root / (label + ".log")).open("wb")
64 self.logs.append(log) 70 self.logs.append(log)
65 proc = subprocess.Popen(argv, env=self.env if env is None else env, 71 proc = subprocess.Popen(argv, env=self.env if env is None else env,
66 stdout=log, stderr=subprocess.STDOUT) 72 cwd=cwd, stdout=log, stderr=subprocess.STDOUT)
67 self.procs.append(proc) 73 self.procs.append(proc)
68 return proc 74 return proc
69 75
@@ -110,14 +116,39 @@ class Rig:
110 return self.launch_gui(["--sock", first, "--session", "left"], label) 116 return self.launch_gui(["--sock", first, "--session", "left"], label)
111 117
112 def launch_gui(self, args, label, attached=True): 118 def launch_gui(self, args, label, attached=True):
119 require(self.gui is None, "close the previous fixture window before relaunch")
113 fifo = self.root / (label + ".fifo") 120 fifo = self.root / (label + ".fifo")
114 os.mkfifo(fifo) 121 os.mkfifo(fifo)
115 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) 122 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
116 self.env["MUXG_TEST_FIFO"] = str(fifo) 123 self.env["MUXG_TEST_FIFO"] = str(fifo)
117 self.gui_log = self.root / (label + ".log") 124 self.gui_log = self.root / (label + ".log")
118 self.gui = self.spawn([self.muxg, *args], label) 125 self.gui = self.spawn([self.muxg, *args], label, cwd=self.gui_cwd)
119 return self.wait_state(lambda s: len(s["panes"]) == 1 and 126 state = self.state()
120 (not attached or s["panes"][0]["phase"] == "attached")) 127 if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1' and (
128 self.env['SDL_VIDEO_DRIVER'] == 'wayland' or self.float_scale is not None):
129 require(self.env['SDL_VIDEO_DRIVER'] == 'wayland', 'Sway floating requires Wayland')
130 def mapped(node):
131 return node.get('pid') == self.gui.pid or any(
132 mapped(child) for child in node.get('nodes', []) + node.get('floating_nodes', []))
133 eventually(lambda: mapped(json.loads(subprocess.check_output(
134 ['swaymsg', '-r', '-t', 'get_tree'], env=self.env, timeout=3))),
135 'owned test window did not map before floating')
136 width, height = self.float_size
137 command = (f'[pid={self.gui.pid}] floating enable, border none, '
138 f'resize set width {width} px height {height} px')
139 result = subprocess.run(['swaymsg', '-r', command], env=self.env,
140 capture_output=True, text=True, check=True, timeout=3)
141 require(all(item['success'] for item in json.loads(result.stdout)),
142 'could not float this test window')
143 state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == self.float_size)
144 if self.float_scale is not None:
145 require(state['width'] == state['logical_width'] * self.float_scale and
146 state['height'] == state['logical_height'] * self.float_scale,
147 'Wayland resize acceptance requires the expected density')
148 if attached:
149 state = self.wait_state(lambda s: bool(s["panes"]) and
150 all(p["phase"] == "attached" for p in s["panes"]))
151 return state
121 152
122 def send(self, *lines): 153 def send(self, *lines):
123 require(self.gui.poll() is None, "GUI exited unexpectedly") 154 require(self.gui.poll() is None, "GUI exited unexpectedly")
@@ -237,7 +268,9 @@ class Rig:
237 self.checkpoints += 1 268 self.checkpoints += 1
238 print(f"{self.test_label} OK ({self.checkpoints}): {message}", flush=True) 269 print(f"{self.test_label} OK ({self.checkpoints}): {message}", flush=True)
239 270
240 def close(self): 271 def close(self, success=None):
272 if success is None:
273 success = sys.exc_info()[0] is None
241 if self.gui is not None and self.gui.poll() is None: 274 if self.gui is not None and self.gui.poll() is None:
242 self.gui.terminate() 275 self.gui.terminate()
243 for sock, proc in self.daemons: 276 for sock, proc in self.daemons:
@@ -258,6 +291,12 @@ class Rig:
258 os.close(self.fd) 291 os.close(self.fd)
259 for log in self.logs: 292 for log in self.logs:
260 log.close() 293 log.close()
294 if success and os.environ.get("MUXG_TEST_KEEP_ARTIFACTS") != "1":
295 require(self.root == self._owned_root and not self.root.is_symlink(),
296 "refusing to remove a foreign artifact root")
297 shutil.rmtree(self._owned_root)
298 else:
299 print("Native test artifacts retained:", self.root, flush=True)
261 300
262 301
263 def rect_contains(rect, x, y): 302 def rect_contains(rect, x, y):
@@ -284,185 +323,35 @@ def colour_counts(capture, rect, colour):
284 return inside, outside 323 return inside, outside
285 324
286 325
287 def check_geometry(rig, state, first, second, direction): 326 def output_flood(rig, refs):
288 a, b = state["panes"] 327 """Unslept 256 KiB writes stress fairness independently of the paced journey."""
289 ar, br = a["outer"], b["outer"] 328 left, right, _ = refs
290 size_axis, pos_axis = ("w", "x") if direction == "beside" else ("h", "y") 329 # A bounded flood with an external stop signal. During the frame-count
291 require(abs(ar[size_axis] - br[size_axis]) <= 1, "split did not divide the armed pane equally") 330 # interval only nonforcing state observations are allowed, never captures.
292 require(br[pos_axis] >= ar[pos_axis] + ar[size_axis], "panes overlap") 331 stop = rig.root / "stop-flood"
293 for pane, sock, session in ((a, first, "left"), (b, second, "right")): 332 progress = rig.root / "flood-progress"
294 content = pane["content"] 333 rig.focus(left)
295 cols = content["w"] // state["cell_w"] 334 flood = (f"i=0; while [ $i -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do "
296 rows = content["h"] // state["cell_h"] 335 "head -c 262144 /dev/zero | tr '\\000' X; "
297 require(cols >= 2 and rows >= 1, "invalid terminal footprint") 336 f"i=$((i+1)); echo $i > {shlex.quote(str(progress))}; done; echo FLOOD-DONE")
298 require((pane["cols"], pane["rows"]) == (cols, rows), "GUI cell sizing differs from physical content") 337 rig.shell(flood)
299 eventually(lambda: (lambda s: (s["cols"], s["rows"]) == (cols, rows))(rig.status(sock, session)), 338 eventually(lambda: progress.exists() and progress.stat().st_size > 0, "flood did not start")
300 "daemon did not receive its pane's physical size") 339 count1, _ = rig.frames()
301 340 rig.focus(right)
302 341 started = time.monotonic()
303 def normal_scenario(rig, direction): 342 rig.shell("printf '\\033[38;2;0;0;255mRESPONSIVE-%s\\033[0m\\n' RIGHT")
304 first, _ = rig.daemon("first-" + direction) 343 rig.wait_marker(*refs[right], "RESPONSIVE-RIGHT")
305 second, second_proc = rig.daemon("second-" + direction, quic=direction == "stacked") 344 # State observes the last completed frame and must not itself request a
306 if direction == "beside": 345 # repaint. This detects B staying visually stale while A keeps painting.
307 for flags in (("--next-target", "box"), ("--next-session", "right")): 346 rig.wait_state(lambda s: "RESPONSIVE-RIGHT" in next(p for p in s["panes"] if p["id"] == right)["painted_text"])
308 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env, 347 latency = time.monotonic() - started
309 capture_output=True, timeout=3) 348 time.sleep(0.3)
310 require(result.returncode == 2, "retired staged-target arguments were accepted") 349 require("FLOOD-DONE" not in rig.dump(*refs[left]), "flood ended before concurrent check")
311 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") 350 count2, p99 = rig.frames()
312 before = rig.start_gui(first, second, "gui-" + direction) 351 require(colour_counts(rig.pixels(), next(p for p in rig.state()["panes"] if p["id"] == right)["content"], "blue")[0] > 30,
313 rig.open_picker(direction) 352 "new responsive marker was not rendered in the other pane")
314 armed = rig.picker("hosts") 353 stop.touch()
315 require(armed["pending"]["direction"] == direction, 354 rig.wait_marker(*refs[left], "FLOOD-DONE")
316 "picker has the wrong split direction") 355 require(count2 > count1, "no frames painted while output flooded")
317 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], 356 require(latency < DEADLINE and p99 < 20000, f"flood isolation exceeded budget: {latency}s / {p99}us")
318 "arming split changed geometry") 357 rig.ok(f"flood leaves other pane responsive ({latency*1000:.0f} ms input-to-frame, {p99} us frame p99)")
319 stats = rig.command("d", "stats", "--sock", second).stdout
320 require(re.search(r"\battaches=0\b", stats),
321 "picker target attached before insertion: " + stats)
322 # Daemons may start a default session themselves. The staged named session
323 # must still be absent, and this observer query cannot create one.
324 require(rig.command("a", "status", "--sock", second, "--session", "right",
325 "--timeout", "200", check=False).returncode != 0,
326 "staged named session exists before insertion")
327 rig.key("escape")
328 cancelled = rig.wait_state(lambda s: s["picker"] is None)
329 require(len(cancelled["panes"]) == 1 and cancelled["pending"] == armed["pending"],
330 "closing host picker changed panes or forgot its armed split")
331 rig.key("escape")
332 rig.wait_state(lambda s: s["pending"] is None)
333 rig.open_picker(direction)
334 rig.picker("hosts")
335 rig.choose(rig.targets.get(second, "--sock " + second))
336 rig.picker("sessions")
337 rig.new_session("right")
338 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
339 check_geometry(rig, state, first, second, direction)
340 transport = "Unix + QUIC" if direction == "stacked" else "two Unix sockets"
341 rig.ok(direction + " split over " + transport + "; both PTYs have exact pane sizes")
342
343 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mRIGHT-%s\\033[0m\\n' MARK")
344 rig.wait_marker(second, "right", "RIGHT-MARK")
345 require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane")
346 rig.chord("h" if direction == "beside" else "k")
347 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mLEFT-%s\\033[0m\\n' MARK")
348 rig.wait_marker(first, "left", "LEFT-MARK")
349 require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane")
350 state = rig.state()
351 def correct_pixels():
352 pixels = rig.pixels()
353 red = colour_counts(pixels, state["panes"][0]["content"], "red")
354 green = colour_counts(pixels, state["panes"][1]["content"], "green")
355 return red[0] > 30 and green[0] > 30 and red[1] == green[1] == 0
356 eventually(correct_pixels, "coloured glyphs missing or spill into another pane")
357 rig.ok("focus routes input independently and framebuffer regions contain only their own coloured glyphs")
358
359 rig.send("resize:640x400")
360 state = rig.wait_state(lambda s: (s["width"], s["height"]) == (640, 400))
361 check_geometry(rig, state, first, second, direction)
362 eventually(correct_pixels, "resized framebuffer lost or misplaced pane content")
363 # Clear red content after shrinking; the other pane's green text must survive.
364 rig.shell("printf '\\033[2J\\033[H'")
365 def cleared():
366 pixels = rig.pixels()
367 red = colour_counts(pixels, state["panes"][0]["content"], "red")
368 green = colour_counts(pixels, state["panes"][1]["content"], "green")
369 return sum(red) == 0 and green[0] > 30 and green[1] == 0
370 eventually(cleared, "clear/shrink left old pixels or erased a neighbour")
371 # Click uses logical coordinates; offscreen is 100%, Wayland checked separately.
372 content = state["panes"][1]["content"]
373 rig.send(f"click:{content['x'] + content['w']//2},{content['y'] + content['h']//2}")
374 rig.shell("printf 'CLICK-%s\\n' RIGHT")
375 rig.wait_marker(second, "right", "CLICK-RIGHT")
376 require("CLICK-RIGHT" not in rig.dump(first, "left"), "click focus routed to wrong pane")
377 rig.ok("window resize, neighbour-safe clearing, and mouse focus work")
378
379 if direction == "beside":
380 rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; "
381 "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE")
382 rig.wait_marker(second, "right", "RAW-READY")
383 rig.send("key:prefix", "key:prefix", "text:A")
384 rig.wait_marker(second, "right", "RAW-DONE")
385 require("1c 41" in rig.dump(second, "right"), "prefix passthrough did not send one 0x1c then A")
386 rig.ok("double prefix sends the terminal encoding exactly once; ordinary input follows")
387 # A bounded flood with an external stop signal. During the frame-count
388 # interval only nonforcing state observations are allowed, never captures.
389 stop = rig.root / "stop-flood"
390 progress = rig.root / "flood-progress"
391 rig.chord("h")
392 flood = (f"i=0; while [ $i -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do "
393 "head -c 262144 /dev/zero | tr '\\000' X; "
394 f"i=$((i+1)); echo $i > {shlex.quote(str(progress))}; done; echo FLOOD-DONE")
395 rig.shell(flood)
396 eventually(lambda: progress.exists() and progress.stat().st_size > 0, "flood did not start")
397 count1, _ = rig.frames()
398 rig.chord("l")
399 started = time.monotonic()
400 rig.shell("printf '\\033[38;2;0;0;255mRESPONSIVE-%s\\033[0m\\n' RIGHT")
401 rig.wait_marker(second, "right", "RESPONSIVE-RIGHT")
402 # State observes the last completed frame and must not itself request a
403 # repaint. This detects B staying visually stale while A keeps painting.
404 rig.wait_state(lambda s: "RESPONSIVE-RIGHT" in s["panes"][1]["painted_text"])
405 latency = time.monotonic() - started
406 time.sleep(0.3)
407 require("FLOOD-DONE" not in rig.dump(first, "left"), "flood ended before concurrent check")
408 count2, p99 = rig.frames()
409 require(colour_counts(rig.pixels(), state["panes"][1]["content"], "blue")[0] > 30,
410 "new responsive marker was not rendered in the other pane")
411 stop.touch()
412 rig.wait_marker(first, "left", "FLOOD-DONE")
413 require(count2 > count1, "no frames painted while output flooded")
414 require(latency < DEADLINE and p99 < 20000, f"flood isolation exceeded budget: {latency}s / {p99}us")
415 rig.ok(f"flood leaves other pane responsive ({latency*1000:.0f} ms input-to-frame, {p99} us frame p99)")
416
417 # Disconnect one daemon while the other continues accepting input.
418 rig.stop_daemon(second, second_proc)
419 rig.wait_state(lambda s: s["panes"][1]["phase"] != "attached")
420 rig.chord("h")
421 rig.shell("printf 'SURVIVOR-%s\\n' OK")
422 rig.wait_marker(first, "left", "SURVIVOR-OK")
423 rig.ok("a disconnected daemon cannot take down the other pane")
424 else:
425 rig.shell("exit 7")
426 rig.wait_state(lambda s: s["panes"][1]["phase"] == "exited" and s["panes"][1]["exit_code"] == 7)
427 rig.chord("k")
428 rig.shell("printf 'AFTER-EXIT-%s\\n' OK")
429 rig.wait_marker(first, "left", "AFTER-EXIT-OK")
430 rig.ok("shell exit is retained in its pane while its neighbour remains usable")
431 rig.quit()
432 require(("SURVIVOR-OK" if direction == "beside" else "AFTER-EXIT-OK") in rig.dump(first, "left"),
433 "window close ended or replaced the surviving session")
434 rig.ok("closing the window preserves the surviving daemon session")
435
436
437 def unavailable_scenario(rig):
438 missing = str(rig.root / "missing.sock")
439 rig.launch_gui(["--sock", missing, "--session", "offline"], "gui-unavailable", attached=False)
440 rig.wait_state(lambda s: s["panes"][0]["phase"] in ("dial_failed", "failed", "reconnecting"))
441 rig.send("resize:8x8")
442 state = rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8)
443 require(len(state["panes"]) == 1 and all(p["cols"] > 0 and p["rows"] > 0 for p in state["panes"]),
444 "tiny window destroyed panes or sent invalid sizes")
445 rig.quit()
446 rig.ok("initially unreachable target retains its pane; tiny window and shutdown remain safe")
447
448
449 def main():
450 rig = Rig(*sys.argv[1:3])
451 try:
452 version = subprocess.check_output([rig.muxg, "--version"], text=True)
453 require("ReleaseSafe" in version or "ReleaseFast" in version, "native tiling requires a release build")
454 for direction in ("beside", "stacked"):
455 normal_scenario(rig, direction)
456 unavailable_scenario(rig)
457 print(f"native tiling OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}")
458 except BaseException:
459 print("Native tiling failure artifacts:", rig.root, file=sys.stderr)
460 for path in rig.root.glob("gui*.log"):
461 print(path.name + ":\n" + path.read_text()[-2000:], file=sys.stderr)
462 raise
463 finally:
464 rig.close()
465
466
467 if __name__ == "__main__":
468 main()
test/native_tmux_mouse.py
Old New
@@ -153,7 +153,7 @@ def run(mux, muxg):
153 'Shift muxg drag changed tmux selection or buffer') 153 'Shift muxg drag changed tmux selection or buffer')
154 rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged') 154 rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged')
155 rig.quit() 155 rig.quit()
156 print('Native tmux mouse acceptance passed; artifacts:', rig.root, flush=True) 156 print('Native tmux mouse acceptance passed', flush=True)
157 except BaseException: 157 except BaseException:
158 rig.failure_artifacts() 158 rig.failure_artifacts()
159 raise 159 raise
test/native_wheel.py
Old New
@@ -8,7 +8,7 @@ import sys
8 import time 8 import time
9 9
10 sys.dont_write_bytecode = True 10 sys.dont_write_bytecode = True
11 from native_lifecycle import start_persistent 11 from native_lifecycle import start_workspace
12 from native_resize import by_id, one_divider 12 from native_resize import by_id, one_divider
13 from native_selection import SelectionRig, cell_background 13 from native_selection import SelectionRig, cell_background
14 from native_tiling import eventually, require 14 from native_tiling import eventually, require
@@ -296,15 +296,16 @@ def application_wheel(rig, panes):
296 def main(): 296 def main():
297 rig = WheelRig(*sys.argv[1:3]) 297 rig = WheelRig(*sys.argv[1:3])
298 try: 298 try:
299 refs = start_persistent(rig) 299 refs = start_workspace(rig)
300 panes = list(refs) 300 panes = list(refs)
301 shell_history(rig, panes) 301 if rig.env['SDL_VIDEO_DRIVER'] == 'wayland':
302 shell_history(rig, panes)
302 application_wheel(rig, panes) 303 application_wheel(rig, panes)
303 rig.kernel_sizes() 304 rig.kernel_sizes()
304 rig.assert_cli_untouched() 305 rig.assert_cli_untouched()
305 rig.quit() 306 rig.quit()
306 rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state') 307 rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state')
307 print('Wheel artifacts:', rig.root) 308 print('Native application wheel acceptance passed', flush=True)
308 except Exception: 309 except Exception:
309 rig.failure_artifacts() 310 rig.failure_artifacts()
310 raise 311 raise