mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Add web UI with FastAPI + HTMX + xterm.js (#13)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from pathlib import Path # noqa: TC003
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -55,12 +55,12 @@ class TestMigrationCommands:
|
||||
commands_called: list[str] = []
|
||||
|
||||
async def mock_run_compose_step(
|
||||
cfg: Config, # noqa: ARG001
|
||||
cfg: Config,
|
||||
service: str,
|
||||
command: str,
|
||||
*,
|
||||
raw: bool, # noqa: ARG001
|
||||
host: str | None = None, # noqa: ARG001
|
||||
raw: bool,
|
||||
host: str | None = None,
|
||||
) -> CommandResult:
|
||||
commands_called.append(command)
|
||||
return CommandResult(
|
||||
|
||||
1
tests/web/__init__.py
Normal file
1
tests/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web UI tests."""
|
||||
99
tests/web/conftest.py
Normal file
99
tests/web/conftest.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Fixtures for web UI tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compose_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary compose directory with sample services."""
|
||||
compose_path = tmp_path / "compose"
|
||||
compose_path.mkdir()
|
||||
|
||||
# Create a sample service
|
||||
plex_dir = compose_path / "plex"
|
||||
plex_dir.mkdir()
|
||||
(plex_dir / "compose.yaml").write_text("""
|
||||
services:
|
||||
plex:
|
||||
image: plexinc/pms-docker
|
||||
container_name: plex
|
||||
ports:
|
||||
- "32400:32400"
|
||||
""")
|
||||
(plex_dir / ".env").write_text("PLEX_CLAIM=claim-xxx\n")
|
||||
|
||||
# Create another service
|
||||
sonarr_dir = compose_path / "sonarr"
|
||||
sonarr_dir.mkdir()
|
||||
(sonarr_dir / "compose.yaml").write_text("""
|
||||
services:
|
||||
sonarr:
|
||||
image: linuxserver/sonarr
|
||||
""")
|
||||
|
||||
return compose_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path: Path, compose_dir: Path) -> Path:
|
||||
"""Create a temporary config file and state file."""
|
||||
config_path = tmp_path / "compose-farm.yaml"
|
||||
config_path.write_text(f"""
|
||||
compose_dir: {compose_dir}
|
||||
|
||||
hosts:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
server-2:
|
||||
address: 192.168.1.11
|
||||
|
||||
services:
|
||||
plex: server-1
|
||||
sonarr: server-2
|
||||
""")
|
||||
|
||||
# State file must be alongside config file
|
||||
state_path = tmp_path / "compose-farm-state.yaml"
|
||||
state_path.write_text("""
|
||||
deployed:
|
||||
plex: server-1
|
||||
""")
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(
|
||||
config_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Generator[Config, None, None]:
|
||||
"""Patch get_config to return a test config."""
|
||||
from compose_farm.config import load_config
|
||||
from compose_farm.web import deps as web_deps
|
||||
from compose_farm.web.routes import api as web_api
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Save original and clear cache before patching
|
||||
original_get_config = web_deps.get_config
|
||||
original_get_config.cache_clear()
|
||||
|
||||
# Patch in all modules that import get_config
|
||||
monkeypatch.setattr(web_deps, "get_config", lambda: config)
|
||||
monkeypatch.setattr(web_api, "get_config", lambda: config)
|
||||
|
||||
yield config
|
||||
|
||||
# monkeypatch auto-restores, then clear cache
|
||||
# (cache_clear happens after monkeypatch cleanup via addfinalizier)
|
||||
monkeypatch.undo()
|
||||
original_get_config.cache_clear()
|
||||
106
tests/web/test_helpers.py
Normal file
106
tests/web/test_helpers.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for web API helper functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
|
||||
class TestValidateYaml:
|
||||
"""Tests for _validate_yaml helper."""
|
||||
|
||||
def test_valid_yaml(self) -> None:
|
||||
from compose_farm.web.routes.api import _validate_yaml
|
||||
|
||||
# Should not raise
|
||||
_validate_yaml("key: value")
|
||||
_validate_yaml("list:\n - item1\n - item2")
|
||||
_validate_yaml("")
|
||||
|
||||
def test_invalid_yaml(self) -> None:
|
||||
from compose_farm.web.routes.api import _validate_yaml
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_yaml("key: [unclosed")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Invalid YAML" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetServiceComposePath:
|
||||
"""Tests for _get_service_compose_path helper."""
|
||||
|
||||
def test_service_found(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _get_service_compose_path
|
||||
|
||||
path = _get_service_compose_path("plex")
|
||||
assert isinstance(path, Path)
|
||||
assert path.name == "compose.yaml"
|
||||
assert path.parent.name == "plex"
|
||||
|
||||
def test_service_not_found(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _get_service_compose_path
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_get_service_compose_path("nonexistent")
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "not found" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestRenderContainers:
|
||||
"""Tests for container template rendering."""
|
||||
|
||||
def test_render_running_container(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "running"}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "badge-success" in html
|
||||
assert "plex" in html
|
||||
assert "initExecTerminal" in html
|
||||
|
||||
def test_render_unknown_state(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "unknown"}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "loading-spinner" in html
|
||||
|
||||
def test_render_other_state(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "exited"}]
|
||||
html = _render_containers("plex", "server-1", containers)
|
||||
|
||||
assert "badge-warning" in html
|
||||
assert "exited" in html
|
||||
|
||||
def test_render_with_header(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [{"Name": "plex", "State": "running"}]
|
||||
html = _render_containers("plex", "server-1", containers, show_header=True)
|
||||
|
||||
assert "server-1" in html
|
||||
assert "font-semibold" in html
|
||||
|
||||
def test_render_multiple_containers(self, mock_config: Config) -> None:
|
||||
from compose_farm.web.routes.api import _render_containers
|
||||
|
||||
containers = [
|
||||
{"Name": "app-web-1", "State": "running"},
|
||||
{"Name": "app-db-1", "State": "running"},
|
||||
]
|
||||
html = _render_containers("app", "server-1", containers)
|
||||
|
||||
assert "app-web-1" in html
|
||||
assert "app-db-1" in html
|
||||
Reference in New Issue
Block a user