forked from HAL9000/cleveragents-core
5e625b22e1
Replace CliRunner + unittest.mock.patch with subprocess.run for all 21 CLI-facing test functions across the M1-M6 E2E verification helpers. Application code fixes: - action.py: _get_lifecycle_service() uses container.plan_lifecycle_service() - plan.py: _get_lifecycle_service() uses container.plan_lifecycle_service() - plan.py: three container.resolve(DecisionService) → container.decision_service() Test infrastructure: - New robot/helper_e2e_common.py with shared subprocess utilities (run_cli, setup_workspace with DB migrations, cleanup_workspace) - M1-M4, M6 helpers refactored to use run_cli() with real SQLite DB - M5 unchanged (0 CLI tests, all domain-level) - TDD detection updated to recognise run_cli() as subprocess invocation - Remove @tdd_expected_fail from TDD feature + robot tags - Update 8 Behave step files that mocked container.resolve() to use container.decision_service() / container.plan_lifecycle_service()
272 lines
9.0 KiB
Python
272 lines
9.0 KiB
Python
"""Helper script for tdd_e2e_mock_only_coverage.robot smoke tests.
|
|
|
|
Each subcommand uses AST analysis to inspect the M1-M6 E2E verification
|
|
helper files and classify each public test function by its mock and CLI
|
|
invocation strategy. The helper reports the **real** outcome: it exits 0
|
|
and prints the sentinel when the assertion holds (bug is fixed), and exits
|
|
1 when the bug is still present. The ``tdd_expected_fail`` tag on the
|
|
Robot side handles pass/fail inversion while the bug remains open.
|
|
|
|
Root cause (Bug #658)
|
|
~~~~~~~~~~~~~~~~~~~~~
|
|
All 21 CLI-facing tests in ``robot/helper_m*_e2e_verification.py`` use
|
|
``unittest.mock.patch`` to replace service factories with ``MagicMock``
|
|
objects and invoke the CLI via Typer's in-process ``CliRunner``. Zero
|
|
tests invoke the actual ``agents`` CLI binary via ``subprocess.run``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
_HELPER_FILES = [
|
|
_REPO_ROOT / f"robot/helper_m{n}_e2e_verification.py" for n in range(1, 7)
|
|
]
|
|
|
|
# Patch targets that indicate service-layer mocking.
|
|
_SERVICE_MOCK_INDICATORS: frozenset[str] = frozenset(
|
|
{
|
|
"_get_lifecycle_service",
|
|
"_get_registry_service",
|
|
"_get_namespaced_project_repo",
|
|
"_get_resource_link_repo",
|
|
"_get_resource_registry_service",
|
|
"_get_services",
|
|
"_get_service",
|
|
"get_container",
|
|
"CorrectionService",
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class FunctionAnalysis:
|
|
"""Analysis result for a single function in an E2E helper file."""
|
|
|
|
name: str
|
|
suite: str
|
|
uses_cli_runner: bool = False
|
|
uses_mock_patch: bool = False
|
|
uses_subprocess_cli: bool = False
|
|
mock_targets: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _analyze_helper(helper_path: Path) -> list[FunctionAnalysis]:
|
|
"""Parse a helper file and classify each top-level function."""
|
|
source = helper_path.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=str(helper_path))
|
|
suite = helper_path.stem
|
|
|
|
results: list[FunctionAnalysis] = []
|
|
|
|
for node in ast.iter_child_nodes(tree):
|
|
if not isinstance(node, ast.FunctionDef):
|
|
continue
|
|
if node.name.startswith("_"):
|
|
continue
|
|
|
|
fa = FunctionAnalysis(name=node.name, suite=suite)
|
|
|
|
for child in ast.walk(node):
|
|
if isinstance(child, ast.Attribute):
|
|
attr_name = child.attr
|
|
if attr_name == "invoke" and _is_cli_runner_invoke(child):
|
|
fa.uses_cli_runner = True
|
|
if attr_name in ("run", "Popen") and _is_subprocess_call(child):
|
|
fa.uses_subprocess_cli = True
|
|
|
|
# Detect ``run_cli(...)`` calls from helper_e2e_common
|
|
# which invoke the real CLI via subprocess.run.
|
|
if isinstance(child, ast.Call) and _is_run_cli_call(child):
|
|
fa.uses_cli_runner = True # CLI-facing function
|
|
fa.uses_subprocess_cli = True # via real subprocess
|
|
|
|
if isinstance(child, ast.Call) and _is_patch_call(child):
|
|
fa.uses_mock_patch = True
|
|
target = _extract_patch_target(child)
|
|
if target:
|
|
fa.mock_targets.append(target)
|
|
|
|
if isinstance(child, ast.Constant) and isinstance(child.value, str):
|
|
for indicator in _SERVICE_MOCK_INDICATORS:
|
|
if indicator in child.value:
|
|
fa.uses_mock_patch = True
|
|
|
|
results.append(fa)
|
|
|
|
return results
|
|
|
|
|
|
def _is_cli_runner_invoke(node: ast.Attribute) -> bool:
|
|
"""Check if attribute access is ``runner.invoke(...)``."""
|
|
return isinstance(node.value, ast.Name) and node.value.id in (
|
|
"runner",
|
|
"cli_runner",
|
|
)
|
|
|
|
|
|
def _is_subprocess_call(node: ast.Attribute) -> bool:
|
|
"""Check if attribute access is ``subprocess.run(...)``."""
|
|
return isinstance(node.value, ast.Name) and node.value.id == "subprocess"
|
|
|
|
|
|
def _is_run_cli_call(node: ast.Call) -> bool:
|
|
"""Check if a Call node is ``run_cli(...)`` from helper_e2e_common.
|
|
|
|
``run_cli`` is a convenience wrapper around ``subprocess.run`` that
|
|
invokes the real ``agents`` CLI binary. Functions calling it are
|
|
CLI-facing and exercise subprocess invocation without mocks.
|
|
"""
|
|
func = node.func
|
|
return isinstance(func, ast.Name) and func.id == "run_cli"
|
|
|
|
|
|
def _is_patch_call(node: ast.Call) -> bool:
|
|
"""Check if a Call node is ``patch(...)`` or ``mock.patch(...)``."""
|
|
func = node.func
|
|
if isinstance(func, ast.Attribute) and func.attr == "patch":
|
|
return True
|
|
return isinstance(func, ast.Name) and func.id == "patch"
|
|
|
|
|
|
def _extract_patch_target(node: ast.Call) -> str | None:
|
|
"""Extract the first string argument from a ``patch(...)`` call."""
|
|
if node.args and isinstance(node.args[0], ast.Constant):
|
|
val = node.args[0].value
|
|
if isinstance(val, str):
|
|
return val
|
|
return None
|
|
|
|
|
|
def _get_all_functions() -> list[FunctionAnalysis]:
|
|
"""Analyze all M1-M6 helpers and return flat function list."""
|
|
all_functions: list[FunctionAnalysis] = []
|
|
for helper_path in _HELPER_FILES:
|
|
if not helper_path.is_file():
|
|
print(
|
|
f"ERROR: missing helper file {helper_path}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
all_functions.extend(_analyze_helper(helper_path))
|
|
return all_functions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_subprocess_usage() -> None:
|
|
"""Assert at least one CLI-facing test uses subprocess.
|
|
|
|
Exits 0 with sentinel when the assertion holds (bug fixed).
|
|
Exits 1 when the bug is still present.
|
|
"""
|
|
functions = _get_all_functions()
|
|
cli_facing = [f for f in functions if f.uses_cli_runner]
|
|
if not cli_facing:
|
|
print("ERROR: no CLI-facing functions found", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
subprocess_users = [f for f in cli_facing if f.uses_subprocess_cli]
|
|
if not subprocess_users:
|
|
print(
|
|
f"Bug #658 present: {len(cli_facing)} CLI-facing E2E test "
|
|
f"functions, but NONE use subprocess. All use CliRunner.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("tdd-e2e-subprocess-check-ok")
|
|
|
|
|
|
def check_unmocked_services() -> None:
|
|
"""Assert at least one CLI-facing test skips service mocking.
|
|
|
|
Exits 0 with sentinel when the assertion holds (bug fixed).
|
|
Exits 1 when the bug is still present.
|
|
"""
|
|
functions = _get_all_functions()
|
|
cli_facing = [f for f in functions if f.uses_cli_runner]
|
|
if not cli_facing:
|
|
print("ERROR: no CLI-facing functions found", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
unmocked = [f for f in cli_facing if not f.uses_mock_patch]
|
|
if not unmocked:
|
|
print(
|
|
f"Bug #658 present: {len(cli_facing)} CLI-facing E2E test "
|
|
f"functions, but ALL mock the service layer.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("tdd-e2e-unmocked-services-ok")
|
|
|
|
|
|
def check_per_suite_coverage() -> None:
|
|
"""Assert no suite has 100% mocked CLI tests.
|
|
|
|
Exits 0 with sentinel when the assertion holds (bug fixed).
|
|
Exits 1 when the bug is still present.
|
|
"""
|
|
per_suite: dict[str, list[FunctionAnalysis]] = {}
|
|
for helper_path in _HELPER_FILES:
|
|
if not helper_path.is_file():
|
|
print(
|
|
f"ERROR: missing helper file {helper_path}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(2)
|
|
suite = helper_path.stem
|
|
per_suite[suite] = _analyze_helper(helper_path)
|
|
|
|
all_mocked_suites: list[str] = []
|
|
for suite, functions in per_suite.items():
|
|
cli_facing = [f for f in functions if f.uses_cli_runner]
|
|
if not cli_facing:
|
|
continue
|
|
unmocked = [f for f in cli_facing if not f.uses_mock_patch]
|
|
if not unmocked:
|
|
all_mocked_suites.append(
|
|
f"{suite} ({len(cli_facing)} CLI tests, all mocked)"
|
|
)
|
|
|
|
if all_mocked_suites:
|
|
print(
|
|
"Bug #658 present: suites with 100% mocked CLI tests:\n"
|
|
+ "\n".join(f" - {s}" for s in all_mocked_suites),
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("tdd-e2e-per-suite-coverage-ok")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COMMANDS: dict[str, Callable[[], None]] = {
|
|
"check-subprocess-usage": check_subprocess_usage,
|
|
"check-unmocked-services": check_unmocked_services,
|
|
"check-per-suite-coverage": check_per_suite_coverage,
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
|
print(
|
|
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
cmd = _COMMANDS[sys.argv[1]]
|
|
cmd()
|