This commit is contained in:
Rui Carmo
2026-01-21 23:53:57 +00:00
commit a0e31d43fd
52 changed files with 6312 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for textual-webterm."""
+76
View File
@@ -0,0 +1,76 @@
"""Pytest configuration and fixtures for textual-webterm tests."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import pytest
from textual_webterm.config import App, Config
from textual_webterm.local_server import LocalServer
from textual_webterm.poller import Poller
from textual_webterm.session_manager import SessionManager
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
@pytest.fixture
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
"""Create an event loop for async tests."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def sample_terminal_app() -> App:
"""Create a sample terminal app configuration."""
return App(
name="Test Terminal",
slug="test-terminal",
terminal=True,
command="echo hello",
)
@pytest.fixture
def sample_config(sample_terminal_app: App) -> Config:
"""Create a sample configuration with a terminal app."""
return Config(apps=[sample_terminal_app])
@pytest.fixture
def tmp_config_path(tmp_path: Path) -> Path:
"""Create a temporary config path."""
return tmp_path / "config"
@pytest.fixture
def poller() -> Poller:
"""Create a Poller instance."""
return Poller()
@pytest.fixture
def session_manager(poller: Poller, tmp_path: Path, sample_terminal_app: App) -> SessionManager:
"""Create a SessionManager instance."""
return SessionManager(poller, tmp_path, [sample_terminal_app])
@pytest.fixture
async def local_server(
tmp_config_path: Path, sample_config: Config
) -> AsyncGenerator[LocalServer, None]:
"""Create a LocalServer instance for testing."""
server = LocalServer(
str(tmp_config_path),
sample_config,
host="127.0.0.1",
port=0, # Use random available port
)
yield server
# Cleanup
server.force_exit()
+133
View File
@@ -0,0 +1,133 @@
"""Tests for app_session module."""
import asyncio
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual_webterm.app_session import AppSession, ProcessState
class TestProcessState:
"""Tests for ProcessState enum."""
def test_process_states_exist(self):
"""Test that all process states exist."""
assert ProcessState.PENDING is not None
assert ProcessState.RUNNING is not None
assert ProcessState.CLOSED is not None
class TestAppSession:
"""Tests for AppSession class."""
@pytest.fixture
def mock_path(self, tmp_path):
"""Create a mock path."""
return tmp_path
def test_init(self, mock_path):
"""Test AppSession initialization."""
session = AppSession(mock_path, "python app.py", "test-session")
assert session.working_directory == mock_path
assert session.command == "python app.py"
assert session.session_id == "test-session"
assert session.state == ProcessState.PENDING
def test_init_with_devtools(self, mock_path):
"""Test AppSession with devtools enabled."""
session = AppSession(mock_path, "python app.py", "test-session", devtools=True)
assert session.devtools is True
@pytest.mark.asyncio
async def test_send_bytes_not_running(self, mock_path):
"""Test send_bytes when not running returns False."""
session = AppSession(mock_path, "python app.py", "test-session")
# Session not started, will return False gracefully
result = await session.send_bytes(b"test")
assert result is False
@pytest.mark.asyncio
async def test_send_meta(self, mock_path):
"""Test send_meta."""
session = AppSession(mock_path, "python app.py", "test-session")
session._process = MagicMock()
session._process.stdin = MagicMock()
session._process.stdin.write = MagicMock()
session._process.stdin.drain = AsyncMock()
await session.send_meta({"key": "value"})
# Should handle meta data
@pytest.mark.asyncio
async def test_set_terminal_size(self, mock_path):
"""Test set_terminal_size."""
session = AppSession(mock_path, "python app.py", "test-session")
session._process = MagicMock()
session._process.stdin = MagicMock()
session._process.stdin.write = MagicMock()
session._process.stdin.drain = AsyncMock()
# Should not raise
await session.set_terminal_size(100, 50)
@pytest.mark.asyncio
async def test_close_not_running(self, mock_path):
"""Test close when not running handles gracefully."""
session = AppSession(mock_path, "python app.py", "test-session")
# No process running, close should handle gracefully (not crash)
await session.close()
assert session.state == ProcessState.CLOSING
@pytest.mark.asyncio
async def test_wait_no_task(self, mock_path):
"""Test wait when no task."""
session = AppSession(mock_path, "python app.py", "test-session")
# Should not raise
await session.wait()
def test_state_transitions(self, mock_path):
"""Test state transition tracking."""
session = AppSession(mock_path, "python app.py", "test-session")
assert session.state == ProcessState.PENDING
# Manually set state for testing
session.state = ProcessState.RUNNING
assert session.state == ProcessState.RUNNING
session.state = ProcessState.CLOSED
assert session.state == ProcessState.CLOSED
class TestAppSessionConnector:
"""Tests for AppSession with connector."""
@pytest.fixture
def mock_connector(self):
"""Create a mock connector."""
connector = MagicMock()
connector.on_data = AsyncMock()
connector.on_close = AsyncMock()
return connector
@pytest.mark.asyncio
async def test_start_creates_task(self, tmp_path, mock_connector):
"""Test that start creates a task."""
session = AppSession(tmp_path, "echo test", "test-session")
with (
patch.object(session, "open", new_callable=AsyncMock),
patch.object(session, "run", new_callable=AsyncMock),
):
task = await session.start(mock_connector)
assert task is not None
# Cancel to clean up
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+227
View File
@@ -0,0 +1,227 @@
"""Tests for CLI module."""
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
class TestParseAppPath:
"""Tests for parse_app_path function."""
def test_parse_module_class(self):
"""Test parsing module:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("mymodule:MyClass")
assert module == "mymodule"
assert cls == "MyClass"
def test_parse_nested_module_class(self):
"""Test parsing nested.module:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("my.nested.module:MyClass")
assert module == "my.nested.module"
assert cls == "MyClass"
def test_parse_file_path_class(self):
"""Test parsing file/path.py:class format."""
from textual_webterm.cli import parse_app_path
module, cls = parse_app_path("path/to/file.py:MyClass")
assert module == "path/to/file.py"
assert cls == "MyClass"
def test_parse_no_colon_raises(self):
"""Test that missing colon raises BadParameter."""
from textual_webterm.cli import parse_app_path
with pytest.raises(click.BadParameter) as exc_info:
parse_app_path("invalid_format")
assert "Expected format" in str(exc_info.value)
class TestLoadAppClass:
"""Tests for load_app_class function."""
def test_load_nonexistent_module(self):
"""Test loading from non-existent module raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("nonexistent_module_xyz:MyClass")
assert "Could not import" in str(exc_info.value)
def test_load_nonexistent_class(self):
"""Test loading non-existent class from existing module raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("os:NonExistentClass")
assert "has no attribute" in str(exc_info.value)
def test_load_existing_class(self):
"""Test loading an existing class from a module."""
from textual_webterm.cli import load_app_class
# Load Path from pathlib
cls = load_app_class("pathlib:Path")
assert cls is Path
def test_load_from_file_nonexistent(self):
"""Test loading from non-existent file raises."""
from textual_webterm.cli import load_app_class
with pytest.raises(click.BadParameter) as exc_info:
load_app_class("/nonexistent/path.py:MyClass")
assert (
"not found" in str(exc_info.value).lower()
or "does not exist" in str(exc_info.value).lower()
)
class TestCLI:
"""Tests for CLI command."""
def test_cli_help(self):
"""Test CLI help output."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert result.exit_code == 0
assert "terminal" in result.output.lower() or "command" in result.output.lower()
def test_cli_runs_terminal_command(self, monkeypatch):
from textual_webterm import cli
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_terminal(self, name, command, slug):
calls["terminal"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
runner = CliRunner()
result = runner.invoke(cli.app, ["htop"])
assert result.exit_code == 0
assert calls["terminal"][1] == "htop"
def test_cli_runs_default_shell(self, monkeypatch):
import os
from textual_webterm import cli
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_terminal(self, name, command, slug):
calls["terminal"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setenv("SHELL", "/bin/zsh")
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
runner = CliRunner()
result = runner.invoke(cli.app, [])
assert result.exit_code == 0
assert calls["terminal"][1] == os.environ["SHELL"]
def test_cli_app_module_validation_rejects(self):
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "os;rm -rf /:Fake"])
assert result.exit_code != 0
def test_cli_version(self):
"""Test CLI version output."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--version"])
assert result.exit_code == 0
assert "0.1.0" in result.output
def test_cli_invalid_app_path(self):
"""Test CLI with invalid app path."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "invalid"])
assert result.exit_code != 0
def test_cli_port_option(self):
"""Test CLI port option parsing."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--port" in result.output or "-p" in result.output
def test_cli_host_option(self):
"""Test CLI host option parsing."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--host" in result.output or "-H" in result.output
class TestModuleValidation:
"""Tests for module/class name validation in CLI."""
def test_invalid_module_characters(self):
"""Test that invalid module names are rejected."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
# Module with shell characters should be rejected or fail gracefully
result = runner.invoke(cli_app, ["--app", "os; rm -rf /:Fake"])
# Should not succeed
assert result.exit_code != 0
def test_invalid_class_name(self):
"""Test that invalid class names are rejected."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--app", "os:123invalid"])
assert result.exit_code != 0
class TestCLIOptions:
"""Tests for CLI option handling."""
def test_debug_option(self):
"""Test --debug option exists."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
assert "--app" in result.output
def test_no_run_option(self):
"""Test --no-run option exists."""
from textual_webterm.cli import app as cli_app
runner = CliRunner()
result = runner.invoke(cli_app, ["--help"])
# Check that basic options are documented
assert "port" in result.output.lower()
+73
View File
@@ -0,0 +1,73 @@
import asyncio
from pathlib import Path
from click.testing import CliRunner
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
from textual_webterm import cli
manifest = tmp_path / "landing.yaml"
manifest.write_text(
"""
- name: One
slug: one
command: echo one
"""
)
called = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
called["init"] = True
def add_terminal(self, name, command, slug):
called["terminal"] = (name, command, slug)
async def run(self):
called["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli, "asyncio", asyncio)
runner = CliRunner()
result = runner.invoke(cli.app, ["-L", str(manifest)])
assert result.exit_code == 0
assert called.get("terminal") == ("One", "echo one", "one")
assert called.get("run") is True
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
from textual_webterm import cli
manifest = tmp_path / "compose.yaml"
manifest.write_text(
"""
services:
svc1:
labels:
webterm-command: echo svc1
"""
)
called = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
called["init"] = True
def add_terminal(self, name, command, slug):
called["terminal"] = (name, command, slug)
async def run(self):
called["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli, "asyncio", asyncio)
runner = CliRunner()
result = runner.invoke(cli.app, ["-C", str(manifest)])
assert result.exit_code == 0
assert called.get("terminal") == ("svc1", "echo svc1", "svc1")
assert called.get("run") is True
+55
View File
@@ -0,0 +1,55 @@
"""Extra CLI coverage tests for app execution paths."""
from __future__ import annotations
from click.testing import CliRunner
def test_cli_runs_app_from_file(monkeypatch, tmp_path):
from textual_webterm import cli
app_file = tmp_path / "myapp.py"
app_file.write_text(
"""
class MyApp:
TITLE = "MyApp"
def run(self):
return 0
""".lstrip()
)
calls: dict[str, object] = {}
class FakeServer:
def __init__(self, *_args, **_kwargs):
calls["init"] = True
def add_app(self, name, command, slug):
calls["app"] = (name, command, slug)
async def run(self):
calls["run"] = True
monkeypatch.setattr(cli, "LocalServer", FakeServer)
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
runner = CliRunner()
result = runner.invoke(cli.app, ["--app", f"{app_file}:MyApp"])
assert result.exit_code == 0
assert calls["app"][0] == "MyApp"
assert "python3" in calls["app"][1]
def test_load_app_class_from_file(tmp_path):
from textual_webterm.cli import load_app_class
app_file = tmp_path / "myapp2.py"
app_file.write_text(
"""
class MyApp2:
pass
""".lstrip()
)
cls = load_app_class(f"{app_file}:MyApp2")
assert cls.__name__ == "MyApp2"
+110
View File
@@ -0,0 +1,110 @@
"""Tests for configuration handling."""
from __future__ import annotations
from textual_webterm.config import App, Config
class TestApp:
"""Tests for App configuration."""
def test_create_terminal_app(self) -> None:
"""Test creating a terminal app configuration."""
app = App(
name="My Terminal",
slug="my-terminal",
terminal=True,
command="bash",
)
assert app.name == "My Terminal"
assert app.slug == "my-terminal"
assert app.terminal is True
assert app.command == "bash"
def test_create_textual_app(self) -> None:
"""Test creating a Textual app configuration."""
app = App(
name="My App",
slug="my-app",
terminal=False,
command="python -m myapp",
)
assert app.terminal is False
class TestConfig:
"""Tests for Config."""
def test_create_config_with_apps(self) -> None:
"""Test creating a config with apps."""
app = App(name="Test", slug="test", terminal=True, command="bash")
config = Config(apps=[app])
assert len(config.apps) == 1
assert config.apps[0].name == "Test"
def test_create_empty_config(self) -> None:
"""Test creating a config with no apps."""
config = Config(apps=[])
assert len(config.apps) == 0
class TestDefaultConfig:
"""Tests for default_config function."""
def test_default_config_returns_config(self):
"""Test that default_config returns a Config object."""
from textual_webterm.config import default_config
config = default_config()
assert config is not None
assert hasattr(config, "apps")
class TestLoadConfig:
"""Tests for load_config function."""
def test_load_config_parses_app_and_terminal(self, tmp_path):
from textual_webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[app.demo]
command = "echo demo"
[terminal.shell]
command = "bash"
""".lstrip()
)
config = load_config(config_path)
assert len(config.apps) == 2
assert {a.name for a in config.apps} == {"demo", "shell"}
assert any(a.terminal for a in config.apps)
def test_load_config_slugify_for_app(self, tmp_path):
from textual_webterm.config import load_config
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[app."My App"]
command = "echo hi"
""".lstrip()
)
config = load_config(config_path)
assert config.apps[0].slug
def test_load_config_expands_vars(self, tmp_path, monkeypatch):
from textual_webterm.config import load_config
monkeypatch.setenv("MY_CMD", "echo expanded")
config_path = tmp_path / "config.toml"
config_path.write_text(
"""
[terminal.t]
command = "$MY_CMD"
""".lstrip()
)
config = load_config(config_path)
assert config.apps[0].command == "echo expanded"
+44
View File
@@ -0,0 +1,44 @@
import tempfile
from pathlib import Path
from textual_webterm.config import load_compose_manifest, load_landing_yaml
def test_load_landing_yaml_simple():
data = """
- name: One
slug: one
command: echo one
- name: Two
command: echo two
"""
with tempfile.NamedTemporaryFile("w+", delete=False) as f:
f.write(data)
f.flush()
apps = load_landing_yaml(Path(f.name))
assert len(apps) == 2
assert apps[0].slug == "one"
assert apps[1].command == "echo two"
def test_load_compose_manifest_reads_label():
data = """
services:
svc1:
labels:
webterm-command: echo svc1
svc2:
labels:
- webterm-command=echo svc2
svc3:
labels:
other: value
"""
with tempfile.NamedTemporaryFile("w+", delete=False) as f:
f.write(data)
f.flush()
apps = load_compose_manifest(Path(f.name))
slugs = {a.slug for a in apps}
commands = {a.command for a in apps}
assert slugs == {"svc1", "svc2"}
assert "echo svc1" in commands and "echo svc2" in commands
+34
View File
@@ -0,0 +1,34 @@
"""Tests for constants helpers."""
from __future__ import annotations
def test_get_environ_bool(monkeypatch):
from textual_webterm.constants import get_environ_bool
monkeypatch.setenv("FLAG", "1")
assert get_environ_bool("FLAG") is True
monkeypatch.setenv("FLAG", "0")
assert get_environ_bool("FLAG") is False
def test_get_environ_int_keyerror(monkeypatch):
from textual_webterm.constants import get_environ_int
monkeypatch.delenv("INT", raising=False)
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valueerror(monkeypatch):
from textual_webterm.constants import get_environ_int
monkeypatch.setenv("INT", "not-an-int")
assert get_environ_int("INT", 7) == 7
def test_get_environ_int_valid(monkeypatch):
from textual_webterm.constants import get_environ_int
monkeypatch.setenv("INT", "42")
assert get_environ_int("INT", 7) == 42
+81
View File
@@ -0,0 +1,81 @@
"""Tests for LocalServer."""
from __future__ import annotations
from textual_webterm.config import App, Config
from textual_webterm.local_server import STATIC_PATH, LocalServer
class TestLocalServer:
"""Tests for LocalServer."""
def test_static_path_exists(self) -> None:
"""Test that static path is set from textual-serve."""
assert STATIC_PATH is not None
assert STATIC_PATH.exists()
def test_static_path_has_required_files(self) -> None:
"""Test that static path contains required assets."""
assert STATIC_PATH is not None
assert (STATIC_PATH / "js" / "textual.js").exists()
assert (STATIC_PATH / "css" / "xterm.css").exists()
def test_create_server(self, tmp_path) -> None:
"""Test creating a LocalServer instance."""
app = App(name="Test", slug="test", terminal=True, command="echo test")
config = Config(apps=[app])
server = LocalServer(
str(tmp_path),
config,
host="127.0.0.1",
port=8080,
)
assert server.host == "127.0.0.1"
assert server.port == 8080
assert server.app_count == 1
def test_add_app(self, tmp_path) -> None:
"""Test adding an app to the server."""
config = Config(apps=[])
server = LocalServer(str(tmp_path), config, host="127.0.0.1", port=8080)
assert server.app_count == 0
server.add_app("New App", "echo hello", slug="new-app")
assert server.app_count == 1
class TestWebSocketProtocol:
"""Tests for WebSocket protocol handling."""
def test_stdin_message_format(self) -> None:
"""Test that stdin messages use correct format."""
import json
msg = json.dumps(["stdin", "hello"])
parsed = json.loads(msg)
assert parsed[0] == "stdin"
assert parsed[1] == "hello"
def test_resize_message_format(self) -> None:
"""Test that resize messages use correct format."""
import json
msg = json.dumps(["resize", {"width": 80, "height": 24}])
parsed = json.loads(msg)
assert parsed[0] == "resize"
assert parsed[1]["width"] == 80
assert parsed[1]["height"] == 24
def test_ping_pong_format(self) -> None:
"""Test ping/pong message format."""
import json
ping = json.dumps(["ping", "12345"])
parsed = json.loads(ping)
assert parsed[0] == "ping"
pong = json.dumps(["pong", "12345"])
parsed = json.loads(pong)
assert parsed[0] == "pong"
+365
View File
@@ -0,0 +1,365 @@
"""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 LocalServer
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."""
@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 "<svg" in response.text
out = capsys.readouterr()
assert out.out == ""
assert out.err == ""
@pytest.mark.asyncio
async def test_screenshot_creates_session_for_known_slug(self, server, monkeypatch):
request = MagicMock()
request.query = {"route_key": "known", "width": "90"}
session = MagicMock()
session.get_replay_buffer = AsyncMock(return_value=b"world\r\n")
# Pretend app exists for slug "known"
server.session_manager.apps_by_slug["known"] = App(
name="Known",
slug="known",
path="./",
command="echo world",
terminal=True,
)
created = {}
async def create_session(route_key, width, height):
created["called"] = (route_key, width, height)
server.session_manager.routes["known"] = "sid"
monkeypatch.setattr(server, "_create_terminal_session", create_session)
monkeypatch.setattr(
server.session_manager,
"get_session_by_route_key",
lambda _rk: session if created else None,
)
response = await server._handle_screenshot(request)
assert response.content_type == "image/svg+xml"
assert "<svg" in response.text
assert created["called"][0] == "known"
assert created["called"][1:] == (132, 45)
@pytest.mark.asyncio
async def test_screenshot_returns_404_for_unknown_slug(self, server, monkeypatch):
request = MagicMock()
request.query = {"route_key": "unknown"}
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: None)
with pytest.raises(web.HTTPNotFound) as exc:
await server._handle_screenshot(request)
assert exc.value.status == 404
@pytest.mark.asyncio
async def test_root_click_route_key_redirects(self, server):
request = MagicMock()
request.query = {}
server._landing_apps = [
App(name="Known", slug="known", path="./", command="echo world", terminal=True)
]
response = await server._handle_root(request)
assert "/?route_key=${encodeURIComponent(tile.slug)}" in response.text
assert "visibilitychange" in response.text
@pytest.fixture
def config(self):
"""Create a test config."""
return Config(
apps=[],
)
@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_get_ws_url_basic(self, server):
"""Test basic WebSocket URL generation."""
request = MagicMock()
request.headers = {"Host": "localhost:8080"}
request.secure = False
url = server._get_ws_url_from_request(request, "test-route")
assert "ws://" in url
assert "test-route" in url
def test_get_ws_url_secure(self, server):
"""Test secure WebSocket URL generation."""
request = MagicMock()
request.headers = {"Host": "localhost:8080", "X-Forwarded-Proto": "https"}
request.secure = True
url = server._get_ws_url_from_request(request, "test-route")
assert "wss://" in url
def test_get_ws_url_forwarded_host(self, server):
"""Test WebSocket URL with forwarded host."""
request = MagicMock()
request.headers = {
"Host": "localhost:8080",
"X-Forwarded-Host": "example.com",
"X-Forwarded-Proto": "https",
}
request.secure = False
url = server._get_ws_url_from_request(request, "test-route")
assert "example.com" in url
def test_get_ws_url_forwarded_port(self, server):
"""Test WebSocket URL with forwarded port."""
request = MagicMock()
request.headers = {
"Host": "localhost:8080",
"X-Forwarded-Host": "example.com",
"X-Forwarded-Port": "9000",
}
request.secure = False
url = server._get_ws_url_from_request(request, "test-route")
assert "9000" in url
def test_get_ws_url_standard_port_omitted(self, server):
"""Test that standard ports are omitted from URL."""
request = MagicMock()
request.headers = {
"Host": "example.com",
"X-Forwarded-Port": "443",
"X-Forwarded-Proto": "https",
}
request.secure = True
url = server._get_ws_url_from_request(request, "test-route")
# Port 443 should be omitted
assert ":443" not in url or url == "wss://example.com/ws/test-route"
class TestWebSocketProtocol:
"""Tests for WebSocket protocol message formats."""
def test_stdin_message_format(self):
"""Test stdin message format."""
msg = ["stdin", "hello"]
assert msg[0] == "stdin"
assert msg[1] == "hello"
def test_resize_message_format(self):
"""Test resize message format."""
msg = ["resize", {"width": 80, "height": 24}]
assert msg[0] == "resize"
assert msg[1]["width"] == 80
assert msg[1]["height"] == 24
def test_ping_pong_format(self):
"""Test ping/pong message format."""
ping = ["ping", "1234567890"]
pong = ["pong", "1234567890"]
assert ping[0] == "ping"
assert pong[0] == "pong"
assert ping[1] == pong[1]
+67
View File
@@ -0,0 +1,67 @@
"""Tests for constants module."""
class TestConstants:
"""Tests for constants module."""
def test_import(self):
"""Test module can be imported."""
from textual_webterm import constants
assert constants is not None
def test_debug_exists(self, monkeypatch):
"""Test DEBUG constant exists and respects env var."""
import importlib
from textual_webterm import constants
assert hasattr(constants, "DEBUG")
assert isinstance(constants.DEBUG, bool)
monkeypatch.setenv("DEBUG", "1")
reloaded = importlib.reload(constants)
assert reloaded.DEBUG is True
monkeypatch.setenv("DEBUG", "0")
reloaded = importlib.reload(constants)
assert reloaded.DEBUG is False
class TestExitPoller:
"""Tests for exit_poller module."""
def test_import(self):
"""Test module can be imported."""
from textual_webterm.exit_poller import ExitPoller
assert ExitPoller is not None
async def test_exits_when_idle(self, monkeypatch):
"""ExitPoller should call force_exit after idle_wait seconds with no sessions."""
import asyncio
from textual_webterm import exit_poller
from textual_webterm.exit_poller import ExitPoller
# Speed up the poll loop for the unit test.
monkeypatch.setattr(exit_poller, "EXIT_POLL_RATE", 0.01)
class FakeServer:
def __init__(self):
class SM:
def __init__(self):
self.sessions = {}
self.session_manager = SM()
self.exited = False
def force_exit(self):
self.exited = True
server = FakeServer()
poller = ExitPoller(server, idle_wait=0.02)
poller.start()
await asyncio.sleep(0.1)
poller.stop()
assert server.exited is True
+127
View File
@@ -0,0 +1,127 @@
"""Tests for poller module."""
import asyncio
import contextlib
from unittest.mock import MagicMock, patch
import pytest
from textual_webterm.poller import Poller, Write
class TestWrite:
"""Tests for Write dataclass."""
def test_create_write(self):
"""Test creating a Write object."""
write = Write(data=b"test")
assert write.data == b"test"
assert write.position == 0
assert write.done_event is not None
def test_write_with_position(self):
"""Test Write with custom position."""
write = Write(data=b"test", position=5)
assert write.position == 5
class TestPoller:
"""Tests for Poller class."""
def test_init(self):
"""Test Poller initialization."""
poller = Poller()
assert poller._loop is None
assert poller._read_queues == {}
assert poller._write_queues == {}
assert not poller._exit_event.is_set()
def test_set_loop(self):
"""Test setting the asyncio loop."""
poller = Poller()
mock_loop = MagicMock()
poller.set_loop(mock_loop)
assert poller._loop == mock_loop
def test_add_file(self):
"""Test adding a file descriptor."""
poller = Poller()
# Use a mock file descriptor
with patch.object(poller._selector, "register"):
queue = poller.add_file(42)
assert 42 in poller._read_queues
assert isinstance(queue, asyncio.Queue)
def test_remove_file(self):
"""Test removing a file descriptor."""
poller = Poller()
# Add first
with patch.object(poller._selector, "register"):
poller.add_file(42)
# Remove
with patch.object(poller._selector, "unregister"):
poller.remove_file(42)
assert 42 not in poller._read_queues
def test_remove_nonexistent_file(self):
"""Test removing a non-existent file descriptor."""
poller = Poller()
with patch.object(poller._selector, "unregister"):
# Should not raise
poller.remove_file(999)
@pytest.mark.asyncio
async def test_write_creates_queue(self):
"""Test that write creates a write queue if needed."""
poller = Poller()
poller._loop = asyncio.get_event_loop()
# Mock selector
with patch.object(poller._selector, "register"):
poller.add_file(42)
with patch.object(poller._selector, "modify"):
# Start write in background (won't complete without poller running)
task = asyncio.create_task(poller.write(42, b"test"))
# Give it time to set up
await asyncio.sleep(0.01)
assert 42 in poller._write_queues
assert len(poller._write_queues[42]) == 1
# Cancel to clean up
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
def test_exit_sets_event(self):
"""Test that exit sets the exit event."""
poller = Poller()
poller._exit_event.clear()
# Mock join to avoid blocking
with patch.object(poller, "join"):
poller.exit()
assert poller._exit_event.is_set()
assert poller._read_queues == {}
assert poller._write_queues == {}
def test_exit_puts_none_in_queues(self):
"""Test that exit puts None in all read queues."""
poller = Poller()
# Add some queues
with patch.object(poller._selector, "register"):
q1 = poller.add_file(1)
q2 = poller.add_file(2)
# Mock join
with patch.object(poller, "join"):
poller.exit()
# Queues should have None
assert q1.get_nowait() is None
assert q2.get_nowait() is None
+40
View File
@@ -0,0 +1,40 @@
"""Tests for session management."""
from __future__ import annotations
from textual_webterm.types import RouteKey, SessionID
class TestTypes:
"""Tests for type definitions."""
def test_session_id_is_string(self) -> None:
"""Test that SessionID is a string type."""
session_id = SessionID("test-session-123")
assert isinstance(session_id, str)
assert session_id == "test-session-123"
def test_route_key_is_string(self) -> None:
"""Test that RouteKey is a string type."""
route_key = RouteKey("abc123")
assert isinstance(route_key, str)
assert route_key == "abc123"
class TestIdentity:
"""Tests for identity generation."""
def test_generate_unique_ids(self) -> None:
"""Test that generated IDs are unique."""
from textual_webterm.identity import generate
ids = [generate() for _ in range(100)]
assert len(set(ids)) == 100 # All unique
def test_generate_id_format(self) -> None:
"""Test that generated IDs have expected format."""
from textual_webterm.identity import generate
id_ = generate()
assert isinstance(id_, str)
assert len(id_) > 0
+258
View File
@@ -0,0 +1,258 @@
"""Tests for session_manager module."""
import platform
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from textual_webterm.config import App
from textual_webterm.session_manager import SessionManager
from textual_webterm.types import RouteKey, SessionID
class TestSessionManager:
"""Tests for SessionManager class."""
@pytest.fixture
def mock_poller(self):
"""Create a mock poller."""
return MagicMock()
@pytest.fixture
def mock_path(self, tmp_path):
"""Create a mock path."""
return tmp_path
@pytest.fixture
def sample_apps(self):
"""Create sample apps."""
return [
App(name="Test Terminal", slug="terminal", path="./", command="bash", terminal=True),
App(name="Test App", slug="app", path="./", command="python app.py", terminal=False),
]
def test_init(self, mock_poller, mock_path, sample_apps):
"""Test SessionManager initialization."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
assert manager.poller == mock_poller
assert manager.path == mock_path
assert len(manager.apps) == 2
assert "terminal" in manager.apps_by_slug
assert "app" in manager.apps_by_slug
assert len(manager.sessions) == 0
assert len(manager.routes) == 0
def test_get_default_app(self, mock_poller, mock_path, sample_apps):
"""Test getting the default app."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
assert manager.get_default_app() == sample_apps[0]
def test_get_default_app_empty(self, mock_poller, mock_path):
"""Test getting the default app when no apps are configured."""
manager = SessionManager(mock_poller, mock_path, [])
assert manager.get_default_app() is None
def test_add_app(self, mock_poller, mock_path):
"""Test adding an app."""
manager = SessionManager(mock_poller, mock_path, [])
manager.add_app("New App", "python new.py", "newapp", terminal=False)
assert len(manager.apps) == 1
assert "newapp" in manager.apps_by_slug
assert manager.apps_by_slug["newapp"].name == "New App"
def test_add_app_auto_slug(self, mock_poller, mock_path):
"""Test adding an app with auto-generated slug."""
manager = SessionManager(mock_poller, mock_path, [])
manager.add_app("Auto App", "python auto.py", "", terminal=False)
assert len(manager.apps) == 1
# Slug should be auto-generated
assert len(manager.apps[0].slug) > 0
def test_get_session_not_found(self, mock_poller, mock_path, sample_apps):
"""Test getting a non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
result = manager.get_session(SessionID("nonexistent"))
assert result is None
def test_get_session_by_route_key_not_found(self, mock_poller, mock_path, sample_apps):
"""Test getting session by non-existent route key."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
result = manager.get_session_by_route_key(RouteKey("nonexistent"))
assert result is None
def test_on_session_end(self, mock_poller, mock_path, sample_apps):
"""Test session end cleanup."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Manually add a session
session_id = SessionID("test-session")
route_key = RouteKey("test-route")
mock_session = MagicMock()
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
# End session
manager.on_session_end(session_id)
assert session_id not in manager.sessions
assert route_key not in manager.routes
def test_on_session_end_nonexistent(self, mock_poller, mock_path, sample_apps):
"""Test session end for non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
manager.on_session_end(SessionID("nonexistent"))
@pytest.mark.asyncio
async def test_close_all_empty(self, mock_poller, mock_path, sample_apps):
"""Test closing all sessions when empty."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
await manager.close_all()
@pytest.mark.asyncio
async def test_close_all_with_sessions(self, mock_poller, mock_path, sample_apps):
"""Test closing all sessions."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Add mock sessions
mock_session = MagicMock()
mock_session.close = AsyncMock()
mock_session.wait = AsyncMock()
manager.sessions[SessionID("s1")] = mock_session
await manager.close_all(timeout=1.0)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_session(self, mock_poller, mock_path, sample_apps):
"""Test closing a specific session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
mock_session = MagicMock()
mock_session.close = AsyncMock()
session_id = SessionID("test-session")
manager.sessions[session_id] = mock_session
await manager.close_session(session_id)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_close_session_nonexistent(self, mock_poller, mock_path, sample_apps):
"""Test closing a non-existent session."""
manager = SessionManager(mock_poller, mock_path, sample_apps)
# Should not raise
await manager.close_session(SessionID("nonexistent"))
@pytest.mark.asyncio
async def test_new_session_no_app(self, mock_poller, mock_path):
"""Test creating session with no matching app."""
manager = SessionManager(mock_poller, mock_path, [])
result = await manager.new_session(
"nonexistent",
SessionID("test"),
RouteKey("route"),
)
assert result is None
@pytest.mark.asyncio
@pytest.mark.skipif(platform.system() == "Windows", reason="Terminal not supported on Windows")
async def test_new_terminal_session(self, mock_poller, mock_path):
"""Test creating a new terminal session."""
from textual_webterm.terminal_session import TerminalSession
app = App(name="Terminal", slug="term", path="./", command="echo test", terminal=True)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(TerminalSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"term",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, TerminalSession)
assert SessionID("test-session") in manager.sessions
assert RouteKey("test-route") in manager.routes
@pytest.mark.asyncio
async def test_new_app_session(self, mock_poller, mock_path):
"""Test creating a new app session."""
from textual_webterm.app_session import AppSession
app = App(name="App", slug="app", path="./", command="python app.py", terminal=False)
manager = SessionManager(mock_poller, mock_path, [app])
with patch.object(AppSession, "open", new_callable=AsyncMock):
result = await manager.new_session(
"app",
SessionID("test-session"),
RouteKey("test-route"),
)
assert result is not None
assert isinstance(result, AppSession)
class TestSessionManagerRoutes:
"""Tests for SessionManager route handling."""
@pytest.fixture
def manager(self, tmp_path):
"""Create a session manager with mock poller."""
mock_poller = MagicMock()
return SessionManager(mock_poller, tmp_path, [])
def test_route_mapping(self, manager):
"""Test route to session mapping."""
session_id = SessionID("session1")
route_key = RouteKey("route1")
manager.routes[route_key] = session_id
assert manager.routes.get(route_key) == session_id
assert manager.routes.get_key(session_id) == route_key
def test_get_session_by_route(self, manager):
"""Test getting session by route key."""
session_id = SessionID("session1")
route_key = RouteKey("route1")
mock_session = MagicMock()
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
result = manager.get_session_by_route_key(route_key)
assert result == mock_session
def test_get_first_running_session_none(self, manager):
"""Test getting first running session when empty."""
assert manager.get_first_running_session() is None
def test_get_first_running_session_found(self, manager):
"""Test getting first running session."""
session_id = SessionID("s1")
route_key = RouteKey("r1")
mock_session = MagicMock()
mock_session.is_running.return_value = True
manager.sessions[session_id] = mock_session
manager.routes[route_key] = session_id
result = manager.get_first_running_session()
assert result == (route_key, mock_session)
+40
View File
@@ -0,0 +1,40 @@
"""Tests for slugify module."""
from textual_webterm.slugify import slugify
class TestSlugify:
"""Tests for the slugify function."""
def test_lowercase(self):
"""Test that slugify converts to lowercase."""
assert slugify("HelloWorld") == "helloworld"
def test_spaces_to_dashes(self):
"""Test that spaces are converted to dashes."""
assert slugify("hello world") == "hello-world"
def test_multiple_spaces(self):
"""Test that multiple spaces become single dash."""
assert slugify("hello world") == "hello-world"
def test_special_characters_removed(self):
"""Test that special characters are removed."""
assert slugify("hello@world!") == "helloworld"
def test_combined(self):
"""Test combination of transformations."""
assert slugify("Hello World!") == "hello-world"
def test_empty_string(self):
"""Test empty string."""
assert slugify("") == ""
def test_numbers_preserved(self):
"""Test that numbers are preserved."""
assert slugify("test123") == "test123"
def test_leading_trailing_spaces(self):
"""Test that leading/trailing spaces are handled."""
result = slugify(" hello ")
assert "hello" in result
+194
View File
@@ -0,0 +1,194 @@
"""Tests for terminal_session module."""
import os
import platform
import pty
import shlex
from unittest.mock import MagicMock, patch
import pytest
# Skip tests on Windows
pytestmark = pytest.mark.skipif(
platform.system() == "Windows",
reason="Terminal sessions not supported on Windows",
)
class TestTerminalSession:
"""Tests for TerminalSession class."""
def test_import(self):
"""Test that module can be imported."""
from textual_webterm.terminal_session import TerminalSession
assert TerminalSession is not None
def test_replay_buffer_size(self):
"""Test replay buffer size constant."""
from textual_webterm.terminal_session import REPLAY_BUFFER_SIZE
assert REPLAY_BUFFER_SIZE == 64 * 1024 # 64KB
def test_init(self):
"""Test TerminalSession initialization."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
assert session.session_id == "test-session"
assert session.command == "bash"
assert session.master_fd is None
assert session.pid is None
assert session._task is None
def test_init_default_shell(self):
"""Test that default shell is used when command is empty."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}):
session = TerminalSession(mock_poller, "test-session", "")
assert session.command == "/bin/zsh"
@pytest.mark.asyncio
async def test_replay_buffer_add(self):
"""Test adding data to replay buffer."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
await session._add_to_replay_buffer(b"test data")
assert session._replay_buffer_size == 9
assert await session.get_replay_buffer() == b"test data"
@pytest.mark.asyncio
async def test_replay_buffer_multiple_adds(self):
"""Test adding multiple chunks to replay buffer."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
await session._add_to_replay_buffer(b"chunk1")
await session._add_to_replay_buffer(b"chunk2")
assert await session.get_replay_buffer() == b"chunk1chunk2"
@pytest.mark.asyncio
async def test_replay_buffer_overflow(self):
"""Test that replay buffer trims old data when exceeding limit."""
from textual_webterm.terminal_session import (
REPLAY_BUFFER_SIZE,
TerminalSession,
)
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
# Add more data than buffer size
chunk_size = 1024
for _i in range(100): # 100KB total
await session._add_to_replay_buffer(b"x" * chunk_size)
# Buffer should be trimmed
assert session._replay_buffer_size <= REPLAY_BUFFER_SIZE + chunk_size
def test_update_connector(self):
"""Test updating connector."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
mock_connector = MagicMock()
session.update_connector(mock_connector)
assert session._connector == mock_connector
def test_is_running_not_started(self):
"""Test is_running when session not started."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
assert session.is_running() is False
@pytest.mark.asyncio
async def test_send_bytes_no_fd(self):
"""Test send_bytes returns False when no master_fd."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
result = await session.send_bytes(b"test")
assert result is False
@pytest.mark.asyncio
async def test_send_meta(self):
"""Test send_meta returns True."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
result = await session.send_meta({})
assert result is True
@pytest.mark.asyncio
async def test_close_no_pid(self):
"""Test close when no pid."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
# Should not raise
await session.close()
@pytest.mark.asyncio
async def test_wait_no_task(self):
"""Test wait when no task."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
# Should not raise
await session.wait()
def test_rich_repr(self):
"""Test rich repr output."""
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
session = TerminalSession(mock_poller, "test-session", "bash")
repr_items = list(session.__rich_repr__())
assert ("session_id", "test-session") in repr_items
assert ("command", "bash") in repr_items
@pytest.mark.asyncio
async def test_open_uses_shlex_split_and_execvp_with_args(self):
from textual_webterm.terminal_session import TerminalSession
mock_poller = MagicMock()
command = 'echo "hello world"'
session = TerminalSession(mock_poller, "test-session", command)
with (
patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork,
patch("textual_webterm.terminal_session.version", return_value="0.0.0"),
patch("textual_webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
patch("textual_webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp,
patch("textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
pytest.raises(SystemExit),
):
await session.open()
mock_fork.assert_called_once()
mock_split.assert_called_once_with(command)
mock_execvp.assert_called_once_with("echo", ["echo", "hello world"])
mock_exit.assert_called_once_with(1)
+71
View File
@@ -0,0 +1,71 @@
"""Tests for TwoWayDict."""
from __future__ import annotations
from textual_webterm._two_way_dict import TwoWayDict
class TestTwoWayDict:
"""Tests for TwoWayDict bidirectional mapping."""
def test_set_and_get(self) -> None:
"""Test basic set and get operations."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
assert d.get("a") == 1
assert d.get("b") == 2
def test_get_key(self) -> None:
"""Test reverse lookup by value."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
assert d.get_key(1) == "a"
assert d.get_key(2) == "b"
def test_delete(self) -> None:
"""Test deletion removes both mappings."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
del d["a"]
assert d.get("a") is None
assert d.get_key(1) is None
def test_contains(self) -> None:
"""Test key containment check."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
assert "a" in d
assert "b" not in d
def test_contains_value(self) -> None:
"""Test value containment check."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
assert d.contains_value(1) is True
assert d.contains_value(2) is False
def test_len(self) -> None:
"""Test length of dictionary."""
d: TwoWayDict[str, int] = TwoWayDict()
assert len(d) == 0
d["a"] = 1
assert len(d) == 1
d["b"] = 2
assert len(d) == 2
def test_iter(self) -> None:
"""Test iteration over keys."""
d: TwoWayDict[str, int] = TwoWayDict()
d["a"] = 1
d["b"] = 2
keys = list(d)
assert "a" in keys
assert "b" in keys
def test_initial_data(self) -> None:
"""Test initialization with data."""
d: TwoWayDict[str, int] = TwoWayDict({"a": 1, "b": 2})
assert d.get("a") == 1
assert d.get_key(2) == "b"