forked from HAL9000/cleveragents-core
4e3bf7d3ad
Add TDD-style Behave BDD tests for the missing agents init --yes flag (bug #522). Five Gherkin scenarios cover: exit code validation, prompt suppression, -y alias, output summary fields, and interactive-mode regression guard. Includes Robot Framework smoke tests (tagged @wip) and ASV benchmarks. Configure behave.ini to exclude @wip scenarios globally and noxfile.py to exclude wip-tagged Robot suites, so TDD-failing tests do not break CI. Review feedback addressed: - Remove unnecessary # type: ignore from benchmark (outside Pyright scope) - Fix Then...Then to Then...And in Gherkin (L1) - Fix CHANGELOG 'three scenarios' to 'five scenarios' (L2) - Add behave.ini documentation for @wip workaround (Aditya F1) - Rename Scenario 2 title to 'suppresses interactive prompts' (Aditya F2) Closes #536
211 lines
7.6 KiB
Python
211 lines
7.6 KiB
Python
"""Step definitions for CLI init --yes flag tests (bug #522).
|
|
|
|
These tests are TDD-style: they assert the CORRECT expected behaviour of
|
|
``agents init --yes`` and are expected to FAIL until the bug fix is applied.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import create_autospec, patch
|
|
|
|
from behave import given, then, when
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.application.services.project_service import ProjectService
|
|
from cleveragents.cli.main import app
|
|
|
|
|
|
def _restore_cwd(context):
|
|
"""Restore the original working directory, env var, and clean up."""
|
|
os.chdir(context._init_original_cwd)
|
|
if context._init_original_home is None:
|
|
os.environ.pop("CLEVERAGENTS_HOME", None)
|
|
else:
|
|
os.environ["CLEVERAGENTS_HOME"] = context._init_original_home
|
|
shutil.rmtree(context.temp_dir, ignore_errors=True)
|
|
|
|
|
|
def _create_init_mocks(context):
|
|
"""Create and configure mocked container, service, and project.
|
|
|
|
Returns ``(patcher, mock_service)`` where *patcher* is the started
|
|
``patch`` context manager.
|
|
|
|
The service mock uses ``create_autospec(ProjectService)`` so that
|
|
attribute access and method calls are validated against the real
|
|
``ProjectService`` interface. The project mock uses a typed
|
|
``_MockProject`` class instead of ``MagicMock`` because the
|
|
spec-required output fields (``data_dir``, ``config_path``,
|
|
``database_status``, ``directories``) do not yet exist on the legacy
|
|
``Project`` model — they will be added when the #522 fix aligns the
|
|
model with ``docs/specification.md:1381-1386``. Using
|
|
``create_autospec(Project)`` would reject those attribute assignments.
|
|
"""
|
|
mock_service = create_autospec(ProjectService, instance=True)
|
|
|
|
class _MockProject:
|
|
"""Lightweight stand-in for the Project model with spec fields."""
|
|
|
|
name: str
|
|
path: Path
|
|
data_dir: Path
|
|
config_path: Path
|
|
database_status: str
|
|
directories: list[str]
|
|
|
|
mock_project = _MockProject()
|
|
mock_project.name = Path(context.temp_dir).name
|
|
mock_project.path = Path(context.temp_dir)
|
|
# Pre-populate spec-required fields (specification.md:1381-1386) so
|
|
# that when the #522 fix lands, output assertions fail for the right
|
|
# reason (real bugs) rather than MagicMock stringification artefacts.
|
|
mock_project.data_dir = Path(context.temp_dir)
|
|
mock_project.config_path = Path(context.temp_dir) / "config.toml"
|
|
mock_project.database_status = "initialized (schema v3)"
|
|
mock_project.directories = ["logs", "cache", "sessions", "contexts"]
|
|
mock_service.initialize_project.return_value = mock_project
|
|
|
|
patcher = patch("cleveragents.application.container.get_container")
|
|
mock_container = patcher.start()
|
|
mock_container.return_value.project_service.return_value = mock_service
|
|
|
|
return patcher, mock_service
|
|
|
|
|
|
@given("I have a temporary project directory for init")
|
|
def step_temp_project_directory(context):
|
|
"""Create a temporary directory and store it on *context*."""
|
|
context.temp_dir = tempfile.mkdtemp()
|
|
# Use step-private attribute names (prefixed with _init_) to avoid
|
|
# colliding with environment.py's context.original_cwd (str).
|
|
# Match the framework's type (str via os.getcwd()) for consistency.
|
|
context._init_original_cwd = os.getcwd()
|
|
context._init_original_home = os.environ.get("CLEVERAGENTS_HOME")
|
|
os.environ["CLEVERAGENTS_HOME"] = context.temp_dir
|
|
os.chdir(context.temp_dir)
|
|
context.add_cleanup(_restore_cwd, context)
|
|
|
|
|
|
def _run_init_with_flag(context, flag: str) -> None:
|
|
"""Invoke ``agents init`` with the given flag via the Typer test runner."""
|
|
runner = CliRunner()
|
|
|
|
patcher, mock_service = _create_init_mocks(context)
|
|
try:
|
|
result = runner.invoke(app, ["init", flag])
|
|
finally:
|
|
patcher.stop()
|
|
|
|
context.init_yes_result = {
|
|
"exit_code": result.exit_code,
|
|
"output": result.output,
|
|
}
|
|
context.init_yes_raw_result = result
|
|
context.init_yes_mock_service = mock_service
|
|
|
|
|
|
@when("I run agents init with the --yes flag")
|
|
def step_run_init_yes(context):
|
|
"""Invoke ``agents init --yes`` via the Typer test runner."""
|
|
_run_init_with_flag(context, "--yes")
|
|
|
|
|
|
@when("I run agents init with the -y flag")
|
|
def step_run_init_short_yes(context):
|
|
"""Invoke ``agents init -y`` via the Typer test runner."""
|
|
_run_init_with_flag(context, "-y")
|
|
|
|
|
|
@then("the init command should exit with code {code:d}")
|
|
def step_init_exit_code(context, code):
|
|
"""Assert the init command exited with the expected code."""
|
|
actual = context.init_yes_result["exit_code"]
|
|
assert actual == code, (
|
|
f"Expected exit code {code}, got {actual}. "
|
|
f"Output: {context.init_yes_result['output']}"
|
|
)
|
|
|
|
|
|
@then('the init output should contain "{text}"')
|
|
def step_init_output_contains(context, text):
|
|
"""Assert that the init command output contains *text*."""
|
|
output = context.init_yes_result["output"]
|
|
assert text in output, f"Expected '{text}' in output:\n{output}"
|
|
|
|
|
|
@then("the project service initialize_project should have been called")
|
|
def step_initialize_project_called(context):
|
|
"""Assert the mock project service's initialize_project was invoked."""
|
|
context.init_yes_mock_service.initialize_project.assert_called_once()
|
|
|
|
|
|
@when("I run agents init without the --yes flag")
|
|
def step_run_init_no_yes(context):
|
|
"""Invoke ``agents init`` without --yes (interactive mode)."""
|
|
runner = CliRunner()
|
|
|
|
patcher, _mock_service = _create_init_mocks(context)
|
|
try:
|
|
result = runner.invoke(app, ["init"])
|
|
finally:
|
|
patcher.stop()
|
|
|
|
context.init_yes_result = {
|
|
"exit_code": result.exit_code,
|
|
"output": result.output,
|
|
}
|
|
context.init_yes_raw_result = result
|
|
|
|
|
|
@then("the init output should indicate interactive mode")
|
|
def step_output_indicates_interactive(context):
|
|
"""Assert that without --yes the output does NOT contain the
|
|
non-interactive marker, indicating the command ran in interactive mode.
|
|
|
|
This is the negative complement to the --yes scenarios: when the fix
|
|
lands, ``agents init`` (without --yes) should either present a prompt
|
|
or omit the ``Initialized (non-interactive)`` marker.
|
|
"""
|
|
output = context.init_yes_result["output"]
|
|
assert "Initialized (non-interactive)" not in output, (
|
|
"Expected interactive mode but output contains "
|
|
f"'Initialized (non-interactive)':\n{output}"
|
|
)
|
|
|
|
|
|
@then("no interactive prompt should have been presented")
|
|
def step_no_interactive_prompt(context):
|
|
"""Assert that no interactive prompt was presented.
|
|
|
|
Verifies that the command output does not contain common prompt-like
|
|
tokens, which would indicate the ``--yes`` flag failed to suppress
|
|
interactive prompts.
|
|
"""
|
|
output = context.init_yes_result["output"]
|
|
prompt_tokens = (
|
|
"[Y/n]",
|
|
"[y/N]",
|
|
"Continue? ",
|
|
"Proceed? ",
|
|
"Confirm ",
|
|
"(yes/no)",
|
|
)
|
|
for token in prompt_tokens:
|
|
assert token not in output, (
|
|
f"Unexpected prompt token '{token}' found in output:\n{output}"
|
|
)
|
|
# Regex catches "Enter project name:", "Enter path:", "Enter value?"
|
|
# without false-positiving on "Entered configuration" or "Enterprise".
|
|
# Non-greedy .*? stops at the first : or ? to avoid over-matching
|
|
# when the line contains colons in non-prompt contexts (e.g. "Config: /path").
|
|
enter_prompt = re.search(r"Enter\s+\S+.*?[:?]", output)
|
|
assert enter_prompt is None, (
|
|
f"Unexpected Enter-style prompt found in output: "
|
|
f"'{enter_prompt.group()}'\n{output}"
|
|
)
|