Files
cleveragents-core/features/steps/container_clone_into_steps.py
HAL9000 93f6cc703c
CI / push-validation (pull_request) Successful in 29s
CI / helm (pull_request) Successful in 42s
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 52s
CI / quality (pull_request) Successful in 53s
CI / typecheck (pull_request) Successful in 58s
CI / security (pull_request) Successful in 1m32s
CI / unit_tests (pull_request) Successful in 6m15s
CI / docker (pull_request) Successful in 1m38s
CI / integration_tests (pull_request) Successful in 10m5s
CI / coverage (pull_request) Successful in 12m43s
CI / status-check (pull_request) Successful in 3s
fix(tests): use valid ULID and classification in clone-into step fixture
Resource Pydantic model requires resource_id to match the ULID pattern
^[0-9A-HJKMNP-TV-Z]{26}$ and classification to be 'physical' or
'virtual'. The test fixture used '01HANDLER0000000000000001' (invalid
ULID) and 'tool' (invalid enum), causing a ValidationError during
scenario setup that Behave reported as a traceback outside scenario.

Fixes the errored scenario in features/container_clone_into.feature:88.

ISSUES CLOSED: #7555
2026-06-02 20:37:35 -04:00

405 lines
15 KiB
Python

"""Step definitions for container_clone_into.feature.
Tests the CloneIntoHandler module: URL validation, argument validation,
successful clone, failure handling, and container-instance resource type
CLI argument registration.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from cleveragents.application.services._resource_registry_data import (
BUILTIN_TYPES,
)
from cleveragents.domain.models.core.container_lifecycle import (
ContainerLifecycleState,
ContainerLifecycleTracker,
)
from cleveragents.resource.handlers.clone_into import (
CloneIntoError,
clone_repo_into_container,
validate_clone_into_url,
)
from cleveragents.resource.handlers.devcontainer import (
DevcontainerHandler,
clear_lifecycle_registry,
set_lifecycle_tracker,
)
from cleveragents.domain.models.core.resource import Resource
# ── When steps ───────────────────────────────────────────────
@when('I validate clone-into URL "{url}"')
def step_validate_url(context: Context, url: str) -> None:
"""Validate a clone-into URL."""
context.clone_url_error = None
try:
validate_clone_into_url(url)
context.clone_url_valid = True
except ValueError as exc:
context.clone_url_valid = False
context.clone_url_error = exc
@when("I validate clone-into URL with whitespace only")
def step_validate_whitespace_url(context: Context) -> None:
"""Validate a whitespace-only clone-into URL."""
context.clone_url_error = None
try:
validate_clone_into_url(" ")
context.clone_url_valid = True
except ValueError as exc:
context.clone_url_valid = False
context.clone_url_error = exc
@when("I call clone_repo_into_container with empty container_id")
def step_clone_empty_container_id(context: Context) -> None:
"""Call clone_repo_into_container with an empty container_id."""
context.clone_error = None
try:
clone_repo_into_container("", "https://github.com/org/repo.git")
except ValueError as exc:
context.clone_error = exc
@when("I call clone_repo_into_container with empty repo_url")
def step_clone_empty_repo_url(context: Context) -> None:
"""Call clone_repo_into_container with an empty repo_url."""
context.clone_error = None
try:
clone_repo_into_container("abc123def456", "")
except ValueError as exc:
context.clone_error = exc
@when('I clone "{repo_url}" into container "{container_id}"')
def step_clone_into_container(
context: Context, repo_url: str, container_id: str
) -> None:
"""Clone a repository into a container."""
context.clone_error = None
context.clone_result = None
try:
context.clone_result = clone_repo_into_container(container_id, repo_url)
except (CloneIntoError, ValueError) as exc:
context.clone_error = exc
@when(
'I clone repo "{repo_url}" into container "{container_id}" with target "{target_dir}"'
)
def step_clone_into_container_at(
context: Context, repo_url: str, container_id: str, target_dir: str
) -> None:
"""Clone a repository into a container at a specific target directory."""
context.clone_error = None
context.clone_result = None
try:
context.clone_result = clone_repo_into_container(
container_id, repo_url, target_dir
)
except (CloneIntoError, ValueError) as exc:
context.clone_error = exc
@when('I look up the "{type_name}" resource type spec')
def step_look_up_resource_type_spec(context: Context, type_name: str) -> None:
"""Look up a resource type spec from BUILTIN_TYPES."""
context.resource_type_spec = None
for entry in BUILTIN_TYPES:
if entry.get("name") == type_name:
context.resource_type_spec = entry
break
# ── Given steps ──────────────────────────────────────────────
@given("a mock docker exec that succeeds for clone")
def step_mock_docker_exec_success(context: Context) -> None:
"""Patch subprocess.run to simulate a successful docker exec git clone."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
patcher = patch(
"cleveragents.resource.handlers.clone_into.subprocess.run",
return_value=mock_result,
)
patcher.start()
context.add_cleanup(patcher.stop)
@given('a mock docker exec that fails for clone with stderr "{stderr_msg}"')
def step_mock_docker_exec_failure(context: Context, stderr_msg: str) -> None:
"""Patch subprocess.run to simulate a failed docker exec git clone."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = stderr_msg
patcher = patch(
"cleveragents.resource.handlers.clone_into.subprocess.run",
return_value=mock_result,
)
patcher.start()
context.add_cleanup(patcher.stop)
@given(
'a CloneIntoError with repo "{repo_url}" container "{container_id}" stderr "{stderr}"'
)
def step_create_clone_into_error(
context: Context, repo_url: str, container_id: str, stderr: str
) -> None:
"""Create a CloneIntoError instance for attribute testing."""
context.clone_into_error = CloneIntoError(
repo_url=repo_url,
container_id=container_id,
stderr=stderr,
)
# ── Then steps ───────────────────────────────────────────────
@then("the clone-into URL should be valid")
def step_url_should_be_valid(context: Context) -> None:
assert context.clone_url_valid is True, (
"Expected URL to be valid but validate_clone_into_url returned False"
)
@then("the clone-into URL should be invalid")
def step_url_should_be_invalid(context: Context) -> None:
assert context.clone_url_valid is False, (
"Expected URL to be invalid but validate_clone_into_url returned True"
)
@then('it should raise ValueError mentioning "{field}"')
def step_should_raise_value_error(context: Context, field: str) -> None:
assert context.clone_error is not None, (
"Expected a ValueError but no error was raised"
)
assert isinstance(context.clone_error, ValueError), (
f"Expected ValueError, got {type(context.clone_error).__name__}"
)
assert field.lower() in str(context.clone_error).lower(), (
f"Expected '{field}' in error message, got: {context.clone_error!r}"
)
@then("the clone should succeed")
def step_clone_should_succeed(context: Context) -> None:
assert context.clone_error is None, (
f"Expected clone to succeed but got error: {context.clone_error!r}"
)
assert context.clone_result is not None, "Expected a clone result but got None"
@then('the clone target should be "{expected_target}"')
def step_clone_target(context: Context, expected_target: str) -> None:
assert context.clone_result == expected_target, (
f"Expected clone target '{expected_target}', got '{context.clone_result}'"
)
@then("the clone should raise CloneIntoError")
def step_clone_should_raise(context: Context) -> None:
assert context.clone_error is not None, (
"Expected CloneIntoError but no error was raised"
)
assert isinstance(context.clone_error, CloneIntoError), (
f"Expected CloneIntoError, got {type(context.clone_error).__name__}"
)
@then('the CloneIntoError should mention "{text}"')
def step_clone_error_mentions(context: Context, text: str) -> None:
error_str = str(context.clone_error)
assert text.lower() in error_str.lower(), (
f"Expected '{text}' in CloneIntoError message, got: {error_str!r}"
)
@then('the CloneIntoError repo_url should be "{expected}"')
def step_clone_error_repo_url(context: Context, expected: str) -> None:
assert context.clone_into_error.repo_url == expected, (
f"Expected repo_url '{expected}', got '{context.clone_into_error.repo_url}'"
)
@then('the CloneIntoError container_id should be "{expected}"')
def step_clone_error_container_id(context: Context, expected: str) -> None:
assert context.clone_into_error.container_id == expected, (
f"Expected container_id '{expected}', "
f"got '{context.clone_into_error.container_id}'"
)
@then('the CloneIntoError stderr should be "{expected}"')
def step_clone_error_stderr(context: Context, expected: str) -> None:
assert context.clone_into_error.stderr == expected, (
f"Expected stderr '{expected}', got '{context.clone_into_error.stderr}'"
)
@then('the spec should have a CLI argument named "{arg_name}"')
def step_spec_has_cli_arg(context: Context, arg_name: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
arg_names = [arg.get("name") for arg in cli_args]
assert arg_name in arg_names, f"Expected CLI argument '{arg_name}' in {arg_names}"
@then('the clone-into argument should be of type "{expected_type}"')
def step_clone_into_arg_type(context: Context, expected_type: str) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
clone_into_arg = next(
(arg for arg in cli_args if arg.get("name") == "clone-into"), None
)
assert clone_into_arg is not None, "clone-into argument not found"
assert clone_into_arg.get("type") == expected_type, (
f"Expected type '{expected_type}', got '{clone_into_arg.get('type')}'"
)
@then("the clone-into argument should not be required")
def step_clone_into_not_required(context: Context) -> None:
spec = context.resource_type_spec
assert spec is not None, "No resource type spec found"
cli_args: list[dict[str, Any]] = spec.get("cli_args", [])
clone_into_arg = next(
(arg for arg in cli_args if arg.get("name") == "clone-into"), None
)
assert clone_into_arg is not None, "clone-into argument not found"
assert clone_into_arg.get("required") is False, (
f"Expected clone-into to be optional (required=False), "
f"got required={clone_into_arg.get('required')}"
)
# -- End-to-end handler wiring steps --
@given(
'a devcontainer-instance resource with clone_into "{clone_url}" and tracker container_id "{container_id}"'
)
def step_devcontainer_resource_with_clone_into(
context: Context, clone_url: str, container_id: str
) -> None:
"""Set up a devcontainer-instance resource with clone_into property and tracker."""
# Clear any existing lifecycle state
clear_lifecycle_registry()
# Create a resource with clone_into in properties
# resource_id must be a valid ULID (Crockford Base32, 26 chars, no I/L/O/U)
# classification must be 'physical' or 'virtual'
context.handler_resource = Resource(
resource_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
name="test-devcontainer",
resource_type_name="devcontainer-instance",
classification="physical",
description="Test devcontainer for clone-into wiring",
location="/tmp/test-workspace",
properties={"clone_into": clone_url},
)
# Set up the lifecycle tracker in DISCOVERED state with container_id pre-set
# (simulates the state after activate_container() has run and set container_id)
tracker = ContainerLifecycleTracker(
resource_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
current_state=ContainerLifecycleState.DISCOVERED,
container_id=container_id,
)
set_lifecycle_tracker(tracker)
context.handler_clone_url = clone_url
context.handler_container_id = container_id
# Store cleanup reference
context.add_cleanup(clear_lifecycle_registry)
@given("clone_repo_into_container is mocked to succeed")
def step_mock_clone_repo_into_container(context: Context) -> None:
"""Mock clone_repo_into_container to capture calls without running docker."""
mock_clone = MagicMock(return_value="/workspace")
patcher = patch(
"cleveragents.resource.handlers.devcontainer.clone_repo_into_container",
mock_clone,
)
patcher.start()
context.mock_clone_repo = mock_clone
context.add_cleanup(patcher.stop)
@when("DevcontainerHandler.resolve() is called for the resource")
def step_call_devcontainer_handler_resolve(context: Context) -> None:
"""Call DevcontainerHandler.resolve() with the test resource."""
handler = DevcontainerHandler()
mock_sandbox_manager = MagicMock()
mock_bound_resource = MagicMock()
# Patch activate_container to be a no-op (tracker already has container_id)
# Patch BaseResourceHandler.resolve to return a mock BoundResource
with (
patch("cleveragents.resource.handlers.devcontainer.activate_container"),
patch(
"cleveragents.resource.handlers._base.BaseResourceHandler.resolve",
return_value=mock_bound_resource,
),
):
context.handler_resolve_error = None
context.handler_resolve_result = None
try:
context.handler_resolve_result = handler.resolve(
resource=context.handler_resource,
plan_id="test-plan-001",
slot_name="test-slot",
sandbox_manager=mock_sandbox_manager,
)
except Exception as exc:
context.handler_resolve_error = exc
@then(
'clone_repo_into_container should have been called with container_id "{expected_container_id}" and url "{expected_url}"'
)
def step_assert_clone_called(
context: Context, expected_container_id: str, expected_url: str
) -> None:
"""Assert that clone_repo_into_container was called with the expected arguments."""
assert context.handler_resolve_error is None, (
f"Expected resolve() to succeed but got error: {context.handler_resolve_error!r}"
)
mock_clone = context.mock_clone_repo
assert mock_clone.called, (
"Expected clone_repo_into_container to have been called, but it was not"
)
call_args = mock_clone.call_args
actual_container_id = (
call_args[0][0] if call_args[0] else call_args[1].get("container_id")
)
actual_url = (
call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("repo_url")
)
assert actual_container_id == expected_container_id, (
f"Expected container_id '{expected_container_id}', got '{actual_container_id}'"
)
assert actual_url == expected_url, (
f"Expected url '{expected_url}', got '{actual_url}'"
)