merge
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for textual-webterm."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user