Files
cleveragents-core/features/steps/devcontainer_activation_steps.py

495 lines
17 KiB
Python

"""Step definitions for devcontainer activation, stop, and rebuild.
Covers: lazy activation (success/failure/timeout/orphan), manual stop,
rebuild, concurrent activation and stop, argument validation for
activate/stop/rebuild, container_id clearing, and early-return on
terminal state.
All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
"""
from __future__ import annotations
import json
import subprocess
from typing import cast
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.container_lifecycle import (
ContainerLifecycleState,
ContainerLifecycleTracker,
)
from cleveragents.resource.handlers.devcontainer import (
activate_container,
get_lifecycle_tracker,
rebuild_container,
set_lifecycle_tracker,
stop_container,
)
from features.mocks.mock_devcontainer_cli import (
MockDevcontainerRunner,
MockProcessResult,
)
# ── Given steps ──────────────────────────────────────────────
@given("a mock devcontainer CLI runner")
def step_create_mock_runner(context: Context) -> None:
context.mock_runner = MockDevcontainerRunner()
@given("the runner configured for successful activation")
def step_configure_success(context: Context) -> None:
context.mock_runner.set_up_result(
container_id="aabbccddee0011223344",
workspace="/workspaces/project",
)
@given("the runner configured for failed activation")
def step_configure_failure(context: Context) -> None:
context.mock_runner.set_up_failure(stderr="container build failed")
@given('an active container "{resource_id}" with container ID "{ctr_id}"')
def step_create_active_container(
context: Context, resource_id: str, ctr_id: str
) -> None:
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.RUNNING,
container_id=ctr_id,
workspace_path="/workspaces/project",
)
set_lifecycle_tracker(tracker)
@given('a stopped container "{resource_id}"')
def step_create_stopped_container(context: Context, resource_id: str) -> None:
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.STOPPED,
)
set_lifecycle_tracker(tracker)
@given('an errored container "{resource_id}"')
def step_create_errored_container(context: Context, resource_id: str) -> None:
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.FAILED,
)
set_lifecycle_tracker(tracker)
@given('an active container with no container_id "{resource_id}"')
def step_create_active_no_ctr_id(context: Context, resource_id: str) -> None:
tracker = ContainerLifecycleTracker(
resource_id=resource_id,
current_state=ContainerLifecycleState.RUNNING,
container_id=None,
workspace_path="/workspaces/project",
)
set_lifecycle_tracker(tracker)
@given("the runner configured for activation with no container_id")
def step_configure_activation_no_ctr_id(context: Context) -> None:
"""Configure devcontainer up to succeed but return no containerId."""
context.mock_runner.up_result = MockProcessResult(
returncode=0,
stdout=json.dumps({"outcome": "success"}),
stderr="",
)
@given("the runner configured for failed docker stop")
def step_configure_stop_failure_rc(context: Context) -> None:
"""Configure docker stop to return a non-zero exit code (not raise)."""
context.mock_runner.set_stop_result(returncode=1)
@given("the runner configured to raise exception on stop")
def step_configure_stop_exception(context: Context) -> None:
"""Replace mock_runner with a wrapper that raises on docker stop."""
inner = context.mock_runner
def raising_runner(args: list[str], **kwargs: object) -> object:
inner.calls.append((list(args), dict(kwargs)))
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
raise OSError("docker daemon not running")
return inner(args, **kwargs)
context.mock_runner = cast(MockDevcontainerRunner, raising_runner)
@given("the runner configured to raise generic exception on up")
def step_configure_up_generic_exception(context: Context) -> None:
"""Replace mock_runner with a wrapper that raises on devcontainer up."""
inner = context.mock_runner
def raising_runner(args: list[str], **kwargs: object) -> object:
inner.calls.append((list(args), dict(kwargs)))
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
raise OSError("network unreachable")
return inner(args, **kwargs)
context.mock_runner = cast(MockDevcontainerRunner, raising_runner)
@given("a mock devcontainer CLI runner that times out")
def step_create_timeout_runner(context: Context) -> None:
"""Create a runner that raises subprocess.TimeoutExpired."""
def _timeout_runner(args: list[str], **kwargs: object) -> None:
raise subprocess.TimeoutExpired(cmd=args, timeout=300)
context.mock_runner = _timeout_runner
@given("a mock devcontainer CLI runner that times out with orphan containers")
def step_create_timeout_orphan_runner(context: Context) -> None:
"""Create a runner that times out on devcontainer up but returns
orphan container IDs on docker ps.
"""
call_log: list[tuple[list[str], dict[str, object]]] = []
def _runner(args: list[str], **kwargs: object) -> object:
call_log.append((list(args), dict(kwargs)))
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
raise subprocess.TimeoutExpired(cmd=args, timeout=300)
if len(args) >= 2 and args[0] == "docker" and args[1] == "ps":
return MockProcessResult(returncode=0, stdout="orphan123abc\n", stderr="")
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
return MockProcessResult(returncode=0)
return MockProcessResult(returncode=0)
context.mock_runner = _runner
context.orphan_call_log = call_log
# ── When steps ───────────────────────────────────────────────
@when('I activate container "{resource_id}" at "{workspace}"')
def step_activate(context: Context, resource_id: str, workspace: str) -> None:
context.activation_error = None
try:
context.activated_tracker = activate_container(
resource_id,
workspace,
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when('I attempt to activate container "{resource_id}" at "{workspace}"')
def step_attempt_activate(context: Context, resource_id: str, workspace: str) -> None:
context.activation_error = None
try:
context.activated_tracker = activate_container(
resource_id,
workspace,
run_command=getattr(context, "mock_runner", None),
)
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when('I stop container "{resource_id}"')
def step_stop(context: Context, resource_id: str) -> None:
context.stop_error = None
try:
stop_container(
resource_id,
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.stop_error = exc
@when('I attempt to stop container "{resource_id}"')
def step_attempt_stop(context: Context, resource_id: str) -> None:
context.stop_error = None
try:
stop_container(
resource_id,
run_command=getattr(context, "mock_runner", None),
)
except (RuntimeError, ValueError) as exc:
context.stop_error = exc
@when('I rebuild container "{resource_id}" at "{workspace}"')
def step_rebuild(context: Context, resource_id: str, workspace: str) -> None:
context.rebuild_error = None
try:
rebuild_container(
resource_id,
workspace,
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.rebuild_error = exc
@when('I call rebuild_container for "{resource_id}" at "{workspace}"')
def step_rebuild_direct(context: Context, resource_id: str, workspace: str) -> None:
context.rebuild_error = None
try:
rebuild_container(
resource_id,
workspace,
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.rebuild_error = exc
@when("I call rebuild_container with empty resource_id")
def step_rebuild_empty_resource_id(context: Context) -> None:
context.rebuild_error = None
try:
rebuild_container("", "/workspace")
except (RuntimeError, ValueError) as exc:
context.rebuild_error = exc
@when("I call rebuild_container with empty workspace_folder")
def step_rebuild_empty_workspace(context: Context) -> None:
context.rebuild_error = None
try:
rebuild_container("01TESTLIFECYCLE0000000091", "")
except (RuntimeError, ValueError) as exc:
context.rebuild_error = exc
@when("I attempt to activate a container with empty resource_id")
def step_activate_empty_resource_id(context: Context) -> None:
context.activation_error = None
try:
activate_container("", "/workspace")
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when("I attempt to activate a container with empty workspace_folder")
def step_activate_empty_workspace(context: Context) -> None:
context.activation_error = None
try:
activate_container("01TESTLIFECYCLE0000000080", "")
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when("I attempt to stop a container with empty resource_id")
def step_stop_empty_resource_id(context: Context) -> None:
context.stop_error = None
try:
stop_container("")
except (RuntimeError, ValueError) as exc:
context.stop_error = exc
@when("I attempt to activate a container with relative workspace_folder")
def step_activate_relative_workspace(context: Context) -> None:
context.activation_error = None
try:
activate_container("01TESTLIFECYCLE0000000080", "relative/path")
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when("I attempt to activate the timed-out container")
def step_activate_timeout(context: Context) -> None:
context.activation_error = None
try:
activate_container(
context.resource_id,
"/workspace",
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when("I attempt to activate the timed-out container with orphan cleanup")
def step_activate_timeout_orphan(context: Context) -> None:
context.activation_error = None
try:
activate_container(
context.resource_id,
"/workspace",
run_command=context.mock_runner,
)
except (RuntimeError, ValueError) as exc:
context.activation_error = exc
@when('I stop container "{resource_id}" expecting early return')
def step_stop_container_early_return(context: Context, resource_id: str) -> None:
"""Stop a container that is already in a terminal state."""
context.stop_result = stop_container(
resource_id,
run_command=context.mock_runner,
)
# ── Then steps ───────────────────────────────────────────────
@then('the container state should be "{state}"')
def step_check_container_state(context: Context, state: str) -> None:
assert context.activated_tracker.current_state == ContainerLifecycleState(state)
@then('the container ID should be "{ctr_id}"')
def step_check_container_id(context: Context, ctr_id: str) -> None:
assert context.activated_tracker.container_id == ctr_id
@then('the workspace path should be "{ws_path}"')
def step_check_workspace_path(context: Context, ws_path: str) -> None:
assert context.activated_tracker.workspace_path == ws_path
@then("the runner should have received a devcontainer up call")
def step_check_up_call(context: Context) -> None:
assert len(context.mock_runner.up_calls) > 0
@then("the activation should raise RuntimeError")
def step_check_activation_runtime_error(context: Context) -> None:
assert context.activation_error is not None
assert isinstance(context.activation_error, RuntimeError)
@then('the container state for "{resource_id}" should be "{state}"')
def step_check_specific_state(context: Context, resource_id: str, state: str) -> None:
tracker = get_lifecycle_tracker(resource_id)
assert tracker.current_state == ContainerLifecycleState(state), (
f"Expected {state}, got {tracker.current_state.value}"
)
@then("the runner should have received a docker stop call")
def step_check_stop_call(context: Context) -> None:
assert len(context.mock_runner.stop_calls) > 0
@then("the runner should not have received a docker stop call")
def step_check_no_stop_call(context: Context) -> None:
assert len(context.mock_runner.stop_calls) == 0, (
f"Expected 0 docker stop calls, got {len(context.mock_runner.stop_calls)}"
)
@then('the activation should raise ValueError with "{keyword}"')
def step_check_activation_value_error(context: Context, keyword: str) -> None:
assert context.activation_error is not None
assert isinstance(context.activation_error, ValueError)
assert keyword in str(context.activation_error)
@then('the stop should raise ValueError with "{keyword}"')
def step_check_stop_value_error(context: Context, keyword: str) -> None:
assert context.stop_error is not None
assert isinstance(context.stop_error, ValueError)
assert keyword in str(context.stop_error)
@then("the stop should raise RuntimeError")
def step_check_stop_runtime_error(context: Context) -> None:
assert context.stop_error is not None
assert isinstance(context.stop_error, RuntimeError)
@then('the rebuild should raise ValueError with "{keyword}"')
def step_check_rebuild_value_error(context: Context, keyword: str) -> None:
assert context.rebuild_error is not None
assert isinstance(context.rebuild_error, ValueError)
assert keyword in str(context.rebuild_error)
@then('the activation should raise RuntimeError with "{text}"')
def step_check_runtime_error(context: Context, text: str) -> None:
assert context.activation_error is not None, "Expected an error but none was raised"
assert isinstance(context.activation_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.activation_error).__name__}"
)
assert text in str(context.activation_error), (
f"Expected '{text}' in error message, got '{context.activation_error}'"
)
@then('the tracker "{resource_id}" should be in "{state}" state')
def step_check_named_tracker_state(
context: Context, resource_id: str, state: str
) -> None:
tracker = get_lifecycle_tracker(resource_id)
assert tracker.current_state == ContainerLifecycleState(state), (
f"Expected state '{state}', got '{tracker.current_state.value}'"
)
@then('the container_id for "{resource_id}" should be None')
def step_check_container_id_none(context: Context, resource_id: str) -> None:
tracker = get_lifecycle_tracker(resource_id)
assert tracker.container_id is None, (
f"Expected container_id None, got '{tracker.container_id}'"
)
@then('the workspace_path for "{resource_id}" should be None')
def step_check_workspace_path_none(context: Context, resource_id: str) -> None:
tracker = get_lifecycle_tracker(resource_id)
assert tracker.workspace_path is None, (
f"Expected workspace_path None, got '{tracker.workspace_path}'"
)
@then('the host_workspace_path for "{resource_id}" should be "{path}"')
def step_check_host_workspace_path(
context: Context, resource_id: str, path: str
) -> None:
tracker = get_lifecycle_tracker(resource_id)
assert tracker.host_workspace_path == path, (
f"Expected host_workspace_path '{path}', got '{tracker.host_workspace_path}'"
)
@then('the devcontainer up call should include "{flag}"')
def step_check_up_call_includes_flag(context: Context, flag: str) -> None:
up_calls = context.mock_runner.up_calls
assert len(up_calls) > 0, "No devcontainer up calls recorded"
args, _ = up_calls[-1]
assert flag in args, f"Expected '{flag}' in {args}"
@then('the devcontainer up call should not include "{flag}"')
def step_check_up_call_excludes_flag(context: Context, flag: str) -> None:
up_calls = context.mock_runner.up_calls
assert len(up_calls) > 0, "No devcontainer up calls recorded"
args, _ = up_calls[-1]
assert flag not in args, f"Did not expect '{flag}' in {args}"
@then("the runner should have attempted orphan container cleanup")
def step_check_orphan_cleanup(context: Context) -> None:
ps_calls = [
(a, k)
for a, k in context.orphan_call_log
if len(a) >= 2 and a[0] == "docker" and a[1] == "ps"
]
assert len(ps_calls) >= 1, "Expected docker ps call for orphan lookup"
stop_calls = [
(a, k)
for a, k in context.orphan_call_log
if len(a) >= 2 and a[0] == "docker" and a[1] == "stop"
]
assert len(stop_calls) >= 1, "Expected docker stop call for orphan cleanup (F1 fix)"