Files
placeholder/features/steps/container_executor_coverage_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
Added 52 new .feature files and corresponding _steps.py files targeting
previously uncovered code paths in the following areas:

- TUI layer: app, commands, persona (state/schema/registry), widgets,
  input (shell_exec, reference_parser)
- Application services: plan lifecycle/service/executor, session,
  project, repo indexing, correction, checkpoint, actor, llm_actors,
  strategy coordinator, resource file watcher, service retry wiring
- CLI commands: session, resource, repl, plan, db, automation_profile
- Domain models: retry_policy, resource_type, cost_budget,
  docker_compose_analyzer, detail_level, _sql_string_aware,
  _postgresql_helpers
- Core: circuit_breaker, retry_service_patterns
- Infrastructure: repositories, transaction_sandbox, strategy_registry,
  plugins/loader, container
- Config: settings
- Agents: plan_generation, context_analysis, auto_debug
- A2A: facade

All new tests follow the Behave/Gherkin BDD standard. Resolved step
definition collisions with unique prefixes. Fixed Alembic fileConfig
logger disabling issue (disable_existing_loggers=False).

ISSUES CLOSED: #1068
2026-03-20 21:22:10 +00:00

659 lines
23 KiB
Python

"""Step definitions for container_executor_coverage.feature.
These steps target specific uncovered lines in container_executor.py:
- Lines 184-185: Symlink default sandbox path rejection
- Lines 536-545: subprocess.TimeoutExpired handling in _run_command
- Lines 555-562: stdout truncation defense-in-depth
- Lines 563-570: stderr truncation defense-in-depth
- Lines 736-740: _read_bounded drain and partial chunk paths
- Line 759 branches: _looks_like_path with \\r and \\t
- Line 712: _parse_output RecursionError/MemoryError fallback
- Lines 582-590: _run_command OSError at Popen level
"""
from __future__ import annotations
import io
import subprocess
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.tool.container_executor import (
_MAX_OUTPUT_BYTES,
ContainerConfig,
ContainerExecutionError,
ContainerToolExecutor,
_looks_like_path,
_read_bounded,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_FAKE_DEVCONTAINER_BIN = "/usr/local/bin/devcontainer"
def _make_executor(
host_sandbox_path: str = "/tmp/sandbox",
workspace_folder: str = "/workspace",
container_id: str = "test-ctr",
timeout_seconds: int = 120,
) -> ContainerToolExecutor:
"""Create a ContainerToolExecutor with a fake devcontainer binary."""
config = ContainerConfig(
workspace_folder=workspace_folder,
container_id=container_id,
host_sandbox_path=host_sandbox_path,
timeout_seconds=timeout_seconds,
)
executor = ContainerToolExecutor(config)
executor._devcontainer_bin = _FAKE_DEVCONTAINER_BIN
return executor
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the container executor coverage module is imported")
def step_coverage_module_imported(context: Any) -> None:
"""Ensure the module is importable."""
assert ContainerToolExecutor is not None
assert _read_bounded is not None
assert _looks_like_path is not None
# =========================================================================
# Symlink default sandbox path rejection (lines 184-185)
# =========================================================================
@given("the default sandbox path is a symlink")
def step_default_sandbox_is_symlink(context: Any) -> None:
"""Set up the mock so that Path('/tmp/sandbox').is_symlink() returns True."""
context.symlink_patcher = patch.object(Path, "is_symlink", return_value=True)
context.symlink_patcher.start()
context.add_cleanup(context.symlink_patcher.stop)
@when("I try to create a ContainerToolExecutor with empty host_sandbox_path")
def step_create_executor_empty_sandbox(context: Any) -> None:
"""Attempt to create executor with empty host_sandbox_path (uses default)."""
config = ContainerConfig(
workspace_folder="/workspace",
container_id="test",
host_sandbox_path="",
)
try:
ContainerToolExecutor(config)
context.symlink_error = None
except ContainerExecutionError as exc:
context.symlink_error = exc
@then("a ContainerExecutionError should be raised about symlink default sandbox")
def step_check_symlink_error(context: Any) -> None:
assert context.symlink_error is not None, (
"Expected ContainerExecutionError for symlink default sandbox"
)
assert "symlink" in str(context.symlink_error).lower(), (
f"Expected 'symlink' in error: {context.symlink_error}"
)
# =========================================================================
# Real subprocess.TimeoutExpired in _run_command (lines 536-545)
# =========================================================================
@given(
"I have a container executor with a Popen mock that raises TimeoutExpired on wait"
)
def step_executor_popen_timeout_expired(context: Any) -> None:
"""Mock subprocess.Popen so proc.wait() raises TimeoutExpired.
The code calls proc.wait(timeout=...) in the try block which raises
TimeoutExpired, then calls proc.kill() and proc.wait() (no timeout)
in the except block. The second wait() must succeed.
"""
executor = _make_executor(timeout_seconds=5)
mock_proc = MagicMock()
mock_proc.stdout = io.BytesIO(b"partial output")
mock_proc.stderr = io.BytesIO(b"partial error")
mock_proc.stdin = None
mock_proc.returncode = -9
mock_proc.wait = MagicMock(
side_effect=[
subprocess.TimeoutExpired(cmd="test", timeout=5),
None,
]
)
mock_proc.kill = MagicMock()
patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
return_value=mock_proc,
)
patcher.start()
context.add_cleanup(patcher.stop)
context.executor = executor
context.mock_proc = mock_proc
@when("I invoke _run_command with the timeout-raising command")
def step_invoke_run_command_timeout(context: Any) -> None:
context.exec_result = context.executor._run_command(["fake", "command"], timeout=5)
@then("the exec result should indicate timed_out true")
def step_check_timed_out_true(context: Any) -> None:
assert context.exec_result.timed_out is True, (
f"Expected timed_out=True, got {context.exec_result.timed_out}"
)
@then('the exec result stderr should mention "timed out"')
def step_check_exec_result_stderr_timeout(context: Any) -> None:
assert "timed out" in context.exec_result.stderr.lower(), (
f"Expected 'timed out' in stderr: {context.exec_result.stderr!r}"
)
@then("the exec result exit_code should be -1")
def step_check_exec_result_exit_code_neg1(context: Any) -> None:
assert context.exec_result.exit_code == -1, (
f"Expected exit_code=-1, got {context.exec_result.exit_code}"
)
@then("the mock process should have been killed")
def step_check_process_killed(context: Any) -> None:
context.mock_proc.kill.assert_called_once()
# =========================================================================
# stdout truncation defense-in-depth (lines 555-562)
# =========================================================================
@given("I have a container executor with _read_bounded returning oversized stdout")
def step_executor_oversized_stdout(context: Any) -> None:
"""Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stdout."""
executor = _make_executor(timeout_seconds=30)
oversized = b"X" * (_MAX_OUTPUT_BYTES + 2048)
normal_stderr = b"ok"
call_count = {"n": 0}
def fake_read_bounded(stream: Any, max_bytes: int) -> bytes:
call_count["n"] += 1
if call_count["n"] == 1:
return oversized # stdout
return normal_stderr # stderr
mock_proc = MagicMock()
mock_proc.stdout = io.BytesIO(b"placeholder")
mock_proc.stderr = io.BytesIO(b"placeholder")
mock_proc.stdin = None
mock_proc.returncode = 0
mock_proc.wait = MagicMock(return_value=0)
popen_patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
return_value=mock_proc,
)
read_patcher = patch(
"cleveragents.tool.container_executor._read_bounded",
side_effect=fake_read_bounded,
)
popen_patcher.start()
read_patcher.start()
context.add_cleanup(popen_patcher.stop)
context.add_cleanup(read_patcher.stop)
context.executor = executor
@when("I invoke _run_command for the oversized stdout scenario")
def step_run_command_oversized_stdout(context: Any) -> None:
context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30)
@then("the exec result stdout length in bytes should not exceed MAX_OUTPUT_BYTES")
def step_check_stdout_truncated(context: Any) -> None:
stdout_bytes = context.exec_result.stdout.encode("utf-8", errors="replace")
assert len(stdout_bytes) <= _MAX_OUTPUT_BYTES, (
f"Expected stdout <= {_MAX_OUTPUT_BYTES} bytes, got {len(stdout_bytes)}"
)
# =========================================================================
# stderr truncation defense-in-depth (lines 563-570)
# =========================================================================
@given("I have a container executor with _read_bounded returning oversized stderr")
def step_executor_oversized_stderr(context: Any) -> None:
"""Mock _read_bounded to return data larger than MAX_OUTPUT_BYTES for stderr."""
executor = _make_executor(timeout_seconds=30)
normal_stdout = b"ok"
oversized = b"E" * (_MAX_OUTPUT_BYTES + 4096)
call_count = {"n": 0}
def fake_read_bounded(stream: Any, max_bytes: int) -> bytes:
call_count["n"] += 1
if call_count["n"] == 1:
return normal_stdout # stdout
return oversized # stderr
mock_proc = MagicMock()
mock_proc.stdout = io.BytesIO(b"placeholder")
mock_proc.stderr = io.BytesIO(b"placeholder")
mock_proc.stdin = None
mock_proc.returncode = 0
mock_proc.wait = MagicMock(return_value=0)
popen_patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
return_value=mock_proc,
)
read_patcher = patch(
"cleveragents.tool.container_executor._read_bounded",
side_effect=fake_read_bounded,
)
popen_patcher.start()
read_patcher.start()
context.add_cleanup(popen_patcher.stop)
context.add_cleanup(read_patcher.stop)
context.executor = executor
@when("I invoke _run_command for the oversized stderr scenario")
def step_run_command_oversized_stderr(context: Any) -> None:
context.exec_result = context.executor._run_command(["fake", "cmd"], timeout=30)
@then("the exec result stderr length in bytes should not exceed MAX_OUTPUT_BYTES")
def step_check_stderr_truncated(context: Any) -> None:
stderr_bytes = context.exec_result.stderr.encode("utf-8", errors="replace")
assert len(stderr_bytes) <= _MAX_OUTPUT_BYTES, (
f"Expected stderr <= {_MAX_OUTPUT_BYTES} bytes, got {len(stderr_bytes)}"
)
# =========================================================================
# _read_bounded drain and partial chunk (lines 736-740)
# =========================================================================
@given("I have a stream with data exceeding the byte cap")
def step_stream_exceeding_cap(context: Any) -> None:
"""Create a BytesIO stream with substantially more data than the cap."""
context.test_max_bytes = 100
# Provide 300 bytes so that after reading 100, we continue draining
context.bounded_stream = io.BytesIO(b"A" * 300)
@when("I call _read_bounded with a small max_bytes limit")
def step_call_read_bounded_small(context: Any) -> None:
context.bounded_result = _read_bounded(
context.bounded_stream, context.test_max_bytes
)
@then("the returned bytes should be exactly max_bytes long")
def step_check_bounded_exact_length(context: Any) -> None:
assert len(context.bounded_result) == context.test_max_bytes, (
f"Expected {context.test_max_bytes} bytes, got {len(context.bounded_result)}"
)
@then("the stream should have been fully consumed")
def step_check_stream_consumed(context: Any) -> None:
remaining = context.bounded_stream.read()
assert len(remaining) == 0, (
f"Expected stream to be fully consumed, but {len(remaining)} bytes remain"
)
@given("I have a stream where a chunk crosses the max_bytes boundary")
def step_stream_chunk_crosses_boundary(context: Any) -> None:
"""Create a stream where a single 64KB chunk would exceed the cap.
_read_bounded reads in _READ_CHUNK_SIZE (65536) byte chunks.
If max_bytes is less than the chunk size, the first chunk itself
crosses the boundary, exercising the partial-slice path.
"""
context.boundary_max_bytes = 100
# Provide one full chunk worth of data (> 100 bytes)
context.boundary_stream = io.BytesIO(b"B" * 70_000)
@when("I call _read_bounded with a boundary-crossing limit")
def step_call_read_bounded_boundary(context: Any) -> None:
context.bounded_result = _read_bounded(
context.boundary_stream, context.boundary_max_bytes
)
@then("the returned bytes should be exactly the boundary limit")
def step_check_boundary_exact(context: Any) -> None:
assert len(context.bounded_result) == context.boundary_max_bytes, (
f"Expected {context.boundary_max_bytes} bytes, "
f"got {len(context.bounded_result)}"
)
# =========================================================================
# _looks_like_path branch coverage for \r and \t (line 759)
# =========================================================================
@when("I check whether a string with carriage return looks like a path")
def step_looks_like_path_cr(context: Any) -> None:
context.path_check_result = _looks_like_path("/some/path\rwith cr")
@when("I check whether a string with tab character looks like a path")
def step_looks_like_path_tab(context: Any) -> None:
context.path_check_result = _looks_like_path("/some/path\twith tab")
@when("I check whether a relative string looks like a path")
def step_looks_like_path_relative(context: Any) -> None:
context.path_check_result = _looks_like_path("relative/path/file.py")
@then("the path check result should be false")
def step_check_path_result_false(context: Any) -> None:
assert context.path_check_result is False, (
"Expected _looks_like_path to return False"
)
# =========================================================================
# _parse_output RecursionError / MemoryError fallback (line 712)
# =========================================================================
@given("json.loads is patched to raise RecursionError")
def step_patch_json_recursion(context: Any) -> None:
patcher = patch(
"cleveragents.tool.container_executor.json.loads",
side_effect=RecursionError("maximum recursion depth exceeded"),
)
patcher.start()
context.add_cleanup(patcher.stop)
@when("I call _parse_output with a non-empty string")
def step_call_parse_output_nonempty(context: Any) -> None:
context.parse_input_str = " some non-json content "
context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str)
@then("the parsed result should contain raw_output with the stripped string")
def step_check_raw_output_stripped(context: Any) -> None:
assert "raw_output" in context.parse_result, (
f"Expected 'raw_output' key, got {context.parse_result}"
)
assert context.parse_result["raw_output"] == context.parse_input_str.strip(), (
f"Expected stripped input, got {context.parse_result['raw_output']!r}"
)
@given("json.loads is patched to raise MemoryError")
def step_patch_json_memory(context: Any) -> None:
patcher = patch(
"cleveragents.tool.container_executor.json.loads",
side_effect=MemoryError("out of memory"),
)
patcher.start()
context.add_cleanup(patcher.stop)
@when("I call _parse_output with a non-empty JSON-like string")
def step_call_parse_output_json_like(context: Any) -> None:
context.parse_input_str = ' {"key": "value"} '
context.parse_result = ContainerToolExecutor._parse_output(context.parse_input_str)
# =========================================================================
# _run_command OSError at Popen level (lines 582-590)
# =========================================================================
@given("I have a container executor with Popen raising OSError")
def step_executor_popen_oserror(context: Any) -> None:
executor = _make_executor(timeout_seconds=10)
patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
side_effect=OSError("No such file or directory"),
)
patcher.start()
context.add_cleanup(patcher.stop)
context.executor = executor
@when("I invoke _run_command for the OSError scenario")
def step_run_command_oserror(context: Any) -> None:
context.exec_result = context.executor._run_command(
["nonexistent", "command"], timeout=10
)
@then("the exec result should indicate failure with OSError message")
def step_check_oserror_result(context: Any) -> None:
assert context.exec_result.exit_code == -1, (
f"Expected exit_code=-1, got {context.exec_result.exit_code}"
)
assert "No such file" in context.exec_result.stderr, (
f"Expected OSError message in stderr: {context.exec_result.stderr!r}"
)
@then("the exec result timed_out should be false")
def step_check_oserror_not_timed_out(context: Any) -> None:
assert context.exec_result.timed_out is False
# =========================================================================
# _map_value_host_to_container with non-path strings (line 624)
# =========================================================================
@given("I have a coverage boost container executor with path mapping")
def step_coverage_boost_executor_path_mapping(context: Any) -> None:
context.cov_executor = _make_executor()
@when("I map an input dict containing non-path string values")
def step_map_input_non_path_strings(context: Any) -> None:
inputs = {
"message": "hello world",
"flag": "true",
"number": 42,
"url": "https://example.com/api",
"abs_path_outside": "/usr/lib/something",
}
context.mapped_non_path_inputs = context.cov_executor._map_input_paths(inputs)
@then("non-path string values should remain unchanged")
def step_check_non_path_inputs_unchanged(context: Any) -> None:
m = context.mapped_non_path_inputs
assert m["message"] == "hello world"
assert m["flag"] == "true"
assert m["number"] == 42
assert m["url"] == "https://example.com/api"
# Absolute path outside host root should remain unchanged
assert m["abs_path_outside"] == "/usr/lib/something"
# =========================================================================
# _map_value_container_to_host with non-path strings
# =========================================================================
@when("I map an output dict containing non-path string values")
def step_map_output_non_path_strings(context: Any) -> None:
output = {
"message": "result text",
"code": "200",
"count": 5,
"external_path": "/usr/local/bin/tool",
}
context.mapped_non_path_outputs = context.cov_executor._map_output_paths(output)
@then("non-path output string values should remain unchanged")
def step_check_non_path_outputs_unchanged(context: Any) -> None:
m = context.mapped_non_path_outputs
assert m["message"] == "result text"
assert m["code"] == "200"
assert m["count"] == 5
assert m["external_path"] == "/usr/local/bin/tool"
# =========================================================================
# _run_command stdin_data piping (lines 524-528)
# =========================================================================
@given("I have a container executor with a Popen mock that accepts stdin")
def step_executor_popen_stdin(context: Any) -> None:
executor = _make_executor(timeout_seconds=10)
mock_stdin = MagicMock()
mock_stdin.closed = False
mock_proc = MagicMock()
mock_proc.stdout = io.BytesIO(b'{"status": "ok"}')
mock_proc.stderr = io.BytesIO(b"")
mock_proc.stdin = mock_stdin
mock_proc.returncode = 0
mock_proc.wait = MagicMock(return_value=0)
patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
return_value=mock_proc,
)
patcher.start()
context.add_cleanup(patcher.stop)
context.executor = executor
context.mock_stdin = mock_stdin
@when("I invoke _run_command with stdin_data provided")
def step_run_command_with_stdin(context: Any) -> None:
context.exec_result = context.executor._run_command(
["fake", "cmd"], timeout=10, stdin_data='{"input": "test"}'
)
@then("stdin should have been written with the encoded data")
def step_check_stdin_written(context: Any) -> None:
context.mock_stdin.write.assert_called_once_with(b'{"input": "test"}')
@then("stdin should have been closed after writing")
def step_check_stdin_closed(context: Any) -> None:
context.mock_stdin.close.assert_called()
# =========================================================================
# _build_exec_command with low timeout
# =========================================================================
@given("I have a coverage boost container executor with devcontainer binary")
def step_coverage_boost_executor_with_bin(context: Any) -> None:
context.cov_executor = _make_executor(container_id="low-timeout-ctr")
@when("I build an exec command with timeout value {timeout:d}")
def step_build_exec_low_timeout(context: Any, timeout: int) -> None:
cmd, stdin_data = context.cov_executor._build_exec_command(
"test_tool", {"key": "val"}, timeout
)
context.built_cmd = cmd
context.built_stdin = stdin_data
@then("the container-side timeout in the command should be 1 or greater")
def step_check_container_timeout_minimum(context: Any) -> None:
# The command ends with "sh -c timeout N cleveragents-tool-exec ..."
# Find the "timeout" argument in the sh -c string
sh_c_arg = context.built_cmd[-1] # last element is the sh -c argument
# Parse "timeout N cleveragents-tool-exec ..."
parts = sh_c_arg.split()
timeout_idx = parts.index("timeout")
container_timeout = int(parts[timeout_idx + 1])
assert container_timeout >= 1, (
f"Expected container timeout >= 1, got {container_timeout}"
)
# =========================================================================
# _run_command stream cleanup in finally block (lines 547-551)
# =========================================================================
@given("I have a container executor with a Popen mock with open streams")
def step_executor_popen_open_streams(context: Any) -> None:
executor = _make_executor(timeout_seconds=10)
# Create mock streams that report as not closed and return data
# then empty bytes (so _read_bounded terminates its loop)
mock_stdout = MagicMock()
mock_stdout.closed = False
mock_stdout.read = MagicMock(side_effect=[b"output data", b""])
mock_stderr = MagicMock()
mock_stderr.closed = False
mock_stderr.read = MagicMock(side_effect=[b"error data", b""])
mock_stdin = MagicMock()
mock_stdin.closed = False
mock_proc = MagicMock()
mock_proc.stdout = mock_stdout
mock_proc.stderr = mock_stderr
mock_proc.stdin = mock_stdin
mock_proc.returncode = 0
mock_proc.wait = MagicMock(return_value=0)
patcher = patch(
"cleveragents.tool.container_executor.subprocess.Popen",
return_value=mock_proc,
)
patcher.start()
context.add_cleanup(patcher.stop)
context.executor = executor
context.mock_stdout = mock_stdout
context.mock_stderr = mock_stderr
context.mock_stdin = mock_stdin
@when("I invoke _run_command for the stream cleanup scenario")
def step_run_command_stream_cleanup(context: Any) -> None:
context.exec_result = context.executor._run_command(
["fake", "cmd"], timeout=10, stdin_data="some data"
)
@then("all process streams should have been closed")
def step_check_all_streams_closed(context: Any) -> None:
# The finally block should close all open streams
context.mock_stdout.close.assert_called()
context.mock_stderr.close.assert_called()
context.mock_stdin.close.assert_called()