0761be0b
test: 12_panes moves to a file of its own
a73x 2026-08-26 19:13
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -102,7 +102,7 @@ done | |||
| 102 | E2E_DIR=$(dirname "$0") | 102 | E2E_DIR=$(dirname "$0") |
| 103 | . "$E2E_DIR/e2e_lib.sh" | 103 | . "$E2E_DIR/e2e_lib.sh" |
| 104 | 104 | ||
| 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select' | 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes' |
| 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses | 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses |
| 107 | # these by name rather than running them: a group whose fixtures are | 107 | # these by name rather than running them: a group whose fixtures are |
| 108 | # missing does not fail, it passes having tested something else — and a | 108 | # missing does not fail, it passes having tested something else — and a |
| @@ -145,807 +145,6 @@ for _g in $E2E_GROUPS; do | |||
| 145 | . "$E2E_DIR/e2e_$_g.sh" | 145 | . "$E2E_DIR/e2e_$_g.sh" |
| 146 | done | 146 | done |
| 147 | 147 | ||
| 148 | # The scrollback-rect leg: a focused tile's history page must own only its | ||
| 149 | # sub-rect, so its daemon gets a socket of its own for the grid-size | ||
| 150 | # reason every wall leg has. | ||
| 151 | SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock" | ||
| 152 | defer_sock "$SOCK53" | ||
| 153 | SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock" | ||
| 154 | defer_sock "$SOCK54" | ||
| 155 | SOCK55="${TMPDIR:-/tmp}/muxd-e2e-resize-$$.sock" | ||
| 156 | defer_sock "$SOCK55" | ||
| 157 | SOCK56="${TMPDIR:-/tmp}/muxd-e2e-spanclear-$$.sock" | ||
| 158 | defer_sock "$SOCK56" | ||
| 159 | SOCK57="${TMPDIR:-/tmp}/muxd-e2e-hjkl-$$.sock" | ||
| 160 | defer_sock "$SOCK57" | ||
| 161 | SOCK58="${TMPDIR:-/tmp}/muxd-e2e-splitbirth-$$.sock" | ||
| 162 | defer_sock "$SOCK58" | ||
| 163 | SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock" | ||
| 164 | defer_sock "$SOCK59" | ||
| 165 | SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock" | ||
| 166 | defer_sock "$SOCK60" | ||
| 167 | SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock" | ||
| 168 | defer_sock "$SOCK61" | ||
| 169 | SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock" | ||
| 170 | defer_sock "$SOCK62" | ||
| 171 | SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock" | ||
| 172 | defer_sock "$SOCK63" | ||
| 173 | |||
| 174 | # --- the cursor rests in the focused tile, whoever painted last ---------- | ||
| 175 | # The cursor-ownership leg: a daemon of its own, for the grid-size reason | ||
| 176 | # every wall leg has — the assertion is about where the cursor RESTS, and a | ||
| 177 | # session another block created would change the tile it rests in. | ||
| 178 | SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock" | ||
| 179 | defer_sock "$SOCK52" | ||
| 180 | # | ||
| 181 | # Every painter ends by showing the cursor where its own stripe sits, so | ||
| 182 | # without an owner the visible cursor lands on whichever pump painted last: | ||
| 183 | # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live | ||
| 184 | # while the keys go elsewhere. The focused tile's paint now records its | ||
| 185 | # screen cursor in Shared, and an unfocused paint's last act is to put the | ||
| 186 | # cursor back there. This leg focuses tile 3 of a three-tile wall and | ||
| 187 | # asserts the terminal's final cursor position is in tile 3's stripe — | ||
| 188 | # deterministic, because every paint now ends at the focused tile's cursor. | ||
| 189 | # | ||
| 190 | # Three sessions on a daemon of its own, each marked by its shell before the | ||
| 191 | # wall attaches (the shell-expanded marker trick: a hit is the shell's work, | ||
| 192 | # never an echo of anything typed here). The ptyclient wall focuses tile 3, | ||
| 193 | # settles, and detaches; the capture's last cursor-position escape is the | ||
| 194 | # witness. | ||
| 195 | start_daemon "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound" --shell /bin/sh | ||
| 196 | D52PID=$DPID | ||
| 197 | |||
| 198 | pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a | ||
| 199 | pipe_send 'printf "ma-%%s\\n" pin\n' | ||
| 200 | await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client" | ||
| 201 | pipe_detach | ||
| 202 | wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a | ||
| 203 | pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b | ||
| 204 | pipe_send 'printf "mb-%%s\\n" pin\n' | ||
| 205 | await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client" | ||
| 206 | pipe_detach | ||
| 207 | wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b | ||
| 208 | pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c | ||
| 209 | pipe_send 'printf "mc-%%s\\n" pin\n' | ||
| 210 | await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client" | ||
| 211 | pipe_detach | ||
| 212 | wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c | ||
| 213 | |||
| 214 | set +e | ||
| 215 | timeout 40 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \ | ||
| 216 | "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF' | ||
| 217 | expect mc-pin 20000 | ||
| 218 | settle 500 15000 | ||
| 219 | send \x1c3 | ||
| 220 | settle 500 15000 | ||
| 221 | send \x1cd | ||
| 222 | waitexit 10000 | ||
| 223 | EOF | ||
| 224 | RC=$? | ||
| 225 | set -e | ||
| 226 | [ "$RC" -eq 0 ] || { | ||
| 227 | echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:" | ||
| 228 | cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; } | ||
| 229 | # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21). | ||
| 230 | # The focused tile's paint records its cursor; an unfocused paint's last act | ||
| 231 | # is a bare CUP putting it back there, with no show of its own. So the | ||
| 232 | # terminal's final cursor position — the last CUP in the capture, show or | ||
| 233 | # none — rests in tile 3's stripe whichever pump painted last. The last | ||
| 234 | # CUP+show would be the last painter's OWN cursor (racy); the last CUP of | ||
| 235 | # any kind is the focused tile's, and that is what the fix owns. | ||
| 236 | _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H' | ||
| 237 | _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1) | ||
| 238 | # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix. | ||
| 239 | _cur_row=${_last_cup#??} | ||
| 240 | _cur_row=${_cur_row%%;*} | ||
| 241 | [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || { | ||
| 242 | echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):" | ||
| 243 | cat "$OUT.cucap"; exit 1; } | ||
| 244 | assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop" | ||
| 245 | D52PID="" | ||
| 246 | ok "the cursor rests in the focused tile, whichever pump painted last" | ||
| 247 | |||
| 248 | # ---- a focused tile's scrollback page owns only its sub-rect ---------- | ||
| 249 | # | ||
| 250 | # The one-tile wheel leg above has no label bar, so its geometry is the | ||
| 251 | # plain client's: the one paint at row 0 that DOES own the whole screen. | ||
| 252 | # That exemption is what let a scrollback page at a row_off through that | ||
| 253 | # opened with `\x1b[H\x1b[2J` — clearing the whole terminal and painting | ||
| 254 | # history at row 1, while the exit path repaints only the focused tile's | ||
| 255 | # own rect, so every neighbour stayed blank. This leg is the twin that | ||
| 256 | # catches it: two tiles over 30 rows, the focused one scrolls back on the | ||
| 257 | # wheel, and the neighbour's pin must still be the last content painted in | ||
| 258 | # its rows, with no whole-screen clear following it. | ||
| 259 | # | ||
| 260 | # Two sessions on one daemon. The focused tile (a, the first spelling) | ||
| 261 | # fills a scrollback the wheel can page into; the neighbour (b) holds a | ||
| 262 | # pin and is never touched again. Both shells are /bin/sh and the content | ||
| 263 | # is typed, the focus-skip leg's pattern — the neighbour's pin is the | ||
| 264 | # session's own output, not an echo of the command that made it. | ||
| 265 | start_daemon "$SOCK53" "$OUT.sb2.d" "scrollback-rect daemon never bound" --shell /bin/sh | ||
| 266 | D54PID=$DPID | ||
| 267 | |||
| 268 | # Session a: 100 lines of history, then cat. The wall attaches only once | ||
| 269 | # seq has finished, so the page the wheel fetches is content this tile was | ||
| 270 | # never sent live — the wheel leg's reason, verbatim. | ||
| 271 | pipe_mux "$OUT.sb2a" "$OUT.sb2a.err" timeout 40 "$MUX" --sock "$SOCK53" --session a | ||
| 272 | pipe_send 'seq 1 100 | sed '\''s/^/wln/'\''; exec cat\n' | ||
| 273 | await_out "$OUT.sb2a" "wln100" "scrollback-rect: session a never finished seq" | ||
| 274 | pipe_detach | ||
| 275 | wait_grid "$SOCK53" "wln100" "scrollback-rect: session a's tail" a | ||
| 276 | |||
| 277 | # Session b: a pin the capture can grep for, then cat. `printf "sb2%s\n" | ||
| 278 | # nbr` so the pin is the output, not the echoed command — the focus-skip | ||
| 279 | # leg's prefix trick. | ||
| 280 | pipe_mux "$OUT.sb2b" "$OUT.sb2b.err" timeout 40 "$MUX" --sock "$SOCK53" --session b | ||
| 281 | pipe_send 'printf "sb2%%s\\n" nbr; exec cat\n' | ||
| 282 | await_out "$OUT.sb2b" "sb2nbr" "scrollback-rect: session b's pin never reached the client" | ||
| 283 | pipe_detach | ||
| 284 | wait_grid "$SOCK53" "sb2nbr" "scrollback-rect: session b's pin" b | ||
| 285 | |||
| 286 | set +e | ||
| 287 | timeout 90 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.sb2cap" --err "$OUT.sb2cap.err" -- \ | ||
| 288 | "$MUX" wall "--sock $SOCK53#a" "--sock $SOCK53#b" > "$OUT.sb2pc" 2>&1 <<'EOF' | ||
| 289 | expect sb2nbr 20000 | ||
| 290 | settle 700 20000 | ||
| 291 | send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M | ||
| 292 | expect \x1b[7m[scroll] 15000 | ||
| 293 | settle 500 15000 | ||
| 294 | send \x1cd | ||
| 295 | waitexit 10000 | ||
| 296 | EOF | ||
| 297 | RC=$? | ||
| 298 | set -e | ||
| 299 | [ "$RC" -eq 0 ] || { | ||
| 300 | echo "e2e FAIL: scrollback-rect: ptyclient leg exited $RC (did the focused wheel scroll?):" | ||
| 301 | cat "$OUT.sb2pc"; exit 1; } | ||
| 302 | # The neighbour's pin is still the last content painted in its rows: the | ||
| 303 | # capture holds it, and nothing after it cleared the whole screen. A | ||
| 304 | # scrollback page that owned the whole terminal (the bug) would open with | ||
| 305 | # `\x1b[H\x1b[2J` right here, wiping the neighbour's rows; the fix clears | ||
| 306 | # per row inside the focused tile's sub-rect only. | ||
| 307 | grep -qaF "sb2nbr" "$OUT.sb2cap" || { | ||
| 308 | echo "e2e FAIL: scrollback-rect: the neighbour's pin left the capture:" | ||
| 309 | cat "$OUT.sb2pc"; exit 1; } | ||
| 310 | _sb2last=$(grep -aboF -- "sb2nbr" "$OUT.sb2cap" | tail -1 | cut -d: -f1) | ||
| 311 | tail -c +$((_sb2last + 1)) "$OUT.sb2cap" | grep -aF -- "$(printf '\x1b[2J')" && { | ||
| 312 | echo "e2e FAIL: scrollback-rect: a whole-screen clear followed the neighbour's paint:" | ||
| 313 | cat "$OUT.sb2pc"; exit 1; } | ||
| 314 | assert_stopped "$SOCK53" "$D54PID" "scrollback-rect" "$OUT.sb2stop" | ||
| 315 | D54PID="" | ||
| 316 | ok "a focused tile's scrollback page owns only its sub-rect, and the neighbour's pin stays" | ||
| 317 | |||
| 318 | # ---- fullscreen: f toggles, focus follows, restore ------------------- | ||
| 319 | # | ||
| 320 | # `f` is a layout op, not a mode: the focused tile takes the whole | ||
| 321 | # terminal, every other gets 0x0. A 0x0 rect claims nothing, so hidden | ||
| 322 | # panes stay attached and paint nothing. Focus moves while fullscreened | ||
| 323 | # re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores | ||
| 324 | # the real layout. This leg proves all three with `muxa capture` — the | ||
| 325 | # marker each session received names which tile owned the terminal. | ||
| 326 | # | ||
| 327 | # No convergence point: `muxa capture` reads the daemon's grid, not the | ||
| 328 | # terminal's, and the daemon's grid is the side channel this asserts on. | ||
| 329 | start_daemon "$SOCK54" "$OUT.fs.d" "fullscreen daemon never bound" --shell /bin/sh | ||
| 330 | D55PID=$DPID | ||
| 331 | |||
| 332 | pipe_mux "$OUT.fsa" "$OUT.fsa.err" timeout 40 "$MUX" --sock "$SOCK54" --session a | ||
| 333 | pipe_send 'printf "fs-%%s\\n" init-a\n' | ||
| 334 | await_out "$OUT.fsa" "fs-init-a" "fullscreen: session a's marker never reached the client" | ||
| 335 | pipe_detach | ||
| 336 | wait_grid "$SOCK54" "fs-init-a" "fullscreen: session a's marker" a | ||
| 337 | pipe_mux "$OUT.fsb" "$OUT.fsb.err" timeout 40 "$MUX" --sock "$SOCK54" --session b | ||
| 338 | pipe_send 'printf "fs-%%s\\n" init-b\n' | ||
| 339 | await_out "$OUT.fsb" "fs-init-b" "fullscreen: session b's marker never reached the client" | ||
| 340 | pipe_detach | ||
| 341 | wait_grid "$SOCK54" "fs-init-b" "fullscreen: session b's marker" b | ||
| 342 | |||
| 343 | set +e | ||
| 344 | # wide: 80x24 trips the aspect rule (80 >= 48 → beside) | ||
| 345 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.fscap" --err "$OUT.fscap.err" -- \ | ||
| 346 | "$MUX" wall "--sock $SOCK54#a" "--sock $SOCK54#b" > "$OUT.fspc" 2>&1 <<'EOF' | ||
| 347 | expect fs-init-b 20000 | ||
| 348 | settle 700 20000 | ||
| 349 | send \x1cf | ||
| 350 | settle 700 20000 | ||
| 351 | send printf 'fs-%s\n' one\n | ||
| 352 | expect fs-one 10000 | ||
| 353 | settle 400 15000 | ||
| 354 | send \x1c2 | ||
| 355 | settle 700 20000 | ||
| 356 | send printf 'fs-%s\n' two\n | ||
| 357 | expect fs-two 10000 | ||
| 358 | settle 400 15000 | ||
| 359 | send \x1cf | ||
| 360 | settle 700 20000 | ||
| 361 | send \x1cd | ||
| 362 | waitexit 10000 | ||
| 363 | EOF | ||
| 364 | RC=$? | ||
| 365 | set -e | ||
| 366 | [ "$RC" -eq 0 ] || { | ||
| 367 | echo "e2e FAIL: fullscreen: ptyclient leg exited $RC:" | ||
| 368 | cat "$OUT.fspc" "$OUT.fscap.err"; exit 1; } | ||
| 369 | # fs-one landed in session a (the tile that was fullscreened first); | ||
| 370 | # fs-two landed in session b (the tile Ctrl-\ 2 moved the full rect to). | ||
| 371 | timeout 20 "$MUXA" capture --sock "$SOCK54" --session a > "$OUT.fsfa" 2>&1 | ||
| 372 | timeout 20 "$MUXA" capture --sock "$SOCK54" --session b > "$OUT.fsfb" 2>&1 | ||
| 373 | grep -q "fs-one" "$OUT.fsfa" || { | ||
| 374 | echo "e2e FAIL: fullscreen: session a never got fs-one:" | ||
| 375 | cat "$OUT.fsfa"; exit 1; } | ||
| 376 | grep -q "fs-one" "$OUT.fsfb" && { | ||
| 377 | echo "e2e FAIL: fullscreen: fs-one reached session b, which was hidden:" | ||
| 378 | cat "$OUT.fsfb"; exit 1; } | ||
| 379 | grep -q "fs-two" "$OUT.fsfb" || { | ||
| 380 | echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not move the full rect to session b:" | ||
| 381 | cat "$OUT.fsfb"; exit 1; } | ||
| 382 | grep -q "fs-two" "$OUT.fsfa" && { | ||
| 383 | echo "e2e FAIL: fullscreen: fs-two reached session a, which was hidden after the focus move:" | ||
| 384 | cat "$OUT.fsfa"; exit 1; } | ||
| 385 | # What the human saw after Ctrl-\ 2: the `>` moved and the digits did not. | ||
| 386 | # A bar's digit is the chord that reaches that tile for its whole life, not | ||
| 387 | # a count of who holds the focus — the render oracle is the only witness, | ||
| 388 | # since the daemon's grid has no bars in it. The label is cut to the stripe | ||
| 389 | # at 80 columns beside, so the marker and the head of the spelling are what | ||
| 390 | # survive; the digit is what tells the two stripes apart. | ||
| 391 | "$RENDER" --cols 80 --rows 24 < "$OUT.fscap" > "$OUT.fsgrid" || { | ||
| 392 | echo "e2e FAIL: fullscreen: render oracle failed" | ||
| 393 | cat "$OUT.fsgrid"; exit 1; } | ||
| 394 | grep -q -- " 2> --sock" "$OUT.fsgrid" || { | ||
| 395 | echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not leave the \`>\` on tile 2's digit:" | ||
| 396 | cat "$OUT.fsgrid"; exit 1; } | ||
| 397 | grep -q -- " 1 --sock" "$OUT.fsgrid" || { | ||
| 398 | echo "e2e FAIL: fullscreen: tile 1 lost its digit or kept the \`>\`:" | ||
| 399 | cat "$OUT.fsgrid"; exit 1; } | ||
| 400 | assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop" | ||
| 401 | D55PID="" | ||
| 402 | ok "fullscreen gives the focused tile the terminal; focus follows; f restores" | ||
| 403 | |||
| 404 | # ---- Ctrl-\ r trades cells between panes ----------------------------- | ||
| 405 | # | ||
| 406 | # Two beside panes at 80x24 (aspect rule → beside). Ctrl-\ r enters | ||
| 407 | # resize mode; lll grows the focused pane's width by 3 cells, moving the | ||
| 408 | # rail 3 columns right. The rail is a reverse-video space painted at a | ||
| 409 | # CUP column; grep the column before and after. Esc exits resize mode, | ||
| 410 | # and typed prose lands in the focused session (marker echo). | ||
| 411 | # | ||
| 412 | # No convergence point: the rail column is a terminal-capture assertion, | ||
| 413 | # not a daemon-grid comparison. | ||
| 414 | start_daemon "$SOCK55" "$OUT.rsz.d" "resize daemon never bound" --shell /bin/sh | ||
| 415 | D56PID=$DPID | ||
| 416 | |||
| 417 | pipe_mux "$OUT.rsza" "$OUT.rsza.err" timeout 40 "$MUX" --sock "$SOCK55" --session a | ||
| 418 | pipe_send 'printf "rsz-%%s\\n" init-a\n' | ||
| 419 | await_out "$OUT.rsza" "rsz-init-a" "resize: session a's marker never reached the client" | ||
| 420 | pipe_detach | ||
| 421 | wait_grid "$SOCK55" "rsz-init-a" "resize: session a's marker" a | ||
| 422 | pipe_mux "$OUT.rszb" "$OUT.rszb.err" timeout 40 "$MUX" --sock "$SOCK55" --session b | ||
| 423 | pipe_send 'printf "rsz-%%s\\n" init-b\n' | ||
| 424 | await_out "$OUT.rszb" "rsz-init-b" "resize: session b's marker never reached the client" | ||
| 425 | pipe_detach | ||
| 426 | wait_grid "$SOCK55" "rsz-init-b" "resize: session b's marker" b | ||
| 427 | |||
| 428 | set +e | ||
| 429 | # 80x24: aspect rule makes beside; two panes split 39/40 with the rail | ||
| 430 | # at column 40 (1-indexed). Three l presses grow the left pane by 3, | ||
| 431 | # moving the rail to column 43. | ||
| 432 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.rszcap" --err "$OUT.rszcap.err" -- \ | ||
| 433 | "$MUX" wall "--sock $SOCK55#a" "--sock $SOCK55#b" > "$OUT.rszpc" 2>&1 <<'EOF' | ||
| 434 | expect rsz-init-b 20000 | ||
| 435 | settle 700 20000 | ||
| 436 | send \x1cr | ||
| 437 | settle 300 10000 | ||
| 438 | send l | ||
| 439 | settle 300 10000 | ||
| 440 | send l | ||
| 441 | settle 300 10000 | ||
| 442 | send l | ||
| 443 | settle 500 15000 | ||
| 444 | send \x1b | ||
| 445 | settle 300 10000 | ||
| 446 | send printf 'rsz-%s\n' grown\n | ||
| 447 | expect rsz-grown 10000 | ||
| 448 | settle 400 15000 | ||
| 449 | send \x1cd | ||
| 450 | waitexit 10000 | ||
| 451 | EOF | ||
| 452 | RC=$? | ||
| 453 | set -e | ||
| 454 | [ "$RC" -eq 0 ] || { | ||
| 455 | echo "e2e FAIL: resize: ptyclient leg exited $RC:" | ||
| 456 | cat "$OUT.rszpc" "$OUT.rszcap.err"; exit 1; } | ||
| 457 | # The rail paints as ESC[row;colH followed by ESC[7m (reverse video). | ||
| 458 | # Extract the column from every CUP that precedes a reverse-video | ||
| 459 | # space — the rail's column — and verify it moved right by 3. | ||
| 460 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 461 | # Before resize: rail at column 40 (1-indexed). After lll: column 43. | ||
| 462 | # Grab all rail CUP columns; the last one is the post-resize position. | ||
| 463 | _rail_cols=$(grep -ao "$_rail_re" "$OUT.rszcap" | \ | ||
| 464 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n) | ||
| 465 | _last_rail=$(echo "$_rail_cols" | tail -1) | ||
| 466 | [ -n "$_last_rail" ] || { | ||
| 467 | echo "e2e FAIL: resize: no rail glyph found in the capture:" | ||
| 468 | cat "$OUT.rszcap"; exit 1; } | ||
| 469 | # 39 cols + rail at 40 initially; 42 cols + rail at 43 after lll. | ||
| 470 | [ "$_last_rail" -ge 43 ] || { | ||
| 471 | echo "e2e FAIL: resize: rail column is $_last_rail, want >=43 (40 + 3 grows):" | ||
| 472 | cat "$OUT.rszcap"; exit 1; } | ||
| 473 | # Esc ended resize mode and typed prose reached the focused session. | ||
| 474 | timeout 20 "$MUXA" capture --sock "$SOCK55" --session a > "$OUT.rszsta" 2>&1 | ||
| 475 | timeout 20 "$MUXA" capture --sock "$SOCK55" --session b > "$OUT.rszstb" 2>&1 | ||
| 476 | if ! grep -q "rsz-grown" "$OUT.rszsta" && ! grep -q "rsz-grown" "$OUT.rszstb"; then | ||
| 477 | echo "e2e FAIL: resize: rsz-grown reached neither session after Esc:" | ||
| 478 | cat "$OUT.rszsta" "$OUT.rszstb"; exit 1 | ||
| 479 | fi | ||
| 480 | assert_stopped "$SOCK55" "$D56PID" "resize" "$OUT.rszstop" | ||
| 481 | D56PID="" | ||
| 482 | ok "Ctrl-\\ r trades cells between panes; Esc returns to prose" | ||
| 483 | |||
| 484 | # ---- span-clear survival: the engine oracle ------------------------- | ||
| 485 | # | ||
| 486 | # Two beside panes at 80x24 (aspect rule: cols >= 2*rows → beside). A | ||
| 487 | # marker in the RIGHT pane's session, then a flood in the LEFT pane's | ||
| 488 | # session (seq 1 200). The regression this catches forever is a whole-line | ||
| 489 | # clear (\x1b[2K) blanking the neighbor: a painter that emits too much | ||
| 490 | # still passes a byte grep, so the assertion replays the wall client's | ||
| 491 | # FULL captured stdout through the render fixture (an engine, not a grep) | ||
| 492 | # and checks the right pane's marker text survives on the replayed grid. | ||
| 493 | start_daemon "$SOCK56" "$OUT.spc.d" "span-clear daemon never bound" --shell /bin/sh | ||
| 494 | D57PID=$DPID | ||
| 495 | |||
| 496 | pipe_mux "$OUT.spca" "$OUT.spca.err" timeout 40 "$MUX" --sock "$SOCK56" --session a | ||
| 497 | pipe_send 'printf "spc-%%s\\n" left-flood\n' | ||
| 498 | await_out "$OUT.spca" "spc-left-flood" "span-clear: left session's marker never reached the client" | ||
| 499 | pipe_detach | ||
| 500 | wait_grid "$SOCK56" "spc-left-flood" "span-clear: left session's marker" a | ||
| 501 | pipe_mux "$OUT.spcb" "$OUT.spcb.err" timeout 40 "$MUX" --sock "$SOCK56" --session b | ||
| 502 | pipe_send 'printf "spc-%%s\\n" right-survivor\n' | ||
| 503 | await_out "$OUT.spcb" "spc-right-survivor" "span-clear: right session's marker never reached the client" | ||
| 504 | pipe_detach | ||
| 505 | wait_grid "$SOCK56" "spc-right-survivor" "span-clear: right session's marker" b | ||
| 506 | |||
| 507 | set +e | ||
| 508 | # 80x24: aspect rule → beside. The wall client is a tty session: its | ||
| 509 | # stdout is the terminal's full byte stream, captured by ptyclient. | ||
| 510 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.spccap" --err "$OUT.spccap.err" -- \ | ||
| 511 | "$MUX" wall "--sock $SOCK56#a" "--sock $SOCK56#b" > "$OUT.spcpc" 2>&1 <<'EOF' | ||
| 512 | expect spc-right-survivor 20000 | ||
| 513 | settle 700 20000 | ||
| 514 | send \x1ch | ||
| 515 | settle 500 15000 | ||
| 516 | send seq 1 200\n | ||
| 517 | expect 200 20000 | ||
| 518 | settle 700 20000 | ||
| 519 | send \x1cd | ||
| 520 | waitexit 10000 | ||
| 521 | EOF | ||
| 522 | RC=$? | ||
| 523 | set -e | ||
| 524 | [ "$RC" -eq 0 ] || { | ||
| 525 | echo "e2e FAIL: span-clear: ptyclient leg exited $RC:" | ||
| 526 | cat "$OUT.spcpc" "$OUT.spccap.err"; exit 1; } | ||
| 527 | # The oracle: replay the captured stream through the render fixture at the | ||
| 528 | # same dimensions, producing the final grid text. The right pane's marker | ||
| 529 | # must still be on that grid — a span-clear regression blanks it. | ||
| 530 | "$RENDER" --cols 80 --rows 24 < "$OUT.spccap" > "$OUT.spcgrid" || { | ||
| 531 | echo "e2e FAIL: span-clear: render fixture failed on the capture:" | ||
| 532 | cat "$OUT.spcgrid"; exit 1; } | ||
| 533 | grep -q "spc-right-survivor" "$OUT.spcgrid" || { | ||
| 534 | echo "e2e FAIL: span-clear: right pane's marker lost from the replayed grid:" | ||
| 535 | echo " a span-clear regression blanked the neighbor:" | ||
| 536 | cat "$OUT.spcgrid"; exit 1; } | ||
| 537 | assert_stopped "$SOCK56" "$D57PID" "span-clear" "$OUT.spcstop" | ||
| 538 | D57PID="" | ||
| 539 | ok "a span-clear flood in the left pane cannot blank the right pane's grid" | ||
| 540 | |||
| 541 | # ---- hjkl focus walk ------------------------------------------------- | ||
| 542 | # | ||
| 543 | # One tile, split right (births a second session), split below (births a | ||
| 544 | # third). Then walk focus with hjkl and type a distinct marker after each | ||
| 545 | # move; muxa capture names which session received it. Covers at least a | ||
| 546 | # left move, a right move, and one vertical move. | ||
| 547 | # | ||
| 548 | # No convergence point: the assertion is per-session muxa capture, not a | ||
| 549 | # daemon-grid diff. | ||
| 550 | start_daemon "$SOCK57" "$OUT.hjk.d" "hjkl daemon never bound" --shell /bin/sh | ||
| 551 | D58PID=$DPID | ||
| 552 | |||
| 553 | pipe_mux "$OUT.hjkcap" "$OUT.hjkcap.err" timeout 40 "$MUX" --sock "$SOCK57" --session a | ||
| 554 | pipe_send 'printf "hjk-%%s\\n" origin\n' | ||
| 555 | await_out "$OUT.hjkcap" "hjk-origin" "hjkl: origin marker never reached the client" | ||
| 556 | pipe_detach | ||
| 557 | wait_grid "$SOCK57" "hjk-origin" "hjkl: origin marker" a | ||
| 558 | |||
| 559 | set +e | ||
| 560 | # 80x24: aspect rule → beside for the first split; the second split goes | ||
| 561 | # below, producing a 1 | (2 over 3) tree. | ||
| 562 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.hjkcap" --err "$OUT.hjkcap.err" -- \ | ||
| 563 | "$MUX" wall "--sock $SOCK57#a" > "$OUT.hjkpc" 2>&1 <<'EOF' | ||
| 564 | expect hjk-origin 20000 | ||
| 565 | settle 700 20000 | ||
| 566 | send \x1c| | ||
| 567 | settle 1000 20000 | ||
| 568 | send \x1c- | ||
| 569 | settle 1000 20000 | ||
| 570 | send \x1ch | ||
| 571 | settle 500 15000 | ||
| 572 | send printf 'hjk-%s\n' went-left\n | ||
| 573 | expect hjk-went-left 10000 | ||
| 574 | settle 500 15000 | ||
| 575 | send \x1cl | ||
| 576 | settle 500 15000 | ||
| 577 | send printf 'hjk-%s\n' went-right\n | ||
| 578 | expect hjk-went-right 10000 | ||
| 579 | settle 500 15000 | ||
| 580 | send \x1cj | ||
| 581 | settle 500 15000 | ||
| 582 | send printf 'hjk-%s\n' went-down\n | ||
| 583 | expect hjk-went-down 10000 | ||
| 584 | settle 500 15000 | ||
| 585 | send \x1cd | ||
| 586 | waitexit 10000 | ||
| 587 | EOF | ||
| 588 | RC=$? | ||
| 589 | set -e | ||
| 590 | [ "$RC" -eq 0 ] || { | ||
| 591 | echo "e2e FAIL: hjkl: ptyclient leg exited $RC:" | ||
| 592 | cat "$OUT.hjkpc" "$OUT.hjkcap.err"; exit 1; } | ||
| 593 | # Three sessions exist now: a (the origin), and two birthed by splits. | ||
| 594 | # The daemon assigns numeric names to the new sessions; capture all three. | ||
| 595 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session a > "$OUT.hjkfa" 2>&1 | ||
| 596 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session 1 > "$OUT.hjkfb" 2>&1 | ||
| 597 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session 2 > "$OUT.hjkfc" 2>&1 | ||
| 598 | # went-left: typed after \x1ch from the right pane → lands in session a. | ||
| 599 | grep -q "hjk-went-left" "$OUT.hjkfa" || { | ||
| 600 | echo "e2e FAIL: hjkl: hjk-went-left not in session a (left focus move):" | ||
| 601 | cat "$OUT.hjkfa"; exit 1; } | ||
| 602 | # went-right: typed after \x1cl from session a → lands in session 1. | ||
| 603 | grep -q "hjk-went-right" "$OUT.hjkfb" || { | ||
| 604 | echo "e2e FAIL: hjkl: hjk-went-right not in session 1 (right focus move):" | ||
| 605 | cat "$OUT.hjkfb"; exit 1; } | ||
| 606 | # went-down: typed after \x1cj from session 1 → lands in session 2. | ||
| 607 | grep -q "hjk-went-down" "$OUT.hjkfc" || { | ||
| 608 | echo "e2e FAIL: hjkl: hjk-went-down not in session 2 (down focus move):" | ||
| 609 | cat "$OUT.hjkfc"; exit 1; } | ||
| 610 | assert_stopped "$SOCK57" "$D58PID" "hjkl" "$OUT.hjkstop" | ||
| 611 | D58PID="" | ||
| 612 | ok "hjkl walks focus across panes in both axes" | ||
| 613 | |||
| 614 | # ---- split births a session beside ----------------------------------- | ||
| 615 | # | ||
| 616 | # From one pane, Ctrl-\ | splits right and births a new session. The | ||
| 617 | # daemon's session count rises to two, and the new pane's label bar sits | ||
| 618 | # at a column to the right of the rail (the rail paints as ESC[r;cH | ||
| 619 | # ESC[7m — the same idiom the resize leg uses). | ||
| 620 | start_daemon "$SOCK58" "$OUT.spl.d" "split-birth daemon never bound" --shell /bin/sh | ||
| 621 | D59PID=$DPID | ||
| 622 | |||
| 623 | pipe_mux "$OUT.splcap" "$OUT.splcap.err" timeout 40 "$MUX" --sock "$SOCK58" --session a | ||
| 624 | pipe_send 'printf "spl-%%s\\n" origin\n' | ||
| 625 | await_out "$OUT.splcap" "spl-origin" "split-birth: origin marker never reached the client" | ||
| 626 | pipe_detach | ||
| 627 | wait_grid "$SOCK58" "spl-origin" "split-birth: origin marker" a | ||
| 628 | |||
| 629 | set +e | ||
| 630 | # 80x24: aspect rule → beside. One tile starts, then Ctrl-\ | births a | ||
| 631 | # second session to the right. | ||
| 632 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.splcap" --err "$OUT.splcap.err" -- \ | ||
| 633 | "$MUX" wall "--sock $SOCK58#a" > "$OUT.splpc" 2>&1 <<'EOF' | ||
| 634 | expect spl-origin 20000 | ||
| 635 | settle 700 20000 | ||
| 636 | send \x1c| | ||
| 637 | settle 1500 20000 | ||
| 638 | send \x1cd | ||
| 639 | waitexit 10000 | ||
| 640 | EOF | ||
| 641 | RC=$? | ||
| 642 | set -e | ||
| 643 | [ "$RC" -eq 0 ] || { | ||
| 644 | echo "e2e FAIL: split-birth: ptyclient leg exited $RC:" | ||
| 645 | cat "$OUT.splpc" "$OUT.splcap.err"; exit 1; } | ||
| 646 | # The daemon held sessions 0 (default) and a; the split birthed one | ||
| 647 | # more, so three live sessions proves the chord created a new session. | ||
| 648 | wait_sessions "$SOCK58" 3 "split-birth: split did not birth a new session" | ||
| 649 | # The rail paints as ESC[row;colH ESC[7m (reverse video). The new pane's | ||
| 650 | # label bar is at a column greater than the rail's column — it sits to | ||
| 651 | # the right of the separator, which is the whole point of beside. | ||
| 652 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 653 | _rail_col=$(grep -ao "$_rail_re" "$OUT.splcap" | \ | ||
| 654 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | head -1) | ||
| 655 | [ -n "$_rail_col" ] || { | ||
| 656 | echo "e2e FAIL: split-birth: no rail glyph found in the capture:" | ||
| 657 | cat "$OUT.splcap"; exit 1; } | ||
| 658 | # The rail sits at the boundary; the new pane's label bar is beyond it. | ||
| 659 | # A label bar is a CUP followed by text, not reverse-video. Find a CUP | ||
| 660 | # whose column exceeds the rail's — that is the right pane's bar start. | ||
| 661 | _bar_col=$(grep -ao $'\x1b\\[[0-9]*;[0-9]*H' "$OUT.splcap" | \ | ||
| 662 | sed 's/\x1b\[[0-9]*;\([0-9]*\)H/\1/' | sort -n | \ | ||
| 663 | awk -v r="$_rail_col" '$1 > r { print; exit }') | ||
| 664 | [ -n "$_bar_col" ] || { | ||
| 665 | echo "e2e FAIL: split-birth: no label bar found right of the rail (col $_rail_col):" | ||
| 666 | cat "$OUT.splcap"; exit 1; } | ||
| 667 | assert_stopped "$SOCK58" "$D59PID" "split-birth" "$OUT.splstop" | ||
| 668 | D59PID="" | ||
| 669 | ok "Ctrl-\\ | births a session beside; its label bar sits right of the rail" | ||
| 670 | |||
| 671 | # ---- layout sidecar: restore round-trip ------------------------------ | ||
| 672 | # | ||
| 673 | # A no-argv `mux wall` hydrates from the wall file, and on Ctrl-\ d the | ||
| 674 | # resized tree is saved to the layout sidecar. Reattaching with the same | ||
| 675 | # state home loads the sidecar verbatim — the resize survived the round | ||
| 676 | # trip. The rail's CUP column in the second capture is >= 43 (three | ||
| 677 | # right-resizes from a 40|40 split), and a marker typed into the restored | ||
| 678 | # wall lands in the focused session. | ||
| 679 | LPSTATE="${TMPDIR:-/tmp}/mux-e2e-lprrestore-state-$$" | ||
| 680 | defer_rm "$LPSTATE" | ||
| 681 | LPWALL="$LPSTATE/mux/wall" | ||
| 682 | LPLAYOUT="$LPSTATE/mux/layout" | ||
| 683 | start_daemon "$SOCK59" "$OUT.lpr.d" "layout-restore daemon never bound" --shell /bin/sh | ||
| 684 | D60PID=$DPID | ||
| 685 | |||
| 686 | pipe_mux "$OUT.lpra" "$OUT.lpra.err" timeout 40 "$MUX" --sock "$SOCK59" --session a | ||
| 687 | pipe_send 'printf "lpr-%%s\\n" origin-a\n' | ||
| 688 | await_out "$OUT.lpra" "lpr-origin-a" "layout-restore: session a marker never reached the client" | ||
| 689 | pipe_detach | ||
| 690 | wait_grid "$SOCK59" "lpr-origin-a" "layout-restore: session a marker" a | ||
| 691 | pipe_mux "$OUT.lprb" "$OUT.lprb.err" timeout 40 "$MUX" --sock "$SOCK59" --session b | ||
| 692 | pipe_send 'printf "lpr-%%s\\n" origin-b\n' | ||
| 693 | await_out "$OUT.lprb" "lpr-origin-b" "layout-restore: session b marker never reached the client" | ||
| 694 | pipe_detach | ||
| 695 | wait_grid "$SOCK59" "lpr-origin-b" "layout-restore: session b marker" b | ||
| 696 | |||
| 697 | # Write the wall file by hand: two spellings, one per line. The no-argv | ||
| 698 | # `mux wall` reads this on startup — the hydration path. | ||
| 699 | mkdir -p "$LPSTATE/mux" | ||
| 700 | printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK59" "$SOCK59" > "$LPWALL" | ||
| 701 | |||
| 702 | set +e | ||
| 703 | # First ptyclient: hydrate, resize three right, detach. The detach saves | ||
| 704 | # the sidecar. | ||
| 705 | XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 706 | --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \ | ||
| 707 | "$MUX" wall > "$OUT.lprpc1" 2>&1 <<'EOF' | ||
| 708 | expect lpr-origin-a 20000 | ||
| 709 | settle 700 20000 | ||
| 710 | send \x1cr | ||
| 711 | settle 300 5000 | ||
| 712 | send l | ||
| 713 | settle 300 5000 | ||
| 714 | send l | ||
| 715 | settle 300 5000 | ||
| 716 | send l | ||
| 717 | settle 500 5000 | ||
| 718 | send \x1b | ||
| 719 | settle 500 5000 | ||
| 720 | send \x1cl | ||
| 721 | settle 500 5000 | ||
| 722 | send \x1cd | ||
| 723 | waitexit 10000 | ||
| 724 | EOF | ||
| 725 | RC=$? | ||
| 726 | set -e | ||
| 727 | [ "$RC" -eq 0 ] || { | ||
| 728 | echo "e2e FAIL: layout-restore: first ptyclient exited $RC:" | ||
| 729 | cat "$OUT.lprpc1" "$OUT.lprcap1.err"; exit 1; } | ||
| 730 | |||
| 731 | # The sidecar exists and its header is the format the spec mandates. | ||
| 732 | [ -f "$LPLAYOUT" ] || { | ||
| 733 | echo "e2e FAIL: layout-restore: sidecar not saved at $LPLAYOUT" | ||
| 734 | ls -la "$LPSTATE/mux/" 2>&1; exit 1; } | ||
| 735 | _head1=$(head -1 "$LPLAYOUT") | ||
| 736 | _head2=$(sed -n '2p' "$LPLAYOUT") | ||
| 737 | [ "$_head1" = "mux-layout 1" ] || { | ||
| 738 | echo "e2e FAIL: layout-restore: sidecar header is '$_head1', want 'mux-layout 1'" | ||
| 739 | cat "$LPLAYOUT"; exit 1; } | ||
| 740 | # A 2-pane beside at 80 cols: the root is `beside 0`. | ||
| 741 | case "$_head2" in | ||
| 742 | "beside 0") ;; | ||
| 743 | *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'" | ||
| 744 | cat "$LPLAYOUT"; exit 1 ;; | ||
| 745 | esac | ||
| 746 | # The focus record: focus-right moved focus to pane b (leaf 1). | ||
| 747 | grep -q '^focus 1$' "$LPLAYOUT" || { | ||
| 748 | echo "e2e FAIL: layout-restore: sidecar carries no focus record" | ||
| 749 | cat "$LPLAYOUT"; exit 1; } | ||
| 750 | |||
| 751 | set +e | ||
| 752 | # Second ptyclient: reattach. The sidecar restores the resized tree — | ||
| 753 | # the rail sits at column >= 43. Type a marker, then detach. | ||
| 754 | XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 755 | --out "$OUT.lprcap2" --err "$OUT.lprcap2.err" -- \ | ||
| 756 | "$MUX" wall > "$OUT.lprpc2" 2>&1 <<'EOF' | ||
| 757 | expect lpr-origin-a 20000 | ||
| 758 | settle 1000 20000 | ||
| 759 | send printf 'lpr-rt-%s\n' marker\n | ||
| 760 | expect lpr-rt-marker 10000 | ||
| 761 | settle 500 15000 | ||
| 762 | send \x1cd | ||
| 763 | waitexit 10000 | ||
| 764 | EOF | ||
| 765 | RC=$? | ||
| 766 | set -e | ||
| 767 | [ "$RC" -eq 0 ] || { | ||
| 768 | echo "e2e FAIL: layout-restore: second ptyclient exited $RC:" | ||
| 769 | cat "$OUT.lprpc2" "$OUT.lprcap2.err"; exit 1; } | ||
| 770 | # The rail's CUP column in the second capture must be >= 43 — three | ||
| 771 | # right-resizes moved the boundary from 41 to 44. The _rail_re idiom | ||
| 772 | # (from the split-birth leg) matches ESC[row;colH ESC[7m. | ||
| 773 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 774 | _max_rail=$(grep -ao "$_rail_re" "$OUT.lprcap2" | \ | ||
| 775 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | tail -1) | ||
| 776 | [ -n "$_max_rail" ] || { | ||
| 777 | echo "e2e FAIL: layout-restore: no rail glyph in the second capture:" | ||
| 778 | cat "$OUT.lprcap2"; exit 1; } | ||
| 779 | [ "$_max_rail" -ge 43 ] || { | ||
| 780 | echo "e2e FAIL: layout-restore: rail column $_max_rail < 43 (resize did not survive)" | ||
| 781 | cat "$OUT.lprcap2"; exit 1; } | ||
| 782 | # The marker typed into the restored wall landed in session b — the | ||
| 783 | # focus was saved on pane b and restore put it back there, not tile 0. | ||
| 784 | timeout 20 "$MUXA" capture --sock "$SOCK59" --session b > "$OUT.lprfb" 2>&1 | ||
| 785 | grep -q "lpr-rt-marker" "$OUT.lprfb" || { | ||
| 786 | echo "e2e FAIL: layout-restore: marker not in session b (focus did not survive):" | ||
| 787 | cat "$OUT.lprfb"; exit 1; } | ||
| 788 | timeout 20 "$MUXA" capture --sock "$SOCK59" --session a > "$OUT.lprfa" 2>&1 | ||
| 789 | ! grep -q "lpr-rt-marker" "$OUT.lprfa" || { | ||
| 790 | echo "e2e FAIL: layout-restore: marker leaked into session a (focus was tile 0, not b):" | ||
| 791 | cat "$OUT.lprfa"; exit 1; } | ||
| 792 | assert_stopped "$SOCK59" "$D60PID" "layout-restore" "$OUT.lprstop" | ||
| 793 | D60PID="" | ||
| 794 | rm -rf "$LPSTATE" | ||
| 795 | ok "a resized layout survives a detach/reattach round trip via the sidecar" | ||
| 796 | |||
| 797 | # ---- layout sidecar: healing on wall-file drift ---------------------- | ||
| 798 | # | ||
| 799 | # After a 2-pane sidecar is saved, the wall file drifts: one original is | ||
| 800 | # removed and a new spelling is added. On reattach, restoreLayout heals — | ||
| 801 | # the survivor leaf keeps its session, the new tile inserts beside it, | ||
| 802 | # and the unmatched saved leaf collapses out. Two tiles, not three, and | ||
| 803 | # the wall did not refuse. | ||
| 804 | LPHSTATE="${TMPDIR:-/tmp}/mux-e2e-lpheal-state-$$" | ||
| 805 | defer_rm "$LPHSTATE" | ||
| 806 | LPHWALL="$LPHSTATE/mux/wall" | ||
| 807 | LPHLAYOUT="$LPHSTATE/mux/layout" | ||
| 808 | start_daemon "$SOCK60" "$OUT.lph.d" "layout-heal daemon never bound" --shell /bin/sh | ||
| 809 | D61PID=$DPID | ||
| 810 | |||
| 811 | pipe_mux "$OUT.lpha" "$OUT.lpha.err" timeout 40 "$MUX" --sock "$SOCK60" --session a | ||
| 812 | pipe_send 'printf "lph-%%s\\n" survivor-a\n' | ||
| 813 | await_out "$OUT.lpha" "lph-survivor-a" "layout-heal: session a marker never reached the client" | ||
| 814 | pipe_detach | ||
| 815 | wait_grid "$SOCK60" "lph-survivor-a" "layout-heal: session a marker" a | ||
| 816 | pipe_mux "$OUT.lphb" "$OUT.lphb.err" timeout 40 "$MUX" --sock "$SOCK60" --session b | ||
| 817 | pipe_send 'printf "lph-%%s\\n" removed-b\n' | ||
| 818 | await_out "$OUT.lphb" "lph-removed-b" "layout-heal: session b marker never reached the client" | ||
| 819 | pipe_detach | ||
| 820 | wait_grid "$SOCK60" "lph-removed-b" "layout-heal: session b marker" b | ||
| 821 | pipe_mux "$OUT.lphc" "$OUT.lphc.err" timeout 40 "$MUX" --sock "$SOCK60" --session c | ||
| 822 | pipe_send 'printf "lph-%%s\\n" newcomer-c\n' | ||
| 823 | await_out "$OUT.lphc" "lph-newcomer-c" "layout-heal: session c marker never reached the client" | ||
| 824 | pipe_detach | ||
| 825 | wait_grid "$SOCK60" "lph-newcomer-c" "layout-heal: session c marker" c | ||
| 826 | |||
| 827 | # Wall file starts with a and b. The first ptyclient detaches and saves | ||
| 828 | # a 2-pane sidecar for [a, b]. | ||
| 829 | mkdir -p "$LPHSTATE/mux" | ||
| 830 | printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK60" "$SOCK60" > "$LPHWALL" | ||
| 831 | |||
| 832 | set +e | ||
| 833 | XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 834 | --out "$OUT.lphcap1" --err "$OUT.lphcap1.err" -- \ | ||
| 835 | "$MUX" wall > "$OUT.lphpc1" 2>&1 <<'EOF' | ||
| 836 | expect lph-survivor-a 20000 | ||
| 837 | settle 700 20000 | ||
| 838 | send \x1cd | ||
| 839 | waitexit 10000 | ||
| 840 | EOF | ||
| 841 | RC=$? | ||
| 842 | set -e | ||
| 843 | [ "$RC" -eq 0 ] || { | ||
| 844 | echo "e2e FAIL: layout-heal: first ptyclient exited $RC:" | ||
| 845 | cat "$OUT.lphpc1" "$OUT.lphcap1.err"; exit 1; } | ||
| 846 | [ -f "$LPHLAYOUT" ] || { | ||
| 847 | echo "e2e FAIL: layout-heal: sidecar not saved after first detach" | ||
| 848 | ls -la "$LPHSTATE/mux/" 2>&1; exit 1; } | ||
| 849 | |||
| 850 | # Drift: remove session a's line, add session c's line. The sidecar still | ||
| 851 | # describes [a, b]; the wall file now says [b, c]. On reattach, b is the | ||
| 852 | # survivor (matched), c inserts beside it, a collapses out (unmatched | ||
| 853 | # saved leaf). | ||
| 854 | printf -- '--sock %s#b\n--sock %s#c\n' "$SOCK60" "$SOCK60" > "$LPHWALL" | ||
| 855 | |||
| 856 | set +e | ||
| 857 | XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 858 | --out "$OUT.lphcap2" --err "$OUT.lphcap2.err" -- \ | ||
| 859 | "$MUX" wall > "$OUT.lphpc2" 2>&1 <<'EOF' | ||
| 860 | expect lph-removed-b 20000 | ||
| 861 | settle 700 20000 | ||
| 862 | send printf 'lph-heal-%s\n' marker\n | ||
| 863 | expect lph-heal-marker 10000 | ||
| 864 | settle 500 15000 | ||
| 865 | send \x1cd | ||
| 866 | waitexit 10000 | ||
| 867 | EOF | ||
| 868 | RC=$? | ||
| 869 | set -e | ||
| 870 | [ "$RC" -eq 0 ] || { | ||
| 871 | echo "e2e FAIL: layout-heal: second ptyclient exited $RC:" | ||
| 872 | cat "$OUT.lphpc2" "$OUT.lphcap2.err"; exit 1; } | ||
| 873 | # The wall must not have refused: the ptyclient exited 0 (checked above), | ||
| 874 | # and stderr mentions no layout error. The survivor (b) still reaches its | ||
| 875 | # session — the marker typed after hydration landed in the focused tile, | ||
| 876 | # which is tile 0 (session b, the first wall-file line). | ||
| 877 | grep -qi "layout" "$OUT.lphcap2.err" && { | ||
| 878 | echo "e2e FAIL: layout-heal: stderr mentions layout (heal was not silent):" | ||
| 879 | cat "$OUT.lphcap2.err"; exit 1; } | ||
| 880 | # Two tiles present: the survivor (b) and the newcomer (c). The daemon | ||
| 881 | # has sessions a, b, and c; the wall shows b and c. muxa capture of b | ||
| 882 | # finds the survivor's marker. | ||
| 883 | timeout 20 "$MUXA" capture --sock "$SOCK60" --session b > "$OUT.lphfa" 2>&1 | ||
| 884 | grep -q "lph-heal-marker" "$OUT.lphfa" || { | ||
| 885 | echo "e2e FAIL: layout-heal: survivor's marker not in session b:" | ||
| 886 | cat "$OUT.lphfa"; exit 1; } | ||
| 887 | assert_stopped "$SOCK60" "$D61PID" "layout-heal" "$OUT.lphstop" | ||
| 888 | D61PID="" | ||
| 889 | rm -rf "$LPHSTATE" | ||
| 890 | ok "the layout heals on wall-file drift: survivor keeps its session, newcomer joins" | ||
| 891 | |||
| 892 | # ---- layout sidecar: silent degrade on garbage ----------------------- | ||
| 893 | # | ||
| 894 | # A corrupted sidecar (`bogus 9`) is not a fatal error: restoreLayout | ||
| 895 | # returns null, the default tree is built, and the wall comes up. The | ||
| 896 | # assertion is that a marker typed into the wall reaches the focused | ||
| 897 | # session (the wall works), and the client's stderr never mentions | ||
| 898 | # "layout" (the degrade is silent). | ||
| 899 | LPDSTATE="${TMPDIR:-/tmp}/mux-e2e-lpdegrade-state-$$" | ||
| 900 | defer_rm "$LPDSTATE" | ||
| 901 | LPDWALL="$LPDSTATE/mux/wall" | ||
| 902 | LPDLAYOUT="$LPDSTATE/mux/layout" | ||
| 903 | start_daemon "$SOCK61" "$OUT.lpd.d" "layout-degrade daemon never bound" --shell /bin/sh | ||
| 904 | D62PID=$DPID | ||
| 905 | |||
| 906 | pipe_mux "$OUT.lpda" "$OUT.lpda.err" timeout 40 "$MUX" --sock "$SOCK61" --session a | ||
| 907 | pipe_send 'printf "lpd-%%s\\n" origin\n' | ||
| 908 | await_out "$OUT.lpda" "lpd-origin" "layout-degrade: session a marker never reached the client" | ||
| 909 | pipe_detach | ||
| 910 | wait_grid "$SOCK61" "lpd-origin" "layout-degrade: session a marker" a | ||
| 911 | |||
| 912 | mkdir -p "$LPDSTATE/mux" | ||
| 913 | printf -- '--sock %s#a\n' "$SOCK61" > "$LPDWALL" | ||
| 914 | # A garbage sidecar: wrong magic, wrong version. restoreLayout returns | ||
| 915 | # null on the header check and the caller builds the default tree. | ||
| 916 | printf 'bogus 9\n' > "$LPDLAYOUT" | ||
| 917 | |||
| 918 | set +e | ||
| 919 | XDG_STATE_HOME="$LPDSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 920 | --out "$OUT.lpdcap" --err "$OUT.lpdcap.err" -- \ | ||
| 921 | "$MUX" wall > "$OUT.lpdpc" 2>&1 <<'EOF' | ||
| 922 | expect lpd-origin 20000 | ||
| 923 | settle 700 20000 | ||
| 924 | send printf 'lpd-degrade-%s\n' marker\n | ||
| 925 | expect lpd-degrade-marker 10000 | ||
| 926 | settle 500 15000 | ||
| 927 | send \x1cd | ||
| 928 | waitexit 10000 | ||
| 929 | EOF | ||
| 930 | RC=$? | ||
| 931 | set -e | ||
| 932 | [ "$RC" -eq 0 ] || { | ||
| 933 | echo "e2e FAIL: layout-degrade: ptyclient exited $RC:" | ||
| 934 | cat "$OUT.lpdpc" "$OUT.lpdcap.err"; exit 1; } | ||
| 935 | # The wall came up: the marker reached the focused session. | ||
| 936 | timeout 20 "$MUXA" capture --sock "$SOCK61" --session a > "$OUT.lpdfa" 2>&1 | ||
| 937 | grep -q "lpd-degrade-marker" "$OUT.lpdfa" || { | ||
| 938 | echo "e2e FAIL: layout-degrade: marker not in session a (wall did not come up):" | ||
| 939 | cat "$OUT.lpdfa"; exit 1; } | ||
| 940 | # The degrade was silent: no mention of "layout" on the client's stderr. | ||
| 941 | grep -qi "layout" "$OUT.lpdcap.err" && { | ||
| 942 | echo "e2e FAIL: layout-degrade: stderr mentions layout (degrade was not silent):" | ||
| 943 | cat "$OUT.lpdcap.err"; exit 1; } | ||
| 944 | assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop" | ||
| 945 | D62PID="" | ||
| 946 | rm -rf "$LPDSTATE" | ||
| 947 | ok "a corrupted sidecar degrades silently to the default layout" | ||
| 948 | |||
| 949 | # The hydrated-create leg. One daemon, and it serves QUIC as well as its | 148 | # The hydrated-create leg. One daemon, and it serves QUIC as well as its |
| 950 | # socket: the leg's whole point is the LOCAL/remote split, so both halves | 149 | # socket: the leg's whole point is the LOCAL/remote split, so both halves |
| 951 | # have to be the same run of the same daemon or the comparison is between | 150 | # have to be the same run of the same daemon or the comparison is between |
test/e2e_12_panes.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,804 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # e2e_12_panes.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in | ||
| 3 | # the order they stand in; see the lib's header for what this file may | ||
| 4 | # assume and what it must register. | ||
| 5 | # The scrollback-rect leg: a focused tile's history page must own only its | ||
| 6 | # sub-rect, so its daemon gets a socket of its own for the grid-size | ||
| 7 | # reason every wall leg has. | ||
| 8 | SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock" | ||
| 9 | defer_sock "$SOCK53" | ||
| 10 | SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock" | ||
| 11 | defer_sock "$SOCK54" | ||
| 12 | SOCK55="${TMPDIR:-/tmp}/muxd-e2e-resize-$$.sock" | ||
| 13 | defer_sock "$SOCK55" | ||
| 14 | SOCK56="${TMPDIR:-/tmp}/muxd-e2e-spanclear-$$.sock" | ||
| 15 | defer_sock "$SOCK56" | ||
| 16 | SOCK57="${TMPDIR:-/tmp}/muxd-e2e-hjkl-$$.sock" | ||
| 17 | defer_sock "$SOCK57" | ||
| 18 | SOCK58="${TMPDIR:-/tmp}/muxd-e2e-splitbirth-$$.sock" | ||
| 19 | defer_sock "$SOCK58" | ||
| 20 | SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock" | ||
| 21 | defer_sock "$SOCK59" | ||
| 22 | SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock" | ||
| 23 | defer_sock "$SOCK60" | ||
| 24 | SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock" | ||
| 25 | defer_sock "$SOCK61" | ||
| 26 | SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock" | ||
| 27 | defer_sock "$SOCK62" | ||
| 28 | SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock" | ||
| 29 | defer_sock "$SOCK63" | ||
| 30 | |||
| 31 | # --- the cursor rests in the focused tile, whoever painted last ---------- | ||
| 32 | # The cursor-ownership leg: a daemon of its own, for the grid-size reason | ||
| 33 | # every wall leg has — the assertion is about where the cursor RESTS, and a | ||
| 34 | # session another block created would change the tile it rests in. | ||
| 35 | SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock" | ||
| 36 | defer_sock "$SOCK52" | ||
| 37 | # | ||
| 38 | # Every painter ends by showing the cursor where its own stripe sits, so | ||
| 39 | # without an owner the visible cursor lands on whichever pump painted last: | ||
| 40 | # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live | ||
| 41 | # while the keys go elsewhere. The focused tile's paint now records its | ||
| 42 | # screen cursor in Shared, and an unfocused paint's last act is to put the | ||
| 43 | # cursor back there. This leg focuses tile 3 of a three-tile wall and | ||
| 44 | # asserts the terminal's final cursor position is in tile 3's stripe — | ||
| 45 | # deterministic, because every paint now ends at the focused tile's cursor. | ||
| 46 | # | ||
| 47 | # Three sessions on a daemon of its own, each marked by its shell before the | ||
| 48 | # wall attaches (the shell-expanded marker trick: a hit is the shell's work, | ||
| 49 | # never an echo of anything typed here). The ptyclient wall focuses tile 3, | ||
| 50 | # settles, and detaches; the capture's last cursor-position escape is the | ||
| 51 | # witness. | ||
| 52 | start_daemon "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound" --shell /bin/sh | ||
| 53 | D52PID=$DPID | ||
| 54 | |||
| 55 | pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a | ||
| 56 | pipe_send 'printf "ma-%%s\\n" pin\n' | ||
| 57 | await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client" | ||
| 58 | pipe_detach | ||
| 59 | wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a | ||
| 60 | pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b | ||
| 61 | pipe_send 'printf "mb-%%s\\n" pin\n' | ||
| 62 | await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client" | ||
| 63 | pipe_detach | ||
| 64 | wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b | ||
| 65 | pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c | ||
| 66 | pipe_send 'printf "mc-%%s\\n" pin\n' | ||
| 67 | await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client" | ||
| 68 | pipe_detach | ||
| 69 | wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c | ||
| 70 | |||
| 71 | set +e | ||
| 72 | timeout 40 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \ | ||
| 73 | "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF' | ||
| 74 | expect mc-pin 20000 | ||
| 75 | settle 500 15000 | ||
| 76 | send \x1c3 | ||
| 77 | settle 500 15000 | ||
| 78 | send \x1cd | ||
| 79 | waitexit 10000 | ||
| 80 | EOF | ||
| 81 | RC=$? | ||
| 82 | set -e | ||
| 83 | [ "$RC" -eq 0 ] || { | ||
| 84 | echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:" | ||
| 85 | cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; } | ||
| 86 | # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21). | ||
| 87 | # The focused tile's paint records its cursor; an unfocused paint's last act | ||
| 88 | # is a bare CUP putting it back there, with no show of its own. So the | ||
| 89 | # terminal's final cursor position — the last CUP in the capture, show or | ||
| 90 | # none — rests in tile 3's stripe whichever pump painted last. The last | ||
| 91 | # CUP+show would be the last painter's OWN cursor (racy); the last CUP of | ||
| 92 | # any kind is the focused tile's, and that is what the fix owns. | ||
| 93 | _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H' | ||
| 94 | _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1) | ||
| 95 | # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix. | ||
| 96 | _cur_row=${_last_cup#??} | ||
| 97 | _cur_row=${_cur_row%%;*} | ||
| 98 | [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || { | ||
| 99 | echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):" | ||
| 100 | cat "$OUT.cucap"; exit 1; } | ||
| 101 | assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop" | ||
| 102 | D52PID="" | ||
| 103 | ok "the cursor rests in the focused tile, whichever pump painted last" | ||
| 104 | |||
| 105 | # ---- a focused tile's scrollback page owns only its sub-rect ---------- | ||
| 106 | # | ||
| 107 | # The one-tile wheel leg above has no label bar, so its geometry is the | ||
| 108 | # plain client's: the one paint at row 0 that DOES own the whole screen. | ||
| 109 | # That exemption is what let a scrollback page at a row_off through that | ||
| 110 | # opened with `\x1b[H\x1b[2J` — clearing the whole terminal and painting | ||
| 111 | # history at row 1, while the exit path repaints only the focused tile's | ||
| 112 | # own rect, so every neighbour stayed blank. This leg is the twin that | ||
| 113 | # catches it: two tiles over 30 rows, the focused one scrolls back on the | ||
| 114 | # wheel, and the neighbour's pin must still be the last content painted in | ||
| 115 | # its rows, with no whole-screen clear following it. | ||
| 116 | # | ||
| 117 | # Two sessions on one daemon. The focused tile (a, the first spelling) | ||
| 118 | # fills a scrollback the wheel can page into; the neighbour (b) holds a | ||
| 119 | # pin and is never touched again. Both shells are /bin/sh and the content | ||
| 120 | # is typed, the focus-skip leg's pattern — the neighbour's pin is the | ||
| 121 | # session's own output, not an echo of the command that made it. | ||
| 122 | start_daemon "$SOCK53" "$OUT.sb2.d" "scrollback-rect daemon never bound" --shell /bin/sh | ||
| 123 | D54PID=$DPID | ||
| 124 | |||
| 125 | # Session a: 100 lines of history, then cat. The wall attaches only once | ||
| 126 | # seq has finished, so the page the wheel fetches is content this tile was | ||
| 127 | # never sent live — the wheel leg's reason, verbatim. | ||
| 128 | pipe_mux "$OUT.sb2a" "$OUT.sb2a.err" timeout 40 "$MUX" --sock "$SOCK53" --session a | ||
| 129 | pipe_send 'seq 1 100 | sed '\''s/^/wln/'\''; exec cat\n' | ||
| 130 | await_out "$OUT.sb2a" "wln100" "scrollback-rect: session a never finished seq" | ||
| 131 | pipe_detach | ||
| 132 | wait_grid "$SOCK53" "wln100" "scrollback-rect: session a's tail" a | ||
| 133 | |||
| 134 | # Session b: a pin the capture can grep for, then cat. `printf "sb2%s\n" | ||
| 135 | # nbr` so the pin is the output, not the echoed command — the focus-skip | ||
| 136 | # leg's prefix trick. | ||
| 137 | pipe_mux "$OUT.sb2b" "$OUT.sb2b.err" timeout 40 "$MUX" --sock "$SOCK53" --session b | ||
| 138 | pipe_send 'printf "sb2%%s\\n" nbr; exec cat\n' | ||
| 139 | await_out "$OUT.sb2b" "sb2nbr" "scrollback-rect: session b's pin never reached the client" | ||
| 140 | pipe_detach | ||
| 141 | wait_grid "$SOCK53" "sb2nbr" "scrollback-rect: session b's pin" b | ||
| 142 | |||
| 143 | set +e | ||
| 144 | timeout 90 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.sb2cap" --err "$OUT.sb2cap.err" -- \ | ||
| 145 | "$MUX" wall "--sock $SOCK53#a" "--sock $SOCK53#b" > "$OUT.sb2pc" 2>&1 <<'EOF' | ||
| 146 | expect sb2nbr 20000 | ||
| 147 | settle 700 20000 | ||
| 148 | send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M | ||
| 149 | expect \x1b[7m[scroll] 15000 | ||
| 150 | settle 500 15000 | ||
| 151 | send \x1cd | ||
| 152 | waitexit 10000 | ||
| 153 | EOF | ||
| 154 | RC=$? | ||
| 155 | set -e | ||
| 156 | [ "$RC" -eq 0 ] || { | ||
| 157 | echo "e2e FAIL: scrollback-rect: ptyclient leg exited $RC (did the focused wheel scroll?):" | ||
| 158 | cat "$OUT.sb2pc"; exit 1; } | ||
| 159 | # The neighbour's pin is still the last content painted in its rows: the | ||
| 160 | # capture holds it, and nothing after it cleared the whole screen. A | ||
| 161 | # scrollback page that owned the whole terminal (the bug) would open with | ||
| 162 | # `\x1b[H\x1b[2J` right here, wiping the neighbour's rows; the fix clears | ||
| 163 | # per row inside the focused tile's sub-rect only. | ||
| 164 | grep -qaF "sb2nbr" "$OUT.sb2cap" || { | ||
| 165 | echo "e2e FAIL: scrollback-rect: the neighbour's pin left the capture:" | ||
| 166 | cat "$OUT.sb2pc"; exit 1; } | ||
| 167 | _sb2last=$(grep -aboF -- "sb2nbr" "$OUT.sb2cap" | tail -1 | cut -d: -f1) | ||
| 168 | tail -c +$((_sb2last + 1)) "$OUT.sb2cap" | grep -aF -- "$(printf '\x1b[2J')" && { | ||
| 169 | echo "e2e FAIL: scrollback-rect: a whole-screen clear followed the neighbour's paint:" | ||
| 170 | cat "$OUT.sb2pc"; exit 1; } | ||
| 171 | assert_stopped "$SOCK53" "$D54PID" "scrollback-rect" "$OUT.sb2stop" | ||
| 172 | D54PID="" | ||
| 173 | ok "a focused tile's scrollback page owns only its sub-rect, and the neighbour's pin stays" | ||
| 174 | |||
| 175 | # ---- fullscreen: f toggles, focus follows, restore ------------------- | ||
| 176 | # | ||
| 177 | # `f` is a layout op, not a mode: the focused tile takes the whole | ||
| 178 | # terminal, every other gets 0x0. A 0x0 rect claims nothing, so hidden | ||
| 179 | # panes stay attached and paint nothing. Focus moves while fullscreened | ||
| 180 | # re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores | ||
| 181 | # the real layout. This leg proves all three with `muxa capture` — the | ||
| 182 | # marker each session received names which tile owned the terminal. | ||
| 183 | # | ||
| 184 | # No convergence point: `muxa capture` reads the daemon's grid, not the | ||
| 185 | # terminal's, and the daemon's grid is the side channel this asserts on. | ||
| 186 | start_daemon "$SOCK54" "$OUT.fs.d" "fullscreen daemon never bound" --shell /bin/sh | ||
| 187 | D55PID=$DPID | ||
| 188 | |||
| 189 | pipe_mux "$OUT.fsa" "$OUT.fsa.err" timeout 40 "$MUX" --sock "$SOCK54" --session a | ||
| 190 | pipe_send 'printf "fs-%%s\\n" init-a\n' | ||
| 191 | await_out "$OUT.fsa" "fs-init-a" "fullscreen: session a's marker never reached the client" | ||
| 192 | pipe_detach | ||
| 193 | wait_grid "$SOCK54" "fs-init-a" "fullscreen: session a's marker" a | ||
| 194 | pipe_mux "$OUT.fsb" "$OUT.fsb.err" timeout 40 "$MUX" --sock "$SOCK54" --session b | ||
| 195 | pipe_send 'printf "fs-%%s\\n" init-b\n' | ||
| 196 | await_out "$OUT.fsb" "fs-init-b" "fullscreen: session b's marker never reached the client" | ||
| 197 | pipe_detach | ||
| 198 | wait_grid "$SOCK54" "fs-init-b" "fullscreen: session b's marker" b | ||
| 199 | |||
| 200 | set +e | ||
| 201 | # wide: 80x24 trips the aspect rule (80 >= 48 → beside) | ||
| 202 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.fscap" --err "$OUT.fscap.err" -- \ | ||
| 203 | "$MUX" wall "--sock $SOCK54#a" "--sock $SOCK54#b" > "$OUT.fspc" 2>&1 <<'EOF' | ||
| 204 | expect fs-init-b 20000 | ||
| 205 | settle 700 20000 | ||
| 206 | send \x1cf | ||
| 207 | settle 700 20000 | ||
| 208 | send printf 'fs-%s\n' one\n | ||
| 209 | expect fs-one 10000 | ||
| 210 | settle 400 15000 | ||
| 211 | send \x1c2 | ||
| 212 | settle 700 20000 | ||
| 213 | send printf 'fs-%s\n' two\n | ||
| 214 | expect fs-two 10000 | ||
| 215 | settle 400 15000 | ||
| 216 | send \x1cf | ||
| 217 | settle 700 20000 | ||
| 218 | send \x1cd | ||
| 219 | waitexit 10000 | ||
| 220 | EOF | ||
| 221 | RC=$? | ||
| 222 | set -e | ||
| 223 | [ "$RC" -eq 0 ] || { | ||
| 224 | echo "e2e FAIL: fullscreen: ptyclient leg exited $RC:" | ||
| 225 | cat "$OUT.fspc" "$OUT.fscap.err"; exit 1; } | ||
| 226 | # fs-one landed in session a (the tile that was fullscreened first); | ||
| 227 | # fs-two landed in session b (the tile Ctrl-\ 2 moved the full rect to). | ||
| 228 | timeout 20 "$MUXA" capture --sock "$SOCK54" --session a > "$OUT.fsfa" 2>&1 | ||
| 229 | timeout 20 "$MUXA" capture --sock "$SOCK54" --session b > "$OUT.fsfb" 2>&1 | ||
| 230 | grep -q "fs-one" "$OUT.fsfa" || { | ||
| 231 | echo "e2e FAIL: fullscreen: session a never got fs-one:" | ||
| 232 | cat "$OUT.fsfa"; exit 1; } | ||
| 233 | grep -q "fs-one" "$OUT.fsfb" && { | ||
| 234 | echo "e2e FAIL: fullscreen: fs-one reached session b, which was hidden:" | ||
| 235 | cat "$OUT.fsfb"; exit 1; } | ||
| 236 | grep -q "fs-two" "$OUT.fsfb" || { | ||
| 237 | echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not move the full rect to session b:" | ||
| 238 | cat "$OUT.fsfb"; exit 1; } | ||
| 239 | grep -q "fs-two" "$OUT.fsfa" && { | ||
| 240 | echo "e2e FAIL: fullscreen: fs-two reached session a, which was hidden after the focus move:" | ||
| 241 | cat "$OUT.fsfa"; exit 1; } | ||
| 242 | # What the human saw after Ctrl-\ 2: the `>` moved and the digits did not. | ||
| 243 | # A bar's digit is the chord that reaches that tile for its whole life, not | ||
| 244 | # a count of who holds the focus — the render oracle is the only witness, | ||
| 245 | # since the daemon's grid has no bars in it. The label is cut to the stripe | ||
| 246 | # at 80 columns beside, so the marker and the head of the spelling are what | ||
| 247 | # survive; the digit is what tells the two stripes apart. | ||
| 248 | "$RENDER" --cols 80 --rows 24 < "$OUT.fscap" > "$OUT.fsgrid" || { | ||
| 249 | echo "e2e FAIL: fullscreen: render oracle failed" | ||
| 250 | cat "$OUT.fsgrid"; exit 1; } | ||
| 251 | grep -q -- " 2> --sock" "$OUT.fsgrid" || { | ||
| 252 | echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not leave the \`>\` on tile 2's digit:" | ||
| 253 | cat "$OUT.fsgrid"; exit 1; } | ||
| 254 | grep -q -- " 1 --sock" "$OUT.fsgrid" || { | ||
| 255 | echo "e2e FAIL: fullscreen: tile 1 lost its digit or kept the \`>\`:" | ||
| 256 | cat "$OUT.fsgrid"; exit 1; } | ||
| 257 | assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop" | ||
| 258 | D55PID="" | ||
| 259 | ok "fullscreen gives the focused tile the terminal; focus follows; f restores" | ||
| 260 | |||
| 261 | # ---- Ctrl-\ r trades cells between panes ----------------------------- | ||
| 262 | # | ||
| 263 | # Two beside panes at 80x24 (aspect rule → beside). Ctrl-\ r enters | ||
| 264 | # resize mode; lll grows the focused pane's width by 3 cells, moving the | ||
| 265 | # rail 3 columns right. The rail is a reverse-video space painted at a | ||
| 266 | # CUP column; grep the column before and after. Esc exits resize mode, | ||
| 267 | # and typed prose lands in the focused session (marker echo). | ||
| 268 | # | ||
| 269 | # No convergence point: the rail column is a terminal-capture assertion, | ||
| 270 | # not a daemon-grid comparison. | ||
| 271 | start_daemon "$SOCK55" "$OUT.rsz.d" "resize daemon never bound" --shell /bin/sh | ||
| 272 | D56PID=$DPID | ||
| 273 | |||
| 274 | pipe_mux "$OUT.rsza" "$OUT.rsza.err" timeout 40 "$MUX" --sock "$SOCK55" --session a | ||
| 275 | pipe_send 'printf "rsz-%%s\\n" init-a\n' | ||
| 276 | await_out "$OUT.rsza" "rsz-init-a" "resize: session a's marker never reached the client" | ||
| 277 | pipe_detach | ||
| 278 | wait_grid "$SOCK55" "rsz-init-a" "resize: session a's marker" a | ||
| 279 | pipe_mux "$OUT.rszb" "$OUT.rszb.err" timeout 40 "$MUX" --sock "$SOCK55" --session b | ||
| 280 | pipe_send 'printf "rsz-%%s\\n" init-b\n' | ||
| 281 | await_out "$OUT.rszb" "rsz-init-b" "resize: session b's marker never reached the client" | ||
| 282 | pipe_detach | ||
| 283 | wait_grid "$SOCK55" "rsz-init-b" "resize: session b's marker" b | ||
| 284 | |||
| 285 | set +e | ||
| 286 | # 80x24: aspect rule makes beside; two panes split 39/40 with the rail | ||
| 287 | # at column 40 (1-indexed). Three l presses grow the left pane by 3, | ||
| 288 | # moving the rail to column 43. | ||
| 289 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.rszcap" --err "$OUT.rszcap.err" -- \ | ||
| 290 | "$MUX" wall "--sock $SOCK55#a" "--sock $SOCK55#b" > "$OUT.rszpc" 2>&1 <<'EOF' | ||
| 291 | expect rsz-init-b 20000 | ||
| 292 | settle 700 20000 | ||
| 293 | send \x1cr | ||
| 294 | settle 300 10000 | ||
| 295 | send l | ||
| 296 | settle 300 10000 | ||
| 297 | send l | ||
| 298 | settle 300 10000 | ||
| 299 | send l | ||
| 300 | settle 500 15000 | ||
| 301 | send \x1b | ||
| 302 | settle 300 10000 | ||
| 303 | send printf 'rsz-%s\n' grown\n | ||
| 304 | expect rsz-grown 10000 | ||
| 305 | settle 400 15000 | ||
| 306 | send \x1cd | ||
| 307 | waitexit 10000 | ||
| 308 | EOF | ||
| 309 | RC=$? | ||
| 310 | set -e | ||
| 311 | [ "$RC" -eq 0 ] || { | ||
| 312 | echo "e2e FAIL: resize: ptyclient leg exited $RC:" | ||
| 313 | cat "$OUT.rszpc" "$OUT.rszcap.err"; exit 1; } | ||
| 314 | # The rail paints as ESC[row;colH followed by ESC[7m (reverse video). | ||
| 315 | # Extract the column from every CUP that precedes a reverse-video | ||
| 316 | # space — the rail's column — and verify it moved right by 3. | ||
| 317 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 318 | # Before resize: rail at column 40 (1-indexed). After lll: column 43. | ||
| 319 | # Grab all rail CUP columns; the last one is the post-resize position. | ||
| 320 | _rail_cols=$(grep -ao "$_rail_re" "$OUT.rszcap" | \ | ||
| 321 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n) | ||
| 322 | _last_rail=$(echo "$_rail_cols" | tail -1) | ||
| 323 | [ -n "$_last_rail" ] || { | ||
| 324 | echo "e2e FAIL: resize: no rail glyph found in the capture:" | ||
| 325 | cat "$OUT.rszcap"; exit 1; } | ||
| 326 | # 39 cols + rail at 40 initially; 42 cols + rail at 43 after lll. | ||
| 327 | [ "$_last_rail" -ge 43 ] || { | ||
| 328 | echo "e2e FAIL: resize: rail column is $_last_rail, want >=43 (40 + 3 grows):" | ||
| 329 | cat "$OUT.rszcap"; exit 1; } | ||
| 330 | # Esc ended resize mode and typed prose reached the focused session. | ||
| 331 | timeout 20 "$MUXA" capture --sock "$SOCK55" --session a > "$OUT.rszsta" 2>&1 | ||
| 332 | timeout 20 "$MUXA" capture --sock "$SOCK55" --session b > "$OUT.rszstb" 2>&1 | ||
| 333 | if ! grep -q "rsz-grown" "$OUT.rszsta" && ! grep -q "rsz-grown" "$OUT.rszstb"; then | ||
| 334 | echo "e2e FAIL: resize: rsz-grown reached neither session after Esc:" | ||
| 335 | cat "$OUT.rszsta" "$OUT.rszstb"; exit 1 | ||
| 336 | fi | ||
| 337 | assert_stopped "$SOCK55" "$D56PID" "resize" "$OUT.rszstop" | ||
| 338 | D56PID="" | ||
| 339 | ok "Ctrl-\\ r trades cells between panes; Esc returns to prose" | ||
| 340 | |||
| 341 | # ---- span-clear survival: the engine oracle ------------------------- | ||
| 342 | # | ||
| 343 | # Two beside panes at 80x24 (aspect rule: cols >= 2*rows → beside). A | ||
| 344 | # marker in the RIGHT pane's session, then a flood in the LEFT pane's | ||
| 345 | # session (seq 1 200). The regression this catches forever is a whole-line | ||
| 346 | # clear (\x1b[2K) blanking the neighbor: a painter that emits too much | ||
| 347 | # still passes a byte grep, so the assertion replays the wall client's | ||
| 348 | # FULL captured stdout through the render fixture (an engine, not a grep) | ||
| 349 | # and checks the right pane's marker text survives on the replayed grid. | ||
| 350 | start_daemon "$SOCK56" "$OUT.spc.d" "span-clear daemon never bound" --shell /bin/sh | ||
| 351 | D57PID=$DPID | ||
| 352 | |||
| 353 | pipe_mux "$OUT.spca" "$OUT.spca.err" timeout 40 "$MUX" --sock "$SOCK56" --session a | ||
| 354 | pipe_send 'printf "spc-%%s\\n" left-flood\n' | ||
| 355 | await_out "$OUT.spca" "spc-left-flood" "span-clear: left session's marker never reached the client" | ||
| 356 | pipe_detach | ||
| 357 | wait_grid "$SOCK56" "spc-left-flood" "span-clear: left session's marker" a | ||
| 358 | pipe_mux "$OUT.spcb" "$OUT.spcb.err" timeout 40 "$MUX" --sock "$SOCK56" --session b | ||
| 359 | pipe_send 'printf "spc-%%s\\n" right-survivor\n' | ||
| 360 | await_out "$OUT.spcb" "spc-right-survivor" "span-clear: right session's marker never reached the client" | ||
| 361 | pipe_detach | ||
| 362 | wait_grid "$SOCK56" "spc-right-survivor" "span-clear: right session's marker" b | ||
| 363 | |||
| 364 | set +e | ||
| 365 | # 80x24: aspect rule → beside. The wall client is a tty session: its | ||
| 366 | # stdout is the terminal's full byte stream, captured by ptyclient. | ||
| 367 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.spccap" --err "$OUT.spccap.err" -- \ | ||
| 368 | "$MUX" wall "--sock $SOCK56#a" "--sock $SOCK56#b" > "$OUT.spcpc" 2>&1 <<'EOF' | ||
| 369 | expect spc-right-survivor 20000 | ||
| 370 | settle 700 20000 | ||
| 371 | send \x1ch | ||
| 372 | settle 500 15000 | ||
| 373 | send seq 1 200\n | ||
| 374 | expect 200 20000 | ||
| 375 | settle 700 20000 | ||
| 376 | send \x1cd | ||
| 377 | waitexit 10000 | ||
| 378 | EOF | ||
| 379 | RC=$? | ||
| 380 | set -e | ||
| 381 | [ "$RC" -eq 0 ] || { | ||
| 382 | echo "e2e FAIL: span-clear: ptyclient leg exited $RC:" | ||
| 383 | cat "$OUT.spcpc" "$OUT.spccap.err"; exit 1; } | ||
| 384 | # The oracle: replay the captured stream through the render fixture at the | ||
| 385 | # same dimensions, producing the final grid text. The right pane's marker | ||
| 386 | # must still be on that grid — a span-clear regression blanks it. | ||
| 387 | "$RENDER" --cols 80 --rows 24 < "$OUT.spccap" > "$OUT.spcgrid" || { | ||
| 388 | echo "e2e FAIL: span-clear: render fixture failed on the capture:" | ||
| 389 | cat "$OUT.spcgrid"; exit 1; } | ||
| 390 | grep -q "spc-right-survivor" "$OUT.spcgrid" || { | ||
| 391 | echo "e2e FAIL: span-clear: right pane's marker lost from the replayed grid:" | ||
| 392 | echo " a span-clear regression blanked the neighbor:" | ||
| 393 | cat "$OUT.spcgrid"; exit 1; } | ||
| 394 | assert_stopped "$SOCK56" "$D57PID" "span-clear" "$OUT.spcstop" | ||
| 395 | D57PID="" | ||
| 396 | ok "a span-clear flood in the left pane cannot blank the right pane's grid" | ||
| 397 | |||
| 398 | # ---- hjkl focus walk ------------------------------------------------- | ||
| 399 | # | ||
| 400 | # One tile, split right (births a second session), split below (births a | ||
| 401 | # third). Then walk focus with hjkl and type a distinct marker after each | ||
| 402 | # move; muxa capture names which session received it. Covers at least a | ||
| 403 | # left move, a right move, and one vertical move. | ||
| 404 | # | ||
| 405 | # No convergence point: the assertion is per-session muxa capture, not a | ||
| 406 | # daemon-grid diff. | ||
| 407 | start_daemon "$SOCK57" "$OUT.hjk.d" "hjkl daemon never bound" --shell /bin/sh | ||
| 408 | D58PID=$DPID | ||
| 409 | |||
| 410 | pipe_mux "$OUT.hjkcap" "$OUT.hjkcap.err" timeout 40 "$MUX" --sock "$SOCK57" --session a | ||
| 411 | pipe_send 'printf "hjk-%%s\\n" origin\n' | ||
| 412 | await_out "$OUT.hjkcap" "hjk-origin" "hjkl: origin marker never reached the client" | ||
| 413 | pipe_detach | ||
| 414 | wait_grid "$SOCK57" "hjk-origin" "hjkl: origin marker" a | ||
| 415 | |||
| 416 | set +e | ||
| 417 | # 80x24: aspect rule → beside for the first split; the second split goes | ||
| 418 | # below, producing a 1 | (2 over 3) tree. | ||
| 419 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.hjkcap" --err "$OUT.hjkcap.err" -- \ | ||
| 420 | "$MUX" wall "--sock $SOCK57#a" > "$OUT.hjkpc" 2>&1 <<'EOF' | ||
| 421 | expect hjk-origin 20000 | ||
| 422 | settle 700 20000 | ||
| 423 | send \x1c| | ||
| 424 | settle 1000 20000 | ||
| 425 | send \x1c- | ||
| 426 | settle 1000 20000 | ||
| 427 | send \x1ch | ||
| 428 | settle 500 15000 | ||
| 429 | send printf 'hjk-%s\n' went-left\n | ||
| 430 | expect hjk-went-left 10000 | ||
| 431 | settle 500 15000 | ||
| 432 | send \x1cl | ||
| 433 | settle 500 15000 | ||
| 434 | send printf 'hjk-%s\n' went-right\n | ||
| 435 | expect hjk-went-right 10000 | ||
| 436 | settle 500 15000 | ||
| 437 | send \x1cj | ||
| 438 | settle 500 15000 | ||
| 439 | send printf 'hjk-%s\n' went-down\n | ||
| 440 | expect hjk-went-down 10000 | ||
| 441 | settle 500 15000 | ||
| 442 | send \x1cd | ||
| 443 | waitexit 10000 | ||
| 444 | EOF | ||
| 445 | RC=$? | ||
| 446 | set -e | ||
| 447 | [ "$RC" -eq 0 ] || { | ||
| 448 | echo "e2e FAIL: hjkl: ptyclient leg exited $RC:" | ||
| 449 | cat "$OUT.hjkpc" "$OUT.hjkcap.err"; exit 1; } | ||
| 450 | # Three sessions exist now: a (the origin), and two birthed by splits. | ||
| 451 | # The daemon assigns numeric names to the new sessions; capture all three. | ||
| 452 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session a > "$OUT.hjkfa" 2>&1 | ||
| 453 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session 1 > "$OUT.hjkfb" 2>&1 | ||
| 454 | timeout 20 "$MUXA" capture --sock "$SOCK57" --session 2 > "$OUT.hjkfc" 2>&1 | ||
| 455 | # went-left: typed after \x1ch from the right pane → lands in session a. | ||
| 456 | grep -q "hjk-went-left" "$OUT.hjkfa" || { | ||
| 457 | echo "e2e FAIL: hjkl: hjk-went-left not in session a (left focus move):" | ||
| 458 | cat "$OUT.hjkfa"; exit 1; } | ||
| 459 | # went-right: typed after \x1cl from session a → lands in session 1. | ||
| 460 | grep -q "hjk-went-right" "$OUT.hjkfb" || { | ||
| 461 | echo "e2e FAIL: hjkl: hjk-went-right not in session 1 (right focus move):" | ||
| 462 | cat "$OUT.hjkfb"; exit 1; } | ||
| 463 | # went-down: typed after \x1cj from session 1 → lands in session 2. | ||
| 464 | grep -q "hjk-went-down" "$OUT.hjkfc" || { | ||
| 465 | echo "e2e FAIL: hjkl: hjk-went-down not in session 2 (down focus move):" | ||
| 466 | cat "$OUT.hjkfc"; exit 1; } | ||
| 467 | assert_stopped "$SOCK57" "$D58PID" "hjkl" "$OUT.hjkstop" | ||
| 468 | D58PID="" | ||
| 469 | ok "hjkl walks focus across panes in both axes" | ||
| 470 | |||
| 471 | # ---- split births a session beside ----------------------------------- | ||
| 472 | # | ||
| 473 | # From one pane, Ctrl-\ | splits right and births a new session. The | ||
| 474 | # daemon's session count rises to two, and the new pane's label bar sits | ||
| 475 | # at a column to the right of the rail (the rail paints as ESC[r;cH | ||
| 476 | # ESC[7m — the same idiom the resize leg uses). | ||
| 477 | start_daemon "$SOCK58" "$OUT.spl.d" "split-birth daemon never bound" --shell /bin/sh | ||
| 478 | D59PID=$DPID | ||
| 479 | |||
| 480 | pipe_mux "$OUT.splcap" "$OUT.splcap.err" timeout 40 "$MUX" --sock "$SOCK58" --session a | ||
| 481 | pipe_send 'printf "spl-%%s\\n" origin\n' | ||
| 482 | await_out "$OUT.splcap" "spl-origin" "split-birth: origin marker never reached the client" | ||
| 483 | pipe_detach | ||
| 484 | wait_grid "$SOCK58" "spl-origin" "split-birth: origin marker" a | ||
| 485 | |||
| 486 | set +e | ||
| 487 | # 80x24: aspect rule → beside. One tile starts, then Ctrl-\ | births a | ||
| 488 | # second session to the right. | ||
| 489 | timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.splcap" --err "$OUT.splcap.err" -- \ | ||
| 490 | "$MUX" wall "--sock $SOCK58#a" > "$OUT.splpc" 2>&1 <<'EOF' | ||
| 491 | expect spl-origin 20000 | ||
| 492 | settle 700 20000 | ||
| 493 | send \x1c| | ||
| 494 | settle 1500 20000 | ||
| 495 | send \x1cd | ||
| 496 | waitexit 10000 | ||
| 497 | EOF | ||
| 498 | RC=$? | ||
| 499 | set -e | ||
| 500 | [ "$RC" -eq 0 ] || { | ||
| 501 | echo "e2e FAIL: split-birth: ptyclient leg exited $RC:" | ||
| 502 | cat "$OUT.splpc" "$OUT.splcap.err"; exit 1; } | ||
| 503 | # The daemon held sessions 0 (default) and a; the split birthed one | ||
| 504 | # more, so three live sessions proves the chord created a new session. | ||
| 505 | wait_sessions "$SOCK58" 3 "split-birth: split did not birth a new session" | ||
| 506 | # The rail paints as ESC[row;colH ESC[7m (reverse video). The new pane's | ||
| 507 | # label bar is at a column greater than the rail's column — it sits to | ||
| 508 | # the right of the separator, which is the whole point of beside. | ||
| 509 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 510 | _rail_col=$(grep -ao "$_rail_re" "$OUT.splcap" | \ | ||
| 511 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | head -1) | ||
| 512 | [ -n "$_rail_col" ] || { | ||
| 513 | echo "e2e FAIL: split-birth: no rail glyph found in the capture:" | ||
| 514 | cat "$OUT.splcap"; exit 1; } | ||
| 515 | # The rail sits at the boundary; the new pane's label bar is beyond it. | ||
| 516 | # A label bar is a CUP followed by text, not reverse-video. Find a CUP | ||
| 517 | # whose column exceeds the rail's — that is the right pane's bar start. | ||
| 518 | _bar_col=$(grep -ao $'\x1b\\[[0-9]*;[0-9]*H' "$OUT.splcap" | \ | ||
| 519 | sed 's/\x1b\[[0-9]*;\([0-9]*\)H/\1/' | sort -n | \ | ||
| 520 | awk -v r="$_rail_col" '$1 > r { print; exit }') | ||
| 521 | [ -n "$_bar_col" ] || { | ||
| 522 | echo "e2e FAIL: split-birth: no label bar found right of the rail (col $_rail_col):" | ||
| 523 | cat "$OUT.splcap"; exit 1; } | ||
| 524 | assert_stopped "$SOCK58" "$D59PID" "split-birth" "$OUT.splstop" | ||
| 525 | D59PID="" | ||
| 526 | ok "Ctrl-\\ | births a session beside; its label bar sits right of the rail" | ||
| 527 | |||
| 528 | # ---- layout sidecar: restore round-trip ------------------------------ | ||
| 529 | # | ||
| 530 | # A no-argv `mux wall` hydrates from the wall file, and on Ctrl-\ d the | ||
| 531 | # resized tree is saved to the layout sidecar. Reattaching with the same | ||
| 532 | # state home loads the sidecar verbatim — the resize survived the round | ||
| 533 | # trip. The rail's CUP column in the second capture is >= 43 (three | ||
| 534 | # right-resizes from a 40|40 split), and a marker typed into the restored | ||
| 535 | # wall lands in the focused session. | ||
| 536 | LPSTATE="${TMPDIR:-/tmp}/mux-e2e-lprrestore-state-$$" | ||
| 537 | defer_rm "$LPSTATE" | ||
| 538 | LPWALL="$LPSTATE/mux/wall" | ||
| 539 | LPLAYOUT="$LPSTATE/mux/layout" | ||
| 540 | start_daemon "$SOCK59" "$OUT.lpr.d" "layout-restore daemon never bound" --shell /bin/sh | ||
| 541 | D60PID=$DPID | ||
| 542 | |||
| 543 | pipe_mux "$OUT.lpra" "$OUT.lpra.err" timeout 40 "$MUX" --sock "$SOCK59" --session a | ||
| 544 | pipe_send 'printf "lpr-%%s\\n" origin-a\n' | ||
| 545 | await_out "$OUT.lpra" "lpr-origin-a" "layout-restore: session a marker never reached the client" | ||
| 546 | pipe_detach | ||
| 547 | wait_grid "$SOCK59" "lpr-origin-a" "layout-restore: session a marker" a | ||
| 548 | pipe_mux "$OUT.lprb" "$OUT.lprb.err" timeout 40 "$MUX" --sock "$SOCK59" --session b | ||
| 549 | pipe_send 'printf "lpr-%%s\\n" origin-b\n' | ||
| 550 | await_out "$OUT.lprb" "lpr-origin-b" "layout-restore: session b marker never reached the client" | ||
| 551 | pipe_detach | ||
| 552 | wait_grid "$SOCK59" "lpr-origin-b" "layout-restore: session b marker" b | ||
| 553 | |||
| 554 | # Write the wall file by hand: two spellings, one per line. The no-argv | ||
| 555 | # `mux wall` reads this on startup — the hydration path. | ||
| 556 | mkdir -p "$LPSTATE/mux" | ||
| 557 | printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK59" "$SOCK59" > "$LPWALL" | ||
| 558 | |||
| 559 | set +e | ||
| 560 | # First ptyclient: hydrate, resize three right, detach. The detach saves | ||
| 561 | # the sidecar. | ||
| 562 | XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 563 | --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \ | ||
| 564 | "$MUX" wall > "$OUT.lprpc1" 2>&1 <<'EOF' | ||
| 565 | expect lpr-origin-a 20000 | ||
| 566 | settle 700 20000 | ||
| 567 | send \x1cr | ||
| 568 | settle 300 5000 | ||
| 569 | send l | ||
| 570 | settle 300 5000 | ||
| 571 | send l | ||
| 572 | settle 300 5000 | ||
| 573 | send l | ||
| 574 | settle 500 5000 | ||
| 575 | send \x1b | ||
| 576 | settle 500 5000 | ||
| 577 | send \x1cl | ||
| 578 | settle 500 5000 | ||
| 579 | send \x1cd | ||
| 580 | waitexit 10000 | ||
| 581 | EOF | ||
| 582 | RC=$? | ||
| 583 | set -e | ||
| 584 | [ "$RC" -eq 0 ] || { | ||
| 585 | echo "e2e FAIL: layout-restore: first ptyclient exited $RC:" | ||
| 586 | cat "$OUT.lprpc1" "$OUT.lprcap1.err"; exit 1; } | ||
| 587 | |||
| 588 | # The sidecar exists and its header is the format the spec mandates. | ||
| 589 | [ -f "$LPLAYOUT" ] || { | ||
| 590 | echo "e2e FAIL: layout-restore: sidecar not saved at $LPLAYOUT" | ||
| 591 | ls -la "$LPSTATE/mux/" 2>&1; exit 1; } | ||
| 592 | _head1=$(head -1 "$LPLAYOUT") | ||
| 593 | _head2=$(sed -n '2p' "$LPLAYOUT") | ||
| 594 | [ "$_head1" = "mux-layout 1" ] || { | ||
| 595 | echo "e2e FAIL: layout-restore: sidecar header is '$_head1', want 'mux-layout 1'" | ||
| 596 | cat "$LPLAYOUT"; exit 1; } | ||
| 597 | # A 2-pane beside at 80 cols: the root is `beside 0`. | ||
| 598 | case "$_head2" in | ||
| 599 | "beside 0") ;; | ||
| 600 | *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'" | ||
| 601 | cat "$LPLAYOUT"; exit 1 ;; | ||
| 602 | esac | ||
| 603 | # The focus record: focus-right moved focus to pane b (leaf 1). | ||
| 604 | grep -q '^focus 1$' "$LPLAYOUT" || { | ||
| 605 | echo "e2e FAIL: layout-restore: sidecar carries no focus record" | ||
| 606 | cat "$LPLAYOUT"; exit 1; } | ||
| 607 | |||
| 608 | set +e | ||
| 609 | # Second ptyclient: reattach. The sidecar restores the resized tree — | ||
| 610 | # the rail sits at column >= 43. Type a marker, then detach. | ||
| 611 | XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 612 | --out "$OUT.lprcap2" --err "$OUT.lprcap2.err" -- \ | ||
| 613 | "$MUX" wall > "$OUT.lprpc2" 2>&1 <<'EOF' | ||
| 614 | expect lpr-origin-a 20000 | ||
| 615 | settle 1000 20000 | ||
| 616 | send printf 'lpr-rt-%s\n' marker\n | ||
| 617 | expect lpr-rt-marker 10000 | ||
| 618 | settle 500 15000 | ||
| 619 | send \x1cd | ||
| 620 | waitexit 10000 | ||
| 621 | EOF | ||
| 622 | RC=$? | ||
| 623 | set -e | ||
| 624 | [ "$RC" -eq 0 ] || { | ||
| 625 | echo "e2e FAIL: layout-restore: second ptyclient exited $RC:" | ||
| 626 | cat "$OUT.lprpc2" "$OUT.lprcap2.err"; exit 1; } | ||
| 627 | # The rail's CUP column in the second capture must be >= 43 — three | ||
| 628 | # right-resizes moved the boundary from 41 to 44. The _rail_re idiom | ||
| 629 | # (from the split-birth leg) matches ESC[row;colH ESC[7m. | ||
| 630 | _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m' | ||
| 631 | _max_rail=$(grep -ao "$_rail_re" "$OUT.lprcap2" | \ | ||
| 632 | sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | tail -1) | ||
| 633 | [ -n "$_max_rail" ] || { | ||
| 634 | echo "e2e FAIL: layout-restore: no rail glyph in the second capture:" | ||
| 635 | cat "$OUT.lprcap2"; exit 1; } | ||
| 636 | [ "$_max_rail" -ge 43 ] || { | ||
| 637 | echo "e2e FAIL: layout-restore: rail column $_max_rail < 43 (resize did not survive)" | ||
| 638 | cat "$OUT.lprcap2"; exit 1; } | ||
| 639 | # The marker typed into the restored wall landed in session b — the | ||
| 640 | # focus was saved on pane b and restore put it back there, not tile 0. | ||
| 641 | timeout 20 "$MUXA" capture --sock "$SOCK59" --session b > "$OUT.lprfb" 2>&1 | ||
| 642 | grep -q "lpr-rt-marker" "$OUT.lprfb" || { | ||
| 643 | echo "e2e FAIL: layout-restore: marker not in session b (focus did not survive):" | ||
| 644 | cat "$OUT.lprfb"; exit 1; } | ||
| 645 | timeout 20 "$MUXA" capture --sock "$SOCK59" --session a > "$OUT.lprfa" 2>&1 | ||
| 646 | ! grep -q "lpr-rt-marker" "$OUT.lprfa" || { | ||
| 647 | echo "e2e FAIL: layout-restore: marker leaked into session a (focus was tile 0, not b):" | ||
| 648 | cat "$OUT.lprfa"; exit 1; } | ||
| 649 | assert_stopped "$SOCK59" "$D60PID" "layout-restore" "$OUT.lprstop" | ||
| 650 | D60PID="" | ||
| 651 | rm -rf "$LPSTATE" | ||
| 652 | ok "a resized layout survives a detach/reattach round trip via the sidecar" | ||
| 653 | |||
| 654 | # ---- layout sidecar: healing on wall-file drift ---------------------- | ||
| 655 | # | ||
| 656 | # After a 2-pane sidecar is saved, the wall file drifts: one original is | ||
| 657 | # removed and a new spelling is added. On reattach, restoreLayout heals — | ||
| 658 | # the survivor leaf keeps its session, the new tile inserts beside it, | ||
| 659 | # and the unmatched saved leaf collapses out. Two tiles, not three, and | ||
| 660 | # the wall did not refuse. | ||
| 661 | LPHSTATE="${TMPDIR:-/tmp}/mux-e2e-lpheal-state-$$" | ||
| 662 | defer_rm "$LPHSTATE" | ||
| 663 | LPHWALL="$LPHSTATE/mux/wall" | ||
| 664 | LPHLAYOUT="$LPHSTATE/mux/layout" | ||
| 665 | start_daemon "$SOCK60" "$OUT.lph.d" "layout-heal daemon never bound" --shell /bin/sh | ||
| 666 | D61PID=$DPID | ||
| 667 | |||
| 668 | pipe_mux "$OUT.lpha" "$OUT.lpha.err" timeout 40 "$MUX" --sock "$SOCK60" --session a | ||
| 669 | pipe_send 'printf "lph-%%s\\n" survivor-a\n' | ||
| 670 | await_out "$OUT.lpha" "lph-survivor-a" "layout-heal: session a marker never reached the client" | ||
| 671 | pipe_detach | ||
| 672 | wait_grid "$SOCK60" "lph-survivor-a" "layout-heal: session a marker" a | ||
| 673 | pipe_mux "$OUT.lphb" "$OUT.lphb.err" timeout 40 "$MUX" --sock "$SOCK60" --session b | ||
| 674 | pipe_send 'printf "lph-%%s\\n" removed-b\n' | ||
| 675 | await_out "$OUT.lphb" "lph-removed-b" "layout-heal: session b marker never reached the client" | ||
| 676 | pipe_detach | ||
| 677 | wait_grid "$SOCK60" "lph-removed-b" "layout-heal: session b marker" b | ||
| 678 | pipe_mux "$OUT.lphc" "$OUT.lphc.err" timeout 40 "$MUX" --sock "$SOCK60" --session c | ||
| 679 | pipe_send 'printf "lph-%%s\\n" newcomer-c\n' | ||
| 680 | await_out "$OUT.lphc" "lph-newcomer-c" "layout-heal: session c marker never reached the client" | ||
| 681 | pipe_detach | ||
| 682 | wait_grid "$SOCK60" "lph-newcomer-c" "layout-heal: session c marker" c | ||
| 683 | |||
| 684 | # Wall file starts with a and b. The first ptyclient detaches and saves | ||
| 685 | # a 2-pane sidecar for [a, b]. | ||
| 686 | mkdir -p "$LPHSTATE/mux" | ||
| 687 | printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK60" "$SOCK60" > "$LPHWALL" | ||
| 688 | |||
| 689 | set +e | ||
| 690 | XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 691 | --out "$OUT.lphcap1" --err "$OUT.lphcap1.err" -- \ | ||
| 692 | "$MUX" wall > "$OUT.lphpc1" 2>&1 <<'EOF' | ||
| 693 | expect lph-survivor-a 20000 | ||
| 694 | settle 700 20000 | ||
| 695 | send \x1cd | ||
| 696 | waitexit 10000 | ||
| 697 | EOF | ||
| 698 | RC=$? | ||
| 699 | set -e | ||
| 700 | [ "$RC" -eq 0 ] || { | ||
| 701 | echo "e2e FAIL: layout-heal: first ptyclient exited $RC:" | ||
| 702 | cat "$OUT.lphpc1" "$OUT.lphcap1.err"; exit 1; } | ||
| 703 | [ -f "$LPHLAYOUT" ] || { | ||
| 704 | echo "e2e FAIL: layout-heal: sidecar not saved after first detach" | ||
| 705 | ls -la "$LPHSTATE/mux/" 2>&1; exit 1; } | ||
| 706 | |||
| 707 | # Drift: remove session a's line, add session c's line. The sidecar still | ||
| 708 | # describes [a, b]; the wall file now says [b, c]. On reattach, b is the | ||
| 709 | # survivor (matched), c inserts beside it, a collapses out (unmatched | ||
| 710 | # saved leaf). | ||
| 711 | printf -- '--sock %s#b\n--sock %s#c\n' "$SOCK60" "$SOCK60" > "$LPHWALL" | ||
| 712 | |||
| 713 | set +e | ||
| 714 | XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 715 | --out "$OUT.lphcap2" --err "$OUT.lphcap2.err" -- \ | ||
| 716 | "$MUX" wall > "$OUT.lphpc2" 2>&1 <<'EOF' | ||
| 717 | expect lph-removed-b 20000 | ||
| 718 | settle 700 20000 | ||
| 719 | send printf 'lph-heal-%s\n' marker\n | ||
| 720 | expect lph-heal-marker 10000 | ||
| 721 | settle 500 15000 | ||
| 722 | send \x1cd | ||
| 723 | waitexit 10000 | ||
| 724 | EOF | ||
| 725 | RC=$? | ||
| 726 | set -e | ||
| 727 | [ "$RC" -eq 0 ] || { | ||
| 728 | echo "e2e FAIL: layout-heal: second ptyclient exited $RC:" | ||
| 729 | cat "$OUT.lphpc2" "$OUT.lphcap2.err"; exit 1; } | ||
| 730 | # The wall must not have refused: the ptyclient exited 0 (checked above), | ||
| 731 | # and stderr mentions no layout error. The survivor (b) still reaches its | ||
| 732 | # session — the marker typed after hydration landed in the focused tile, | ||
| 733 | # which is tile 0 (session b, the first wall-file line). | ||
| 734 | grep -qi "layout" "$OUT.lphcap2.err" && { | ||
| 735 | echo "e2e FAIL: layout-heal: stderr mentions layout (heal was not silent):" | ||
| 736 | cat "$OUT.lphcap2.err"; exit 1; } | ||
| 737 | # Two tiles present: the survivor (b) and the newcomer (c). The daemon | ||
| 738 | # has sessions a, b, and c; the wall shows b and c. muxa capture of b | ||
| 739 | # finds the survivor's marker. | ||
| 740 | timeout 20 "$MUXA" capture --sock "$SOCK60" --session b > "$OUT.lphfa" 2>&1 | ||
| 741 | grep -q "lph-heal-marker" "$OUT.lphfa" || { | ||
| 742 | echo "e2e FAIL: layout-heal: survivor's marker not in session b:" | ||
| 743 | cat "$OUT.lphfa"; exit 1; } | ||
| 744 | assert_stopped "$SOCK60" "$D61PID" "layout-heal" "$OUT.lphstop" | ||
| 745 | D61PID="" | ||
| 746 | rm -rf "$LPHSTATE" | ||
| 747 | ok "the layout heals on wall-file drift: survivor keeps its session, newcomer joins" | ||
| 748 | |||
| 749 | # ---- layout sidecar: silent degrade on garbage ----------------------- | ||
| 750 | # | ||
| 751 | # A corrupted sidecar (`bogus 9`) is not a fatal error: restoreLayout | ||
| 752 | # returns null, the default tree is built, and the wall comes up. The | ||
| 753 | # assertion is that a marker typed into the wall reaches the focused | ||
| 754 | # session (the wall works), and the client's stderr never mentions | ||
| 755 | # "layout" (the degrade is silent). | ||
| 756 | LPDSTATE="${TMPDIR:-/tmp}/mux-e2e-lpdegrade-state-$$" | ||
| 757 | defer_rm "$LPDSTATE" | ||
| 758 | LPDWALL="$LPDSTATE/mux/wall" | ||
| 759 | LPDLAYOUT="$LPDSTATE/mux/layout" | ||
| 760 | start_daemon "$SOCK61" "$OUT.lpd.d" "layout-degrade daemon never bound" --shell /bin/sh | ||
| 761 | D62PID=$DPID | ||
| 762 | |||
| 763 | pipe_mux "$OUT.lpda" "$OUT.lpda.err" timeout 40 "$MUX" --sock "$SOCK61" --session a | ||
| 764 | pipe_send 'printf "lpd-%%s\\n" origin\n' | ||
| 765 | await_out "$OUT.lpda" "lpd-origin" "layout-degrade: session a marker never reached the client" | ||
| 766 | pipe_detach | ||
| 767 | wait_grid "$SOCK61" "lpd-origin" "layout-degrade: session a marker" a | ||
| 768 | |||
| 769 | mkdir -p "$LPDSTATE/mux" | ||
| 770 | printf -- '--sock %s#a\n' "$SOCK61" > "$LPDWALL" | ||
| 771 | # A garbage sidecar: wrong magic, wrong version. restoreLayout returns | ||
| 772 | # null on the header check and the caller builds the default tree. | ||
| 773 | printf 'bogus 9\n' > "$LPDLAYOUT" | ||
| 774 | |||
| 775 | set +e | ||
| 776 | XDG_STATE_HOME="$LPDSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 777 | --out "$OUT.lpdcap" --err "$OUT.lpdcap.err" -- \ | ||
| 778 | "$MUX" wall > "$OUT.lpdpc" 2>&1 <<'EOF' | ||
| 779 | expect lpd-origin 20000 | ||
| 780 | settle 700 20000 | ||
| 781 | send printf 'lpd-degrade-%s\n' marker\n | ||
| 782 | expect lpd-degrade-marker 10000 | ||
| 783 | settle 500 15000 | ||
| 784 | send \x1cd | ||
| 785 | waitexit 10000 | ||
| 786 | EOF | ||
| 787 | RC=$? | ||
| 788 | set -e | ||
| 789 | [ "$RC" -eq 0 ] || { | ||
| 790 | echo "e2e FAIL: layout-degrade: ptyclient exited $RC:" | ||
| 791 | cat "$OUT.lpdpc" "$OUT.lpdcap.err"; exit 1; } | ||
| 792 | # The wall came up: the marker reached the focused session. | ||
| 793 | timeout 20 "$MUXA" capture --sock "$SOCK61" --session a > "$OUT.lpdfa" 2>&1 | ||
| 794 | grep -q "lpd-degrade-marker" "$OUT.lpdfa" || { | ||
| 795 | echo "e2e FAIL: layout-degrade: marker not in session a (wall did not come up):" | ||
| 796 | cat "$OUT.lpdfa"; exit 1; } | ||
| 797 | # The degrade was silent: no mention of "layout" on the client's stderr. | ||
| 798 | grep -qi "layout" "$OUT.lpdcap.err" && { | ||
| 799 | echo "e2e FAIL: layout-degrade: stderr mentions layout (degrade was not silent):" | ||
| 800 | cat "$OUT.lpdcap.err"; exit 1; } | ||
| 801 | assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop" | ||
| 802 | D62PID="" | ||
| 803 | rm -rf "$LPDSTATE" | ||
| 804 | ok "a corrupted sidecar degrades silently to the default layout" | ||