* stage v0.51.348: Phase 0 hotfix — approval/clarify timeout regression (#3913), queue/draft durability (#3906), settings auto-reopen (#3909), kanban FD leak (#3904) * stage v0.51.348: re-anchor 4 SSE frontend tests to poll-only design (#3913); apply Opus SHOULD-FIX — immediate first poll tick so pending approval/clarify cards show instantly --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""Tests for clarify SSE long-connection (mirrors test_approval_sse.py structure).
|
|
|
|
Covers:
|
|
- Static analysis of backend and frontend code
|
|
- Unit tests for subscribe/unsubscribe/notify lifecycle
|
|
- Concurrency safety of subscriber registry
|
|
"""
|
|
|
|
import os
|
|
import queue
|
|
import threading
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
# ── Paths ────────────────────────────────────────────────────────────────────
|
|
_ROUTES = os.path.join(os.path.dirname(__file__), "..", "api", "routes.py")
|
|
_CLARIFY = os.path.join(os.path.dirname(__file__), "..", "api", "clarify.py")
|
|
_MESSAGES = os.path.join(os.path.dirname(__file__), "..", "static", "messages.js")
|
|
|
|
|
|
def _read(path):
|
|
with open(path) as f:
|
|
return f.read()
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# 1. Static analysis — verify code structure without importing the server
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
@pytest.mark.parametrize("marker", [
|
|
"_clarify_sse_subscribers",
|
|
"def sse_subscribe",
|
|
"def sse_unsubscribe",
|
|
"_clarify_sse_notify",
|
|
])
|
|
class TestClarifySSEBackendMarkers:
|
|
def test_clarify_module_has_marker(self, marker):
|
|
src = _read(_CLARIFY)
|
|
assert marker in src, f"api/clarify.py missing: {marker}"
|
|
|
|
|
|
class TestClarifySSEBackendCode:
|
|
def test_submit_pending_calls_notify(self):
|
|
src = _read(_CLARIFY)
|
|
assert "_clarify_sse_notify(" in src, (
|
|
"submit_pending should call _clarify_sse_notify inside _lock"
|
|
)
|
|
|
|
def test_resolve_clarify_calls_notify(self):
|
|
src = _read(_CLARIFY)
|
|
# Count occurrences — resolve should notify on both branches
|
|
assert src.count("_clarify_sse_notify(") >= 2, (
|
|
"resolve_clarify should call _clarify_sse_notify for both queue-has-more and empty cases"
|
|
)
|
|
|
|
|
|
class TestClarifySSERoutesCode:
|
|
def test_route_registered(self):
|
|
src = _read(_ROUTES)
|
|
assert '"/api/clarify/stream"' in src, "Missing /api/clarify/stream route"
|
|
|
|
def test_handler_function_exists(self):
|
|
src = _read(_ROUTES)
|
|
assert "def _handle_clarify_sse_stream(" in src
|
|
|
|
def test_imports_sse_subscribe(self):
|
|
src = _read(_ROUTES)
|
|
assert "clarify_sse_subscribe" in src
|
|
|
|
def test_imports_sse_unsubscribe(self):
|
|
src = _read(_ROUTES)
|
|
assert "clarify_sse_unsubscribe" in src
|
|
|
|
|
|
class TestClarifySSEFrontendCode:
|
|
"""Frontend clarify transport.
|
|
|
|
As of #3913 the frontend no longer opens a clarify-stream EventSource
|
|
(it was one of six persistent SSE streams that exhausted the browser's
|
|
6-per-origin HTTP/1.1 pool). ``startClarifyPolling`` now routes straight
|
|
to the HTTP fallback poller. The backend SSE route stays for compatibility
|
|
(tests above); these pin the poll-only frontend against regression.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _load_js(self):
|
|
self.js = _read(_MESSAGES)
|
|
|
|
def _clarify_polling_body(self):
|
|
start = self.js.index("function startClarifyPolling(")
|
|
end = self.js.index("\nfunction ", start + 1)
|
|
return self.js[start:end]
|
|
|
|
def test_does_not_open_clarify_stream(self):
|
|
body = self._clarify_polling_body()
|
|
assert "api/clarify/stream" not in body, \
|
|
"Frontend must not open the clarify-stream EventSource (conn-pool exhaustion, #3913)"
|
|
assert "new EventSource" not in body, \
|
|
"startClarifyPolling must not construct an EventSource — it polls over HTTP now"
|
|
|
|
def test_routes_directly_to_fallback_poll(self):
|
|
body = self._clarify_polling_body()
|
|
assert "_startClarifyFallbackPoll(sid)" in body, \
|
|
"startClarifyPolling must route to the HTTP fallback poller"
|
|
|
|
def test_frontend_has_fallback_poll(self):
|
|
assert "_startClarifyFallbackPoll" in self.js or "_clarifyFallbackTimer" in self.js
|
|
|
|
def test_fallback_poll_hits_pending_endpoint(self):
|
|
assert 'api("/api/clarify/pending?session_id="' in self.js, \
|
|
"Clarify fallback poll must query /api/clarify/pending"
|
|
assert "EventSource('/api/clarify/stream" not in self.js, \
|
|
"No root-absolute clarify EventSource may remain (subpath-mount safety)"
|
|
|
|
def test_frontend_fallback_interval_3s(self):
|
|
# Fallback poll interval should be 3000ms
|
|
assert "3000" in self.js
|
|
|
|
def test_frontend_stop_defensively_closes_event_source(self):
|
|
# _clarifyEventSource stays declared (always null now) with a
|
|
# null-guarded close() so any future re-introduction stays safe.
|
|
assert "_clarifyEventSource" in self.js
|
|
assert ".close()" in self.js
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# 2. Unit tests — import clarify module directly
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
@pytest.fixture()
|
|
def clarify_mod():
|
|
"""Import api.clarify fresh (module-level state is shared)."""
|
|
from api import clarify
|
|
return clarify
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cleanup_subscribers(clarify_mod):
|
|
"""Clear SSE subscribers between tests to avoid leakage."""
|
|
yield
|
|
clarify_mod._clarify_sse_subscribers.clear()
|
|
|
|
|
|
class TestClarifySSEUnit:
|
|
def test_subscribe_returns_queue(self, clarify_mod):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
assert isinstance(q, queue.Queue)
|
|
assert q.maxsize == 16
|
|
|
|
def test_unsubscribe_removes_queue(self, clarify_mod):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.sse_unsubscribe("s1", q)
|
|
assert "s1" not in clarify_mod._clarify_sse_subscribers
|
|
|
|
def test_unsubscribe_cleans_empty_session(self, clarify_mod):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.sse_unsubscribe("s1", q)
|
|
assert "s1" not in clarify_mod._clarify_sse_subscribers
|
|
|
|
def test_unsubscribe_unknown_queue_no_error(self, clarify_mod):
|
|
q = queue.Queue()
|
|
clarify_mod.sse_unsubscribe("s1", q) # should not raise
|
|
|
|
def test_multiple_subscribers_same_session(self, clarify_mod):
|
|
q1 = clarify_mod.sse_subscribe("s1")
|
|
q2 = clarify_mod.sse_subscribe("s1")
|
|
assert len(clarify_mod._clarify_sse_subscribers["s1"]) == 2
|
|
clarify_mod.sse_unsubscribe("s1", q1)
|
|
assert len(clarify_mod._clarify_sse_subscribers["s1"]) == 1
|
|
|
|
def test_notify_delivers_to_all_subscribers(self, clarify_mod):
|
|
q1 = clarify_mod.sse_subscribe("s1")
|
|
q2 = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod._clarify_sse_notify("s1", {"question": "test?"}, 1)
|
|
assert q1.get(timeout=1)["pending"]["question"] == "test?"
|
|
assert q2.get(timeout=1)["pending"]["question"] == "test?"
|
|
|
|
def test_cross_session_isolation(self, clarify_mod):
|
|
q1 = clarify_mod.sse_subscribe("s1")
|
|
q2 = clarify_mod.sse_subscribe("s2")
|
|
clarify_mod._clarify_sse_notify("s1", {"question": "q1"}, 1)
|
|
assert q1.get(timeout=1)["pending"]["question"] == "q1"
|
|
assert q2.empty()
|
|
|
|
def test_queue_overflow_drops_silently(self, clarify_mod):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
for i in range(20): # maxsize=16
|
|
clarify_mod._clarify_sse_notify("s1", {"q": i}, i + 1)
|
|
# Should not raise; some messages dropped
|
|
count = 0
|
|
while not q.empty():
|
|
q.get_nowait()
|
|
count += 1
|
|
assert count <= 16
|
|
|
|
def test_submit_pending_triggers_notify(self, clarify_mod):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.submit_pending("s1", {"question": "hello?", "choices_offered": []})
|
|
payload = q.get(timeout=1)
|
|
assert payload["pending"] is not None
|
|
assert payload["pending"]["question"] == "hello?"
|
|
assert payload["pending_count"] == 1
|
|
|
|
def test_unsubscribe_mid_notify_safe(self, clarify_mod):
|
|
q1 = clarify_mod.sse_subscribe("s1")
|
|
q2 = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.sse_unsubscribe("s1", q2)
|
|
clarify_mod._clarify_sse_notify("s1", {"question": "safe?"}, 1)
|
|
assert q1.get(timeout=1)["pending"]["question"] == "safe?"
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# 3. Concurrency tests
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
class TestClarifySSEConcurrency:
|
|
def test_concurrent_subscribe_unsubscribe(self, clarify_mod):
|
|
errors = []
|
|
|
|
def worker():
|
|
try:
|
|
for _ in range(50):
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.sse_unsubscribe("s1", q)
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(4)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=5)
|
|
assert not errors
|
|
|
|
def test_concurrent_notify_and_subscribe(self, clarify_mod):
|
|
received = []
|
|
lock = threading.Lock()
|
|
q = clarify_mod.sse_subscribe("s1")
|
|
|
|
def notifier():
|
|
for i in range(20):
|
|
clarify_mod._clarify_sse_notify("s1", {"q": i}, i + 1)
|
|
|
|
def subscriber():
|
|
for _ in range(20):
|
|
q2 = clarify_mod.sse_subscribe("s1")
|
|
clarify_mod.sse_unsubscribe("s1", q2)
|
|
|
|
t1 = threading.Thread(target=notifier)
|
|
t2 = threading.Thread(target=subscriber)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=5)
|
|
t2.join(timeout=5)
|
|
# Just verify no crash — some events may have been dropped
|
|
while not q.empty():
|
|
payload = q.get_nowait()
|
|
with lock:
|
|
received.append(payload)
|
|
# Should have received at least some events
|
|
assert len(received) > 0
|