fix(ci): fix parallel Behave test isolation and undefined step errors

- Rewrite TUI session export/import step definitions to use constructor-
  based dependency injection (container_factory) instead of
  unittest.mock.patch context managers that fail across fork() boundaries
  in the parallel test runner.
- Add container_factory parameter to TuiCommandRouter dataclass so tests
  can inject a mock container that survives multiprocessing.fork().
- Add use_step_matcher('re') to a2a_jsonrpc_wire_format_steps.py so
  regex-based step patterns are matched correctly (fixes 30 errored
  scenarios with 56 undefined steps).
- Add plural 'rows' variant to database_handler_crud_steps.py row-count
  step matcher (fixes 1 errored scenario).

ISSUES CLOSED: #2597
This commit is contained in:
2026-04-04 10:20:40 +00:00
committed by Forgejo
parent 02250473ad
commit 7db698b602
3 changed files with 59 additions and 36 deletions
@@ -406,6 +406,7 @@ def step_then_write_message_contains(context: Context, text: str) -> None:
)
@then('the SQLite table "{table_name}" should contain {count:d} rows')
@then('the SQLite table "{table_name}" should contain {count:d} row')
def step_then_sqlite_table_row_count(
context: Context, table_name: str, count: int
@@ -14,7 +14,7 @@ import tempfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
@@ -268,10 +268,15 @@ def step_exported_md_file_contains(context: Context, text: str) -> None:
def _make_tui_router_with_export_mock(
session_id: str,
export_data: dict,
export_data: dict[str, object],
session_obj: Session,
) -> tuple[TuiCommandRouter, MagicMock]:
"""Build a TuiCommandRouter with a mocked container/service for export."""
) -> TuiCommandRouter:
"""Build a TuiCommandRouter with a mocked container/service for export.
The mock container is injected via the ``container_factory`` constructor
parameter so the mock survives ``multiprocessing.fork()`` boundaries
used by the parallel test runner.
"""
mock_service = MagicMock()
mock_service.export_session.return_value = export_data
mock_service.get.return_value = session_obj
@@ -281,8 +286,12 @@ def _make_tui_router_with_export_mock(
registry = FakePersonaRegistry()
state = FakePersonaState()
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
return router, mock_container
router = TuiCommandRouter(
persona_registry=registry,
persona_state=state,
container_factory=lambda: mock_container,
)
return router
@given("a TUI command router with mocked session service for export")
@@ -297,11 +306,9 @@ def step_tui_router_for_export(context: Context) -> None:
session = _make_session(session_id=session_id, messages=msgs)
export_data = session.as_export_dict()
router, mock_container = _make_tui_router_with_export_mock(
context.tui_router = _make_tui_router_with_export_mock(
session_id, export_data, session
)
context.tui_router = router
context.tui_mock_container = mock_container
@given("a TUI command router with mocked session service for import")
@@ -318,9 +325,12 @@ def step_tui_router_for_import(context: Context) -> None:
registry = FakePersonaRegistry()
state = FakePersonaState()
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
router = TuiCommandRouter(
persona_registry=registry,
persona_state=state,
container_factory=lambda: mock_container,
)
context.tui_router = router
context.tui_mock_container = mock_container
context.tui_imported_session = imported_session
@@ -346,37 +356,25 @@ def step_invalid_json_for_tui(context: Context) -> None:
@when('I call TUI handle with "{command}" for the current session')
def step_tui_handle_command(context: Context, command: str) -> None:
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@when('I call TUI handle with "session import <path>" for the import file')
def step_tui_handle_import_valid(context: Context) -> None:
command = f"session import {context.tui_import_path}"
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@when('I call TUI handle with "session import <invalid_path>" for the import file')
def step_tui_handle_import_invalid(context: Context) -> None:
command = f"session import {context.tui_invalid_json_path}"
with patch(
"cleveragents.tui.commands.get_container",
return_value=context.tui_mock_container,
):
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
context.tui_result = context.tui_router.handle(
command, session_id=context.tui_session_id
)
@then('the TUI handle result should contain "{text}"')
+28 -4
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from cleveragents.application.container import get_container
from cleveragents.tui.app import CleverAgentsTuiApp, textual_available
@@ -14,10 +16,32 @@ from cleveragents.tui.persona.state import PersonaState
@dataclass(slots=True)
class TuiCommandRouter:
"""Slash command router used by the TUI prompt."""
"""Slash command router used by the TUI prompt.
Parameters
----------
persona_registry:
Registry of available personas.
persona_state:
Mutable state tracking the active persona per session.
container_factory:
Optional callable that returns the DI container. Defaults to
:func:`get_container` when *None*. Accepting this parameter
enables constructor-based dependency injection, which is
essential for test isolation under ``multiprocessing.fork()``
where ``unittest.mock.patch`` context managers do not propagate
to child workers.
"""
persona_registry: PersonaRegistry
persona_state: PersonaState
container_factory: Callable[[], Any] | None = field(default=None, repr=False)
def _resolve_container(self) -> Any:
"""Return the DI container, using the injected factory or the global default."""
if self.container_factory is not None:
return self.container_factory()
return get_container()
def handle(self, raw: str, *, session_id: str) -> str:
tokens = raw.strip().split()
@@ -75,7 +99,7 @@ class TuiCommandRouter:
return f"Invalid format: {fmt!r}. Use 'json' or 'md'."
try:
container = get_container()
container = self._resolve_container()
service = container.session_service()
export_data = service.export_session(session_id)
@@ -133,7 +157,7 @@ class TuiCommandRouter:
return f"Invalid JSON: {exc}"
try:
container = get_container()
container = self._resolve_container()
service = container.session_service()
session = service.import_session(data)
return f"Session imported: {session.session_id}"