9940d8fd
test: consolidate native end-to-end journey
a73x 2026-09-07 12:30
Commit message
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: |