Files
cleveragents-core/features/steps/repl_steps.py
T
aditya d9e51d98f8 fix(tui,cli,tests): harden persona/input modes and stabilize parallel test execution
- Add shell safety controls for REPL/TUI (`looks_dangerous`, confirmation gate, timeout handling, and env-based shell disable guard).
- Secure persona workflows with strict name/path validation, safe import/export resolution, atomic+locked registry writes, and malformed YAML resilience.
- Unify persona models and wiring by reusing canonical TUI schema/registry, adding DI providers, and lazy-loading TUI exports to avoid circular imports.
- Improve reference discovery with ignored-directory filtering, symlink-safe walking, and TTL caching for CLI/TUI reference catalogs.
- Expand Behave/Robot coverage for safety/error paths and parallel-isolation behavior; add shared `features/mocks/fake_repl_input.py` helper.
- Fix parallel-run flakiness via deterministic cleanup/reload patterns and watchdog polling fallback when inotify limits are reached.

ISSUES CLOSED: #695
2026-03-18 10:09:06 +00:00

240 lines
7.4 KiB
Python

"""Behave step implementations for the REPL feature."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.cli.commands.repl import (
_get_prompt_context,
run_repl,
)
from features.mocks.fake_repl_input import FakeReplInput
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_repl_with_input(
lines: list[str],
*,
no_history: bool = False,
history_path: Path | None = None,
) -> tuple[int, str]:
"""Run the REPL with simulated input and capture console output."""
from io import StringIO
from rich.console import Console
buf = StringIO()
console = Console(file=buf, no_color=True, width=200)
kw: dict[str, object] = {"no_history": no_history}
if history_path is not None:
kw["history_path"] = history_path
fake = FakeReplInput(lines, interrupt_token="__KEYBOARD_INTERRUPT__")
with (
patch("cleveragents.cli.commands.repl._console", console),
patch("builtins.input", fake),
):
code = run_repl(**kw) # type: ignore[arg-type]
return code, buf.getvalue()
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given("the REPL is started")
def step_repl_started(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = True # default to no history in tests
context.repl_history_path = None
@given("the REPL is started with --no-history")
def step_repl_started_no_history(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = True
context.repl_history_path = None
@given("the REPL is started with a custom history path")
def step_repl_started_custom_history(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = False
tmpdir = Path(tempfile.mkdtemp())
context.repl_history_path = tmpdir / "test_history"
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I send the input "{text}"')
def step_send_input(context: Context, text: str) -> None:
context.repl_lines.append(text)
# Execute REPL with accumulated lines
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I send a multi-line version command")
def step_send_multiline_version(context: Context) -> None:
# Simulate typing ``version \`` followed by an empty continuation line
context.repl_lines.append("version \\")
context.repl_lines.append("")
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I send an empty input line")
def step_send_empty_input(context: Context) -> None:
context.repl_lines.append("")
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("the REPL exits")
def step_repl_exits(context: Context) -> None:
# Re-run with the accumulated lines (EOFError ends the REPL)
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I query the REPL prompt context")
def step_query_prompt(context: Context) -> None:
context.repl_prompt = _get_prompt_context()
@when("I simulate a KeyboardInterrupt during input")
def step_simulate_ctrl_c(context: Context) -> None:
# Send a keyboard interrupt marker, then :exit
context.repl_lines = ["__KEYBOARD_INTERRUPT__", ":exit"]
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
@when("I simulate an EOFError during input")
def step_simulate_ctrl_d(context: Context) -> None:
# Empty lines list → immediate EOFError → clean exit
context.repl_lines = []
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('the REPL output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
assert text in context.repl_output, (
f"Expected '{text}' in output but got:\n{context.repl_output}"
)
@then("the REPL exit code should be {code:d}")
def step_exit_code(context: Context, code: int) -> None:
assert context.repl_exit_code == code, (
f"Expected exit code {code} but got {context.repl_exit_code}"
)
@then("the REPL should exit cleanly")
def step_exit_cleanly(context: Context) -> None:
# A clean exit means exit code 0
if not hasattr(context, "repl_exit_code") or context.repl_exit_code is None:
# Run with empty input to trigger EOFError → clean exit
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
assert context.repl_exit_code == 0, (
f"Expected clean exit (code 0) but got {context.repl_exit_code}"
)
@then("the REPL should continue running")
def step_repl_continues(context: Context) -> None:
# After Ctrl+C the REPL should still exit cleanly (via :exit or EOFError)
assert context.repl_exit_code == 0, (
f"REPL did not continue after interrupt (exit code {context.repl_exit_code})"
)
@then("the REPL should continue without error")
def step_repl_continues_no_error(context: Context) -> None:
assert context.repl_exit_code == 0
@then("no history file should be written")
def step_no_history_file(context: Context) -> None:
if context.repl_history_path is not None:
assert not context.repl_history_path.exists(), (
f"History file should not exist but found: {context.repl_history_path}"
)
@then("the history file should exist")
def step_history_exists(context: Context) -> None:
assert context.repl_history_path is not None
assert context.repl_history_path.exists(), (
f"History file should exist at {context.repl_history_path}"
)
@then('the prompt should contain "{text}"')
def step_prompt_contains(context: Context, text: str) -> None:
assert text in context.repl_prompt, (
f"Expected '{text}' in prompt '{context.repl_prompt}'"
)