Files
cleveragents-core/robot/helper_tdd_invariant_persistence.py
T
HAL9000 c1dca18654 fix(invariant): use json format in persistence helper list calls
The helper used the CLI default Rich table renderer and substring-checked
for the invariant text in the captured output. With CliRunner's narrow
default console width the Rich table soft-wraps "Must validate inputs"
across two visual rows, so the substring check always failed even though
the invariant was correctly persisted and retrieved from the database.

Pass --format json on the list invocations: the JSON serialiser emits the
text verbatim so the substring assertion now reflects persistence rather
than column-width formatting. Verified locally by running all three
helper subcommands against a fresh SQLite DB — all three now print their
ok markers.

ISSUES CLOSED: #8573
2026-06-18 11:36:35 -04:00

142 lines
5.0 KiB
Python

"""Helper script for tdd_invariant_persistence.robot (bug #1022, now fixed).
Exercises InvariantService cross-invocation persistence at the integration
level. Each subcommand simulates a fresh CLI process by creating a new
InvariantService instance, mirroring how the real CLI works (each
``python -m cleveragents`` call gets its own service).
Bug #8573 / #1022 is now FIXED: InvariantService uses SQLite-based persistence
via the configured ``database_url``, so invariants added in one CLI invocation
persist across process restarts.
This helper is called from Robot Framework via ``Run Process``.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from typer.testing import CliRunner # noqa: E402
from cleveragents.application.services.invariant_service import ( # noqa: E402
InvariantService,
)
from cleveragents.cli.commands.invariant import app as invariant_app # noqa: E402
from cleveragents.core.exceptions import NotFoundError # noqa: E402
from cleveragents.domain.models.core.invariant import InvariantScope # noqa: E402
runner = CliRunner()
def add_then_list_project() -> None:
"""Add a project invariant in invocation 1, list in invocation 2.
Simulates two separate CLI invocations by using fresh InvariantService
instances. The list in invocation 2 should show the invariant added
in invocation 1 — but it won't because of bug #1022.
"""
# Invocation 1: add
svc1 = InvariantService()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc1):
add_result = runner.invoke(
invariant_app,
["add", "--project", "local/test-proj", "Must validate inputs"],
)
if add_result.exit_code != 0:
print(f"FAIL-ADD: exit={add_result.exit_code} out={add_result.output}")
sys.exit(1)
# Invocation 2: list (fresh service — simulates new process).
# Use --format json so the invariant text is emitted verbatim instead of
# being soft-wrapped by the default Rich table renderer (which breaks
# substring matching when the text spans multiple visual rows).
svc2 = InvariantService()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc2):
list_result = runner.invoke(
invariant_app,
["list", "--project", "local/test-proj", "--format", "json"],
)
# The list output should contain the invariant — if it doesn't, bug exists
if "Must validate inputs" in list_result.output:
print("invariant-persist-project-ok")
else:
print(
f"FAIL-PERSIST: invariant not found in second invocation. "
f"output:\n{list_result.output}"
)
sys.exit(1)
def add_then_list_global() -> None:
"""Add a global invariant in invocation 1, list in invocation 2."""
svc1 = InvariantService()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc1):
add_result = runner.invoke(
invariant_app,
["add", "--global", "Never expose credentials"],
)
if add_result.exit_code != 0:
print(f"FAIL-ADD: exit={add_result.exit_code} out={add_result.output}")
sys.exit(1)
svc2 = InvariantService()
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc2):
list_result = runner.invoke(
invariant_app, ["list", "--global", "--format", "json"]
)
if "Never expose credentials" in list_result.output:
print("invariant-persist-global-ok")
else:
print(
f"FAIL-PERSIST: invariant not found in second invocation. "
f"output:\n{list_result.output}"
)
sys.exit(1)
def add_then_remove_cross_instance() -> None:
"""Add invariant in instance 1, remove by ID in instance 2."""
svc1 = InvariantService()
inv = svc1.add_invariant(
text="Temporary constraint",
scope=InvariantScope.PROJECT,
source_name="local/temp",
)
inv_id = inv.id
# Fresh instance — simulates new CLI process
svc2 = InvariantService()
try:
svc2.remove_invariant(inv_id)
print("invariant-cross-remove-ok")
except NotFoundError as exc:
print(f"FAIL-REMOVE: {type(exc).__name__}: {exc}")
sys.exit(1)
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
COMMANDS = {
"add-then-list-project": add_then_list_project,
"add-then-list-global": add_then_list_global,
"add-then-remove-cross": add_then_remove_cross_instance,
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else None
if cmd not in COMMANDS:
print(f"Unknown command: {cmd}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
sys.exit(2)
COMMANDS[cmd]()