Files
devx/tests/unit/test_start_docker.py
T
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3ac3e613e9
Post-merge / detect-and-configure (push) Canceled after 0s
Post-merge / release-and-maintain (push) Canceled after 0s
DEVX-155: fix: kill inner dockerd with SIGKILL, use alt socket if alive
The inner dockerd started by the CI image doesn't respond to SIGTERM.
Use pkill -9 to force-kill it, then verify it's actually dead by
running docker info. If the old daemon is still alive (can't be killed),
use /dev/shm/docker.sock as an alternate socket path to avoid conflicts.

Also moved the data root cleanup after the kill verification, so we
don't delete files while the old daemon might still be writing to them.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 01:25:37 +02:00

544 lines
24 KiB
Python

"""Unit tests for devx.molecule.start_docker."""
import os
from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner
from devx.molecule.start_docker import (
DOCKER_SOCK,
HOST_DOCKER_SOCK,
ROOTLESS_SOCK,
_diagnose_socket,
_get_docker_free_bytes,
_try_socket,
is_docker_ready,
main,
start_docker_daemon,
)
class TestIsDockerReady:
@patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["docker", "info"]
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
@patch("devx.molecule.start_docker.subprocess.run")
def test_not_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1)
assert is_docker_ready() is False
@patch("devx.molecule.start_docker.subprocess.run")
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
"""Should check the socket specified by DOCKER_HOST env var."""
mock_run.return_value = MagicMock(returncode=0)
rootless = "unix:///run/user/999/docker.sock"
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
class TestGetDockerFreeBytes:
@patch("devx.molecule.start_docker.shutil.disk_usage")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.subprocess.run")
def test_returns_free_bytes(self, mock_run: MagicMock, mock_exists: MagicMock, mock_du: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="/var/lib/docker\n", returncode=0, text="")
mock_du.return_value = MagicMock(free=50 * 1024**3)
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
assert _get_docker_free_bytes() == 50 * 1024**3
@patch("devx.molecule.start_docker.subprocess.run")
def test_daemon_not_reachable(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", text="")
assert _get_docker_free_bytes() == 0
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.subprocess.run")
def test_data_root_not_accessible(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="/some/path\n", returncode=0, text="")
assert _get_docker_free_bytes() == 0
@patch("devx.molecule.start_docker.subprocess.run", side_effect=FileNotFoundError)
def test_subprocess_not_found(self, mock_run: MagicMock) -> None:
assert _get_docker_free_bytes() == 0
class TestTrySocket:
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_ready(self, mock_ready: MagicMock) -> None:
assert _try_socket("/run/user/999/docker.sock") is True
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
def test_not_ready(self, mock_ready: MagicMock) -> None:
assert _try_socket("/run/user/999/docker.sock") is False
class TestDiagnoseSocket:
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None:
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(
stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n",
returncode=0,
text="",
),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.subprocess.run")
def test_docker_info_no_matching_lines(
self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock
) -> None:
"""docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines."""
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
def _exists_map(paths: set[str]) -> MagicMock:
"""Return a mock os.path.exists that returns True only for *paths*."""
return MagicMock(side_effect=lambda p: p in paths)
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_host_socket_available_with_space(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should return immediately if /var/run/docker.sock has enough space."""
assert start_docker_daemon(timeout=5) is True
mock_diag.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({HOST_DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_host_rootless_inaccessible_root_uses_host(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should use host rootless Docker when root dir is inaccessible (free=0)."""
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
@patch("devx.molecule.start_docker.time.sleep")
def test_inner_dockerd_free_zero_starts_local(
self,
mock_sleep: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Inner dockerd with free=0 (not host socket) should NOT be trusted — start local."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# is_docker_ready: first check (inner dockerd) True, then local daemon checks
mock_ready.side_effect = [True, False, False, False, False, True]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
@patch("devx.molecule.start_docker.time.sleep")
def test_host_socket_low_space_starts_local(
self,
mock_sleep: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should start local dockerd if host Docker has low space and no rootless sockets."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
mock_ready.side_effect = [True, False, False, False, False, True]
mock_free.return_value = 5 * 1024**3
# mock_run: pkill calls (x2), docker info check (returncode=1 = dead), rm call
mock_run.side_effect = [
MagicMock(),
MagicMock(),
MagicMock(returncode=1),
MagicMock(),
]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
@patch("devx.molecule.start_docker.time.sleep")
def test_inner_dockerd_alive_uses_alt_sock(
self,
mock_sleep: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should use /dev/shm/docker.sock if inner dockerd can't be killed."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
mock_ready.side_effect = [True, False, False, False, False, True]
mock_free.return_value = 5 * 1024**3
# mock_run: pkill calls (x2), docker info check (returncode=0 = alive), rm call
mock_run.side_effect = [
MagicMock(),
MagicMock(),
MagicMock(returncode=0),
MagicMock(),
]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
assert os.environ.get("DOCKER_HOST") == "unix:///dev/shm/docker.sock"
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_host_rootless_preferred_over_inner_dockerd(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should prefer /run/host-docker.sock over /var/run/docker.sock."""
# host rootless ready with space, inner dockerd never tried
mock_ready.side_effect = [True]
mock_free.side_effect = [200 * 1024**3]
assert start_docker_daemon(timeout=5) is True
# DOCKER_HOST should be set to host socket
assert os.environ.get("DOCKER_HOST") == f"unix://{HOST_DOCKER_SOCK}"
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_host_rootless_not_ready_falls_to_inner(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should fall through to inner dockerd if host rootless socket is not ready."""
mock_ready.side_effect = [False, True]
assert start_docker_daemon(timeout=5) is True
assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch(
"devx.molecule.start_docker.os.path.exists",
side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK, ROOTLESS_SOCK, "/run/user/999/docker.sock"}),
)
@patch("devx.molecule.start_docker.glob.glob", return_value=["/run/user/999/docker.sock"])
def test_glob_finds_extra_rootless_socket(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should include rootless sockets found via glob scan."""
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_inner_dockerd_fallback_when_host_rootless_low_space(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should fall back to inner dockerd if host rootless has low space."""
# host rootless ready low space, inner dockerd ready with space
mock_ready.side_effect = [True, True]
mock_free.side_effect = [5 * 1024**3, 200 * 1024**3]
assert start_docker_daemon(timeout=5) is True
assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
@patch("devx.molecule.start_docker.time.sleep")
def test_all_sockets_low_space_starts_local(
self,
mock_sleep: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""Should start local dockerd if all sockets have low space."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
mock_ready.side_effect = [True, False, False, False, False, True]
mock_free.return_value = 5 * 1024**3
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_starts_local_daemon(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# No sockets exist, local daemon starts
mock_ready.side_effect = [False, False, False, False, True]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
popen_args = mock_popen.call_args.args[0]
assert "dockerd" in popen_args
assert "--storage-driver" in popen_args
assert "vfs" in popen_args
assert "-H" in popen_args
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_after_timeout(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", mock_open(read_data="dockerd error log")):
assert start_docker_daemon(timeout=3) is False
mock_popen.assert_called_once()
assert mock_sleep.call_count == 4 # 1 after pkill + 3 timeout retries
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_log_read_error(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
"""Should handle log read errors gracefully."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", side_effect=OSError("permission denied")):
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.run")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_local_daemon_ready_on_first_check(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_run: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# No sockets exist, local ready on second loop check
mock_ready.side_effect = [False, False, True]
assert start_docker_daemon(timeout=5) is True
assert mock_popen.call_count == 1
assert mock_sleep.call_count == 3 # 1 after pkill + 2 loop retries
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
@patch("devx.molecule.start_docker.os.environ")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
def test_sets_docker_host(
self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_environ: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None:
"""DOCKER_HOST must be set so molecule connects to correct socket."""
start_docker_daemon(timeout=5)
mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
class TestMain:
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_success(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=False)
def test_failure(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 1
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_custom_timeout_flag(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--timeout", "60"])
assert result.exit_code == 0
mock_start.assert_called_once_with(60)
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}
with patch.dict("os.environ", env, clear=True):
with patch("builtins.open", mock_open()) as mock_file:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
mock_file.assert_called_with("/tmp/github_env", "a", encoding="utf-8")
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should not crash when GITHUB_ENV is not set."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0