Files
python-sdk/tests/test_integration.py
pptx704 875af78e7a
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
test: cover stats/usage/metrics/events; fix SSE error path
Extend test_client.py with async stats/usage/metrics + async events
stream, SSE edge cases (multi-line data, keepalive-only, incomplete
trailing frame, full payload round trip, 4xx → typed error), default
arg paths that omit query params, string/partial date variants, and
404/401 error paths. Verify __version__ == "0.3.0" and that the new
models (Actor, CapsuleMetrics, CapsuleStats, MetricPoint, Resource,
SSEEvent, UsageResponse) are exported from wrenn.models.

Add live integration tests in test_integration.py: TestStatsUsageMetrics
(stats default/range, usage no-args/date-range, metrics on a running
capsule, metrics range=2h, metrics on a destroyed capsule → 404) and
TestEventsStream (spawn reader thread, create capsule, assert its
lifecycle event arrives).

Add direct unit tests in test_code_runner_unit.py for the bare
_protocol helpers (pick_kernel_id, validate_language, build_ws_url,
apply_kernel_message) covering parent filtering, stdout/stderr
routing, execute_result vs display_data main flag, error emit, and
idle/busy status return.

Fix EventsResource.stream() and AsyncEventsResource.stream(): calling
_raise_for_status on a streaming httpx.Response without first reading
the body raised httpx.ResponseNotRead on any 4xx/5xx. Now read the
body (resp.read() / await resp.aread()) before status parsing when
status >= 400.

Bypass TestCodeRunnerMimeTypes._run in test_requests_status_code so
the in-capsule SSL/network failure skips the test instead of tripping
the helper's "unexpected error" pre-assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 01:52:13 +06:00

540 lines
18 KiB
Python

