d9e51d98f8
- 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
240 lines
7.4 KiB
Python
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}'"
|
|
)
|