a73x

96378b1a

test: the wheel works in a zoomed tile, and the demote gives it back

a73x   2026-08-20 04:19

Commit message
test: the wheel works in a zoomed tile, and the demote gives it back

Three scenarios, pin 48 -> 51.

The zoomed wheel borrows scenario 42's arithmetic verbatim (eight notches
= 24 rows = one screenful on 80x24) so a difference here is the wall's and
not the geometry's. Its lines are wln1..wln100 rather than bare numbers
because a wall's label bar carries the socket path, which carries the
run's pid: a bare 60 in a pid would pass the money assertion for no
reason.

The demoted half rides the same capture, anchored on a positive the same
leg proves, and counts the mouse-off writes: exactly two on this run, the
demote and the wall's exit. One is a wall that left the user's terminal
reporting clicks into their shell.

The app-owns-mouse leg is the one that forced code: a promote is answered
by resyncSnapshot, which carries no term_modes, so the handover cannot
come from a frame. claimTerminal level-sets what the Core already knew
from watching every mode sample while the tile was a stripe.

src/interact.zig
Old New
@@ -2551,6 +2551,18 @@ test "interact: a borrowed terminal's claim is the client's, and every teardown
2551 } 2551 }
2552 } 2552 }
2553 2553
2554 /// Everything waiting on a NON-BLOCKING pipe, as one slice. One escape
2555 /// sequence per `write` is how these are emitted, so a reader that took the
2556 /// first write for the whole answer would pin half a pair.
2557 fn drainPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
2558 var n: usize = 0;
2559 while (std.posix.read(fd, buf[n..])) |got| {
2560 if (got == 0) break;
2561 n += got;
2562 } else |_| {}
2563 return buf[0..n];
2564 }
2565
2554 test "interact: a promote takes the mouse, a demote gives it back, a demote twice writes nothing" { 2566 test "interact: a promote takes the mouse, a demote gives it back, a demote twice writes nothing" {
2555 const alloc = std.testing.allocator; 2567 const alloc = std.testing.allocator;
2556 // Non-blocking, so a half of the pair that stopped writing FAILS here 2568 // Non-blocking, so a half of the pair that stopped writing FAILS here
@@ -2569,11 +2581,18 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic
2569 core.is_tty = true; 2581 core.is_tty = true;
2570 defer core.deinit(); 2582 defer core.deinit();
2571 2583
2572 var buf: [256]u8 = undefined; 2584 var buf: [512]u8 = undefined;
2573 2585
2574 try std.testing.expect(core.claimTerminal()); 2586 try std.testing.expect(core.claimTerminal());
2575 try std.testing.expectEqual(Claim.session, core.claim); 2587 try std.testing.expectEqual(Claim.session, core.claim);
2576 try std.testing.expectEqualStrings(session_claim, buf[0..try std.posix.read(p[0], &buf)]); 2588 // Two writes, drained as one: the claim itself and then the SESSION's
2589 // modes level-set on top of it.
2590 const claimed = drainPipe(p[0], &buf);
2591 try std.testing.expect(std.mem.startsWith(u8, claimed, session_claim));
2592 // A mode the claim never names, so its presence can only be the
2593 // level-set — the half a promote needs because `resyncSnapshot` answers
2594 // a resize with no `term_modes` at all.
2595 try std.testing.expect(std.mem.indexOf(u8, claimed, "\x1b[?1002l") != null);
2577 2596
2578 // A promote onto the tile already zoomed re-asserts the grid, not the 2597 // A promote onto the tile already zoomed re-asserts the grid, not the
2579 // modes: nothing is written a second time, so nothing has to come off 2598 // modes: nothing is written a second time, so nothing has to come off
@@ -2583,7 +2602,7 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic
2583 2602
2584 core.releaseTerminal(.write); 2603 core.releaseTerminal(.write);
2585 try std.testing.expectEqual(Claim.none, core.claim); 2604 try std.testing.expectEqual(Claim.none, core.claim);
2586 try std.testing.expectEqualStrings(session_release, buf[0..try std.posix.read(p[0], &buf)]); 2605 try std.testing.expectEqualStrings(session_release, drainPipe(p[0], &buf));
2587 2606
2588 // A release with nothing claimed writes nothing. That is what makes 2607 // A release with nothing claimed writes nothing. That is what makes
2589 // `deinit` safe on a tile nobody ever zoomed — and what stops a wall 2608 // `deinit` safe on a tile nobody ever zoomed — and what stops a wall
test/e2e.sh
Old New
@@ -332,6 +332,19 @@ D35PID=""
332 SOCK39="${TMPDIR:-/tmp}/muxd-e2e-zoomdead-$$.sock" 332 SOCK39="${TMPDIR:-/tmp}/muxd-e2e-zoomdead-$$.sock"
333 D36PID="" 333 D36PID=""
334 334
335 # The wheel inside a zoom (phase 3b). Two daemons for the wheel pair's own
336 # reason, turned up one notch: each needs a session whose SHELL is fixed for
337 # the session's whole life — one that scrolls and one that ASKS for the
338 # mouse — and a mode another scenario's session set would follow the wall's
339 # terminal around. Both use the daemon's DEFAULT session, so the wall
340 # spelling is a bare `--sock PATH` and nothing has to create anything.
341 SOCK41="${TMPDIR:-/tmp}/muxd-e2e-zoomwheel-$$.sock"
342 D38PID=""
343 ZWHEELSH="${TMPDIR:-/tmp}/mux-e2e-zoomwheel-$$.sh"
344 SOCK42="${TMPDIR:-/tmp}/muxd-e2e-zoomappmouse-$$.sock"
345 D39PID=""
346 ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-zoomappmouse-$$.sh"
347
335 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its 348 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
336 # own, for the dynamic-wall leg's reason turned up one notch: what these 349 # own, for the dynamic-wall leg's reason turned up one notch: what these
337 # two blocks read back is the wall FILE, and every `mux` in this suite now 350 # two blocks read back is the wall FILE, and every `mux` in this suite now
@@ -968,6 +981,8 @@ cleanup() {
968 [ -n "$D35PID" ] && kill "$D35PID" 2>/dev/null || true 981 [ -n "$D35PID" ] && kill "$D35PID" 2>/dev/null || true
969 [ -n "$D36PID" ] && kill "$D36PID" 2>/dev/null || true 982 [ -n "$D36PID" ] && kill "$D36PID" 2>/dev/null || true
970 [ -n "$D37PID" ] && kill "$D37PID" 2>/dev/null || true 983 [ -n "$D37PID" ] && kill "$D37PID" 2>/dev/null || true
984 [ -n "$D38PID" ] && kill "$D38PID" 2>/dev/null || true
985 [ -n "$D39PID" ] && kill "$D39PID" 2>/dev/null || true
971 # The stops still precede the socket rm below, like SOCK14-17 above: 986 # The stops still precede the socket rm below, like SOCK14-17 above:
972 # unlinking a socket first would leave a live daemon nothing could reach 987 # unlinking a socket first would leave a live daemon nothing could reach
973 # by path. 988 # by path.
@@ -990,6 +1005,8 @@ cleanup() {
990 [ -S "$SOCK38" ] && "$MUXD" stop --sock "$SOCK38" 2>/dev/null || true 1005 [ -S "$SOCK38" ] && "$MUXD" stop --sock "$SOCK38" 2>/dev/null || true
991 [ -S "$SOCK39" ] && "$MUXD" stop --sock "$SOCK39" 2>/dev/null || true 1006 [ -S "$SOCK39" ] && "$MUXD" stop --sock "$SOCK39" 2>/dev/null || true
992 [ -S "$SOCK40" ] && "$MUXD" stop --sock "$SOCK40" 2>/dev/null || true 1007 [ -S "$SOCK40" ] && "$MUXD" stop --sock "$SOCK40" 2>/dev/null || true
1008 [ -S "$SOCK41" ] && "$MUXD" stop --sock "$SOCK41" 2>/dev/null || true
1009 [ -S "$SOCK42" ] && "$MUXD" stop --sock "$SOCK42" 2>/dev/null || true
993 1010
994 # ---- the leak sweep (hygiene kit, 6a) ---- 1011 # ---- the leak sweep (hygiene kit, 6a) ----
995 # Here rather than at the bottom of the file, which `set -e` reaches only 1012 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1003,7 +1020,8 @@ cleanup() {
1003 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \ 1020 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
1004 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 1021 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
1005 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1022 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
1006 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" 1023 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1024 "$D38PID" "$D39PID"
1007 _leak=0 1025 _leak=0
1008 leak_sweep "$_rc" || _leak=1 1026 leak_sweep "$_rc" || _leak=1
1009 1027
@@ -1059,6 +1077,10 @@ cleanup() {
1059 # and the daemon logs beside them. 1077 # and the daemon logs beside them.
1060 rm -f "$OUT.whl" "$OUT.whl.err" "$OUT.whl.log" "$OUT.whl.d" "$WHEELSH" \ 1078 rm -f "$OUT.whl" "$OUT.whl.err" "$OUT.whl.log" "$OUT.whl.d" "$WHEELSH" \
1061 "$OUT.mse" "$OUT.mse.err" "$OUT.mse.log" "$OUT.mse.d" "$MOUSESH" \ 1079 "$OUT.mse" "$OUT.mse.err" "$OUT.mse.log" "$OUT.mse.d" "$MOUSESH" \
1080 "$SOCK41" "$OUT.zw.d" "$OUT.zwcap" "$OUT.zwcap.err" "$OUT.zwpc" \
1081 "$OUT.zwcapg" "$OUT.zwstop" "$ZWHEELSH" \
1082 "$SOCK42" "$OUT.zm2.d" "$OUT.zm2cap" "$OUT.zm2cap.err" "$OUT.zm2pc" \
1083 "$OUT.zm2capg" "$OUT.zm2stop" "$ZMOUSESH" \
1062 "$SOCK33" "$SOCK34" "$OUT.whlcap" "$OUT.msecap" \ 1084 "$SOCK33" "$SOCK34" "$OUT.whlcap" "$OUT.msecap" \
1063 "$OUT.whlstop" "$OUT.msestop" 1085 "$OUT.whlstop" "$OUT.msestop"
1064 rm -f "$OUT.pgr" "$OUT.pgr.err" "$OUT.pgr.log" "$OUT.pgr.d" "$LESSSH" \ 1086 rm -f "$OUT.pgr" "$OUT.pgr.err" "$OUT.pgr.log" "$OUT.pgr.d" "$LESSSH" \
@@ -5745,6 +5767,193 @@ assert_stopped "$SOCK38" "$D35PID" "zoom mute" "$OUT.zmstop"
5745 D35PID="" 5767 D35PID=""
5746 ok "an unzoomed wall forwards nothing; the zoom one keystroke later does" 5768 ok "an unzoomed wall forwards nothing; the zoom one keystroke later does"
5747 5769
5770 # ---- the wheel inside a zoom, and the mouse handed back on the way out --
5771 #
5772 # What phase 3b is FOR. A zoomed tile used to be keyboard-only: the wall
5773 # hand-rolled a subset of the client's input path, and the wheel — which
5774 # needs the session's own modes, the tile's scrollback view and the tile's
5775 # transport all in one place — was not in that subset. Now the tile's input
5776 # goes through the same `interact.Core` a plain client's does, so the wheel
5777 # arrives with it.
5778 #
5779 # Two claims, one run, because the second is the first read backwards and
5780 # has to be anchored on it:
5781 #
5782 # * ZOOMED, the wheel scrolls the SESSION's history and never reaches its
5783 # pty. Same arithmetic as the plain client's wheel leg (scenario 42) and
5784 # deliberately the same pty size, so a difference here is the wall's and
5785 # not the geometry's: eight notches in one write are 24 rows, exactly a
5786 # screenful on 80x24, and the page that lands holds `wln60` — a line
5787 # that is in the history and in nothing painted before it.
5788 # * DEMOTED, the wheel reaches nothing at all, and the terminal is left
5789 # with no mouse mode enabled. The demote is what pairs with the promote's
5790 # claim, and it is the pair that turns over many times per wall where a
5791 # client's turns over once per process.
5792 #
5793 # The lines are `wln1`..`wln100` rather than bare numbers because this
5794 # terminal is a WALL: its label bar carries the socket path, which carries
5795 # this run's pid, and a bare `60` in a pid would make the money assertion
5796 # pass for a reason that has nothing to do with the wheel.
5797 #
5798 # The wall's spelling is a bare `--sock PATH` — the daemon's DEFAULT
5799 # session, so nothing has to be created and the session's shell is fixed for
5800 # its whole life.
5801 cat > "$ZWHEELSH" <<'EOF'
5802 #!/bin/sh
5803 seq 1 100 | sed 's/^/wln/'
5804 exec /bin/cat
5805 EOF
5806 chmod +x "$ZWHEELSH"
5807 "$MUXD" run --sock "$SOCK41" --shell "$ZWHEELSH" > "$OUT.zw.d" 2>&1 &
5808 D38PID=$!
5809 wait_sock "$SOCK41" "$OUT.zw.d" "zoom-wheel daemon never bound"
5810 # The wall attaches only once seq has finished, scenario 42's reason: the
5811 # page the wheel fetches has to be content this tile was never sent.
5812 i=0
5813 until "$MUXD" dump --sock "$SOCK41" | grep -q "wln100"; do
5814 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: zoom wheel: session never finished seq"; exit 1; }
5815 sleep 0.1
5816 done
5817 set +e
5818 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.zwcap" --err "$OUT.zwcap.err" -- \
5819 "$MUX" wall "--sock $SOCK41" > "$OUT.zwpc" 2>&1 <<'EOF'
5820 expect wln100 20000
5821 settle 700 20000
5822 send \r
5823 settle 900 20000
5824 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
5825 expect wln60 15000
5826 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
5827 expect wln100 15000
5828 settle 500 15000
5829 send zwheelpin\n
5830 expect zwheelpin 15000
5831 settle 500 15000
5832 send \x1cw
5833 expect wln100 20000
5834 settle 900 20000
5835 send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5Mwallwheel
5836 settle 900 20000
5837 send q
5838 waitexit 10000
5839 EOF
5840 RC=$?
5841 set -e
5842 [ "$RC" -eq 0 ] || {
5843 echo "e2e FAIL: zoom wheel: ptyclient leg exited $RC (did the zoomed wheel scroll, and come back?):"
5844 cat "$OUT.zwpc"; exit 1; }
5845 # The anchor first, and it is a POSITIVE: a keystroke typed at the zoom
5846 # reached this session's pty. Everything silent below is measured against
5847 # it, so a leg that fell over early can never pass by having done nothing.
5848 timeout 20 "$MUXA" capture --sock "$SOCK41" > "$OUT.zwcapg" 2>&1
5849 grep -q "zwheelpin" "$OUT.zwcapg" || {
5850 echo "e2e FAIL: zoom wheel: the zoom never typed into the session, so the"
5851 echo " silent halves below prove nothing:"
5852 cat "$OUT.zwcapg"; cat "$OUT.zwpc"; exit 1; }
5853 # Twenty mouse reports were typed at a shell that echoes everything it is
5854 # given, and the session's grid must hold none: zoomed they were the
5855 # client's own wheel, demoted they were never forwarded at all.
5856 grep -qF -- "[<64;" "$OUT.zwcapg" && {
5857 echo "e2e FAIL: zoom wheel: a mouse report reached the pty as input:"
5858 cat "$OUT.zwcapg"; exit 1; }
5859 ok "a zoomed tile scrolls its session's history on the wheel, and the pty sees none of it"
5860
5861 # The demoted half, on the same capture. Every letter of `wallwheel` is one
5862 # an unzoomed wall swallows — none is a selection key, `q` or `x` — which is
5863 # the same care scenario 45 spells out: a negative that cannot fail is not a
5864 # test.
5865 grep -q "wallwheel" "$OUT.zwcapg" && {
5866 echo "e2e FAIL: zoom wheel: the DEMOTED wall forwarded keystrokes to the session:"
5867 cat "$OUT.zwcapg"; exit 1; }
5868 # ...and the terminal was given back its mouse. `?1000l` is written exactly
5869 # twice on this run — once by the demote and once by the wall's own exit —
5870 # and the promote writes only `h`s, so a count of one is a demote that left
5871 # the user's terminal reporting clicks into their shell for the rest of the
5872 # wall's life. Counted with `grep -o`: these are escape sequences inside a
5873 # paint, with no newlines to make a line count mean anything.
5874 _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l)
5875 [ "$_zw_off" -ge 2 ] || {
5876 echo "e2e FAIL: zoom wheel: mouse reporting was turned off $_zw_off time(s);"
5877 echo " the demote must take it off as well as the wall's exit:"
5878 cat "$OUT.zwpc"; exit 1; }
5879 assert_stopped "$SOCK41" "$D38PID" "zoom wheel" "$OUT.zwstop"
5880 D38PID=""
5881 ok "a demoted tile forwards nothing and leaves no mouse mode on the wall's terminal"
5882
5883 # ---- an application in a ZOOMED tile owns the mouse ---------------------
5884 #
5885 # Scenario 43 said this about a plain client; this says it about a tile,
5886 # where it is a stronger claim than it looks. A tile learns the session's
5887 # modes while it is a STRIPE — `interact.Core` decodes every `term_modes`
5888 # whatever the tile's claim is, and only the WRITE to the terminal is gated
5889 # — and the promote is answered by `resyncSnapshot`, which carries no modes
5890 # at all. So the handover cannot come from a frame: it has to come from what
5891 # the Core already knew, level-set onto the terminal at the moment of the
5892 # claim. Measured before that existed: `?1002h` never reached this capture
5893 # and the wheel was eaten as scrollback.
5894 #
5895 # 1002 is what makes the leg sharp. The wall asks its terminal for NO mouse
5896 # mode, and a promoted tile's own capture set is 1000+1006, so a `?1002h` on
5897 # this terminal can only be the session's, mirrored.
5898 cat > "$ZMOUSESH" <<'EOF'
5899 #!/bin/sh
5900 seq 1 100 | sed 's/^/mln/'
5901 printf '\033[?1000h\033[?1002h\033[?1006h'
5902 printf 'mapp-holds-the-mouse\n'
5903 exec /bin/cat
5904 EOF
5905 chmod +x "$ZMOUSESH"
5906 "$MUXD" run --sock "$SOCK42" --shell "$ZMOUSESH" > "$OUT.zm2.d" 2>&1 &
5907 D39PID=$!
5908 wait_sock "$SOCK42" "$OUT.zm2.d" "zoom-app-mouse daemon never bound"
5909 i=0
5910 until "$MUXD" dump --sock "$SOCK42" | grep -q "mapp-holds-the-mouse"; do
5911 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: zoom app mouse: session never armed"; exit 1; }
5912 sleep 0.1
5913 done
5914 # The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes
5915 # that reach the pty come back as `^[[<64;10;5M` and bytes that do not, do
5916 # not.
5917 set +e
5918 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.zm2cap" --err "$OUT.zm2cap.err" -- \
5919 "$MUX" wall "--sock $SOCK42" > "$OUT.zm2pc" 2>&1 <<'EOF'
5920 expect mapp-holds-the-mouse 20000
5921 settle 700 20000
5922 send \r
5923 settle 900 20000
5924 send \x1b[<64;10;5M
5925 expect [<64;10;5M 15000
5926 settle 500 15000
5927 send \x1cw
5928 settle 700 20000
5929 send q
5930 waitexit 10000
5931 EOF
5932 RC=$?
5933 set -e
5934 [ "$RC" -eq 0 ] || {
5935 echo "e2e FAIL: zoom app mouse: ptyclient leg exited $RC (did the wheel reach the app?):"
5936 cat "$OUT.zm2pc"; exit 1; }
5937 # The session's own grid, not just this terminal: the echo is the pty saying
5938 # it received the bytes.
5939 timeout 20 "$MUXA" capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1
5940 grep -qF -- "[<64;10;5M" "$OUT.zm2capg" || {
5941 echo "e2e FAIL: zoom app mouse: the wheel never reached the application's pty:"
5942 cat "$OUT.zm2capg"; exit 1; }
5943 # The mirror: this terminal was asked for the SESSION's modes at the
5944 # promote, not for the tile's own set.
5945 grep -qaF "$(printf '\033')[?1002h" "$OUT.zm2cap" || {
5946 echo "e2e FAIL: zoom app mouse: the promote never mirrored the session's drag mode:"
5947 cat "$OUT.zm2pc"; exit 1; }
5948 # And the negative that makes the pair a pair: this session has the same 77
5949 # rows of history as the leg above, and the same wheel byte moved none of it.
5950 grep -qa "mln60" "$OUT.zm2cap" && {
5951 echo "e2e FAIL: zoom app mouse: the tile scrolled back on a wheel the app owned:"
5952 cat "$OUT.zm2pc"; exit 1; }
5953 assert_stopped "$SOCK42" "$D39PID" "zoom app mouse" "$OUT.zm2stop"
5954 D39PID=""
5955 ok "an application in a zoomed tile gets the wheel, and the tile does not"
5956
5748 # ---- a tile whose pump has died still says something --------------------- 5957 # ---- a tile whose pump has died still says something ---------------------
5749 # 5958 #
5750 # Only pumps answer `repaint_gen`, so a tile whose pump has ENDED has 5959 # Only pumps answer `repaint_gen`, so a tile whose pump has ENDED has
@@ -6177,8 +6386,8 @@ DPID=""
6177 # convergence point either: what it asserts on is that file shrinking by 6386 # convergence point either: what it asserts on is that file shrinking by
6178 # one line while the session it named goes on answering, which is two 6387 # one line while the session it named goes on answering, which is two
6179 # facts a grid comparison speaks to neither of. 6388 # facts a grid comparison speaks to neither of.
6180 [ "$OK_COUNT" = "48" ] || { 6389 [ "$OK_COUNT" = "51" ] || {
6181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 48 —" 6390 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 51 —"
6182 echo " a scenario was added (update the pin) or silently lost" 6391 echo " a scenario was added (update the pin) or silently lost"
6183 exit 1 6392 exit 1
6184 } 6393 }
@@ -6186,4 +6395,4 @@ DPID=""
6186 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 6395 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
6187 exit 1 6396 exit 1
6188 } 6397 }
6189 echo "e2e OK (48 scenarios, 35 convergence points)" 6398 echo "e2e OK (51 scenarios, 35 convergence points)"