from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from wrenn import Capsule, CommandResult
from wrenn.client import WrennClient
from wrenn.commands import CommandHandle, ProcessInfo
from wrenn.exceptions import WrennNotFoundError
from wrenn.models import (
Capsule as CapsuleModel,
CapsuleMetrics,
CapsuleStats,
FileEntry,
SSEEvent,
Status,
UsageResponse,
)
pytestmark = pytest.mark.integration
_env_loaded = False
def _ensure_env() -> None:
global _env_loaded
if _env_loaded:
return
_env_loaded = True
env_file = Path(__file__).resolve().parent.parent / ".env"
if not env_file.exists():
return
for line in env_file.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key and key not in os.environ:
os.environ[key] = value
class TestCapsuleLifecycle:
"""Each test manages its own capsule to test create/destroy paths."""
def setup_method(self):
_ensure_env()
def test_create_and_destroy(self):
capsule = Capsule()
capsule_id = capsule.capsule_id
try:
assert capsule_id
assert capsule.info is not None
finally:
capsule.destroy(wait=True)
info = Capsule.get_info(capsule_id)
assert info.status in (Status.stopped, Status.missing)
def test_create_with_wait(self):
capsule = Capsule(wait=True)
try:
assert capsule.info is not None
assert capsule.info.status == Status.running
finally:
capsule.destroy()
def test_context_manager_destroys(self):
with Capsule(wait=True) as capsule:
capsule_id = capsule.capsule_id
assert capsule.is_running()
info = Capsule.get_info(capsule_id)
assert info.status in (Status.stopping, Status.stopped, Status.missing)
def test_get_info(self):
capsule = Capsule(wait=True)
try:
info = capsule.get_info()
assert isinstance(info, CapsuleModel)
assert info.id == capsule.capsule_id
assert info.status == Status.running
finally:
capsule.destroy()
def test_pause_and_resume(self):
capsule = Capsule(wait=True)
try:
paused = capsule.pause(wait=True)
assert paused.status == Status.paused
assert not capsule.is_running()
resumed = capsule.resume(wait=True)
assert resumed.status == Status.running
finally:
capsule.destroy()
def test_static_destroy(self):
capsule = Capsule(wait=True)
capsule_id = capsule.capsule_id
try:
Capsule.destroy(capsule_id, wait=True)
except Exception:
capsule.destroy()
raise
info = Capsule.get_info(capsule_id)
assert info.status in (Status.stopped, Status.missing)
def test_connect_to_existing(self):
capsule = Capsule(wait=True)
try:
connected = Capsule.connect(capsule.capsule_id)
assert connected.capsule_id == capsule.capsule_id
assert connected.info is not None
assert connected.info.status == Status.running
finally:
capsule.destroy()
def test_connect_resumes_paused(self):
capsule = Capsule(wait=True)
try:
capsule.pause()
connected = Capsule.connect(capsule.capsule_id)
assert connected.info is not None
assert connected.info.status == Status.running
finally:
capsule.destroy()
def test_list_capsules(self):
capsule = Capsule(wait=True)
try:
capsules = Capsule.list()
assert isinstance(capsules, list)
ids = [c.id for c in capsules]
assert capsule.capsule_id in ids
finally:
capsule.destroy()
def test_wait_ready(self):
capsule = Capsule()
try:
capsule.wait_ready(timeout=60)
assert capsule.is_running()
finally:
capsule.destroy()
def test_ping(self):
capsule = Capsule(wait=True)
try:
capsule.ping()
finally:
capsule.destroy()
class TestCommands:
"""Shared capsule for command execution tests."""
capsule: Capsule
@classmethod
def setup_class(cls):
_ensure_env()
cls.capsule = Capsule(wait=True)
@classmethod
def teardown_class(cls):
try:
cls.capsule.destroy()
except Exception:
pass
def test_run_foreground(self):
result = self.capsule.commands.run("echo hello")
assert isinstance(result, CommandResult)
assert result.exit_code == 0
assert "hello" in result.stdout
def test_run_stderr(self):
result = self.capsule.commands.run("echo error >&2")
assert "error" in result.stderr
def test_run_exit_code(self):
result = self.capsule.commands.run("exit 42")
assert result.exit_code == 42
def test_run_with_envs(self):
result = self.capsule.commands.run("export MY_VAR=test_value && echo $MY_VAR")
assert "test_value" in result.stdout
def test_run_with_cwd(self):
result = self.capsule.commands.run("cd /tmp && pwd")
assert result.stdout.strip() == "/tmp"
def test_run_multiline_output(self):
result = self.capsule.commands.run("echo -e 'line1\\nline2\\nline3'")
assert result.exit_code == 0
lines = result.stdout.strip().splitlines()
assert len(lines) == 3
def test_run_background(self):
handle = self.capsule.commands.run("sleep 30", background=True, tag="bg-test")
assert isinstance(handle, CommandHandle)
assert handle.pid > 0
assert handle.tag == "bg-test"
assert handle.capsule_id == self.capsule.capsule_id
self.capsule.commands.kill(handle.pid)
def test_list_processes(self):
handle = self.capsule.commands.run("sleep 30", background=True, tag="list-test")
try:
time.sleep(0.5)
processes = self.capsule.commands.list()
assert isinstance(processes, list)
pids = [p.pid for p in processes]
assert handle.pid in pids
proc = next(p for p in processes if p.pid == handle.pid)
assert isinstance(proc, ProcessInfo)
finally:
self.capsule.commands.kill(handle.pid)
def test_kill_process(self):
handle = self.capsule.commands.run("sleep 30", background=True)
self.capsule.commands.kill(handle.pid)
# Registry prune runs asynchronously after the process end event,
# so poll rather than asserting on a zero-delay list().
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if handle.pid not in [p.pid for p in self.capsule.commands.list()]:
break
time.sleep(0.2)
assert handle.pid not in [p.pid for p in self.capsule.commands.list()]
def test_run_duration_ms(self):
result = self.capsule.commands.run("sleep 1")
assert result.duration_ms is None or result.duration_ms >= 900
class TestFiles:
"""Shared capsule for filesystem tests."""
capsule: Capsule
@classmethod
def setup_class(cls):
_ensure_env()
cls.capsule = Capsule(wait=True)
@classmethod
def teardown_class(cls):
try:
cls.capsule.destroy()
except Exception:
pass
def test_write_and_read(self):
self.capsule.files.write("/tmp/test.txt", "hello world")
content = self.capsule.files.read("/tmp/test.txt")
assert content == "hello world"
def test_write_and_read_bytes(self):
data = b"\x00\x01\x02\xff"
self.capsule.files.write("/tmp/test.bin", data)
result = self.capsule.files.read_bytes("/tmp/test.bin")
assert result == data
def test_list_directory(self):
self.capsule.files.write("/tmp/listdir/a.txt", "a")
self.capsule.files.write("/tmp/listdir/b.txt", "b")
entries = self.capsule.files.list("/tmp/listdir")
assert isinstance(entries, list)
names = [e.name for e in entries]
assert "a.txt" in names
assert "b.txt" in names
def test_exists(self):
self.capsule.files.write("/tmp/exists_test.txt", "x")
assert self.capsule.files.exists("/tmp/exists_test.txt")
assert not self.capsule.files.exists("/tmp/does_not_exist_xyz.txt")
def test_make_dir(self):
entry = self.capsule.files.make_dir("/tmp/newdir")
assert isinstance(entry, FileEntry)
assert self.capsule.files.exists("/tmp/newdir")
def test_make_dir_idempotent(self):
self.capsule.files.make_dir("/tmp/idempotent_dir")
entry = self.capsule.files.make_dir("/tmp/idempotent_dir")
assert isinstance(entry, FileEntry)
def test_remove_file(self):
self.capsule.files.write("/tmp/to_remove.txt", "delete me")
assert self.capsule.files.exists("/tmp/to_remove.txt")
self.capsule.files.remove("/tmp/to_remove.txt")
assert not self.capsule.files.exists("/tmp/to_remove.txt")
def test_remove_directory(self):
self.capsule.files.make_dir("/tmp/dir_to_remove")
self.capsule.files.write("/tmp/dir_to_remove/child.txt", "data")
self.capsule.files.remove("/tmp/dir_to_remove")
assert not self.capsule.files.exists("/tmp/dir_to_remove")
def test_write_creates_parent_dirs(self):
self.capsule.files.write("/tmp/deep/nested/dir/file.txt", "nested")
content = self.capsule.files.read("/tmp/deep/nested/dir/file.txt")
assert content == "nested"
def test_list_with_depth(self):
self.capsule.files.write("/tmp/depth_test/a/b.txt", "deep")
entries_shallow = self.capsule.files.list("/tmp/depth_test", depth=1)
entries_deep = self.capsule.files.list("/tmp/depth_test", depth=2)
assert len(entries_deep) >= len(entries_shallow)
def test_overwrite_file(self):
self.capsule.files.write("/tmp/overwrite.txt", "original")
self.capsule.files.write("/tmp/overwrite.txt", "updated")
content = self.capsule.files.read("/tmp/overwrite.txt")
assert content == "updated"
def test_upload_and_download_stream(self):
chunks = [b"chunk1", b"chunk2", b"chunk3"]
self.capsule.files.upload_stream("/tmp/streamed.bin", iter(chunks))
downloaded = b"".join(self.capsule.files.download_stream("/tmp/streamed.bin"))
assert downloaded == b"chunk1chunk2chunk3"
class TestGit:
"""Shared capsule for git operation tests.
Initializes a repo at /home/wrenn-user (default cwd) since the exec API
does not support the cwd parameter.
"""
capsule: Capsule
@classmethod
def setup_class(cls):
_ensure_env()
cls.capsule = Capsule(wait=True)
cls.capsule.git.init(".", initial_branch="main")
cls.capsule.git.configure_user("Test User", "test@example.com")
@classmethod
def teardown_class(cls):
try:
cls.capsule.destroy()
except Exception:
pass
def test_init_created_repo(self):
assert self.capsule.files.exists("/home/wrenn-user/.git")
def test_status_clean(self):
status = self.capsule.git.status()
assert status.branch == "main"
def test_add_and_commit(self):
self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0
def test_status_after_commit(self):
status = self.capsule.git.status()
assert status.is_clean
def test_status_with_changes(self):
self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
try:
status = self.capsule.git.status()
assert not status.is_clean
paths = [f.path for f in status.files]
assert "dirty.txt" in paths
finally:
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
def test_branches(self):
branches = self.capsule.git.branches()
assert len(branches) >= 1
names = [b.name for b in branches]
assert "main" in names
current = [b for b in branches if b.is_current]
assert len(current) == 1
def test_create_and_checkout_branch(self):
self.capsule.git.create_branch("feature-1")
branches = self.capsule.git.branches()
names = [b.name for b in branches]
assert "feature-1" in names
current = [b for b in branches if b.is_current]
assert current[0].name == "feature-1"
self.capsule.git.checkout_branch("main")
def test_delete_branch(self):
self.capsule.git.create_branch("to-delete")
self.capsule.git.checkout_branch("main")
self.capsule.git.delete_branch("to-delete")
branches = self.capsule.git.branches()
names = [b.name for b in branches]
assert "to-delete" not in names
def test_set_and_get_config(self):
self.capsule.git.set_config("test.key", "test-value")
value = self.capsule.git.get_config("test.key")
assert value == "test-value"
def test_get_config_missing_returns_none(self):
value = self.capsule.git.get_config("nonexistent.key")
assert value is None
class TestStatsUsageMetrics:
"""Account-scoped + per-capsule observability endpoints."""
def setup_method(self):
_ensure_env()
def test_stats_default(self):
with WrennClient() as client:
stats = client.capsules.stats()
assert isinstance(stats, CapsuleStats)
# current.running_count is always present even when zero
assert stats.current is not None
assert stats.peaks is not None
def test_stats_range(self):
with WrennClient() as client:
stats = client.capsules.stats(range="24h")
assert isinstance(stats, CapsuleStats)
if stats.range is not None:
assert stats.range.value == "24h"
def test_usage_no_args(self):
with WrennClient() as client:
usage = client.capsules.usage()
assert isinstance(usage, UsageResponse)
assert usage.points is not None
def test_usage_date_range(self):
from datetime import date, timedelta
today = date.today()
with WrennClient() as client:
usage = client.capsules.usage(from_=today - timedelta(days=7), to=today)
assert isinstance(usage, UsageResponse)
def test_metrics_running_capsule(self):
capsule = Capsule(wait=True)
try:
with WrennClient() as client:
m = client.capsules.metrics(capsule.capsule_id)
assert isinstance(m, CapsuleMetrics)
assert m.sandbox_id == capsule.capsule_id
assert m.points is not None
finally:
capsule.destroy()
def test_metrics_range_2h(self):
capsule = Capsule(wait=True)
try:
with WrennClient() as client:
m = client.capsules.metrics(capsule.capsule_id, range="2h")
assert isinstance(m, CapsuleMetrics)
finally:
capsule.destroy()
def test_metrics_destroyed_capsule_raises(self):
# Create, destroy, then ask for metrics — guaranteed valid-shape ID
# that no longer exists. Server returns 404 for the missing capsule
# (avoids the 400 validation path for malformed IDs).
capsule = Capsule(wait=True)
capsule_id = capsule.capsule_id
capsule.destroy(wait=True)
with WrennClient() as client:
with pytest.raises(WrennNotFoundError):
client.capsules.metrics(capsule_id)
class TestEventsStream:
"""SSE lifecycle stream — drives a capsule create and watches for it."""
def setup_method(self):
_ensure_env()
def test_stream_receives_capsule_create_event(self):
import threading
seen: list[SSEEvent] = []
ready = threading.Event()
stop = threading.Event()
def _reader() -> None:
with WrennClient() as client:
stream = client.events.stream()
ready.set()
for ev in stream:
seen.append(ev)
if stop.is_set() or len(seen) > 20:
return
t = threading.Thread(target=_reader, daemon=True)
t.start()
assert ready.wait(timeout=5)
# Give the server a beat to fully open the SSE stream before
# the action that should appear on it.
time.sleep(0.5)
capsule = Capsule()
capsule_id = capsule.capsule_id
try:
capsule.wait_ready(timeout=60)
finally:
capsule.destroy()
# Wait for either our capsule's event or a timeout.
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if any(
ev.resource is not None and ev.resource.id == capsule_id for ev in seen
):
break
time.sleep(0.5)
stop.set()
assert any(isinstance(ev, SSEEvent) for ev in seen)
# Best-effort: assert we saw a capsule.* event for our capsule.
matching = [ev for ev in seen if ev.resource and ev.resource.id == capsule_id]
assert matching, f"no events for {capsule_id} in {len(seen)} received"