e73150ab74
ISSUES CLOSED: #6441
219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
"""Shared step definitions for TDD session DI bug tests.
|
|
|
|
Steps in this module are used by both ``tdd_session_create_di.feature`` and
|
|
``tdd_session_list_di.feature``. Behave loads all step files globally, so
|
|
placing shared steps here avoids duplication and satisfies the CONTRIBUTING.md
|
|
rule that feature-specific steps live in matching ``*_steps.py`` files while
|
|
shared steps live in clearly named reusable modules.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from behave import given, then
|
|
from behave.runner import Context
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.application.container import reset_container
|
|
from cleveragents.cli.commands import session as session_mod
|
|
from cleveragents.config.settings import Settings
|
|
|
|
|
|
def _suppress_structlog_stdout() -> tuple[int, bool]:
|
|
"""Prevent structlog debug lines from contaminating CLI stdout.
|
|
|
|
When ``behave-parallel`` runs multiple features in one process,
|
|
another feature may (re)configure structlog with a ``ConsoleRenderer``
|
|
or leave it at the default ``PrintLogger`` — both write to
|
|
``sys.stdout``. The ``@database_retry`` decorator logs a *debug*
|
|
event on every attempt, and ``CliRunner.invoke()`` captures
|
|
``sys.stdout``, so those debug lines end up in the captured output,
|
|
breaking JSON/YAML assertions.
|
|
|
|
Returns the previous root-logger level and ``cache_logger_on_first_use``
|
|
flag so that the caller's cleanup function can restore them.
|
|
"""
|
|
import structlog
|
|
|
|
root = logging.getLogger()
|
|
prev_level = root.level
|
|
root.setLevel(logging.CRITICAL)
|
|
|
|
# Force structlog to route through stdlib logging (not PrintLogger)
|
|
# and disable logger caching so the level change takes effect for
|
|
# loggers that were already instantiated.
|
|
prev_config = structlog.get_config()
|
|
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
|
|
structlog.configure(
|
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
wrapper_class=structlog.stdlib.BoundLogger,
|
|
cache_logger_on_first_use=False,
|
|
)
|
|
|
|
return prev_level, prev_cache
|
|
|
|
|
|
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
|
|
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
|
|
import structlog
|
|
|
|
logging.getLogger().setLevel(prev_level)
|
|
structlog.configure(cache_logger_on_first_use=prev_cache)
|
|
|
|
|
|
@given("a CLI runner using the real session DI path")
|
|
def step_real_di_runner(context: Context) -> None:
|
|
"""Set up a CLI runner that does NOT mock the session service.
|
|
|
|
By ensuring ``session_mod._service`` is ``None``, the CLI will call
|
|
``_get_session_service()`` which hits the real DI container and
|
|
resolves the ``session_service`` provider.
|
|
"""
|
|
context.runner = CliRunner()
|
|
|
|
# Ensure we go through the real DI path — no mock service.
|
|
session_mod._service = None
|
|
|
|
# Reset singletons before setup so stale state from a parallel
|
|
# scenario does not leak in.
|
|
reset_container()
|
|
Settings._instance = None # type: ignore[attr-defined]
|
|
|
|
# Suppress structlog debug output so CliRunner captures clean output.
|
|
prev_level, prev_cache = _suppress_structlog_stdout()
|
|
|
|
# Provide a database URL so the container can be constructed.
|
|
fd, db_path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
context._tdd_db_path = db_path
|
|
|
|
db_url = f"sqlite:///{db_path}"
|
|
os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url
|
|
|
|
# Create the schema so the database file is ready for use.
|
|
from sqlalchemy import create_engine
|
|
|
|
from cleveragents.infrastructure.database.models import Base
|
|
|
|
engine = create_engine(db_url, echo=False)
|
|
try:
|
|
Base.metadata.create_all(engine)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def _cleanup() -> None:
|
|
session_mod._service = None
|
|
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
|
|
reset_container()
|
|
# Reset the Settings singleton so stale database URLs from this
|
|
# scenario do not leak into subsequent scenarios.
|
|
Settings._instance = None # type: ignore[attr-defined]
|
|
Path(db_path).unlink(missing_ok=True)
|
|
_restore_structlog(prev_level, prev_cache)
|
|
|
|
context.add_cleanup(_cleanup)
|
|
|
|
|
|
@then("the session {subcommand} command should exit successfully")
|
|
def step_session_exits_ok(context: Context, subcommand: str) -> None:
|
|
"""Assert the command exits with code 0."""
|
|
assert context.result.exit_code == 0, (
|
|
f"Expected exit code 0, got {context.result.exit_code}.\n"
|
|
f"Output:\n{context.result.output}"
|
|
)
|
|
|
|
|
|
@then("the session {subcommand} output should be valid JSON")
|
|
def step_session_output_json(context: Context, subcommand: str) -> None:
|
|
"""Assert the output is parseable JSON."""
|
|
output = context.result.output
|
|
# Strip ANSI color codes and MCP health check warning that may prepend to JSON
|
|
output = output.lstrip()
|
|
lines = output.split("\n")
|
|
# Skip leading lines that are not JSON (like MCP warnings)
|
|
json_start_idx = 0
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if stripped.startswith("{") or stripped.startswith("["):
|
|
json_start_idx = i
|
|
break
|
|
output = "\n".join(lines[json_start_idx:])
|
|
|
|
try:
|
|
json.loads(output)
|
|
except json.JSONDecodeError as exc:
|
|
raise AssertionError(
|
|
f"Output is not valid JSON:\n{context.result.output}"
|
|
) from exc
|
|
|
|
|
|
@then("the session create JSON response should match the session create spec")
|
|
def step_session_create_matches_spec(context: Context) -> None:
|
|
"""Assert the session create JSON envelope matches the specification."""
|
|
|
|
raw_output = context.result.output
|
|
json_start = raw_output.find("{")
|
|
assert json_start >= 0, f"No JSON payload in output: {raw_output!r}"
|
|
try:
|
|
payload = json.loads(raw_output[json_start:])
|
|
except json.JSONDecodeError as exc: # pragma: no cover - defensive assertion
|
|
raise AssertionError(
|
|
f"Session create output is not valid JSON:\n{raw_output}"
|
|
) from exc
|
|
|
|
expected_command = "agents session create --actor local/orchestrator --format json"
|
|
assert payload.get("command") == expected_command, (
|
|
f"Expected command '{expected_command}', got {payload.get('command')!r}"
|
|
)
|
|
assert payload.get("status") == "ok", payload
|
|
assert payload.get("exit_code") == 0, payload
|
|
|
|
timing = payload.get("timing")
|
|
assert isinstance(timing, dict), f"timing not dict: {timing!r}"
|
|
duration = timing.get("duration_ms")
|
|
assert isinstance(duration, int) and duration >= 0, (
|
|
f"duration_ms invalid: {duration!r}"
|
|
)
|
|
|
|
messages = payload.get("messages")
|
|
assert isinstance(messages, list) and messages, messages
|
|
assert any(msg.get("text") == "Session created" for msg in messages), messages
|
|
|
|
data = payload.get("data")
|
|
assert isinstance(data, dict), f"data not dict: {data!r}"
|
|
|
|
session_block = data.get("session")
|
|
assert isinstance(session_block, dict), f"session block missing: {session_block!r}"
|
|
session_id = session_block.get("id")
|
|
assert isinstance(session_id, str) and len(session_id) == 26, session_block
|
|
assert session_block.get("actor") == "local/orchestrator", (
|
|
f"Unexpected actor: {session_block.get('actor')!r}"
|
|
)
|
|
assert session_block.get("namespace") == "local", session_block
|
|
created = session_block.get("created")
|
|
assert isinstance(created, str) and created, session_block
|
|
|
|
settings = data.get("settings")
|
|
expected_settings = {
|
|
"automation": "review",
|
|
"streaming": "off",
|
|
"context": "default",
|
|
"memory": "enabled",
|
|
"max_history": 50,
|
|
}
|
|
assert settings == expected_settings, f"Settings mismatch: {settings!r}"
|
|
|
|
actor_details = data.get("actor_details")
|
|
assert isinstance(actor_details, dict) and actor_details, actor_details
|
|
assert (
|
|
isinstance(actor_details.get("provider"), str) and actor_details["provider"]
|
|
), actor_details
|
|
assert isinstance(actor_details.get("model"), str) and actor_details["model"], (
|
|
actor_details
|
|
)
|