forked from cleveragents/cleveragents-core
495 lines
17 KiB
Python
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)"
|