import asyncio import json from pathlib import Path from typing import Any, cast import pytest from app.library.Services import Services from app.library.TerminalSessionManager import TerminalSessionManager from app.library.config import Config from app.library.encoder import Encoder from app.routes.api.system import ( cancel_terminal_session, create_terminal_session, get_active_terminal_session, get_terminal_session, stream_terminal_session, ) class _FakeTransport: def __init__(self) -> None: self._closing = False def is_closing(self) -> bool: return self._closing class _FakeRequest: def __init__( self, *, payload: dict | None = None, match_info: dict[str, str] | None = None, query: dict[str, str] | None = None, headers: dict[str, str] | None = None, can_read_body: bool = False, ) -> None: self._payload = payload self.match_info = match_info or {} self.query = query or {} self.headers = headers or {} self.can_read_body = can_read_body self.transport = _FakeTransport() async def json(self) -> dict | None: return self._payload class _FakeStreamResponse: def __init__(self, *, status: int, headers: dict[str, str]) -> None: self.status = status self.headers = headers self.payload = bytearray() self.prepared = False self.closed = False async def prepare(self, _request: _FakeRequest) -> "_FakeStreamResponse": self.prepared = True return self async def write(self, data: bytes) -> None: self.payload.extend(data) async def write_eof(self) -> None: self.closed = True class _FakeStdout: def __init__(self, lines: list[bytes]) -> None: self._lines = lines def __aiter__(self) -> "_FakeStdout": return self async def __anext__(self) -> bytes: if not self._lines: raise StopAsyncIteration return self._lines.pop(0) class _BlockingProc: def __init__(self, done_event: asyncio.Event) -> None: self.stdout = _FakeStdout([]) self._done_event = done_event self.returncode: int | None = None async def wait(self) -> int: await self._done_event.wait() self.returncode = 0 return 0 class _CompletedProc: def __init__(self, lines: list[bytes], exit_code: int = 0) -> None: self.stdout = _FakeStdout(lines) self._exit_code = exit_code self.returncode: int | None = None async def wait(self) -> int: await asyncio.sleep(0) self.returncode = self._exit_code return self._exit_code class _TerminableProc: def __init__(self) -> None: self.stdout = _FakeStdout([]) self.returncode: int | None = None self.terminate_calls = 0 self.kill_calls = 0 self.wait_started = asyncio.Event() self._done_event = asyncio.Event() def terminate(self) -> None: self.terminate_calls += 1 if self.returncode is None: self.returncode = -15 self._done_event.set() def kill(self) -> None: self.kill_calls += 1 if self.returncode is None: self.returncode = -9 self._done_event.set() async def wait(self) -> int: self.wait_started.set() await self._done_event.wait() assert self.returncode is not None return self.returncode @pytest.fixture def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]: Services._reset_singleton() Config._reset_singleton() TerminalSessionManager._reset_singleton() config = Config.get_instance() config.console_enabled = True config.config_path = str(tmp_path / "config") config.download_path = str(tmp_path / "downloads") Path(config.config_path).mkdir(parents=True, exist_ok=True) Path(config.download_path).mkdir(parents=True, exist_ok=True) manager = TerminalSessionManager.get_instance() encoder = Encoder() return config, manager, encoder class TestTerminalSessionRoutes: @pytest.mark.asyncio async def test_start_returns_session_metadata_and_active_conflict( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup await manager.initialize() done_event = asyncio.Event() async def fake_create_subprocess_exec(*_args, **_kwargs): return _BlockingProc(done_event) monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) request = _FakeRequest(payload={"command": "--help"}, can_read_body=True) response = await create_terminal_session(request, config, encoder, manager) payload = json.loads(response.body.decode("utf-8")) assert 200 == response.status assert payload["session_id"] assert "starting" == payload["status"] await asyncio.sleep(0) conflict = await create_terminal_session(request, config, encoder, manager) assert 409 == conflict.status assert b"already active" in conflict.body.lower() active = await get_active_terminal_session(config, encoder, manager) active_payload = json.loads(active.body.decode("utf-8")) assert payload["session_id"] == active_payload["session_id"] assert manager._active is not None task = manager._active.task done_event.set() await task @pytest.mark.asyncio async def test_stream_endpoint_replays_persisted_events_and_resume_cursor( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup await manager.initialize() async def fake_create_subprocess_exec(*_args, **_kwargs): return _CompletedProc([b"first\n", b"second\n"]) monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) monkeypatch.setattr("app.library.TerminalSessionManager.web.StreamResponse", _FakeStreamResponse) start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] assert manager._active is not None task = manager._active.task await task status_response = await get_terminal_session( _FakeRequest(match_info={"session_id": session_id}), config, encoder, manager ) status_payload = json.loads(status_response.body.decode("utf-8")) assert "completed" == status_payload["status"] assert 3 == status_payload["last_sequence"] assert 0 == status_payload["exit_code"] stream_request = _FakeRequest(match_info={"session_id": session_id}) stream_response = await stream_terminal_session(stream_request, config, manager) stream_payload = stream_response.payload.decode("utf-8") assert "id: 1" in stream_payload assert "id: 2" in stream_payload assert "id: 3" in stream_payload assert 'data: {"type": "stdout", "line": "first"}' in stream_payload assert 'data: {"exitcode": 0}' in stream_payload resumed_request = _FakeRequest(match_info={"session_id": session_id}, query={"since": "1"}) resumed_response = await stream_terminal_session(resumed_request, config, manager) resumed_payload = resumed_response.payload.decode("utf-8") assert "id: 1" not in resumed_payload assert "id: 2" in resumed_payload assert "id: 3" in resumed_payload @pytest.mark.asyncio async def test_completed_session_expires_after_drain_window( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup manager._drain_ttl = 0.05 await manager.initialize() async def fake_create_subprocess_exec(*_args, **_kwargs): return _CompletedProc([b"done\n"]) monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] assert manager._active is not None task = manager._active.task await task before_expiry = await manager.get_session(session_id) assert before_expiry is not None await asyncio.sleep(0.06) expired = await manager.get_session(session_id) assert expired is None assert not (manager.root_path / session_id).exists() @pytest.mark.asyncio async def test_shutdown_interrupts_active_session_and_clears_active_marker( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup manager._shutdown_timeout = 0.05 await manager.initialize() proc = _TerminableProc() async def fake_create_subprocess_exec(*_args, **_kwargs): return proc monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] await proc.wait_started.wait() await manager.on_shutdown(cast(Any, None)) metadata = await manager.get_session(session_id) transcript = manager._read_transcript(session_id=session_id, since=0) assert metadata is not None assert "interrupted" == metadata["status"] assert -15 == metadata["exit_code"] assert metadata["finished_at"] is not None assert metadata["expires_at"] is not None assert 1 == proc.terminate_calls assert 0 == proc.kill_calls assert manager._active is None assert manager._load_active_marker() is None assert "close" == transcript[-1]["event"] assert -15 == transcript[-1]["data"]["exitcode"] @pytest.mark.asyncio async def test_stream_endpoint_emits_keepalive_for_silent_active_session( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup manager._keepalive_interval = 0.01 await manager.initialize() done_event = asyncio.Event() async def fake_create_subprocess_exec(*_args, **_kwargs): return _BlockingProc(done_event) monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) monkeypatch.setattr("app.library.TerminalSessionManager.web.StreamResponse", _FakeStreamResponse) start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] assert manager._active is not None session_task = manager._active.task stream_request = _FakeRequest(match_info={"session_id": session_id}) stream_task = asyncio.create_task(_stream_session(stream_request, config, manager)) await asyncio.sleep(0.03) done_event.set() await session_task stream_response = await stream_task stream_payload = stream_response.payload.decode("utf-8") assert ": keepalive" in stream_payload assert "id: 1" in stream_payload assert 'data: {"exitcode": 0}' in stream_payload @pytest.mark.asyncio async def test_cancel_endpoint_interrupts_active_session( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup await manager.initialize() proc = _TerminableProc() async def fake_create_subprocess_exec(*_args, **_kwargs): return proc monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] await proc.wait_started.wait() cancel_response = await cancel_terminal_session( _FakeRequest(match_info={"session_id": session_id}), config, encoder, manager ) cancel_payload = json.loads(cancel_response.body.decode("utf-8")) assert 200 == cancel_response.status assert session_id == cancel_payload["session_id"] assert manager._active is not None active_task = manager._active.task await active_task metadata = await manager.get_session(session_id) transcript = manager._read_transcript(session_id=session_id, since=0) assert metadata is not None assert "interrupted" == metadata["status"] assert -15 == metadata["exit_code"] assert 1 == proc.terminate_calls assert 0 == proc.kill_calls assert transcript[-1]["event"] == "close" assert -15 == transcript[-1]["data"]["exitcode"] @pytest.mark.asyncio async def test_cancel_endpoint_returns_conflict_for_inactive_session( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup await manager.initialize() async def fake_create_subprocess_exec(*_args, **_kwargs): return _CompletedProc([b"done\n"]) monkeypatch.setattr( "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec ) monkeypatch.setattr(manager, "_open_pty", lambda: None) start_request = _FakeRequest(payload={"command": "--version"}, can_read_body=True) start_response = await create_terminal_session(start_request, config, encoder, manager) session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] assert manager._active is not None await manager._active.task cancel_response = await cancel_terminal_session( _FakeRequest(match_info={"session_id": session_id}), config, encoder, manager ) assert 409 == cancel_response.status assert b"not active" in cancel_response.body.lower() @pytest.mark.asyncio async def test_cancel_endpoint_returns_not_found_for_unknown_session( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder] ) -> None: config, manager, encoder = terminal_setup await manager.initialize() cancel_response = await cancel_terminal_session( _FakeRequest(match_info={"session_id": "missing"}), config, encoder, manager ) assert 404 == cancel_response.status assert b"not found" in cancel_response.body.lower() async def _stream_session( request: _FakeRequest, config: Config, manager: TerminalSessionManager ) -> _FakeStreamResponse: response = await stream_terminal_session(request, config, manager) assert isinstance(response, _FakeStreamResponse) return response