a73x

9940d8fd

test: consolidate native end-to-end journey

a73x   2026-09-07 12:30

Commit message
test: consolidate native end-to-end journey

README.md
Old New
@@ -28,7 +28,7 @@ the default development build and CI gates:
28 28
29 ```sh 29 ```sh
30 make native # Debug development build and no-window unit tests 30 make native # Debug development build and no-window unit tests
31 make native-e2e # ReleaseSafe pane, picker, resize and restore checks; needs python3 31 make native-e2e # One ReleaseSafe GUI user journey; needs python3
32 make native-stress # Linux: raw cat /dev/random, shared and separate daemon neighbours 32 make native-stress # Linux: raw cat /dev/random, shared and separate daemon neighbours
33 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] 33 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
34 ``` 34 ```
build.zig
Old New
@@ -1168,61 +1168,15 @@ pub fn build(b: *std.Build) void {
1168 native_test_step.dependOn(&run_native_core_tests.step); 1168 native_test_step.dependOn(&run_native_core_tests.step);
1169 native_test_step.dependOn(&b.addRunArtifact(native_tests).step); 1169 native_test_step.dependOn(&b.addRunArtifact(native_tests).step);
1170 1170
1171 // The runner is added with the full event path; declaring it here keeps 1171 // One ordinary journey keeps the shared window/panes/daemons alive through
1172 // the opt-in interface fixed while remaining lazy until that step runs. 1172 // input, tiling, resize, transport, rendering, fonts and restore. Destructive
1173 const native_e2e = b.addSystemCommand(&.{"test/native.sh"}); 1173 // and platform-specific Python probes remain directly runnable, not another
1174 native_e2e.addArtifactArg(mux_exe); 1174 // repetition of this default acceptance setup.
1175 native_e2e.addArtifactArg(muxg_exe); 1175 const native_journey = b.addSystemCommand(&.{ "python3", "-B", "test/native_journey.py" });
1176 const native_tiling = b.addSystemCommand(&.{ "python3", "test/native_tiling.py" }); 1176 native_journey.addArtifactArg(mux_exe);
1177 native_tiling.addArtifactArg(mux_exe); 1177 native_journey.addArtifactArg(muxg_exe);
1178 native_tiling.addArtifactArg(muxg_exe);
1179 native_tiling.step.dependOn(&native_e2e.step);
1180 const native_picker = b.addSystemCommand(&.{ "python3", "-B", "test/native_picker.py" });
1181 native_picker.addArtifactArg(mux_exe);
1182 native_picker.addArtifactArg(muxg_exe);
1183 native_picker.step.dependOn(&native_tiling.step);
1184 const native_resize = b.addSystemCommand(&.{ "python3", "-B", "test/native_resize.py" });
1185 native_resize.addArtifactArg(mux_exe);
1186 native_resize.addArtifactArg(muxg_exe);
1187 native_resize.step.dependOn(&native_picker.step);
1188 const native_lifecycle = b.addSystemCommand(&.{ "python3", "-B", "test/native_lifecycle.py" });
1189 native_lifecycle.addArtifactArg(mux_exe);
1190 native_lifecycle.addArtifactArg(muxg_exe);
1191 native_lifecycle.step.dependOn(&native_resize.step);
1192 const native_theme = b.addSystemCommand(&.{ "python3", "-B", "test/native_theme.py" });
1193 native_theme.addArtifactArg(mux_exe);
1194 native_theme.addArtifactArg(muxg_exe);
1195 native_theme.step.dependOn(&native_lifecycle.step);
1196 const native_fonts = b.addSystemCommand(&.{ "python3", "-B", "test/native_fonts.py" });
1197 native_fonts.addArtifactArg(mux_exe);
1198 native_fonts.addArtifactArg(muxg_exe);
1199 native_fonts.step.dependOn(&native_theme.step);
1200 const native_theme_config = b.addSystemCommand(&.{ "python3", "-B", "test/native_theme_config.py" });
1201 native_theme_config.addArtifactArg(mux_exe);
1202 native_theme_config.addArtifactArg(muxg_exe);
1203 native_theme_config.step.dependOn(&native_fonts.step);
1204 const native_selection = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection.py" });
1205 native_selection.addArtifactArg(mux_exe);
1206 native_selection.addArtifactArg(muxg_exe);
1207 native_selection.step.dependOn(&native_theme_config.step);
1208 const native_selection_follow = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection_follow.py" });
1209 native_selection_follow.addArtifactArg(mux_exe);
1210 native_selection_follow.addArtifactArg(muxg_exe);
1211 native_selection_follow.step.dependOn(&native_selection.step);
1212 const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" });
1213 native_wheel.addArtifactArg(mux_exe);
1214 native_wheel.addArtifactArg(muxg_exe);
1215 native_wheel.step.dependOn(&native_selection_follow.step);
1216 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1178 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1217 const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); 1179 native_e2e_step.dependOn(&native_journey.step);
1218 native_mouse.addArtifactArg(mux_exe);
1219 native_mouse.addArtifactArg(muxg_exe);
1220 native_mouse.step.dependOn(&native_wheel.step);
1221 const native_tmux_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_tmux_mouse.py" });
1222 native_tmux_mouse.addArtifactArg(mux_exe);
1223 native_tmux_mouse.addArtifactArg(muxg_exe);
1224 native_tmux_mouse.step.dependOn(&native_mouse.step);
1225 native_e2e_step.dependOn(&native_tmux_mouse.step);
1226 1180
1227 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale 1181 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale
1228 // Debug daemon gives misleading latency numbers under raw terminal output. 1182 // Debug daemon gives misleading latency numbers under raw terminal output.
docs/superpowers/specs/2026-09-04-native-client-design.md
Old New
@@ -306,36 +306,34 @@ window, and runs:
306 overflows opens a new shelf, and growth keeps every earlier entry's 306 overflows opens a new shelf, and growth keeps every earlier entry's
307 rectangle. 307 rectangle.
308 308
309 **One end-to-end leg, `test/native.sh`**, `make native-e2e`, against a real 309 **One canonical user journey, `test/native_journey.py`**, anchors
310 daemon on an isolated `XDG_RUNTIME_DIR` and `XDG_STATE_HOME`, in the 310 `make native-e2e` against isolated XDG homes and owned daemons. It keeps one
311 harness's shape (`test/os_oracle.sh` for anything about a pid): 311 workspace alive across the compatible checks instead of reopening a basic
312 312 window for each feature:
313 1. Refuse a Debug binary by checking `muxg --version`, start a daemon, then 313
314 start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen` and a fixed 314 1. Refuse a Debug binary, open an empty persistent window, and select the
315 window size. The 2026-09-04 development-box probe succeeded with the 315 first real daemon session explicitly.
316 offscreen driver and an OpenGL 4.6 core context. The VM has not been tested. 316 2. Create one pane beside and one below through the picker, using both Unix
317 2. Type a split `printf` command through the real event path — structured so 317 and QUIC transports; cancellation must not attach or create anything.
318 the shell's command echo cannot contain the expected `native-ok-PID` result 318 3. Navigate and type independently in all three panes, then copy and paste
319 — using SDL's event injection hook, gated on an environment variable the 319 Unicode/multiline text; compare daemon grids, framebuffer regions, layout
320 leg sets. Read the result through `mux a`; the bytes crossed the keymap, 320 geometry, and kernel PTY dimensions.
321 mailbox, pump and daemon. 321 4. Move both dividers by pointer and keyboard, including clamps and a batched
322 3. Flood the session with a bounded stream — a fixed byte count of base64 322 release/window-resize/input sequence, without replacing pane identities.
323 from `/dev/urandom` through `mux a run`, never an unbounded `cat` — and 323 5. Keep a bounded producer active in one pane while a QUIC neighbour accepts
324 send SIGUSR1 when it ends. 324 input and paints; frame count must advance and p99 must remain in budget.
325 4. Read the table: frames were painted WHILE the flood ran (the frame count 325 6. Run the font/config restart matrix on that same workspace, including a real
326 grew between two SIGUSR1 reads taken during it), and the window-side 326 fallback glyph when the independently inspected installed faces provide one.
327 stages' p99 sat under the budget below. This is the waystty failure, 327 7. Close and reopen around raw DEL-adjacent text, verify retained pixels and
328 pinned. 328 identities, and attach a terminal client to the same session.
329 5. Ask the real GL renderer for a framebuffer capture after drawing coloured 329 8. Confine remote loss and shell exit to their panes, then represent and close
330 session text, then inspect the pixels for the expected red glyphs. 330 an initially unavailable target at a tiny window size.
331 6. Resize the window through the test hook and read the session's size 331
332 back through `mux a status`: the daemon followed the window. 332 Picker races, persistence failures, theme parsing, selection/clipboard,
333 7. Close the window; the process exits 0 and the daemon still lists the 333 application mouse, wheel, tmux, DPI transitions, stress, and real OpenSSH retain
334 session. 334 directly runnable probes for targeted work. They are not chained behind
335 8. Attach a terminal client to the same session and verify its input reaches 335 `make native-e2e`: destructive and platform-specific fixtures must not turn the
336 the daemon. 336 ordinary journey into the same fresh workspace repeated under different names.
337 9. Attach a named native session, exit its shell with code 7, and verify the
338 GUI process returns 7.
339 337
340 At the fixed 960×600 test window, the window-side total p99 budget is 338 At the fixed 960×600 test window, the window-side total p99 budget is
341 20,000 microseconds over the timing ring, including draw/swap and any 339 20,000 microseconds over the timing ring, including draw/swap and any
@@ -362,7 +360,7 @@ may run the same leg under Xvfb with SDL's X11 driver.
362 - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3, 360 - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3,
363 freetype2, fontconfig and HarfBuzz, the table row, the two folder rules. 361 freetype2, fontconfig and HarfBuzz, the table row, the two folder rules.
364 - `Makefile`: `native`, `native-e2e`. 362 - `Makefile`: `native`, `native-e2e`.
365 - `test/native.sh`. 363 - `test/native_journey.py` and focused native acceptance scripts.
366 - `README.md`: one section, `muxg TARGET`, and that it is a viewer. 364 - `README.md`: one section, `muxg TARGET`, and that it is a viewer.
367 - `CLAUDE.md`: the table row, the two rules, the build line. 365 - `CLAUDE.md`: the table row, the two rules, the build line.
368 366
test/native.sh
Old New
@@ -1,175 +0,0 @@
1 #!/bin/sh
2 # The native client's end-to-end leg: a real daemon, a real muxg on SDL's
3 # offscreen driver, keys through the real event path, a bounded flood,
4 # and the frame table read while the flood ran. Opt-in (`make native-e2e`);
5 # not part of ci, because it needs SDL3 and a GL-capable offscreen driver.
6 #
7 # The one pin that matters is the waystty failure: frames were painted
8 # WHILE output flooded the session. A painter that is fast per frame and
9 # never asked to paint reports a fine p99 and zero frames; this leg reads
10 # the frame count twice during the flood and asserts it grew.
11 set -eu
12 MUX="$1"
13 MUXG="$2"
14 [ -x "$MUX" ] && [ -x "$MUXG" ] || { echo "native FAIL: need mux and muxg (run: make native)"; exit 1; }
15 E2E_DIR=$(dirname "$0")
16 . "$E2E_DIR/e2e_lib.sh"
17
18 # A Debug muxg measures a Debug replica; the numbers would mean nothing.
19 # The budget below is stated for ReleaseSafe/ReleaseFast and the leg
20 # refuses to grade anything else. `zig build native -Doptimize=ReleaseSafe`.
21 case "$("$MUXG" --version 2>&1)" in
22 *ReleaseSafe*|*ReleaseFast*) ;;
23 *) echo "native FAIL: build muxg with -Doptimize=ReleaseSafe or ReleaseFast"; exit 1 ;;
24 esac
25
26 SOCK="${TMPDIR:-/tmp}/muxd-native-$$.sock"
27 LOG="${TMPDIR:-/tmp}/muxd-native-$$.log"
28 defer_rm "$LOG"
29 start_daemon "$SOCK" "$LOG" "native daemon"
30
31 FIFO="${TMPDIR:-/tmp}/muxg-hook-$$"
32 defer_rm "$FIFO"
33 mkfifo "$FIFO"
34 GLOG="${TMPDIR:-/tmp}/muxg-native-$$.log"
35 defer_rm "$GLOG"
36
37 SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \
38 "$MUXG" --sock "$SOCK" 2>"$GLOG" &
39 GPID=$!
40 defer_kill "$GPID"
41 # Hold the FIFO's write end open for the whole leg; each hook line is one echo.
42 exec 8<>"$FIFO"
43
44 # 1. The window attached: the daemon counts a client.
45 # wait_until TENTHS LABEL PREDICATE — the predicate is eval'd per tick.
46 wait_until 100 "muxg attached" '[ "$(attaches_now "$SOCK")" -ge 1 ]'
47 ok "muxg attached to a real daemon on the offscreen driver"
48
49 # 2. Keys through the real event path reach the shell.
50 printf '%s\n' "text:printf 'native-%s\\n' 'ok-$$'" >&8
51 printf 'key:enter\n' >&8
52 wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid"
53 ok "text and Enter cross the keymap, the mailbox, the pump and the daemon"
54
55 # 3. A bounded producer with explicit readiness, progress and completion.
56 # Fixed /tmp template yields a shell-safe path for the session command.
57 FLOOD_DIR=$(mktemp -d /tmp/mux-native-flood.XXXXXXXX)
58 defer_rm "$FLOOD_DIR"
59 reports_now() { grep -c '^=== muxg frame timing (' "$GLOG" || true; }
60 frames_now() {
61 frame_reports_before=$(reports_now)
62 kill -USR1 "$GPID" || return 1
63 # Wait for a NEW, COMPLETE report, rather than rereading an old table.
64 # The total row is printed last, so its count acknowledges the report.
65 wait_until 50 "fresh frame report" '[ "$(grep -c "^total " "$GLOG" || true)" -gt "$frame_reports_before" ]' >&2
66 grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'
67 }
68 # At most 4096 x 256 KiB of source bytes; normal completion is requested
69 # after the two samples. The producer writes progress only after output.
70 "$MUX" a run --sock "$SOCK" --timeout 60000 "i=0; : > '$FLOOD_DIR/ready'; while [ \"\$i\" -lt 4096 ] && [ ! -e '$FLOOD_DIR/stop' ]; do head -c 262144 /dev/urandom | base64; i=\$((i + 1)); printf '%s\\n' \"\$i\" > '$FLOOD_DIR/progress.tmp'; mv '$FLOOD_DIR/progress.tmp' '$FLOOD_DIR/progress'; done; : > '$FLOOD_DIR/done'; echo flood-done" >/dev/null 2>&1 &
71 RUNPID=$!
72 defer_kill "$RUNPID"
73 wait_until 100 "flood producer ready" '[ -s "$FLOOD_DIR/progress" ]'
74 [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished before sampling"; exit 1; }
75 before=$(frames_now)
76 progress_before=$(cat "$FLOOD_DIR/progress")
77 sleep 1
78 wait_until 50 "producer output advanced" '[ -s "$FLOOD_DIR/progress" ] && [ "$(cat "$FLOOD_DIR/progress")" != "$progress_before" ]'
79 mid=$(frames_now)
80 [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished during sampling"; exit 1; }
81 [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during active output (before=$before mid=$mid)"; exit 1; }
82 : > "$FLOOD_DIR/stop"
83 wait "$RUNPID" || { echo "native FAIL: producer command failed"; exit 1; }
84 wait_grid "$SOCK" "flood-done" "the flood ended"
85 after=$(frames_now)
86 ok "frames were painted while the session flooded: before=$before mid=$mid after=$after"
87
88 # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only).
89 # Budget: 20 ms window total including swap, for a 960x600
90 # window of ~4,500 cells; the row to read is `total`.
91 p99=$(grep -E '^total ' "$GLOG" | tail -1 | awk '{print $4}')
92 [ -n "$p99" ] && [ "$p99" -lt 20000 ] || { echo "native FAIL: total p99 ${p99:-?} us over the 20000 us budget"; tail -12 "$GLOG"; exit 1; }
93 ok "window-side total p99 ${p99} us under 20000 us"
94
95 # Raw DEL output previously crashed the reader and prevented reattachment.
96 # Keep this producer bounded, then retain a DEL beside the render marker.
97 printf '%s\n' "text:head -c 4096 /dev/zero | tr '\000' '\177'" >&8
98 printf 'key:enter\n' >&8
99
100 # Read back real pixels after asking the shell to draw coloured text. The
101 # Explicit RGB makes this a glyph-rendering check independent of the theme;
102 # the shell's command echo cannot satisfy the red-pixel assertion.
103 CAPTURE="$OUT.native.ppm"
104 printf '%s\n' "text:printf '\033[2J\033[H\033[38;2;255;0;0mNATIVE-%s\177\033[0m\n' RENDER" >&8
105 printf 'key:enter\n' >&8
106 wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon"
107 printf 'capture:%s\n' "$CAPTURE" >&8
108 wait_until 50 "framebuffer capture completed" '[ -s "$CAPTURE" ]'
109 has_red_glyphs() {
110 [ -s "$1" ] || return 1
111 tail -n +4 "$1" | od -An -v -tu1 | awk '
112 { for (i=1; i<=NF; i++) { channel=(n++ % 3); if(channel==0) r=$i; else if(channel==1) g=$i; else if(r>80 && r>g*2 && r>$i*2) red++; } }
113 END { if(red<30) exit 1; }
114 '
115 }
116 has_red_glyphs "$CAPTURE" || { echo "native FAIL: framebuffer contains no rendered red text"; exit 1; }
117 ok "the OpenGL framebuffer contains the session's coloured glyphs"
118
119 # 5. A resize through the hook is followed by the daemon: cols shrink from
120 # whatever the 960 px window gave (the face decides the number) to
121 # fewer at 640 px. Read before, then wait for after to differ.
122 cols_of() { "$MUX" a status --sock "$SOCK" --timeout 2000 | sed 's/.*"cols":\([0-9]*\).*/\1/'; }
123 cols_before=$(cols_of)
124 printf 'resize:640x400\n' >&8
125 wait_until 100 "the daemon's cols changed after the resize" '[ "$(cols_of)" != "$cols_before" ]'
126 cols_after=$(cols_of)
127 [ "$cols_after" -lt "$cols_before" ] || { echo "native FAIL: cols $cols_before -> $cols_after did not shrink with the 640 px window"; exit 1; }
128 ok "the daemon followed the window's resize (cols $cols_before -> $cols_after)"
129
130 # 6. Close the window: muxg exits and the session survives on the daemon.
131 # wait_pid_gone PID LABEL — it allows 2 s.
132 printf 'quit\n' >&8
133 wait_pid_gone "$GPID" "muxg exits on quit"
134 wait "$GPID" || { echo "native FAIL: muxg did not exit 0"; cat "$GLOG"; exit 1; }
135 "$MUX" a status --sock "$SOCK" --timeout 2000 >/dev/null 2>&1 || { echo "native FAIL: the session did not survive the window closing"; exit 1; }
136 ok "closing the window detaches and leaves the session on its daemon"
137
138 # Reattach to the snapshot containing DEL; it must still paint and detach.
139 SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \
140 "$MUXG" --sock "$SOCK" 2>"$OUT.native-reattach.log" &
141 RPID=$!
142 defer_kill "$RPID"
143 printf 'capture:%s\n' "$OUT.reattach.ppm" >&8
144 wait_until 50 "reattached framebuffer contains session glyphs" 'printf "capture:%s\n" "$OUT.reattach.ppm" >&8; has_red_glyphs "$OUT.reattach.ppm"'
145 printf 'quit\n' >&8
146 wait_pid_gone "$RPID" "reattached muxg exits on quit"
147 wait "$RPID" || { echo "native FAIL: reattach after DEL failed"; cat "$OUT.native-reattach.log"; exit 1; }
148 ok "raw DEL output survives rendering and native reattachment"
149
150 pipe_mux "$OUT.terminal" "$OUT.terminal.err" timeout 15 "$MUX" --sock "$SOCK"
151 await_out "$OUT.terminal" "NATIVE-RENDER" "terminal attach sees the GUI session"
152 pipe_send "printf 'terminal-%%s\\n' attached\n"
153 wait_grid "$SOCK" "terminal-attached" "terminal input reaches the same session"
154 pipe_detach "terminal client detached"
155 ok "a terminal client can attach to and type into the GUI's session"
156
157 attaches_before=$(attaches_now "$SOCK")
158 SDL_VIDEODRIVER="${MUXG_VIDEODRIVER:-offscreen}" MUXG_TEST_FIFO="$FIFO" \
159 "$MUXG" --sock "$SOCK" --session native-exit 2>"$OUT.native-exit.log" &
160 EXITPID=$!
161 defer_kill "$EXITPID"
162 wait_until 100 "named native session attached" '[ "$(attaches_now "$SOCK")" -gt "$attaches_before" ]'
163 printf '%s\n' 'text:exit 7' 'key:enter' >&8
164 exited_state() {
165 [ -s "$1" ] || return 1
166 python3 -c 'import json,sys; p=json.load(open(sys.argv[1]))["panes"][0]; sys.exit(not (p["phase"] == "exited" and p["exit_code"] == 7))' "$1"
167 }
168 wait_until 50 "native pane retains shell exit 7" 'printf "state:%s\n" "$OUT.native-exit.json" >&8; exited_state "$OUT.native-exit.json"'
169 kill -0 "$EXITPID" || { echo "native FAIL: one exited pane closed the window"; exit 1; }
170 printf 'quit\n' >&8
171 wait_pid_gone "$EXITPID" "native window exits after explicit quit"
172 wait "$EXITPID" || { echo "native FAIL: explicit quit did not detach cleanly"; exit 1; }
173 ok "a named native session retains its exit code in an open pane until explicit quit"
174
175 echo "native OK ($OK_COUNT checkpoints)"
test/native_fonts.py
Old New
@@ -18,7 +18,7 @@ import subprocess
18 import sys 18 import sys
19 19
20 sys.dont_write_bytecode = True 20 sys.dont_write_bytecode = True
21 from native_lifecycle import LifecycleRig, start_persistent 21 from native_lifecycle import LifecycleRig, start_workspace
22 from native_resize import by_id 22 from native_resize import by_id
23 from native_theme import pixel 23 from native_theme import pixel
24 from native_tiling import eventually, require 24 from native_tiling import eventually, require
@@ -190,32 +190,30 @@ def invalid_configs(rig, family):
190 rig.ok('invalid config/CLI fail promptly with file:line diagnostics; help still works') 190 rig.ok('invalid config/CLI fail promptly with file:line diagnostics; help still works')
191 191
192 192
193 def main(): 193 def exercise(rig, refs=None, *, family='monospace', nerd=False, output=None):
194 parser = argparse.ArgumentParser(description=__doc__) 194 """Run font/config acceptance, optionally continuing an existing workspace."""
195 parser.add_argument('mux')
196 parser.add_argument('muxg')
197 parser.add_argument('--family', default='monospace')
198 parser.add_argument('--nerd', action='store_true')
199 parser.add_argument('--output')
200 args = parser.parse_args()
201 rig = LifecycleRig(args.mux, args.muxg)
202 rig.env.pop('MUXG_TEST_THEME', None) 195 rig.env.pop('MUXG_TEST_THEME', None)
203 report = {'family': args.family} 196 starting_checks = rig.checkpoints
204 fallback = fallback_oracle(args.family) 197 report = {'family': family}
205 fallback_family = fallback['family'] if fallback else args.family 198 fallback = fallback_oracle(family)
199 fallback_family = fallback['family'] if fallback else family
206 original_scale = None 200 original_scale = None
201
207 def scale(value): 202 def scale(value):
208 response = subprocess.run(['swaymsg', '-r', 'output', args.output, 'scale', str(value)], 203 response = subprocess.run(['swaymsg', '-r', 'output', output, 'scale', str(value)],
209 capture_output=True, text=True, check=True) 204 capture_output=True, text=True, check=True)
210 require(all(x['success'] for x in json.loads(response.stdout)), 'scale request failed') 205 require(all(x['success'] for x in json.loads(response.stdout)), 'scale request failed')
206
211 try: 207 try:
212 if args.output: 208 if output:
213 require(args.output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland', 209 require(refs is None, 'font scale acceptance must own its initial window')
210 require(output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland',
214 'use only owned headless Wayland output') 211 'use only owned headless Wayland output')
215 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs'])) 212 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
216 original_scale = next(o['scale'] for o in outputs if o['name'] == args.output) 213 original_scale = next(o['scale'] for o in outputs if o['name'] == output)
217 scale(2) 214 scale(2)
218 refs = start_persistent(rig) 215 if refs is None:
216 refs = start_workspace(rig)
219 paint(rig, refs) 217 paint(rig, refs)
220 baseline = signature(rig) 218 baseline = signature(rig)
221 state = rig.state() 219 state = rig.state()
@@ -230,16 +228,16 @@ def main():
230 rig.shell('printf %s "$$" > ' + shlex.quote(str(path))) 228 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
231 eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written') 229 eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written')
232 shell_files[pane_id] = (path, path.read_text()) 230 shell_files[pane_id] = (path, path.read_text())
233 config = ('font-family = "' + args.family + '"\nfont-family = "' + 231 config = ('font-family = "' + family + '"\nfont-family = "' +
234 fallback_family + '"\nfont-size = 12.4\n') 232 fallback_family + '"\nfont-size = 12.4\n')
235 write_config(rig, config) 233 write_config(rig, config)
236 paint(rig, refs) 234 paint(rig, refs)
237 require(signature(rig) == baseline, 'config changed the already running client') 235 require(signature(rig) == baseline, 'config changed the already running client')
238 rig.quit() 236 rig.quit()
239 invalid_configs(rig, args.family) 237 invalid_configs(rig, family)
240 write_config(rig, config) 238 write_config(rig, config)
241 rig.launch_gui([], 'gui-config-font') 239 rig.launch_gui([], 'gui-config-font')
242 paint(rig, refs, args.nerd) 240 paint(rig, refs, nerd)
243 configured = signature(rig) 241 configured = signature(rig)
244 rig.kernel_sizes() 242 rig.kernel_sizes()
245 for pane_id, (path, pid) in shell_files.items(): 243 for pane_id, (path, pid) in shell_files.items():
@@ -248,21 +246,21 @@ def main():
248 rig.shell('printf %s "$$" > ' + shlex.quote(str(path))) 246 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
249 eventually(lambda: path.exists() and path.stat().st_size, 'reopened shell PID not written') 247 eventually(lambda: path.exists() and path.stat().st_size, 'reopened shell PID not written')
250 require(path.read_text() == pid and Path(rig.tty_paths[pane_id]).exists(), 'restart replaced shell') 248 require(path.read_text() == pid and Path(rig.tty_paths[pane_id]).exists(), 'restart replaced shell')
251 if args.nerd: 249 if nerd:
252 report['font_oracle'] = font_has_icons(args.family) 250 report['font_oracle'] = font_has_icons(family)
253 check_icons(rig, refs) 251 check_icons(rig, refs)
254 rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes') 252 rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes')
255 rig.quit() 253 rig.quit()
256 # Explicit CLI families replace the complete conflicting config chain. 254 # Explicit CLI families replace the complete conflicting config chain.
257 write_config(rig, 'font-family = monospace\nfont-family = MuxIgnoredFallback012345\nfont-size = 18\n') 255 write_config(rig, 'font-family = monospace\nfont-family = MuxIgnoredFallback012345\nfont-size = 18\n')
258 cli_families = ['--font-family', args.family, '--font-family', fallback_family] 256 cli_families = ['--font-family', family, '--font-family', fallback_family]
259 rig.launch_gui([*cli_families, '--font-size', '12.4'], 'gui-cli-font') 257 rig.launch_gui([*cli_families, '--font-size', '12.4'], 'gui-cli-font')
260 paint(rig, refs, args.nerd) 258 paint(rig, refs, nerd)
261 require(signature(rig) == configured, 'CLI did not override both config font settings') 259 require(signature(rig) == configured, 'CLI did not override both config font settings')
262 fallback_render = glyph_signature(rig, next(iter(refs)), fallback['text']) if fallback else None 260 fallback_render = glyph_signature(rig, next(iter(refs)), fallback['text']) if fallback else None
263 rig.quit() 261 rig.quit()
264 if fallback: 262 if fallback:
265 rig.launch_gui(['--font-family', args.family, '--font-size', '12.4'], 263 rig.launch_gui(['--font-family', family, '--font-size', '12.4'],
266 'gui-cli-primary-font') 264 'gui-cli-primary-font')
267 primary_render = glyph_signature(rig, next(iter(refs)), fallback['text']) 265 primary_render = glyph_signature(rig, next(iter(refs)), fallback['text'])
268 require(fallback_render[1], 'fallback glyph rendered blank') 266 require(fallback_render[1], 'fallback glyph rendered blank')
@@ -273,16 +271,16 @@ def main():
273 rig.ok('Adwaita fallback glyph differs from the primary .notdef raster') 271 rig.ok('Adwaita fallback glyph differs from the primary .notdef raster')
274 rig.quit() 272 rig.quit()
275 # Record the independently calculated raster size; fractional rounding is also unit-tested. 273 # Record the independently calculated raster size; fractional rounding is also unit-tested.
276 display_scale = 2 if args.output else 1 274 display_scale = 2 if output else 1
277 base_dpi = 72 if sys.platform == 'darwin' else 96 275 base_dpi = 72 if sys.platform == 'darwin' else 96
278 pixels = math.floor(12.4 * base_dpi / 72 * display_scale + .5) 276 pixels = math.floor(12.4 * base_dpi / 72 * display_scale + .5)
279 # A .5 base pixel value at 200% cannot be expressed by --font-px; 277 # A .5 base pixel value at 200% cannot be expressed by --font-px;
280 # compare 12pt with 16px separately; the scale round trip checks retained rasters. 278 # compare 12pt with 16px separately; the scale round trip checks retained rasters.
281 rig.launch_gui(['--font-family', args.family, '--font-size', '12'], 'gui-twelve-points') 279 rig.launch_gui(['--font-family', family, '--font-size', '12'], 'gui-twelve-points')
282 paint(rig, refs) 280 paint(rig, refs)
283 points12 = signature(rig) 281 points12 = signature(rig)
284 rig.quit() 282 rig.quit()
285 rig.launch_gui(['--font-family', args.family, '--font-px', str(base_dpi // 6)], 'gui-pixel-override') 283 rig.launch_gui(['--font-family', family, '--font-px', str(base_dpi // 6)], 'gui-pixel-override')
286 paint(rig, refs) 284 paint(rig, refs)
287 require(signature(rig) == points12, 'point units or legacy pixel override differ') 285 require(signature(rig) == points12, 'point units or legacy pixel override differ')
288 rig.quit() 286 rig.quit()
@@ -294,30 +292,45 @@ def main():
294 rig.env['HOME'] = str(home) 292 rig.env['HOME'] = str(home)
295 rig.env['XDG_CONFIG_HOME'] = '' 293 rig.env['XDG_CONFIG_HOME'] = ''
296 rig.launch_gui([], 'gui-home-fallback') 294 rig.launch_gui([], 'gui-home-fallback')
297 paint(rig, refs, args.nerd) 295 paint(rig, refs, nerd)
298 require(signature(rig) == configured, 'HOME fallback did not load same config') 296 require(signature(rig) == configured, 'HOME fallback did not load same config')
299 rig.ok('CLI precedence, point units, --font-px compatibility and HOME fallback verified in pixels') 297 rig.ok('CLI precedence, point units, --font-px compatibility and HOME fallback verified in pixels')
300 if args.output: 298 if output:
301 for value in (1, 1.5, 2): 299 for value in (1, 1.5, 2):
302 scale(value) 300 scale(value)
303 rig.wait_state(lambda s: s['width'] == value * s['logical_width']) 301 rig.wait_state(lambda s: s['width'] == value * s['logical_width'])
304 paint(rig, refs, args.nerd) 302 paint(rig, refs, nerd)
305 rig.kernel_sizes() 303 rig.kernel_sizes()
306 if args.nerd: 304 if nerd:
307 check_icons(rig, refs) 305 check_icons(rig, refs)
308 require(signature(rig) == configured, 'family/size changed after DPI round trip') 306 require(signature(rig) == configured, 'family/size changed after DPI round trip')
309 rig.ok('200% -> 100% -> 150% -> 200% retains family, icons and independent PTY sizes') 307 rig.ok('200% -> 100% -> 150% -> 200% retains family, icons and independent PTY sizes')
310 rig.assert_cli_untouched() 308 rig.assert_cli_untouched()
311 report.update(baseline=baseline, configured=configured, expected_raster_pixels=pixels, 309 report.update(baseline=baseline, configured=configured, expected_raster_pixels=pixels,
312 checks=rig.checkpoints) 310 checks=rig.checkpoints - starting_checks)
313 (rig.root / 'fonts-result.json').write_text(json.dumps(report, indent=2)) 311 (rig.root / 'fonts-result.json').write_text(json.dumps(report, indent=2))
314 rig.quit() 312 rig.quit()
315 print('PASS: native fonts;', rig.root, flush=True) 313 return report
316 finally: 314 finally:
317 rig.close()
318 if original_scale is not None: 315 if original_scale is not None:
319 scale(original_scale) 316 scale(original_scale)
320 317
321 318
319 def main():
320 parser = argparse.ArgumentParser(description=__doc__)
321 parser.add_argument('mux')
322 parser.add_argument('muxg')
323 parser.add_argument('--family', default='monospace')
324 parser.add_argument('--nerd', action='store_true')
325 parser.add_argument('--output')
326 args = parser.parse_args()
327 rig = LifecycleRig(args.mux, args.muxg)
328 try:
329 exercise(rig, family=args.family, nerd=args.nerd, output=args.output)
330 print('PASS: native fonts;', rig.root, flush=True)
331 finally:
332 rig.close()
333
334
322 if __name__ == '__main__': 335 if __name__ == '__main__':
323 main() 336 main()
test/native_journey.py
Old New
@@ -0,0 +1,233 @@
1 #!/usr/bin/env python3
2 """One ordinary native-client journey through panes, transport, fonts, and restore.
3
4 The focused acceptance scripts keep destructive races and platform-specific
5 oracles isolated. This journey owns the shared happy path so those scripts can
6 start from a lean fixture instead of each re-proving the same window setup.
7 """
8 import os
9 import shlex
10 import subprocess
11 import sys
12 import time
13
14 sys.dont_write_bytecode = True
15 from native_fonts import exercise as exercise_fonts, fallback_oracle, font_match
16 from native_lifecycle import start_workspace
17 from native_picker import sessions
18 from native_resize import (by_id, clamps, drag_and_keys, modal_leakage,
19 pixels_and_batch, pty_sizes)
20 from native_selection import SelectionRig, copy_shortcut, paste_shortcut
21 from native_tiling import DEADLINE, colour_counts, eventually, require
22
23
24 def release_binary(rig):
25 version = subprocess.check_output([rig.muxg, '--version'], text=True)
26 require('ReleaseSafe' in version or 'ReleaseFast' in version,
27 'native journey requires a ReleaseSafe or ReleaseFast muxg')
28
29
30 def font_family():
31 requested = os.environ.get('MUXG_TEST_FONT_FAMILY')
32 candidates = [requested] if requested else ['CommitMono Nerd Font Mono', 'monospace']
33 for family in candidates:
34 match = font_match(family)
35 exact = family == 'monospace' or match['family'].casefold() == family.casefold()
36 if exact and fallback_oracle(family):
37 return family
38 if requested:
39 require(font_match(requested)['family'].casefold() == requested.casefold(),
40 'MUXG_TEST_FONT_FAMILY is not an installed exact family')
41 return requested
42 return 'monospace'
43
44
45 def stable_layout(state):
46 return (state['tab'], [(p['id'], p['label']) for p in state['panes']],
47 [(d['id'], d['direction']) for d in state['dividers']])
48
49
50 def basic_workspace(rig):
51 refs = start_workspace(rig, quic=True, picker_checks=True)
52 panes = list(refs)
53 left, right, lower = panes
54 state = rig.state()
55 require(len(state['dividers']) == 2 and
56 {d['direction'] for d in state['dividers']} == {'beside', 'stacked'},
57 'v/b did not produce one below and one beside split')
58 left_rect, right_rect, lower_rect = [by_id(state)[pane]['outer'] for pane in panes]
59 require(right_rect['x'] >= left_rect['x'] + left_rect['w'],
60 'v pane is not beside the original pane')
61 require(lower_rect['y'] >= right_rect['y'] + right_rect['h'],
62 'b pane is not below the focused pane')
63 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']])
64
65 rig.focus(right)
66 rig.chord('h')
67 rig.wait_state(lambda s: s['focus'] == left)
68 rig.focus(right)
69 rig.chord('j')
70 rig.wait_state(lambda s: s['focus'] == lower)
71 rig.chord('k')
72 rig.wait_state(lambda s: s['focus'] == right)
73
74 for pane_id, (sock, name) in refs.items():
75 marker = 'JOURNEY-' + name.upper()
76 rig.mark(pane_id, sock, name, marker)
77 for other_sock, other_name in refs.values():
78 if (other_sock, other_name) != (sock, name):
79 require(marker not in rig.dump(other_sock, other_name),
80 'focused input leaked into another pane')
81
82 first_sock = refs[left][0]
83 for flags in (('--next-target', 'box'), ('--next-session', 'right')):
84 result = subprocess.run([rig.muxg, '--sock', first_sock, *flags], env=rig.env,
85 capture_output=True, timeout=3)
86 require(result.returncode == 2, 'retired staged-target arguments were accepted')
87
88 rig.focus(right)
89 rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; "
90 "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE")
91 rig.wait_marker(*refs[right], 'RAW-READY')
92 rig.send('key:prefix', 'key:prefix', 'text:A')
93 rig.wait_marker(*refs[right], 'RAW-DONE')
94 require('1c 41' in rig.dump(*refs[right]),
95 'double prefix did not send one 0x1c before ordinary input')
96 rig.ok('one window creates below/beside local and QUIC panes, then routes focus and input independently')
97 return refs
98
99
100 def flood_while_responsive(rig, refs):
101 left, right, _ = list(refs)
102 stop = rig.root / 'journey-flood-stop'
103 progress = rig.root / 'journey-flood-progress'
104 done = rig.root / 'journey-flood-done'
105 producer = rig.root / 'journey-flood.sh'
106 producer.write_text(
107 'i=0\n'
108 f'while [ "$i" -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do\n'
109 " head -c 1024 /dev/zero | tr '\\000' X\n"
110 f' i=$((i+1)); echo "$i" > {shlex.quote(str(progress))}; sleep .01\n'
111 'done\n'
112 f'touch {shlex.quote(str(done))}\n')
113 rig.focus(left)
114 rig.shell('sh ' + shlex.quote(str(producer)))
115 eventually(lambda: progress.exists() and progress.stat().st_size > 0, 'journey flood did not start')
116 require(not done.exists(), 'journey flood ended before it could be sampled')
117 first_count, _ = rig.frames()
118 first_progress = progress.read_text()
119
120 rig.focus(right)
121 started = time.monotonic()
122 rig.shell("printf '\\033[38;2;0;0;255mJOURNEY-%s\\033[0m\\n' RESPONSIVE")
123 rig.wait_marker(*refs[right], 'JOURNEY-RESPONSIVE')
124 state = rig.wait_state(lambda s: 'JOURNEY-RESPONSIVE' in by_id(s)[right]['painted_text'])
125 latency = time.monotonic() - started
126 eventually(lambda: progress.read_text() != first_progress, 'flood output stopped advancing')
127 time.sleep(.3)
128 second_count, p99 = rig.frames()
129 require(not done.exists(), 'journey flood ended during concurrent rendering')
130 require(colour_counts(rig.pixels(), by_id(state)[right]['content'], 'blue')[0] > 30,
131 'responsive remote marker did not reach its framebuffer region')
132 stop.touch()
133 eventually(done.exists, 'journey flood did not stop')
134 require(second_count > first_count, 'no frame completed while another pane flooded')
135 require(latency < DEADLINE and p99 < 20000,
136 f'pane isolation exceeded budget: {latency}s / {p99}us')
137 rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)')
138
139
140 def raw_snapshot_and_terminal_peer(rig, refs):
141 left = next(iter(refs))
142 sock, name = refs[left]
143 rig.focus(left)
144 rig.shell("head -c 4096 /dev/zero | tr '\\000' '\\177'")
145 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mJOURNEY-%s\\177\\033[0m\\n' RENDER")
146 rig.wait_marker(sock, name, 'JOURNEY-RENDER')
147 state = rig.wait_state(lambda s: 'JOURNEY-RENDER' in by_id(s)[left]['painted_text'])
148 eventually(lambda: colour_counts(rig.pixels(), by_id(state)[left]['content'], 'red')[0] > 30,
149 'DEL-adjacent marker was not rendered before close')
150 before = stable_layout(state)
151 rig.quit()
152 for target_sock, session in refs.values():
153 require(session in sessions(target_sock), 'closing the journey ended a daemon session')
154
155 rig.launch_gui([], 'gui-journey-restored')
156 restored = rig.wait_state(lambda s: len(s['panes']) == 3 and
157 all(p['phase'] == 'attached' for p in s['panes']))
158 require(stable_layout(restored) == before, 'reopen changed pane or divider identity')
159 require('JOURNEY-RENDER' in by_id(restored)[left]['painted_text'],
160 'reopen lost the DEL-adjacent terminal snapshot')
161 eventually(lambda: colour_counts(rig.pixels(), by_id(restored)[left]['content'], 'red')[0] > 30,
162 'reopened framebuffer did not paint the retained marker')
163
164 terminal = rig.holder(sock, name)
165 rig.release_holder(terminal)
166 rig.wait_state(lambda s: 'HOLDER-ONLINE' in by_id(s)[left]['painted_text'])
167 rig.ok('close/reopen retains panes and raw terminal text; a terminal client shares the GUI session')
168
169
170 def independent_failures(rig, refs):
171 left, right, lower = list(refs)
172 remote_sock, remote_proc = next((sock, proc) for sock, proc in rig.daemons
173 if sock == refs[right][0])
174 rig.stop_daemon(remote_sock, remote_proc)
175 rig.wait_state(lambda s: by_id(s)[right]['phase'] != 'attached')
176 rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR')
177
178 rig.focus(lower)
179 rig.shell('exit 7')
180 rig.wait_state(lambda s: by_id(s)[lower]['phase'] == 'exited' and
181 by_id(s)[lower]['exit_code'] == 7)
182 rig.mark(left, *refs[left], 'JOURNEY-AFTER-EXIT')
183 require(rig.gui.poll() is None, 'a failed transport or exited neighbour closed the window')
184 rig.quit()
185 require('left' in sessions(refs[left][0]), 'final close ended the surviving local session')
186 rig.ok('remote loss and shell exit stay confined to their panes')
187
188 missing = str(rig.root / 'missing.sock')
189 rig.launch_gui(['--sock', missing, '--session', 'offline'], 'gui-journey-unavailable', attached=False)
190 rig.wait_state(lambda s: s['panes'][0]['phase'] in ('dial_failed', 'failed', 'reconnecting'))
191 rig.send('resize:8x8')
192 state = rig.wait_state(lambda s: (s['width'], s['height']) == (8, 8))
193 require(len(state['panes']) == 1 and state['panes'][0]['cols'] > 0 and
194 state['panes'][0]['rows'] > 0, 'tiny unavailable pane acquired invalid dimensions')
195 rig.quit()
196 rig.ok('an initially unavailable target remains representable and closes cleanly')
197
198
199 def main():
200 require(len(sys.argv) == 3, 'usage: native_journey.py RELEASE_MUX RELEASE_MUXG')
201 rig = SelectionRig(*sys.argv[1:])
202 rig.test_label = 'journey'
203 try:
204 release_binary(rig)
205 refs = basic_workspace(rig)
206 state = rig.state()
207 identities = [(p['id'], p['generation']) for p in state['panes']]
208 drag_and_keys(rig, identities, refs)
209 modal_leakage(rig, identities)
210 clamps(rig, identities, refs)
211 pixels_and_batch(rig, identities, refs)
212 flood_while_responsive(rig, refs)
213 target = list(refs)[1]
214 copy_shortcut(rig, target)
215 paste_shortcut(rig, target)
216
217 family = font_family()
218 exercise_fonts(rig, refs, family=family)
219 rig.launch_gui([], 'gui-journey-after-fonts')
220 rig.wait_state(lambda s: len(s['panes']) == 3 and
221 all(p['phase'] == 'attached' for p in s['panes']))
222 raw_snapshot_and_terminal_peer(rig, refs)
223 independent_failures(rig, refs)
224 print(f'PASS: native journey ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True)
225 except BaseException:
226 rig.failure_artifacts()
227 raise
228 finally:
229 rig.close()
230
231
232 if __name__ == '__main__':
233 main()
test/native_lifecycle.py
Old New
@@ -166,28 +166,70 @@ def durable_structure(document):
166 [(tab['id'], tab['tree'], tab['panes']) for tab in document['tabs']]) 166 [(tab['id'], tab['tree'], tab['panes']) for tab in document['tabs']])
167 167
168 168
169 def start_persistent(rig): 169 def start_workspace(rig, *, quic=False, picker_checks=False):
170 """Build the shared three-pane fixture without re-testing its prerequisites."""
170 a, _ = rig.daemon('lifecycle-left') 171 a, _ = rig.daemon('lifecycle-left')
171 b, _ = rig.daemon('lifecycle-right') 172 b, _ = rig.daemon('lifecycle-right', quic=quic)
172 rig.catalogue(['--sock ' + a, '--sock ' + b]) 173 first_target = '--sock ' + a
174 second_target = rig.targets.get(b, '--sock ' + b)
175 rig.catalogue([first_target, second_target])
173 rig.launch_gui([], 'gui-persistent-first', attached=False) 176 rig.launch_gui([], 'gui-persistent-first', attached=False)
174 rig.picker('hosts') 177 rig.picker('hosts')
175 require(not rig.state()['panes'], 'first launch created a shell before explicit selection') 178 if picker_checks:
176 rig.choose('--sock ' + a) 179 require(not rig.state()['panes'], 'first launch created a shell before explicit selection')
180 rig.choose(first_target)
177 rig.picker('sessions') 181 rig.picker('sessions')
178 rig.new_session('left') 182 rig.new_session('left')
179 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') 183 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
180 rig.host('--sock ' + b, 'v') 184 if picker_checks:
185 rig.chord('v')
186 armed = rig.picker('hosts')
187 require(armed['pending']['direction'] == 'beside',
188 'v did not arm a split beside the focused pane')
189 stats = rig.command('d', 'stats', '--sock', b).stdout
190 require('attaches=0' in stats, 'picker attached its target before insertion: ' + stats)
191 require(rig.command('a', 'status', '--sock', b, '--session', 'right',
192 '--timeout', '200', check=False).returncode != 0,
193 'picker created its named session before insertion')
194 rig.key('escape')
195 cancelled = rig.wait_state(lambda s: s['picker'] is None)
196 require(len(cancelled['panes']) == 1 and cancelled['pending'] == armed['pending'],
197 'closing the host picker changed layout or forgot the armed split')
198 rig.key('escape')
199 rig.wait_state(lambda s: s['pending'] is None)
200 rig.host(second_target, 'beside')
181 rig.new_session('right') 201 rig.new_session('right')
182 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) 202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes']))
183 rig.host('--sock ' + a, 'b') 203 if picker_checks:
204 rig.chord('b')
205 armed = rig.picker('hosts')
206 require(armed['pending']['direction'] == 'stacked',
207 'b did not arm a split below the focused pane')
208 rig.key('escape')
209 rig.key('escape')
210 rig.wait_state(lambda s: s['pending'] is None)
211 rig.host(first_target, 'stacked')
184 rig.choose('0') 212 rig.choose('0')
185 state = rig.wait_state(lambda s: len(s['panes']) == 3 and 213 state = rig.wait_state(lambda s: len(s['panes']) == 3 and
186 all(p['phase'] == 'attached' for p in s['panes'])) 214 all(p['phase'] == 'attached' for p in s['panes']))
187 refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'), 215 refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'),
188 state['panes'][2]['id']: (a, '0')} 216 state['panes'][2]['id']: (a, '0')}
189 for pane_id, (sock, name) in refs.items(): 217 rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']]
218 for pane_id in refs:
190 rig.remember_tty(pane_id) 219 rig.remember_tty(pane_id)
220 rig.wait_saved()
221 expected_ids = set(refs)
222 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')
224 rig.assert_cli_untouched()
225 return refs
226
227
228 def start_persistent(rig):
229 """Build the persistence fixture and give it observable, non-default state."""
230 refs = start_workspace(rig)
231 state = rig.state()
232 for pane_id, (sock, name) in refs.items():
191 rig.mark(pane_id, sock, name, 'PERSIST-' + name) 233 rig.mark(pane_id, sock, name, 'PERSIST-' + name)
192 rig.drag('beside', dx=state['cell_w'] * 7) 234 rig.drag('beside', dx=state['cell_w'] * 7)
193 rig.drag('stacked', dy=-state['cell_h'] * 4) 235 rig.drag('stacked', dy=-state['cell_h'] * 4)
@@ -493,7 +535,7 @@ def delayed_end_transitions(mux, muxg):
493 a, daemon = rig.daemon('delayed-end') 535 a, daemon = rig.daemon('delayed-end')
494 b, _ = rig.daemon('healthy-neighbour') 536 b, _ = rig.daemon('healthy-neighbour')
495 rig.start_gui(a, b, 'gui-delayed-' + transition) 537 rig.start_gui(a, b, 'gui-delayed-' + transition)
496 rig.host('--sock ' + b, 'v') 538 rig.host('--sock ' + b, 'beside')
497 rig.choose('0') 539 rig.choose('0')
498 state = rig.wait_state(lambda s: len(s['panes']) == 2 and 540 state = rig.wait_state(lambda s: len(s['panes']) == 2 and
499 all(p['phase'] == 'attached' for p in s['panes'])) 541 all(p['phase'] == 'attached' for p in s['panes']))
test/native_picker.py
Old New
@@ -138,7 +138,7 @@ def workflow(rig):
138 initial = rig.launch_gui(["--sock", first, "--session", "left"], "gui-picker") 138 initial = rig.launch_gui(["--sock", first, "--session", "left"], "gui-picker")
139 original_layout = [p["outer"] for p in initial["panes"]] 139 original_layout = [p["outer"] for p in initial["panes"]]
140 before = sessions(second) 140 before = sessions(second)
141 rig.host(second_target, "v") 141 rig.host(second_target, "beside")
142 rig.choose("New session...") 142 rig.choose("New session...")
143 rig.picker("session_name") 143 rig.picker("session_name")
144 rig.send("text:CANCELLED-NAME", "key:escape", "key:escape", "key:escape") 144 rig.send("text:CANCELLED-NAME", "key:escape", "key:escape", "key:escape")
@@ -157,7 +157,7 @@ def workflow(rig):
157 rig.wait_marker(second, "right", "PICKER-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") 158 require("PICKER-RIGHT" not in rig.dump(first, "left"), "new pane input reached old pane")
159 untouched = state["panes"][0]["outer"] 159 untouched = state["panes"][0]["outer"]
160 rig.host(first_target, "b") 160 rig.host(first_target, "stacked")
161 rig.choose("0") 161 rig.choose("0")
162 state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and 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"])) 163 all(p["phase"] == "attached" for p in s["panes"]))
@@ -172,7 +172,7 @@ def workflow(rig):
172 require(green[0] > 30 and green[1] == 0, "nested split erased or misplaced its neighbour's glyphs") 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") 173 rig.ok("picker creates over QUIC and joins existing session in nested split with real input and pixels")
174 174
175 rig.host(first_target, "v") 175 rig.host(first_target, "beside")
176 rig.choose("New session...") 176 rig.choose("New session...")
177 rig.picker("session_name") 177 rig.picker("session_name")
178 # One event batch: Enter must use the actual resized window even before 178 # One event batch: Enter must use the actual resized window even before
@@ -187,7 +187,7 @@ def workflow(rig):
187 rig.ok("window becoming too small during name entry refuses split before creating a remote shell") 187 rig.ok("window becoming too small during name entry refuses split before creating a remote shell")
188 188
189 # Change the server after the user has already entered a fresh name. 189 # Change the server after the user has already entered a fresh name.
190 rig.host(first_target, "v") 190 rig.host(first_target, "beside")
191 rig.choose("New session...") 191 rig.choose("New session...")
192 rig.picker("session_name") 192 rig.picker("session_name")
193 rig.send("text:raced-name") 193 rig.send("text:raced-name")
@@ -221,7 +221,7 @@ def add_host(rig):
221 rig.catalogue([first_target]) 221 rig.catalogue([first_target])
222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") 222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host")
223 before = rig.pixels() 223 before = rig.pixels()
224 rig.chord("v") 224 rig.split("beside")
225 state = rig.picker("hosts") 225 state = rig.picker("hosts")
226 rect = state["picker"]["rect"] 226 rect = state["picker"]["rect"]
227 painted = rig.pixels() 227 painted = rig.pixels()
@@ -259,7 +259,7 @@ def slow_and_legacy(rig):
259 slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path 259 slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path
260 rig.catalogue([first_target, slow_target, legacy_target]) 260 rig.catalogue([first_target, slow_target, legacy_target])
261 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") 261 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker")
262 rig.chord("v") 262 rig.split("beside")
263 rig.picker("hosts") 263 rig.picker("hosts")
264 rig.choose(slow_target) 264 rig.choose(slow_target)
265 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") 265 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried")
@@ -318,12 +318,12 @@ def old_daemon(rig, binary):
318 target = "--sock " + first 318 target = "--sock " + first
319 rig.catalogue([target]) 319 rig.catalogue([target])
320 rig.launch_gui(["--sock", first, "--session", "left"], "gui-old-daemon") 320 rig.launch_gui(["--sock", first, "--session", "left"], "gui-old-daemon")
321 rig.host(target, "v") 321 rig.host(target, "beside")
322 rig.choose("0") 322 rig.choose("0")
323 rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"])) 323 rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
324 rig.shell("printf 'OLD-DAEMON-%s\\n' JOINED") 324 rig.shell("printf 'OLD-DAEMON-%s\\n' JOINED")
325 rig.wait_marker(first, "0", "OLD-DAEMON-JOINED") 325 rig.wait_marker(first, "0", "OLD-DAEMON-JOINED")
326 rig.host(target, "b") 326 rig.host(target, "stacked")
327 rig.new_session("unsupported-create") 327 rig.new_session("unsupported-create")
328 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20) 328 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
329 require("unsupported-create" not in sessions(first), "old daemon fallback created a shell") 329 require("unsupported-create" not in sessions(first), "old daemon fallback created a shell")
test/native_resize.py
Old New
@@ -137,10 +137,10 @@ def build_nested(rig):
137 c, _ = rig.daemon('resize-lower') 137 c, _ = rig.daemon('resize-lower')
138 rig.catalogue(['--sock ' + sock for sock in (a, b, c)]) 138 rig.catalogue(['--sock ' + sock for sock in (a, b, c)])
139 rig.launch_gui(['--sock', a, '--session', 'left'], 'gui-resize') 139 rig.launch_gui(['--sock', a, '--session', 'left'], 'gui-resize')
140 rig.host('--sock ' + b, axis='v') 140 rig.host('--sock ' + b, direction='beside')
141 rig.new_session('upper') 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'])) 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, axis='b') 143 rig.host('--sock ' + c, direction='stacked')
144 rig.new_session('lower') 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'])) 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']] 146 identities = [(p['id'], p['generation']) for p in state['panes']]
test/native_ssh.py
Old New
@@ -134,7 +134,7 @@ def run(r, ssh, show=lambda label: None):
134 r.picker('sessions') 134 r.picker('sessions')
135 r.choose('neighbour') 135 r.choose('neighbour')
136 r.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') 136 r.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
137 r.host(ssh.target, 'v') 137 r.host(ssh.target, 'beside')
138 r.choose('remote') 138 r.choose('remote')
139 state = r.wait_state(lambda s: len(s['panes']) == 2 and 139 state = r.wait_state(lambda s: len(s['panes']) == 2 and
140 all(p['phase'] == 'attached' for p in s['panes'])) 140 all(p['phase'] == 'attached' for p in s['panes']))
test/native_tiling.py
Old New
@@ -18,6 +18,7 @@ import time
18 18
19 19
20 DEADLINE = 5.0 20 DEADLINE = 5.0
21 SPLIT_BINDINGS = {"beside": "v", "stacked": "b"}
21 22
22 23
23 def require(condition, message): 24 def require(condition, message):
@@ -56,6 +57,7 @@ class Rig:
56 self.fd = None 57 self.fd = None
57 self.serial = 0 58 self.serial = 0
58 self.checkpoints = 0 59 self.checkpoints = 0
60 self.test_label = "tiling"
59 61
60 def spawn(self, argv, label, env=None): 62 def spawn(self, argv, label, env=None):
61 log = (self.root / (label + ".log")).open("wb") 63 log = (self.root / (label + ".log")).open("wb")
@@ -128,6 +130,11 @@ class Rig:
128 def chord(self, key): 130 def chord(self, key):
129 self.send("key:prefix", "key:" + key) 131 self.send("key:prefix", "key:" + key)
130 132
133 def split(self, direction):
134 key = SPLIT_BINDINGS.get(direction)
135 require(key is not None, "unknown split direction: " + str(direction))
136 self.chord(key)
137
131 def shell(self, command): 138 def shell(self, command):
132 self.send("text:" + command, "key:enter") 139 self.send("text:" + command, "key:enter")
133 140
@@ -173,9 +180,9 @@ class Rig:
173 self.key("down" if delta > 0 else "up") 180 self.key("down" if delta > 0 else "up")
174 self.key("enter") 181 self.key("enter")
175 182
176 def host(self, spelling, axis=None): 183 def host(self, spelling, direction=None):
177 if axis: 184 if direction:
178 self.chord(axis) 185 self.split(direction)
179 else: 186 else:
180 self.chord("enter") 187 self.chord("enter")
181 self.picker("hosts") 188 self.picker("hosts")
@@ -226,7 +233,7 @@ class Rig:
226 233
227 def ok(self, message): 234 def ok(self, message):
228 self.checkpoints += 1 235 self.checkpoints += 1
229 print(f"tiling OK ({self.checkpoints}): {message}", flush=True) 236 print(f"{self.test_label} OK ({self.checkpoints}): {message}", flush=True)
230 237
231 def close(self): 238 def close(self):
232 if self.gui is not None and self.gui.poll() is None: 239 if self.gui is not None and self.gui.poll() is None:
@@ -275,10 +282,10 @@ def colour_counts(capture, rect, colour):
275 return inside, outside 282 return inside, outside
276 283
277 284
278 def check_geometry(rig, state, first, second, axis): 285 def check_geometry(rig, state, first, second, direction):
279 a, b = state["panes"] 286 a, b = state["panes"]
280 ar, br = a["outer"], b["outer"] 287 ar, br = a["outer"], b["outer"]
281 size_axis, pos_axis = ("w", "x") if axis == "v" else ("h", "y") 288 size_axis, pos_axis = ("w", "x") if direction == "beside" else ("h", "y")
282 require(abs(ar[size_axis] - br[size_axis]) <= 1, "split did not divide the armed pane equally") 289 require(abs(ar[size_axis] - br[size_axis]) <= 1, "split did not divide the armed pane equally")
283 require(br[pos_axis] >= ar[pos_axis] + ar[size_axis], "panes overlap") 290 require(br[pos_axis] >= ar[pos_axis] + ar[size_axis], "panes overlap")
284 for pane, sock, session in ((a, first, "left"), (b, second, "right")): 291 for pane, sock, session in ((a, first, "left"), (b, second, "right")):
@@ -291,19 +298,19 @@ def check_geometry(rig, state, first, second, axis):
291 "daemon did not receive its pane's physical size") 298 "daemon did not receive its pane's physical size")
292 299
293 300
294 def normal_scenario(rig, axis): 301 def normal_scenario(rig, direction):
295 first, _ = rig.daemon("first-" + axis) 302 first, _ = rig.daemon("first-" + direction)
296 second, second_proc = rig.daemon("second-" + axis, quic=axis == "b") 303 second, second_proc = rig.daemon("second-" + direction, quic=direction == "stacked")
297 if axis == "v": 304 if direction == "beside":
298 for flags in (("--next-target", "box"), ("--next-session", "right")): 305 for flags in (("--next-target", "box"), ("--next-session", "right")):
299 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env, 306 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env,
300 capture_output=True, timeout=3) 307 capture_output=True, timeout=3)
301 require(result.returncode == 2, "retired staged-target arguments were accepted") 308 require(result.returncode == 2, "retired staged-target arguments were accepted")
302 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") 309 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets")
303 before = rig.start_gui(first, second, "gui-" + axis) 310 before = rig.start_gui(first, second, "gui-" + direction)
304 rig.chord(axis) 311 rig.split(direction)
305 armed = rig.picker("hosts") 312 armed = rig.picker("hosts")
306 require(armed["pending"]["direction"] == ("beside" if axis == "v" else "stacked"), 313 require(armed["pending"]["direction"] == direction,
307 "split chord opened picker with the wrong direction") 314 "split chord opened picker with the wrong direction")
308 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], 315 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"],
309 "arming split changed geometry") 316 "arming split changed geometry")
@@ -321,20 +328,20 @@ def normal_scenario(rig, axis):
321 "closing host picker changed panes or forgot its armed split") 328 "closing host picker changed panes or forgot its armed split")
322 rig.key("escape") 329 rig.key("escape")
323 rig.wait_state(lambda s: s["pending"] is None) 330 rig.wait_state(lambda s: s["pending"] is None)
324 rig.chord(axis) 331 rig.split(direction)
325 rig.picker("hosts") 332 rig.picker("hosts")
326 rig.choose(rig.targets.get(second, "--sock " + second)) 333 rig.choose(rig.targets.get(second, "--sock " + second))
327 rig.picker("sessions") 334 rig.picker("sessions")
328 rig.new_session("right") 335 rig.new_session("right")
329 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"])) 336 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
330 check_geometry(rig, state, first, second, axis) 337 check_geometry(rig, state, first, second, direction)
331 transport = "Unix + QUIC" if axis == "b" else "two Unix sockets" 338 transport = "Unix + QUIC" if direction == "stacked" else "two Unix sockets"
332 rig.ok(axis + " split over " + transport + "; both PTYs have exact pane sizes") 339 rig.ok(direction + " split over " + transport + "; both PTYs have exact pane sizes")
333 340
334 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mRIGHT-%s\\033[0m\\n' MARK") 341 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mRIGHT-%s\\033[0m\\n' MARK")
335 rig.wait_marker(second, "right", "RIGHT-MARK") 342 rig.wait_marker(second, "right", "RIGHT-MARK")
336 require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane") 343 require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane")
337 rig.chord("h" if axis == "v" else "k") 344 rig.chord("h" if direction == "beside" else "k")
338 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mLEFT-%s\\033[0m\\n' MARK") 345 rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mLEFT-%s\\033[0m\\n' MARK")
339 rig.wait_marker(first, "left", "LEFT-MARK") 346 rig.wait_marker(first, "left", "LEFT-MARK")
340 require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane") 347 require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane")
@@ -349,7 +356,7 @@ def normal_scenario(rig, axis):
349 356
350 rig.send("resize:640x400") 357 rig.send("resize:640x400")
351 state = rig.wait_state(lambda s: (s["width"], s["height"]) == (640, 400)) 358 state = rig.wait_state(lambda s: (s["width"], s["height"]) == (640, 400))
352 check_geometry(rig, state, first, second, axis) 359 check_geometry(rig, state, first, second, direction)
353 eventually(correct_pixels, "resized framebuffer lost or misplaced pane content") 360 eventually(correct_pixels, "resized framebuffer lost or misplaced pane content")
354 # Clear red content after shrinking; the other pane's green text must survive. 361 # Clear red content after shrinking; the other pane's green text must survive.
355 rig.shell("printf '\\033[2J\\033[H'") 362 rig.shell("printf '\\033[2J\\033[H'")
@@ -367,7 +374,7 @@ def normal_scenario(rig, axis):
367 require("CLICK-RIGHT" not in rig.dump(first, "left"), "click focus routed to wrong pane") 374 require("CLICK-RIGHT" not in rig.dump(first, "left"), "click focus routed to wrong pane")
368 rig.ok("window resize, neighbour-safe clearing, and mouse focus work") 375 rig.ok("window resize, neighbour-safe clearing, and mouse focus work")
369 376
370 if axis == "v": 377 if direction == "beside":
371 rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; " 378 rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; "
372 "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE") 379 "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE")
373 rig.wait_marker(second, "right", "RAW-READY") 380 rig.wait_marker(second, "right", "RAW-READY")
@@ -420,7 +427,7 @@ def normal_scenario(rig, axis):
420 rig.wait_marker(first, "left", "AFTER-EXIT-OK") 427 rig.wait_marker(first, "left", "AFTER-EXIT-OK")
421 rig.ok("shell exit is retained in its pane while its neighbour remains usable") 428 rig.ok("shell exit is retained in its pane while its neighbour remains usable")
422 rig.quit() 429 rig.quit()
423 require(("SURVIVOR-OK" if axis == "v" else "AFTER-EXIT-OK") in rig.dump(first, "left"), 430 require(("SURVIVOR-OK" if direction == "beside" else "AFTER-EXIT-OK") in rig.dump(first, "left"),
424 "window close ended or replaced the surviving session") 431 "window close ended or replaced the surviving session")
425 rig.ok("closing the window preserves the surviving daemon session") 432 rig.ok("closing the window preserves the surviving daemon session")
426 433
@@ -442,8 +449,8 @@ def main():
442 try: 449 try:
443 version = subprocess.check_output([rig.muxg, "--version"], text=True) 450 version = subprocess.check_output([rig.muxg, "--version"], text=True)
444 require("ReleaseSafe" in version or "ReleaseFast" in version, "native tiling requires a release build") 451 require("ReleaseSafe" in version or "ReleaseFast" in version, "native tiling requires a release build")
445 for axis in ("v", "b"): 452 for direction in ("beside", "stacked"):
446 normal_scenario(rig, axis) 453 normal_scenario(rig, direction)
447 unavailable_scenario(rig) 454 unavailable_scenario(rig)
448 print(f"native tiling OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}") 455 print(f"native tiling OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}")
449 except BaseException: 456 except BaseException: