Files
cleveragents-core/features/environment.py
Luis Mendes c8cd7eab82
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / security (pull_request) Successful in 40s
CI / typecheck (pull_request) Successful in 55s
CI / unit_tests (pull_request) Successful in 3m0s
CI / integration_tests (pull_request) Successful in 3m26s
CI / docker (pull_request) Successful in 44s
CI / coverage (pull_request) Successful in 5m28s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 36s
CI / typecheck (push) Successful in 52s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m17s
CI / integration_tests (push) Successful in 3m29s
CI / docker (push) Successful in 42s
CI / coverage (push) Successful in 6m47s
CI / benchmark-publish (push) Successful in 18m40s
CI / benchmark-regression (pull_request) Successful in 37m4s
feat(testing): implement @tdd_expected_fail tag handling in Behave environment
Implemented the three-tag TDD bug-capture system in Behave environment hooks:

- Added tag validation in before_scenario: @tdd_bug_<N> requires @tdd_bug,
  @tdd_expected_fail requires both @tdd_bug and @tdd_bug_<N>
- Added result inversion via Scenario.run() wrapper installed in before_all:
  scenarios tagged @tdd_expected_fail that fail are reported as passed
  (expected failure), and scenarios that unexpectedly pass are reported as
  failed with guidance to remove the tag
- Added helper functions validate_tdd_tags() and should_invert_result()
- Added inline documentation referencing CONTRIBUTING.md > TDD Bug Test Tags
- Added Behave test scenarios for tag validation and inversion behavior
- Extract apply_tdd_inversion() as a public, testable function that
  encapsulates all inversion logic with proper guards
- Refactored handle_tdd_expected_fail() to delegate to apply_tdd_inversion()
  after tag validation, eliminating ~55 lines of duplicated inversion logic
- Tag validation errors in handle_tdd_expected_fail() are now logged at
  WARNING level instead of being silently swallowed
- Add hook_failed guard: never invert infrastructure/hook errors
- Add was_dry_run guard: skip inversion when no test actually ran
- Add non-AssertionError guard: warn and skip inversion for exceptions
  that likely indicate infrastructure problems, not the captured bug
- Log exception details at DEBUG level before clearing (previously
  discarded silently)
- Attach synthetic AssertionError to last step on unexpected pass so
  the failure reason appears in standard Behave formatter output

ISSUES CLOSED: #627
2026-03-12 19:49:11 +00:00

711 lines
28 KiB
Python

