Files
2026-07-13 13:12:00 +08:00

497 lines
20 KiB
Python

"""
Unit + integration tests for :mod:`omnigent.terminals.control_bridge`.
Covers the pure helpers (``unescape_control_output`` octal round-trip,
``_hex_send_keys_commands`` chunking) and an end-to-end drive of
``bridge_tmux_control_to_websocket`` against a real private tmux server via a
fake WebSocket: seed-on-attach, ``%output`` streaming of ``send-keys`` input,
and the detach close code.
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from omnigent.terminals.control_bridge import (
_SEND_KEYS_HEX_BYTES_PER_CALL,
_hex_send_keys_commands,
bridge_tmux_control_to_websocket,
unescape_control_output,
)
_HAS_TMUX = shutil.which("tmux") is not None
def test_unescape_control_output_round_trips_control_bytes() -> None:
"""tmux octal escapes (\\ooo) decode back to raw ESC/CR/LF bytes."""
assert unescape_control_output(rb"\033[31mRED\033[0m\015\012") == b"\x1b[31mRED\x1b[0m\r\n"
# A literal backslash is escaped as \134 and must decode back to one byte.
assert unescape_control_output(rb"a\134b") == b"a\\b"
# Printable bytes pass through untouched.
assert unescape_control_output(b"plain text 123") == b"plain text 123"
def test_hex_send_keys_commands_encodes_and_chunks() -> None:
"""Input bytes become space-separated hex, split under the per-call cap."""
cmds = _hex_send_keys_commands("main", b"\x1b[A")
assert cmds == [b"send-keys -t main -H 1b 5b 41\n"]
big = b"\x00" * (_SEND_KEYS_HEX_BYTES_PER_CALL + 5)
cmds = _hex_send_keys_commands("main", big)
assert len(cmds) == 2
# First chunk carries exactly the cap's worth of "00" tokens.
assert cmds[0].count(b"00") == _SEND_KEYS_HEX_BYTES_PER_CALL
assert cmds[1].count(b"00") == 5
class _FakeWebSocket:
"""Minimal WebSocket stand-in driving bridge_tmux_control_to_websocket.
Records outbound binary frames, feeds a scripted sequence of inbound
messages, and captures the close code.
"""
def __init__(self, inbound: list[dict[str, object]], send_delay_s: float = 0.0) -> None:
self._inbound = list(inbound)
self.sent: list[bytes] = []
self.close_code: int | None = None
self.close_reason: str | None = None
self._recv_gate = asyncio.Event()
# Per-send delay simulates a real network so a burst backlogs behind the
# send — the condition under which the forwarder coalesces.
self._send_delay_s = send_delay_s
async def send_bytes(self, data: bytes) -> None:
if self._send_delay_s:
await asyncio.sleep(self._send_delay_s)
self.sent.append(data)
async def receive(self) -> dict[str, object]:
if self._inbound:
return self._inbound.pop(0)
# Block forever once scripted input is exhausted so the bridge's other
# task (control→ws) decides when the attach ends.
await self._recv_gate.wait()
return {"type": "websocket.disconnect"}
async def close(self, code: int = 1000, reason: str = "") -> None:
self.close_code = code
self.close_reason = reason
async def _new_private_tmux(inner: str) -> tuple[Path, str]:
"""Create a private single-pane tmux server like terminal.py:launch."""
tmux = shutil.which("tmux")
assert tmux
tmpdir = Path(tempfile.mkdtemp(prefix="cc-test-"))
sock = tmpdir / "tmux.sock"
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
str(sock),
"-f",
os.devnull,
"set-option",
"-g",
"history-limit",
"10000",
";",
"new-session",
"-d",
"-s",
"main",
"-x",
"80",
"-y",
"24",
inner,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, err = await proc.communicate()
assert proc.returncode == 0, err.decode()
return sock, "main"
async def _kill_tmux(sock: Path) -> None:
tmux = shutil.which("tmux")
if tmux is None:
return
with contextlib.suppress(Exception):
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
str(sock),
"kill-server",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
shutil.rmtree(sock.parent, ignore_errors=True)
async def _kill_and_join(sock: Path, task: asyncio.Task[None]) -> None:
"""Kill the tmux server and wind the bridge task down cleanly.
Killing the server closes the control client's stdout, so the bridge
exits on its own; we wait a bounded time, then cancel as a fallback and
await the cancellation propagating. Shared teardown for the end-to-end
tests so each doesn't repeat the kill/join dance.
:param sock: tmux socket whose server to kill.
:param task: The running ``bridge_tmux_control_to_websocket`` task.
"""
await _kill_tmux(sock)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(task, timeout=5)
if not task.done():
task.cancel()
# ``await`` blocks until the cancelled task finishes unwinding — the
# return value is discarded but the wait is the point.
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_control_bridge_streams_large_output_burst() -> None:
"""A large post-attach output burst streams through intact, no reader crash.
The raw-``read()`` reader (not ``readline()``) has no per-line length cap,
so a big burst can't raise ``LimitOverrunError`` and kill the reader. Assert
a ~200 KiB live burst reaches the browser fully rather than dropping the
connection.
"""
# Hold the pane quiet for 1s, THEN emit ~200 KiB in one burst — so the
# payload arrives as live post-attach %output (the readline path), not via
# the capture-pane seed.
payload_len = 200_000
sock, target = await _new_private_tmux(
f'python3 -c \'import sys,time; time.sleep(1.0); sys.stdout.write("X"*{payload_len}); '
"sys.stdout.flush(); time.sleep(30)'"
)
# Attach while the pane is still quiet (before the burst fires).
await asyncio.sleep(0.2)
ws = _FakeWebSocket(inbound=[])
async def _run() -> None:
await bridge_tmux_control_to_websocket(
ws, socket_path=str(sock), tmux_target=target, read_only=False
)
task = asyncio.create_task(_run())
# Wait past the burst so the big %output line is read and forwarded.
await asyncio.sleep(2.0)
# The reader must still be alive and the large payload must have reached the
# browser via the live stream.
total_x = sum(frame.count(b"X") for frame in ws.sent)
assert total_x >= payload_len, (
f"large output truncated/dropped: got {total_x} X bytes of {payload_len}"
)
await _kill_and_join(sock, task)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_control_bridge_coalesces_burst_when_send_lags() -> None:
"""A burst behind a slow send collapses into far fewer, larger frames.
tmux firehoses ``%output`` as many small per-line writes; when the browser
send can't keep pace a backlog forms, and the forwarder merges it into large
``send_bytes`` instead of thousands of tiny ones. Assert on the *average
frame size* rather than an absolute frame count: the count is scheduling-
dependent (how much backlog accrues between drains varies with load), but a
coalesced frame is always many times a single ``%output`` line (~1 KB),
which is the timing-robust signal that merging happened at all.
"""
payload_len = 500_000
sock, target = await _new_private_tmux(
f'python3 -c \'import sys,time; time.sleep(1.0); sys.stdout.write("X"*{payload_len}); '
"sys.stdout.flush(); time.sleep(30)'"
)
await asyncio.sleep(0.2)
# A per-frame send delay makes the browser lag tmux's firehose so a backlog
# forms; 5 ms is generous enough that a backlog reliably accrues even under
# a loaded CI runner (where a 1 ms delay can keep pace and defeat merging).
ws = _FakeWebSocket(inbound=[], send_delay_s=0.005)
task = asyncio.create_task(
bridge_tmux_control_to_websocket(
ws, socket_path=str(sock), tmux_target=target, read_only=False
)
)
# Allow ample time for the full burst to drain through the slow send.
await asyncio.sleep(8.0)
burst_frames = [f for f in ws.sent if b"X" in f]
total_x = sum(f.count(b"X") for f in ws.sent)
assert total_x >= payload_len, f"burst truncated: got {total_x} X bytes of {payload_len}"
# Coalesced frames are far larger than a single ``%output`` line (~1 KB).
# Require a comfortably-above-per-line average — proves merging without
# depending on the exact (scheduling-dependent) frame count. Without
# coalescing this average would be ~1 KB; merged it is many KB.
avg_frame = total_x / max(1, len(burst_frames))
assert avg_frame > 4000, (
f"expected coalesced frames (avg > 4 KB), got avg {avg_frame:.0f}B over "
f"{len(burst_frames)} frames — forwarder is not merging the backlog"
)
await _kill_and_join(sock, task)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_control_bridge_burst_then_exit_delivers_full_tail() -> None:
"""A burst-then-exit program's tail isn't dropped when %exit races the drain.
The reader and forwarder are separate tasks; shutdown keys on the reader.
When a program dumps a big burst and exits immediately (``cat bigfile``,
build output), ``%exit`` arrives while the slow browser send is still
draining the queued backlog. The bridge must let the forwarder finish
draining the sentinel-terminated queue before teardown, or the tail is
silently lost. Emit the burst then exit (no trailing sleep) behind a slow
send and assert the FULL payload still reaches the browser.
"""
# The backlog must be too big to fully drain before the reader hits %exit,
# or the forwarder finishes on its own and the race never triggers. 2 MB
# behind a 5 ms/frame send leaves a large queued tail at %exit time — the
# pre-fix code (cancel forwarder on reader-done) drops ~35% of it.
payload_len = 2_000_000
sock, target = await _new_private_tmux(
# Sleep first so the control client attaches BEFORE the burst — the
# payload then arrives as live %output. Then burst and exit immediately
# (no trailing sleep) so %exit races the still-draining slow send: the
# regression window. (A burst emitted before attach is gone at the tmux
# layer, not a bridge concern.)
f'python3 -c \'import sys,time; time.sleep(1.5); sys.stdout.write("Y"*{payload_len}); '
"sys.stdout.flush()'"
)
await asyncio.sleep(0.2)
ws = _FakeWebSocket(inbound=[], send_delay_s=0.005)
task = asyncio.create_task(
bridge_tmux_control_to_websocket(
ws, socket_path=str(sock), tmux_target=target, read_only=False
)
)
# Wait past attach + burst + the bounded drain (coalesced to ~100 frames of
# ~20 KB, so ~0.5 s of 5 ms sends; generous margin below).
await asyncio.sleep(10.0)
total_y = sum(f.count(b"Y") for f in ws.sent)
assert total_y >= payload_len, (
f"burst-then-exit dropped the tail: got {total_y} Y bytes of {payload_len} "
"— forwarder was cancelled before draining the queued backlog"
)
await _kill_and_join(sock, task)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_control_bridge_seeds_streams_and_detaches() -> None:
"""End-to-end: seed the pre-attach screen, stream typed input, detach clean."""
# `cat` echoes input back to the pane (→ %output); the printf lands before
# attach so it can only reach the browser via the capture-pane seed.
sock, target = await _new_private_tmux("printf 'SEEDED-LINE\\n'; cat")
await asyncio.sleep(0.3) # let the printf render before we attach
ws = _FakeWebSocket(
inbound=[
{"type": "websocket.receive", "text": '{"type":"resize","cols":100,"rows":30}'},
{"type": "websocket.receive", "bytes": b"typed-input\r"},
]
)
async def _run() -> None:
await bridge_tmux_control_to_websocket(
ws, socket_path=str(sock), tmux_target=target, read_only=False
)
task = asyncio.create_task(_run())
# Give it time to seed, resize, inject input, and observe the echo.
await asyncio.sleep(1.2)
joined = b"".join(ws.sent)
assert b"SEEDED-LINE" in joined, "seed-on-attach did not paint pre-attach screen"
assert b"typed-input" in joined, "send-keys input was not echoed via %output"
# The seed frame must not carry bare-LF row separators: capture-pane -p
# joins rows with a lone \n, which would staircase the whole screen to the
# right in xterm. The bridge rewrites them to CRLF and prepends home+clear.
seed_frame = ws.sent[0]
assert seed_frame.startswith(b"\x1b[H\x1b[2J"), "seed did not start with home+clear"
assert b"\n" not in seed_frame.replace(b"\r\n", b""), (
"seed frame contains a bare LF — rows will staircase in xterm"
)
# Kill the server → the control client's stdout closes → bridge exits.
await _kill_and_join(sock, task)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_seed_restores_cursor_position() -> None:
"""The seed ends with a CUP escape putting the cursor where the app left it.
capture-pane records only cell contents, not the cursor, so the seed must
reposition it explicitly or the browser cursor sits at the end of the
seeded text instead of inside the app's prompt.
"""
from omnigent.terminals.control_bridge import _run_tmux_capture
# Park the cursor at row 9, col 8 (1-based) and hold the pane open.
sock, target = await _new_private_tmux(
'python3 -c \'import sys,time; sys.stdout.write("a\\r\\nb\\r\\n\\x1b[9;8H"); '
"sys.stdout.flush(); time.sleep(30)'"
)
await asyncio.sleep(0.4)
try:
seed = await _run_tmux_capture(str(sock), target)
assert seed is not None
# tmux reports 0-based cursor_x=7, cursor_y=8 → CUP is 1-based [9;8H.
assert b"\x1b[9;8H" in seed, f"cursor CUP escape missing from seed: {seed[-24:]!r}"
# Visible cursor → show-cursor tail.
assert seed.endswith(b"\x1b[?25h"), f"seed did not end with show-cursor: {seed[-12:]!r}"
finally:
await _kill_tmux(sock)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_seed_full_height_pane_does_not_scroll_or_shift_cursor() -> None:
"""A full-height pane seed must render row1 at top and the cursor in place.
capture-pane -p emits a trailing LF after the final row; writing it on a
full-height pane scrolls the whole screen up one line (the "extra line"
off-by-one) and shifts the restored cursor. Render the seed through a real
VT emulator (pyte) and assert no scroll + exact cursor position.
"""
import pyte
from omnigent.terminals.control_bridge import _run_tmux_capture
# Fill all 24 rows (row1..row24) and park the cursor at row24 col6.
sock, target = await _new_private_tmux(
"python3 -c 'import sys,time\n"
'sys.stdout.write("\\x1b[?1049h")\n'
'for r in range(1,25): sys.stdout.write(f"\\x1b[{r};1Hrow{r}")\n'
'sys.stdout.write("\\x1b[24;6H")\n'
"sys.stdout.flush(); time.sleep(30)'"
)
await asyncio.sleep(0.4)
try:
seed = await _run_tmux_capture(str(sock), target)
assert seed is not None
screen = pyte.Screen(80, 24)
stream = pyte.ByteStream(screen)
stream.feed(seed)
display = screen.display
# No scroll-up: the top row is still row1, the bottom is row24.
assert display[0].startswith("row1"), f"top row scrolled off: {display[0]!r}"
assert display[23].startswith("row24"), f"bottom row wrong: {display[23]!r}"
# Cursor restored to the app's position (0-based (23, 5) for [24;6H).
assert (screen.cursor.y, screen.cursor.x) == (23, 5), (
f"cursor off: got ({screen.cursor.y}, {screen.cursor.x}), expected (23, 5)"
)
finally:
await _kill_tmux(sock)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_seed_recovers_primary_screen_scrollback() -> None:
"""On the primary screen the seed captures full history, not just the screen."""
from omnigent.terminals.control_bridge import _run_tmux_capture
sock, target = await _new_private_tmux("bash --norc")
await asyncio.sleep(0.2)
tmux = shutil.which("tmux")
assert tmux
# Emit 100 history lines — far more than the 24-row visible screen.
proc = await asyncio.create_subprocess_exec(
tmux,
"-S",
str(sock),
"send-keys",
"-t",
target,
"-l",
"for i in $(seq 1 100); do echo hist-$i; done",
)
await proc.communicate()
proc = await asyncio.create_subprocess_exec(
tmux, "-S", str(sock), "send-keys", "-t", target, "Enter"
)
await proc.communicate()
await asyncio.sleep(0.6)
try:
seed = await _run_tmux_capture(str(sock), target)
assert seed is not None
# Full history recovered (a visible-only capture would show ~23 lines).
assert seed.count(b"hist-") >= 100, (
f"scrollback not recovered: only {seed.count(b'hist-')} history lines"
)
finally:
await _kill_tmux(sock)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_seed_alternate_screen_does_not_leak_primary_history() -> None:
"""On the alternate screen the seed must not include stale primary history.
``capture-pane -S -`` on an alt-screen pane returns the primary buffer's
scrollback from before the app switched — lines that were never part of
the app's UI. The bridge must capture the visible screen only there.
"""
from omnigent.terminals.control_bridge import _run_tmux_capture
# 50 primary-screen "OLD" lines, then enter the alternate screen and draw.
sock, target = await _new_private_tmux(
"python3 -c 'import sys,time\n"
'for i in range(50): sys.stdout.write(f"OLD-{i}\\r\\n")\n'
'sys.stdout.write("\\x1b[?1049h")\n'
'for r in range(1,25): sys.stdout.write(f"\\x1b[{r};1HALT-row{r}")\n'
"sys.stdout.flush(); time.sleep(30)'"
)
await asyncio.sleep(0.5)
try:
seed = await _run_tmux_capture(str(sock), target)
assert seed is not None
assert seed.count(b"OLD-") == 0, (
f"alt-screen seed leaked {seed.count(b'OLD-')} stale primary-history lines"
)
assert b"ALT-row" in seed, "alt-screen visible content missing from seed"
finally:
await _kill_tmux(sock)
@pytest.mark.skipif(not _HAS_TMUX, reason="tmux not installed")
@pytest.mark.asyncio
async def test_control_bridge_read_only_drops_input() -> None:
"""read_only=True must not inject typed bytes into the pane."""
sock, target = await _new_private_tmux("cat")
await asyncio.sleep(0.2)
ws = _FakeWebSocket(inbound=[{"type": "websocket.receive", "bytes": b"should-not-appear\r"}])
task = asyncio.create_task(
bridge_tmux_control_to_websocket(
ws, socket_path=str(sock), tmux_target=target, read_only=True
)
)
await asyncio.sleep(0.8)
assert b"should-not-appear" not in b"".join(ws.sent)
await _kill_and_join(sock, task)