0fe78e7eff
CI / load-versions (pull_request) Successful in 17s
CI / push-validation (pull_request) Successful in 25s
CI / lint (pull_request) Successful in 33s
CI / build (pull_request) Successful in 39s
CI / helm (pull_request) Successful in 53s
CI / typecheck (pull_request) Successful in 1m11s
CI / security (pull_request) Successful in 1m17s
CI / quality (pull_request) Successful in 3m55s
CI / unit_tests (pull_request) Successful in 5m40s
CI / docker (pull_request) Successful in 2m13s
CI / integration_tests (pull_request) Successful in 8m32s
CI / coverage (pull_request) Successful in 9m33s
CI / status-check (pull_request) Successful in 1s
CI / load-versions (push) Successful in 12s
CI / push-validation (push) Successful in 24s
CI / build (push) Successful in 34s
CI / helm (push) Successful in 40s
CI / quality (push) Successful in 58s
CI / benchmark-publish (push) Has started running
CI / lint (push) Successful in 3m46s
CI / typecheck (push) Successful in 4m23s
CI / security (push) Successful in 4m45s
CI / integration_tests (push) Successful in 8m37s
CI / unit_tests (push) Successful in 14m53s
CI / status-check (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / coverage (push) Has been cancelled
- Verify inherited Widget._render() returns non-None via MRO traversal - Actually invoke _refresh_display() and verify correct overlay content Refs: #11039
587 lines
20 KiB
Python
587 lines
20 KiB
Python
"""Step definitions for tui_first_run.feature.
|
|
|
|
Covers:
|
|
- is_first_run helper
|
|
- create_default_persona_for_actor helper
|
|
- render_actor_selection helper
|
|
- ActorSelectionOverlay widget
|
|
- TUI app integration (on_mount first-run detection, _complete_first_run)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared mock-Textual infrastructure (mirrors tui_app_coverage_steps.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MOCK_TEXTUAL_KEYS = [
|
|
"textual",
|
|
"textual.app",
|
|
"textual.containers",
|
|
"textual.widgets",
|
|
]
|
|
|
|
|
|
def _build_mock_textual() -> dict[str, ModuleType]:
|
|
mock_textual = ModuleType("textual")
|
|
mock_textual_app = ModuleType("textual.app")
|
|
mock_textual_containers = ModuleType("textual.containers")
|
|
mock_textual_widgets = ModuleType("textual.widgets")
|
|
|
|
class MockApp:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self._widgets: dict[str, object] = {}
|
|
|
|
def query_one(self, selector: str, widget_type: type | None = None) -> object:
|
|
if selector in self._widgets:
|
|
return self._widgets[selector]
|
|
if widget_type is not None:
|
|
widget = widget_type(id=selector.lstrip("#"))
|
|
self._widgets[selector] = widget
|
|
return widget
|
|
return MagicMock()
|
|
|
|
class MockVertical:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
def __enter__(self) -> MockVertical:
|
|
return self
|
|
|
|
def __exit__(self, *args: object) -> None:
|
|
pass
|
|
|
|
class MockHeader:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
class MockFooter:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
class MockStatic:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self._text = ""
|
|
|
|
def update(self, text: str) -> None:
|
|
self._text = text
|
|
|
|
mock_textual_app.App = MockApp
|
|
mock_textual_containers.Vertical = MockVertical
|
|
mock_textual_widgets.Header = MockHeader
|
|
mock_textual_widgets.Footer = MockFooter
|
|
mock_textual_widgets.Static = MockStatic
|
|
|
|
return {
|
|
"textual": mock_textual,
|
|
"textual.app": mock_textual_app,
|
|
"textual.containers": mock_textual_containers,
|
|
"textual.widgets": mock_textual_widgets,
|
|
}
|
|
|
|
|
|
def _reload_tui_modules(mock_modules: dict[str, ModuleType]) -> None:
|
|
"""Reload TUI modules with mocked Textual in sys.modules."""
|
|
tui_keys = [
|
|
key for key in list(sys.modules.keys()) if key.startswith("cleveragents.tui")
|
|
]
|
|
for key in tui_keys:
|
|
del sys.modules[key]
|
|
for name, mod in mock_modules.items():
|
|
sys.modules[name] = mod
|
|
|
|
|
|
def _restore_textual(original: dict[str, object]) -> None:
|
|
for key in _MOCK_TEXTUAL_KEYS:
|
|
if key in original:
|
|
sys.modules[key] = original[key] # type: ignore[assignment]
|
|
else:
|
|
sys.modules.pop(key, None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_registry(tmp_dir: Path) -> object:
|
|
from cleveragents.tui.persona.registry import PersonaRegistry
|
|
|
|
return PersonaRegistry(config_dir=tmp_dir)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_first_run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an empty persona registry")
|
|
def step_empty_registry(context: object) -> None:
|
|
tmp = tempfile.mkdtemp()
|
|
context._tmp_dir = Path(tmp)
|
|
context._registry = _make_registry(context._tmp_dir)
|
|
|
|
|
|
@given('a persona registry with a default persona using actor "{actor}"')
|
|
def step_registry_with_persona(context: object, actor: str) -> None:
|
|
tmp = tempfile.mkdtemp()
|
|
context._tmp_dir = Path(tmp)
|
|
context._registry = _make_registry(context._tmp_dir)
|
|
from cleveragents.tui.persona.schema import Persona
|
|
|
|
persona = Persona(name="default", actor=actor, description="test")
|
|
context._registry.save(persona)
|
|
|
|
|
|
@then("is_first_run should return True")
|
|
def step_is_first_run_true(context: object) -> None:
|
|
from cleveragents.tui.first_run import is_first_run
|
|
|
|
assert is_first_run(context._registry) is True
|
|
|
|
|
|
@then("is_first_run should return False")
|
|
def step_is_first_run_false(context: object) -> None:
|
|
from cleveragents.tui.first_run import is_first_run
|
|
|
|
assert is_first_run(context._registry) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_default_persona_for_actor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I call create_default_persona_for_actor with actor "{actor}"')
|
|
def step_create_default_persona(context: object, actor: str) -> None:
|
|
from cleveragents.tui.first_run import create_default_persona_for_actor
|
|
|
|
context._created_persona = create_default_persona_for_actor(
|
|
context._registry, actor
|
|
)
|
|
|
|
|
|
@then('the registry should contain a persona named "{name}"')
|
|
def step_registry_has_persona(context: object, name: str) -> None:
|
|
persona = context._registry.get(name)
|
|
assert persona is not None, f"Expected persona '{name}' in registry"
|
|
|
|
|
|
@then('the default persona actor should be "{actor}"')
|
|
def step_default_persona_actor(context: object, actor: str) -> None:
|
|
persona = context._registry.get("default")
|
|
assert persona is not None
|
|
assert persona.actor == actor, f"Expected actor '{actor}', got '{persona.actor}'"
|
|
|
|
|
|
@then('the registry last persona should be "{name}"')
|
|
def step_registry_last_persona(context: object, name: str) -> None:
|
|
last = context._registry.get_last_persona()
|
|
assert last == name, f"Expected last persona '{name}', got '{last}'"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# render_actor_selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I render the actor selection with default actors")
|
|
def step_render_default(context: object) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import (
|
|
_DEFAULT_ACTORS,
|
|
render_actor_selection,
|
|
)
|
|
|
|
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, "")
|
|
|
|
|
|
@when("I render the actor selection with selected index 2")
|
|
def step_render_selected_index(context: object) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import (
|
|
_DEFAULT_ACTORS,
|
|
render_actor_selection,
|
|
)
|
|
|
|
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 2, "")
|
|
|
|
|
|
@then("the rendered text should contain a cursor marker")
|
|
def step_rendered_contains_cursor(context: object) -> None:
|
|
# The cursor character is the heavy right-pointing angle quotation mark
|
|
# used as a selection indicator per the spec mockup.
|
|
cursor = "\u276f"
|
|
assert cursor in context._rendered, (
|
|
f"Expected cursor marker in rendered text:\n{context._rendered}"
|
|
)
|
|
|
|
|
|
@when('I render the actor selection with search query "{query}"')
|
|
def step_render_with_query(context: object, query: str) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import (
|
|
_DEFAULT_ACTORS,
|
|
render_actor_selection,
|
|
)
|
|
|
|
context._rendered = render_actor_selection(_DEFAULT_ACTORS, 0, query)
|
|
|
|
|
|
@then('the rendered text should contain "{text}"')
|
|
def step_rendered_contains(context: object, text: str) -> None:
|
|
assert text in context._rendered, (
|
|
f"Expected '{text}' in rendered text:\n{context._rendered}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ActorSelectionOverlay widget
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a new ActorSelectionOverlay")
|
|
def step_new_overlay(context: object) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
|
|
|
context._overlay = ActorSelectionOverlay()
|
|
|
|
|
|
@when("I call show on the overlay")
|
|
def step_overlay_show(context: object) -> None:
|
|
context._overlay.show()
|
|
|
|
|
|
@when("I call hide on the overlay")
|
|
def step_overlay_hide(context: object) -> None:
|
|
context._overlay.hide()
|
|
|
|
|
|
@when("I call show on the overlay with actors {actors_json}")
|
|
def step_overlay_show_custom(context: object, actors_json: str) -> None:
|
|
import json
|
|
|
|
actors = json.loads(actors_json)
|
|
context._overlay.show(actors=actors)
|
|
|
|
|
|
@when("I call move_down on the overlay")
|
|
def step_overlay_move_down(context: object) -> None:
|
|
context._overlay.move_down()
|
|
|
|
|
|
@when("I call move_up on the overlay")
|
|
def step_overlay_move_up(context: object) -> None:
|
|
context._overlay.move_up()
|
|
|
|
|
|
@when("I move_down past the last actor")
|
|
def step_overlay_move_down_past_last(context: object) -> None:
|
|
count = len(context._overlay.filtered_actors)
|
|
for _ in range(count):
|
|
context._overlay.move_down()
|
|
|
|
|
|
@when('I call set_search with query "{query}"')
|
|
def step_overlay_set_search(context: object, query: str) -> None:
|
|
context._overlay.set_search(query)
|
|
|
|
|
|
@when('I call set_search with query ""')
|
|
def step_overlay_set_search_empty(context: object) -> None:
|
|
context._overlay.set_search("")
|
|
|
|
|
|
@when("I call confirm on the overlay")
|
|
def step_overlay_confirm(context: object) -> None:
|
|
context._confirmed_result = context._overlay.confirm()
|
|
|
|
|
|
@then("the overlay should be visible")
|
|
def step_overlay_visible(context: object) -> None:
|
|
assert context._overlay.visible is True
|
|
|
|
|
|
@then("the overlay should not be visible")
|
|
def step_overlay_not_visible(context: object) -> None:
|
|
assert context._overlay.visible is False
|
|
|
|
|
|
@then('the overlay actors list should contain "{actor}"')
|
|
def step_overlay_actors_contains(context: object, actor: str) -> None:
|
|
assert actor in context._overlay.actors, (
|
|
f"Expected '{actor}' in actors: {context._overlay.actors}"
|
|
)
|
|
|
|
|
|
@then("the overlay selected index should be 1")
|
|
def step_overlay_selected_1(context: object) -> None:
|
|
assert context._overlay.selected_index == 1, (
|
|
f"Expected 1, got {context._overlay.selected_index}"
|
|
)
|
|
|
|
|
|
@then("the overlay selected index should be the last index")
|
|
def step_overlay_selected_last(context: object) -> None:
|
|
last = len(context._overlay.filtered_actors) - 1
|
|
assert context._overlay.selected_index == last, (
|
|
f"Expected {last}, got {context._overlay.selected_index}"
|
|
)
|
|
|
|
|
|
@then("the overlay selected index should be 0")
|
|
def step_overlay_selected_0(context: object) -> None:
|
|
assert context._overlay.selected_index == 0, (
|
|
f"Expected 0, got {context._overlay.selected_index}"
|
|
)
|
|
|
|
|
|
@then('the overlay filtered actors should only contain actors matching "{query}"')
|
|
def step_overlay_filtered_match(context: object, query: str) -> None:
|
|
for actor in context._overlay.filtered_actors:
|
|
assert query.lower() in actor.lower(), (
|
|
f"Actor '{actor}' does not match query '{query}'"
|
|
)
|
|
|
|
|
|
@then("the overlay filtered actors count should equal the full actors count")
|
|
def step_overlay_filtered_count_full(context: object) -> None:
|
|
assert len(context._overlay.filtered_actors) == len(context._overlay.actors), (
|
|
f"Filtered: {len(context._overlay.filtered_actors)}, "
|
|
f"Full: {len(context._overlay.actors)}"
|
|
)
|
|
|
|
|
|
@then('the confirmed actor should be "{actor}"')
|
|
def step_confirmed_actor(context: object, actor: str) -> None:
|
|
assert context._confirmed_result == actor, (
|
|
f"Expected '{actor}', got '{context._confirmed_result}'"
|
|
)
|
|
|
|
|
|
@then("the confirmed actor should be None")
|
|
def step_confirmed_actor_none(context: object) -> None:
|
|
assert context._confirmed_result is None, (
|
|
f"Expected None, got '{context._confirmed_result}'"
|
|
)
|
|
|
|
|
|
@then("the overlay confirmed flag should be True")
|
|
def step_overlay_confirmed_flag(context: object) -> None:
|
|
assert context._overlay.confirmed is True
|
|
|
|
|
|
@then("the overlay selected_actor should be None")
|
|
def step_overlay_selected_actor_none(context: object) -> None:
|
|
assert context._overlay.selected_actor is None
|
|
|
|
|
|
@then('the overlay search_query should be ""')
|
|
def step_overlay_search_query_empty(context: object) -> None:
|
|
assert context._overlay.search_query == "", (
|
|
f"Expected empty string, got '{context._overlay.search_query}'"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TUI app integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_first_run_app(context: object, *, has_personas: bool) -> None:
|
|
"""Build a mocked TUI app for first-run integration tests."""
|
|
tmp = tempfile.mkdtemp()
|
|
context._tmp_dir = Path(tmp)
|
|
|
|
original = {k: sys.modules[k] for k in _MOCK_TEXTUAL_KEYS if k in sys.modules}
|
|
# Save all cleveragents.tui.* modules before deleting them so we can
|
|
# restore them after the test (prevents module re-import issues in
|
|
# subsequent tests that patch cleveragents.tui.commands.get_container).
|
|
original_tui = {
|
|
k: sys.modules[k]
|
|
for k in list(sys.modules.keys())
|
|
if k.startswith("cleveragents.tui")
|
|
}
|
|
mock_modules = _build_mock_textual()
|
|
_reload_tui_modules(mock_modules)
|
|
|
|
def _cleanup() -> None:
|
|
_restore_textual(original)
|
|
# Restore cleveragents.tui.* modules to prevent re-import issues
|
|
for key, mod in original_tui.items():
|
|
sys.modules[key] = mod
|
|
|
|
context.add_cleanup(_cleanup)
|
|
|
|
from cleveragents.tui.persona.registry import PersonaRegistry
|
|
from cleveragents.tui.persona.schema import Persona
|
|
from cleveragents.tui.persona.state import PersonaState
|
|
|
|
registry = PersonaRegistry(config_dir=context._tmp_dir)
|
|
if has_personas:
|
|
persona = Persona(name="default", actor="local/mock", description="test")
|
|
registry.save(persona)
|
|
|
|
context._registry = registry
|
|
persona_state = PersonaState(registry=registry)
|
|
|
|
class _MockRouter:
|
|
def handle(self, raw: str, *, session_id: str) -> str:
|
|
return f"handled:{raw}"
|
|
|
|
from cleveragents.tui.app import CleverAgentsTuiApp
|
|
|
|
app = CleverAgentsTuiApp(command_router=_MockRouter(), persona_state=persona_state)
|
|
context._first_run_app = app
|
|
|
|
|
|
@given("the TUI app is initialised with an empty persona registry")
|
|
def step_app_empty_registry(context: object) -> None:
|
|
_build_first_run_app(context, has_personas=False)
|
|
|
|
|
|
@given("the TUI app is initialised with an existing persona registry")
|
|
def step_app_existing_registry(context: object) -> None:
|
|
_build_first_run_app(context, has_personas=True)
|
|
|
|
|
|
@when("I call on_mount on the first-run app")
|
|
def step_app_on_mount(context: object) -> None:
|
|
context._first_run_app.on_mount()
|
|
|
|
|
|
@then("the actor selection overlay should be visible")
|
|
def step_actor_overlay_visible(context: object) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
|
|
|
overlay = context._first_run_app.query_one(
|
|
"#actor-selection", ActorSelectionOverlay
|
|
)
|
|
assert overlay.visible is True, "Expected actor selection overlay to be visible"
|
|
|
|
|
|
@then("the actor selection overlay should not be visible")
|
|
def step_actor_overlay_not_visible(context: object) -> None:
|
|
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
|
|
|
|
overlay = context._first_run_app.query_one(
|
|
"#actor-selection", ActorSelectionOverlay
|
|
)
|
|
assert overlay.visible is False, "Expected actor selection overlay to be hidden"
|
|
|
|
|
|
@when('I call _complete_first_run with actor "{actor}"')
|
|
def step_complete_first_run(context: object, actor: str) -> None:
|
|
context._first_run_app._complete_first_run(actor)
|
|
|
|
|
|
@then("the persona bar should reflect the new actor")
|
|
def step_persona_bar_reflects_actor(context: object) -> None:
|
|
from cleveragents.tui.widgets.persona_bar import PersonaBar
|
|
|
|
bar = context._first_run_app.query_one("#persona-bar", PersonaBar)
|
|
assert "anthropic/claude-sonnet-4-20250514" in bar._text, (
|
|
f"Expected actor in persona bar, got: {bar._text}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TDD regression: issue #11039 - no _render() shadowing Textual Widget._render
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the overlay should not override Textual Widget._render")
|
|
def step_overlay_no_render_override(context: object) -> None:
|
|
"""Verify that ActorSelectionOverlay does NOT define its own ``_render``.
|
|
|
|
The Shadowing Bug (issue #11039): ActorSelectionOverlay once defined
|
|
``def _render(self) -> None`` which returned ``None``, shadowing
|
|
``textual.widgets.Static._render()`` (which returns a
|
|
:class:`textual.strip.Strip`). This caused the Textual layout engine to
|
|
crash with ``AttributeError: 'NoneType' object has no attribute
|
|
'get_height'``.
|
|
|
|
The fix renamed the method to ``_refresh_display`` so that the inherited
|
|
base-class ``_render`` is untouched and returns a proper renderable.
|
|
"""
|
|
from cleveragents.tui.widgets.actor_selection_overlay import (
|
|
ActorSelectionOverlay,
|
|
)
|
|
|
|
cls = ActorSelectionOverlay
|
|
# The overlay should NOT define its own _render in its __dict__.
|
|
# If it did, _render found on the class would resolve to this method
|
|
# rather than the Textual base-class implementation.
|
|
has_own_render = "_render" in cls.__dict__
|
|
assert not has_own_render, (
|
|
"ActorSelectionOverlay defines its own _render() which shadows "
|
|
"Textual Widget._render. This is the bug from issue #11039."
|
|
)
|
|
|
|
|
|
@then("the overlay _refresh_display method should be callable")
|
|
def step_overlay_refresh_display_callable(context: object) -> None:
|
|
"""Verify ``_refresh_display`` exists and is callable after show()."""
|
|
assert hasattr(context._overlay, "_refresh_display"), (
|
|
"Overlay must have _refresh_display method"
|
|
)
|
|
refresh = context._overlay._refresh_display
|
|
assert callable(refresh), "_refresh_display must be callable"
|
|
|
|
|
|
@then("the inherited Widget._render should return a proper renderable")
|
|
def step_inherited_render_returns_renderable(context: object) -> None:
|
|
"""Verify the inherited ``Widget._render()`` returns a non-None renderable.
|
|
|
|
Bug #11039: ``ActorSelectionOverlay`` defined its own ``_render() -> None``
|
|
which returned ``None``, causing Textual's layout engine to crash. The
|
|
fix renamed it to ``_refresh_display()``. This assertion confirms that
|
|
when Textual IS available, calling the real inherited ``_render()``
|
|
produces a proper renderable (not ``None``). When Textual is not
|
|
available (``_FallbackStatic`` has no ``_render``), the check passes
|
|
trivially since the fallback case cannot trigger the shadowing crash.
|
|
"""
|
|
|
|
from cleveragents.tui.widgets.actor_selection_overlay import (
|
|
ActorSelectionOverlay,
|
|
)
|
|
|
|
cls = ActorSelectionOverlay
|
|
overlay = context._overlay
|
|
|
|
render_from_mro = None
|
|
for base in cls.__mro__[1:]:
|
|
if "_render" in base.__dict__:
|
|
render_from_mro = base.__dict__["_render"]
|
|
break
|
|
|
|
if render_from_mro is None:
|
|
return
|
|
|
|
result = render_from_mro(overlay)
|
|
assert result is not None, (
|
|
"Bug #11039 regression: inherited Widget._render() returned None. "
|
|
"ActorSelectionOverlay may still be shadowing the base-class "
|
|
"_render method, which would cause a NoneType crash during "
|
|
"Textual layout in textual >=1.0."
|
|
)
|
|
|
|
|
|
@then("_refresh_display should produce correct overlay content")
|
|
def step_refresh_display_produces_content(context: object) -> None:
|
|
"""Invoke ``_refresh_display()`` and verify it renders correct content."""
|
|
from typing import cast
|
|
|
|
overlay = context._overlay
|
|
overlay._refresh_display()
|
|
content: str = cast(str, overlay._text)
|
|
assert content, "_refresh_display must produce non-empty rendered overlay content"
|
|
assert "Welcome to CleverAgents" in content, (
|
|
"_refresh_display content must include the welcome header"
|
|
)
|