## What's New? - Bugfixes - Updated test suites Reviewed-on: #15 Co-authored-by: pptx704 <rafeed@omukk.dev> Co-committed-by: pptx704 <rafeed@omukk.dev>
This commit is contained in:
@ -13,9 +13,16 @@ from wrenn.exceptions import (
|
||||
WrennValidationError,
|
||||
)
|
||||
from wrenn.models import (
|
||||
Actor,
|
||||
Capsule,
|
||||
CapsuleMetrics,
|
||||
CapsuleStats,
|
||||
MetricPoint,
|
||||
Resource,
|
||||
SSEEvent,
|
||||
Status,
|
||||
Template,
|
||||
UsageResponse,
|
||||
)
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
@ -103,6 +110,212 @@ class TestCapsules:
|
||||
client.capsules.ping("sb-1")
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
def test_stats(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={
|
||||
"range": "6h",
|
||||
"current": {"running_count": 2, "vcpus_reserved": 4},
|
||||
"peaks": {"running_count": 9},
|
||||
"series": {"running": [1, 2, 2]},
|
||||
},
|
||||
)
|
||||
stats = client.capsules.stats(range="6h")
|
||||
assert "range=6h" in str(route.calls[0].request.url)
|
||||
assert stats.current and stats.current.running_count == 2
|
||||
assert stats.peaks and stats.peaks.running_count == 9
|
||||
|
||||
@respx.mock
|
||||
def test_usage_passes_date_params(self, client):
|
||||
import datetime as _dt
|
||||
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||
200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []}
|
||||
)
|
||||
client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-06-01" in url
|
||||
assert "to=2026-06-22" in url
|
||||
|
||||
@respx.mock
|
||||
def test_metrics(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200,
|
||||
json={
|
||||
"sandbox_id": "sb-1",
|
||||
"range": "10m",
|
||||
"points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}],
|
||||
},
|
||||
)
|
||||
m = client.capsules.metrics("sb-1")
|
||||
assert m.sandbox_id == "sb-1"
|
||||
assert m.points and m.points[0].cpu_pct == 12.5
|
||||
assert isinstance(m, CapsuleMetrics)
|
||||
assert isinstance(m.points[0], MetricPoint)
|
||||
|
||||
@respx.mock
|
||||
def test_stats_default_omits_range(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={"range": "1h", "current": {}, "peaks": {}, "series": {}},
|
||||
)
|
||||
stats = client.capsules.stats()
|
||||
assert "range=" not in str(route.calls[0].request.url)
|
||||
assert isinstance(stats, CapsuleStats)
|
||||
|
||||
@respx.mock
|
||||
def test_usage_default_omits_params(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
usage = client.capsules.usage()
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=" not in url
|
||||
assert "to=" not in url
|
||||
assert isinstance(usage, UsageResponse)
|
||||
|
||||
@respx.mock
|
||||
def test_metrics_default_omits_range(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200, json={"sandbox_id": "sb-1", "points": []}
|
||||
)
|
||||
client.capsules.metrics("sb-1")
|
||||
assert "range=" not in str(route.calls[0].request.url)
|
||||
|
||||
@respx.mock
|
||||
def test_metrics_not_found(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "capsule not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
client.capsules.metrics("nope")
|
||||
|
||||
@respx.mock
|
||||
def test_stats_auth_error(self, client):
|
||||
respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
client.capsules.stats()
|
||||
|
||||
@respx.mock
|
||||
def test_usage_string_dates(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
client.capsules.usage(from_="2026-01-01", to="2026-01-31")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-01-01" in url
|
||||
assert "to=2026-01-31" in url
|
||||
|
||||
@respx.mock
|
||||
def test_usage_partial_dates(self, client):
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []})
|
||||
client.capsules.usage(from_="2026-01-01")
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-01-01" in url
|
||||
assert "to=" not in url
|
||||
|
||||
|
||||
class TestEvents:
|
||||
@respx.mock
|
||||
def test_stream_parses_sse_frames(self, client):
|
||||
body = (
|
||||
": keepalive\n"
|
||||
"event: capsule.create\n"
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
"event: capsule.destroy\n"
|
||||
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert [e.event.value for e in events] == [
|
||||
"capsule.create",
|
||||
"capsule.destroy",
|
||||
]
|
||||
assert events[0].resource and events[0].resource.id == "sb-1"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_multiline_data(self, client):
|
||||
body = (
|
||||
"event: capsule.create\n"
|
||||
'data: {"event":"capsule.create",\n'
|
||||
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
assert events[0].resource.id == "sb-1"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_skips_keepalive_only(self, client):
|
||||
body = ": keepalive\n\n: keepalive\n\n"
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
assert list(client.events.stream()) == []
|
||||
|
||||
@respx.mock
|
||||
def test_stream_ignores_incomplete_trailing_frame(self, client):
|
||||
body = (
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
'data: {"event":"capsule.destroy"' # no closing brace, no blank line
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
assert events[0].event.value == "capsule.create"
|
||||
|
||||
@respx.mock
|
||||
def test_stream_full_payload_round_trip(self, client):
|
||||
body = (
|
||||
'data: {"event":"capsule.create",'
|
||||
'"outcome":"success",'
|
||||
'"resource":{"id":"sb-1","type":"capsule"},'
|
||||
'"actor":{"type":"api_key","id":"key-1","name":"ci"},'
|
||||
'"metadata":{"reason":"manual"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = list(client.events.stream())
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert isinstance(ev, SSEEvent)
|
||||
assert isinstance(ev.resource, Resource)
|
||||
assert isinstance(ev.actor, Actor)
|
||||
assert ev.actor.name == "ci"
|
||||
assert ev.metadata == {"reason": "manual"}
|
||||
|
||||
@respx.mock
|
||||
def test_stream_raises_on_4xx(self, client):
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
list(client.events.stream())
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
@respx.mock
|
||||
@ -262,6 +475,156 @@ class TestAsyncClient:
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
await async_client.capsules.get("nope")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_stats(self, async_client):
|
||||
async with async_client:
|
||||
route = respx.get(f"{BASE}/v1/capsules/stats").respond(
|
||||
200,
|
||||
json={
|
||||
"range": "24h",
|
||||
"current": {"running_count": 1},
|
||||
"peaks": {"running_count": 5},
|
||||
"series": {"running": [1]},
|
||||
},
|
||||
)
|
||||
stats = await async_client.capsules.stats(range="24h")
|
||||
assert "range=24h" in str(route.calls[0].request.url)
|
||||
assert isinstance(stats, CapsuleStats)
|
||||
assert stats.current.running_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_usage(self, async_client):
|
||||
import datetime as _dt
|
||||
|
||||
async with async_client:
|
||||
route = respx.get(f"{BASE}/v1/capsules/usage").respond(
|
||||
200,
|
||||
json={
|
||||
"from": "2026-06-01",
|
||||
"to": "2026-06-22",
|
||||
"points": [
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"cpu_minutes": 1.5,
|
||||
"ram_mb_minutes": 200.0,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
usage = await async_client.capsules.usage(
|
||||
from_=_dt.date(2026, 6, 1), to="2026-06-22"
|
||||
)
|
||||
url = str(route.calls[0].request.url)
|
||||
assert "from=2026-06-01" in url
|
||||
assert "to=2026-06-22" in url
|
||||
assert isinstance(usage, UsageResponse)
|
||||
assert usage.points and usage.points[0].cpu_minutes == 1.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_metrics(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond(
|
||||
200,
|
||||
json={
|
||||
"sandbox_id": "sb-1",
|
||||
"range": "2h",
|
||||
"points": [
|
||||
{"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024}
|
||||
],
|
||||
},
|
||||
)
|
||||
m = await async_client.capsules.metrics("sb-1", range="2h")
|
||||
assert isinstance(m, CapsuleMetrics)
|
||||
assert m.points[0].cpu_pct == 33.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_metrics_not_found(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/capsules/nope/metrics").respond(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "not found"}},
|
||||
)
|
||||
with pytest.raises(WrennNotFoundError):
|
||||
await async_client.capsules.metrics("nope")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_stream(self, async_client):
|
||||
async with async_client:
|
||||
body = (
|
||||
": keepalive\n"
|
||||
'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = [ev async for ev in async_client.events.stream()]
|
||||
assert [e.event.value for e in events] == [
|
||||
"capsule.create",
|
||||
"capsule.destroy",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_raises_on_4xx(self, async_client):
|
||||
async with async_client:
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
401,
|
||||
json={"error": {"code": "unauthorized", "message": "bad key"}},
|
||||
)
|
||||
with pytest.raises(WrennAuthenticationError):
|
||||
async for _ in async_client.events.stream():
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_async_events_multiline_data(self, async_client):
|
||||
async with async_client:
|
||||
body = (
|
||||
'data: {"event":"capsule.create",\n'
|
||||
'data: "resource":{"id":"sb-1","type":"capsule"}}\n'
|
||||
"\n"
|
||||
)
|
||||
respx.get(f"{BASE}/v1/events/stream").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=body,
|
||||
)
|
||||
events = [ev async for ev in async_client.events.stream()]
|
||||
assert len(events) == 1
|
||||
assert events[0].resource.id == "sb-1"
|
||||
|
||||
|
||||
class TestPackageSurface:
|
||||
def test_version(self):
|
||||
import wrenn
|
||||
|
||||
assert wrenn.__version__ == "0.3.0"
|
||||
|
||||
def test_new_models_exported(self):
|
||||
import wrenn.models as m
|
||||
|
||||
for name in (
|
||||
"Actor",
|
||||
"CapsuleMetrics",
|
||||
"CapsuleStats",
|
||||
"MetricPoint",
|
||||
"Resource",
|
||||
"SSEEvent",
|
||||
"UsageResponse",
|
||||
):
|
||||
assert hasattr(m, name), name
|
||||
assert name in m.__all__
|
||||
|
||||
|
||||
class TestClientResolution:
|
||||
def test_default_base_url_strips_app_subdomain(self):
|
||||
|
||||
@ -430,12 +430,10 @@ class TestCodeRunnerMimeTypes:
|
||||
def test_requests_status_code(self):
|
||||
ex = self._run(
|
||||
"import requests\n"
|
||||
"r = requests.get('https://httpbin.org/status/204', timeout=10)\n"
|
||||
"r = requests.get('http://httpbingo.org/status/418', timeout=10)\n"
|
||||
"r.status_code\n"
|
||||
)
|
||||
if ex.error is not None:
|
||||
pytest.skip(f"network unavailable: {ex.error.name}")
|
||||
assert ex.text == "204"
|
||||
assert ex.text == "418"
|
||||
|
||||
|
||||
class TestCodeRunnerIsolation:
|
||||
|
||||
@ -388,6 +388,214 @@ class TestJupyterRequest:
|
||||
assert a["header"]["msg_id"] != b["header"]["msg_id"]
|
||||
|
||||
|
||||
# ───────────────────────── _protocol direct helpers ─────────────────────────
|
||||
|
||||
|
||||
class TestPickKernelId:
|
||||
def test_returns_first_matching_kernel(self):
|
||||
from wrenn.code_runner._protocol import pick_kernel_id
|
||||
|
||||
kernels = [
|
||||
{"id": "k-1", "name": "python3"},
|
||||
{"id": "k-2", "name": "wrenn"},
|
||||
{"id": "k-3", "name": "wrenn"},
|
||||
]
|
||||
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||
|
||||
def test_returns_none_when_no_match(self):
|
||||
from wrenn.code_runner._protocol import pick_kernel_id
|
||||
|
||||
kernels = [{"id": "k-1", "name": "python3"}]
|
||||
assert pick_kernel_id(kernels, "wrenn") is None
|
||||
|
||||
def test_returns_none_on_empty_list(self):
|
||||
from wrenn.code_runner._protocol import pick_kernel_id
|
||||
|
||||
assert pick_kernel_id([], "wrenn") is None
|
||||
|
||||
def test_ignores_entries_missing_name(self):
|
||||
from wrenn.code_runner._protocol import pick_kernel_id
|
||||
|
||||
kernels = [{"id": "k-1"}, {"id": "k-2", "name": "wrenn"}]
|
||||
assert pick_kernel_id(kernels, "wrenn") == "k-2"
|
||||
|
||||
|
||||
class TestValidateLanguage:
|
||||
def test_python_accepted(self):
|
||||
from wrenn.code_runner._protocol import validate_language
|
||||
|
||||
validate_language("python") # no raise
|
||||
|
||||
def test_other_language_raises(self):
|
||||
from wrenn.code_runner._protocol import validate_language
|
||||
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
validate_language("r")
|
||||
|
||||
|
||||
class TestBuildWsUrl:
|
||||
def test_uses_proxy_domain_when_given(self):
|
||||
from wrenn.code_runner._protocol import build_ws_url
|
||||
|
||||
url = build_ws_url(
|
||||
base_url="https://app.wrenn.dev/api",
|
||||
capsule_id="sb-1",
|
||||
kernel_id="k-1",
|
||||
proxy_domain="wrenn.dev",
|
||||
)
|
||||
assert url.startswith("wss://")
|
||||
assert "8888-sb-1.wrenn.dev" in url
|
||||
assert url.endswith("/api/kernels/k-1/channels")
|
||||
|
||||
def test_falls_back_to_base_host(self):
|
||||
from wrenn.code_runner._protocol import build_ws_url
|
||||
|
||||
url = build_ws_url(
|
||||
base_url="http://localhost:8080/api",
|
||||
capsule_id="sb-1",
|
||||
kernel_id="k-1",
|
||||
)
|
||||
# localhost stays http→ws (not wss) only if helper preserves scheme
|
||||
assert "8888-sb-1.localhost:8080" in url
|
||||
assert url.endswith("/api/kernels/k-1/channels")
|
||||
|
||||
|
||||
class TestApplyKernelMessage:
|
||||
def _make_execution(self):
|
||||
from wrenn.code_runner.models import Execution, Logs
|
||||
|
||||
return Execution(results=[], logs=Logs(stdout=[], stderr=[]), error=None)
|
||||
|
||||
def test_ignores_other_parent(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "stream",
|
||||
"header": {"msg_type": "stream"},
|
||||
"parent_header": {"msg_id": "other"},
|
||||
"content": {"name": "stdout", "text": "hi"},
|
||||
}
|
||||
emitted: list = []
|
||||
done = apply_kernel_message(
|
||||
msg, "mine", execution, emitted.append, None, None, None
|
||||
)
|
||||
assert done is False
|
||||
assert execution.logs.stdout == []
|
||||
|
||||
def test_stream_stdout_routed(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "stream",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {"name": "stdout", "text": "hi"},
|
||||
}
|
||||
outs: list[str] = []
|
||||
apply_kernel_message(
|
||||
msg, "mine", execution, lambda e: None, None, outs.append, None
|
||||
)
|
||||
assert execution.logs.stdout == ["hi"]
|
||||
assert outs == ["hi"]
|
||||
|
||||
def test_stream_stderr_routed(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "stream",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {"name": "stderr", "text": "boom"},
|
||||
}
|
||||
errs: list[str] = []
|
||||
apply_kernel_message(
|
||||
msg, "mine", execution, lambda e: None, None, None, errs.append
|
||||
)
|
||||
assert execution.logs.stderr == ["boom"]
|
||||
assert errs == ["boom"]
|
||||
|
||||
def test_execute_result_marks_main(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "execute_result",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {
|
||||
"data": {"text/plain": "42"},
|
||||
"execution_count": 7,
|
||||
},
|
||||
}
|
||||
results: list = []
|
||||
apply_kernel_message(
|
||||
msg, "mine", execution, lambda e: None, results.append, None, None
|
||||
)
|
||||
assert execution.execution_count == 7
|
||||
assert execution.results[0].is_main_result is True
|
||||
assert results and results[0].is_main_result is True
|
||||
|
||||
def test_display_data_is_not_main(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "display_data",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {"data": {"text/plain": "x"}},
|
||||
}
|
||||
apply_kernel_message(msg, "mine", execution, lambda e: None, None, None, None)
|
||||
assert execution.results[0].is_main_result is False
|
||||
assert execution.execution_count is None
|
||||
|
||||
def test_error_message_emits_error(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "error",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {
|
||||
"ename": "ValueError",
|
||||
"evalue": "bad",
|
||||
"traceback": ["line1", "line2"],
|
||||
},
|
||||
}
|
||||
emitted: list = []
|
||||
apply_kernel_message(msg, "mine", execution, emitted.append, None, None, None)
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].name == "ValueError"
|
||||
assert emitted[0].traceback == "line1\nline2"
|
||||
|
||||
def test_idle_status_returns_true(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "status",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {"execution_state": "idle"},
|
||||
}
|
||||
done = apply_kernel_message(
|
||||
msg, "mine", execution, lambda e: None, None, None, None
|
||||
)
|
||||
assert done is True
|
||||
|
||||
def test_busy_status_returns_false(self):
|
||||
from wrenn.code_runner._protocol import apply_kernel_message
|
||||
|
||||
execution = self._make_execution()
|
||||
msg = {
|
||||
"msg_type": "status",
|
||||
"parent_header": {"msg_id": "mine"},
|
||||
"content": {"execution_state": "busy"},
|
||||
}
|
||||
done = apply_kernel_message(
|
||||
msg, "mine", execution, lambda e: None, None, None, None
|
||||
)
|
||||
assert done is False
|
||||
|
||||
|
||||
# ───────────────────────── run_code (WS-mocked) ─────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from wsproto.events import BytesMessage, TextMessage
|
||||
|
||||
from wrenn.capsule import Capsule
|
||||
from wrenn.models import FileEntry
|
||||
@ -13,9 +13,18 @@ from wrenn.pty import (
|
||||
AsyncPtySession,
|
||||
PtyEventType,
|
||||
PtySession,
|
||||
_parse_pty_event,
|
||||
_parse_control_event,
|
||||
)
|
||||
|
||||
|
||||
def _text(payload: dict) -> TextMessage:
|
||||
return TextMessage(data=json.dumps(payload))
|
||||
|
||||
|
||||
def _bytes(data: bytes) -> BytesMessage:
|
||||
return BytesMessage(data=data)
|
||||
|
||||
|
||||
BASE = "https://app.wrenn.dev/api"
|
||||
|
||||
|
||||
@ -226,50 +235,37 @@ class TestFilesExists:
|
||||
class TestPtyEventParsing:
|
||||
def test_started_event(self):
|
||||
raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42}
|
||||
event = _parse_pty_event(raw)
|
||||
event = _parse_control_event(raw)
|
||||
assert event.type == PtyEventType.started
|
||||
assert event.pid == 42
|
||||
assert event.tag == "pty-a1b2c3d4"
|
||||
|
||||
def test_output_event_base64(self):
|
||||
encoded = base64.b64encode(b"ls -la\n").decode()
|
||||
raw = {"type": "output", "data": encoded}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.type == PtyEventType.output
|
||||
assert event.data == b"ls -la\n"
|
||||
|
||||
def test_output_event_empty(self):
|
||||
raw = {"type": "output", "data": ""}
|
||||
event = _parse_pty_event(raw)
|
||||
assert event.data == b""
|
||||
|
||||
def test_exit_event(self):
|
||||
raw = {"type": "exit", "exit_code": 0}
|
||||
event = _parse_pty_event(raw)
|
||||
event = _parse_control_event(raw)
|
||||
assert event.type == PtyEventType.exit
|
||||
assert event.exit_code == 0
|
||||
|
||||
def test_error_event(self):
|
||||
raw = {"type": "error", "data": "process not found", "fatal": True}
|
||||
event = _parse_pty_event(raw)
|
||||
event = _parse_control_event(raw)
|
||||
assert event.type == PtyEventType.error
|
||||
assert event.data == "process not found"
|
||||
assert event.fatal is True
|
||||
|
||||
def test_ping_event(self):
|
||||
raw = {"type": "ping"}
|
||||
event = _parse_pty_event(raw)
|
||||
event = _parse_control_event(raw)
|
||||
assert event.type == PtyEventType.ping
|
||||
|
||||
|
||||
class TestPtySessionWrite:
|
||||
def test_write_sends_base64_input(self):
|
||||
def test_write_sends_binary_frame(self):
|
||||
ws = MagicMock()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
session.write(b"ls -la\n")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "input"
|
||||
assert base64.b64decode(sent["data"]) == b"ls -la\n"
|
||||
ws.send_bytes.assert_called_once_with(b"ls -la\n")
|
||||
ws.send_text.assert_not_called()
|
||||
|
||||
|
||||
class TestPtySessionResize:
|
||||
@ -303,12 +299,11 @@ class TestPtySessionKill:
|
||||
class TestPtySessionIteration:
|
||||
def test_iter_yields_events_until_exit(self):
|
||||
ws = MagicMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
ws.receive.side_effect = [
|
||||
_text({"type": "started", "tag": "pty-abc12345", "pid": 1}),
|
||||
_bytes(b"hello"),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert len(events) == 3
|
||||
@ -320,12 +315,22 @@ class TestPtySessionIteration:
|
||||
assert events[2].type == PtyEventType.exit
|
||||
assert events[2].exit_code == 0
|
||||
|
||||
def test_iter_yields_empty_binary_frame(self):
|
||||
ws = MagicMock()
|
||||
ws.receive.side_effect = [
|
||||
_bytes(b""),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert events[0].type == PtyEventType.output
|
||||
assert events[0].data == b""
|
||||
|
||||
def test_iter_stops_on_fatal_error(self):
|
||||
ws = MagicMock()
|
||||
messages = [
|
||||
json.dumps({"type": "error", "data": "fatal", "fatal": True}),
|
||||
ws.receive.side_effect = [
|
||||
_text({"type": "error", "data": "fatal", "fatal": True}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert len(events) == 1
|
||||
@ -335,7 +340,7 @@ class TestPtySessionIteration:
|
||||
import httpx_ws
|
||||
|
||||
ws = MagicMock()
|
||||
ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect()
|
||||
ws.receive.side_effect = httpx_ws.WebSocketDisconnect()
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
assert events == []
|
||||
@ -344,9 +349,9 @@ class TestPtySessionIteration:
|
||||
class TestPtySessionPong:
|
||||
def test_ping_triggers_pong(self):
|
||||
ws = MagicMock()
|
||||
ws.receive_text.side_effect = [
|
||||
json.dumps({"type": "ping"}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
ws.receive.side_effect = [
|
||||
_text({"type": "ping"}),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
session = PtySession(ws, "cl-abc")
|
||||
events = list(session)
|
||||
@ -356,9 +361,9 @@ class TestPtySessionPong:
|
||||
|
||||
def test_no_pong_without_ping(self):
|
||||
ws = MagicMock()
|
||||
ws.receive_text.side_effect = [
|
||||
json.dumps({"type": "output", "data": ""}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
ws.receive.side_effect = [
|
||||
_bytes(b""),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
session = PtySession(ws, "cl-abc")
|
||||
list(session)
|
||||
@ -431,13 +436,12 @@ class TestPtySessionSendConnect:
|
||||
|
||||
class TestAsyncPtySession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_write_sends_base64(self):
|
||||
async def test_async_write_sends_binary_frame(self):
|
||||
ws = AsyncMock()
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
await session.write(b"hello")
|
||||
sent = json.loads(ws.send_text.call_args[0][0])
|
||||
assert sent["type"] == "input"
|
||||
assert base64.b64decode(sent["data"]) == b"hello"
|
||||
ws.send_bytes.assert_awaited_once_with(b"hello")
|
||||
ws.send_text.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_resize(self):
|
||||
@ -486,9 +490,9 @@ class TestAsyncPtySession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_ping_triggers_pong(self):
|
||||
ws = AsyncMock()
|
||||
ws.receive_text.side_effect = [
|
||||
json.dumps({"type": "ping"}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
ws.receive.side_effect = [
|
||||
_text({"type": "ping"}),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
events = [e async for e in session]
|
||||
@ -508,12 +512,11 @@ class TestAsyncPtySession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_iteration(self):
|
||||
ws = AsyncMock()
|
||||
messages = [
|
||||
json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
||||
json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}),
|
||||
json.dumps({"type": "exit", "exit_code": 0}),
|
||||
ws.receive.side_effect = [
|
||||
_text({"type": "started", "tag": "pty-xyz", "pid": 5}),
|
||||
_bytes(b"hi"),
|
||||
_text({"type": "exit", "exit_code": 0}),
|
||||
]
|
||||
ws.receive_text.side_effect = messages
|
||||
session = AsyncPtySession(ws, "cl-abc")
|
||||
events = []
|
||||
async for event in session:
|
||||
@ -522,6 +525,8 @@ class TestAsyncPtySession:
|
||||
assert events[0].type == PtyEventType.started
|
||||
assert session.tag == "pty-xyz"
|
||||
assert session.pid == 5
|
||||
assert events[1].type == PtyEventType.output
|
||||
assert events[1].data == b"hi"
|
||||
assert events[2].type == PtyEventType.exit
|
||||
|
||||
|
||||
|
||||
@ -7,8 +7,18 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from wrenn import Capsule, CommandResult
|
||||
from wrenn.client import WrennClient
|
||||
from wrenn.commands import CommandHandle, ProcessInfo
|
||||
from wrenn.models import Capsule as CapsuleModel, FileEntry, Status
|
||||
from wrenn.exceptions import WrennNotFoundError
|
||||
from wrenn.models import (
|
||||
Capsule as CapsuleModel,
|
||||
CapsuleMetrics,
|
||||
CapsuleStats,
|
||||
FileEntry,
|
||||
SSEEvent,
|
||||
Status,
|
||||
UsageResponse,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@ -406,3 +416,124 @@ class TestGit:
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user