"""Tests for local_server module - unit tests for helper functions."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from aiohttp import web
from textual_webterm.config import App, Config
from textual_webterm.local_server import (
LocalClientConnector,
LocalServer,
_apply_carriage_returns,
_rewrite_svg_fonts,
)
class TestGetStaticPath:
"""Tests for static path function."""
def test_static_path_exists(self):
"""Test that static path exists."""
from textual_webterm.local_server import _get_static_path
path = _get_static_path()
assert path is not None and path.exists()
def test_static_path_has_js(self):
"""Test that static path has JS directory."""
from textual_webterm.local_server import _get_static_path
path = _get_static_path()
assert path is not None
assert (path / "js").exists()
def test_static_path_has_css(self):
"""Test that static path has CSS directory."""
from textual_webterm.local_server import _get_static_path
path = _get_static_path()
assert path is not None
assert (path / "css").exists()
class TestLocalServer:
"""Tests for LocalServer class."""
@pytest.fixture
def config(self):
"""Create a test config."""
return Config(
apps=[
App(name="Test", slug="test", path="./", command="echo test", terminal=True),
],
)
@pytest.fixture
def server(self, config, tmp_path):
"""Create a test server."""
config_file = tmp_path / "config.toml"
config_file.write_text("")
return LocalServer(
config_path=str(config_file),
config=config,
host="localhost",
port=8080,
)
def test_init(self, server):
"""Test LocalServer initialization."""
assert server.host == "localhost"
assert server.port == 8080
assert server.session_manager is not None
def test_add_app(self, server):
"""Test adding an app."""
server.add_app("New App", "python app.py", "newapp")
assert "newapp" in server.session_manager.apps_by_slug
def test_add_terminal(self, server):
"""Test adding a terminal."""
server.add_terminal("Terminal", "bash", "term")
assert "term" in server.session_manager.apps_by_slug
app = server.session_manager.apps_by_slug["term"]
assert app.terminal is True
@pytest.mark.asyncio
async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch):
from textual_webterm import local_server
monkeypatch.setattr(local_server, "generate", lambda: "fixed-session")
session = MagicMock()
session.start = AsyncMock()
monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session))
await server._create_terminal_session("test", 80, 24)
server.session_manager.new_session.assert_awaited_once_with(
"test",
"fixed-session",
"test",
size=(80, 24),
)
session.start.assert_awaited_once()
connector = session.start.call_args.args[0]
assert connector.session_id == "fixed-session"
assert connector.route_key == "test"
class TestLocalServerHelpers:
"""Tests for LocalServer helper methods."""
def test_apply_carriage_returns_overwrites_line(self):
text = "hello\rworld\nnext"
assert _apply_carriage_returns(text) == ["world", "next"]
@pytest.mark.asyncio
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
ws1 = MagicMock()
ws1.close = AsyncMock()
ws2 = MagicMock()
ws2.close = AsyncMock()
server._websocket_connections["a"] = ws1
server._websocket_connections["b"] = ws2
monkeypatch.setattr(server.session_manager, "close_all", AsyncMock())
server.on_keyboard_interrupt()
assert server._shutdown_task is not None
await server._shutdown_task
ws1.close.assert_awaited_once()
ws2.close.assert_awaited_once()
server.session_manager.close_all.assert_awaited_once()
assert server.exit_event.is_set()
@pytest.mark.asyncio
async def test_ws_resize_creates_session_when_slug_exists(self, server, monkeypatch):
server.session_manager.apps_by_slug["slug"] = App(
name="Known",
slug="slug",
path="./",
command="echo ok",
terminal=True,
)
monkeypatch.setattr(server, "_create_terminal_session", AsyncMock())
ws = MagicMock()
session_created = await server._dispatch_ws_message(
["resize", {"width": 100, "height": 40}],
"slug",
ws,
session_created=False,
)
assert session_created is True
server._create_terminal_session.assert_awaited_once_with("slug", 100, 40)
@pytest.mark.asyncio
async def test_ws_resize_sends_error_if_no_apps(self, server):
ws = MagicMock()
ws.send_json = AsyncMock()
server._websocket_connections["rk"] = ws
session_created = await server._dispatch_ws_message(
["resize", {"width": 80, "height": 24}],
"rk",
ws,
session_created=False,
)
assert session_created is True
ws.send_json.assert_awaited_once_with(["error", "No app configured"])
@pytest.mark.asyncio
async def test_resize_on_disconnect_calls_set_terminal_size(self, server, monkeypatch):
session = MagicMock()
session.set_terminal_size = AsyncMock()
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session)
await server._resize_on_disconnect("rk")
session.set_terminal_size.assert_called_once_with(132, 45)
@pytest.mark.asyncio
async def test_create_terminal_session_sends_error_if_no_apps(self, server):
ws = MagicMock()
ws.send_json = AsyncMock()
server._websocket_connections["rk"] = ws
await server._create_terminal_session("rk", 80, 24)
ws.send_json.assert_awaited_once_with(["error", "No app configured"])
@pytest.mark.asyncio
async def test_screenshot_svg_handler_returns_svg(self, server, monkeypatch, capsys):
request = MagicMock()
request.query = {"route_key": "rk", "width": "80"}
session = MagicMock()
session.get_replay_buffer = AsyncMock(return_value=b"hello\r\n")
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session)
response = await server._handle_screenshot(request)
assert response.content_type == "image/svg+xml"
assert "'
)
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 = ''
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, "")
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, "")
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, "")
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 "