a73x

8a3c3ba9

test: 11_select moves to a file of its own

a73x   2026-08-26 19:12

Commit message
test: 11_select moves to a file of its own

the wall's own mouse, the drag that copies, and the mute offerer — 7 scenarios, 388 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -102,13 +102,13 @@ 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' 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'
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
109 # check that passes without running is the one failure a green tree cannot 109 # check that passes without running is the one failure a green tree cannot
110 # show. 110 # show.
111 E2E_NEEDS='03_side:01_boot' 111 E2E_NEEDS='03_side:01_boot 11_select:10_agent'
112 112
113 # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in 113 # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in
114 # while it is being written; the gate is still the whole suite, which is 114 # while it is being written; the gate is still the whole suite, which is
@@ -145,391 +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 wall's own mouse claim needs a daemon of its own for the same reason
149 # every other leg does: the wall scenario above stops its daemon on the way
150 # out, so there is nothing left to reuse.
151 SOCK50="${TMPDIR:-/tmp}/muxd-e2e-wallmouse-$$.sock"
152 defer_sock "$SOCK50"
153
154 # ---- the SOCK50 cluster: a daemon, two named sessions ------------------
155 #
156 # A daemon and two named sessions (a, b) shared by the click leg below and
157 # the drag legs after it. The wall-mouse filter this cluster used to open
158 # with is gone — every click and drag routes to the focused tile's own
159 # Core, hit-tested by rect — so what remains here is the setup: a daemon,
160 # a marker per session, and the tiles a `mux wall` over them paints.
161 start_daemon "$SOCK50" "$OUT.wmse.d" "wall-cluster daemon never bound" --shell /bin/sh
162 D48PID=$DPID
163 { printf 'printf "wma-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
164 timeout 40 "$MUX" --sock "$SOCK50" --session a > "$OUT.wmsa" 2> "$OUT.wmsa.err"
165 wait_grid "$SOCK50" "wma-pin" "wall cluster: session a's marker" a
166 { printf 'printf "wmb-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
167 timeout 40 "$MUX" --sock "$SOCK50" --session b > "$OUT.wmsb" 2> "$OUT.wmsb.err"
168 wait_grid "$SOCK50" "wmb-pin" "wall cluster: session b's marker" b
169
170 # ...and a click in a tile's rect FOCUSES it. Two tiles over 30 rows is two
171 # fifteen-row stripes, each opening with a label bar, so terminal row 19 is
172 # inside session b's content and terminal row 2 is inside session a's.
173 #
174 # Which tile the focus is ON is read off the DAEMON, never off the paint: a
175 # marker typed after the click lands in the focused session and `muxa
176 # capture` says which one that was. The report bytes themselves go through
177 # to the focused tile's Core unchanged, so the Core that owns the drag sees
178 # them.
179 #
180 # Two runs. The first clicks b's stripe from a fresh wall (focus starts on
181 # a) and proves the click moved the focus to b; the second focuses b with
182 # `Ctrl-\ 2` and clicks a's stripe, proving a click moves the focus the
183 # other way. A negative that cannot fail is not a test, so each carries the
184 # marker that MUST land in the focused session.
185 set +e
186 timeout 60 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.wmcap3" --err "$OUT.wmcap3.err" -- \
187 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc3" 2>&1 <<'EOF'
188 expect wmb-pin 20000
189 settle 700 20000
190 send \x1b[<0;3;19M\x1b[<0;3;19m
191 settle 700 20000
192 send printf 'wm-%s\\n' three\n
193 expect wm-three 15000
194 settle 400 15000
195 send \x1cd
196 waitexit 10000
197 EOF
198 RC=$?
199 set -e
200 [ "$RC" -eq 0 ] || {
201 echo "e2e FAIL: wall click: ptyclient leg exited $RC (did the click focus b?):"
202 cat "$OUT.wmpc3"; exit 1; }
203 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
204 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
205 grep -q "wm-three" "$OUT.wmfb" || {
206 echo "e2e FAIL: wall click: a click in session b's stripe did not focus it:"
207 cat "$OUT.wmfb"; exit 1; }
208 grep -q "wm-three" "$OUT.wmfa" && {
209 echo "e2e FAIL: wall click: the focus stayed on session a:"
210 cat "$OUT.wmfa"; exit 1; }
211
212 # The other direction: focus b explicitly, then click a's content and watch
213 # the focus come back to a.
214 set +e
215 timeout 60 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.wmcap4" --err "$OUT.wmcap4.err" -- \
216 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc4" 2>&1 <<'EOF'
217 expect wmb-pin 20000
218 settle 700 20000
219 send \x1c2
220 settle 700 20000
221 send \x1b[<0;3;2M\x1b[<0;3;2m
222 settle 700 20000
223 send printf 'wm-%s\\n' four\n
224 expect wm-four 15000
225 settle 400 15000
226 send \x1cd
227 waitexit 10000
228 EOF
229 RC=$?
230 set -e
231 [ "$RC" -eq 0 ] || {
232 echo "e2e FAIL: wall click: label-bar leg exited $RC:"
233 cat "$OUT.wmpc4"; exit 1; }
234 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
235 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
236 grep -q "wm-four" "$OUT.wmfa" || {
237 echo "e2e FAIL: wall click: a click in session a's stripe did not focus it:"
238 cat "$OUT.wmfa"; exit 1; }
239 grep -q "wm-four" "$OUT.wmfb" && {
240 echo "e2e FAIL: wall click: the click never moved the focus off session b:"
241 cat "$OUT.wmfb"; exit 1; }
242 ok "a click in a tile's rect focuses it, from wherever the focus was"
243
244 # A beside layout gives every tile the same rows, so a click that tests
245 # only the row hits the first present tile every time. A click in the
246 # RIGHT pane's content — column, not row — is what proves the column
247 # test in rectHit earns its keep: focus starts on a (the left pane) and
248 # the click must move it to b (the right pane).
249 set +e
250 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap5" --err "$OUT.wmcap5.err" -- \
251 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc5" 2>&1 <<'EOF'
252 expect wmb-pin 20000
253 settle 700 20000
254 send \x1b[<0;70;9M\x1b[<0;70;9m
255 settle 700 20000
256 send printf 'wm-%s\\n' five\n
257 expect wm-five 15000
258 settle 400 15000
259 send \x1cd
260 waitexit 10000
261 EOF
262 RC=$?
263 set -e
264 [ "$RC" -eq 0 ] || {
265 echo "e2e FAIL: wall click: beside leg exited $RC (did the column click focus b?):"
266 cat "$OUT.wmpc5"; exit 1; }
267 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
268 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
269 grep -q "wm-five" "$OUT.wmfb" || {
270 echo "e2e FAIL: wall click: a click in the right pane did not focus it:"
271 cat "$OUT.wmfb"; exit 1; }
272 grep -q "wm-five" "$OUT.wmfa" && {
273 echo "e2e FAIL: wall click: the focus stayed on the left pane:"
274 cat "$OUT.wmfa"; exit 1; }
275 ok "a click in a beside pane finds it by column, not just row"
276
277 # ---- a drag copies, and the copy leaves as OSC 52 ----------------------
278 #
279 # The first end-to-end proof that this feature does what it was asked for.
280 # The highlight legs above could not have one: a highlight is a PAINT, and
281 # these legs read the daemon rather than the screen. A copy is different —
282 # it lands on the CLIENT's tty as OSC 52, which the fixture holding that
283 # tty can read, so the assertion is the copied text itself.
284 #
285 # A one-tile wall, because that is where the geometry is knowable: `mux
286 # TARGET` is a wall of one tile whose rect is the whole terminal (no label
287 # bar), so a terminal row IS a grid row and the text can be put on one by
288 # name. The session is given its content with an explicit cursor address
289 # rather than by printing lines, so the row this drag crosses does not
290 # depend on where a prompt happened to leave the cursor.
291 #
292 # The typed line is `ZZ-%s` and the printed line is `ZZ-COPYME`, so the
293 # expect below cannot be satisfied by the shell ECHOING the command back.
294 #
295 # Columns 1 to 9 and no further: the prompt lands at column 10 on the same
296 # row, and a selection that reached it would copy the prompt too.
297 set +e
298 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.selcap" --err "$OUT.selcap.err" -- \
299 "$MUX" --sock "$SOCK50" --session s > "$OUT.selpc" 2>&1 <<'EOF'
300 settle 900 20000
301 send printf '\\033[2J\\033[9;1HZZ-%s' COPYME\n
302 expect ZZ-COPYME 15000
303 settle 700 20000
304 send \x1b[<0;3;9M\x1b[<0;3;9m
305 settle 500 15000
306 send \x1b[<0;1;9M\x1b[<32;9;9M\x1b[<0;9;9m
307 expect \x1b]52;c;WlotQ09QWU1F 15000
308 send \x1cd
309 waitexit 10000
310 EOF
311 RC=$?
312 set -e
313 [ "$RC" -eq 0 ] || {
314 echo "e2e FAIL: drag copy: ptyclient leg exited $RC (no OSC 52 with the dragged text?):"
315 cat "$OUT.selpc"; exit 1; }
316 # Exactly one copy on that tty, which is what makes the CLICK above a
317 # negative rather than decoration: a click highlights nothing, so it has
318 # nothing to copy, and a client that copied on every button release would
319 # have written two.
320 SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l)
321 [ "$SELCOPIES" = "1" ] || {
322 echo "e2e FAIL: drag copy: $SELCOPIES OSC 52 writes on the client's tty, expected 1"; exit 1; }
323 # ---- a drag over WIDE cells leaves the client's screen converged -------
324 #
325 # The gate the wide-cell shift walked straight through. Every other
326 # assertion this feature has is either on the daemon's state or on the
327 # ESCAPE BYTES the painter emitted — and a row that emits the same wide
328 # glyph TWICE still contains every substring the painter is supposed to
329 # emit, while landing a column wider than the grid. Only a check that
330 # judges the client's screen against the daemon's own, through a separate
331 # engine, can see that: which is assert_converged, and which no selection
332 # leg used until this one.
333 #
334 # Both edges of the drag land inside a wide cell on purpose. Row 3 is
335 # `w漢字x-WIDEMARK`, so column 3 is the spacer tail of 漢 and column 4 the
336 # first half of 字 — the two ways a span can cut a wide cell in half.
337 #
338 # The DEFAULT session, unlike the copy leg above: `converged_quiet` dumps
339 # the daemon's default grid, so a leg that named a session would diff this
340 # client's screen against a grid it never attached to and fail for a reason
341 # that has nothing to do with wide cells.
342 #
343 # Asserted to fail, per the wan.sh rule: with `snapWide` stubbed out to
344 # return its arguments, this leg renders `w 漢字x-WIDEMARK` against the
345 # daemon's `w漢字x-WIDEMARK` — the extra column, which is the bug.
346 #
347 # The click after the drag is what makes the STYLED half of this check
348 # mean something. A live highlight is reverse video the daemon has no
349 # notion of, so a leg that detached still holding one would diverge on
350 # style every time and could only ever assert the plain grid. Clearing it
351 # first buys the byte-exact comparison, and with it the assertion that a
352 # highlight leaves nothing behind when it goes — the overlay-never-becomes-
353 # state rule, on the only overlay that paints through the replica.
354 set +e
355 timeout 60 "$PTYCLIENT" --cols 40 --rows 12 --out "$OUT.swcap" --err "$OUT.swcap.err" -- \
356 "$MUX" --sock "$SOCK50" > "$OUT.swpc" 2>&1 <<'EOF'
357 settle 900 20000
358 send printf '\\033[2J\\033[3;1Hw\\346\\274\\242\\345\\255\\227x-WIDEMARK'\n
359 expect WIDEMARK 15000
360 settle 700 20000
361 send \x1b[<0;3;3M\x1b[<32;4;3M\x1b[<0;4;3m
362 settle 700 20000
363 send \x1b[<0;1;3M\x1b[<0;1;3m
364 settle 700 20000
365 send \x1cd
366 waitexit 10000
367 EOF
368 RC=$?
369 set -e
370 [ "$RC" -eq 0 ] || {
371 echo "e2e FAIL: wide drag: ptyclient leg exited $RC:"
372 # The client's own stderr as well as the fixture's: the fixture can only
373 # report THAT the client closed the pty, and the reason is over here.
374 cat "$OUT.swpc" "$OUT.swcap.err"; exit 1; }
375 assert_converged "$OUT.swcap" "$SOCK50" "wide drag" 40 12
376 ok "a drag across wide cells leaves the client's screen converged"
377
378 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
379 D48PID=""
380 ok "a drag copies on release, and a click copies nothing"
381
382 # The in-band resize leg: a daemon of its own, so the grid it reports on is
383 # the one this leg sized.
384 SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock"
385 defer_sock "$SOCK51"
386
387 # --- a resize is told in-band to an app that asked for it (mode 2048) ------
388 #
389 # nvim 0.11+ asks the terminal for in-band size reports (DECRQM 2048), and
390 # the engine says "recognised" — so nvim turns the mode on and from then on
391 # IGNORES SIGWINCH, waiting for `CSI 48;rows;cols;h;w t` on its input. The
392 # daemon never sent one, and a resized session stayed painted at its old
393 # size until nvim was restarted (found by hand, 2026-08-22, a drag-resized
394 # window full of two overlaid screens).
395 #
396 # The witness needs no nvim: `cat -v` prints whatever arrives on its stdin,
397 # so the report — INPUT to the app — lands on the grid as text. Two reports
398 # are asserted, because they are two code paths: the one the mode-set
399 # itself owes (the app sizes itself from it), and the one each resize owes.
400 start_daemon "$SOCK51" "$OUT.inband.d" "in-band daemon never bound" --shell /bin/sh
401 D51PID=$DPID
402 set +e
403 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.inband" --err "$OUT.inband.err" -- \
404 "$MUX" --sock "$SOCK51" > "$OUT.inband.log" 2>&1 <<'EOF'
405 expect \x1b[?1049h 15000
406 settle 400 15000
407 send printf '\\033[?2048h'; cat -v\n
408 expect [48;30;100;0;0t 10000
409 resize 120 40
410 expect [48;40;120;0;0t 10000
411 settle 500 15000
412 send \x1c\x1c
413 waitexit 10000
414 EOF
415 RC=$?
416 set -e
417 [ "$RC" -eq 0 ] || {
418 echo "e2e FAIL: in-band size: the leg exited $RC:"; cat "$OUT.inband.log"; exit 1; }
419 assert_stopped "$SOCK51" "$D51PID" "in-band size" "$OUT.inbstop"
420 D51PID=""
421 ok "a resize is reported in-band to an app that turned on mode 2048"
422
423 # --- The default socket path refuses to be guessed. Field incident: a tmux
424 # server started before logind exported XDG_RUNTIME_DIR handed every pane an
425 # environment without it, so a pane's `muxd stop` looked at the /tmp guess
426 # while the daemon a pane had started owned $XDG_RUNTIME_DIR/muxd.sock — one
427 # uid, one box, two daemons. `env -u` rather than the harness environment:
428 # on a box that HAS the variable the unset case is otherwise never taken.
429 # No convergence: nothing gets far enough to attach.
430 for BIN in "$MUXD stats" "$MUX"; do
431 # shellcheck disable=SC2086 # the two-word muxd form is deliberate
432 if env -u XDG_RUNTIME_DIR $BIN > "$OUT.nort" 2>&1; then
433 echo "e2e FAIL: '$BIN' with no XDG_RUNTIME_DIR did not refuse"; exit 1
434 fi
435 grep -q 'XDG_RUNTIME_DIR is unset' "$OUT.nort" || {
436 echo "e2e FAIL: '$BIN' refusal does not name the variable:"; cat "$OUT.nort"; exit 1; }
437 grep -q -- '--sock' "$OUT.nort" || {
438 echo "e2e FAIL: '$BIN' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; }
439 done
440 # muxa answers agents in JSON, so its refusal has to arrive as a reply.
441 env -u XDG_RUNTIME_DIR "$MUXA" status > "$OUT.nort.a" 2>&1 && {
442 echo "e2e FAIL: muxa with no XDG_RUNTIME_DIR did not refuse"; exit 1; }
443 grep -q '"error":"no default socket path"' "$OUT.nort.a" || {
444 echo "e2e FAIL: muxa refusal is not a JSON error:"; cat "$OUT.nort.a"; exit 1; }
445 # The socketless verbs must survive the same environment: --version is how an
446 # operator checks whether the scp landed, and it owes nothing to a socket.
447 env -u XDG_RUNTIME_DIR "$MUXD" --version | grep -q '^muxd 0\.' || {
448 echo "e2e FAIL: muxd --version needs XDG_RUNTIME_DIR"; exit 1; }
449 env -u XDG_RUNTIME_DIR "$MUX" --version | grep -q '^mux 0\.' || {
450 echo "e2e FAIL: mux --version needs XDG_RUNTIME_DIR"; exit 1; }
451 rm -f "$OUT.nort" "$OUT.nort.a"
452 ok "no XDG_RUNTIME_DIR: the default path is refused by name, --version is not"
453
454 # --- an offerer that cannot answer is hung up on, and ssh falls through ----
455 #
456 # An agent_offer is a declaration, not a capability (decisions.md
457 # 2026-08-22). The offerer here is a real `mux -A` whose agent is a real
458 # ssh-agent under SIGSTOP: the kernel still completes the connect from the
459 # listen backlog, so the -A preflight — which fails OPEN on silence, by
460 # design — lets the client through, and every request the session forwards
461 # to it then vanishes. That is the mute peer the daemon's answer clock
462 # exists for, produced with nothing but the binaries this suite already
463 # requires.
464 #
465 # Measured by hand for the issue: ssh-add against a socket that accepts and
466 # never replies blocks past 8s, against one that accepts and closes it
467 # fails in 2ms. So the two things asserted are the two halves of the fix:
468 # the FIRST ssh-add comes back inside `timeout 8` (the daemon closed the
469 # channel for the client that would not), and the SECOND is refused in
470 # milliseconds (the offer was taken away, so nobody routes to the mute
471 # client again and ssh pays nothing).
472 start_daemon "$SOCK48" "$OUT.agtmute.d" "agent-mute daemon never bound" --shell /bin/sh
473 D42PID=$DPID
474 ssh-agent -a "$AGENT48" > "$OUT.agtmute.env" 2>&1
475 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agtmute.env")
476 defer_kill "$AGENT48PID"
477 [ -n "$AGENT48PID" ] || {
478 echo "e2e FAIL: agent-mute: ssh-agent printed no pid to stop:"
479 cat "$OUT.agtmute.env"; exit 1; }
480 kill -STOP "$AGENT48PID"
481 set +e
482 SSH_AUTH_SOCK="$AGENT48" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
483 --out "$OUT.agtmute" --err "$OUT.agtmute.err" \
484 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agtmute.log" 2>&1 <<'EOF'
485 expect \x1b[?1049h 15000
486 settle 400 15000
487 send timeout 8 ssh-add -l; echo mu""te1=$?\n
488 expect mute1= 20000
489 send S=$(date +%s%N); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( ($(date +%s%N)-S)/1000000 ))\n
490 expect mute2= 15000
491 settle 400 15000
492 send exit\n
493 waitexit 10000
494 EOF
495 RC=$?
496 set -e
497 # The agent is resumed before it is killed: a stopped process ignores
498 # SIGTERM until it runs again, and the trap's plain kill would leave it.
499 kill -CONT "$AGENT48PID" 2>/dev/null || true
500 softkill "$AGENT48PID" || true
501 AGENT48PID=""
502 # The needles are split in the typed lines (`mu""te1=`) so an expect can
503 # only be satisfied by the command's OUTPUT, never by the pty's echo of the
504 # command — with an 8s wait in play, matching the echo runs the script
505 # ahead into a shell that is still busy. The wedge is checked before the
506 # exit code, because a wedged second ssh-add also swallows the `exit`.
507 grep -q "mute1=124" "$OUT.agtmute" && {
508 echo "e2e FAIL: agent-mute: the first ssh-add wedged for the whole 8s — the"
509 echo " daemon never hung up on the client that would not answer:"
510 cat -v "$OUT.agtmute"; exit 1; }
511 [ "$RC" -eq 0 ] || {
512 echo "e2e FAIL: agent-mute: the leg exited $RC:"
513 cat "$OUT.agtmute.log"; exit 1; }
514 grep -qE "mute1=[1-9]" "$OUT.agtmute" || {
515 echo "e2e FAIL: agent-mute: the first ssh-add did not fail at all — something"
516 echo " answered for a stopped agent:"
517 cat -v "$OUT.agtmute"; exit 1; }
518 MUTE2MS=$(sed -n 's/.*mute2=[0-9]* ms=\([0-9]*\).*/\1/p' "$OUT.agtmute" | head -1)
519 [ -n "$MUTE2MS" ] || {
520 echo "e2e FAIL: agent-mute: the second ssh-add reported no timing:"
521 cat -v "$OUT.agtmute"; exit 1; }
522 # Well under the 5s bound, not merely under 8s: a refusal is the dial being
523 # closed at accept, and the only way to take 5s here is to be routed to the
524 # mute client again, which means the offer was not cleared.
525 [ "$MUTE2MS" -lt 2000 ] || {
526 echo "e2e FAIL: agent-mute: the second ssh-add took ${MUTE2MS}ms — the mute"
527 echo " client kept its offer and ssh paid the bound again"
528 cat -v "$OUT.agtmute"; exit 1; }
529 wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should follow"
530 D42PID=""
531 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)"
532
533 # The scrollback-rect leg: a focused tile's history page must own only its 148 # The scrollback-rect leg: a focused tile's history page must own only its
534 # sub-rect, so its daemon gets a socket of its own for the grid-size 149 # sub-rect, so its daemon gets a socket of its own for the grid-size
535 # reason every wall leg has. 150 # reason every wall leg has.
test/e2e_11_select.sh
Old New
@@ -0,0 +1,388 @@
1 # shellcheck shell=sh
2 # e2e_11_select.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 wall's own mouse claim needs a daemon of its own for the same reason
6 # every other leg does: the wall scenario above stops its daemon on the way
7 # out, so there is nothing left to reuse.
8 SOCK50="${TMPDIR:-/tmp}/muxd-e2e-wallmouse-$$.sock"
9 defer_sock "$SOCK50"
10
11 # ---- the SOCK50 cluster: a daemon, two named sessions ------------------
12 #
13 # A daemon and two named sessions (a, b) shared by the click leg below and
14 # the drag legs after it. The wall-mouse filter this cluster used to open
15 # with is gone — every click and drag routes to the focused tile's own
16 # Core, hit-tested by rect — so what remains here is the setup: a daemon,
17 # a marker per session, and the tiles a `mux wall` over them paints.
18 start_daemon "$SOCK50" "$OUT.wmse.d" "wall-cluster daemon never bound" --shell /bin/sh
19 D48PID=$DPID
20 { printf 'printf "wma-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
21 timeout 40 "$MUX" --sock "$SOCK50" --session a > "$OUT.wmsa" 2> "$OUT.wmsa.err"
22 wait_grid "$SOCK50" "wma-pin" "wall cluster: session a's marker" a
23 { printf 'printf "wmb-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
24 timeout 40 "$MUX" --sock "$SOCK50" --session b > "$OUT.wmsb" 2> "$OUT.wmsb.err"
25 wait_grid "$SOCK50" "wmb-pin" "wall cluster: session b's marker" b
26
27 # ...and a click in a tile's rect FOCUSES it. Two tiles over 30 rows is two
28 # fifteen-row stripes, each opening with a label bar, so terminal row 19 is
29 # inside session b's content and terminal row 2 is inside session a's.
30 #
31 # Which tile the focus is ON is read off the DAEMON, never off the paint: a
32 # marker typed after the click lands in the focused session and `muxa
33 # capture` says which one that was. The report bytes themselves go through
34 # to the focused tile's Core unchanged, so the Core that owns the drag sees
35 # them.
36 #
37 # Two runs. The first clicks b's stripe from a fresh wall (focus starts on
38 # a) and proves the click moved the focus to b; the second focuses b with
39 # `Ctrl-\ 2` and clicks a's stripe, proving a click moves the focus the
40 # other way. A negative that cannot fail is not a test, so each carries the
41 # marker that MUST land in the focused session.
42 set +e
43 timeout 60 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.wmcap3" --err "$OUT.wmcap3.err" -- \
44 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc3" 2>&1 <<'EOF'
45 expect wmb-pin 20000
46 settle 700 20000
47 send \x1b[<0;3;19M\x1b[<0;3;19m
48 settle 700 20000
49 send printf 'wm-%s\\n' three\n
50 expect wm-three 15000
51 settle 400 15000
52 send \x1cd
53 waitexit 10000
54 EOF
55 RC=$?
56 set -e
57 [ "$RC" -eq 0 ] || {
58 echo "e2e FAIL: wall click: ptyclient leg exited $RC (did the click focus b?):"
59 cat "$OUT.wmpc3"; exit 1; }
60 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
61 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
62 grep -q "wm-three" "$OUT.wmfb" || {
63 echo "e2e FAIL: wall click: a click in session b's stripe did not focus it:"
64 cat "$OUT.wmfb"; exit 1; }
65 grep -q "wm-three" "$OUT.wmfa" && {
66 echo "e2e FAIL: wall click: the focus stayed on session a:"
67 cat "$OUT.wmfa"; exit 1; }
68
69 # The other direction: focus b explicitly, then click a's content and watch
70 # the focus come back to a.
71 set +e
72 timeout 60 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.wmcap4" --err "$OUT.wmcap4.err" -- \
73 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc4" 2>&1 <<'EOF'
74 expect wmb-pin 20000
75 settle 700 20000
76 send \x1c2
77 settle 700 20000
78 send \x1b[<0;3;2M\x1b[<0;3;2m
79 settle 700 20000
80 send printf 'wm-%s\\n' four\n
81 expect wm-four 15000
82 settle 400 15000
83 send \x1cd
84 waitexit 10000
85 EOF
86 RC=$?
87 set -e
88 [ "$RC" -eq 0 ] || {
89 echo "e2e FAIL: wall click: label-bar leg exited $RC:"
90 cat "$OUT.wmpc4"; exit 1; }
91 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
92 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
93 grep -q "wm-four" "$OUT.wmfa" || {
94 echo "e2e FAIL: wall click: a click in session a's stripe did not focus it:"
95 cat "$OUT.wmfa"; exit 1; }
96 grep -q "wm-four" "$OUT.wmfb" && {
97 echo "e2e FAIL: wall click: the click never moved the focus off session b:"
98 cat "$OUT.wmfb"; exit 1; }
99 ok "a click in a tile's rect focuses it, from wherever the focus was"
100
101 # A beside layout gives every tile the same rows, so a click that tests
102 # only the row hits the first present tile every time. A click in the
103 # RIGHT pane's content — column, not row — is what proves the column
104 # test in rectHit earns its keep: focus starts on a (the left pane) and
105 # the click must move it to b (the right pane).
106 set +e
107 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap5" --err "$OUT.wmcap5.err" -- \
108 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc5" 2>&1 <<'EOF'
109 expect wmb-pin 20000
110 settle 700 20000
111 send \x1b[<0;70;9M\x1b[<0;70;9m
112 settle 700 20000
113 send printf 'wm-%s\\n' five\n
114 expect wm-five 15000
115 settle 400 15000
116 send \x1cd
117 waitexit 10000
118 EOF
119 RC=$?
120 set -e
121 [ "$RC" -eq 0 ] || {
122 echo "e2e FAIL: wall click: beside leg exited $RC (did the column click focus b?):"
123 cat "$OUT.wmpc5"; exit 1; }
124 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
125 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
126 grep -q "wm-five" "$OUT.wmfb" || {
127 echo "e2e FAIL: wall click: a click in the right pane did not focus it:"
128 cat "$OUT.wmfb"; exit 1; }
129 grep -q "wm-five" "$OUT.wmfa" && {
130 echo "e2e FAIL: wall click: the focus stayed on the left pane:"
131 cat "$OUT.wmfa"; exit 1; }
132 ok "a click in a beside pane finds it by column, not just row"
133
134 # ---- a drag copies, and the copy leaves as OSC 52 ----------------------
135 #
136 # The first end-to-end proof that this feature does what it was asked for.
137 # The highlight legs above could not have one: a highlight is a PAINT, and
138 # these legs read the daemon rather than the screen. A copy is different —
139 # it lands on the CLIENT's tty as OSC 52, which the fixture holding that
140 # tty can read, so the assertion is the copied text itself.
141 #
142 # A one-tile wall, because that is where the geometry is knowable: `mux
143 # TARGET` is a wall of one tile whose rect is the whole terminal (no label
144 # bar), so a terminal row IS a grid row and the text can be put on one by
145 # name. The session is given its content with an explicit cursor address
146 # rather than by printing lines, so the row this drag crosses does not
147 # depend on where a prompt happened to leave the cursor.
148 #
149 # The typed line is `ZZ-%s` and the printed line is `ZZ-COPYME`, so the
150 # expect below cannot be satisfied by the shell ECHOING the command back.
151 #
152 # Columns 1 to 9 and no further: the prompt lands at column 10 on the same
153 # row, and a selection that reached it would copy the prompt too.
154 set +e
155 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.selcap" --err "$OUT.selcap.err" -- \
156 "$MUX" --sock "$SOCK50" --session s > "$OUT.selpc" 2>&1 <<'EOF'
157 settle 900 20000
158 send printf '\\033[2J\\033[9;1HZZ-%s' COPYME\n
159 expect ZZ-COPYME 15000
160 settle 700 20000
161 send \x1b[<0;3;9M\x1b[<0;3;9m
162 settle 500 15000
163 send \x1b[<0;1;9M\x1b[<32;9;9M\x1b[<0;9;9m
164 expect \x1b]52;c;WlotQ09QWU1F 15000
165 send \x1cd
166 waitexit 10000
167 EOF
168 RC=$?
169 set -e
170 [ "$RC" -eq 0 ] || {
171 echo "e2e FAIL: drag copy: ptyclient leg exited $RC (no OSC 52 with the dragged text?):"
172 cat "$OUT.selpc"; exit 1; }
173 # Exactly one copy on that tty, which is what makes the CLICK above a
174 # negative rather than decoration: a click highlights nothing, so it has
175 # nothing to copy, and a client that copied on every button release would
176 # have written two.
177 SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l)
178 [ "$SELCOPIES" = "1" ] || {
179 echo "e2e FAIL: drag copy: $SELCOPIES OSC 52 writes on the client's tty, expected 1"; exit 1; }
180 # ---- a drag over WIDE cells leaves the client's screen converged -------
181 #
182 # The gate the wide-cell shift walked straight through. Every other
183 # assertion this feature has is either on the daemon's state or on the
184 # ESCAPE BYTES the painter emitted — and a row that emits the same wide
185 # glyph TWICE still contains every substring the painter is supposed to
186 # emit, while landing a column wider than the grid. Only a check that
187 # judges the client's screen against the daemon's own, through a separate
188 # engine, can see that: which is assert_converged, and which no selection
189 # leg used until this one.
190 #
191 # Both edges of the drag land inside a wide cell on purpose. Row 3 is
192 # `w漢字x-WIDEMARK`, so column 3 is the spacer tail of 漢 and column 4 the
193 # first half of 字 — the two ways a span can cut a wide cell in half.
194 #
195 # The DEFAULT session, unlike the copy leg above: `converged_quiet` dumps
196 # the daemon's default grid, so a leg that named a session would diff this
197 # client's screen against a grid it never attached to and fail for a reason
198 # that has nothing to do with wide cells.
199 #
200 # Asserted to fail, per the wan.sh rule: with `snapWide` stubbed out to
201 # return its arguments, this leg renders `w 漢字x-WIDEMARK` against the
202 # daemon's `w漢字x-WIDEMARK` — the extra column, which is the bug.
203 #
204 # The click after the drag is what makes the STYLED half of this check
205 # mean something. A live highlight is reverse video the daemon has no
206 # notion of, so a leg that detached still holding one would diverge on
207 # style every time and could only ever assert the plain grid. Clearing it
208 # first buys the byte-exact comparison, and with it the assertion that a
209 # highlight leaves nothing behind when it goes — the overlay-never-becomes-
210 # state rule, on the only overlay that paints through the replica.
211 set +e
212 timeout 60 "$PTYCLIENT" --cols 40 --rows 12 --out "$OUT.swcap" --err "$OUT.swcap.err" -- \
213 "$MUX" --sock "$SOCK50" > "$OUT.swpc" 2>&1 <<'EOF'
214 settle 900 20000
215 send printf '\\033[2J\\033[3;1Hw\\346\\274\\242\\345\\255\\227x-WIDEMARK'\n
216 expect WIDEMARK 15000
217 settle 700 20000
218 send \x1b[<0;3;3M\x1b[<32;4;3M\x1b[<0;4;3m
219 settle 700 20000
220 send \x1b[<0;1;3M\x1b[<0;1;3m
221 settle 700 20000
222 send \x1cd
223 waitexit 10000
224 EOF
225 RC=$?
226 set -e
227 [ "$RC" -eq 0 ] || {
228 echo "e2e FAIL: wide drag: ptyclient leg exited $RC:"
229 # The client's own stderr as well as the fixture's: the fixture can only
230 # report THAT the client closed the pty, and the reason is over here.
231 cat "$OUT.swpc" "$OUT.swcap.err"; exit 1; }
232 assert_converged "$OUT.swcap" "$SOCK50" "wide drag" 40 12
233 ok "a drag across wide cells leaves the client's screen converged"
234
235 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
236 D48PID=""
237 ok "a drag copies on release, and a click copies nothing"
238
239 # The in-band resize leg: a daemon of its own, so the grid it reports on is
240 # the one this leg sized.
241 SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock"
242 defer_sock "$SOCK51"
243
244 # --- a resize is told in-band to an app that asked for it (mode 2048) ------
245 #
246 # nvim 0.11+ asks the terminal for in-band size reports (DECRQM 2048), and
247 # the engine says "recognised" — so nvim turns the mode on and from then on
248 # IGNORES SIGWINCH, waiting for `CSI 48;rows;cols;h;w t` on its input. The
249 # daemon never sent one, and a resized session stayed painted at its old
250 # size until nvim was restarted (found by hand, 2026-08-22, a drag-resized
251 # window full of two overlaid screens).
252 #
253 # The witness needs no nvim: `cat -v` prints whatever arrives on its stdin,
254 # so the report — INPUT to the app — lands on the grid as text. Two reports
255 # are asserted, because they are two code paths: the one the mode-set
256 # itself owes (the app sizes itself from it), and the one each resize owes.
257 start_daemon "$SOCK51" "$OUT.inband.d" "in-band daemon never bound" --shell /bin/sh
258 D51PID=$DPID
259 set +e
260 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.inband" --err "$OUT.inband.err" -- \
261 "$MUX" --sock "$SOCK51" > "$OUT.inband.log" 2>&1 <<'EOF'
262 expect \x1b[?1049h 15000
263 settle 400 15000
264 send printf '\\033[?2048h'; cat -v\n
265 expect [48;30;100;0;0t 10000
266 resize 120 40
267 expect [48;40;120;0;0t 10000
268 settle 500 15000
269 send \x1c\x1c
270 waitexit 10000
271 EOF
272 RC=$?
273 set -e
274 [ "$RC" -eq 0 ] || {
275 echo "e2e FAIL: in-band size: the leg exited $RC:"; cat "$OUT.inband.log"; exit 1; }
276 assert_stopped "$SOCK51" "$D51PID" "in-band size" "$OUT.inbstop"
277 D51PID=""
278 ok "a resize is reported in-band to an app that turned on mode 2048"
279
280 # --- The default socket path refuses to be guessed. Field incident: a tmux
281 # server started before logind exported XDG_RUNTIME_DIR handed every pane an
282 # environment without it, so a pane's `muxd stop` looked at the /tmp guess
283 # while the daemon a pane had started owned $XDG_RUNTIME_DIR/muxd.sock — one
284 # uid, one box, two daemons. `env -u` rather than the harness environment:
285 # on a box that HAS the variable the unset case is otherwise never taken.
286 # No convergence: nothing gets far enough to attach.
287 for BIN in "$MUXD stats" "$MUX"; do
288 # shellcheck disable=SC2086 # the two-word muxd form is deliberate
289 if env -u XDG_RUNTIME_DIR $BIN > "$OUT.nort" 2>&1; then
290 echo "e2e FAIL: '$BIN' with no XDG_RUNTIME_DIR did not refuse"; exit 1
291 fi
292 grep -q 'XDG_RUNTIME_DIR is unset' "$OUT.nort" || {
293 echo "e2e FAIL: '$BIN' refusal does not name the variable:"; cat "$OUT.nort"; exit 1; }
294 grep -q -- '--sock' "$OUT.nort" || {
295 echo "e2e FAIL: '$BIN' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; }
296 done
297 # muxa answers agents in JSON, so its refusal has to arrive as a reply.
298 env -u XDG_RUNTIME_DIR "$MUXA" status > "$OUT.nort.a" 2>&1 && {
299 echo "e2e FAIL: muxa with no XDG_RUNTIME_DIR did not refuse"; exit 1; }
300 grep -q '"error":"no default socket path"' "$OUT.nort.a" || {
301 echo "e2e FAIL: muxa refusal is not a JSON error:"; cat "$OUT.nort.a"; exit 1; }
302 # The socketless verbs must survive the same environment: --version is how an
303 # operator checks whether the scp landed, and it owes nothing to a socket.
304 env -u XDG_RUNTIME_DIR "$MUXD" --version | grep -q '^muxd 0\.' || {
305 echo "e2e FAIL: muxd --version needs XDG_RUNTIME_DIR"; exit 1; }
306 env -u XDG_RUNTIME_DIR "$MUX" --version | grep -q '^mux 0\.' || {
307 echo "e2e FAIL: mux --version needs XDG_RUNTIME_DIR"; exit 1; }
308 rm -f "$OUT.nort" "$OUT.nort.a"
309 ok "no XDG_RUNTIME_DIR: the default path is refused by name, --version is not"
310
311 # --- an offerer that cannot answer is hung up on, and ssh falls through ----
312 #
313 # An agent_offer is a declaration, not a capability (decisions.md
314 # 2026-08-22). The offerer here is a real `mux -A` whose agent is a real
315 # ssh-agent under SIGSTOP: the kernel still completes the connect from the
316 # listen backlog, so the -A preflight — which fails OPEN on silence, by
317 # design — lets the client through, and every request the session forwards
318 # to it then vanishes. That is the mute peer the daemon's answer clock
319 # exists for, produced with nothing but the binaries this suite already
320 # requires.
321 #
322 # Measured by hand for the issue: ssh-add against a socket that accepts and
323 # never replies blocks past 8s, against one that accepts and closes it
324 # fails in 2ms. So the two things asserted are the two halves of the fix:
325 # the FIRST ssh-add comes back inside `timeout 8` (the daemon closed the
326 # channel for the client that would not), and the SECOND is refused in
327 # milliseconds (the offer was taken away, so nobody routes to the mute
328 # client again and ssh pays nothing).
329 start_daemon "$SOCK48" "$OUT.agtmute.d" "agent-mute daemon never bound" --shell /bin/sh
330 D42PID=$DPID
331 ssh-agent -a "$AGENT48" > "$OUT.agtmute.env" 2>&1
332 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agtmute.env")
333 defer_kill "$AGENT48PID"
334 [ -n "$AGENT48PID" ] || {
335 echo "e2e FAIL: agent-mute: ssh-agent printed no pid to stop:"
336 cat "$OUT.agtmute.env"; exit 1; }
337 kill -STOP "$AGENT48PID"
338 set +e
339 SSH_AUTH_SOCK="$AGENT48" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
340 --out "$OUT.agtmute" --err "$OUT.agtmute.err" \
341 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agtmute.log" 2>&1 <<'EOF'
342 expect \x1b[?1049h 15000
343 settle 400 15000
344 send timeout 8 ssh-add -l; echo mu""te1=$?\n
345 expect mute1= 20000
346 send S=$(date +%s%N); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( ($(date +%s%N)-S)/1000000 ))\n
347 expect mute2= 15000
348 settle 400 15000
349 send exit\n
350 waitexit 10000
351 EOF
352 RC=$?
353 set -e
354 # The agent is resumed before it is killed: a stopped process ignores
355 # SIGTERM until it runs again, and the trap's plain kill would leave it.
356 kill -CONT "$AGENT48PID" 2>/dev/null || true
357 softkill "$AGENT48PID" || true
358 AGENT48PID=""
359 # The needles are split in the typed lines (`mu""te1=`) so an expect can
360 # only be satisfied by the command's OUTPUT, never by the pty's echo of the
361 # command — with an 8s wait in play, matching the echo runs the script
362 # ahead into a shell that is still busy. The wedge is checked before the
363 # exit code, because a wedged second ssh-add also swallows the `exit`.
364 grep -q "mute1=124" "$OUT.agtmute" && {
365 echo "e2e FAIL: agent-mute: the first ssh-add wedged for the whole 8s — the"
366 echo " daemon never hung up on the client that would not answer:"
367 cat -v "$OUT.agtmute"; exit 1; }
368 [ "$RC" -eq 0 ] || {
369 echo "e2e FAIL: agent-mute: the leg exited $RC:"
370 cat "$OUT.agtmute.log"; exit 1; }
371 grep -qE "mute1=[1-9]" "$OUT.agtmute" || {
372 echo "e2e FAIL: agent-mute: the first ssh-add did not fail at all — something"
373 echo " answered for a stopped agent:"
374 cat -v "$OUT.agtmute"; exit 1; }
375 MUTE2MS=$(sed -n 's/.*mute2=[0-9]* ms=\([0-9]*\).*/\1/p' "$OUT.agtmute" | head -1)
376 [ -n "$MUTE2MS" ] || {
377 echo "e2e FAIL: agent-mute: the second ssh-add reported no timing:"
378 cat -v "$OUT.agtmute"; exit 1; }
379 # Well under the 5s bound, not merely under 8s: a refusal is the dial being
380 # closed at accept, and the only way to take 5s here is to be routed to the
381 # mute client again, which means the offer was not cleared.
382 [ "$MUTE2MS" -lt 2000 ] || {
383 echo "e2e FAIL: agent-mute: the second ssh-add took ${MUTE2MS}ms — the mute"
384 echo " client kept its offer and ssh paid the bound again"
385 cat -v "$OUT.agtmute"; exit 1; }
386 wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should follow"
387 D42PID=""
388 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)"