tylor-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- package/ui/index.html +1314 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server/tests/test_ui_server.py — Tests for Story 6.1: Local UI Server.
|
|
3
|
+
|
|
4
|
+
Tests:
|
|
5
|
+
- GET / serves index.html (200) or 404 if missing
|
|
6
|
+
- GET /api/threads returns JSON array
|
|
7
|
+
- GET /api/threads/{id}/messages returns JSON array
|
|
8
|
+
- WS /ws/threads: initial payload on connect
|
|
9
|
+
- WS broadcast: all clients receive update
|
|
10
|
+
- Port-in-use: server starts gracefully, ui_available = False
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
import pytest
|
|
16
|
+
from unittest.mock import patch, MagicMock, AsyncMock
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from aiohttp.test_utils import AioHTTPTestCase
|
|
20
|
+
from aiohttp import web
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Fixture helpers ──────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
MOCK_THREADS = [
|
|
26
|
+
{"thread_id": "abc123", "name": "Backend", "status": "active",
|
|
27
|
+
"last_activity": "2026-05-13T10:00:00Z", "message_count": 5},
|
|
28
|
+
{"thread_id": "def456", "name": "Frontend", "status": "idle",
|
|
29
|
+
"last_activity": "2026-05-13T09:00:00Z", "message_count": 2},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
MOCK_MESSAGES = [
|
|
33
|
+
{"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
|
|
34
|
+
{"role": "assistant", "content": "Hi there", "timestamp": "2026-05-13T09:01:00Z"},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mock_list_threads():
|
|
39
|
+
return {"threads": MOCK_THREADS}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mock_fetch_messages(thread_id, limit=50):
|
|
43
|
+
if thread_id == "abc123":
|
|
44
|
+
return MOCK_MESSAGES
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Test: REST endpoints ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
class TestRestEndpoints(AioHTTPTestCase):
|
|
51
|
+
|
|
52
|
+
async def get_application(self):
|
|
53
|
+
# Patch storage calls before building the app
|
|
54
|
+
with patch("server.ui_server._fetch_threads", return_value=[
|
|
55
|
+
{"id": t["thread_id"], "title": t["name"], "status": t["status"],
|
|
56
|
+
"created_at": t["last_activity"], "message_count": t["message_count"]}
|
|
57
|
+
for t in MOCK_THREADS
|
|
58
|
+
]):
|
|
59
|
+
from ..ui_server import _make_app
|
|
60
|
+
return _make_app()
|
|
61
|
+
|
|
62
|
+
async def test_get_threads_returns_json_array(self):
|
|
63
|
+
with patch("server.ui_server._fetch_threads", return_value=[
|
|
64
|
+
{"id": "abc123", "title": "Backend", "status": "active",
|
|
65
|
+
"created_at": "2026-05-13T10:00:00Z", "message_count": 5}
|
|
66
|
+
]):
|
|
67
|
+
resp = await self.client.get("/api/threads")
|
|
68
|
+
assert resp.status == 200
|
|
69
|
+
data = await resp.json()
|
|
70
|
+
assert "projects" in data
|
|
71
|
+
thread = data["projects"][0]["threads"][0]
|
|
72
|
+
assert thread["id"] == "abc123"
|
|
73
|
+
assert thread["title"] == "Backend"
|
|
74
|
+
|
|
75
|
+
async def test_get_messages_returns_json_array(self):
|
|
76
|
+
with patch("server.ui_server._fetch_messages", return_value=MOCK_MESSAGES):
|
|
77
|
+
resp = await self.client.get("/api/threads/abc123/messages")
|
|
78
|
+
assert resp.status == 200
|
|
79
|
+
data = await resp.json()
|
|
80
|
+
assert isinstance(data, list)
|
|
81
|
+
assert data[0]["role"] == "user"
|
|
82
|
+
|
|
83
|
+
async def test_get_index_404_when_missing(self):
|
|
84
|
+
with patch.object(Path, "exists", return_value=False):
|
|
85
|
+
resp = await self.client.get("/")
|
|
86
|
+
assert resp.status == 404
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Test: WebSocket ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_websocket_initial_payload():
|
|
93
|
+
"""Client receives full thread list on connect."""
|
|
94
|
+
from ..ui_server import _make_app, WsManager
|
|
95
|
+
import aiohttp
|
|
96
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
97
|
+
|
|
98
|
+
app = _make_app()
|
|
99
|
+
|
|
100
|
+
with patch("server.ui_server._fetch_threads", return_value=[
|
|
101
|
+
{"id": "abc123", "title": "Backend", "status": "active",
|
|
102
|
+
"created_at": "2026-05-13T10:00:00Z", "message_count": 5}
|
|
103
|
+
]):
|
|
104
|
+
async with TestClient(TestServer(app)) as client:
|
|
105
|
+
ws = await client.ws_connect("/ws/threads")
|
|
106
|
+
msg = await asyncio.wait_for(ws.receive(), timeout=2.0)
|
|
107
|
+
data = json.loads(msg.data)
|
|
108
|
+
assert data["type"] == "thread_update"
|
|
109
|
+
assert "projects" in data
|
|
110
|
+
assert data["projects"][0]["threads"][0]["id"] == "abc123"
|
|
111
|
+
assert "seq" in data
|
|
112
|
+
await ws.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.asyncio
|
|
116
|
+
async def test_websocket_broadcast_reaches_all_clients():
|
|
117
|
+
"""After broadcast(), all connected clients receive the message."""
|
|
118
|
+
from ..ui_server import WsManager
|
|
119
|
+
import aiohttp
|
|
120
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
121
|
+
from ..ui_server import _make_app
|
|
122
|
+
|
|
123
|
+
app = _make_app()
|
|
124
|
+
|
|
125
|
+
with patch("server.ui_server._fetch_threads", return_value=[]):
|
|
126
|
+
async with TestClient(TestServer(app)) as client:
|
|
127
|
+
ws1 = await client.ws_connect("/ws/threads")
|
|
128
|
+
ws2 = await client.ws_connect("/ws/threads")
|
|
129
|
+
|
|
130
|
+
# Drain initial payloads
|
|
131
|
+
await asyncio.wait_for(ws1.receive(), timeout=1.0)
|
|
132
|
+
await asyncio.wait_for(ws2.receive(), timeout=1.0)
|
|
133
|
+
|
|
134
|
+
# Broadcast a custom payload
|
|
135
|
+
from ..ui_server import ws_manager
|
|
136
|
+
await ws_manager.broadcast({"type": "thread_update", "threads": [{"id": "x1"}]})
|
|
137
|
+
|
|
138
|
+
msg1 = await asyncio.wait_for(ws1.receive(), timeout=1.0)
|
|
139
|
+
msg2 = await asyncio.wait_for(ws2.receive(), timeout=1.0)
|
|
140
|
+
|
|
141
|
+
d1 = json.loads(msg1.data)
|
|
142
|
+
d2 = json.loads(msg2.data)
|
|
143
|
+
assert d1["threads"][0]["id"] == "x1"
|
|
144
|
+
assert d2["threads"][0]["id"] == "x1"
|
|
145
|
+
assert d1["seq"] == d2["seq"]
|
|
146
|
+
|
|
147
|
+
await ws1.close()
|
|
148
|
+
await ws2.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Test: Port-in-use ─────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_port_in_use_sets_ui_unavailable():
|
|
155
|
+
"""When port 8765 is occupied, start_ui_server returns None and ui_available=False."""
|
|
156
|
+
import server.ui_server as ui_mod
|
|
157
|
+
|
|
158
|
+
# Occupy the port
|
|
159
|
+
blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
160
|
+
blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
161
|
+
try:
|
|
162
|
+
blocker.bind(("localhost", 8765))
|
|
163
|
+
blocker.listen(1)
|
|
164
|
+
|
|
165
|
+
runner = await ui_mod.start_ui_server()
|
|
166
|
+
assert runner is None
|
|
167
|
+
assert ui_mod.ui_available is False
|
|
168
|
+
finally:
|
|
169
|
+
blocker.close()
|
|
170
|
+
# Reset for other tests
|
|
171
|
+
ui_mod.ui_available = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Test: WsManager ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
@pytest.mark.asyncio
|
|
177
|
+
async def test_ws_manager_drops_closed_clients():
|
|
178
|
+
"""WsManager removes clients that fail to receive."""
|
|
179
|
+
from ..ui_server import WsManager
|
|
180
|
+
|
|
181
|
+
mgr = WsManager()
|
|
182
|
+
|
|
183
|
+
# Mock a closed WebSocket
|
|
184
|
+
dead_ws = MagicMock()
|
|
185
|
+
dead_ws.closed = True
|
|
186
|
+
|
|
187
|
+
mgr.connect(dead_ws)
|
|
188
|
+
assert mgr.count == 1
|
|
189
|
+
|
|
190
|
+
await mgr.broadcast({"type": "test"})
|
|
191
|
+
# Dead client should be dropped
|
|
192
|
+
assert mgr.count == 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_ws_manager_sequence_increments():
|
|
197
|
+
"""Each broadcast increments the seq counter."""
|
|
198
|
+
from ..ui_server import WsManager
|
|
199
|
+
import server.ui_server as ui_mod
|
|
200
|
+
|
|
201
|
+
ui_mod._seq = 0
|
|
202
|
+
mgr = WsManager()
|
|
203
|
+
|
|
204
|
+
received = []
|
|
205
|
+
live_ws = MagicMock()
|
|
206
|
+
live_ws.closed = False
|
|
207
|
+
|
|
208
|
+
async def fake_send(text):
|
|
209
|
+
received.append(json.loads(text))
|
|
210
|
+
|
|
211
|
+
live_ws.send_str = fake_send
|
|
212
|
+
mgr.connect(live_ws)
|
|
213
|
+
|
|
214
|
+
await mgr.broadcast({"type": "thread_update", "threads": []})
|
|
215
|
+
await mgr.broadcast({"type": "thread_update", "threads": []})
|
|
216
|
+
|
|
217
|
+
assert received[0]["seq"] == 1
|
|
218
|
+
assert received[1]["seq"] == 2
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── Story 6.2: API shape tests ────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
FULL_MOCK_THREADS = [
|
|
224
|
+
{"id": "abc123def456abc123def456abc12345", "title": "Backend",
|
|
225
|
+
"status": "active", "created_at": "2026-05-13T10:00:00Z", "message_count": 5},
|
|
226
|
+
{"id": "def456abc123def456abc123def45678", "title": "Frontend",
|
|
227
|
+
"status": "idle", "created_at": "2026-05-13T09:00:00Z", "message_count": 2},
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_api_threads_returns_all_five_fields():
|
|
233
|
+
"""GET /api/threads returns {projects:[{id,name,threads:[...]}]} grouped by project."""
|
|
234
|
+
from ..ui_server import _make_app
|
|
235
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
236
|
+
|
|
237
|
+
app = _make_app()
|
|
238
|
+
with patch("server.ui_server._fetch_threads", return_value=FULL_MOCK_THREADS):
|
|
239
|
+
async with TestClient(TestServer(app)) as client:
|
|
240
|
+
resp = await client.get("/api/threads")
|
|
241
|
+
assert resp.status == 200
|
|
242
|
+
data = await resp.json()
|
|
243
|
+
assert "projects" in data
|
|
244
|
+
all_threads = [t for p in data["projects"] for t in p["threads"]]
|
|
245
|
+
assert len(all_threads) == 2
|
|
246
|
+
for t in all_threads:
|
|
247
|
+
assert "id" in t
|
|
248
|
+
assert "title" in t
|
|
249
|
+
assert "status" in t
|
|
250
|
+
assert "created_at" in t
|
|
251
|
+
assert "message_count" in t
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_api_threads_returns_empty_list_not_null():
|
|
256
|
+
"""GET /api/threads with no threads returns {projects:[]} not null."""
|
|
257
|
+
from ..ui_server import _make_app
|
|
258
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
259
|
+
|
|
260
|
+
app = _make_app()
|
|
261
|
+
with patch("server.ui_server._fetch_threads", return_value=[]):
|
|
262
|
+
async with TestClient(TestServer(app)) as client:
|
|
263
|
+
resp = await client.get("/api/threads")
|
|
264
|
+
assert resp.status == 200
|
|
265
|
+
data = await resp.json()
|
|
266
|
+
assert "projects" in data
|
|
267
|
+
assert data["projects"] == []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_api_thread_messages_valid_id_returns_array():
|
|
272
|
+
"""GET /api/threads/{id}/messages with valid hex id returns array."""
|
|
273
|
+
from ..ui_server import _make_app
|
|
274
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
275
|
+
|
|
276
|
+
app = _make_app()
|
|
277
|
+
valid_id = "abc123def456abc123def456abc12345"
|
|
278
|
+
mock_msgs = [
|
|
279
|
+
{"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
|
|
280
|
+
]
|
|
281
|
+
with patch("server.ui_server._fetch_messages", return_value=mock_msgs):
|
|
282
|
+
async with TestClient(TestServer(app)) as client:
|
|
283
|
+
resp = await client.get(f"/api/threads/{valid_id}/messages")
|
|
284
|
+
assert resp.status == 200
|
|
285
|
+
data = await resp.json()
|
|
286
|
+
assert isinstance(data, list)
|
|
287
|
+
assert data[0]["role"] == "user"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ── Story 6.4: message shape tests ───────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
@pytest.mark.asyncio
|
|
293
|
+
async def test_api_thread_messages_returns_role_content_timestamp():
|
|
294
|
+
"""GET /api/threads/{id}/messages returns [{role, content, timestamp}]."""
|
|
295
|
+
from ..ui_server import _make_app
|
|
296
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
297
|
+
|
|
298
|
+
app = _make_app()
|
|
299
|
+
valid_id = "abc123def456abc123def456abc12345"
|
|
300
|
+
mock_msgs = [
|
|
301
|
+
{"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
|
|
302
|
+
{"role": "assistant", "content": "Hi there", "timestamp": "2026-05-13T09:01:00Z"},
|
|
303
|
+
]
|
|
304
|
+
with patch("server.ui_server._fetch_messages", return_value=mock_msgs):
|
|
305
|
+
async with TestClient(TestServer(app)) as client:
|
|
306
|
+
resp = await client.get(f"/api/threads/{valid_id}/messages")
|
|
307
|
+
assert resp.status == 200
|
|
308
|
+
data = await resp.json()
|
|
309
|
+
assert isinstance(data, list)
|
|
310
|
+
for msg in data:
|
|
311
|
+
assert "role" in msg
|
|
312
|
+
assert "content" in msg
|
|
313
|
+
assert "timestamp" in msg
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@pytest.mark.asyncio
|
|
317
|
+
async def test_api_thread_messages_before_param_accepted():
|
|
318
|
+
"""GET /api/threads/{id}/messages?before=<ts> is accepted (pagination)."""
|
|
319
|
+
from ..ui_server import _make_app
|
|
320
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
321
|
+
|
|
322
|
+
app = _make_app()
|
|
323
|
+
valid_id = "abc123def456abc123def456abc12345"
|
|
324
|
+
with patch("server.ui_server._fetch_messages", return_value=[]):
|
|
325
|
+
async with TestClient(TestServer(app)) as client:
|
|
326
|
+
resp = await client.get(
|
|
327
|
+
f"/api/threads/{valid_id}/messages?before=2026-05-13T09:00:00Z"
|
|
328
|
+
)
|
|
329
|
+
assert resp.status == 200
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ── Story 6.5: Live state sync tests ─────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
@pytest.mark.asyncio
|
|
335
|
+
async def test_ws_thread_update_broadcasts_to_all_clients():
|
|
336
|
+
"""After a thread state change, all WS clients receive thread_update."""
|
|
337
|
+
from ..ui_server import _make_app, ws_manager
|
|
338
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
339
|
+
import server.ui_server as ui_mod
|
|
340
|
+
|
|
341
|
+
app = _make_app()
|
|
342
|
+
with patch("server.ui_server._fetch_threads", return_value=[
|
|
343
|
+
{"id": "abc123", "title": "Backend", "status": "active",
|
|
344
|
+
"created_at": "2026-05-13T10:00:00Z", "message_count": 5}
|
|
345
|
+
]):
|
|
346
|
+
async with TestClient(TestServer(app)) as client:
|
|
347
|
+
ws1 = await client.ws_connect("/ws/threads")
|
|
348
|
+
ws2 = await client.ws_connect("/ws/threads")
|
|
349
|
+
# Drain initial payloads
|
|
350
|
+
await asyncio.wait_for(ws1.receive(), timeout=1.0)
|
|
351
|
+
await asyncio.wait_for(ws2.receive(), timeout=1.0)
|
|
352
|
+
|
|
353
|
+
# Simulate thread status change broadcast
|
|
354
|
+
await ws_manager.broadcast({
|
|
355
|
+
"type": "thread_update",
|
|
356
|
+
"threads": [{"id": "abc123", "title": "Backend", "status": "killed",
|
|
357
|
+
"created_at": "2026-05-13T10:00:00Z", "message_count": 5}]
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
m1 = await asyncio.wait_for(ws1.receive(), timeout=1.0)
|
|
361
|
+
m2 = await asyncio.wait_for(ws2.receive(), timeout=1.0)
|
|
362
|
+
d1 = json.loads(m1.data)
|
|
363
|
+
d2 = json.loads(m2.data)
|
|
364
|
+
assert d1["type"] == "thread_update"
|
|
365
|
+
assert d1["threads"][0]["status"] == "killed"
|
|
366
|
+
assert d2["threads"][0]["status"] == "killed"
|
|
367
|
+
await ws1.close(); await ws2.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@pytest.mark.asyncio
|
|
371
|
+
async def test_ws_initial_payload_has_thread_update_type():
|
|
372
|
+
"""Initial WS payload on connect uses type=thread_update."""
|
|
373
|
+
from ..ui_server import _make_app
|
|
374
|
+
from aiohttp.test_utils import TestServer, TestClient
|
|
375
|
+
|
|
376
|
+
app = _make_app()
|
|
377
|
+
with patch("server.ui_server._fetch_threads", return_value=[]):
|
|
378
|
+
async with TestClient(TestServer(app)) as client:
|
|
379
|
+
ws = await client.ws_connect("/ws/threads")
|
|
380
|
+
msg = await asyncio.wait_for(ws.receive(), timeout=2.0)
|
|
381
|
+
data = json.loads(msg.data)
|
|
382
|
+
assert data["type"] == "thread_update"
|
|
383
|
+
assert "projects" in data
|
|
384
|
+
assert isinstance(data["projects"], list)
|
|
385
|
+
await ws.close()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Static checks for the Thread Visualizer background layers."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
UI_HTML = Path(__file__).parent.parent.parent.parent / "ui" / "index.html"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _html() -> str:
|
|
9
|
+
return UI_HTML.read_text(encoding="utf-8")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_thread_visualizer_does_not_use_fluid_shader_background():
|
|
13
|
+
html = _html()
|
|
14
|
+
assert "function initShaderBackground()" not in html
|
|
15
|
+
assert "getContext('webgl'" not in html
|
|
16
|
+
assert "precision highp float;" not in html
|
|
17
|
+
assert "gl.drawArrays(gl.TRIANGLES, 0, 6)" not in html
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_fluid_shader_fallback_and_pointer_interaction_were_removed():
|
|
21
|
+
html = _html()
|
|
22
|
+
assert "function drawStaticShaderFallback()" not in html
|
|
23
|
+
assert "shaderPointer" not in html
|
|
24
|
+
assert "window.addEventListener('pointermove'" not in html
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_old_particle_background_was_removed():
|
|
28
|
+
html = _html()
|
|
29
|
+
assert "Background particle net" not in html
|
|
30
|
+
assert "const DOTS = Array.from" not in html
|
|
31
|
+
assert "function drawBg()" not in html
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sparkles_background_canvas_is_layered_behind_thread_graph():
|
|
35
|
+
html = _html()
|
|
36
|
+
assert '<canvas id="sparkles-canvas" aria-hidden="true"></canvas>' in html
|
|
37
|
+
assert "#sparkles-canvas" in html
|
|
38
|
+
assert "mix-blend-mode:screen" in html
|
|
39
|
+
assert "#graph-svg { position:fixed; inset:0; z-index:1; pointer-events:none; }" in html
|
|
40
|
+
assert "/* bubble nodes appended to body at z-index:3 */" in html
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_sparkles_background_has_particles_without_title_component():
|
|
44
|
+
html = _html()
|
|
45
|
+
assert "const SPARKLE_COUNT = 95" in html
|
|
46
|
+
assert "function makeSparkle()" in html
|
|
47
|
+
assert "function drawSparkle(ctx, x, y, r, opacity, color)" in html
|
|
48
|
+
assert "function drawSparkles(now)" in html
|
|
49
|
+
assert "sparklesCtx.globalCompositeOperation = 'lighter'" in html
|
|
50
|
+
assert "requestAnimationFrame(drawSparkles)" in html
|
|
51
|
+
assert "SparklesCore" not in html
|
|
52
|
+
assert "sparkles-title" not in html
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Static checks for Story 6.3 silk thread SVG animation."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
UI_HTML = Path(__file__).parent.parent.parent.parent / "ui" / "index.html"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _html() -> str:
|
|
11
|
+
return UI_HTML.read_text(encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_silk_curve_css_and_gradient_defs_present():
|
|
15
|
+
html = _html()
|
|
16
|
+
assert ".thread-link" in html
|
|
17
|
+
assert "stroke:rgba(139,92,246,0.35)" in html
|
|
18
|
+
assert "stroke-width:1.5" in html
|
|
19
|
+
assert "fill:none" in html
|
|
20
|
+
assert "stroke-linecap:round" in html
|
|
21
|
+
assert "id=\"active-thread-gradient\"" in html
|
|
22
|
+
assert "stop-color=\"#8b5cf6\"" in html
|
|
23
|
+
assert "stop-color=\"#22d3ee\"" in html
|
|
24
|
+
assert ".thread-link.status-active" in html
|
|
25
|
+
assert "opacity:.6" in html
|
|
26
|
+
assert ".thread-link.status-killed" in html
|
|
27
|
+
assert "opacity:.15" in html
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_silk_path_uses_cubic_bezier_with_perpendicular_offset_and_tick_updates():
|
|
31
|
+
html = _html()
|
|
32
|
+
assert "function silkPath(t, now=performance.now())" in html
|
|
33
|
+
assert "function threadAmplitude(status)" in html
|
|
34
|
+
assert "const wave = Math.sin(now * 0.0014 + (t._phase || 0)) * threadAmplitude(t.status)" in html
|
|
35
|
+
assert "const offset = dist * 0.22 + wave" in html
|
|
36
|
+
assert "const px = -dy / dist" in html
|
|
37
|
+
assert "const py = dx / dist" in html
|
|
38
|
+
assert "return `M ${CX} ${CY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${t.x} ${t.y}`" in html
|
|
39
|
+
assert "renderSilkThreads()" in html
|
|
40
|
+
assert ".attr('d', d => silkPath(d, now))" in html
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_thread_bubbles_are_static_left_lanes_with_animated_thread_physics():
|
|
44
|
+
html = _html()
|
|
45
|
+
assert "function layoutThreadLanes()" in html
|
|
46
|
+
assert "const laneX = Math.max(170, Math.min(260, W * 0.2))" in html
|
|
47
|
+
assert "wrap.style.left = t.x + 'px'" in html
|
|
48
|
+
assert "wrap.style.top = t.y + 'px'" in html
|
|
49
|
+
assert "function animateThreadPhysics(now)" in html
|
|
50
|
+
assert "requestAnimationFrame(animateThreadPhysics)" in html
|
|
51
|
+
assert "d3.forceSimulation" not in html
|
|
52
|
+
assert "d3.forceCenter" not in html
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_silk_pulse_dots_use_animate_motion_after_two_seconds():
|
|
56
|
+
html = _html()
|
|
57
|
+
assert ".silk-dot" in html
|
|
58
|
+
assert ".attr('r', 4)" in html
|
|
59
|
+
assert ".attr('fill', 'rgba(255,255,255,0.6)')" in html
|
|
60
|
+
assert ".append('animateMotion')" in html
|
|
61
|
+
assert ".attr('repeatCount', 'indefinite')" in html
|
|
62
|
+
assert "randomDuration(t)" in html
|
|
63
|
+
assert "Math.random()*4 + 3" in html
|
|
64
|
+
assert "randomBegin(t, index)" in html
|
|
65
|
+
assert "setTimeout(() => graphSvg.classed('silk-ready', true), 2000)" in html
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_silk_threads_render_paths_and_motion_dots_in_browser():
|
|
69
|
+
try:
|
|
70
|
+
from playwright.sync_api import sync_playwright
|
|
71
|
+
except ImportError:
|
|
72
|
+
pytest.skip("playwright is not installed")
|
|
73
|
+
|
|
74
|
+
with sync_playwright() as p:
|
|
75
|
+
try:
|
|
76
|
+
browser = p.chromium.launch(headless=True)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
pytest.skip(f"chromium is unavailable in this environment: {exc}")
|
|
79
|
+
page = browser.new_page()
|
|
80
|
+
errors = []
|
|
81
|
+
page.on("pageerror", lambda exc: errors.append(str(exc)))
|
|
82
|
+
page.route(
|
|
83
|
+
"**/api/threads",
|
|
84
|
+
lambda route: route.fulfill(
|
|
85
|
+
status=200,
|
|
86
|
+
content_type="application/json",
|
|
87
|
+
body=(
|
|
88
|
+
'[{"id":"active-one","title":"Active Thread","status":"active",'
|
|
89
|
+
'"created_at":"2026-05-13T00:00:00Z","message_count":3},'
|
|
90
|
+
'{"id":"idle-two","title":"Idle Thread","status":"idle",'
|
|
91
|
+
'"created_at":"2026-05-13T00:00:00Z","message_count":1}]'
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
page.route("**/ws/threads", lambda route: route.abort())
|
|
96
|
+
page.goto(UI_HTML.resolve().as_uri(), wait_until="domcontentloaded", timeout=10000)
|
|
97
|
+
page.wait_for_function("document.querySelectorAll('path.thread-link').length >= 2")
|
|
98
|
+
page.wait_for_function("document.querySelectorAll('circle.silk-dot animateMotion').length >= 2")
|
|
99
|
+
page.wait_for_timeout(2100)
|
|
100
|
+
|
|
101
|
+
assert page.locator("#graph-svg.silk-ready").count() == 1
|
|
102
|
+
assert page.locator("path.thread-link.status-active").count() >= 1
|
|
103
|
+
assert page.locator("path.thread-link.status-idle").count() >= 1
|
|
104
|
+
assert errors == []
|
|
105
|
+
browser.close()
|
|
File without changes
|