Files
cleveragents-core/features/steps/tui_interface_steps.py
freemo e7df15d453 feat(ui): add TUI/Web interface
Implement UI data-provider interface backed by local services.
Add Textual-based TUI with plan list, plan detail, diff viewer,
and validation summary panes. Add Web UI stub with local-only
read-only routes. Configure auto-refresh and manual keybinds.

ISSUES CLOSED: #341
2026-03-24 20:30:54 +00:00

354 lines
11 KiB
Python

"""Step definitions for the TUI/Web UI interface feature."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.ui.data_provider import (
DiffItem,
LocalUIDataProvider,
LogEntry,
PlanDetail,
PlanSummary,
SessionSummary,
ValidationResult,
)
# ---------------------------------------------------------------------------
# Fixtures: mock plan objects
# ---------------------------------------------------------------------------
_PLAN_ALPHA = MagicMock()
_PLAN_ALPHA.id = "plan-1"
_PLAN_ALPHA.name = "plan-alpha"
_PLAN_ALPHA.status = "draft"
_PLAN_ALPHA.created_at = "2025-01-01T00:00:00"
_PLAN_ALPHA.prompt = "Do something"
_PLAN_ALPHA.decisions = []
_PLAN_ALPHA.linked_resources = []
_PLAN_BETA = MagicMock()
_PLAN_BETA.id = "plan-2"
_PLAN_BETA.name = "plan-beta"
_PLAN_BETA.status = "built"
_PLAN_BETA.created_at = "2025-01-02T00:00:00"
_PLAN_BETA.prompt = "Do something else"
_DEC = MagicMock()
_DEC.id = "dec-1"
_DEC.type = "approval"
_DEC.status = "pending"
_PLAN_ALPHA_WITH_DEC = MagicMock()
_PLAN_ALPHA_WITH_DEC.id = "plan-1"
_PLAN_ALPHA_WITH_DEC.name = "plan-alpha"
_PLAN_ALPHA_WITH_DEC.status = "draft"
_PLAN_ALPHA_WITH_DEC.created_at = "2025-01-01T00:00:00"
_PLAN_ALPHA_WITH_DEC.prompt = "Do something"
_PLAN_ALPHA_WITH_DEC.decisions = [_DEC]
_PLAN_ALPHA_WITH_DEC.linked_resources = ["res-1"]
_PLAN_ALPHA_WITH_DEC.project_id = "proj-1"
# ---------------------------------------------------------------------------
# Mock data provider for web tests
# ---------------------------------------------------------------------------
class _MockProvider:
"""Trivial UIDataProvider for web route testing."""
def __init__(self, *, with_plans: bool = False) -> None:
self._with_plans = with_plans
def get_plans(self, project_id: str) -> list[PlanSummary]:
if not self._with_plans:
return []
return [
PlanSummary(
plan_id="plan-1",
name="plan-alpha",
status="draft",
created_at="2025-01-01T00:00:00",
project_id=project_id or "proj-1",
),
]
def get_plan_detail(self, plan_id: str) -> PlanDetail | None:
if not self._with_plans:
return None
if plan_id != "plan-1":
return None
return PlanDetail(
plan_id="plan-1",
name="plan-alpha",
status="draft",
created_at="2025-01-01T00:00:00",
project_id="proj-1",
prompt="Do something",
decisions=[{"id": "d1", "type": "approval", "status": "pending"}],
linked_resources=["res-1"],
)
def get_sessions(self) -> list[SessionSummary]:
return []
def get_validations(self, plan_id: str) -> list[ValidationResult]:
return []
def get_diffs(self, plan_id: str) -> list[DiffItem]:
return []
def get_logs(self, plan_id: str, level: str = "INFO") -> list[LogEntry]:
return []
# ---------------------------------------------------------------------------
# UIDataProvider steps
# ---------------------------------------------------------------------------
@given("a UIDataProvider backed by mock services")
def step_provider_empty(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.list_plans.return_value = []
mock_svc.get_plan.return_value = None
context.ui_provider = LocalUIDataProvider(plan_service=mock_svc)
@given("a UIDataProvider backed by mock services with plans")
def step_provider_with_plans(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.list_plans.return_value = [_PLAN_ALPHA, _PLAN_BETA]
mock_svc.get_plan.side_effect = lambda pid: (
_PLAN_ALPHA_WITH_DEC if pid == "plan-1" else None
)
context.ui_provider = LocalUIDataProvider(plan_service=mock_svc)
@when('I request UI plans for project "{project_id}"')
def step_request_ui_plans(context: Context, project_id: str) -> None:
context.ui_result_plans = context.ui_provider.get_plans(project_id)
@then("the UI plan list should be empty")
def step_ui_plans_empty(context: Context) -> None:
assert context.ui_result_plans == [], (
f"Expected empty, got {context.ui_result_plans}"
)
@then("the UI plan list should contain {count:d} plans")
def step_ui_plans_count(context: Context, count: int) -> None:
assert len(context.ui_result_plans) == count, (
f"Expected {count}, got {len(context.ui_result_plans)}"
)
@then('the first UI plan name should be "{name}"')
def step_first_ui_plan_name(context: Context, name: str) -> None:
assert context.ui_result_plans[0].name == name
@when('I request UI plan detail for "{plan_id}"')
def step_request_ui_detail(context: Context, plan_id: str) -> None:
context.ui_result_detail = context.ui_provider.get_plan_detail(plan_id)
@then('the UI plan detail should have name "{name}"')
def step_ui_detail_name(context: Context, name: str) -> None:
assert context.ui_result_detail is not None
assert context.ui_result_detail.name == name
@then("the UI plan detail should include decisions")
def step_ui_detail_decisions(context: Context) -> None:
assert context.ui_result_detail is not None
assert isinstance(context.ui_result_detail.decisions, list)
@then("the UI plan detail should be None")
def step_ui_detail_none(context: Context) -> None:
assert context.ui_result_detail is None
@when('I request UI validations for plan "{plan_id}"')
def step_request_ui_validations(context: Context, plan_id: str) -> None:
context.ui_result_validations = context.ui_provider.get_validations(plan_id)
@then("the UI validations list should be empty")
def step_ui_validations_empty(context: Context) -> None:
assert context.ui_result_validations == []
@when('I request UI diffs for plan "{plan_id}"')
def step_request_ui_diffs(context: Context, plan_id: str) -> None:
context.ui_result_diffs = context.ui_provider.get_diffs(plan_id)
@then("the UI diffs list should be empty")
def step_ui_diffs_empty(context: Context) -> None:
assert context.ui_result_diffs == []
@when('I request UI logs for plan "{plan_id}" with level "{level}"')
def step_request_ui_logs(context: Context, plan_id: str, level: str) -> None:
context.ui_result_logs = context.ui_provider.get_logs(plan_id, level)
@then("the UI logs list should be empty")
def step_ui_logs_empty(context: Context) -> None:
assert context.ui_result_logs == []
@when("I request UI sessions")
def step_request_ui_sessions(context: Context) -> None:
context.ui_result_sessions = context.ui_provider.get_sessions()
@then("the UI sessions list should be empty")
def step_ui_sessions_empty(context: Context) -> None:
assert context.ui_result_sessions == []
# ---------------------------------------------------------------------------
# Configuration steps
# ---------------------------------------------------------------------------
@given("default UI settings")
def step_default_ui_settings(context: Context) -> None:
for key in (
"CLEVERAGENTS_TUI_REFRESH_INTERVAL",
"CLEVERAGENTS_WEB_UI_ENABLED",
"CLEVERAGENTS_WEB_UI_PORT",
):
os.environ.pop(key, None)
from cleveragents.config.settings import Settings
context.ui_settings = Settings()
@given("UI settings with tui_refresh_interval set to {val:d}")
def step_custom_ui_refresh(context: Context, val: int) -> None:
os.environ["CLEVERAGENTS_TUI_REFRESH_INTERVAL"] = str(val)
from cleveragents.config.settings import Settings
context.ui_settings = Settings()
os.environ.pop("CLEVERAGENTS_TUI_REFRESH_INTERVAL", None)
@then("the TUI refresh interval should be {val:d}")
def step_check_tui_refresh(context: Context, val: int) -> None:
assert context.ui_settings.tui_refresh_interval == val
@then("web_ui_enabled should be false")
def step_web_ui_disabled(context: Context) -> None:
assert context.ui_settings.web_ui_enabled is False
@then("web_ui_port should be {port:d}")
def step_web_ui_port(context: Context, port: int) -> None:
assert context.ui_settings.web_ui_port == port
# ---------------------------------------------------------------------------
# Web route steps
# ---------------------------------------------------------------------------
@given("a web UI app backed by mock provider")
def step_web_ui_app_empty(context: Context) -> None:
from fastapi.testclient import TestClient
from cleveragents.ui.web_app import create_web_app
web = create_web_app(provider=_MockProvider())
context.web_ui_client = TestClient(web)
@given("a web UI app backed by mock provider with plans")
def step_web_ui_app_plans(context: Context) -> None:
from fastapi.testclient import TestClient
from cleveragents.ui.web_app import create_web_app
web = create_web_app(provider=_MockProvider(with_plans=True))
context.web_ui_client = TestClient(web)
@when('I send a GET to UI route "{path}"')
def step_get_ui_route(context: Context, path: str) -> None:
context.web_ui_response = context.web_ui_client.get(path)
@then("the UI response status should be {code:d}")
def step_ui_status_code(context: Context, code: int) -> None:
assert context.web_ui_response.status_code == code, (
f"Expected {code}, got {context.web_ui_response.status_code}"
)
@then('the UI response JSON should contain key "{key}"')
def step_ui_json_key(context: Context, key: str) -> None:
data = context.web_ui_response.json()
assert key in data, f"Key '{key}' not in {list(data.keys())}"
# ---------------------------------------------------------------------------
# CLI steps
# ---------------------------------------------------------------------------
@given("the CLI app is loaded for UI testing")
def step_cli_loaded_for_ui(context: Context) -> None:
from cleveragents.cli.main import ensure_cli_commands_registered
ensure_cli_commands_registered()
context.ui_cli_loaded = True
@then('the "{name}" command group should be available')
def step_command_group_available(context: Context, name: str) -> None:
from cleveragents.cli.commands.ui import app as ui_app
_ = name
assert ui_app is not None
@given("a CLI runner with web UI disabled")
def step_cli_web_ui_disabled(context: Context) -> None:
from typer.testing import CliRunner
from cleveragents.cli.commands.ui import app as ui_app
context.ui_cli_runner = CliRunner()
context.ui_cli_app = ui_app
@when('I invoke the UI CLI with "{cmd}"')
def step_invoke_ui_cli(context: Context, cmd: str) -> None:
parts = cmd.split()
with patch.dict(os.environ, {"CLEVERAGENTS_WEB_UI_ENABLED": "false"}):
context.ui_cli_result = context.ui_cli_runner.invoke(context.ui_cli_app, parts)
@then("the UI CLI should exit with code {code:d}")
def step_ui_cli_exit_code(context: Context, code: int) -> None:
assert context.ui_cli_result.exit_code == code, (
f"Expected exit code {code}, got {context.ui_cli_result.exit_code}: "
f"{context.ui_cli_result.output}"
)
@then('the UI CLI output should mention "{text}"')
def step_ui_cli_output_contains(context: Context, text: str) -> None:
assert text.lower() in context.ui_cli_result.output.lower(), (
f"'{text}' not found in output: {context.ui_cli_result.output}"
)