forked from HAL9000/cleveragents-core
051ee7c290
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
659 lines
23 KiB
Python
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()
|