test: WebSocket API test suite (19 cases)
Covers connection, protocol robustness, get_status, load_graph, stop/start_node, crash detection, and multi-client push. Runs against a live studio-manager; --skip-nodes skips tests that need node binaries and MXL. Run: ./tests/.venv/bin/python3 tests/test_ws_api.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebSocket API test suite for dmf-studio-manager.
|
||||
|
||||
Usage:
|
||||
./tests/.venv/bin/python3 tests/test_ws_api.py [options]
|
||||
|
||||
Options:
|
||||
--url URL WebSocket server URL (default: ws://localhost:7070/ws)
|
||||
--skip-nodes Skip tests that require node binaries and MXL
|
||||
|
||||
Prerequisites:
|
||||
- studio-manager running (no graph loaded, or will be reset)
|
||||
- Node binaries in the same dir as studio-manager (for node tests)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import websockets
|
||||
|
||||
# ── Minimal graph: testpattern → fakesink (no hardware needed) ────────────────
|
||||
|
||||
SIMPLE_GRAPH = {
|
||||
"nodes": [
|
||||
{"id": "src", "type": "testpattern", "params": {}},
|
||||
{"id": "sink", "type": "fakesink", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "src", "from_port": "video_flow_id",
|
||||
"to": "sink", "to_port": "video_flow_id",
|
||||
"format": {"kind": "video", "width": 1920, "height": 1080,
|
||||
"fps_num": 25, "fps_den": 1},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
EMPTY_GRAPH = {"nodes": [], "edges": []}
|
||||
|
||||
# ── Suite / reporter ──────────────────────────────────────────────────────────
|
||||
|
||||
class Suite:
|
||||
def __init__(self):
|
||||
self.passed = self.failed = self.skipped = 0
|
||||
|
||||
def ok(self, name):
|
||||
print(f" \033[32m✓\033[0m {name}")
|
||||
self.passed += 1
|
||||
|
||||
def fail(self, name, reason=""):
|
||||
msg = f": {reason}" if reason else ""
|
||||
print(f" \033[31m✗\033[0m {name}{msg}")
|
||||
self.failed += 1
|
||||
|
||||
def skip(self, name, reason=""):
|
||||
msg = f" ({reason})" if reason else ""
|
||||
print(f" \033[33m–\033[0m {name}{msg}")
|
||||
self.skipped += 1
|
||||
|
||||
def section(self, title):
|
||||
print(f"\n{title}")
|
||||
print("─" * len(title))
|
||||
|
||||
def summary(self):
|
||||
total = self.passed + self.failed + self.skipped
|
||||
parts = [f"{self.passed}/{total} passed"]
|
||||
if self.skipped:
|
||||
parts.append(f"{self.skipped} skipped")
|
||||
if self.failed:
|
||||
parts.append(f"\033[31m{self.failed} failed\033[0m")
|
||||
print("\n" + " ".join(parts))
|
||||
return self.failed == 0
|
||||
|
||||
|
||||
# ── Low-level helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async def recv(ws, timeout=3.0):
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
return json.loads(raw)
|
||||
|
||||
async def send_recv(ws, payload, timeout=3.0):
|
||||
await ws.send(json.dumps(payload))
|
||||
return await recv(ws, timeout)
|
||||
|
||||
async def open_ws(url):
|
||||
return await websockets.connect(url)
|
||||
|
||||
async def reset(ws):
|
||||
"""Load empty graph to clear all nodes between tests."""
|
||||
r = await send_recv(ws, {"type": "load_graph", "graph": EMPTY_GRAPH})
|
||||
return r
|
||||
|
||||
def is_status(msg):
|
||||
return isinstance(msg, dict) and msg.get("type") == "status"
|
||||
|
||||
def is_error(msg):
|
||||
return isinstance(msg, dict) and msg.get("type") == "error"
|
||||
|
||||
def nodes_by_id(status):
|
||||
return {n["id"]: n for n in status.get("nodes", [])}
|
||||
|
||||
|
||||
# ── Test groups ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_connection_tests(url, s):
|
||||
s.section("Connection")
|
||||
|
||||
# 1. Connect → immediate status push
|
||||
ws = await open_ws(url)
|
||||
try:
|
||||
msg = await recv(ws, timeout=3.0)
|
||||
if is_status(msg) and "nodes" in msg:
|
||||
s.ok("connect → immediate status received")
|
||||
else:
|
||||
s.fail("connect → immediate status received", f"got {msg}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("connect → immediate status received", "timeout")
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
# 2. Two clients connect → both receive status independently
|
||||
ws1 = await open_ws(url)
|
||||
ws2 = await open_ws(url)
|
||||
try:
|
||||
m1 = await recv(ws1, timeout=3.0)
|
||||
m2 = await recv(ws2, timeout=3.0)
|
||||
if is_status(m1) and is_status(m2):
|
||||
s.ok("two clients connect → both receive status")
|
||||
else:
|
||||
s.fail("two clients connect → both receive status",
|
||||
f"ws1={m1.get('type')} ws2={m2.get('type')}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("two clients connect → both receive status", "timeout")
|
||||
finally:
|
||||
await ws1.close()
|
||||
await ws2.close()
|
||||
|
||||
|
||||
async def run_protocol_tests(url, s):
|
||||
s.section("Protocol robustness")
|
||||
|
||||
ws = await open_ws(url)
|
||||
await recv(ws) # discard initial status
|
||||
|
||||
# 3. Invalid JSON → error, connection stays open
|
||||
await ws.send("not json {{")
|
||||
try:
|
||||
msg = await recv(ws, timeout=3.0)
|
||||
if is_error(msg):
|
||||
s.ok("invalid JSON → error response")
|
||||
else:
|
||||
s.fail("invalid JSON → error response", f"got type={msg.get('type')}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("invalid JSON → error response", "timeout")
|
||||
|
||||
# 4. Unknown command type → error, connection stays open
|
||||
try:
|
||||
msg = await send_recv(ws, {"type": "explode"})
|
||||
if is_error(msg):
|
||||
s.ok("unknown command → error response")
|
||||
else:
|
||||
s.fail("unknown command → error response", f"got type={msg.get('type')}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("unknown command → error response", "timeout")
|
||||
|
||||
# 5. Connection still alive after errors
|
||||
try:
|
||||
msg = await send_recv(ws, {"type": "get_status"})
|
||||
if is_status(msg):
|
||||
s.ok("connection alive after error responses")
|
||||
else:
|
||||
s.fail("connection alive after error responses", f"got {msg}")
|
||||
except Exception as e:
|
||||
s.fail("connection alive after error responses", str(e))
|
||||
|
||||
# 6. load_graph with missing 'graph' key → error
|
||||
try:
|
||||
msg = await send_recv(ws, {"type": "load_graph"})
|
||||
if is_error(msg):
|
||||
s.ok("load_graph missing 'graph' key → error")
|
||||
else:
|
||||
s.fail("load_graph missing 'graph' key → error", f"got {msg}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("load_graph missing 'graph' key → error", "timeout")
|
||||
|
||||
# 7. stop_node with missing 'id' key → error
|
||||
try:
|
||||
msg = await send_recv(ws, {"type": "stop_node"})
|
||||
if is_error(msg):
|
||||
s.ok("stop_node missing 'id' key → error")
|
||||
else:
|
||||
s.fail("stop_node missing 'id' key → error", f"got {msg}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("stop_node missing 'id' key → error", "timeout")
|
||||
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def run_status_tests(url, s):
|
||||
s.section("get_status")
|
||||
|
||||
ws = await open_ws(url)
|
||||
push = await recv(ws)
|
||||
|
||||
# 8. get_status response matches last push
|
||||
try:
|
||||
reply = await send_recv(ws, {"type": "get_status"})
|
||||
if reply.get("nodes") == push.get("nodes"):
|
||||
s.ok("get_status matches initial push")
|
||||
else:
|
||||
s.fail("get_status matches initial push",
|
||||
f"push={push['nodes']} reply={reply['nodes']}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("get_status matches initial push", "timeout")
|
||||
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def run_node_tests(url, s):
|
||||
s.section("Graph and node lifecycle (requires node binaries + MXL)")
|
||||
|
||||
ws = await open_ws(url)
|
||||
await recv(ws) # discard initial status
|
||||
|
||||
# 9. load_graph with valid simple graph → nodes Running
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
|
||||
nodes = nodes_by_id(r)
|
||||
if (is_status(r)
|
||||
and "src" in nodes and nodes["src"]["state"] == "running"
|
||||
and "sink" in nodes and nodes["sink"]["state"] == "running"):
|
||||
s.ok("load valid graph → nodes running")
|
||||
else:
|
||||
s.fail("load valid graph → nodes running",
|
||||
f"states={[(n['id'], n['state']) for n in r.get('nodes', [])]}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("load valid graph → nodes running", "timeout")
|
||||
|
||||
# 10. get_status consistent with load_graph response
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "get_status"})
|
||||
nodes = nodes_by_id(r)
|
||||
if "src" in nodes and "sink" in nodes:
|
||||
s.ok("get_status consistent after load")
|
||||
else:
|
||||
s.fail("get_status consistent after load", f"nodes={list(nodes.keys())}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("get_status consistent after load", "timeout")
|
||||
|
||||
# 11. load_graph again → previous nodes stopped, new ones started
|
||||
try:
|
||||
r1 = await send_recv(ws, {"type": "get_status"})
|
||||
old_pids = {n["id"]: n["pid"] for n in r1.get("nodes", [])}
|
||||
|
||||
r2 = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
|
||||
new_nodes = nodes_by_id(r2)
|
||||
new_pids = {nid: n["pid"] for nid, n in new_nodes.items()}
|
||||
|
||||
if (all(n["state"] == "running" for n in new_nodes.values())
|
||||
and any(new_pids.get(k) != old_pids.get(k) for k in old_pids)):
|
||||
s.ok("reload graph → new PIDs, all running")
|
||||
else:
|
||||
s.fail("reload graph → new PIDs, all running",
|
||||
f"old={old_pids} new={new_pids}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("reload graph → new PIDs, all running", "timeout")
|
||||
|
||||
# 12. stop_node → state becomes stopped
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "stop_node", "id": "src"})
|
||||
nodes = nodes_by_id(r)
|
||||
if nodes.get("src", {}).get("state") == "stopped":
|
||||
s.ok("stop_node → state=stopped")
|
||||
else:
|
||||
s.fail("stop_node → state=stopped",
|
||||
f"state={nodes.get('src', {}).get('state')}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("stop_node → state=stopped", "timeout")
|
||||
|
||||
# 13. stop already-stopped node → no crash, state still stopped
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "stop_node", "id": "src"})
|
||||
nodes = nodes_by_id(r)
|
||||
if is_status(r) and nodes.get("src", {}).get("state") == "stopped":
|
||||
s.ok("stop already-stopped node → graceful no-op")
|
||||
else:
|
||||
s.fail("stop already-stopped node → graceful no-op", f"got {r}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("stop already-stopped node → graceful no-op", "timeout")
|
||||
|
||||
# 14. stop nonexistent node → error or status (not a crash)
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "stop_node", "id": "does-not-exist"})
|
||||
if is_status(r) or is_error(r):
|
||||
s.ok("stop nonexistent node → no crash")
|
||||
else:
|
||||
s.fail("stop nonexistent node → no crash", f"got {r}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("stop nonexistent node → no crash", "timeout")
|
||||
|
||||
# 15. start_node → state becomes running
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "start_node", "id": "src"}, timeout=5.0)
|
||||
nodes = nodes_by_id(r)
|
||||
if nodes.get("src", {}).get("state") == "running":
|
||||
s.ok("start_node → state=running")
|
||||
else:
|
||||
s.fail("start_node → state=running",
|
||||
f"state={nodes.get('src', {}).get('state')}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("start_node → state=running", "timeout")
|
||||
|
||||
# 16. start already-running node → graceful no-op
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "start_node", "id": "src"})
|
||||
nodes = nodes_by_id(r)
|
||||
if is_status(r) and nodes.get("src", {}).get("state") == "running":
|
||||
s.ok("start already-running node → graceful no-op")
|
||||
else:
|
||||
s.fail("start already-running node → graceful no-op", f"got {r}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("start already-running node → graceful no-op", "timeout")
|
||||
|
||||
# 17. Node crash detection: kill a node externally → pushed as crashed
|
||||
try:
|
||||
status = await send_recv(ws, {"type": "get_status"})
|
||||
src_pid = nodes_by_id(status).get("src", {}).get("pid", -1)
|
||||
if src_pid > 0:
|
||||
os.kill(src_pid, signal.SIGKILL)
|
||||
# Monitor polls every 500ms; wait up to 2s for crash push
|
||||
deadline = asyncio.get_event_loop().time() + 2.0
|
||||
crashed = False
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
push = await recv(ws, timeout=1.5)
|
||||
if is_status(push):
|
||||
node = nodes_by_id(push).get("src", {})
|
||||
if node.get("state") == "crashed":
|
||||
crashed = True
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if crashed:
|
||||
s.ok("crashed node detected and pushed within 2s")
|
||||
else:
|
||||
s.fail("crashed node detected and pushed within 2s", "no push received")
|
||||
else:
|
||||
s.fail("crashed node detected and pushed within 2s", "couldn't get src pid")
|
||||
except Exception as e:
|
||||
s.fail("crashed node detected and pushed within 2s", str(e))
|
||||
|
||||
# 18. Push sent to all connected clients simultaneously
|
||||
ws2 = await open_ws(url)
|
||||
await recv(ws2) # discard initial status
|
||||
try:
|
||||
r = await send_recv(ws, {"type": "load_graph", "graph": SIMPLE_GRAPH}, timeout=10.0)
|
||||
# ws gets the response; ws2 should also get a push
|
||||
if is_status(r):
|
||||
try:
|
||||
push2 = await recv(ws2, timeout=3.0)
|
||||
if is_status(push2) and len(push2.get("nodes", [])) > 0:
|
||||
s.ok("load_graph push reaches all connected clients")
|
||||
else:
|
||||
s.fail("load_graph push reaches all connected clients",
|
||||
f"ws2 got {push2}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("load_graph push reaches all connected clients",
|
||||
"ws2 got no push")
|
||||
else:
|
||||
s.fail("load_graph push reaches all connected clients",
|
||||
f"load response was {r}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("load_graph push reaches all connected clients", "timeout on load")
|
||||
finally:
|
||||
await ws2.close()
|
||||
|
||||
# 19. Unknown node type → node quickly transitions to crashed
|
||||
try:
|
||||
r = await send_recv(ws,
|
||||
{"type": "load_graph", "graph": {
|
||||
"nodes": [{"id": "bad", "type": "nonexistent-node-type", "params": {}}],
|
||||
"edges": []
|
||||
}}, timeout=5.0)
|
||||
# The load itself may succeed (fork happens), but the node should crash fast
|
||||
if is_status(r):
|
||||
nodes = nodes_by_id(r)
|
||||
initial_state = nodes.get("bad", {}).get("state")
|
||||
if initial_state == "crashed":
|
||||
s.ok("unknown node type → immediately crashed")
|
||||
else:
|
||||
# Wait for crash push (exec fails → child exits → monitor detects)
|
||||
try:
|
||||
push = await recv(ws, timeout=2.0)
|
||||
if is_status(push) and nodes_by_id(push).get("bad", {}).get("state") == "crashed":
|
||||
s.ok("unknown node type → crashes quickly")
|
||||
else:
|
||||
s.fail("unknown node type → crashes quickly",
|
||||
f"initial={initial_state}, push={push}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("unknown node type → crashes quickly",
|
||||
f"initial state={initial_state}, no crash push")
|
||||
else:
|
||||
s.fail("unknown node type → crashes quickly", f"load returned {r}")
|
||||
except asyncio.TimeoutError:
|
||||
s.fail("unknown node type → crashes quickly", "timeout")
|
||||
|
||||
# Cleanup
|
||||
await reset(ws)
|
||||
await ws.close()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def main(url, skip_nodes):
|
||||
print(f"studio-manager WebSocket API tests")
|
||||
print(f"server: {url}")
|
||||
|
||||
s = Suite()
|
||||
|
||||
try:
|
||||
await run_connection_tests(url, s)
|
||||
await run_protocol_tests(url, s)
|
||||
await run_status_tests(url, s)
|
||||
if skip_nodes:
|
||||
s.section("Graph and node lifecycle (requires node binaries + MXL)")
|
||||
for name in [
|
||||
"load valid graph → nodes running",
|
||||
"get_status consistent after load",
|
||||
"reload graph → new PIDs, all running",
|
||||
"stop_node → state=stopped",
|
||||
"stop already-stopped node → graceful no-op",
|
||||
"stop nonexistent node → no crash",
|
||||
"start_node → state=running",
|
||||
"start already-running node → graceful no-op",
|
||||
"crashed node detected and pushed within 2s",
|
||||
"load_graph push reaches all connected clients",
|
||||
"unknown node type → crashes quickly",
|
||||
]:
|
||||
s.skip(name, "--skip-nodes")
|
||||
else:
|
||||
await run_node_tests(url, s)
|
||||
except OSError as e:
|
||||
print(f"\n\033[31mCannot connect to {url}: {e}\033[0m")
|
||||
print("Is studio-manager running?")
|
||||
sys.exit(2)
|
||||
|
||||
s.summary()
|
||||
sys.exit(0 if s.failed == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--url", default="ws://localhost:7070/ws",
|
||||
help="WebSocket server URL")
|
||||
parser.add_argument("--skip-nodes", action="store_true",
|
||||
help="Skip tests requiring node binaries and MXL")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(args.url, args.skip_nodes))
|
||||
Reference in New Issue
Block a user