ea42165ed2
- Queue bounded ordered terminal input so acknowledgements remain responsive - Prevent stale sockets and retries from replacing healthy connections - Preserve desktop scrollback behavior and add terminal regression coverage Quality gates: frontend tests (91 passed), typecheck, lint, build, Python compilation, LSP diagnostics. Backend pytest skipped by user request.
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""Integration tests for multi-session terminal WebSocket and REST API."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from src.api.system.terminal import (
|
|
MAX_TERMINAL_INPUT_BYTES,
|
|
SessionRef,
|
|
_queue_terminal_input,
|
|
_write_loop,
|
|
)
|
|
from src.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(app)
|
|
|
|
|
|
class TestTerminalWebSocketMultiSession:
|
|
"""Tests for multi-session WebSocket routing."""
|
|
|
|
def test_specific_session_websocket_route_exists(self, client):
|
|
"""The specific session WebSocket route should be registered."""
|
|
# We can't easily test WebSocket without auth, but we can verify
|
|
# the route exists by checking for a 403 (no auth cookie)
|
|
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
|
|
# WebSocket endpoint returns 403 when accessed via HTTP GET
|
|
assert response.status_code == 403 or response.status_code == 404
|
|
|
|
def test_default_session_alias_route_exists(self, client):
|
|
"""The default session alias route should still exist."""
|
|
response = client.get("/ws/tool-instances/test-instance/terminal")
|
|
assert response.status_code == 403 or response.status_code == 404
|
|
|
|
|
|
def test_terminal_input_queue_rejects_excess_input_without_blocking() -> None:
|
|
"""A stalled PTY writer cannot make the input queue grow without limit."""
|
|
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(maxsize=1)
|
|
session = object()
|
|
|
|
assert _queue_terminal_input(input_queue, session, b"first")
|
|
assert not _queue_terminal_input(input_queue, session, b"second")
|
|
assert not _queue_terminal_input(
|
|
asyncio.Queue(), session, b"x" * (MAX_TERMINAL_INPUT_BYTES + 1)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ack_is_processed_while_a_pty_write_is_waiting() -> None:
|
|
"""A blocked paste writer must not block flow-control acknowledgements."""
|
|
|
|
write_started = asyncio.Event()
|
|
|
|
class Session:
|
|
_closed = False
|
|
|
|
def __init__(self) -> None:
|
|
self.acks: list[int] = []
|
|
self.write_finished = False
|
|
|
|
def is_alive(self) -> bool:
|
|
return True
|
|
|
|
async def write_input(self, _data: bytes) -> None:
|
|
write_started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
self.write_finished = True
|
|
|
|
def acknowledge_data(self, char_count: int) -> None:
|
|
self.acks.append(char_count)
|
|
|
|
class WebSocket:
|
|
def __init__(self) -> None:
|
|
self.messages = iter(
|
|
[
|
|
{"type": "websocket.receive", "bytes": b"large paste"},
|
|
{"type": "websocket.receive", "text": '{"type":"ack","chars":4096}'},
|
|
{"type": "websocket.disconnect"},
|
|
]
|
|
)
|
|
self.receive_count = 0
|
|
|
|
async def receive(self):
|
|
self.receive_count += 1
|
|
if self.receive_count > 1:
|
|
await write_started.wait()
|
|
return next(self.messages)
|
|
|
|
session = Session()
|
|
websocket = WebSocket()
|
|
task = asyncio.create_task(_write_loop(SessionRef(session), websocket, "instance"))
|
|
|
|
await asyncio.wait_for(write_started.wait(), timeout=0.1)
|
|
await asyncio.sleep(0)
|
|
assert session.acks == [4096]
|
|
|
|
await task
|
|
assert session.write_finished
|
|
|
|
|
|
class TestTerminalRestApi:
|
|
"""Tests for REST API endpoints."""
|
|
|
|
def test_list_sessions_requires_auth(self, client):
|
|
"""List sessions endpoint requires authentication."""
|
|
response = client.get("/instances/test/terminal/sessions")
|
|
assert response.status_code == 401
|
|
|
|
def test_create_session_requires_auth(self, client):
|
|
"""Create session endpoint requires authentication."""
|
|
response = client.post(
|
|
"/instances/test/terminal/sessions",
|
|
json={},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
def test_close_session_requires_auth(self, client):
|
|
"""Close session endpoint requires authentication."""
|
|
response = client.delete("/instances/test/terminal/sessions/test-session")
|
|
assert response.status_code == 401
|
|
|
|
def test_reset_session_requires_auth(self, client):
|
|
"""Reset session endpoint requires authentication."""
|
|
response = client.post("/instances/test/terminal/sessions/test-session/reset")
|
|
assert response.status_code == 401
|
|
|
|
def test_rename_session_requires_auth(self, client):
|
|
"""Rename session endpoint requires authentication."""
|
|
response = client.post(
|
|
"/instances/test/terminal/sessions/test-session/rename",
|
|
json={"name": "New Name"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
def test_legacy_reset_alias_requires_auth(self, client):
|
|
"""Legacy reset endpoint still requires auth."""
|
|
response = client.post("/instances/test/terminal/reset")
|
|
assert response.status_code == 401
|