|
| 1 | +"""Tests for ``core.ws_manager`` (issue #23 / M4.1). |
| 2 | +
|
| 3 | +Covers: |
| 4 | +- ``broadcast`` reaches every connected socket. |
| 5 | +- Closed/dead sockets are silently dropped, not raised to the caller. |
| 6 | +- ``current_turn_id`` ContextVar is auto-injected when set, omitted when not. |
| 7 | +- Smoke test: a broadcast reaches a connected websocket within 100 ms |
| 8 | + (acceptance addition from issue #23 comment 1). |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import asyncio |
| 14 | +import json |
| 15 | +import time |
| 16 | +from typing import Any |
| 17 | + |
| 18 | +import pytest |
| 19 | +from core.ws_manager import WSManager, current_turn_id, ws_manager |
| 20 | + |
| 21 | + |
| 22 | +class _FakeWS: |
| 23 | + """Stand-in for ``starlette.websockets.WebSocket``. |
| 24 | +
|
| 25 | + Mimics only the surface ``WSManager`` touches: ``accept``, ``send_text``, |
| 26 | + ``close``, plus a ``client_state`` attribute compared against |
| 27 | + ``WebSocketState.CONNECTED``. |
| 28 | + """ |
| 29 | + |
| 30 | + def __init__(self, *, fail_on_send: bool = False, disconnected: bool = False) -> None: |
| 31 | + self.sent: list[str] = [] |
| 32 | + self.accepted = False |
| 33 | + self.fail_on_send = fail_on_send |
| 34 | + # Mimic starlette's enum value comparison via duck typing — anything |
| 35 | + # not equal to the imported CONNECTED sentinel will be treated as dead. |
| 36 | + from starlette.websockets import WebSocketState |
| 37 | + |
| 38 | + self.client_state = ( |
| 39 | + WebSocketState.DISCONNECTED if disconnected else WebSocketState.CONNECTED |
| 40 | + ) |
| 41 | + |
| 42 | + async def accept(self) -> None: |
| 43 | + self.accepted = True |
| 44 | + |
| 45 | + async def send_text(self, text: str) -> None: |
| 46 | + if self.fail_on_send: |
| 47 | + raise RuntimeError("socket exploded") |
| 48 | + self.sent.append(text) |
| 49 | + |
| 50 | + |
| 51 | +@pytest.fixture |
| 52 | +def mgr() -> WSManager: |
| 53 | + return WSManager() |
| 54 | + |
| 55 | + |
| 56 | +@pytest.mark.asyncio |
| 57 | +async def test_connect_accepts_and_registers(mgr: WSManager) -> None: |
| 58 | + ws = _FakeWS() |
| 59 | + await mgr.connect(ws) # type: ignore[arg-type] |
| 60 | + assert ws.accepted is True |
| 61 | + assert ws in mgr.connections |
| 62 | + |
| 63 | + |
| 64 | +def test_disconnect_is_idempotent(mgr: WSManager) -> None: |
| 65 | + ws = _FakeWS() |
| 66 | + mgr._connections.add(ws) # type: ignore[arg-type] |
| 67 | + mgr.disconnect(ws) # type: ignore[arg-type] |
| 68 | + mgr.disconnect(ws) # type: ignore[arg-type] # no raise |
| 69 | + assert ws not in mgr.connections |
| 70 | + |
| 71 | + |
| 72 | +@pytest.mark.asyncio |
| 73 | +async def test_broadcast_fans_out_to_all_sockets(mgr: WSManager) -> None: |
| 74 | + a, b = _FakeWS(), _FakeWS() |
| 75 | + await mgr.connect(a) # type: ignore[arg-type] |
| 76 | + await mgr.connect(b) # type: ignore[arg-type] |
| 77 | + |
| 78 | + await mgr.broadcast("anomaly_detected", {"cell_id": 2, "value": 9.1}) |
| 79 | + |
| 80 | + for ws in (a, b): |
| 81 | + assert len(ws.sent) == 1 |
| 82 | + frame = json.loads(ws.sent[0]) |
| 83 | + assert frame == {"type": "anomaly_detected", "cell_id": 2, "value": 9.1} |
| 84 | + |
| 85 | + |
| 86 | +@pytest.mark.asyncio |
| 87 | +async def test_broadcast_drops_failing_sockets(mgr: WSManager) -> None: |
| 88 | + good = _FakeWS() |
| 89 | + bad = _FakeWS(fail_on_send=True) |
| 90 | + await mgr.connect(good) # type: ignore[arg-type] |
| 91 | + await mgr.connect(bad) # type: ignore[arg-type] |
| 92 | + |
| 93 | + await mgr.broadcast("ui_render", {"agent": "kb_builder"}) |
| 94 | + |
| 95 | + assert good in mgr.connections |
| 96 | + assert bad not in mgr.connections |
| 97 | + assert len(good.sent) == 1 |
| 98 | + |
| 99 | + |
| 100 | +@pytest.mark.asyncio |
| 101 | +async def test_broadcast_drops_disconnected_sockets(mgr: WSManager) -> None: |
| 102 | + dead = _FakeWS(disconnected=True) |
| 103 | + await mgr.connect(dead) # type: ignore[arg-type] |
| 104 | + await mgr.broadcast("agent_end", {"agent": "x", "finish_reason": "ok"}) |
| 105 | + assert dead not in mgr.connections |
| 106 | + assert dead.sent == [] # send_text was never even attempted |
| 107 | + |
| 108 | + |
| 109 | +@pytest.mark.asyncio |
| 110 | +async def test_broadcast_no_connections_is_noop(mgr: WSManager) -> None: |
| 111 | + # Must not raise even when nobody is listening. |
| 112 | + await mgr.broadcast("agent_start", {"agent": "x"}) |
| 113 | + |
| 114 | + |
| 115 | +@pytest.mark.asyncio |
| 116 | +async def test_broadcast_injects_turn_id_from_contextvar(mgr: WSManager) -> None: |
| 117 | + ws = _FakeWS() |
| 118 | + await mgr.connect(ws) # type: ignore[arg-type] |
| 119 | + |
| 120 | + token = current_turn_id.set("abc-123") |
| 121 | + try: |
| 122 | + await mgr.broadcast("ui_render", {"agent": "kb_builder"}) |
| 123 | + finally: |
| 124 | + current_turn_id.reset(token) |
| 125 | + |
| 126 | + frame = json.loads(ws.sent[0]) |
| 127 | + assert frame["turn_id"] == "abc-123" |
| 128 | + |
| 129 | + |
| 130 | +@pytest.mark.asyncio |
| 131 | +async def test_broadcast_omits_turn_id_when_not_set(mgr: WSManager) -> None: |
| 132 | + ws = _FakeWS() |
| 133 | + await mgr.connect(ws) # type: ignore[arg-type] |
| 134 | + await mgr.broadcast("ui_render", {"agent": "kb_builder"}) |
| 135 | + frame = json.loads(ws.sent[0]) |
| 136 | + assert "turn_id" not in frame |
| 137 | + |
| 138 | + |
| 139 | +@pytest.mark.asyncio |
| 140 | +async def test_broadcast_explicit_turn_id_is_preserved(mgr: WSManager) -> None: |
| 141 | + ws = _FakeWS() |
| 142 | + await mgr.connect(ws) # type: ignore[arg-type] |
| 143 | + |
| 144 | + token = current_turn_id.set("from-context") |
| 145 | + try: |
| 146 | + await mgr.broadcast("ui_render", {"agent": "x", "turn_id": "explicit"}) |
| 147 | + finally: |
| 148 | + current_turn_id.reset(token) |
| 149 | + |
| 150 | + frame = json.loads(ws.sent[0]) |
| 151 | + assert frame["turn_id"] == "explicit" |
| 152 | + |
| 153 | + |
| 154 | +@pytest.mark.asyncio |
| 155 | +async def test_module_singleton_is_reusable() -> None: |
| 156 | + """Acceptance smoke test (#23 comment 1) — a broadcast on the shipped |
| 157 | + ``ws_manager`` singleton reaches a connected socket within 100 ms. |
| 158 | + """ |
| 159 | + ws = _FakeWS() |
| 160 | + await ws_manager.connect(ws) # type: ignore[arg-type] |
| 161 | + try: |
| 162 | + start = time.perf_counter() |
| 163 | + await asyncio.wait_for( |
| 164 | + ws_manager.broadcast("agent_start", {"agent": "smoke"}), |
| 165 | + timeout=0.1, |
| 166 | + ) |
| 167 | + elapsed_ms = (time.perf_counter() - start) * 1000 |
| 168 | + finally: |
| 169 | + ws_manager.disconnect(ws) # type: ignore[arg-type] |
| 170 | + |
| 171 | + assert elapsed_ms < 100 |
| 172 | + assert len(ws.sent) == 1 |
| 173 | + frame = json.loads(ws.sent[0]) |
| 174 | + assert frame["type"] == "agent_start" |
| 175 | + |
| 176 | + |
| 177 | +@pytest.mark.asyncio |
| 178 | +async def test_broadcast_drops_unserialisable_payload(mgr: WSManager) -> None: |
| 179 | + ws = _FakeWS() |
| 180 | + await mgr.connect(ws) # type: ignore[arg-type] |
| 181 | + |
| 182 | + class _NotJSON: |
| 183 | + pass |
| 184 | + |
| 185 | + # ``default=str`` in the encoder means most things serialise; force a |
| 186 | + # genuine TypeError by passing a circular reference. |
| 187 | + bad: dict[str, Any] = {} |
| 188 | + bad["self"] = bad |
| 189 | + |
| 190 | + await mgr.broadcast("ui_render", bad) |
| 191 | + # No frame sent, no raise. |
| 192 | + assert ws.sent == [] |
| 193 | + assert ws in mgr.connections # socket itself is still healthy |
0 commit comments