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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
+153
View File
@@ -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
+124
View File
@@ -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"
+218
View File
@@ -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
+242
View File
@@ -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}")