"""Mock subprocess runner for devcontainer CLI commands. Provides configurable mock responses for ``devcontainer up``, ``devcontainer exec``, and ``docker stop`` commands used in lifecycle BDD tests. All mocks reside in ``features/mocks/`` per project convention. """ from __future__ import annotations import json from dataclasses import dataclass, field @dataclass class MockProcessResult: """Mimics ``subprocess.CompletedProcess`` for test assertions.""" returncode: int = 0 stdout: str = "" stderr: str = "" args: list[str] = field(default_factory=list) class MockDevcontainerRunner: """Configurable mock for subprocess.run calls. Records all invocations and returns pre-configured results based on the command being run. Usage:: runner = MockDevcontainerRunner() runner.set_up_result(container_id="abcdef012345", workspace="/ws") # pass runner as ``run_command=runner`` in handler calls Attributes: calls: List of all recorded (args, kwargs) invocations. up_result: Result returned for ``devcontainer up`` commands. exec_result: Result returned for ``devcontainer exec`` commands. stop_result: Result returned for ``docker stop`` commands. """ def __init__(self) -> None: self.calls: list[tuple[list[str], dict[str, object]]] = [] self.up_result: MockProcessResult = MockProcessResult() self.exec_result: MockProcessResult = MockProcessResult() self.stop_result: MockProcessResult = MockProcessResult() #: TEST-4 fix: expected subprocess kwargs that production code must pass. #: If production code sets ``check=True`` (causing CalledProcessError on #: non-zero rc) or omits ``capture_output``/``text``, this mock will #: flag the mismatch. _EXPECTED_KWARGS = frozenset({"capture_output", "text", "timeout", "check"}) def __call__( self, args: list[str], **kwargs: object, ) -> MockProcessResult: """Handle a subprocess.run invocation. Args: args: Command-line arguments. **kwargs: Additional subprocess kwargs — validated against expected production kwargs (TEST-4 fix). Returns: Mock result based on the command. Raises: AssertionError: If required kwargs are missing or ``check`` is set to ``True`` (which would raise ``CalledProcessError`` in production). """ self.calls.append((list(args), dict(kwargs))) # TEST-4 fix: validate subprocess kwargs. missing = self._EXPECTED_KWARGS - set(kwargs) if missing: raise AssertionError( f"MockDevcontainerRunner: missing expected subprocess kwargs " f"{missing} in call {args}" ) if kwargs.get("check") is True: raise AssertionError( f"MockDevcontainerRunner: check=True would raise " f"CalledProcessError in production for {args}" ) if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up": return self.up_result if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "exec": return self.exec_result if len(args) >= 2 and args[0] == "docker" and args[1] == "stop": return self.stop_result return MockProcessResult(returncode=0) def set_up_result( self, *, container_id: str = "aabbccddee0011223344", workspace: str = "/workspaces/project", returncode: int = 0, stderr: str = "", ) -> None: """Configure the result for ``devcontainer up``. Args: container_id: Container ID in the JSON output. workspace: Workspace path in the JSON output. returncode: Exit code. stderr: Stderr text. """ output = json.dumps( { "outcome": "success" if returncode == 0 else "error", "containerId": container_id, "remoteWorkspaceFolder": workspace, } ) self.up_result = MockProcessResult( returncode=returncode, stdout=output, stderr=stderr, ) def set_up_no_container_id(self) -> None: """Configure ``devcontainer up`` to succeed without a containerId.""" output = json.dumps({"outcome": "success"}) self.up_result = MockProcessResult( returncode=0, stdout=output, stderr="", ) def set_up_failure(self, stderr: str = "container build failed") -> None: """Configure ``devcontainer up`` to fail. Args: stderr: Error message. """ self.up_result = MockProcessResult( returncode=1, stdout="", stderr=stderr, ) def set_exec_result(self, *, returncode: int = 0, stdout: str = "ping") -> None: """Configure the result for ``devcontainer exec``. Args: returncode: Exit code. stdout: Stdout text. """ self.exec_result = MockProcessResult( returncode=returncode, stdout=stdout, ) def set_exec_failure(self) -> None: """Configure ``devcontainer exec`` to fail.""" self.exec_result = MockProcessResult( returncode=1, stdout="", stderr="container not responding", ) def set_stop_result(self, *, returncode: int = 0) -> None: """Configure the result for ``docker stop``. Args: returncode: Exit code. """ self.stop_result = MockProcessResult(returncode=returncode) @property def call_count(self) -> int: """Total number of invocations.""" return len(self.calls) @property def up_calls(self) -> list[tuple[list[str], dict[str, object]]]: """Calls to ``devcontainer up``.""" return [ (a, k) for a, k in self.calls if len(a) >= 2 and a[0] == "devcontainer" and a[1] == "up" ] @property def exec_calls(self) -> list[tuple[list[str], dict[str, object]]]: """Calls to ``devcontainer exec``.""" return [ (a, k) for a, k in self.calls if len(a) >= 2 and a[0] == "devcontainer" and a[1] == "exec" ] @property def stop_calls(self) -> list[tuple[list[str], dict[str, object]]]: """Calls to ``docker stop``.""" return [ (a, k) for a, k in self.calls if len(a) >= 2 and a[0] == "docker" and a[1] == "stop" ]