"""Behave environment setup for feature tests."""
import contextlib
import logging
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from behave.model import Scenario, Status
LANGSMITH_ENV_VARS = [
"CLEVERAGENTS_LANGSMITH_ENABLED",
"CLEVERAGENTS_LANGSMITH_PROJECT",
"CLEVERAGENTS_LANGSMITH_ENDPOINT",
"CLEVERAGENTS_LANGSMITH_API_KEY",
"CLEVERAGENTS_LANGSMITH_TRACING_V2",
"CLEVERAGENTS_LANGSMITH_USER_ID",
"LANGCHAIN_TRACING_V2",
"LANGCHAIN_PROJECT",
"LANGCHAIN_ENDPOINT",
"LANGCHAIN_API_KEY",
"LANGSMITH_PROJECT",
"LANGSMITH_ENDPOINT",
"LANGSMITH_API_KEY",
"LANGSMITH_TRACING_V2",
"LANGSMITH_USER_ID",
]
# ---------------------------------------------------------------------------
# TDD Bug Test Tags — Three-Tag System
# ---------------------------------------------------------------------------
# TDD bug-capture tests use a three-tag system documented in
# CONTRIBUTING.md > Bug Fix Workflow > TDD Bug Test Tags:
#
# @tdd_bug — Generic filter tag. Present on ALL TDD bug tests.
# @tdd_bug_<N> — Issue reference (e.g. @tdd_bug_123). Links the
# test to the specific Type/Bug issue it captures.
# @tdd_expected_fail — Behavioral switch. When present, the test result
# is inverted: a failure means the bug still exists
# (reported as passed), and a pass means the bug was
# fixed without removing the tag (reported as failed).
#
# The ``validate_tdd_tags`` and ``should_invert_result`` helpers below are
# called from the ``before_scenario`` hook and the ``Scenario.run()``
# wrapper (installed in ``before_all``) respectively. They are extracted
# as standalone functions so they can be unit-tested directly from Behave
# step definitions. ``apply_tdd_inversion`` encapsulates the full
# inversion logic and is likewise directly testable.
# ---------------------------------------------------------------------------
_TDD_BUG_N_RE = re.compile(r"tdd_bug_\d+")
_tdd_logger = logging.getLogger("cleveragents.testing.tdd_tags")
def validate_tdd_tags(tags: set[str]) -> None:
"""Validate TDD bug-capture tag combinations.
Raises ``ValueError`` with a descriptive message when the tag set is
inconsistent according to the rules in CONTRIBUTING.md:
* ``@tdd_bug_<N>`` requires ``@tdd_bug`` to also be present.
* ``@tdd_expected_fail`` requires both ``@tdd_bug`` and at least one
``@tdd_bug_<N>``.
Args:
tags: The *effective* tags of a scenario (own tags + feature tags).
Raises:
ValueError: If the tag combination violates the TDD tag rules.
"""
has_tdd_bug = "tdd_bug" in tags
has_tdd_bug_n = any(_TDD_BUG_N_RE.fullmatch(t) for t in tags)
has_expected_fail = "tdd_expected_fail" in tags
if has_tdd_bug_n and not has_tdd_bug:
bug_n_tags = sorted(t for t in tags if _TDD_BUG_N_RE.fullmatch(t))
raise ValueError(
f"Scenario has {', '.join('@' + t for t in bug_n_tags)} but is "
f"missing the required @tdd_bug tag. All TDD bug tests must "
f"include @tdd_bug. See CONTRIBUTING.md > TDD Bug Test Tags."
)
if has_expected_fail:
missing: list[str] = []
if not has_tdd_bug:
missing.append("@tdd_bug")
if not has_tdd_bug_n:
missing.append("@tdd_bug_<N>")
if missing:
raise ValueError(
f"Scenario has @tdd_expected_fail but is missing required "
f"tag(s): {', '.join(missing)}. @tdd_expected_fail requires "
f"both @tdd_bug and at least one @tdd_bug_<N>. "
f"See CONTRIBUTING.md > TDD Bug Test Tags."
)
def should_invert_result(tags: set[str]) -> bool:
"""Return ``True`` if the scenario result should be inverted.
A scenario result is inverted when ``@tdd_expected_fail`` is present in
the effective tags, indicating the test captures a known bug that has not
yet been fixed.
Args:
tags: The *effective* tags of a scenario (own tags + feature tags).
"""
return "tdd_expected_fail" in tags
_UNEXPECTED_PASS_MSG = (
"Bug appears to be fixed. Remove the @tdd_expected_fail tag "
"from this scenario and verify the fix through the bug fix "
"workflow. See CONTRIBUTING.md > Bug Fix Workflow."
)
def apply_tdd_inversion(scenario: Any, failed: bool) -> bool:
"""Apply TDD expected-fail result inversion to a completed scenario.
Encapsulates the full inversion logic so it can be unit-tested directly
from Behave step definitions (using mock scenario objects).
Guards against infrastructure errors and dry-run mode before inverting:
* **Hook/cleanup errors** (``scenario.hook_failed``): never inverted —
infrastructure failures must propagate regardless of tags.
* **Dry-run mode** (``scenario.was_dry_run``): never inverted — no test
actually executed, so the result is meaningless.
* **Non-assertion exceptions**: a step that failed with an exception
other than ``AssertionError`` likely indicates an infrastructure
problem, not the captured bug — a warning is logged and the result
is *not* inverted.
When inverting expected failures, ``step.error_message`` is also cleared
alongside ``step.exception`` and ``step.exc_traceback`` to prevent stale
failure text from leaking to JUnit XML reporters and custom formatters.
Args:
scenario: A Behave ``Scenario`` (or compatible mock) with at least
``effective_tags``, ``all_steps``, ``name``, ``hook_failed``,
``was_dry_run``, ``clear_status()``, and ``set_status()``
attributes.
failed: The boolean return value of the original ``Scenario.run()``.
Returns:
The (possibly inverted) failure status to be returned to the runner.
"""
if not should_invert_result(set(scenario.effective_tags)):
return failed
# Guard: never invert infrastructure / hook errors.
if getattr(scenario, "hook_failed", False):
return failed
# Guard: no-op during dry-run — no test actually executed.
if getattr(scenario, "was_dry_run", False):
return failed
# Materialise all_steps once — some Behave versions return an
# iterator (list_iterator / itertools.chain) instead of a list.
all_steps = list(scenario.all_steps)
if failed:
# Guard: do not invert non-assertion exceptions — they likely
# indicate an infrastructure problem, not the captured bug.
for step in all_steps:
if (
step.status == Status.failed
and step.exception is not None
and not isinstance(step.exception, AssertionError)
):
_tdd_logger.warning(
"Non-assertion exception in expected-fail scenario "
"'%s' step '%s': %s — not inverting.",
scenario.name,
step.name,
step.exception,
)
return failed
# Expected failure — the bug still exists. Reset the failed
# and skipped steps so the scenario is reported as passed.
# (When a step fails, subsequent steps are marked as skipped;
# both must be set to ``Status.passed`` for accurate summary
# counts and consistent ``compute_status()`` behaviour.)
for step in all_steps:
if step.status in (Status.failed, Status.skipped):
_tdd_logger.debug(
"Clearing expected-fail exception for step '%s': %s",
step.name,
step.exception,
)
step.status = Status.passed
step.exception = None
step.exc_traceback = None
step.error_message = None
scenario.clear_status()
scenario.set_status(Status.passed)
return False # Not a failure for the runner
# Unexpected pass — the bug appears to be fixed but the
# @tdd_expected_fail tag has not been removed. Force a failure
# so CI blocks the PR until the tag is cleaned up.
_tdd_logger.warning(
"Bug appears to be fixed. Remove the @tdd_expected_fail "
"tag from scenario '%s' and verify the fix through the "
"bug fix workflow. See CONTRIBUTING.md > Bug Fix Workflow.",
scenario.name,
)
# Attach a synthetic error to the last step so the failure reason
# appears in standard Behave output (formatters show the failed
# step's exception text).
if all_steps:
last_step = all_steps[-1]
last_step.status = Status.failed
last_step.exception = AssertionError(_UNEXPECTED_PASS_MSG)
last_step.error_message = "Assertion Failed: " + _UNEXPECTED_PASS_MSG
scenario.set_status(Status.failed)
return True # Force failure for the runner
def handle_tdd_expected_fail(scenario: Any) -> None:
"""Process a scenario through TDD expected-fail tag validation and inversion.
Public, testable entry point that encapsulates the full TDD expected-fail
logic operating directly on a ``Scenario`` object:
1. **Tag validation** — rejects invalid TDD tag combinations by forcing
the scenario to ``Status.failed``.
2. **Result inversion** — delegates to :func:`apply_tdd_inversion` for
valid ``@tdd_expected_fail`` scenarios, which inverts the result:
a failure becomes passed (expected), a pass becomes failed
(unexpected — the bug appears fixed).
All guard logic (hook errors, dry-run, non-assertion exceptions) and
step-level status manipulation are handled by :func:`apply_tdd_inversion`.
Args:
scenario: A Behave ``Scenario`` (or compatible mock) with at least
``effective_tags`` (or ``tags``), ``all_steps``, ``status``,
``hook_failed``, ``was_dry_run``, ``clear_status()``, and
``set_status()``.
"""
tags = set(getattr(scenario, "effective_tags", getattr(scenario, "tags", [])))
# Validate tags — force failure on invalid combinations.
try:
validate_tdd_tags(tags)
except ValueError as exc:
_tdd_logger.warning(
"Invalid TDD tag combination on scenario '%s': %s",
scenario.name,
exc,
)
scenario.set_status(Status.failed)
return
# Delegate inversion logic to the shared implementation.
apply_tdd_inversion(scenario, scenario.status == Status.failed)
def _install_tdd_expected_fail_patch() -> None:
"""Monkey-patch ``Scenario.run`` to invert results for ``@tdd_expected_fail``.
Behave's ``Scenario.run()`` returns a local ``failed`` boolean that
``after_scenario`` hooks cannot modify. To correctly invert the result
(so that an expected-fail scenario is reported as passed to the runner),
we wrap ``Scenario.run()`` with a thin post-processing layer that
delegates to :func:`apply_tdd_inversion`.
The patch is installed once in ``before_all`` and is idempotent.
"""
if getattr(Scenario, "_tdd_run_patched", False):
return # Already patched (e.g. forked worker reloading hooks)
_original_run = Scenario.run
def _tdd_aware_run(self: Any, runner: Any) -> bool:
failed: bool = _original_run(self, runner)
return apply_tdd_inversion(self, failed)
Scenario.run = _tdd_aware_run
Scenario._tdd_run_patched = True
def before_all(context):
"""Set up test environment before all tests."""
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
# Ensure tests never block on migration prompts or real providers
os.environ.setdefault("CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "true")
os.environ.setdefault("CLEVERAGENTS_TESTING_USE_MOCK_AI", "true")
# Use per-process unique database paths so parallel test subprocesses
# (behave-parallel) never contend on the same SQLite file.
if "CLEVERAGENTS_DATABASE_URL" not in os.environ:
os.environ["CLEVERAGENTS_DATABASE_URL"] = (
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_')}"
)
if "CLEVERAGENTS_TEST_DATABASE_URL" not in os.environ:
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = (
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_test_')}"
)
os.environ.setdefault("BEHAVE_TESTING", "true")
# Set up mock AI provider for all tests
try:
from cleveragents.application.container import override_providers
from features.mocks.mock_ai_provider import MockAIProvider
# Override the AI provider with mock for all tests
mock_provider = MockAIProvider()
override_providers(ai_provider=mock_provider)
except ImportError:
pass # Container not needed for all tests
# --- Eliminate retry waits ---
# Tenacity retry decorators (database_retry, network_retry, etc.) use
# real time.sleep() waits during retries. In tests, mocked operations
# fail deterministically so waiting is pure overhead. Patch
# time.sleep() globally so all tenacity waits (and any other sleeps)
# complete instantly. The small handful of sleep() calls that exist
# in step definitions already use sub-100ms waits and are unaffected
# by this optimisation in practice.
_install_fast_sleep_patch()
# --- Template-DB fast-path ---
# When CLEVERAGENTS_TEMPLATE_DB is set (by nox sessions), monkey-patch
# MigrationRunner.init_or_upgrade so that fresh file-based SQLite
# databases are created by copying the pre-migrated template (~1ms)
# instead of running 25 Alembic migrations (~0.5-3s each).
#
# If running outside of nox (i.e. direct `behave` invocation), auto-
# create the template DB so the fast-path is always active.
_ensure_template_db()
_install_template_db_patch()
# --- TDD Expected-Fail Patch ---
# Wrap Scenario.run() so @tdd_expected_fail inverts the result for
# both the scenario status AND the runner's pass/fail return value.
_install_tdd_expected_fail_patch()
def _install_fast_sleep_patch() -> None:
"""Cap ``time.sleep`` and ``asyncio.sleep`` at 10 ms for fast test execution.
Tenacity retry decorators (``@database_retry``, ``@retry_network_operation``,
etc.) ultimately call ``time.sleep()`` with waits of 0.5-30 s between retry
attempts. Async retry helpers (``retry_auto_debug``,
``async_retry_with_exponential_backoff``) call ``asyncio.sleep()`` with
exponential waits of 1-4 s per attempt. In the test suite, mocked
operations fail deterministically, so the long sleeps are pure overhead
(~1 s per retry cycle x hundreds of scenarios = minutes of wasted time).
Both functions are replaced with capped versions (≤ 10 ms). The originals
are saved as ``time._original_sleep`` / ``asyncio._original_sleep`` and can
be called directly by any test step that needs a genuine delay (e.g.
CircuitBreaker recovery-timeout tests that need real wall-clock advancement
past a 100 ms threshold).
"""
import asyncio
import time
_MAX_SLEEP = 0.01 # 10 ms cap
# --- synchronous time.sleep ---
if not callable(getattr(time, "_original_sleep", None)):
time._original_sleep = time.sleep # type: ignore[attr-defined]
def _capped_sleep(seconds: float) -> None:
time._original_sleep(min(seconds, _MAX_SLEEP)) # type: ignore[attr-defined]
time.sleep = _capped_sleep # type: ignore[assignment]
# --- asynchronous asyncio.sleep ---
if not callable(getattr(asyncio, "_original_sleep", None)):
asyncio._original_sleep = asyncio.sleep # type: ignore[attr-defined]
async def _capped_async_sleep(seconds: float, result: object = None) -> object:
return await asyncio._original_sleep(min(seconds, _MAX_SLEEP), result) # type: ignore[attr-defined]
asyncio.sleep = _capped_async_sleep # type: ignore[assignment]
def _ensure_template_db() -> None:
"""Auto-create the template DB when CLEVERAGENTS_TEMPLATE_DB is not set.
When running tests directly via ``behave`` (without nox), the env var
is missing. This function creates the template on the fly using
``scripts/create_template_db.py`` so the fast-path is always active.
"""
if os.environ.get("CLEVERAGENTS_TEMPLATE_DB"):
return # Already set by nox or CI
template_path = Path(__file__).parent.parent / "build" / ".template-migrated.db"
if template_path.is_file():
# Template already exists from a prior run — reuse it.
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
return
try:
# Import the template creation script
scripts_dir = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(scripts_dir))
from create_template_db import create_template
create_template(str(template_path))
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
except Exception:
pass # Fall back to normal Alembic migrations
def _install_template_db_patch() -> None:
"""Monkey-patch MigrationRunner to skip Alembic migrations in tests.
For file-based SQLite: copies a pre-migrated template DB (~1 ms).
For in-memory SQLite: uses ``Base.metadata.create_all()`` (~5 ms) instead
of running 25 sequential Alembic migrations (~0.5-3 s).
"""
template_path = os.environ.get("CLEVERAGENTS_TEMPLATE_DB")
if not template_path or not Path(template_path).is_file():
return
try:
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
except ImportError:
return
_original_init_or_upgrade = MigrationRunner.init_or_upgrade
# Prefixes used by before_scenario and step files when creating temp DBs.
# "cleveragents_" / "cleveragents_test_" — before_scenario databases
# "test_" — databases created inside step files (services_coverage, etc.)
_SCENARIO_DB_PREFIXES = ("cleveragents_", "cleveragents_test_", "test_", "db.")
def _fast_init_or_upgrade(self: Any, **kwargs: Any) -> None:
"""Replace Alembic migrations with fast alternatives.
- Non-SQLite databases → fall through to original
- In-memory SQLite → ``Base.metadata.create_all()`` + alembic stamp
- File-based SQLite with matching prefix → copy template
- Everything else → fall through to original
"""
db_url: str = getattr(self, "database_url", "")
# Non-SQLite: always fall through
if not db_url.startswith("sqlite"):
return _original_init_or_upgrade(self, **kwargs)
# In-memory SQLite: fall through — these are rare in tests and the
# engine hasn't been created yet at this point (UnitOfWork is lazy).
if ":memory:" in db_url or db_url == "sqlite://":
return _original_init_or_upgrade(self, **kwargs)
# Extract the file path from the URL
db_file_path = db_url.replace("sqlite:///", "")
if not db_file_path.startswith("/"):
db_file_path = "/" + db_file_path
db_path = Path(db_file_path)
# Only apply to scenario-generated temp DBs (avoid hijacking
# migration-runner unit tests that use custom URLs).
if not any(db_path.name.startswith(p) for p in _SCENARIO_DB_PREFIXES):
return _original_init_or_upgrade(self, **kwargs)
# Only copy template for databases that don't exist yet or are empty
# (SQLite auto-creates a 0-byte file on first engine open).
# If the DB already exists and is non-empty, it was either copied from
# the template or created by a prior step — either way it is already
# fully migrated. Skipping the Alembic check avoids redundant engine
# creation, SQLite lock contention, and the cumulative overhead that
# causes intermittent hangs in parallel test runs.
if db_path.exists() and db_path.stat().st_size > 0:
return
# Copy the template — creates a fully-migrated DB in ~1ms
db_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_path, db_file_path)
MigrationRunner.init_or_upgrade = _fast_init_or_upgrade # type: ignore[assignment]
def before_scenario(context, scenario):
"""Set up before each scenario."""
# --- TDD Bug Tag Validation ---
# Validate the three-tag system BEFORE any other setup so that
# misconfigured TDD tests are caught immediately.
# See CONTRIBUTING.md > TDD Bug Test Tags for the full specification.
try:
validate_tdd_tags(set(scenario.effective_tags))
except ValueError as exc:
scenario.hook_failed = True
scenario.set_status(Status.failed)
_tdd_logger.error("TDD TAG ERROR in %r: %s", scenario.name, exc)
return
# Store original working directory
context.original_cwd = os.getcwd()
# Initialize cleanup list
context._cleanup_handlers = []
context.stubbed_clients = {}
# Reset error attributes to prevent stale state leaking between scenarios.
context.acms_error = None
context.assemble_error = None
# Ensure mock AI flag is always set so plan service tests can resolve actors
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
# Re-establish protective env vars every scenario. These are set once
# in before_all, but individual scenarios (e.g. migration_runner tests)
# temporarily remove them. If any cleanup path fails to restore them
# the remaining scenarios in a serial run would be affected.
os.environ["BEHAVE_TESTING"] = "true"
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
# Flush the in-memory engine cache so no stale engines leak between
# scenarios. In serial mode (coverage_report) every feature shares
# the same process, so a previous scenario's real-or-fake engine can
# survive into the next one.
try:
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
for _url, engine in list(MEMORY_ENGINES.items()):
with contextlib.suppress(Exception):
engine.dispose()
MEMORY_ENGINES.clear()
except ImportError:
pass
# Clean up any lingering test environment variables from previous tests
for env_var in [
"CLEVERAGENTS_MOCK_SHOULD_FAIL",
"CLEVERAGENTS_MOCK_INVALID_CODE",
"CLEVERAGENTS_SERVER_MODE",
*LANGSMITH_ENV_VARS,
]:
if env_var in os.environ:
del os.environ[env_var]
# Give each scenario a unique database file so scenarios cannot share
# persisted state AND parallel subprocesses never collide on the same
# SQLite file. Store the paths for cleanup in after_scenario.
#
# Features tagged @mock_only use fully mocked services and never touch
# the database, so skip the temp-file creation for them (~0.5ms each,
# but the real savings come from not triggering MigrationRunner later).
context._scenario_db_paths = []
_is_mock_only = "mock_only" in scenario.effective_tags
if not _is_mock_only:
for env_var, prefix in (
("CLEVERAGENTS_DATABASE_URL", "cleveragents_"),
("CLEVERAGENTS_TEST_DATABASE_URL", "cleveragents_test_"),
):
db_path = tempfile.mktemp(suffix=".db", prefix=prefix)
os.environ[env_var] = f"sqlite:///{db_path}"
context._scenario_db_paths.append(db_path)
# Clear devcontainer lifecycle registry between scenarios to prevent
# test pollution from in-memory lifecycle trackers and health check
# threads left by previous scenarios.
try:
from cleveragents.resource.handlers.devcontainer import (
clear_lifecycle_registry,
)
clear_lifecycle_registry()
except ImportError:
pass # Handler not available in all environments
# Re-apply mock AI provider after container reset
try:
from cleveragents.application.container import override_providers
from features.mocks.mock_ai_provider import MockAIProvider
# Override the AI provider with mock for all tests
mock_provider = MockAIProvider()
override_providers(ai_provider=mock_provider)
except ImportError:
pass # Container not needed for all tests
def after_scenario(context, scenario):
"""Clean up after each scenario."""
# Return to original directory first
if hasattr(context, "original_cwd"):
os.chdir(context.original_cwd)
# Run all cleanup handlers in reverse order
if hasattr(context, "_cleanup_handlers"):
for handler in reversed(context._cleanup_handlers):
with contextlib.suppress(Exception):
handler()
context._cleanup_handlers = []
# Clean up any test directories
if hasattr(context, "test_dir") and context.test_dir:
try:
if Path(context.test_dir).exists():
shutil.rmtree(context.test_dir)
except Exception:
pass # Ignore cleanup errors
context.test_dir = None
# Clean up environment variables set during tests
if hasattr(context, "env_vars_to_clean"):
for key in context.env_vars_to_clean:
os.environ.pop(key, None)
context.env_vars_to_clean = []
for env_var in [
*LANGSMITH_ENV_VARS,
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
]:
os.environ.pop(env_var, None)
# Reset Settings singleton if it was used
try:
from cleveragents.config.settings import Settings
Settings._instance = None
except ImportError:
pass
# Reset global container singleton
try:
from cleveragents.application.container import reset_container
reset_container()
except ImportError:
pass
# Clean up service instances
for attr in ["context_service", "plan_service", "project_service"]:
if hasattr(context, attr):
delattr(context, attr)
# Clean up any remaining attributes that might hold state
for attr in ["plan", "plans", "project", "changes", "added_files", "all_plans"]:
if hasattr(context, attr):
delattr(context, attr)
# Restore provider registry env overrides if used in a scenario
for attr, env_var in [
("original_provider_env", "CLEVERAGENTS_DEFAULT_PROVIDER"),
("original_model_env", "CLEVERAGENTS_DEFAULT_MODEL"),
]:
if hasattr(context, attr):
original = getattr(context, attr)
if original is None:
os.environ.pop(env_var, None)
else:
os.environ[env_var] = original
delattr(context, attr)
# Reset provider registry singleton between scenarios
try:
from cleveragents.providers.registry import (
reset_provider_registry as _reset_registry,
)
_reset_registry()
except ImportError:
pass
# Clean up in-memory database engine cache to ensure each scenario
# gets a fresh database. This is critical for tests using :memory: databases
try:
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
# Dispose of all cached engines and clear the cache
for _url, engine in list(MEMORY_ENGINES.items()):
with contextlib.suppress(Exception):
engine.dispose()
MEMORY_ENGINES.clear()
except ImportError:
pass
# Remove per-scenario temp database files (and associated journal/WAL
# files) now that all engines have been disposed.
for db_path in getattr(context, "_scenario_db_paths", []):
for suffix in ("", "-journal", "-wal", "-shm"):
with contextlib.suppress(OSError):
os.unlink(db_path + suffix)
# T6: Remove log handlers attached to the async-cleanup logger by
# security_async_steps.py so handlers don't accumulate across scenarios.
if hasattr(context, "log_handler"):
async_logger = logging.getLogger("cleveragents.core.async_cleanup")
async_logger.removeHandler(context.log_handler)
# T5: Close event loops left open by security_async_steps.py.
if hasattr(context, "bridge_loop"):
with contextlib.suppress(Exception):
context.bridge_loop.close()
# NOTE: TDD @tdd_expected_fail result inversion is handled by the
# Scenario.run() wrapper installed in _install_tdd_expected_fail_patch(),
# NOT in this hook. See before_all() and CONTRIBUTING.md > TDD Bug
# Test Tags for the full specification.