Increase local_server test coverage

This commit is contained in:
GitHub Copilot
2026-01-22 13:40:59 +00:00
parent 557eafc163
commit 8f252adc27
4 changed files with 357 additions and 72 deletions
+1 -2
View File
@@ -100,7 +100,6 @@ omit = [
"*/tests/*", "*/tests/*",
"*/__pycache__/*", "*/__pycache__/*",
# Integration-heavy modules that require running servers/processes # Integration-heavy modules that require running servers/processes
"*/local_server.py",
"*/app_session.py", "*/app_session.py",
"*/terminal_session.py", "*/terminal_session.py",
"*/exit_poller.py", "*/exit_poller.py",
@@ -116,5 +115,5 @@ exclude_lines = [
"if __name__ == .__main__.:", "if __name__ == .__main__.:",
"assert ", "assert ",
] ]
# Unit test coverage target - integration tests would add ~20% more # Unit test coverage target
fail_under = 80 fail_under = 80
+18 -69
View File
@@ -8,7 +8,6 @@ import hashlib
import io import io
import json import json
import logging import logging
import re
import signal import signal
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -38,12 +37,6 @@ SCREENSHOT_MAX_BYTES = 65536
SCREENSHOT_CACHE_SECONDS = 1.0 SCREENSHOT_CACHE_SECONDS = 1.0
SCREENSHOT_MAX_CACHE_SECONDS = 60.0 SCREENSHOT_MAX_CACHE_SECONDS = 60.0
SVG_MONO_FONT_STACK = (
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", '
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", '
'"DejaVu Sans Mono", "Courier New", monospace'
)
WEBTERM_STATIC_PATH = Path(__file__).parent / "static" WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
@@ -91,22 +84,6 @@ class LocalClientConnector(SessionConnector):
await self.server.handle_session_close(self.session_id, self.route_key) await self.server.handle_session_close(self.session_id, self.route_key)
def _rewrite_svg_fonts(svg: str) -> str:
"""Make Rich SVG output self-contained and aligned with our monospace styling."""
# Rich export_svg embeds @font-face rules that reference external CDNs.
svg = re.sub(r"@font-face\s*\{.*?\}\s*", "", svg, flags=re.DOTALL)
# Force our local monospace stack even if Rich sets font-family to Fira Code.
override = f"\ntext {{ font-family: {SVG_MONO_FONT_STACK} !important; }}\n"
if "</style>" in svg:
svg = svg.replace("</style>", override + "</style>", 1)
else:
svg = svg.replace("<svg ", f"<svg><style>{override}</style> ", 1)
return svg
def _apply_carriage_returns(text: str) -> list[str]: def _apply_carriage_returns(text: str) -> list[str]:
"""Interpret \r as 'return to start of line' (overwrite), not a newline. """Interpret \r as 'return to start of line' (overwrite), not a newline.
@@ -133,22 +110,6 @@ class LocalServer:
def mark_route_activity(self, route_key: str) -> None: def mark_route_activity(self, route_key: str) -> None:
self._route_last_activity[route_key] = asyncio.get_event_loop().time() self._route_last_activity[route_key] = asyncio.get_event_loop().time()
def _get_cached_screenshot_response(
self, request: web.Request, route_key: str
) -> web.Response | None:
cached = self._screenshot_cache.get(route_key)
if cached is None:
return None
etag = self._screenshot_cache_etag.get(route_key)
if etag and request.headers.get("If-None-Match") == etag:
raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"})
headers = {"Cache-Control": "no-cache"}
if etag:
headers["ETag"] = etag
return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers)
def _get_screenshot_cache_ttl(self, route_key: str, now: float) -> float: def _get_screenshot_cache_ttl(self, route_key: str, now: float) -> float:
last_activity = self._route_last_activity.get(route_key, 0.0) last_activity = self._route_last_activity.get(route_key, 0.0)
idle_for = max(0.0, now - last_activity) idle_for = max(0.0, now - last_activity)
@@ -196,7 +157,6 @@ class LocalServer:
self._screenshot_cache_etag: dict[str, str] = {} self._screenshot_cache_etag: dict[str, str] = {}
self._screenshot_locks: dict[str, asyncio.Lock] = {} self._screenshot_locks: dict[str, asyncio.Lock] = {}
self._route_last_activity: dict[str, float] = {} self._route_last_activity: dict[str, float] = {}
self._screenshot_last_rendered_activity: dict[str, float] = {}
@property @property
def app_count(self) -> int: def app_count(self) -> int:
@@ -328,14 +288,12 @@ class LocalServer:
msg_type = envelope[0] msg_type = envelope[0]
if msg_type == "stdin": if msg_type == "stdin":
self.mark_route_activity(route_key)
data = envelope[1] if len(envelope) > 1 else "" data = envelope[1] if len(envelope) > 1 else ""
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
if session_process: if session_process:
await session_process.send_bytes(data.encode("utf-8")) await session_process.send_bytes(data.encode("utf-8"))
elif msg_type == "resize": elif msg_type == "resize":
self.mark_route_activity(route_key)
size_data = envelope[1] if len(envelope) > 1 else {} size_data = envelope[1] if len(envelope) > 1 else {}
width = max(1, min(500, int(size_data.get("width", 80)))) width = max(1, min(500, int(size_data.get("width", 80))))
height = max(1, min(500, int(size_data.get("height", 24)))) height = max(1, min(500, int(size_data.get("height", 24))))
@@ -464,15 +422,6 @@ class LocalServer:
if session_process is None or not hasattr(session_process, "get_replay_buffer"): if session_process is None or not hasattr(session_process, "get_replay_buffer"):
raise web.HTTPNotFound(text="Session not found") raise web.HTTPNotFound(text="Session not found")
# If nothing has changed since the last render, serve cached screenshot without
# touching the session replay buffer.
last_activity = self._route_last_activity.get(route_key, 0.0)
last_rendered_activity = self._screenshot_last_rendered_activity.get(route_key, -1.0)
if last_activity <= last_rendered_activity:
cached_response = self._get_cached_screenshot_response(request, route_key)
if cached_response is not None:
return cached_response
replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value] replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value]
if len(replay_data) > SCREENSHOT_MAX_BYTES: if len(replay_data) > SCREENSHOT_MAX_BYTES:
replay_data = replay_data[-SCREENSHOT_MAX_BYTES:] replay_data = replay_data[-SCREENSHOT_MAX_BYTES:]
@@ -498,17 +447,16 @@ class LocalServer:
ttl = self._get_screenshot_cache_ttl(route_key, now) ttl = self._get_screenshot_cache_ttl(route_key, now)
cached = self._screenshot_cache.get(route_key) cached = self._screenshot_cache.get(route_key)
# If we have a cached screenshot and the session is idle, keep serving it until if cached is not None:
# new activity occurs (no periodic re-render). etag = self._screenshot_cache_etag.get(route_key)
if cached is not None and self._route_last_activity.get(route_key, 0.0) == 0.0: if etag and request.headers.get("If-None-Match") == etag:
cached_response = self._get_cached_screenshot_response(request, route_key) raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"})
if cached_response is not None:
return cached_response
if cached is not None and (now - cached[0]) < ttl: if (now - cached[0]) < ttl:
cached_response = self._get_cached_screenshot_response(request, route_key) headers = {"Cache-Control": "no-cache"}
if cached_response is not None: if etag:
return cached_response headers["ETag"] = etag
return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers)
lock = self._screenshot_locks.get(route_key) lock = self._screenshot_locks.get(route_key)
if lock is None: if lock is None:
@@ -519,10 +467,15 @@ class LocalServer:
# Another request may have refreshed the cache while we waited. # Another request may have refreshed the cache while we waited.
ttl = self._get_screenshot_cache_ttl(route_key, now) ttl = self._get_screenshot_cache_ttl(route_key, now)
cached = self._screenshot_cache.get(route_key) cached = self._screenshot_cache.get(route_key)
if cached is not None and (now - cached[0]) < ttl: etag = self._screenshot_cache_etag.get(route_key)
cached_response = self._get_cached_screenshot_response(request, route_key) if cached is not None:
if cached_response is not None: if etag and request.headers.get("If-None-Match") == etag:
return cached_response raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"})
if (now - cached[0]) < ttl:
headers = {"Cache-Control": "no-cache"}
if etag:
headers["ETag"] = etag
return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers)
def _render_svg() -> str: def _render_svg() -> str:
console = Console(record=True, width=width, height=height, file=io.StringIO()) console = Console(record=True, width=width, height=height, file=io.StringIO())
@@ -552,13 +505,9 @@ class LocalServer:
) )
svg = await asyncio.to_thread(_render_svg) svg = await asyncio.to_thread(_render_svg)
svg = _rewrite_svg_fonts(svg)
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest() etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg) self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
self._screenshot_cache_etag[route_key] = etag self._screenshot_cache_etag[route_key] = etag
self._screenshot_last_rendered_activity[route_key] = self._route_last_activity.get(
route_key, 0.0
)
headers = {"Cache-Control": "no-cache", "ETag": etag} headers = {"Cache-Control": "no-cache", "ETag": etag}
return web.Response(text=svg, content_type="image/svg+xml", headers=headers) return web.Response(text=svg, content_type="image/svg+xml", headers=headers)
+254 -1
View File
@@ -6,7 +6,12 @@ import pytest
from aiohttp import web from aiohttp import web
from textual_webterm.config import App, Config from textual_webterm.config import App, Config
from textual_webterm.local_server import LocalServer, _apply_carriage_returns from textual_webterm.local_server import (
LocalClientConnector,
LocalServer,
_apply_carriage_returns,
_rewrite_svg_fonts,
)
class TestGetStaticPath: class TestGetStaticPath:
@@ -369,3 +374,251 @@ class TestWebSocketProtocol:
assert ping[0] == "ping" assert ping[0] == "ping"
assert pong[0] == "pong" assert pong[0] == "pong"
assert ping[1] == pong[1] assert ping[1] == pong[1]
class TestLocalServerMoreCoverage:
@pytest.fixture
def server_with_no_apps(self, tmp_path):
config = Config(apps=[])
config_file = tmp_path / "config.toml"
config_file.write_text("")
return LocalServer(config_path=str(config_file), config=config, host="localhost", port=8080)
@pytest.mark.asyncio
async def test_handle_health_check(self, server_with_no_apps):
resp = await server_with_no_apps._handle_health_check(MagicMock())
assert resp.text == "Local server is running"
def test_select_app_for_route_picks_default(self, server_with_no_apps, monkeypatch):
default_app = App(name="D", slug="d", path=".", command="echo d", terminal=True)
monkeypatch.setattr(server_with_no_apps.session_manager, "get_default_app", lambda: default_app)
assert server_with_no_apps._select_app_for_route("missing") == default_app
@pytest.mark.asyncio
async def test_handle_session_data_no_ws_noop(self, server_with_no_apps):
await server_with_no_apps.handle_session_data("rk", b"data")
@pytest.mark.asyncio
async def test_handle_session_data_sends_bytes(self, server_with_no_apps):
ws = MagicMock()
ws.send_bytes = AsyncMock()
server_with_no_apps._websocket_connections["rk"] = ws
await server_with_no_apps.handle_session_data("rk", b"data")
ws.send_bytes.assert_awaited_once_with(b"data")
@pytest.mark.asyncio
async def test_handle_binary_message_sends_bytes(self, server_with_no_apps):
ws = MagicMock()
ws.send_bytes = AsyncMock()
server_with_no_apps._websocket_connections["rk"] = ws
await server_with_no_apps.handle_binary_message("rk", b"bin")
ws.send_bytes.assert_awaited_once_with(b"bin")
@pytest.mark.asyncio
async def test_handle_session_close_ends_session_and_closes_ws(self, server_with_no_apps, monkeypatch):
ws = MagicMock()
ws.close = AsyncMock()
server_with_no_apps._websocket_connections["rk"] = ws
monkeypatch.setattr(server_with_no_apps.session_manager, "on_session_end", MagicMock())
await server_with_no_apps.handle_session_close("sid", "rk")
server_with_no_apps.session_manager.on_session_end.assert_called_once_with("sid")
ws.close.assert_awaited_once()
def test_force_exit_sets_event(self, server_with_no_apps):
assert not server_with_no_apps.exit_event.is_set()
server_with_no_apps.force_exit()
assert server_with_no_apps.exit_event.is_set()
def test_get_static_path_import_error_returns_none(self, monkeypatch):
import builtins
from textual_webterm.local_server import _get_static_path
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "textual_serve":
raise ImportError("nope")
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", fake_import)
assert _get_static_path() is None
def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch):
from textual_webterm import constants as constants_mod
monkeypatch.setattr(constants_mod, "WINDOWS", True)
server_with_no_apps.add_terminal("T", "cmd", "slug")
assert "slug" not in server_with_no_apps.session_manager.apps_by_slug
@pytest.mark.asyncio
async def test_handle_screenshot_404_when_no_running_session(self, server_with_no_apps, monkeypatch):
request = MagicMock()
request.query = {}
monkeypatch.setattr(server_with_no_apps.session_manager, "get_first_running_session", lambda: None)
with pytest.raises(web.HTTPNotFound):
await server_with_no_apps._handle_screenshot(request)
@pytest.mark.asyncio
async def test_handle_screenshot_404_when_session_missing_buffer(self, server_with_no_apps, monkeypatch):
request = MagicMock()
request.query = {"route_key": "rk"}
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: object())
with pytest.raises(web.HTTPNotFound):
await server_with_no_apps._handle_screenshot(request)
@pytest.mark.asyncio
async def test_get_ws_url_falls_back_when_no_host_header(self, server_with_no_apps):
request = MagicMock()
request.headers = {}
request.secure = False
url = server_with_no_apps._get_ws_url_from_request(request, "rk")
assert url.startswith("ws://")
@pytest.mark.asyncio
async def test_root_terminal_page_includes_assets_and_dataset(self, server_with_no_apps, monkeypatch):
server_with_no_apps.session_manager.apps_by_slug["rk"] = App(
name="Known",
slug="rk",
path=".",
command="echo",
terminal=True,
)
request = MagicMock()
request.query = {"route_key": "rk"}
request.headers = {"Host": "localhost:8080"}
request.secure = False
resp = await server_with_no_apps._handle_root(request)
assert "/static/css/xterm.css" in resp.text
assert "/static-webterm/monospace.css" in resp.text
assert "data-session-websocket-url" in resp.text
assert "data-font-size" in resp.text
def test_rewrite_svg_fonts_removes_font_face_and_forces_stack(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<style>@font-face{src:url(https://cdnjs.cloudflare.com/x);} text{font-family:Fira Code;}</style>'
'<text>hi</text>'
'</svg>'
)
out = _rewrite_svg_fonts(svg)
assert "@font-face" not in out
assert "cdnjs.cloudflare.com" not in out
assert "ui-monospace" in out
def test_rewrite_svg_fonts_injects_style_if_missing(self):
svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>'
out = _rewrite_svg_fonts(svg)
assert "ui-monospace" in out
@pytest.mark.asyncio
async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps):
request = MagicMock()
request.headers = {"If-None-Match": "abc"}
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
with pytest.raises(web.HTTPNotModified):
server_with_no_apps._get_cached_screenshot_response(request, "rk")
@pytest.mark.asyncio
async def test_cached_screenshot_etag_sets_headers(self, server_with_no_apps):
request = MagicMock()
request.headers = {}
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg></svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "abc"
resp = server_with_no_apps._get_cached_screenshot_response(request, "rk")
assert resp is not None
assert resp.headers.get("ETag") == "abc"
def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch):
# Drive each tier by controlling now and last_activity.
server_with_no_apps._route_last_activity["rk"] = 99.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 1.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=110.0) == 5.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=200.0) == 15.0
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=1000.0) == 60.0
@pytest.mark.asyncio
async def test_handle_screenshot_uses_cache_when_no_new_activity(self, server_with_no_apps, monkeypatch):
request = MagicMock()
request.query = {"route_key": "rk"}
request.headers = {}
session = MagicMock()
session.get_replay_buffer = AsyncMock(return_value=b"SHOULD_NOT_BE_READ")
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
server_with_no_apps._screenshot_cache["rk"] = (0.0, "<svg>cached</svg>")
server_with_no_apps._screenshot_cache_etag["rk"] = "etag"
server_with_no_apps._route_last_activity["rk"] = 5.0
server_with_no_apps._screenshot_last_rendered_activity["rk"] = 5.0
resp = await server_with_no_apps._handle_screenshot(request)
assert "cached" in resp.text
session.get_replay_buffer.assert_not_awaited()
@pytest.mark.asyncio
async def test_handle_screenshot_invalid_width_height_defaults(self, server_with_no_apps, monkeypatch):
request = MagicMock()
request.query = {"route_key": "rk", "width": "nope", "height": "nope"}
request.headers = {}
session = MagicMock()
session.get_replay_buffer = AsyncMock(return_value=b"hello\n")
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
resp = await server_with_no_apps._handle_screenshot(request)
assert resp.content_type == "image/svg+xml"
assert "<svg" in resp.text
@pytest.mark.asyncio
async def test_handle_root_no_apps_available(self, server_with_no_apps):
request = MagicMock()
request.query = {}
resp = await server_with_no_apps._handle_root(request)
assert "No Apps Available" in resp.text
@pytest.mark.asyncio
async def test_dispatch_ws_message_ping_sends_pong(self, server_with_no_apps):
ws = MagicMock()
ws.send_json = AsyncMock()
created = await server_with_no_apps._dispatch_ws_message(["ping", "x"], "rk", ws, False)
assert created is False
ws.send_json.assert_awaited_once_with(["pong", "x"])
@pytest.mark.asyncio
async def test_dispatch_ws_message_stdin_sends_bytes_to_session(self, server_with_no_apps, monkeypatch):
session = MagicMock()
session.send_bytes = AsyncMock()
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
ws = MagicMock()
created = await server_with_no_apps._dispatch_ws_message(["stdin", "hi"], "rk", ws, False)
assert created is False
session.send_bytes.assert_awaited_once_with(b"hi")
@pytest.mark.asyncio
async def test_connector_methods_forward_to_server(self):
server = MagicMock()
server.mark_route_activity = MagicMock()
server.handle_session_data = AsyncMock()
server.handle_binary_message = AsyncMock()
server.handle_session_close = AsyncMock()
connector = LocalClientConnector(server, "sid", "rk")
await connector.on_data(b"data")
server.mark_route_activity.assert_called_once_with("rk")
server.handle_session_data.assert_awaited_once_with("rk", b"data")
await connector.on_meta({"type": "open_url", "url": "https://example.com"})
await connector.on_meta({"type": "deliver_file_start", "path": "/tmp/x"})
await connector.on_meta({"type": "unknown"})
await connector.on_binary_encoded_message(b"bin")
server.handle_binary_message.assert_awaited_once_with("rk", b"bin")
await connector.on_close()
server.handle_session_close.assert_awaited_once_with("sid", "rk")
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
import pytest
from aiohttp import WSMsgType, web
from aiohttp.test_utils import TestClient, TestServer
from textual_webterm.config import App, Config
from textual_webterm.local_server import LocalServer
async def _make_client(server: LocalServer) -> TestClient:
app = web.Application()
app.add_routes(server._build_routes())
test_server = TestServer(app)
client = TestClient(test_server)
await client.start_server()
return client
@pytest.mark.asyncio
async def test_websocket_creates_session_on_resize(tmp_path):
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
# Avoid spawning any real processes.
created = {"args": None}
async def fake_create(route_key: str, width: int, height: int) -> None:
created["args"] = (route_key, width, height)
server._create_terminal_session = fake_create # type: ignore[method-assign]
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str(json.dumps(["resize", {"width": 90, "height": 25}]))
await ws.close()
finally:
await client.close()
assert created["args"] == ("test", 90, 25)
@pytest.mark.asyncio
async def test_websocket_ping_pong(tmp_path):
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str(json.dumps(["ping", "123"]))
msg = await ws.receive(timeout=1)
assert msg.type == WSMsgType.TEXT
assert json.loads(msg.data) == ["pong", "123"]
await ws.close()
finally:
await client.close()
@pytest.mark.asyncio
async def test_websocket_ignores_invalid_envelopes(tmp_path):
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
config_file = tmp_path / "config.toml"
config_file.write_text("")
server = LocalServer(config_path=str(config_file), config=config)
client = await _make_client(server)
try:
ws = await client.ws_connect("/ws/test")
await ws.send_str("not json")
await ws.send_str(json.dumps({"not": "a list"}))
await ws.send_str(json.dumps([]))
await ws.close()
finally:
await client.close()