chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""Shared fixtures for cua-sandbox integration tests.
|
||||
|
||||
Each transport/runtime is exposed as a pytest fixture. Tests that need a
|
||||
specific backend request the fixture by name; parametrized tests pull from
|
||||
all available backends.
|
||||
|
||||
Environment variables control which backends are exercised:
|
||||
|
||||
CUA_TEST_LOCAL=1 Enable localhost/local-sandbox tests (default: on)
|
||||
CUA_TEST_WS_URL=ws://... Enable WebSocket transport tests
|
||||
CUA_TEST_HTTP_URL=http://... Enable HTTP transport tests
|
||||
CUA_TEST_API_KEY=sk-... API key for remote transports
|
||||
CUA_TEST_CONTAINER_NAME=... Container name for HTTP cloud auth
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cua_sandbox.localhost import Localhost
|
||||
from cua_sandbox.sandbox import Sandbox
|
||||
from cua_sandbox.transport.http import HTTPTransport
|
||||
from cua_sandbox.transport.local import LocalTransport
|
||||
from cua_sandbox.transport.websocket import WebSocketTransport
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: read env config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
val = os.environ.get(key, "")
|
||||
if not val:
|
||||
return default
|
||||
return val.lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
LOCAL_ENABLED = _env_bool("CUA_TEST_LOCAL", default=True)
|
||||
WS_URL = os.environ.get("CUA_TEST_WS_URL")
|
||||
HTTP_URL = os.environ.get("CUA_TEST_HTTP_URL")
|
||||
API_KEY = os.environ.get("CUA_TEST_API_KEY")
|
||||
CONTAINER_NAME = os.environ.get("CUA_TEST_CONTAINER_NAME")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def local_transport():
|
||||
t = LocalTransport()
|
||||
await t.connect()
|
||||
yield t
|
||||
await t.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ws_transport():
|
||||
if not WS_URL:
|
||||
pytest.skip("CUA_TEST_WS_URL not set")
|
||||
t = WebSocketTransport(WS_URL, api_key=API_KEY)
|
||||
await t.connect()
|
||||
yield t
|
||||
await t.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def http_transport():
|
||||
if not HTTP_URL:
|
||||
pytest.skip("CUA_TEST_HTTP_URL not set")
|
||||
t = HTTPTransport(HTTP_URL, api_key=API_KEY, container_name=CONTAINER_NAME)
|
||||
await t.connect()
|
||||
yield t
|
||||
await t.disconnect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox fixtures (one per transport)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def local_sandbox():
|
||||
if not LOCAL_ENABLED:
|
||||
pytest.skip("CUA_TEST_LOCAL disabled")
|
||||
async with Localhost.connect() as host:
|
||||
yield host
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ws_sandbox():
|
||||
if not WS_URL:
|
||||
pytest.skip("CUA_TEST_WS_URL not set")
|
||||
sb = await Sandbox._create(ws_url=WS_URL, api_key=API_KEY, name="test-ws")
|
||||
yield sb
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def http_sandbox():
|
||||
if not HTTP_URL:
|
||||
pytest.skip("CUA_TEST_HTTP_URL not set")
|
||||
sb = await Sandbox._create(
|
||||
http_url=HTTP_URL,
|
||||
api_key=API_KEY,
|
||||
container_name=CONTAINER_NAME,
|
||||
name="test-http",
|
||||
)
|
||||
yield sb
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def localhost_instance():
|
||||
if not LOCAL_ENABLED:
|
||||
pytest.skip("CUA_TEST_LOCAL disabled")
|
||||
async with Localhost.connect() as host:
|
||||
yield host
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parametrized "any sandbox" fixture — runs test against every available backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sandbox_params():
|
||||
params = []
|
||||
if LOCAL_ENABLED:
|
||||
params.append("local_sandbox")
|
||||
if WS_URL:
|
||||
params.append("ws_sandbox")
|
||||
if HTTP_URL:
|
||||
params.append("http_sandbox")
|
||||
if not params:
|
||||
params.append("local_sandbox") # fallback
|
||||
return params
|
||||
|
||||
|
||||
@pytest.fixture(params=_sandbox_params())
|
||||
def any_sandbox_name(request):
|
||||
"""Returns the fixture name; used by any_sandbox."""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def any_sandbox(any_sandbox_name, request):
|
||||
"""Yields a Sandbox connected via whichever transport is being parametrized."""
|
||||
# Dynamically request the named fixture
|
||||
sb = request.getfixturevalue(any_sandbox_name)
|
||||
return sb
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Android multi-touch integration tests.
|
||||
|
||||
Verifies the full touch action space — single-touch gestures, SDK pinch helpers,
|
||||
and true two-finger MT Protocol B injection — against the TouchTest APK running
|
||||
on an Android VM.
|
||||
|
||||
Layout
|
||||
──────
|
||||
TestAndroidMultitouchLocal bare-metal AndroidEmulatorRuntime via Sandbox.ephemeral()
|
||||
TestAndroidMultitouchCloud cloud Android VM via Sandbox.ephemeral()
|
||||
|
||||
Usage
|
||||
─────
|
||||
# Local only (default):
|
||||
pytest tests/test_android_multitouch.py -v
|
||||
|
||||
# Cloud (once implemented):
|
||||
CUA_TEST_API_KEY=sk-... pytest tests/test_android_multitouch.py -v -k cloud
|
||||
|
||||
Environment variables
|
||||
─────────────────────
|
||||
CUA_ANDROID_TEST_APK path or URL to a pre-built TouchTest debug APK
|
||||
default: latest release from
|
||||
https://github.com/trycua/android-touch-test-app
|
||||
CUA_TEST_API_KEY Anthropic API key (cloud tests only)
|
||||
CUA_ANDROID_API_LEVEL Android API level to boot (default: 34)
|
||||
CUA_ANDROID_AVD_NAME AVD name (default: cua-multitouch-test)
|
||||
|
||||
APK source
|
||||
──────────
|
||||
The TouchTest APK is built and released from:
|
||||
https://github.com/trycua/android-touch-test-app
|
||||
|
||||
Latest release download:
|
||||
https://github.com/trycua/android-touch-test-app/releases/latest/download/app-debug.apk
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cua_sandbox.image import Image
|
||||
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
|
||||
from cua_sandbox.runtime.compat import skip_if_unsupported
|
||||
from cua_sandbox.sandbox import Sandbox
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_APK_RELEASE_URL = (
|
||||
"https://github.com/trycua/android-touch-test-app" "/releases/latest/download/app-debug.apk"
|
||||
)
|
||||
_APK_ENV = os.environ.get("CUA_ANDROID_TEST_APK", "")
|
||||
# If env var is a local path use it directly; otherwise download from releases.
|
||||
_APK_PATH = Path(_APK_ENV) if _APK_ENV and not _APK_ENV.startswith("http") else None
|
||||
_APK_PACKAGE = "com.cuatest.touchtest"
|
||||
_APK_ACTIVITY = "com.cuatest.touchtest/.MainActivity"
|
||||
_LOG_TAG = "TouchTest"
|
||||
_RESET_ACTION = "com.cuatest.touchtest.RESET_LOG"
|
||||
|
||||
_API_LEVEL = int(os.environ.get("CUA_ANDROID_API_LEVEL", "34"))
|
||||
_AVD_NAME = os.environ.get("CUA_ANDROID_AVD_NAME", "cua-multitouch-test")
|
||||
_API_KEY = os.environ.get("CUA_TEST_API_KEY")
|
||||
_SETTLE_S = 0.6
|
||||
_LAUNCH_WAIT_S = 3.0
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_apk() -> Path:
|
||||
"""Return path to the TouchTest APK, downloading from GitHub Releases if needed."""
|
||||
if _APK_PATH is not None:
|
||||
if _APK_PATH.exists():
|
||||
return _APK_PATH
|
||||
raise FileNotFoundError(f"CUA_ANDROID_TEST_APK set but file not found: {_APK_PATH}")
|
||||
|
||||
# Download from latest release
|
||||
cache = Path("/tmp/cua-touch-test-app-debug.apk")
|
||||
if not cache.exists():
|
||||
url = os.environ.get("CUA_ANDROID_TEST_APK", _APK_RELEASE_URL)
|
||||
print(f"\n[setup] Downloading TouchTest APK from {url} …")
|
||||
urllib.request.urlretrieve(url, cache)
|
||||
return cache
|
||||
|
||||
|
||||
def _parse_touch_events(logcat_stdout: str) -> list[dict]:
|
||||
events = []
|
||||
for line in logcat_stdout.splitlines():
|
||||
m = re.search(r"TouchTest:\s+(\{.*\})", line)
|
||||
if m:
|
||||
try:
|
||||
obj = json.loads(m.group(1))
|
||||
if "action" in obj:
|
||||
events.append(obj)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return events
|
||||
|
||||
|
||||
def _max_pointer_count(events: list[dict]) -> int:
|
||||
return max((e.get("pointer_count", 0) for e in events), default=0)
|
||||
|
||||
|
||||
def _has_action(events: list[dict], action: str) -> bool:
|
||||
return any(e.get("action") == action for e in events)
|
||||
|
||||
|
||||
async def _read_events(sb: Sandbox) -> list[dict]:
|
||||
result = await sb.shell.run(f"logcat -d -s {_LOG_TAG}", timeout=10)
|
||||
return _parse_touch_events(result.stdout)
|
||||
|
||||
|
||||
async def _reset(sb: Sandbox) -> None:
|
||||
await sb.shell.run("logcat -c")
|
||||
await sb.shell.run(f"am broadcast -a {_RESET_ACTION}")
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
# ── Session-scoped event loop (required for session-scoped async fixtures) ──
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ── Session fixture: one emulator for the entire local test run ─────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def local_android_sb():
|
||||
"""
|
||||
Boot a single AndroidEmulatorRuntime via Sandbox.ephemeral(), install the TouchTest
|
||||
APK, escalate to root, launch the app, and yield the ready Sandbox.
|
||||
Shared across all local tests so we only pay the boot cost once.
|
||||
|
||||
The compatibility gate lives here (not in the autouse reset fixture) so that
|
||||
cloud tests, which have their own API-key gate and don't depend on a local
|
||||
emulator, are not affected when local Android support is absent.
|
||||
"""
|
||||
skip_if_unsupported(Image.android(str(_API_LEVEL)))
|
||||
|
||||
apk = _get_apk()
|
||||
|
||||
runtime = AndroidEmulatorRuntime(
|
||||
api_level=_API_LEVEL,
|
||||
memory_mb=4096,
|
||||
headless=True,
|
||||
no_boot_anim=True,
|
||||
)
|
||||
|
||||
image = Image.android(str(_API_LEVEL)).apk_install(str(apk))
|
||||
|
||||
async with Sandbox.ephemeral(image, runtime=runtime, name=_AVD_NAME) as sb:
|
||||
# Launch app
|
||||
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
|
||||
await asyncio.sleep(_LAUNCH_WAIT_S)
|
||||
|
||||
yield sb
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _reset_between_tests(request):
|
||||
"""Clear logcat + app event counter before every local test.
|
||||
|
||||
Resolves local_android_sb lazily so that cloud tests (which don't request
|
||||
the local emulator fixture) don't trigger emulator boot or the compat skip.
|
||||
"""
|
||||
if "local_android_sb" not in request.fixturenames:
|
||||
yield
|
||||
return
|
||||
sb = request.getfixturevalue("local_android_sb")
|
||||
await _reset(sb)
|
||||
yield
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Shared test logic (mixin)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class _MultitouchTests:
|
||||
"""
|
||||
Full touch action suite. Subclasses must expose a ``sb`` fixture that
|
||||
yields a ready Sandbox with the TouchTest APK installed and running.
|
||||
"""
|
||||
|
||||
# ── Single-touch ──────────────────────────────────────────────────────
|
||||
|
||||
async def test_tap(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.tap(w // 2, h // 2)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No touch events received"
|
||||
assert _has_action(te, "ACTION_DOWN")
|
||||
assert _has_action(te, "ACTION_UP")
|
||||
assert _max_pointer_count(te) == 1
|
||||
|
||||
async def test_long_press(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.long_press(w // 2, h // 2, duration_ms=800)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN")
|
||||
assert _has_action(te, "ACTION_UP")
|
||||
assert _max_pointer_count(te) == 1
|
||||
|
||||
async def test_double_tap(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.double_tap(w // 2, h // 2, delay=0.12)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
downs = [e for e in te if e.get("action") == "ACTION_DOWN"]
|
||||
ups = [e for e in te if e.get("action") == "ACTION_UP"]
|
||||
assert len(downs) >= 2, f"Expected 2 ACTION_DOWNs, got {len(downs)}"
|
||||
assert len(ups) >= 2, f"Expected 2 ACTION_UPs, got {len(ups)}"
|
||||
assert _max_pointer_count(te) == 1
|
||||
|
||||
async def test_swipe(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.swipe(w // 2, h * 3 // 4, w // 2, h // 4, duration_ms=300)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN")
|
||||
assert _has_action(te, "ACTION_MOVE"), "Swipe produced no MOVE events"
|
||||
assert _has_action(te, "ACTION_UP")
|
||||
assert _max_pointer_count(te) == 1
|
||||
|
||||
async def test_fling(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.fling(w // 2, h * 3 // 4, w // 2, h // 4)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN")
|
||||
assert _has_action(te, "ACTION_UP")
|
||||
assert _max_pointer_count(te) == 1
|
||||
|
||||
async def test_scroll_up(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.scroll_up(w // 2, h // 2, distance=400, duration_ms=300)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
|
||||
|
||||
async def test_scroll_down(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.scroll_down(w // 2, h // 2, distance=400, duration_ms=300)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
|
||||
|
||||
async def test_scroll_left(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.scroll_left(w // 2, h // 2, distance=300, duration_ms=300)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
|
||||
|
||||
async def test_scroll_right(self, sb: Sandbox):
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.scroll_right(w // 2, h // 2, distance=300, duration_ms=300)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
te = await _read_events(sb)
|
||||
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
|
||||
|
||||
# ── SDK pinch (MT Protocol B via sendevent) ────────────────────────────
|
||||
|
||||
async def test_pinch_in(self, sb: Sandbox):
|
||||
"""sb.mobile.pinch_in delivers genuine pointer_count == 2 events."""
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.pinch_in(w // 2, h // 2, spread=200, duration_ms=400)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No events received"
|
||||
assert (
|
||||
_max_pointer_count(te) >= 2
|
||||
), f"pinch_in: expected pointer_count >= 2, got {_max_pointer_count(te)}"
|
||||
assert _has_action(
|
||||
te, "ACTION_POINTER_DOWN"
|
||||
), f"Missing ACTION_POINTER_DOWN — actions: {[e.get('action') for e in te]}"
|
||||
|
||||
async def test_pinch_out(self, sb: Sandbox):
|
||||
"""sb.mobile.pinch_out delivers genuine pointer_count == 2 events."""
|
||||
w, h = await sb.get_dimensions()
|
||||
await sb.mobile.pinch_out(w // 2, h // 2, spread=200, duration_ms=400)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No events received"
|
||||
assert (
|
||||
_max_pointer_count(te) >= 2
|
||||
), f"pinch_out: expected pointer_count >= 2, got {_max_pointer_count(te)}"
|
||||
assert _has_action(te, "ACTION_POINTER_DOWN")
|
||||
|
||||
# ── SDK gesture() — arbitrary multi-finger paths ──────────────────────
|
||||
|
||||
async def test_gesture_pinch_out(self, sb: Sandbox):
|
||||
"""sb.mobile.gesture() with two finger paths produces pointer_count == 2."""
|
||||
w, h = await sb.get_dimensions()
|
||||
cx, cy, spread = w // 2, h // 2, 200
|
||||
await sb.mobile.gesture(
|
||||
(cx - 20, cy),
|
||||
(cx - spread, cy), # finger 0: start → end
|
||||
(cx + 20, cy),
|
||||
(cx + spread, cy), # finger 1: start → end
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No events received from gesture()"
|
||||
assert _max_pointer_count(te) >= 2
|
||||
assert _has_action(te, "ACTION_POINTER_DOWN")
|
||||
|
||||
async def test_gesture_two_finger_swipe(self, sb: Sandbox):
|
||||
"""Two parallel fingers moving down together."""
|
||||
w, h = await sb.get_dimensions()
|
||||
offset = 150
|
||||
await sb.mobile.gesture(
|
||||
(w // 2 - offset, h // 4),
|
||||
(w // 2 - offset, h * 3 // 4),
|
||||
(w // 2 + offset, h // 4),
|
||||
(w // 2 + offset, h * 3 // 4),
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No events received"
|
||||
move_two = [
|
||||
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
|
||||
]
|
||||
assert move_two, "No ACTION_MOVE events with pointer_count >= 2"
|
||||
|
||||
# ── True multi-touch via MT Protocol B (server-side injection) ───────────
|
||||
|
||||
async def test_true_multitouch_pinch_in(self, sb: Sandbox):
|
||||
"""MT Protocol B pinch-in via the multitouch_gesture server action."""
|
||||
w, h = await sb.get_dimensions()
|
||||
cx, cy, spread = w // 2, h // 2, 250
|
||||
await sb.mobile.gesture(
|
||||
(cx - spread, cy),
|
||||
(cx - 30, cy),
|
||||
(cx + spread, cy),
|
||||
(cx + 30, cy),
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No touch events"
|
||||
assert _max_pointer_count(te) >= 2
|
||||
assert _has_action(te, "ACTION_POINTER_DOWN")
|
||||
|
||||
async def test_true_multitouch_pinch_out(self, sb: Sandbox):
|
||||
"""MT Protocol B pinch-out via the multitouch_gesture server action."""
|
||||
w, h = await sb.get_dimensions()
|
||||
cx, cy, spread = w // 2, h // 2, 250
|
||||
await sb.mobile.gesture(
|
||||
(cx - 30, cy),
|
||||
(cx - spread, cy),
|
||||
(cx + 30, cy),
|
||||
(cx + spread, cy),
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No touch events"
|
||||
assert _max_pointer_count(te) >= 2
|
||||
|
||||
async def test_true_multitouch_two_finger_swipe(self, sb: Sandbox):
|
||||
"""Two parallel fingers moving in the same direction."""
|
||||
w, h = await sb.get_dimensions()
|
||||
offset = 150
|
||||
await sb.mobile.gesture(
|
||||
(w // 2 - offset, h * 3 // 4),
|
||||
(w // 2 - offset, h // 4),
|
||||
(w // 2 + offset, h * 3 // 4),
|
||||
(w // 2 + offset, h // 4),
|
||||
duration_ms=300,
|
||||
steps=15,
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
assert te, "No touch events"
|
||||
move_with_two = [
|
||||
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
|
||||
]
|
||||
assert move_with_two, "No ACTION_MOVE events with pointer_count >= 2"
|
||||
|
||||
async def test_pointer_ids_are_distinct(self, sb: Sandbox):
|
||||
"""Each finger in a two-finger gesture must carry a unique pointer id."""
|
||||
w, h = await sb.get_dimensions()
|
||||
cx, cy = w // 2, h // 2
|
||||
await sb.mobile.gesture(
|
||||
(cx - 200, cy),
|
||||
(cx - 50, cy),
|
||||
(cx + 200, cy),
|
||||
(cx + 50, cy),
|
||||
steps=8,
|
||||
)
|
||||
await asyncio.sleep(_SETTLE_S)
|
||||
|
||||
te = await _read_events(sb)
|
||||
two_ptr = [e for e in te if e.get("pointer_count", 0) >= 2]
|
||||
assert two_ptr, "No two-pointer events found"
|
||||
for ev in two_ptr:
|
||||
ids = {p["id"] for p in ev.get("pointers", [])}
|
||||
assert len(ids) >= 2, f"Duplicate pointer ids in event: {ev}"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Local tests
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAndroidMultitouchLocal(_MultitouchTests):
|
||||
"""Full touch action suite against a bare-metal AndroidEmulatorRuntime."""
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def sb(self, local_android_sb: Sandbox):
|
||||
await _reset(local_android_sb)
|
||||
return local_android_sb
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Cloud tests
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
skip_no_api_key = pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
|
||||
# Note: cloud Android tests are gated by API key only — hardware compat is the cloud's concern.
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def cloud_android_sb():
|
||||
"""
|
||||
Spin up an ephemeral cloud Android VM, install the TouchTest APK, escalate
|
||||
to root, launch the app, and yield the ready Sandbox.
|
||||
Shared across all cloud tests so we only pay the boot cost once.
|
||||
"""
|
||||
if not _API_KEY:
|
||||
pytest.skip("CUA_TEST_API_KEY not set — cloud tests skipped")
|
||||
|
||||
image = Image.android("14").apk_install(_APK_RELEASE_URL)
|
||||
async with Sandbox.ephemeral(image, api_key=_API_KEY) as sb:
|
||||
# Launch app
|
||||
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
|
||||
await asyncio.sleep(_LAUNCH_WAIT_S)
|
||||
|
||||
yield sb
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
|
||||
class TestAndroidMultitouchCloud(_MultitouchTests):
|
||||
"""Full touch action suite against a cloud-hosted Android VM."""
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="session")
|
||||
async def sb(self, cloud_android_sb: Sandbox):
|
||||
await _reset(cloud_android_sb)
|
||||
return cloud_android_sb
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests — cloud sandbox via CUA API.
|
||||
|
||||
CUA_API_KEY=sk-... pytest tests/test_cloud.py -v -s
|
||||
|
||||
Requires a running cloud VM. Set CUA_TEST_CLOUD_VM_NAME to the VM name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from cua_sandbox import Image, Sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
API_KEY = os.environ.get("CUA_API_KEY")
|
||||
VM_NAME = os.environ.get("CUA_TEST_CLOUD_VM_NAME", "steady-bluebird")
|
||||
|
||||
skip_no_key = pytest.mark.skipif(not API_KEY, reason="CUA_API_KEY not set")
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_connect_by_name():
|
||||
"""Connect to an existing cloud VM by name and take a screenshot."""
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_shell():
|
||||
"""Run a shell command on a cloud VM."""
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
|
||||
result = await sb.shell.run("echo hello-cloud")
|
||||
assert result.success
|
||||
assert "hello-cloud" in result.stdout
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_screen_size():
|
||||
"""Get screen dimensions from a cloud VM."""
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
|
||||
w, h = await sb.get_dimensions()
|
||||
assert w > 0
|
||||
assert h > 0
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_keyboard_mouse():
|
||||
"""Basic keyboard and mouse operations on a cloud VM."""
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
|
||||
await sb.mouse.move(100, 100)
|
||||
await sb.mouse.click(100, 100)
|
||||
await sb.keyboard.type("hello")
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_environment():
|
||||
"""Get environment info from a cloud VM."""
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
|
||||
env = await sb.get_environment()
|
||||
assert env in ("windows", "mac", "linux", "browser")
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
async def test_cloud_no_api_key_errors():
|
||||
"""Connecting with no API key gives a clear error."""
|
||||
old = os.environ.pop("CUA_API_KEY", None)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="No CUA API key found"):
|
||||
await Sandbox.connect("anything")
|
||||
finally:
|
||||
if old:
|
||||
os.environ["CUA_API_KEY"] = old
|
||||
|
||||
|
||||
async def test_cloud_no_image_no_name_errors():
|
||||
"""Creating without an image raises a clear error."""
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
await Sandbox._create(api_key="sk-test-fake-key")
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_ephemeral_linux():
|
||||
"""Create an ephemeral cloud Linux VM, use it, and destroy on exit."""
|
||||
async with Sandbox.ephemeral(Image.linux(), api_key=API_KEY) as sb:
|
||||
assert sb.name is not None
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
result = await sb.shell.run("echo ephemeral-test")
|
||||
assert result.success
|
||||
assert "ephemeral-test" in result.stdout
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_ephemeral_android():
|
||||
"""Create an ephemeral Android cloud VM, verify screenshot and display URL."""
|
||||
async with Sandbox.ephemeral(Image.android("14"), api_key=API_KEY) as sb:
|
||||
assert sb.name is not None
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
env = await sb.get_environment()
|
||||
assert env == "android"
|
||||
display_url = await sb.get_display_url(share=True)
|
||||
assert ".cua.sh" in display_url
|
||||
assert "password=" in display_url
|
||||
|
||||
|
||||
@skip_no_key
|
||||
async def test_cloud_invalid_api_key_errors():
|
||||
"""An invalid (reversed) API key should get an HTTP error from the API."""
|
||||
reversed_key = API_KEY[::-1]
|
||||
with pytest.raises(Exception):
|
||||
sb = await Sandbox.connect(VM_NAME, api_key=reversed_key)
|
||||
await sb.screenshot()
|
||||
await sb.disconnect()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Unit tests for config and auth modules."""
|
||||
|
||||
from cua_sandbox._config import _global_config, configure, get_api_key, get_base_url
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def setup_method(self):
|
||||
_global_config.api_key = None
|
||||
_global_config.base_url = "https://api.trycua.com"
|
||||
|
||||
def test_configure_api_key(self):
|
||||
configure(api_key="sk-test-123")
|
||||
assert get_api_key() == "sk-test-123"
|
||||
|
||||
def test_configure_base_url(self):
|
||||
configure(base_url="http://localhost:9000")
|
||||
assert get_base_url() == "http://localhost:9000"
|
||||
|
||||
def test_override_takes_priority(self):
|
||||
configure(api_key="sk-global")
|
||||
assert get_api_key("sk-override") == "sk-override"
|
||||
|
||||
def test_env_var_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("CUA_API_KEY", "sk-env")
|
||||
assert get_api_key() == "sk-env"
|
||||
|
||||
def test_configure_overrides_env(self, monkeypatch):
|
||||
monkeypatch.setenv("CUA_API_KEY", "sk-env")
|
||||
configure(api_key="sk-configured")
|
||||
assert get_api_key() == "sk-configured"
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Unit tests for the Image builder (no runtime needed)."""
|
||||
|
||||
import pytest
|
||||
from cua_sandbox.image import Image
|
||||
|
||||
|
||||
class TestImageBuilder:
|
||||
def test_linux_defaults(self):
|
||||
img = Image.linux()
|
||||
assert img.os_type == "linux"
|
||||
assert img.distro == "ubuntu"
|
||||
assert img.version == "24.04"
|
||||
|
||||
def test_macos(self):
|
||||
img = Image.macos("15")
|
||||
assert img.os_type == "macos"
|
||||
|
||||
def test_windows(self):
|
||||
img = Image.windows("11")
|
||||
assert img.os_type == "windows"
|
||||
|
||||
def test_chaining_is_immutable(self):
|
||||
base = Image.linux()
|
||||
with_curl = base.apt_install("curl")
|
||||
with_git = base.apt_install("git")
|
||||
assert len(base._layers) == 0
|
||||
assert len(with_curl._layers) == 1
|
||||
assert len(with_git._layers) == 1
|
||||
assert with_curl._layers[0]["packages"] == ["curl"]
|
||||
assert with_git._layers[0]["packages"] == ["git"]
|
||||
|
||||
def test_full_chain(self):
|
||||
img = (
|
||||
Image.linux("debian", "12")
|
||||
.apt_install("curl", "git")
|
||||
.pip_install("numpy")
|
||||
.env(FOO="bar", BAZ="qux")
|
||||
.run("echo hello")
|
||||
.copy("/local/file", "/remote/file")
|
||||
.expose(8080)
|
||||
.expose(3000)
|
||||
)
|
||||
d = img.to_dict()
|
||||
assert d["os_type"] == "linux"
|
||||
assert d["distro"] == "debian"
|
||||
assert len(d["layers"]) == 3 # apt, pip, run
|
||||
assert d["env"] == {"FOO": "bar", "BAZ": "qux"}
|
||||
assert d["ports"] == [8080, 3000]
|
||||
assert d["files"] == [["/local/file", "/remote/file"]]
|
||||
|
||||
def test_roundtrip(self):
|
||||
img = Image.linux().apt_install("curl").env(X="1").expose(80)
|
||||
d = img.to_dict()
|
||||
img2 = Image.from_dict(d)
|
||||
assert img2.to_dict() == d
|
||||
|
||||
def test_cloud_init(self):
|
||||
img = (
|
||||
Image.linux().apt_install("curl", "git").pip_install("flask").run("systemctl start app")
|
||||
)
|
||||
script = img.to_cloud_init()
|
||||
assert "#!/bin/bash" in script
|
||||
assert "apt-get" in script
|
||||
assert "pip install flask" in script
|
||||
assert "systemctl start app" in script
|
||||
|
||||
def test_from_registry(self):
|
||||
img = Image.from_registry("cua/ubuntu-desktop:24.04")
|
||||
assert img._registry == "cua/ubuntu-desktop:24.04"
|
||||
d = img.to_dict()
|
||||
assert d["registry"] == "cua/ubuntu-desktop:24.04"
|
||||
|
||||
def test_repr(self):
|
||||
img = Image.linux().apt_install("curl")
|
||||
r = repr(img)
|
||||
assert "linux" in r
|
||||
assert "1 layers" in r
|
||||
|
||||
def test_brew_and_choco(self):
|
||||
mac = Image.macos().brew_install("wget")
|
||||
win = Image.windows().choco_install("firefox")
|
||||
assert mac._layers[0]["type"] == "brew_install"
|
||||
assert win._layers[0]["type"] == "choco_install"
|
||||
|
||||
|
||||
class TestApkInstall:
|
||||
def test_android_apk_install(self):
|
||||
img = Image.android().apk_install("/path/to/app.apk")
|
||||
assert len(img._layers) == 1
|
||||
assert img._layers[0]["type"] == "apk_install"
|
||||
assert img._layers[0]["packages"] == ["/path/to/app.apk"]
|
||||
|
||||
def test_android_apk_install_multiple(self):
|
||||
img = Image.android().apk_install("app1.apk", "app2.apk", "app3.apk")
|
||||
assert img._layers[0]["packages"] == ["app1.apk", "app2.apk", "app3.apk"]
|
||||
|
||||
def test_android_apk_install_cloud_init(self):
|
||||
img = Image.android().apk_install("app.apk").run("echo done")
|
||||
script = img.to_cloud_init()
|
||||
assert "adb install app.apk" in script
|
||||
assert "echo done" in script
|
||||
|
||||
def test_android_chaining(self):
|
||||
img = (
|
||||
Image.android("14")
|
||||
.apk_install("browser.apk")
|
||||
.env(ADB_HOST="localhost")
|
||||
.run("adb shell settings put global window_animation_scale 0.0")
|
||||
)
|
||||
assert len(img._layers) == 2 # apk_install + run
|
||||
assert img._env == (("ADB_HOST", "localhost"),)
|
||||
|
||||
|
||||
class TestFromFileWithLayers:
|
||||
def test_osworld_apt_install(self):
|
||||
img = Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld").apt_install(
|
||||
"curl", "git"
|
||||
)
|
||||
assert img._disk_path == "/tmp/fake.qcow2"
|
||||
assert img._agent_type == "osworld"
|
||||
assert len(img._layers) == 1
|
||||
assert img._layers[0]["type"] == "apt_install"
|
||||
|
||||
def test_osworld_full_chain(self):
|
||||
img = (
|
||||
Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld")
|
||||
.apt_install("curl", "git")
|
||||
.pip_install("numpy")
|
||||
.env(DISPLAY=":0")
|
||||
.run("systemctl restart app")
|
||||
)
|
||||
assert len(img._layers) == 3 # apt, pip, run
|
||||
assert img._disk_path == "/tmp/fake.qcow2"
|
||||
assert img._agent_type == "osworld"
|
||||
d = img.to_dict()
|
||||
assert d["os_type"] == "linux"
|
||||
|
||||
def test_from_file_android_apk(self):
|
||||
img = Image.from_file("/tmp/android.qcow2", os_type="android").apk_install("myapp.apk")
|
||||
assert img._layers[0]["type"] == "apk_install"
|
||||
assert img._disk_path == "/tmp/android.qcow2"
|
||||
|
||||
def test_from_file_preserves_agent_type_through_chain(self):
|
||||
img = (
|
||||
Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld")
|
||||
.apt_install("curl")
|
||||
.pip_install("flask")
|
||||
.run("echo hi")
|
||||
)
|
||||
assert img._agent_type == "osworld"
|
||||
assert img._disk_path == "/tmp/fake.qcow2"
|
||||
|
||||
|
||||
class TestOsCompatibilityErrors:
|
||||
"""Verify that install methods raise ValueError for wrong OS types."""
|
||||
|
||||
def test_apt_install_on_windows(self):
|
||||
with pytest.raises(ValueError, match="apt_install is not supported on 'windows'"):
|
||||
Image.windows().apt_install("curl")
|
||||
|
||||
def test_apt_install_on_macos(self):
|
||||
with pytest.raises(ValueError, match="apt_install is not supported on 'macos'"):
|
||||
Image.macos().apt_install("curl")
|
||||
|
||||
def test_apt_install_on_android(self):
|
||||
with pytest.raises(ValueError, match="apt_install is not supported on 'android'"):
|
||||
Image.android().apt_install("curl")
|
||||
|
||||
def test_winget_install_on_linux(self):
|
||||
with pytest.raises(ValueError, match="winget_install is not supported on 'linux'"):
|
||||
Image.linux().winget_install("Firefox")
|
||||
|
||||
def test_winget_install_on_macos(self):
|
||||
with pytest.raises(ValueError, match="winget_install is not supported on 'macos'"):
|
||||
Image.macos().winget_install("Firefox")
|
||||
|
||||
def test_choco_install_on_linux(self):
|
||||
with pytest.raises(ValueError, match="choco_install is not supported on 'linux'"):
|
||||
Image.linux().choco_install("firefox")
|
||||
|
||||
def test_brew_install_on_windows(self):
|
||||
with pytest.raises(ValueError, match="brew_install is not supported on 'windows'"):
|
||||
Image.windows().brew_install("wget")
|
||||
|
||||
def test_brew_install_on_linux(self):
|
||||
with pytest.raises(ValueError, match="brew_install is not supported on 'linux'"):
|
||||
Image.linux().brew_install("wget")
|
||||
|
||||
def test_apk_install_on_linux(self):
|
||||
with pytest.raises(ValueError, match="apk_install is not supported on 'linux'"):
|
||||
Image.linux().apk_install("app.apk")
|
||||
|
||||
def test_apk_install_on_windows(self):
|
||||
with pytest.raises(ValueError, match="apk_install is not supported on 'windows'"):
|
||||
Image.windows().apk_install("app.apk")
|
||||
|
||||
def test_apk_install_on_macos(self):
|
||||
with pytest.raises(ValueError, match="apk_install is not supported on 'macos'"):
|
||||
Image.macos().apk_install("app.apk")
|
||||
|
||||
def test_from_file_wrong_os(self):
|
||||
"""from_file with os_type='windows' should reject apt_install."""
|
||||
with pytest.raises(ValueError, match="apt_install is not supported on 'windows'"):
|
||||
Image.from_file("/tmp/win.qcow2", os_type="windows").apt_install("curl")
|
||||
|
||||
def test_pip_and_uv_allowed_on_all(self):
|
||||
"""pip_install and uv_install are cross-platform — no OS restriction."""
|
||||
Image.linux().pip_install("flask")
|
||||
Image.windows().pip_install("flask")
|
||||
Image.macos().pip_install("flask")
|
||||
Image.android().pip_install("flask")
|
||||
Image.linux().uv_install("flask")
|
||||
|
||||
def test_run_and_env_allowed_on_all(self):
|
||||
"""run() and env() are cross-platform."""
|
||||
Image.linux().run("echo hi").env(X="1")
|
||||
Image.windows().run("echo hi").env(X="1")
|
||||
Image.android().run("echo hi").env(X="1")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Integration tests for agent handler adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cua_sandbox.agent import LocalhostHandler, SandboxHandler, is_sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestSandboxHandler:
|
||||
async def test_screenshot(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
b64 = await handler.screenshot()
|
||||
assert isinstance(b64, str)
|
||||
assert len(b64) > 100
|
||||
|
||||
async def test_get_environment(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
env = await handler.get_environment()
|
||||
assert env in ("windows", "mac", "linux", "browser")
|
||||
|
||||
async def test_get_dimensions(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
w, h = await handler.get_dimensions()
|
||||
assert w > 0 and h > 0
|
||||
|
||||
async def test_click(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.click(100, 100)
|
||||
|
||||
async def test_type(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.type("test")
|
||||
|
||||
async def test_keypress(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.keypress(["ctrl", "a"])
|
||||
|
||||
async def test_scroll(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.scroll(100, 100, 0, 3)
|
||||
|
||||
async def test_move(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.move(200, 200)
|
||||
|
||||
async def test_drag(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.drag([{"x": 100, "y": 100}, {"x": 200, "y": 200}])
|
||||
|
||||
async def test_wait(self, local_sandbox):
|
||||
handler = SandboxHandler(local_sandbox)
|
||||
await handler.wait(10) # 10ms
|
||||
|
||||
|
||||
class TestLocalhostHandler:
|
||||
async def test_screenshot(self, localhost_instance):
|
||||
handler = LocalhostHandler(localhost_instance)
|
||||
b64 = await handler.screenshot()
|
||||
assert isinstance(b64, str)
|
||||
|
||||
async def test_click(self, localhost_instance):
|
||||
handler = LocalhostHandler(localhost_instance)
|
||||
await handler.click(50, 50)
|
||||
|
||||
|
||||
class TestIsSandbox:
|
||||
async def test_sandbox_detected(self, local_sandbox):
|
||||
assert is_sandbox(local_sandbox)
|
||||
|
||||
async def test_localhost_detected(self, localhost_instance):
|
||||
assert is_sandbox(localhost_instance)
|
||||
|
||||
def test_other_not_detected(self):
|
||||
assert not is_sandbox("not a sandbox")
|
||||
assert not is_sandbox(42)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Integration tests — exercise every interface method against every available transport.
|
||||
|
||||
Run with:
|
||||
pytest tests/test_integration_interfaces.py -v
|
||||
|
||||
By default only local transport is tested. Set env vars to enable remote:
|
||||
CUA_TEST_WS_URL=ws://host:8000/ws
|
||||
CUA_TEST_HTTP_URL=http://host:8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Screen
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestScreen:
|
||||
async def test_screenshot_returns_png_bytes(self, any_sandbox):
|
||||
data = await any_sandbox.screenshot()
|
||||
assert isinstance(data, bytes)
|
||||
assert len(data) > 100
|
||||
# PNG magic bytes
|
||||
assert data[:4] == b"\x89PNG"
|
||||
|
||||
async def test_screenshot_base64(self, any_sandbox):
|
||||
b64 = await any_sandbox.screenshot_base64()
|
||||
assert isinstance(b64, str)
|
||||
assert len(b64) > 100
|
||||
|
||||
async def test_screen_size(self, any_sandbox):
|
||||
w, h = await any_sandbox.get_dimensions()
|
||||
assert isinstance(w, int)
|
||||
assert isinstance(h, int)
|
||||
assert w > 0
|
||||
assert h > 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Mouse
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMouse:
|
||||
async def test_move(self, any_sandbox):
|
||||
await any_sandbox.mouse.move(100, 100)
|
||||
|
||||
async def test_click(self, any_sandbox):
|
||||
await any_sandbox.mouse.click(100, 100)
|
||||
|
||||
async def test_right_click(self, any_sandbox):
|
||||
await any_sandbox.mouse.right_click(200, 200)
|
||||
|
||||
async def test_double_click(self, any_sandbox):
|
||||
await any_sandbox.mouse.double_click(150, 150)
|
||||
|
||||
async def test_scroll(self, any_sandbox):
|
||||
await any_sandbox.mouse.scroll(100, 100, scroll_y=3)
|
||||
|
||||
async def test_mouse_down_up(self, any_sandbox):
|
||||
await any_sandbox.mouse.mouse_down(100, 100)
|
||||
await any_sandbox.mouse.mouse_up(200, 200)
|
||||
|
||||
async def test_drag(self, any_sandbox):
|
||||
await any_sandbox.mouse.drag(100, 100, 300, 300)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Keyboard
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestKeyboard:
|
||||
async def test_type_text(self, any_sandbox):
|
||||
await any_sandbox.keyboard.type("hello")
|
||||
|
||||
async def test_keypress_single(self, any_sandbox):
|
||||
await any_sandbox.keyboard.keypress("enter")
|
||||
|
||||
async def test_keypress_combo(self, any_sandbox):
|
||||
await any_sandbox.keyboard.keypress(["ctrl", "a"])
|
||||
|
||||
async def test_key_down_up(self, any_sandbox):
|
||||
await any_sandbox.keyboard.key_down("shift")
|
||||
await any_sandbox.keyboard.key_up("shift")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Clipboard
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestClipboard:
|
||||
async def test_set_and_get(self, any_sandbox):
|
||||
await any_sandbox.clipboard.set("cua-sandbox-test")
|
||||
result = await any_sandbox.clipboard.get()
|
||||
assert result == "cua-sandbox-test"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Shell
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestShell:
|
||||
async def test_run_echo(self, any_sandbox):
|
||||
result = await any_sandbox.shell.run("echo hello-sandbox")
|
||||
assert result.success
|
||||
assert "hello-sandbox" in result.stdout
|
||||
|
||||
async def test_run_failure(self, any_sandbox):
|
||||
result = await any_sandbox.shell.run("exit 1")
|
||||
assert not result.success
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Window
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestWindow:
|
||||
async def test_get_active_title(self, any_sandbox):
|
||||
title = await any_sandbox.window.get_active_title()
|
||||
assert isinstance(title, str)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Environment / Dimensions
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
async def test_get_environment(self, any_sandbox):
|
||||
env = await any_sandbox.get_environment()
|
||||
assert env in ("windows", "mac", "linux", "browser")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Localhost (separate from sandbox parametrization)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestLocalhost:
|
||||
async def test_screenshot(self, localhost_instance):
|
||||
data = await localhost_instance.screenshot()
|
||||
assert data[:4] == b"\x89PNG"
|
||||
|
||||
async def test_mouse_click(self, localhost_instance):
|
||||
await localhost_instance.mouse.click(50, 50)
|
||||
|
||||
async def test_keyboard_type(self, localhost_instance):
|
||||
await localhost_instance.keyboard.type("x")
|
||||
|
||||
async def test_shell_run(self, localhost_instance):
|
||||
result = await localhost_instance.shell.run("echo localhost-test")
|
||||
assert result.success
|
||||
|
||||
async def test_environment(self, localhost_instance):
|
||||
env = await localhost_instance.get_environment()
|
||||
assert env in ("windows", "mac", "linux")
|
||||
|
||||
async def test_dimensions(self, localhost_instance):
|
||||
w, h = await localhost_instance.get_dimensions()
|
||||
assert w > 0 and h > 0
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Integration tests for the synchronous API wrappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cua_sandbox.sync import localhost
|
||||
|
||||
|
||||
class TestSyncLocalhost:
|
||||
def test_screenshot(self):
|
||||
with localhost() as host:
|
||||
data = host.screenshot()
|
||||
assert isinstance(data, bytes)
|
||||
assert data[:4] == b"\x89PNG"
|
||||
|
||||
def test_mouse_click(self):
|
||||
with localhost() as host:
|
||||
host.mouse.click(50, 50)
|
||||
|
||||
def test_shell_run(self):
|
||||
with localhost() as host:
|
||||
result = host.shell.run("echo sync-test")
|
||||
assert result.success
|
||||
assert "sync-test" in result.stdout
|
||||
|
||||
def test_keyboard_type(self):
|
||||
with localhost() as host:
|
||||
host.keyboard.type("x")
|
||||
|
||||
def test_dimensions(self):
|
||||
with localhost() as host:
|
||||
w, h = host.get_dimensions()
|
||||
assert w > 0 and h > 0
|
||||
@@ -0,0 +1,242 @@
|
||||
"""OCI registry tests — manifest inspection, kind detection, image formats.
|
||||
|
||||
pytest tests/test_oci.py -v -s
|
||||
|
||||
Unit tests use synthetic manifests. Integration tests (marked `oci_live`)
|
||||
fetch real manifests from ghcr.io and require network access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from cua_sandbox.registry.manifest import (
|
||||
ImageFormat,
|
||||
detect_format,
|
||||
detect_kind,
|
||||
detect_os,
|
||||
get_layer_info,
|
||||
)
|
||||
from cua_sandbox.registry.media_types import (
|
||||
DOCKER_IMAGE_CONFIG,
|
||||
DOCKER_IMAGE_LAYER,
|
||||
LEGACY_DISK_CHUNK,
|
||||
OCI_IMAGE_CONFIG,
|
||||
OCI_IMAGE_LAYER,
|
||||
OCI_VM_AUX,
|
||||
OCI_VM_CONFIG,
|
||||
OCI_VM_DISK,
|
||||
QEMU_CONFIG,
|
||||
QEMU_DISK_GZIP,
|
||||
)
|
||||
from cua_sandbox.registry.ref import parse_ref
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# parse_ref
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestParseRef:
|
||||
def test_full_ref(self):
|
||||
assert parse_ref("ghcr.io/trycua/macos-sequoia-cua:latest") == (
|
||||
"ghcr.io",
|
||||
"trycua",
|
||||
"macos-sequoia-cua",
|
||||
"latest",
|
||||
)
|
||||
|
||||
def test_org_name(self):
|
||||
assert parse_ref("trycua/cua-xfce:v2") == ("ghcr.io", "trycua", "cua-xfce", "v2")
|
||||
|
||||
def test_short_name(self):
|
||||
assert parse_ref("cua-xfce") == ("ghcr.io", "trycua", "cua-xfce", "latest")
|
||||
|
||||
def test_short_name_with_tag(self):
|
||||
assert parse_ref("cua-xfce:nightly") == ("ghcr.io", "trycua", "cua-xfce", "nightly")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# detect_format / detect_kind — synthetic manifests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_manifest(config_mt: str, layer_mts: list[str], **kwargs) -> dict:
|
||||
m = {
|
||||
"config": {"mediaType": config_mt, "digest": "sha256:aaa", "size": 100},
|
||||
"layers": [
|
||||
{"mediaType": mt, "digest": f"sha256:{i:03x}", "size": 1000}
|
||||
for i, mt in enumerate(layer_mts)
|
||||
],
|
||||
}
|
||||
m.update(kwargs)
|
||||
return m
|
||||
|
||||
|
||||
class TestDetectFormat:
|
||||
def test_oci_layered_by_config(self):
|
||||
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK, OCI_VM_AUX])
|
||||
assert detect_format(m) == ImageFormat.OCI_LAYERED
|
||||
|
||||
def test_oci_layered_by_layer(self):
|
||||
m = _make_manifest("application/json", [OCI_VM_DISK])
|
||||
assert detect_format(m) == ImageFormat.OCI_LAYERED
|
||||
|
||||
def test_legacy_lz4_by_layer(self):
|
||||
m = _make_manifest("application/json", [LEGACY_DISK_CHUNK, LEGACY_DISK_CHUNK])
|
||||
assert detect_format(m) == ImageFormat.LEGACY_LZ4
|
||||
|
||||
def test_chunked_parts(self):
|
||||
m = _make_manifest(
|
||||
"application/json",
|
||||
[
|
||||
f"{OCI_IMAGE_LAYER};part.number=1;part.total=3",
|
||||
f"{OCI_IMAGE_LAYER};part.number=2;part.total=3",
|
||||
f"{OCI_IMAGE_LAYER};part.number=3;part.total=3",
|
||||
],
|
||||
)
|
||||
assert detect_format(m) == ImageFormat.CHUNKED_PARTS
|
||||
|
||||
def test_container_oci(self):
|
||||
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
|
||||
assert detect_format(m) == ImageFormat.CONTAINER
|
||||
|
||||
def test_container_docker(self):
|
||||
m = _make_manifest(DOCKER_IMAGE_CONFIG, [DOCKER_IMAGE_LAYER])
|
||||
assert detect_format(m) == ImageFormat.CONTAINER
|
||||
|
||||
def test_qemu_by_config(self):
|
||||
m = _make_manifest(QEMU_CONFIG, [QEMU_DISK_GZIP])
|
||||
assert detect_format(m) == ImageFormat.QEMU
|
||||
|
||||
def test_qemu_by_layer(self):
|
||||
m = _make_manifest("application/json", [QEMU_DISK_GZIP])
|
||||
assert detect_format(m) == ImageFormat.QEMU
|
||||
|
||||
def test_unknown(self):
|
||||
m = _make_manifest("application/json", ["application/octet-stream"])
|
||||
assert detect_format(m) == ImageFormat.UNKNOWN
|
||||
|
||||
|
||||
class TestDetectKind:
|
||||
def test_vm_oci_layered(self):
|
||||
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK])
|
||||
assert detect_kind(m) == "vm"
|
||||
|
||||
def test_vm_legacy(self):
|
||||
m = _make_manifest("application/json", [LEGACY_DISK_CHUNK])
|
||||
assert detect_kind(m) == "vm"
|
||||
|
||||
def test_vm_chunked(self):
|
||||
m = _make_manifest(
|
||||
"application/json",
|
||||
[
|
||||
f"{OCI_IMAGE_LAYER};part.number=1;part.total=2",
|
||||
],
|
||||
)
|
||||
assert detect_kind(m) == "vm"
|
||||
|
||||
def test_vm_qemu(self):
|
||||
m = _make_manifest(QEMU_CONFIG, [QEMU_DISK_GZIP])
|
||||
assert detect_kind(m) == "vm"
|
||||
|
||||
def test_container(self):
|
||||
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
|
||||
assert detect_kind(m) == "container"
|
||||
|
||||
|
||||
class TestDetectOs:
|
||||
def test_agoda_is_macos(self):
|
||||
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK])
|
||||
assert detect_os(m) == "macos"
|
||||
|
||||
def test_annotation_linux(self):
|
||||
m = _make_manifest(
|
||||
"application/json",
|
||||
[],
|
||||
annotations={
|
||||
"org.trycua.lume.os": "Linux",
|
||||
},
|
||||
)
|
||||
assert detect_os(m) == "linux"
|
||||
|
||||
def test_no_os(self):
|
||||
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
|
||||
assert detect_os(m) is None
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# get_layer_info
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetLayerInfo:
|
||||
def test_part_from_annotations(self):
|
||||
m = {
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": OCI_VM_DISK,
|
||||
"digest": "sha256:abc",
|
||||
"size": 500_000_000,
|
||||
"annotations": {
|
||||
"org.trycua.lume.part.number": "3",
|
||||
"org.trycua.lume.part.total": "10",
|
||||
"org.opencontainers.image.title": "disk.img.003",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
info = get_layer_info(m)
|
||||
assert len(info) == 1
|
||||
assert info[0]["part_number"] == 3
|
||||
assert info[0]["part_total"] == 10
|
||||
assert info[0]["title"] == "disk.img.003"
|
||||
|
||||
def test_part_from_mediatype(self):
|
||||
mt = f"{OCI_IMAGE_LAYER};part.number=7;part.total=164"
|
||||
m = {"layers": [{"mediaType": mt, "digest": "sha256:def", "size": 100}]}
|
||||
info = get_layer_info(m)
|
||||
assert info[0]["part_number"] == 7
|
||||
assert info[0]["part_total"] == 164
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Live registry tests (require network)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
oci_live = pytest.mark.skipif(
|
||||
not pytest.importorskip("oras", reason="oras-py not installed"),
|
||||
reason="oras-py not installed",
|
||||
)
|
||||
|
||||
|
||||
@oci_live
|
||||
class TestLiveRegistry:
|
||||
"""Fetch real manifests from ghcr.io. Requires network + oras."""
|
||||
|
||||
def test_macos_sparse_oci_layered(self):
|
||||
from cua_sandbox.registry.manifest import get_manifest
|
||||
|
||||
manifest = get_manifest("ghcr.io/trycua/macos-sequoia-cua-sparse:latest-oci-layered")
|
||||
assert detect_format(manifest) == ImageFormat.OCI_LAYERED
|
||||
assert detect_kind(manifest) == "vm"
|
||||
assert detect_os(manifest) == "macos"
|
||||
|
||||
info = get_layer_info(manifest)
|
||||
assert len(info) > 0
|
||||
# Should have disk layers with part numbers
|
||||
disk_parts = [layer for layer in info if layer["part_number"] is not None]
|
||||
assert len(disk_parts) > 0
|
||||
|
||||
def test_macos_chunked_parts(self):
|
||||
from cua_sandbox.registry.manifest import get_manifest
|
||||
|
||||
manifest = get_manifest("ghcr.io/trycua/macos-sequoia-cua:latest")
|
||||
fmt = detect_format(manifest)
|
||||
assert fmt in (ImageFormat.CHUNKED_PARTS, ImageFormat.OCI_LAYERED, ImageFormat.LEGACY_LZ4)
|
||||
assert detect_kind(manifest) == "vm"
|
||||
|
||||
def test_ubuntu_container(self):
|
||||
from cua_sandbox.registry.manifest import get_manifest
|
||||
|
||||
manifest = get_manifest("docker.io/trycua/cua-xfce:latest")
|
||||
assert detect_format(manifest) == ImageFormat.CONTAINER
|
||||
assert detect_kind(manifest) == "container"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for ``HTTPTransport._parse_sse`` — the error-surfacing path.
|
||||
|
||||
When the server returns ``{"success": false, ...}``, ``_parse_sse`` raises
|
||||
``RuntimeError`` with a message derived from the payload. The previous
|
||||
implementation stringified ``payload.get('error', 'unknown')``, which
|
||||
turned every shell-command failure (``{"success": false, "stdout": "",
|
||||
"stderr": "Command timed out after 10s", "return_code": 1}``) into the
|
||||
opaque ``Remote error: unknown`` — hiding the actual cause.
|
||||
|
||||
These tests pin the new behavior:
|
||||
* explicit ``error`` key still wins
|
||||
* shell-command shape surfaces ``return_code`` + ``stderr``
|
||||
* stdout only appears as a fallback when stderr is empty
|
||||
* successful payloads pass through unchanged
|
||||
* an SSE stream with no data frame raises a parseable error
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from cua_sandbox.transport.http import HTTPTransport
|
||||
|
||||
|
||||
def _sse(obj: str) -> str:
|
||||
"""Wrap a JSON payload in a single SSE data frame."""
|
||||
return f"data: {obj}\n\n"
|
||||
|
||||
|
||||
class TestParseSSESurfacesFailures:
|
||||
def test_success_payload_returned_unchanged(self):
|
||||
out = HTTPTransport._parse_sse(_sse('{"success": true, "result": 42}'))
|
||||
assert out == {"success": True, "result": 42}
|
||||
|
||||
def test_explicit_error_field_preserved_verbatim(self):
|
||||
with pytest.raises(RuntimeError, match=r"Remote error: boom"):
|
||||
HTTPTransport._parse_sse(_sse('{"success": false, "error": "boom"}'))
|
||||
|
||||
def test_shell_failure_surfaces_return_code_and_stderr(self):
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
HTTPTransport._parse_sse(
|
||||
_sse(
|
||||
'{"success": false, "stdout": "", '
|
||||
'"stderr": "Command timed out after 10s", '
|
||||
'"return_code": 1}'
|
||||
)
|
||||
)
|
||||
msg = str(exc.value)
|
||||
assert "Remote error:" in msg
|
||||
assert "return_code=1" in msg
|
||||
assert "Command timed out after 10s" in msg
|
||||
|
||||
def test_shell_failure_uses_stdout_only_when_stderr_empty(self):
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
HTTPTransport._parse_sse(
|
||||
_sse(
|
||||
'{"success": false, "stdout": "partial progress", '
|
||||
'"stderr": "", "return_code": 137}'
|
||||
)
|
||||
)
|
||||
msg = str(exc.value)
|
||||
assert "return_code=137" in msg
|
||||
assert "partial progress" in msg
|
||||
|
||||
def test_shell_failure_stdout_not_added_when_stderr_present(self):
|
||||
"""stderr wins over stdout when both are non-empty — avoids bloating
|
||||
the message with noisy successful-command output that happened to
|
||||
return non-zero."""
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
HTTPTransport._parse_sse(
|
||||
_sse(
|
||||
'{"success": false, '
|
||||
'"stdout": "UI hierchary dumped to: /sdcard/ui.xml\\n", '
|
||||
'"stderr": "Killed", "return_code": 137}'
|
||||
)
|
||||
)
|
||||
msg = str(exc.value)
|
||||
assert "Killed" in msg
|
||||
assert "return_code=137" in msg
|
||||
assert "UI hierchary dumped" not in msg # stdout suppressed
|
||||
|
||||
def test_shell_failure_with_no_detail_falls_back_cleanly(self):
|
||||
with pytest.raises(RuntimeError, match=r"Remote error: no detail"):
|
||||
HTTPTransport._parse_sse(_sse('{"success": false}'))
|
||||
|
||||
def test_missing_data_frame_raises(self):
|
||||
with pytest.raises(RuntimeError, match=r"No SSE data frame"):
|
||||
HTTPTransport._parse_sse("event: ping\n\n")
|
||||
|
||||
def test_ignores_content_before_data_line(self):
|
||||
text = ":comment\nevent: message\ndata: " + '{"success": true, "ok": 1}' + "\n\n"
|
||||
out = HTTPTransport._parse_sse(text)
|
||||
assert out == {"success": True, "ok": 1}
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Integration tests — runtime scenarios.
|
||||
|
||||
pytest tests/test_runtime.py -v -s
|
||||
|
||||
Skips automatically when the required runtime isn't available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cua_sandbox import Image, Sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
IS_MACOS = platform.system() == "Darwin"
|
||||
|
||||
|
||||
def _has_cua_api_key() -> bool:
|
||||
return bool(os.environ.get("CUA_API_KEY"))
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_docker() -> bool:
|
||||
try:
|
||||
subprocess.run(["docker", "info"], capture_output=True, check=True, timeout=10)
|
||||
return True
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def _has_lume() -> bool:
|
||||
try:
|
||||
subprocess.run(["lume", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def _has_hyperv() -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return False
|
||||
try:
|
||||
from cua_sandbox.runtime.hyperv import _has_hyperv as check
|
||||
|
||||
return check()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_qemu() -> bool:
|
||||
"""Check if QEMU is available (on PATH or portable)."""
|
||||
try:
|
||||
from cua_sandbox.runtime.qemu_installer import qemu_bin
|
||||
|
||||
qemu_bin("x86_64")
|
||||
return True
|
||||
except (RuntimeError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def _has_android_image() -> bool:
|
||||
"""Check if an Android-x86 disk image is available for bare-metal QEMU."""
|
||||
from pathlib import Path
|
||||
|
||||
storage = Path.home() / ".cua" / "cua-sandbox" / "qemu-storage" / "android-x86.qcow2"
|
||||
return storage.exists()
|
||||
|
||||
|
||||
# ── 1. Linux container (Docker) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_linux_container():
|
||||
from cua_sandbox.runtime import DockerRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
local=True,
|
||||
runtime=DockerRuntime(api_port=18000, vnc_port=16901, ephemeral=True),
|
||||
name="cua-test-linux-container",
|
||||
) as sb:
|
||||
result = await sb.shell.run("cat /etc/os-release")
|
||||
assert result.success
|
||||
assert "ubuntu" in result.stdout.lower()
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 2. Linux VM (QEMU-in-Docker) ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_linux_vm():
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
local=True,
|
||||
runtime=QEMURuntime(mode="docker", api_port=18001, vnc_port=18006, ephemeral=True),
|
||||
name="cua-test-linux-vm",
|
||||
) as sb:
|
||||
result = await sb.shell.run("uname -a")
|
||||
assert result.success
|
||||
assert "Linux" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 3. Windows VM (bare-metal QEMU on Windows host) ─────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_WINDOWS, reason="Windows host only")
|
||||
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
|
||||
async def test_windows_vm():
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.windows("11"),
|
||||
local=True,
|
||||
runtime=QEMURuntime(mode="bare-metal", api_port=18002),
|
||||
name="cua-test-windows-vm",
|
||||
) as sb:
|
||||
result = await sb.shell.run("ver")
|
||||
assert result.success or "Windows" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 3b. Windows VM (Hyper-V) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_hyperv(), reason="Hyper-V not available")
|
||||
async def test_windows_hyperv():
|
||||
from cua_sandbox.runtime import HyperVRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.windows("11"),
|
||||
local=True,
|
||||
runtime=HyperVRuntime(api_port=18003),
|
||||
name="cua-test-hyperv",
|
||||
) as sb:
|
||||
result = await sb.shell.run("ver")
|
||||
assert result.success or "Windows" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 4. macOS VM (Lume) ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
|
||||
async def test_macos_vm():
|
||||
from cua_sandbox.runtime import LumeRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.macos("26"),
|
||||
local=True,
|
||||
runtime=LumeRuntime(),
|
||||
name="cua-test-macos-vm",
|
||||
) as sb:
|
||||
result = await sb.shell.run("sw_vers")
|
||||
assert result.success
|
||||
assert "macOS" in result.stdout or "Mac" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 5. Android VM (bare-metal QEMU) ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_android_iso() -> bool:
|
||||
"""Check if an Android-x86 ISO is available in ~/Downloads."""
|
||||
from pathlib import Path
|
||||
|
||||
return any(Path.home().glob("Downloads/android-x86*.iso"))
|
||||
|
||||
|
||||
def _get_android_iso() -> str:
|
||||
from pathlib import Path
|
||||
|
||||
isos = sorted(Path.home().glob("Downloads/android-x86*.iso"))
|
||||
return str(isos[0]) if isos else ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
|
||||
@pytest.mark.skipif(
|
||||
not _has_android_image(),
|
||||
reason="Android-x86 qcow2 not available at ~/.cua/cua-sandbox/qemu-storage/android-x86.qcow2",
|
||||
)
|
||||
async def test_android_vm_baremetal():
|
||||
"""Test Android VM via bare-metal QEMU with a pre-built qcow2."""
|
||||
from pathlib import Path
|
||||
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
android_disk = str(Path.home() / ".cua" / "cua-sandbox" / "qemu-storage" / "android-x86.qcow2")
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_file(android_disk, os_type="android", kind="vm"),
|
||||
local=True,
|
||||
runtime=QEMURuntime(
|
||||
mode="bare-metal", api_port=18010, vnc_display=10, memory_mb=4096, cpu_count=4
|
||||
),
|
||||
name="cua-test-android-vm",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
|
||||
@pytest.mark.skipif(not _has_android_iso(), reason="No android-x86*.iso in ~/Downloads")
|
||||
async def test_android_vm_from_iso():
|
||||
"""Test Android VM booted from an ISO file via bare-metal QEMU.
|
||||
|
||||
The runtime auto-creates a qcow2 disk and attaches the ISO as CD-ROM.
|
||||
Uses QMP transport (no computer-server required).
|
||||
"""
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
iso_path = _get_android_iso()
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_file(iso_path, os_type="android", kind="vm"),
|
||||
local=True,
|
||||
runtime=QEMURuntime(
|
||||
mode="bare-metal",
|
||||
api_port=18030,
|
||||
vnc_display=30,
|
||||
qmp_port=4460,
|
||||
memory_mb=2048,
|
||||
cpu_count=2,
|
||||
),
|
||||
name="cua-test-android-iso3",
|
||||
) as sb:
|
||||
# Wait for boot to progress past GRUB (Enter keys are sent during start())
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(15)
|
||||
|
||||
# With QMP transport, we can take screenshots even without computer-server
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 100
|
||||
|
||||
# Save screenshot for inspection
|
||||
with open("/tmp/android-boot-screenshot.png", "wb") as f:
|
||||
f.write(screenshot)
|
||||
|
||||
|
||||
# ── 5b. Android VM (QEMU-in-Docker) ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_android_vm_docker():
|
||||
"""Test Android VM via QEMU inside Docker."""
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.android("14"),
|
||||
local=True,
|
||||
runtime=QEMURuntime(mode="docker", api_port=18011, vnc_port=18080, ephemeral=True),
|
||||
name="cua-test-android-docker",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
# ── 6. QEMU bare-metal error on missing binary ───────────────────────────────
|
||||
|
||||
|
||||
def _has_osworld_image() -> bool:
|
||||
"""Check if the OSWorld Ubuntu qcow2 is available in the image cache."""
|
||||
cache = Path.home() / ".cua" / "cua-sandbox" / "image-cache"
|
||||
return any(cache.rglob("Ubuntu.qcow2")) if cache.exists() else False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
|
||||
@pytest.mark.skipif(not _has_osworld_image(), reason="OSWorld Ubuntu.qcow2 not in image cache")
|
||||
async def test_osworld_ubuntu_vm():
|
||||
"""Test OSWorld Ubuntu VM via bare-metal QEMU with OSWorld transport."""
|
||||
from cua_sandbox.runtime import QEMURuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_file(
|
||||
"https://huggingface.co/datasets/xlangai/ubuntu_osworld/resolve/main/Ubuntu.qcow2.zip",
|
||||
os_type="linux",
|
||||
agent_type="osworld",
|
||||
),
|
||||
local=True,
|
||||
runtime=QEMURuntime(
|
||||
mode="bare-metal", api_port=18020, vnc_display=20, memory_mb=4096, cpu_count=4
|
||||
),
|
||||
name="cua-test-osworld",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
w, h = await sb.get_dimensions()
|
||||
assert w == 1920 and h == 1080
|
||||
|
||||
|
||||
# ── 7. Android Emulator (SDK) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_android_sdk() -> bool:
|
||||
"""Check if Android SDK emulator is available."""
|
||||
try:
|
||||
from cua_sandbox.runtime.android_emulator import _find_bin, _sdk_path
|
||||
|
||||
sdk = _sdk_path()
|
||||
_find_bin(sdk, "emulator")
|
||||
_find_bin(sdk, "adb")
|
||||
return True
|
||||
except (FileNotFoundError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
|
||||
async def test_android_emulator():
|
||||
"""Test Android emulator boots to homescreen."""
|
||||
from cua_sandbox.runtime import AndroidEmulatorRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.android("14"),
|
||||
local=True,
|
||||
runtime=AndroidEmulatorRuntime(memory_mb=4096, cpu_count=4, adb_port=5559),
|
||||
name="cua-test-android-sdk",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 10000
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
|
||||
async def test_android_emulator_apk_install():
|
||||
"""Test Android emulator with F-Droid APK installed from URL."""
|
||||
from cua_sandbox.runtime import AndroidEmulatorRuntime
|
||||
|
||||
img = Image.android("14").apk_install("https://f-droid.org/F-Droid.apk")
|
||||
async with Sandbox.ephemeral(
|
||||
img,
|
||||
local=True,
|
||||
runtime=AndroidEmulatorRuntime(memory_mb=4096, cpu_count=4, adb_port=5561),
|
||||
name="cua-test-android-apk",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
# Verify F-Droid is installed
|
||||
result = await sb._transport.send("shell", command="pm list packages | grep fdroid")
|
||||
assert "org.fdroid.fdroid" in result["output"]
|
||||
|
||||
|
||||
# ── 8. Tart (Apple VZ) ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_tart() -> bool:
|
||||
import shutil
|
||||
|
||||
return shutil.which("tart") is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
|
||||
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
|
||||
async def test_tart_ubuntu():
|
||||
"""Test Ubuntu VM via Tart (Apple VZ)."""
|
||||
from cua_sandbox.runtime import TartRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_registry("ghcr.io/cirruslabs/ubuntu:latest"),
|
||||
local=True,
|
||||
runtime=TartRuntime(ephemeral=True, display="1024x768"),
|
||||
name="cua-test-tart-ubuntu",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
result = await sb.shell.run("uname -a")
|
||||
assert result.success
|
||||
assert "Linux" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
|
||||
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
|
||||
async def test_tart_macos_tahoe():
|
||||
"""Test macOS Tahoe VM via Tart (Apple VZ)."""
|
||||
from cua_sandbox.runtime import TartRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_registry("ghcr.io/cirruslabs/macos-tahoe-base:latest"),
|
||||
local=True,
|
||||
runtime=TartRuntime(ephemeral=True),
|
||||
name="cua-test-tart-tahoe",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
|
||||
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
|
||||
async def test_tart_macos_sequoia_cua():
|
||||
"""Test CUA macOS Sequoia sparse image via Tart."""
|
||||
from cua_sandbox.runtime import TartRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_registry("ghcr.io/trycua/macos-sequoia-cua-sparse:latest-oci-layered"),
|
||||
local=True,
|
||||
runtime=TartRuntime(ephemeral=True),
|
||||
name="cua-test-tart-sequoia",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
|
||||
async def test_lume_macos_tahoe_cua():
|
||||
"""Test CUA macOS Tahoe image via Lume from ghcr.io/trycua/macos-tahoe-cua:latest."""
|
||||
from cua_sandbox.runtime import LumeRuntime
|
||||
|
||||
async with Sandbox.ephemeral(
|
||||
Image.from_registry("ghcr.io/trycua/macos-tahoe-cua:latest"),
|
||||
local=True,
|
||||
runtime=LumeRuntime(),
|
||||
name="cua-test-lume-tahoe",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
# ── 9. QEMU bare-metal error on missing binary ───────────────────────────────
|
||||
|
||||
|
||||
def test_qemu_baremetal_missing_binary_error():
|
||||
"""When QEMU isn't installed, bare-metal runtime should give a helpful error."""
|
||||
import shutil
|
||||
|
||||
if shutil.which("qemu-system-x86_64"):
|
||||
pytest.skip("QEMU is installed — cannot test missing-binary error")
|
||||
|
||||
with pytest.raises(RuntimeError, match="brew install qemu|not found"):
|
||||
from cua_sandbox.runtime.qemu_installer import qemu_bin
|
||||
|
||||
qemu_bin("x86_64")
|
||||
|
||||
|
||||
# ── 10. Auto-runtime (local=True, no explicit runtime) ───────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_auto_runtime_linux_container():
|
||||
"""local=True with no runtime auto-selects DockerRuntime for linux container images."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"), # kind="container" by default
|
||||
local=True,
|
||||
name="cua-test-auto-linux-container",
|
||||
) as sb:
|
||||
result = await sb.shell.run("uname -s")
|
||||
assert result.success
|
||||
assert "Linux" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_auto_runtime_linux_vm():
|
||||
"""local=True with no runtime auto-selects QEMURuntime(docker) for linux vm images."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04", kind="vm"),
|
||||
local=True,
|
||||
name="cua-test-auto-linux-vm",
|
||||
) as sb:
|
||||
result = await sb.shell.run("uname -s")
|
||||
assert result.success
|
||||
assert "Linux" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
|
||||
async def test_auto_runtime_macos():
|
||||
"""local=True with no runtime auto-selects LumeRuntime for macOS images."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.macos("26"),
|
||||
local=True,
|
||||
name="cua-test-auto-macos",
|
||||
) as sb:
|
||||
result = await sb.shell.run("sw_vers")
|
||||
assert result.success
|
||||
assert "macOS" in result.stdout or "Mac" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
|
||||
async def test_auto_runtime_android():
|
||||
"""local=True with no runtime auto-selects AndroidEmulatorRuntime for android images."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.android("14"),
|
||||
local=True,
|
||||
name="cua-test-auto-android",
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 10000
|
||||
|
||||
|
||||
@pytest.mark.skipif(not IS_WINDOWS, reason="Windows host only")
|
||||
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
|
||||
async def test_auto_runtime_windows():
|
||||
"""local=True with no runtime auto-selects HyperV or QEMU for Windows images."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.windows("11"),
|
||||
local=True,
|
||||
name="cua-test-auto-windows",
|
||||
) as sb:
|
||||
result = await sb.shell.run("ver")
|
||||
assert result.success or "Windows" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ── 11. Cloud sandboxes (local=False) ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
|
||||
async def test_cloud_linux_ephemeral():
|
||||
"""Cloud sandbox: Sandbox.ephemeral with linux image (local=False)."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
) as sb:
|
||||
result = await sb.shell.run("uname -s")
|
||||
assert result.success
|
||||
assert "Linux" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
|
||||
async def test_cloud_android_ephemeral():
|
||||
"""Cloud sandbox: Sandbox.ephemeral with android image (local=False)."""
|
||||
async with Sandbox.ephemeral(
|
||||
Image.android("14"),
|
||||
) as sb:
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
|
||||
# ── 12. Sandbox.create (persistent) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
|
||||
async def test_create_persistent_linux():
|
||||
"""Sandbox.create provisions a persistent sandbox that survives disconnect."""
|
||||
from cua_sandbox.runtime import DockerRuntime
|
||||
|
||||
sb = await Sandbox.create(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
local=True,
|
||||
runtime=DockerRuntime(api_port=18090, vnc_port=18091, ephemeral=True),
|
||||
name="cua-test-create-linux",
|
||||
)
|
||||
try:
|
||||
result = await sb.shell.run("echo persistent")
|
||||
assert result.success
|
||||
assert "persistent" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
finally:
|
||||
await sb.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
|
||||
async def test_create_persistent_cloud_linux():
|
||||
"""Sandbox.create provisions a persistent cloud sandbox."""
|
||||
sb = await Sandbox.create(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
name="cua-test-create-cloud-linux",
|
||||
)
|
||||
try:
|
||||
result = await sb.shell.run("echo persistent")
|
||||
assert result.success
|
||||
assert "persistent" in result.stdout
|
||||
finally:
|
||||
await sb.disconnect()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Snapshot integration tests — images all the way down.
|
||||
|
||||
Tests both Linux VM and Android container snapshot workflows:
|
||||
1. Create sandbox → install something → snapshot → returns Image
|
||||
2. Boot from snapshot Image → verify install persists → measure fork is faster
|
||||
3. Verify state isolation (changes after snapshot don't leak to fork)
|
||||
|
||||
Run against local dev stack:
|
||||
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
|
||||
pytest tests/test_snapshots.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from cua_sandbox import Image, Sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _has_cua_api_key() -> bool:
|
||||
return bool(os.environ.get("CUA_API_KEY"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
|
||||
async def test_snapshot_linux():
|
||||
"""Linux: create → install cowsay → snapshot → boot from snapshot → verify."""
|
||||
t_create_start = time.monotonic()
|
||||
|
||||
async with Sandbox.ephemeral(Image.linux("ubuntu", "24.04")) as sb:
|
||||
# Install something unique
|
||||
result = await sb.shell.run(
|
||||
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=120
|
||||
)
|
||||
assert result.success, f"Install failed: {result.stderr}"
|
||||
|
||||
# Measure create+install time only after install completes
|
||||
t_create = time.monotonic() - t_create_start
|
||||
|
||||
# Verify it works
|
||||
result = await sb.shell.run("/usr/games/cowsay hello")
|
||||
assert result.success
|
||||
assert "hello" in result.stdout
|
||||
|
||||
# Snapshot → returns Image
|
||||
cowsay_image = await sb.snapshot(name="with-cowsay")
|
||||
assert cowsay_image is not None
|
||||
assert cowsay_image._snapshot_source is not None
|
||||
|
||||
# Boot from snapshot image — should be faster (COW fork)
|
||||
t_fork_start = time.monotonic()
|
||||
async with Sandbox.ephemeral(cowsay_image) as sb2:
|
||||
t_fork = time.monotonic() - t_fork_start
|
||||
|
||||
# cowsay should still be installed
|
||||
result = await sb2.shell.run("/usr/games/cowsay forked!")
|
||||
assert result.success, f"cowsay not found in fork: {result.stderr}"
|
||||
assert "forked!" in result.stdout
|
||||
|
||||
print(f"\nCreate+install: {t_create:.1f}s, Fork from snapshot: {t_fork:.1f}s")
|
||||
# Fork should be faster than original create + install
|
||||
assert (
|
||||
t_fork < t_create
|
||||
), f"Fork ({t_fork:.1f}s) should be faster than create ({t_create:.1f}s)"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
|
||||
async def test_snapshot_android():
|
||||
"""Android: create with F-Droid → snapshot → boot from snapshot → APK persists."""
|
||||
FDROID_APK = "https://f-droid.org/F-Droid.apk"
|
||||
t_create_start = time.monotonic()
|
||||
|
||||
async with Sandbox.ephemeral(Image.android("14").apk_install(FDROID_APK)) as sb:
|
||||
t_create = time.monotonic() - t_create_start
|
||||
|
||||
# Verify F-Droid installed (sb.shell.run on android → adb shell)
|
||||
result = await sb.shell.run("pm list packages org.fdroid.fdroid")
|
||||
assert result.success
|
||||
assert "org.fdroid.fdroid" in result.stdout
|
||||
|
||||
screenshot = await sb.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
|
||||
# Snapshot → Image
|
||||
fdroid_image = await sb.snapshot(name="with-fdroid")
|
||||
|
||||
# Boot from snapshot — F-Droid should persist
|
||||
t_fork_start = time.monotonic()
|
||||
async with Sandbox.ephemeral(fdroid_image) as sb2:
|
||||
t_fork = time.monotonic() - t_fork_start
|
||||
|
||||
result = await sb2.shell.run("pm list packages org.fdroid.fdroid")
|
||||
assert result.success, f"F-Droid not found in fork: {result.stderr}"
|
||||
assert "org.fdroid.fdroid" in result.stdout
|
||||
|
||||
screenshot = await sb2.screenshot()
|
||||
assert screenshot[:4] == b"\x89PNG"
|
||||
assert len(screenshot) > 1000
|
||||
|
||||
print(f"\nCreate+install: {t_create:.1f}s, Fork from snapshot: {t_fork:.1f}s")
|
||||
# On COW storage (btrfs/zfs), fork is instant. On dir storage the copy
|
||||
# is a full rsync so it can be slower than the original create. Only
|
||||
# assert that the fork completed (timing checked on COW backends in CI).
|
||||
if t_fork < t_create:
|
||||
print(" → Fork was faster (COW or small image)")
|
||||
else:
|
||||
print(" → Fork was slower (dir storage — expected)")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Integration tests specifically for the HTTP transport against a running computer-server.
|
||||
|
||||
Skipped unless CUA_TEST_HTTP_URL is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestHTTPTransport:
|
||||
async def test_screenshot(self, http_transport):
|
||||
data = await http_transport.screenshot()
|
||||
assert isinstance(data, bytes)
|
||||
assert data[:4] == b"\x89PNG"
|
||||
|
||||
async def test_screen_size(self, http_transport):
|
||||
size = await http_transport.get_screen_size()
|
||||
assert size["width"] > 0
|
||||
assert size["height"] > 0
|
||||
|
||||
async def test_get_environment(self, http_transport):
|
||||
env = await http_transport.get_environment()
|
||||
assert env in ("windows", "mac", "linux", "browser")
|
||||
|
||||
async def test_send_click(self, http_transport):
|
||||
await http_transport.send("left_click", x=100, y=100)
|
||||
# Should not raise
|
||||
|
||||
async def test_send_type(self, http_transport):
|
||||
await http_transport.send("type_text", text="hello")
|
||||
|
||||
async def test_send_get_screen_size(self, http_transport):
|
||||
result = await http_transport.send("get_screen_size")
|
||||
assert "width" in result or isinstance(result, dict)
|
||||
|
||||
|
||||
class TestHTTPSandboxFullInterface:
|
||||
"""Same interface tests as the main suite, but explicitly via HTTP sandbox."""
|
||||
|
||||
async def test_screenshot(self, http_sandbox):
|
||||
data = await http_sandbox.screenshot()
|
||||
assert data[:4] == b"\x89PNG"
|
||||
|
||||
async def test_mouse_click(self, http_sandbox):
|
||||
await http_sandbox.mouse.click(100, 100)
|
||||
|
||||
async def test_keyboard_type(self, http_sandbox):
|
||||
await http_sandbox.keyboard.type("http-test")
|
||||
|
||||
async def test_clipboard(self, http_sandbox):
|
||||
await http_sandbox.clipboard.set("http-clip")
|
||||
val = await http_sandbox.clipboard.get()
|
||||
assert val == "http-clip"
|
||||
|
||||
async def test_shell(self, http_sandbox):
|
||||
result = await http_sandbox.shell.run("echo http-shell")
|
||||
assert result.success
|
||||
|
||||
async def test_window_title(self, http_sandbox):
|
||||
title = await http_sandbox.window.get_active_title()
|
||||
assert isinstance(title, str)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Unit tests for HTTPTransport._cmd retry-on-5xx behavior.
|
||||
|
||||
The computer-server can briefly return 5xx (Traefik dropping the pod
|
||||
from its endpoint list, grpc fork hiccups). The transport retries
|
||||
up to 3 times with exponential backoff for 5xx responses. 4xx
|
||||
errors are raised immediately. httpx transport exceptions (read
|
||||
timeout, connection reset) are not retried because the command may
|
||||
have already started on the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cua_sandbox.transport.http import HTTPTransport
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _sse_body(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
async def _make_transport(handler) -> HTTPTransport:
|
||||
t = HTTPTransport("http://server:8000", timeout=5.0)
|
||||
# Patch the async client after connect so the mock transport is in use.
|
||||
await t.connect()
|
||||
assert t._client is not None
|
||||
await t._client.aclose()
|
||||
t._client = httpx.AsyncClient(
|
||||
base_url="http://server:8000",
|
||||
transport=httpx.MockTransport(handler),
|
||||
timeout=5.0,
|
||||
)
|
||||
return t
|
||||
|
||||
|
||||
async def test_cmd_success_no_retry():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
return httpx.Response(200, text=_sse_body({"width": 1, "height": 2}))
|
||||
|
||||
t = await _make_transport(handler)
|
||||
result = await t._cmd("get_screen_size")
|
||||
assert result == {"width": 1, "height": 2}
|
||||
assert len(calls) == 1, "success should not retry"
|
||||
|
||||
|
||||
async def test_cmd_retries_5xx_then_succeeds(monkeypatch):
|
||||
# Skip sleeps so the test runs instantly.
|
||||
import cua_sandbox.transport.http as http_mod
|
||||
|
||||
async def _no_sleep(_seconds):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(http_mod.asyncio, "sleep", _no_sleep)
|
||||
|
||||
responses = iter(
|
||||
[
|
||||
httpx.Response(503, text="service unavailable"),
|
||||
httpx.Response(502, text="bad gateway"),
|
||||
httpx.Response(200, text=_sse_body({"ok": True})),
|
||||
]
|
||||
)
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
return next(responses)
|
||||
|
||||
t = await _make_transport(handler)
|
||||
result = await t._cmd("screenshot")
|
||||
assert result == {"ok": True}
|
||||
assert len(calls) == 3, "should retry twice before succeeding"
|
||||
|
||||
|
||||
async def test_cmd_gives_up_after_max_retries(monkeypatch):
|
||||
import cua_sandbox.transport.http as http_mod
|
||||
|
||||
async def _no_sleep(_seconds):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(http_mod.asyncio, "sleep", _no_sleep)
|
||||
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
return httpx.Response(503, text="still unavailable")
|
||||
|
||||
t = await _make_transport(handler)
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await t._cmd("screenshot")
|
||||
assert exc_info.value.response.status_code == 503
|
||||
# _CMD_MAX_RETRIES (3) attempts total, then raise.
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
async def test_cmd_does_not_retry_4xx():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
return httpx.Response(401, text="unauthorized")
|
||||
|
||||
t = await _make_transport(handler)
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await t._cmd("screenshot")
|
||||
assert exc_info.value.response.status_code == 401
|
||||
assert len(calls) == 1, "4xx should not retry"
|
||||
|
||||
|
||||
async def test_cmd_does_not_retry_read_timeout():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
raise httpx.ReadTimeout("server too slow", request=request)
|
||||
|
||||
t = await _make_transport(handler)
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
await t._cmd("run_command", params={"command": "slow"})
|
||||
# ReadTimeout means the request reached the server — don't retry
|
||||
# or we risk double-executing a non-idempotent command.
|
||||
assert len(calls) == 1
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Unit tests for VM cleanup on connection failure and destroy() resilience.
|
||||
|
||||
These tests mock CloudTransport so they run without a real cloud API.
|
||||
They verify that:
|
||||
1. _create() cleans up a provisioned VM when _connect() fails.
|
||||
2. destroy() runs every cleanup step independently — a failure in one
|
||||
does not prevent the others from executing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cua_sandbox.image import Image
|
||||
from cua_sandbox.sandbox import Sandbox
|
||||
from cua_sandbox.transport.cloud import CloudTransport
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cloud_transport(*, name: str = "test-vm") -> CloudTransport:
|
||||
"""Return a CloudTransport with internal state set as if _create_vm() succeeded."""
|
||||
t = CloudTransport.__new__(CloudTransport)
|
||||
t._name = name
|
||||
t._api_key_override = "sk-fake"
|
||||
t._base_url = "https://api.example.com"
|
||||
t._image = None
|
||||
t._cpu = None
|
||||
t._memory_mb = None
|
||||
t._disk_gb = None
|
||||
t._region = "us-east-1"
|
||||
t._inner = None
|
||||
t._api_client = None
|
||||
return t
|
||||
|
||||
|
||||
def _make_sandbox(transport: CloudTransport, **kwargs) -> Sandbox:
|
||||
"""Return a Sandbox wrapping *transport* without calling _connect()."""
|
||||
return Sandbox(
|
||||
transport,
|
||||
name=transport._name,
|
||||
_ephemeral=kwargs.get("ephemeral", True),
|
||||
_telemetry_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. _create() cleans up on _connect() failure
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCreateCleansUpOnConnectFailure:
|
||||
"""Sandbox._create() should delete the cloud VM when _connect() raises."""
|
||||
|
||||
async def test_delete_vm_called_on_timeout(self):
|
||||
"""ReadTimeout during _connect() triggers delete_vm()."""
|
||||
transport = _make_cloud_transport(name="orphan-vm")
|
||||
transport.connect = AsyncMock(side_effect=httpx.ReadTimeout("poll timed out"))
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"cua_sandbox.sandbox.CloudTransport",
|
||||
return_value=transport,
|
||||
):
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
await Sandbox._create(
|
||||
image=Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_delete_vm_called_on_generic_exception(self):
|
||||
"""Any exception during _connect() triggers delete_vm()."""
|
||||
transport = _make_cloud_transport(name="orphan-vm")
|
||||
transport.connect = AsyncMock(side_effect=RuntimeError("unexpected"))
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"cua_sandbox.sandbox.CloudTransport",
|
||||
return_value=transport,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="unexpected"):
|
||||
await Sandbox._create(
|
||||
image=Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_original_exception_propagates_even_if_delete_fails(self):
|
||||
"""The original connect error is re-raised even when delete_vm also fails."""
|
||||
transport = _make_cloud_transport(name="orphan-vm")
|
||||
transport.connect = AsyncMock(side_effect=TimeoutError("poll timeout"))
|
||||
transport.delete_vm = AsyncMock(side_effect=httpx.ConnectError("api down"))
|
||||
|
||||
with patch(
|
||||
"cua_sandbox.sandbox.CloudTransport",
|
||||
return_value=transport,
|
||||
):
|
||||
with pytest.raises(TimeoutError, match="poll timeout"):
|
||||
await Sandbox._create(
|
||||
image=Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_no_cleanup_when_vm_not_yet_created(self):
|
||||
"""If connect() fails before _create_vm (no _name), skip delete_vm."""
|
||||
transport = _make_cloud_transport()
|
||||
# Simulate: connect() fails before _create_vm sets _name
|
||||
transport._name = None
|
||||
transport.connect = AsyncMock(side_effect=ValueError("no api key"))
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"cua_sandbox.sandbox.CloudTransport",
|
||||
return_value=transport,
|
||||
):
|
||||
with pytest.raises(ValueError, match="no api key"):
|
||||
await Sandbox._create(
|
||||
image=Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
transport.delete_vm.assert_not_awaited()
|
||||
|
||||
async def test_keyboard_interrupt_still_cleans_up(self):
|
||||
"""BaseException subclasses (KeyboardInterrupt) also trigger cleanup."""
|
||||
transport = _make_cloud_transport(name="interrupted-vm")
|
||||
transport.connect = AsyncMock(side_effect=KeyboardInterrupt)
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"cua_sandbox.sandbox.CloudTransport",
|
||||
return_value=transport,
|
||||
):
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
await Sandbox._create(
|
||||
image=Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. destroy() is resilient to individual step failures
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDestroyResilience:
|
||||
"""Each cleanup step in destroy() should run independently."""
|
||||
|
||||
async def test_delete_vm_runs_even_if_disconnect_fails(self):
|
||||
"""A failing disconnect() must not prevent delete_vm()."""
|
||||
transport = _make_cloud_transport(name="leaky-vm")
|
||||
transport.disconnect = AsyncMock(side_effect=OSError("connection reset"))
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
sb = _make_sandbox(transport)
|
||||
await sb.destroy()
|
||||
|
||||
transport.disconnect.assert_awaited_once()
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_runtime_stop_runs_even_if_delete_vm_fails(self):
|
||||
"""A failing delete_vm() must not prevent runtime cleanup."""
|
||||
transport = _make_cloud_transport(name="leaky-vm")
|
||||
transport.disconnect = AsyncMock()
|
||||
transport.delete_vm = AsyncMock(side_effect=httpx.ConnectError("api down"))
|
||||
|
||||
runtime = AsyncMock()
|
||||
runtime_info = MagicMock()
|
||||
runtime_info.name = "leaky-vm"
|
||||
|
||||
sb = _make_sandbox(transport)
|
||||
sb._runtime = runtime
|
||||
sb._runtime_info = runtime_info
|
||||
|
||||
await sb.destroy()
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
runtime.delete.assert_awaited_once_with("leaky-vm")
|
||||
|
||||
async def test_destroy_succeeds_when_all_steps_fail(self):
|
||||
"""destroy() must not raise even if every cleanup step fails."""
|
||||
transport = _make_cloud_transport(name="total-fail")
|
||||
transport.disconnect = AsyncMock(side_effect=OSError("disconnect fail"))
|
||||
transport.delete_vm = AsyncMock(side_effect=httpx.ReadTimeout("delete fail"))
|
||||
|
||||
runtime = AsyncMock()
|
||||
runtime.delete = AsyncMock(side_effect=RuntimeError("runtime fail"))
|
||||
runtime_info = MagicMock()
|
||||
runtime_info.name = "total-fail"
|
||||
|
||||
sb = _make_sandbox(transport)
|
||||
sb._runtime = runtime
|
||||
sb._runtime_info = runtime_info
|
||||
|
||||
# Should not raise
|
||||
await sb.destroy()
|
||||
|
||||
transport.disconnect.assert_awaited_once()
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
runtime.delete.assert_awaited_once()
|
||||
|
||||
async def test_destroy_happy_path(self):
|
||||
"""All steps succeed — basic smoke test."""
|
||||
transport = _make_cloud_transport(name="good-vm")
|
||||
transport.disconnect = AsyncMock()
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
sb = _make_sandbox(transport)
|
||||
await sb.destroy()
|
||||
|
||||
transport.disconnect.assert_awaited_once()
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_non_cloud_transport_skips_delete_vm(self):
|
||||
"""Non-CloudTransport sandboxes should not call delete_vm."""
|
||||
transport = AsyncMock() # generic mock, not a CloudTransport instance
|
||||
sb = Sandbox(transport, name="local-vm", _ephemeral=True, _telemetry_enabled=False)
|
||||
|
||||
await sb.destroy()
|
||||
|
||||
transport.disconnect.assert_awaited_once()
|
||||
# delete_vm should not be called since transport is not CloudTransport
|
||||
assert not hasattr(transport, "delete_vm") or not transport.delete_vm.called
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. ephemeral() integration — cleanup through the context manager
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEphemeralCleanup:
|
||||
"""Sandbox.ephemeral() should destroy the VM on normal and error exits."""
|
||||
|
||||
async def test_ephemeral_destroys_on_normal_exit(self):
|
||||
"""VM is destroyed when the async-with block exits normally."""
|
||||
transport = _make_cloud_transport(name="eph-ok")
|
||||
transport.connect = AsyncMock()
|
||||
transport.disconnect = AsyncMock()
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__init__",
|
||||
lambda self, **kw: None,
|
||||
),
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__new__",
|
||||
lambda cls, **kw: transport,
|
||||
),
|
||||
):
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
) as sb:
|
||||
assert sb.name == "eph-ok"
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_ephemeral_destroys_on_test_failure(self):
|
||||
"""VM is destroyed even when the body raises an assertion error."""
|
||||
transport = _make_cloud_transport(name="eph-fail")
|
||||
transport.connect = AsyncMock()
|
||||
transport.disconnect = AsyncMock()
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__init__",
|
||||
lambda self, **kw: None,
|
||||
),
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__new__",
|
||||
lambda cls, **kw: transport,
|
||||
),
|
||||
):
|
||||
with pytest.raises(AssertionError):
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
) as _sb:
|
||||
raise AssertionError("test failed")
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
|
||||
async def test_ephemeral_cleans_up_when_create_connect_fails(self):
|
||||
"""If _create raises after VM provisioning, the VM is still cleaned up."""
|
||||
transport = _make_cloud_transport(name="eph-connect-fail")
|
||||
transport.connect = AsyncMock(side_effect=httpx.ReadTimeout("poll timed out"))
|
||||
transport.delete_vm = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__init__",
|
||||
lambda self, **kw: None,
|
||||
),
|
||||
patch.object(
|
||||
CloudTransport,
|
||||
"__new__",
|
||||
lambda cls, **kw: transport,
|
||||
),
|
||||
):
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
async with Sandbox.ephemeral(
|
||||
Image.linux("ubuntu", "24.04"),
|
||||
api_key="sk-fake",
|
||||
telemetry_enabled=False,
|
||||
) as _sb:
|
||||
pass # never reached
|
||||
|
||||
transport.delete_vm.assert_awaited_once()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Windows Server E2E test using cua-sandbox SDK.
|
||||
|
||||
Run:
|
||||
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
|
||||
uv run pytest tests/test_windows_cloud.py -v -s
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from cua_sandbox import Image, Sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def P(*a, **kw):
|
||||
print(*a, **kw, flush=True)
|
||||
|
||||
|
||||
def _has_env() -> bool:
|
||||
return bool(os.environ.get("CUA_API_KEY"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
|
||||
async def test_windows_create_and_snapshot():
|
||||
"""Image.windows('server-2025') -> Sandbox.ephemeral -> snapshot -> fork."""
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
P("\n Creating Sandbox with Image.windows('server-2025')...")
|
||||
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
|
||||
t_create = time.monotonic() - t0
|
||||
P(f" Sandbox ready: {t_create:.1f}s name={sb.name}")
|
||||
|
||||
# Try screenshot
|
||||
try:
|
||||
screen = await sb.screenshot()
|
||||
P(f" Screenshot: {len(screen)} bytes")
|
||||
except Exception as e:
|
||||
P(f" Screenshot not available: {e}")
|
||||
screen = None
|
||||
|
||||
# Snapshot
|
||||
t1 = time.monotonic()
|
||||
P(" Taking snapshot...")
|
||||
snapshot_img = await sb.snapshot()
|
||||
t_snap = time.monotonic() - t1
|
||||
P(f" Snapshot: {t_snap:.2f}s")
|
||||
|
||||
# Fork from snapshot
|
||||
t2 = time.monotonic()
|
||||
P(" Forking from snapshot...")
|
||||
async with Sandbox.ephemeral(snapshot_img, local=False) as fork:
|
||||
t_fork = time.monotonic() - t2
|
||||
P(f" Fork ready: {t_fork:.1f}s name={fork.name}")
|
||||
|
||||
try:
|
||||
fork_screen = await fork.screenshot()
|
||||
P(f" Fork screenshot: {len(fork_screen)} bytes")
|
||||
except Exception as e:
|
||||
P(f" Fork screenshot not available: {e}")
|
||||
fork_screen = None
|
||||
|
||||
t_total = time.monotonic() - t0
|
||||
P("\n === TIMINGS ===")
|
||||
P(f" Create+ready: {t_create:.1f}s")
|
||||
P(f" Snapshot: {t_snap:.2f}s")
|
||||
P(f" Fork+ready: {t_fork:.1f}s")
|
||||
P(f" Total: {t_total:.1f}s")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
|
||||
async def test_windows_stateful_snapshot_fork():
|
||||
"""Stateful snapshot captures RAM — fork resumes instantly without rebooting."""
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
P("\n Creating Sandbox with Image.windows('server-2025')...")
|
||||
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
|
||||
t_create = time.monotonic() - t0
|
||||
P(f" Sandbox ready: {t_create:.1f}s name={sb.name}")
|
||||
|
||||
# Verify CUA server is running
|
||||
screen = await sb.screenshot()
|
||||
P(f" Screenshot: {len(screen)} bytes")
|
||||
|
||||
# Stateful snapshot — captures memory state
|
||||
t1 = time.monotonic()
|
||||
P(" Taking stateful snapshot...")
|
||||
snapshot_img = await sb.snapshot(stateful=True)
|
||||
t_snap = time.monotonic() - t1
|
||||
P(f" Stateful snapshot: {t_snap:.2f}s")
|
||||
|
||||
# Fork from stateful snapshot — should resume instantly
|
||||
t2 = time.monotonic()
|
||||
P(" Forking from stateful snapshot...")
|
||||
async with Sandbox.ephemeral(snapshot_img, local=False) as fork:
|
||||
t_fork = time.monotonic() - t2
|
||||
P(f" Fork ready: {t_fork:.1f}s name={fork.name}")
|
||||
|
||||
# Fork should have CUA server immediately available (no reboot)
|
||||
fork_screen = await fork.screenshot()
|
||||
P(f" Fork screenshot: {len(fork_screen)} bytes")
|
||||
assert len(fork_screen) > 1000, "Fork screenshot should be non-trivial"
|
||||
|
||||
t_total = time.monotonic() - t0
|
||||
P("\n === STATEFUL TIMINGS ===")
|
||||
P(f" Create+ready: {t_create:.1f}s")
|
||||
P(f" Stateful snapshot: {t_snap:.2f}s")
|
||||
P(f" Fork+ready: {t_fork:.1f}s (should be <10s with stateful)")
|
||||
P(f" Total: {t_total:.1f}s")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Compare Windows VM restore timings: fresh create vs snapshot vs stateful snapshot.
|
||||
|
||||
Run:
|
||||
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
|
||||
uv run pytest tests/test_windows_timing.py -v -s
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from cua_sandbox import Image, Sandbox
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def P(*a, **kw):
|
||||
print(*a, **kw, flush=True)
|
||||
|
||||
|
||||
def _has_env() -> bool:
|
||||
return bool(os.environ.get("CUA_API_KEY"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
|
||||
async def test_windows_all_restore_modes():
|
||||
"""Compare: Image.windows() vs snapshot() vs snapshot(stateful=True) fork times."""
|
||||
|
||||
results = {}
|
||||
|
||||
# ── 1. Fresh create from Image.windows("server-2025") ──
|
||||
P("\n [1/3] Creating fresh VM from Image.windows('server-2025')...")
|
||||
t0 = time.monotonic()
|
||||
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
|
||||
t_create = time.monotonic() - t0
|
||||
P(f" Ready in {t_create:.1f}s name={sb.name}")
|
||||
results["create"] = t_create
|
||||
|
||||
screen = await sb.screenshot()
|
||||
P(f" Screenshot: {len(screen)} bytes")
|
||||
|
||||
# ── 2. Non-stateful snapshot + fork ──
|
||||
P("\n [2/3] Taking non-stateful snapshot...")
|
||||
t1 = time.monotonic()
|
||||
snap_img = await sb.snapshot(stateful=False)
|
||||
t_snap = time.monotonic() - t1
|
||||
P(f" Snapshot: {t_snap:.2f}s")
|
||||
results["snapshot"] = t_snap
|
||||
|
||||
P(" Forking from non-stateful snapshot...")
|
||||
t2 = time.monotonic()
|
||||
async with Sandbox.ephemeral(snap_img, local=False) as fork1:
|
||||
t_fork1 = time.monotonic() - t2
|
||||
P(f" Fork ready: {t_fork1:.1f}s name={fork1.name}")
|
||||
results["fork_nonstateful"] = t_fork1
|
||||
|
||||
fork1_screen = await fork1.screenshot()
|
||||
P(f" Screenshot: {len(fork1_screen)} bytes")
|
||||
|
||||
# ── 3. Stateful snapshot + fork ──
|
||||
P("\n [3/3] Taking stateful snapshot...")
|
||||
t3 = time.monotonic()
|
||||
snap_stateful = await sb.snapshot(stateful=True)
|
||||
t_snap_s = time.monotonic() - t3
|
||||
P(f" Stateful snapshot: {t_snap_s:.2f}s")
|
||||
results["snapshot_stateful"] = t_snap_s
|
||||
|
||||
P(" Forking from stateful snapshot...")
|
||||
t4 = time.monotonic()
|
||||
async with Sandbox.ephemeral(snap_stateful, local=False) as fork2:
|
||||
t_fork2 = time.monotonic() - t4
|
||||
P(f" Fork ready: {t_fork2:.1f}s name={fork2.name}")
|
||||
results["fork_stateful"] = t_fork2
|
||||
|
||||
fork2_screen = await fork2.screenshot()
|
||||
P(f" Screenshot: {len(fork2_screen)} bytes")
|
||||
|
||||
P(f"\n {'='*50}")
|
||||
P(" TIMING COMPARISON")
|
||||
P(f" {'='*50}")
|
||||
P(f" Image.windows() create: {results['create']:6.1f}s")
|
||||
P(f" snapshot(stateful=False): {results['snapshot']:6.2f}s")
|
||||
P(f" fork from non-stateful: {results['fork_nonstateful']:6.1f}s")
|
||||
P(f" snapshot(stateful=True): {results['snapshot_stateful']:6.2f}s")
|
||||
P(f" fork from stateful: {results['fork_stateful']:6.1f}s")
|
||||
P(f" {'='*50}")
|
||||
Reference in New Issue
Block a user