Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

215 lines
8.9 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=protected-access
"""Test cases for :class:`DockerBackend`.
Validates that the three backend primitives (``exec_shell``,
``read_file``, ``write_file``) and the inherited shell-based filesystem
helpers behave correctly inside a real Docker container.
The suite is skipped unless:
* the Docker daemon is reachable (probed via ``docker info``), and
* the host is Linux — the project only validates the Docker backend on
Ubuntu/Linux CI runners; spinning containers up on other hosts is
out of scope for these tests.
A live container is obtained by initializing a :class:`DockerWorkspace`
and reusing its already-wired :class:`DockerBackend` (``ws._backend``),
which avoids duplicating the container bring-up logic here.
"""
import shutil
import subprocess
import sys
import tempfile
import unittest
import uuid
from unittest.async_case import IsolatedAsyncioTestCase
from agentscope.tool import ExecResult
from agentscope.workspace import DockerWorkspace, DockerBackend
from agentscope.workspace._docker._make_dockerfile import CONTAINER_WORKDIR
# ── availability checks ────────────────────────────────────────────
def _docker_available() -> bool:
"""Return ``True`` iff the Docker daemon is reachable.
Probes via the ``docker`` CLI (cheap, synchronous) so the result can
gate the module at import time.
"""
if shutil.which("docker") is None:
return False
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
timeout=5,
check=False,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
_IS_LINUX = sys.platform == "linux"
_DOCKER_OK = _IS_LINUX and _docker_available()
_SKIP_REASON = "Docker backend is only tested on Linux with a live daemon"
@unittest.skipUnless(_DOCKER_OK, _SKIP_REASON)
class TestDockerBackend(IsolatedAsyncioTestCase):
"""Test cases for ``DockerBackend`` against a live container."""
async def asyncSetUp(self) -> None:
"""Start a workspace and reuse its wired backend.
The ``workspace_id`` is randomised so concurrent / repeated runs
do not collide on the deterministic container name.
"""
# pylint: disable=consider-using-with
self.temp_dir = tempfile.TemporaryDirectory()
self.workspace = DockerWorkspace(
workspace_id=f"test-{uuid.uuid4().hex[:8]}",
host_workdir=self.temp_dir.name,
)
await self.workspace.initialize()
self.backend = self.workspace._backend
self.assertIsInstance(self.backend, DockerBackend)
async def asyncTearDown(self) -> None:
"""Stop the container and drop the temp host dir."""
try:
await self.workspace.close()
finally:
self.temp_dir.cleanup()
# ── exec ───────────────────────────────────────────────────────
async def test_exec_returns_stdout(self) -> None:
"""A program's stdout/exit code are captured into ``ExecResult``."""
result = await self.backend.exec_shell(["echo", "hello world"])
self.assertIsInstance(result, ExecResult)
self.assertTrue(result.ok())
self.assertEqual(result.stdout.decode().strip(), "hello world")
async def test_exec_nonzero_exit(self) -> None:
"""A non-zero exit is reported as a normal (non-raising) result."""
result = await self.backend.exec_shell(
["sh", "-c", "echo oops >&2; exit 4"],
)
self.assertEqual(result.exit_code, 4)
self.assertIn("oops", result.stderr.decode())
async def test_exec_argv_not_shell_split(self) -> None:
"""A single argv element with metacharacters reaches the program
intact (no shell interposed by the primitive)."""
tricky = "a b $(echo x) | ;"
result = await self.backend.exec_shell(["echo", tricky])
self.assertTrue(result.ok())
self.assertEqual(result.stdout.decode().rstrip("\n"), tricky)
async def test_exec_cwd_default_is_workdir(self) -> None:
"""With no explicit ``cwd`` the container workdir is used."""
result = await self.backend.exec_shell(["pwd"])
self.assertTrue(result.ok())
self.assertEqual(result.stdout.decode().strip(), CONTAINER_WORKDIR)
async def test_exec_timeout_returns_minus_one(self) -> None:
"""A command exceeding ``timeout`` reports the -1 sentinel."""
result = await self.backend.exec_shell(
["sleep", "10"],
timeout=0.5,
)
self.assertEqual(result.exit_code, -1)
self.assertEqual(result.stderr, b"timed out")
# ── file I/O ───────────────────────────────────────────────────
async def test_write_then_read_roundtrip(self) -> None:
"""Bytes written into the container are read back verbatim."""
path = f"{CONTAINER_WORKDIR}/roundtrip.txt"
payload = b"hello\nworld\n"
await self.backend.write_file(path, payload)
self.assertEqual(await self.backend.read_file(path), payload)
async def test_write_creates_parent_dirs(self) -> None:
"""``write_file`` creates missing parent directories."""
path = f"{CONTAINER_WORKDIR}/a/b/c/file.txt"
await self.backend.write_file(path, b"x")
self.assertEqual(await self.backend.read_file(path), b"x")
async def test_write_preserves_binary(self) -> None:
"""Raw bytes (incl. NULs and high bytes) survive the round-trip."""
path = f"{CONTAINER_WORKDIR}/bin.dat"
payload = b"a\r\nb\x00\xffc"
await self.backend.write_file(path, payload)
self.assertEqual(await self.backend.read_file(path), payload)
async def test_read_missing_file_raises(self) -> None:
"""Reading a non-existent file raises ``FileNotFoundError``."""
with self.assertRaises(FileNotFoundError):
await self.backend.read_file(f"{CONTAINER_WORKDIR}/nope.txt")
# ── derived filesystem helpers (shell-based) ───────────────────
async def test_file_exists_and_is_dir(self) -> None:
"""``file_exists`` / ``is_dir`` reflect the container filesystem."""
path = f"{CONTAINER_WORKDIR}/f.txt"
await self.backend.write_file(path, b"x")
self.assertTrue(await self.backend.file_exists(path))
self.assertTrue(await self.backend.file_exists(CONTAINER_WORKDIR))
self.assertTrue(await self.backend.is_dir(CONTAINER_WORKDIR))
self.assertFalse(await self.backend.is_dir(path))
self.assertFalse(
await self.backend.file_exists(
f"{CONTAINER_WORKDIR}/missing",
),
)
async def test_list_dir(self) -> None:
"""Non-recursive ``list_dir`` returns immediate child base names."""
base = f"{CONTAINER_WORKDIR}/listing"
await self.backend.write_file(f"{base}/a.txt", b"x")
await self.backend.write_file(f"{base}/b.txt", b"x")
entries = await self.backend.list_dir(base)
self.assertEqual(sorted(entries), ["a.txt", "b.txt"])
async def test_list_dir_recursive(self) -> None:
"""Recursive ``list_dir`` returns file paths underneath the root."""
base = f"{CONTAINER_WORKDIR}/rec"
await self.backend.write_file(f"{base}/top.txt", b"x")
await self.backend.write_file(f"{base}/sub/nested.txt", b"x")
entries = await self.backend.list_dir(base, recursive=True)
basenames = sorted(e.rsplit("/", 1)[-1] for e in entries)
self.assertEqual(basenames, ["nested.txt", "top.txt"])
async def test_stat_mtime(self) -> None:
"""``stat_mtime`` returns a float for an existing path, None else."""
path = f"{CONTAINER_WORKDIR}/stat.txt"
await self.backend.write_file(path, b"x")
mtime = await self.backend.stat_mtime(path)
self.assertIsInstance(mtime, float)
self.assertIsNone(
await self.backend.stat_mtime(
f"{CONTAINER_WORKDIR}/missing",
),
)
async def test_delete_path(self) -> None:
"""``delete_path`` removes files and trees; missing is a no-op."""
path = f"{CONTAINER_WORKDIR}/to_delete.txt"
await self.backend.write_file(path, b"x")
await self.backend.delete_path(path)
self.assertFalse(await self.backend.file_exists(path))
tree = f"{CONTAINER_WORKDIR}/tree"
await self.backend.write_file(f"{tree}/deep/f.txt", b"x")
await self.backend.delete_path(tree)
self.assertFalse(await self.backend.file_exists(tree))
# Deleting a non-existent path must not raise.
await self.backend.delete_path(f"{CONTAINER_WORKDIR}/missing")