a73x

d9a5d6cb

test: e2e drives a real wheel from the pty side, both ways

a73x   2026-08-19 21:02

Commit message
test: e2e drives a real wheel from the pty side, both ways

Two legs of one claim: the same session, the same eight-notch burst, and
opposite outcomes. At the live view the wheel scrolls a screenful back and
comes back to live, and the sixteen reports it consumed are absent from the
session's grid — a shell would have echoed every one. With an application
holding the mouse (1000+1002+1006, vim's `set mouse=a` set) the same bytes
come back as the pty's own echo, the client mirrors 1002 onto its terminal
— a mode its own capture set never asks for — and nothing scrolls.

tp1's first needle moves from "100" to `\x1b[0m100`. The client now asks its
terminal for mouse reporting with `\x1b[?1000h`, and "100" is a substring of
that: the plain needle matched the mode enable, so the scroll key was sent
before the snapshot landed and the scenario failed on a scroll that was
never asked to happen.

test/e2e.sh
Old New
@@ -42,6 +42,16 @@ command -v curl > /dev/null 2>&1 || {
42 echo " hub's POST/DELETE/PUT answers); install it, or lose every" 42 echo " hub's POST/DELETE/PUT answers); install it, or lose every"
43 echo " check that the wall can be edited at all" 43 echo " check that the wall can be edited at all"
44 exit 1; } 44 exit 1; }
45 # And the third, for the same reason a third time: the alternate-screen
46 # wheel leg needs a REAL pager. A hand-rolled alt-screen script can prove
47 # arrows arrive, but only something that actually pages proves the user got
48 # what they reached for — and less is where the DECCKM trap lives (it reads
49 # `ESC O A`, not `ESC [ A`), which a stand-in would not reproduce.
50 command -v less > /dev/null 2>&1 || {
51 echo "e2e FAIL: this suite needs less (the alternate-screen wheel leg scrolls"
52 echo " a real pager); install it, or lose the only check that a"
53 echo " wheel notch moves anything on a TUI screen"
54 exit 1; }
45 55
46 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 56 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
47 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 57 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
@@ -286,6 +296,28 @@ D28PID=""
286 SOCK32="${TMPDIR:-/tmp}/muxd-e2e-wallzoom-$$.sock" 296 SOCK32="${TMPDIR:-/tmp}/muxd-e2e-wallzoom-$$.sock"
287 D29PID="" 297 D29PID=""
288 298
299 # The wheel pair. Each needs a scrollback of its own to scroll (or to prove
300 # it did not), which the long-lived /bin/sh daemon has no way to hold still,
301 # and the second one needs a session that ASKS for the mouse — a mode that
302 # would follow every other scenario sharing the daemon around.
303 SOCK33="${TMPDIR:-/tmp}/muxd-e2e-wheel-$$.sock"
304 D30PID=""
305 WHEELSH="${TMPDIR:-/tmp}/mux-e2e-wheel-$$.sh"
306 SOCK34="${TMPDIR:-/tmp}/muxd-e2e-appmouse-$$.sock"
307 D31PID=""
308 MOUSESH="${TMPDIR:-/tmp}/mux-e2e-appmouse-$$.sh"
309 # The alternate-screen half of the wheel: its own daemon because the
310 # session under test is a PAGER holding the alt screen for its whole life,
311 # which no other scenario could share a grid with. And the no-terminal
312 # half, whose session is a bare `cat` so that what reaches the pty is
313 # echoed back into the grid the assertion reads.
314 SOCK35="${TMPDIR:-/tmp}/muxd-e2e-pager-$$.sock"
315 D32PID=""
316 LESSSH="${TMPDIR:-/tmp}/mux-e2e-pager-$$.sh"
317 LESSDATA="${TMPDIR:-/tmp}/mux-e2e-pager-$$.txt"
318 SOCK36="${TMPDIR:-/tmp}/muxd-e2e-pipestdin-$$.sock"
319 D33PID=""
320
289 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 321 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
290 # such line on exit; every field is a key=value pair, so a rename or reorder 322 # such line on exit; every field is a key=value pair, so a rename or reorder
291 # in the client shows up here as an empty read rather than a wrong number. 323 # in the client shows up here as an empty read rather than a wrong number.
@@ -812,6 +844,10 @@ cleanup() {
812 [ -n "$D27PID" ] && kill "$D27PID" 2>/dev/null || true 844 [ -n "$D27PID" ] && kill "$D27PID" 2>/dev/null || true
813 [ -n "$D28PID" ] && kill "$D28PID" 2>/dev/null || true 845 [ -n "$D28PID" ] && kill "$D28PID" 2>/dev/null || true
814 [ -n "$D29PID" ] && kill "$D29PID" 2>/dev/null || true 846 [ -n "$D29PID" ] && kill "$D29PID" 2>/dev/null || true
847 [ -n "$D30PID" ] && kill "$D30PID" 2>/dev/null || true
848 [ -n "$D31PID" ] && kill "$D31PID" 2>/dev/null || true
849 [ -n "$D32PID" ] && kill "$D32PID" 2>/dev/null || true
850 [ -n "$D33PID" ] && kill "$D33PID" 2>/dev/null || true
815 # The stops still precede the socket rm below, like SOCK14-17 above: 851 # The stops still precede the socket rm below, like SOCK14-17 above:
816 # unlinking a socket first would leave a live daemon nothing could reach 852 # unlinking a socket first would leave a live daemon nothing could reach
817 # by path. 853 # by path.
@@ -826,6 +862,10 @@ cleanup() {
826 [ -S "$SOCK30" ] && "$MUXD" stop --sock "$SOCK30" 2>/dev/null || true 862 [ -S "$SOCK30" ] && "$MUXD" stop --sock "$SOCK30" 2>/dev/null || true
827 [ -S "$SOCK31" ] && "$MUXD" stop --sock "$SOCK31" 2>/dev/null || true 863 [ -S "$SOCK31" ] && "$MUXD" stop --sock "$SOCK31" 2>/dev/null || true
828 [ -S "$SOCK32" ] && "$MUXD" stop --sock "$SOCK32" 2>/dev/null || true 864 [ -S "$SOCK32" ] && "$MUXD" stop --sock "$SOCK32" 2>/dev/null || true
865 [ -S "$SOCK33" ] && "$MUXD" stop --sock "$SOCK33" 2>/dev/null || true
866 [ -S "$SOCK34" ] && "$MUXD" stop --sock "$SOCK34" 2>/dev/null || true
867 [ -S "$SOCK35" ] && "$MUXD" stop --sock "$SOCK35" 2>/dev/null || true
868 [ -S "$SOCK36" ] && "$MUXD" stop --sock "$SOCK36" 2>/dev/null || true
829 869
830 # ---- the leak sweep (hygiene kit, 6a) ---- 870 # ---- the leak sweep (hygiene kit, 6a) ----
831 # Here rather than at the bottom of the file, which `set -e` reaches only 871 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -838,7 +878,8 @@ cleanup() {
838 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \ 878 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \
839 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \ 879 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
840 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 880 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
841 "$D26PID" "$D27PID" "$D28PID" "$D29PID" 881 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
882 "$D32PID" "$D33PID"
842 _leak=0 883 _leak=0
843 leak_sweep "$_rc" || _leak=1 884 leak_sweep "$_rc" || _leak=1
844 885
@@ -890,6 +931,15 @@ cleanup() {
890 # so without this line the residue survives exactly the runs where the 931 # so without this line the residue survives exactly the runs where the
891 # leg failed and somebody is looking at it. 932 # leg failed and somebody is looking at it.
892 rm -f "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh" 933 rm -f "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh"
934 # The wheel pair: two captures, two fixture logs, two session scripts
935 # and the daemon logs beside them.
936 rm -f "$OUT.whl" "$OUT.whl.err" "$OUT.whl.log" "$OUT.whl.d" "$WHEELSH" \
937 "$OUT.mse" "$OUT.mse.err" "$OUT.mse.log" "$OUT.mse.d" "$MOUSESH" \
938 "$SOCK33" "$SOCK34" "$OUT.whlcap" "$OUT.msecap" \
939 "$OUT.whlstop" "$OUT.msestop"
940 rm -f "$OUT.pgr" "$OUT.pgr.err" "$OUT.pgr.log" "$OUT.pgr.d" "$LESSSH" \
941 "$LESSDATA" "$OUT.pgrcap" "$OUT.pgrstop" "$SOCK35" \
942 "$OUT.pipe" "$OUT.pipe.d" "$OUT.pipecap" "$OUT.pipestop" "$SOCK36"
893 # ...and the non-tty capture that leg's session feeds. 943 # ...and the non-tty capture that leg's session feeds.
894 rm -f "$OUT.nogate" 944 rm -f "$OUT.nogate"
895 # ...and its other half: the paste capture and the file nvim wrote, which 945 # ...and its other half: the paste capture and the file nvim wrote, which
@@ -2571,11 +2621,11 @@ done
2571 MUX_PREDICT_STATS=1 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \ 2621 MUX_PREDICT_STATS=1 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \
2572 --out "$OUT.tp1" --err "$OUT.tp1.err" -- \ 2622 --out "$OUT.tp1" --err "$OUT.tp1.err" -- \
2573 "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' & 2623 "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' &
2574 expect 100 10000 2624 expect \x1b[0m100 10000
2575 send \x1b[5;2~ 2625 send \x1b[5;2~
2576 expect 60 10000 2626 expect 60 10000
2577 expect reconnecting 20000 2627 expect reconnecting 20000
2578 expect 100 20000 2628 expect \x1b[0m100 20000
2579 send t 2629 send t
2580 expect \x1b[2K\x1b[0mt 20000 2630 expect \x1b[2K\x1b[0mt 20000
2581 settle 500 15000 2631 settle 500 15000
@@ -2588,7 +2638,12 @@ TP1PID=$!
2588 # the fixture's "done 3" line is the barrier. Every needle above is unique 2638 # the fixture's "done 3" line is the barrier. Every needle above is unique
2589 # to its phase, which is what makes each expect sound under the no-counting 2639 # to its phase, which is what makes each expect sound under the no-counting
2590 # rule: 2640 # rule:
2591 # the first "100" is the attach snapshot's last row; 2641 # the first "100" is the attach snapshot's last row, and it is spelled
2642 # with the SGR reset the paint puts in front of every row because a bare
2643 # "100" is no longer unique to it: the client asks its own terminal for
2644 # mouse reporting with `\x1b[?1000h`, which lands in this capture before
2645 # any row does. Measured, not feared — that is the collision that made
2646 # this scenario fail when the wheel landed;
2592 # "60" is in the history page (54..77) and in NOTHING before it. The 2647 # "60" is in the history page (54..77) and in NOTHING before it. The
2593 # snapshot's rows are 78..100, and the escapes around them cannot spell 2648 # snapshot's rows are 78..100, and the escapes around them cannot spell
2594 # it either — but that is a fact about THIS session, not about the paint 2649 # it either — but that is a fact about THIS session, not about the paint
@@ -2598,7 +2653,7 @@ TP1PID=$!
2598 # mode with one keystroke typed after the tear, so that column is 1 or 2 2653 # mode with one keystroke typed after the tear, so that column is 1 or 2
2599 # for the whole scenario, and the row half of a CUP tops out at 24; 2654 # for the whole scenario, and the row half of a CUP tops out at 24;
2600 # "reconnecting" is the banner, painted once, on the tear; 2655 # "reconnecting" is the banner, painted once, on the tear;
2601 # the SECOND "100" can only be the post-resync repaint: the scroll view 2656 # the SECOND "\x1b[0m100" can only be the post-resync repaint: the scroll view
2602 # holds 54..77, and the banner carries no digits but its own position; 2657 # holds 54..77, and the banner carries no digits but its own position;
2603 # the last needle is STRUCTURAL, and it has to be. In canonical mode the 2658 # the last needle is STRUCTURAL, and it has to be. In canonical mode the
2604 # only thing a single keystroke produces is the line discipline's echo of 2659 # only thing a single keystroke produces is the line discipline's echo of
@@ -5013,6 +5068,257 @@ assert_stopped "$SOCK32" "$D29PID" "wall zoom" "$OUT.wzstop"
5013 D29PID="" 5068 D29PID=""
5014 ok "the wall zooms: Enter types into the selected session, Ctrl-\\ d comes back" 5069 ok "the wall zooms: Enter types into the selected session, Ctrl-\\ d comes back"
5015 5070
5071 # ---- the wheel scrolls back, and an app that asks gets it instead -------
5072 #
5073 # The pair is one claim seen from both sides, so the two legs are the same
5074 # session, the same bytes and opposite outcomes: a wheel report belongs to
5075 # the scrollback until an application inside the session asks for the mouse,
5076 # and then it belongs to that application.
5077 #
5078 # The bytes are real ones: `\x1b[<64;10;5M` is what a terminal writes for a
5079 # wheel-up press once the client has asked for SGR reports (1006), which is
5080 # the point — before this the client asked for nothing, so a terminal
5081 # answered the wheel by SYNTHESISING arrow keys on the alternate screen
5082 # (DEC 1007) and the session read them as input.
5083 #
5084 # (a) At the live view, eight notches in ONE write scroll a screenful back.
5085 # Eight because a notch is three rows: 8*3 = 24, exactly what
5086 # Shift+PageUp moves on this 80x24 terminal, which is what lets this
5087 # leg borrow tp1's needle — "60" is in the history page (rows 54..77)
5088 # and in nothing painted before it. One write rather than eight because
5089 # that is how a terminal writes a spin, and because a burst that is not
5090 # SUMMED lands three rows up instead of twenty-four: the needle fails.
5091 cat > "$WHEELSH" <<'EOF'
5092 #!/bin/sh
5093 seq 1 100
5094 exec /bin/cat
5095 EOF
5096 chmod +x "$WHEELSH"
5097 "$MUXD" run --sock "$SOCK33" --shell "$WHEELSH" > "$OUT.whl.d" 2>&1 &
5098 D30PID=$!
5099 wait_sock "$SOCK33" "$OUT.whl.d" "wheel daemon never bound"
5100 # Attach only once seq has finished, for tp1's reason: the page the wheel
5101 # fetches has to be content this client was never sent.
5102 i=0
5103 until "$MUXD" dump --sock "$SOCK33" | grep -q "100"; do
5104 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: wheel session never finished seq"; exit 1; }
5105 sleep 0.1
5106 done
5107 # `\x1b[0m100`, not "100": the client asks its own terminal for mouse
5108 # reporting with `\x1b[?1000h`, so a bare "100" is in this capture before
5109 # any row is. The SGR reset in front of it is the paint's, and nothing else
5110 # writes that pair. (Measured: with the plain needle this leg passes on the
5111 # mode enable and sends the wheel before the snapshot has landed.)
5112 set +e
5113 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.whl" --err "$OUT.whl.err" \
5114 -- "$MUX" --sock "$SOCK33" > "$OUT.whl.log" 2>&1 <<'EOF'
5115 expect \x1b[0m100 15000
5116 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
5117 expect 60 15000
5118 send \x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M
5119 expect \x1b[0m100 15000
5120 send \x1b[<64;10;5Mwheel-and-key
5121 settle 600 15000
5122 send \x1cd
5123 waitexit 10000
5124 EOF
5125 RC=$?
5126 set -e
5127 [ "$RC" -eq 0 ] || {
5128 echo "e2e FAIL: wheel: ptyclient leg exited $RC (did the wheel scroll, and come back?):"
5129 cat "$OUT.whl.log"; exit 1; }
5130 # The other half of "the wheel is the client's": the bytes are CONSUMED.
5131 # Seventeen mouse reports were typed at a shell that would have echoed every
5132 # one of them, and the session's grid must hold none.
5133 timeout 20 "$MUXA" capture --sock "$SOCK33" > "$OUT.whlcap" 2>&1
5134 grep -qF -- "[<64;" "$OUT.whlcap" && {
5135 echo "e2e FAIL: wheel: a mouse report reached the pty as input:"
5136 cat "$OUT.whlcap"; exit 1; }
5137 # ...but a keystroke that shared its read with a notch is NOT consumed. The
5138 # last send was one write holding both, typed at the live view: the wheel
5139 # moved the view and the letters belong to the shell. Swallowing them as
5140 # "the key that leaves scroll mode" loses input to a view the user had not
5141 # seen yet, which is what this needle catches.
5142 grep -qF -- "wheel-and-key" "$OUT.whlcap" || {
5143 echo "e2e FAIL: wheel: a keystroke sharing a read with a notch never reached the pty:"
5144 cat "$OUT.whlcap"; exit 1; }
5145 assert_stopped "$SOCK33" "$D30PID" "wheel" "$OUT.whlstop"
5146 D30PID=""
5147 ok "the wheel scrolls back and returns to live, and never reaches the pty"
5148
5149 # (b) The same session with an application holding the mouse. The script
5150 # asks for tracking (1000), drag (1002) and SGR reports (1006) — vim's
5151 # `set mouse=a` set — before becoming cat, so every wheel byte is the
5152 # application's and the client must hand them over untouched.
5153 #
5154 # 1002 is the mode that makes this leg sharp: the client's OWN capture
5155 # set is 1000+1006, so a `?1002h` on this terminal can only have come
5156 # from mirroring what the session asked for.
5157 cat > "$MOUSESH" <<'EOF'
5158 #!/bin/sh
5159 seq 1 100
5160 printf '\033[?1000h\033[?1002h\033[?1006h'
5161 printf 'app-holds-the-mouse\n'
5162 exec /bin/cat
5163 EOF
5164 chmod +x "$MOUSESH"
5165 "$MUXD" run --sock "$SOCK34" --shell "$MOUSESH" > "$OUT.mse.d" 2>&1 &
5166 D31PID=$!
5167 wait_sock "$SOCK34" "$OUT.mse.d" "app-mouse daemon never bound"
5168 i=0
5169 until "$MUXD" dump --sock "$SOCK34" | grep -q "app-holds-the-mouse"; do
5170 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: app-mouse session never armed"; exit 1; }
5171 sleep 0.1
5172 done
5173 # The modes are already set when this client attaches, so they arrive in
5174 # the attach's own term_modes and there is no race to settle for. The echo
5175 # is the assertion: cat is in canonical mode with ECHOCTL, so bytes that
5176 # reach the pty come back as `^[[<64;10;5M` and bytes that do not, do not.
5177 set +e
5178 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \
5179 -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF'
5180 expect app-holds-the-mouse 15000
5181 send \x1b[<64;10;5M
5182 expect [<64;10;5M 15000
5183 send \x1cd
5184 waitexit 10000
5185 EOF
5186 RC=$?
5187 set -e
5188 [ "$RC" -eq 0 ] || {
5189 echo "e2e FAIL: app mouse: ptyclient leg exited $RC (did the wheel reach the app?):"
5190 cat "$OUT.mse.log"; exit 1; }
5191 # The session's own grid, not just the client's screen: the echo is the pty
5192 # saying it received the bytes.
5193 timeout 20 "$MUXA" capture --sock "$SOCK34" > "$OUT.msecap" 2>&1
5194 grep -qF -- "[<64;10;5M" "$OUT.msecap" || {
5195 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
5196 cat "$OUT.msecap"; exit 1; }
5197 # The mirror: this terminal was asked for the session's modes, not the
5198 # client's own set.
5199 grep -qaF "$(printf '\033[?1002h')" "$OUT.mse" || {
5200 echo "e2e FAIL: app mouse: the client never mirrored the session's drag mode"; exit 1; }
5201 # And the negative that makes the pair a pair: this session has the same
5202 # 77 rows of history as (a), and the same wheel byte moved none of it.
5203 grep -qF -- "60" "$OUT.mse" && {
5204 echo "e2e FAIL: app mouse: the client scrolled back on a wheel the app owned"; exit 1; }
5205 assert_stopped "$SOCK34" "$D31PID" "app mouse" "$OUT.msestop"
5206 D31PID=""
5207 ok "an application that asked for the mouse gets the wheel, and the client does not"
5208
5209 # ---- the wheel on the alternate screen: a pager, not our scrollback ----
5210 #
5211 # The alt screen has no scrollback of ours — `historyRows` is 0 there by
5212 # contract — so a notch that fell through to the scroll arithmetic was
5213 # consumed by the mouse filter and then dropped. Measured on this very
5214 # scenario before the fix: four notches, grid unmoved.
5215 #
5216 # `less +G` on 200 lines: the view starts at the END, so a wheel-UP is the
5217 # direction with somewhere to go. Eight notches is 24 rows, one screenful,
5218 # which moves the top from 178 to 154 — and 178 leaves the screen entirely,
5219 # so the assertion has both a needle and its negative.
5220 #
5221 # LESS is cleared in the script rather than trusted: an operator with
5222 # `LESS=--mouse` exported would have a pager that DOES ask for the mouse,
5223 # which is the other leg's story, and this one would fail for a reason that
5224 # has nothing to do with the code.
5225 seq 1 200 > "$LESSDATA"
5226 cat > "$LESSSH" <<EOF
5227 #!/bin/sh
5228 LESS=
5229 export LESS
5230 exec less +G $LESSDATA
5231 EOF
5232 chmod +x "$LESSSH"
5233 "$MUXD" run --sock "$SOCK35" --shell "$LESSSH" > "$OUT.pgr.d" 2>&1 &
5234 D32PID=$!
5235 wait_sock "$SOCK35" "$OUT.pgr.d" "pager daemon never bound"
5236 i=0
5237 until "$MUXD" dump --sock "$SOCK35" | grep -qx "200"; do
5238 i=$((i+1)); [ "$i" -lt 100 ] || {
5239 echo "e2e FAIL: pager never reached the end of the file:"
5240 "$MUXD" dump --sock "$SOCK35" | tail -3; exit 1; }
5241 sleep 0.1
5242 done
5243 set +e
5244 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pgr" --err "$OUT.pgr.err" \
5245 -- "$MUX" --sock "$SOCK35" > "$OUT.pgr.log" 2>&1 <<'EOF'
5246 expect 199 15000
5247 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
5248 settle 800 15000
5249 send \x1cd
5250 waitexit 10000
5251 EOF
5252 RC=$?
5253 set -e
5254 [ "$RC" -eq 0 ] || {
5255 echo "e2e FAIL: pager: ptyclient leg exited $RC:"; cat "$OUT.pgr.log"; exit 1; }
5256 # `muxd dump`, not `muxa capture`: the assertions below are WHOLE-LINE ones
5257 # and capture answers in JSON, where the grid is one line with escaped
5258 # newlines in it — a `grep -x` against that can never match. (It did not,
5259 # on the first run of this leg, while the grid underneath was exactly
5260 # right.) dump prints the grid a row per line, which is what -x needs.
5261 "$MUXD" dump --sock "$SOCK35" > "$OUT.pgrcap" 2>&1
5262 # A whole line, because the pager's status line carries the file's PATH —
5263 # which holds this run's pid and could spell any short number.
5264 grep -qx "154" "$OUT.pgrcap" || {
5265 echo "e2e FAIL: pager: the wheel moved nothing on the alternate screen:"
5266 cat "$OUT.pgrcap"; exit 1; }
5267 grep -qx "178" "$OUT.pgrcap" && {
5268 echo "e2e FAIL: pager: the old top row is still on screen, so the view did not move a screenful:"
5269 cat "$OUT.pgrcap"; exit 1; }
5270 # The pager never asked for the mouse, so it must never have SEEN a mouse
5271 # report: what reached it was arrow keys, which leave no text behind.
5272 grep -qF -- "[<64;" "$OUT.pgrcap" && {
5273 echo "e2e FAIL: pager: a raw mouse report reached an application that never asked for one:"
5274 cat "$OUT.pgrcap"; exit 1; }
5275 assert_stopped "$SOCK35" "$D32PID" "pager" "$OUT.pgrstop"
5276 D32PID=""
5277 ok "the wheel scrolls a pager on the alternate screen, as arrow keys"
5278
5279 # ---- a client with no terminal filters nothing --------------------------
5280 #
5281 # The mouse filter is gated on this client having TAKEN a terminal over,
5282 # and that gate has to be the alt-screen flag rather than the mode bits:
5283 # a client whose stdin is a pipe never asked anyone for mouse reports, so
5284 # nothing it reads can be one. Filtering there is pure loss — the bytes are
5285 # whatever a script piped in, and an SGR-shaped escape in a heredoc is text
5286 # somebody meant to send.
5287 #
5288 # `cat` as the session, so what arrives is echoed back and the grid IS the
5289 # assertion: the bytes reached the pty or they did not.
5290 "$MUXD" run --sock "$SOCK36" --shell /bin/cat > "$OUT.pipe.d" 2>&1 &
5291 D33PID=$!
5292 wait_sock "$SOCK36" "$OUT.pipe.d" "pipe-stdin daemon never bound"
5293 # The detach chord is a SEPARATE write behind a sleep, and both halves of
5294 # that are load-bearing. Without it the client stays attached after stdin
5295 # ends — a pipe closing is not a session ending — and the leg would prove
5296 # its point by timing out. And it cannot ride the same write as the
5297 # payload: a chord ENDS its chunk (PrefixFilter), so bytes ahead of it in
5298 # the same read are dropped, which would race the assertion away.
5299 { printf 'hello \033[<64;10;5M world\n'; sleep 1; printf '\034d'; } |
5300 timeout 20 "$MUX" --sock "$SOCK36" > "$OUT.pipe" 2>&1
5301 RC=$?
5302 [ "$RC" -eq 0 ] || {
5303 echo "e2e FAIL: pipe stdin: the client exited $RC:"; cat "$OUT.pipe"; exit 1; }
5304 i=0
5305 until timeout 20 "$MUXA" capture --sock "$SOCK36" 2>&1 | grep -qF -- "world"; do
5306 i=$((i+1)); [ "$i" -lt 100 ] || {
5307 echo "e2e FAIL: pipe stdin: the piped line never reached the pty:"
5308 timeout 20 "$MUXA" capture --sock "$SOCK36"; exit 1; }
5309 sleep 0.1
5310 done
5311 timeout 20 "$MUXA" capture --sock "$SOCK36" > "$OUT.pipecap" 2>&1
5312 # The whole line, escape included. `cat` echoes control bytes as `^[`, so
5313 # what a grid can show of `\x1b[<64;10;5M` is `[<64;10;5M` — and a client
5314 # that ate the report leaves `hello world` with the middle missing.
5315 grep -qF -- "[<64;10;5M" "$OUT.pipecap" || {
5316 echo "e2e FAIL: pipe stdin: an SGR-shaped escape was filtered out of a stream nobody was reporting on:"
5317 cat "$OUT.pipecap"; exit 1; }
5318 assert_stopped "$SOCK36" "$D33PID" "pipe stdin" "$OUT.pipestop"
5319 D33PID=""
5320 ok "a client with no terminal of its own forwards SGR-shaped bytes untouched"
5321
5016 # The long-lived daemon has served every scenario that wanted it; stop it 5322 # The long-lived daemon has served every scenario that wanted it; stop it
5017 # NOW so its allocator verdict is written while the suite is still running 5323 # NOW so its allocator verdict is written while the suite is still running
5018 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain 5324 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
@@ -5028,7 +5334,7 @@ DPID=""
5028 5334
5029 # The pins. Literals, not variables set from counting something else — 5335 # The pins. Literals, not variables set from counting something else —
5030 # "assert the literal, never the constant the code under test reads" 5336 # "assert the literal, never the constant the code under test reads"
5031 # (decisions.md, M10). 39 scenario checkpoints; 35 convergence points. 5337 # (decisions.md, M10). 43 scenario checkpoints; 35 convergence points.
5032 # Anyone adding a scenario updates these by hand, on purpose. 5338 # Anyone adding a scenario updates these by hand, on purpose.
5033 # 5339 #
5034 # M18 added three checkpoints and no convergence points: its wall block 5340 # M18 added three checkpoints and no convergence points: its wall block
@@ -5070,9 +5376,23 @@ DPID=""
5070 # reason a third time: its subject is a ptyclient capture — one terminal 5376 # reason a third time: its subject is a ptyclient capture — one terminal
5071 # torn down and rebuilt around a child client — and the two grid checks it 5377 # torn down and rebuilt around a child client — and the two grid checks it
5072 # does make are `muxa status` sizes, which assert_converged does not speak 5378 # does make are `muxa status` sizes, which assert_converged does not speak
5073 # about at all. 5379 # about at all. The 40th is the wheel, and no convergence point because
5074 [ "$OK_COUNT" = "39" ] || { 5380 # what it asserts on is a SCROLLBACK view — rows the live grid does not
5075 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 39 —" 5381 # hold, which is the whole reason to scroll — plus the absence of bytes
5382 # from a grid, which converging two identical empties would say nothing
5383 # about. The 41st is its twin, the application that owns the mouse, and no
5384 # convergence point for the side-channel reason: its subject is the modes
5385 # the client wrote to a terminal and the echo of bytes it forwarded, and a
5386 # grid carries the second without carrying the first. The 42nd is the wheel
5387 # on the ALTERNATE screen, and no convergence point because the grid it
5388 # asserts on belongs to a pager holding that screen — assert_converged
5389 # replays a capture into an engine and compares, and a capture whose whole
5390 # subject is a full-screen application's redraws is one this suite has
5391 # never claimed to reproduce byte for byte. The 43rd is the client with no
5392 # terminal of its own, and no convergence point because it HAS no terminal:
5393 # there is no capture to converge, which is the entire point of the leg.
5394 [ "$OK_COUNT" = "43" ] || {
5395 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 43 —"
5076 echo " a scenario was added (update the pin) or silently lost" 5396 echo " a scenario was added (update the pin) or silently lost"
5077 exit 1 5397 exit 1
5078 } 5398 }
@@ -5080,4 +5400,4 @@ DPID=""
5080 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 5400 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
5081 exit 1 5401 exit 1
5082 } 5402 }
5083 echo "e2e OK (39 scenarios, 35 convergence points)" 5403 echo "e2e OK (43 scenarios, 35 convergence points)"