code-coordinator 0.5.46__py3-none-any.whl

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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Wires the SSE connection (`connection.ts`) to react-query invalidation and
3
+ * exposes the live connection state via context (#1549).
4
+ *
5
+ * Mount once, at the root, inside `QueryClientProvider` (see `main.tsx`).
6
+ * Every screen that wants the honest live/reconnecting/stale indicator reads
7
+ * `useConnectionStatus()`; nothing else needs to know an `EventSource` is
8
+ * involved.
9
+ */
10
+ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
11
+ import { useQueryClient, type QueryClient } from '@tanstack/react-query'
12
+ import {
13
+ createSseConnection,
14
+ type ConnectionStatus,
15
+ type EventSourceFactory,
16
+ } from './connection'
17
+ import { EVENT_TYPES, EVENT_QUERY_KEYS, RESYNC_QUERY_KEYS } from './events'
18
+
19
+ const SSE_URL = '/events'
20
+
21
+ const INITIAL_STATUS: ConnectionStatus = { state: 'connecting', lastLiveAt: null, attempt: 0 }
22
+
23
+ const ConnectionStatusContext = createContext<ConnectionStatus>(INITIAL_STATUS)
24
+
25
+ /** The current SSE connection state — live / reconnecting / disconnected / connecting. */
26
+ // eslint-disable-next-line react-refresh/only-export-components -- a hook alongside its provider component is the standard context pattern (see components/ui/badge.tsx for the same precedent).
27
+ export function useConnectionStatus(): ConnectionStatus {
28
+ return useContext(ConnectionStatusContext)
29
+ }
30
+
31
+ interface RealtimeProviderProps {
32
+ children: ReactNode
33
+ /** Test-only seam; production always uses the browser's native `EventSource`. */
34
+ createEventSource?: EventSourceFactory
35
+ /** Test-only seam for pointing at a fixture/mock stream. */
36
+ url?: string
37
+ /** Test-only seam for exact backoff timing; production uses connection.ts's default schedule. */
38
+ backoffScheduleMs?: readonly number[]
39
+ }
40
+
41
+ function invalidate(queryClient: QueryClient, type: string): void {
42
+ for (const key of EVENT_QUERY_KEYS[type] ?? []) {
43
+ void queryClient.invalidateQueries({ queryKey: key })
44
+ }
45
+ }
46
+
47
+ function resync(queryClient: QueryClient): void {
48
+ for (const key of RESYNC_QUERY_KEYS) {
49
+ void queryClient.invalidateQueries({ queryKey: key })
50
+ }
51
+ }
52
+
53
+ export function RealtimeProvider({
54
+ children,
55
+ createEventSource,
56
+ url,
57
+ backoffScheduleMs,
58
+ }: RealtimeProviderProps) {
59
+ const queryClient = useQueryClient()
60
+ const [status, setStatus] = useState<ConnectionStatus>(INITIAL_STATUS)
61
+
62
+ // useQuery/useQueryClient give a stable-enough client in practice, but the
63
+ // connection object is only ever created once (see the lazy ref below) —
64
+ // route every callback through a ref so it always sees the current client
65
+ // rather than closing over whatever was live at construction time.
66
+ const queryClientRef = useRef(queryClient)
67
+ queryClientRef.current = queryClient
68
+
69
+ // The connection is created exactly once per component instance (lazy
70
+ // useRef initializer) and driven by start()/stop() from the effect below.
71
+ // This is deliberate: StrictMode's mount -> cleanup -> mount replay must
72
+ // reuse the SAME connection object so its start()'s token bump is what
73
+ // guards the superseded socket, rather than constructing two independent
74
+ // connections that both think they're the only one.
75
+ const connRef = useRef<ReturnType<typeof createSseConnection> | null>(null)
76
+ const prevStateRef = useRef<ConnectionStatus['state']>('connecting')
77
+ if (connRef.current === null) {
78
+ connRef.current = createSseConnection({
79
+ url: url ?? SSE_URL,
80
+ eventTypes: EVENT_TYPES,
81
+ createEventSource,
82
+ backoffScheduleMs,
83
+ onEvent: (type, data) => {
84
+ invalidate(queryClientRef.current, type)
85
+ // board_updated carries a fresh timestamp/summary but no assignment
86
+ // identity worth threading further -- data is otherwise unused today.
87
+ void data
88
+ },
89
+ onStatusChange: (next) => {
90
+ // The only path to 'live' is from 'connecting' (first-ever connect,
91
+ // nothing to resync) or from 'reconnecting' (a real recovery, where
92
+ // events may have been missed -- see events.ts RESYNC_QUERY_KEYS).
93
+ if (next.state === 'live' && prevStateRef.current === 'reconnecting') {
94
+ resync(queryClientRef.current)
95
+ }
96
+ prevStateRef.current = next.state
97
+ setStatus(next)
98
+ },
99
+ })
100
+ }
101
+
102
+ useEffect(() => {
103
+ const conn = connRef.current
104
+ if (!conn) return
105
+ conn.start()
106
+ return () => conn.stop()
107
+ }, [])
108
+
109
+ return (
110
+ <ConnectionStatusContext.Provider value={status}>{children}</ConnectionStatusContext.Provider>
111
+ )
112
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * React-level tests for RealtimeProvider (#1549): event -> query-key
3
+ * invalidation, the honest connection-state context, and the StrictMode
4
+ * double-mount trap the module doc comment (connection.ts) describes --
5
+ * exercised here with React's actual `<StrictMode>`, not just simulated.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
8
+ import { act, render, screen, waitFor } from '@testing-library/react'
9
+ import React from 'react'
10
+ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
11
+ import { RealtimeProvider, useConnectionStatus } from '../RealtimeProvider'
12
+ import type { SseHandle } from '../connection'
13
+
14
+ // ── Fake EventSource (same shape as connection.test.ts's) ────────────────────
15
+
16
+ class FakeEventSource implements SseHandle {
17
+ static instances: FakeEventSource[] = []
18
+ static reset(): void {
19
+ FakeEventSource.instances = []
20
+ }
21
+
22
+ url: string
23
+ closed = false
24
+ onopen: ((ev: unknown) => void) | null = null
25
+ onerror: ((ev: unknown) => void) | null = null
26
+ private listeners = new Map<string, Array<(ev: { data: string }) => void>>()
27
+
28
+ constructor(url: string) {
29
+ this.url = url
30
+ FakeEventSource.instances.push(this)
31
+ }
32
+
33
+ addEventListener(type: string, listener: (ev: { data: string }) => void): void {
34
+ const list = this.listeners.get(type) ?? []
35
+ list.push(listener)
36
+ this.listeners.set(type, list)
37
+ }
38
+
39
+ close(): void {
40
+ this.closed = true
41
+ }
42
+
43
+ emitOpen(): void {
44
+ this.onopen?.({})
45
+ }
46
+
47
+ emitError(): void {
48
+ this.onerror?.({})
49
+ }
50
+
51
+ emitMessage(type: string, data: unknown): void {
52
+ for (const listener of this.listeners.get(type) ?? []) {
53
+ listener({ data: JSON.stringify(data) })
54
+ }
55
+ }
56
+ }
57
+
58
+ function lastInstance(): FakeEventSource {
59
+ const inst = FakeEventSource.instances[FakeEventSource.instances.length - 1]
60
+ if (!inst) throw new Error('no FakeEventSource created')
61
+ return inst
62
+ }
63
+
64
+ function openInstances(): FakeEventSource[] {
65
+ return FakeEventSource.instances.filter((i) => !i.closed)
66
+ }
67
+
68
+ beforeEach(() => {
69
+ FakeEventSource.reset()
70
+ })
71
+
72
+ // ── Test fixtures ────────────────────────────────────────────────────────────
73
+
74
+ /** Renders the connection status as text, for assertions. */
75
+ function StatusProbe() {
76
+ const { state } = useConnectionStatus()
77
+ return <span data-testid="status">{state}</span>
78
+ }
79
+
80
+ /** A query whose fetch count is observable, standing in for ['pipeline']. */
81
+ function pipelineFetcher() {
82
+ return vi.fn().mockResolvedValue(['ok'])
83
+ }
84
+
85
+ function PipelineProbe({ queryFn }: { queryFn: () => Promise<string[]> }) {
86
+ const { data } = useQuery({ queryKey: ['pipeline'], queryFn })
87
+ return <span data-testid="pipeline">{data?.length ?? 0}</span>
88
+ }
89
+
90
+ function createTestQueryClient() {
91
+ return new QueryClient({
92
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
93
+ })
94
+ }
95
+
96
+ // ── Tests ────────────────────────────────────────────────────────────────────
97
+
98
+ describe('RealtimeProvider', () => {
99
+ it('exposes connecting -> live via context as the stream opens', async () => {
100
+ render(
101
+ <QueryClientProvider client={createTestQueryClient()}>
102
+ <RealtimeProvider createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}>
103
+ <StatusProbe />
104
+ </RealtimeProvider>
105
+ </QueryClientProvider>,
106
+ )
107
+
108
+ await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0))
109
+ expect(screen.getByTestId('status').textContent).toBe('connecting')
110
+
111
+ act(() => lastInstance().emitOpen())
112
+ await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('live'))
113
+ })
114
+
115
+ it('invalidates the pipeline query when an assignment_started event arrives', async () => {
116
+ const queryClient = createTestQueryClient()
117
+ const queryFn = pipelineFetcher()
118
+
119
+ render(
120
+ <QueryClientProvider client={queryClient}>
121
+ <RealtimeProvider createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}>
122
+ <PipelineProbe queryFn={queryFn} />
123
+ </RealtimeProvider>
124
+ </QueryClientProvider>,
125
+ )
126
+
127
+ await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1))
128
+ act(() => lastInstance().emitOpen())
129
+
130
+ act(() => lastInstance().emitMessage('assignment_started', { assignment_id: 'w-1' }))
131
+
132
+ await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2))
133
+ })
134
+
135
+ it('resyncs the pipeline query on recovery from a drop, not on the first connect', async () => {
136
+ const queryClient = createTestQueryClient()
137
+ const queryFn = pipelineFetcher()
138
+
139
+ render(
140
+ <QueryClientProvider client={queryClient}>
141
+ <RealtimeProvider
142
+ createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}
143
+ backoffScheduleMs={[5]}
144
+ >
145
+ <PipelineProbe queryFn={queryFn} />
146
+ </RealtimeProvider>
147
+ </QueryClientProvider>,
148
+ )
149
+
150
+ await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1))
151
+ act(() => lastInstance().emitOpen())
152
+ // First-ever connect: no resync, still just the initial fetch.
153
+ expect(queryFn).toHaveBeenCalledTimes(1)
154
+
155
+ // Drop, then a fresh socket recovers.
156
+ act(() => lastInstance().emitError())
157
+ await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2))
158
+ act(() => lastInstance().emitOpen())
159
+
160
+ // Recovery from a real drop resyncs, on top of whatever the SSE event
161
+ // stream itself triggered.
162
+ await waitFor(() => expect(queryFn.mock.calls.length).toBeGreaterThanOrEqual(2))
163
+ })
164
+
165
+ it('survives the StrictMode double-mount replay with exactly one live socket', async () => {
166
+ render(
167
+ <React.StrictMode>
168
+ <QueryClientProvider client={createTestQueryClient()}>
169
+ <RealtimeProvider
170
+ createEventSource={(url) => new FakeEventSource(url) as unknown as SseHandle}
171
+ >
172
+ <StatusProbe />
173
+ </RealtimeProvider>
174
+ </QueryClientProvider>
175
+ </React.StrictMode>,
176
+ )
177
+
178
+ // StrictMode's mount -> cleanup -> mount may create an early socket that
179
+ // gets superseded; only one should remain open.
180
+ await waitFor(() => expect(openInstances()).toHaveLength(1))
181
+
182
+ act(() => openInstances()[0].emitOpen())
183
+ await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('live'))
184
+
185
+ // The status only reflects the surviving socket -- a stale callback from
186
+ // any closed socket must not have flipped it back.
187
+ expect(screen.getByTestId('status').textContent).toBe('live')
188
+ })
189
+ })
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Unit tests for the framework-agnostic SSE client (#1549).
3
+ *
4
+ * A fake `EventSource`-shaped handle stands in for the browser API so these
5
+ * tests can drive open/error/message deterministically and assert on the
6
+ * exact connection-state sequence, without any real network or timers other
7
+ * than Vitest's fake ones.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
10
+ import { createSseConnection, type ConnectionStatus, type SseHandle } from '../connection'
11
+
12
+ // ── Fake EventSource ─────────────────────────────────────────────────────────
13
+
14
+ class FakeEventSource implements SseHandle {
15
+ static instances: FakeEventSource[] = []
16
+ static reset(): void {
17
+ FakeEventSource.instances = []
18
+ }
19
+
20
+ url: string
21
+ closed = false
22
+ onopen: ((ev: unknown) => void) | null = null
23
+ onerror: ((ev: unknown) => void) | null = null
24
+ private listeners = new Map<string, Array<(ev: { data: string }) => void>>()
25
+
26
+ constructor(url: string) {
27
+ this.url = url
28
+ FakeEventSource.instances.push(this)
29
+ }
30
+
31
+ addEventListener(type: string, listener: (ev: { data: string }) => void): void {
32
+ const list = this.listeners.get(type) ?? []
33
+ list.push(listener)
34
+ this.listeners.set(type, list)
35
+ }
36
+
37
+ close(): void {
38
+ this.closed = true
39
+ }
40
+
41
+ emitOpen(): void {
42
+ this.onopen?.({})
43
+ }
44
+
45
+ emitError(): void {
46
+ this.onerror?.({})
47
+ }
48
+
49
+ emitMessage(type: string, data: unknown): void {
50
+ for (const listener of this.listeners.get(type) ?? []) {
51
+ listener({ data: JSON.stringify(data) })
52
+ }
53
+ }
54
+ }
55
+
56
+ function lastInstance(): FakeEventSource {
57
+ const inst = FakeEventSource.instances[FakeEventSource.instances.length - 1]
58
+ if (!inst) throw new Error('no FakeEventSource created')
59
+ return inst
60
+ }
61
+
62
+ /** `Array.prototype.at` needs es2022 lib; the project targets es2020 (tsconfig.json). */
63
+ function last<T>(arr: readonly T[]): T | undefined {
64
+ return arr[arr.length - 1]
65
+ }
66
+
67
+ beforeEach(() => {
68
+ FakeEventSource.reset()
69
+ vi.useFakeTimers()
70
+ })
71
+
72
+ afterEach(() => {
73
+ vi.useRealTimers()
74
+ })
75
+
76
+ // ── Helpers ──────────────────────────────────────────────────────────────────
77
+
78
+ function setup(overrides: Partial<Parameters<typeof createSseConnection>[0]> = {}) {
79
+ const events: Array<{ type: string; data: unknown }> = []
80
+ const statuses: ConnectionStatus[] = []
81
+ const conn = createSseConnection({
82
+ url: '/events',
83
+ eventTypes: ['assignment_started', 'board_updated'],
84
+ onEvent: (type, data) => events.push({ type, data }),
85
+ onStatusChange: (status) => statuses.push({ ...status }),
86
+ createEventSource: (url) => new FakeEventSource(url) as unknown as SseHandle,
87
+ backoffScheduleMs: [100, 200, 400],
88
+ jitterFraction: 0,
89
+ now: () => Date.now(),
90
+ ...overrides,
91
+ })
92
+ return { conn, events, statuses }
93
+ }
94
+
95
+ // ── Tests ────────────────────────────────────────────────────────────────────
96
+
97
+ describe('createSseConnection', () => {
98
+ it('opens a socket at the given URL and reports live on open', () => {
99
+ const { conn, statuses } = setup()
100
+ conn.start()
101
+
102
+ expect(FakeEventSource.instances).toHaveLength(1)
103
+ expect(lastInstance().url).toBe('/events')
104
+ expect(last(statuses)).toMatchObject({ state: 'connecting' })
105
+
106
+ lastInstance().emitOpen()
107
+
108
+ expect(last(statuses)).toMatchObject({ state: 'live', attempt: 0 })
109
+ expect(last(statuses)?.lastLiveAt).not.toBeNull()
110
+ })
111
+
112
+ it('forwards subscribed events as parsed JSON', () => {
113
+ const { conn, events } = setup()
114
+ conn.start()
115
+ lastInstance().emitOpen()
116
+
117
+ lastInstance().emitMessage('assignment_started', { assignment_id: 'w-1' })
118
+ lastInstance().emitMessage('board_updated', { timestamp: 123 })
119
+
120
+ expect(events).toEqual([
121
+ { type: 'assignment_started', data: { assignment_id: 'w-1' } },
122
+ { type: 'board_updated', data: { timestamp: 123 } },
123
+ ])
124
+ })
125
+
126
+ it('goes disconnected -> reconnecting -> live on a drop and recovery', () => {
127
+ const { conn, statuses } = setup()
128
+ conn.start()
129
+ lastInstance().emitOpen()
130
+ expect(last(statuses)?.state).toBe('live')
131
+
132
+ // The stream drops.
133
+ lastInstance().emitError()
134
+ expect(last(statuses)).toMatchObject({ state: 'disconnected', attempt: 1 })
135
+
136
+ // Backoff timer fires -> a new attempt starts.
137
+ vi.advanceTimersByTime(200)
138
+ expect(FakeEventSource.instances).toHaveLength(2)
139
+ expect(last(statuses)).toMatchObject({ state: 'reconnecting', attempt: 1 })
140
+
141
+ // The new socket opens successfully.
142
+ lastInstance().emitOpen()
143
+ expect(last(statuses)).toMatchObject({ state: 'live', attempt: 0 })
144
+ })
145
+
146
+ it('uses the backoff schedule and caps at its last entry', () => {
147
+ const { conn, statuses } = setup()
148
+ conn.start()
149
+ lastInstance().emitOpen()
150
+
151
+ // Fail three times in a row; each retry should wait the schedule's next
152
+ // entry (100, 200, 400), and a fourth failure should reuse 400 (the cap).
153
+ lastInstance().emitError() // attempt 1 scheduled, delay 100
154
+ vi.advanceTimersByTime(99)
155
+ expect(FakeEventSource.instances).toHaveLength(1) // not yet retried
156
+
157
+ vi.advanceTimersByTime(1)
158
+ expect(FakeEventSource.instances).toHaveLength(2) // retried at 100ms
159
+
160
+ lastInstance().emitError() // attempt 2 scheduled, delay 200
161
+ vi.advanceTimersByTime(199)
162
+ expect(FakeEventSource.instances).toHaveLength(2)
163
+ vi.advanceTimersByTime(1)
164
+ expect(FakeEventSource.instances).toHaveLength(3)
165
+
166
+ lastInstance().emitError() // attempt 3 scheduled, delay 400
167
+ vi.advanceTimersByTime(400)
168
+ expect(FakeEventSource.instances).toHaveLength(4)
169
+
170
+ lastInstance().emitError() // attempt 4 scheduled, delay capped at 400
171
+ vi.advanceTimersByTime(400)
172
+ expect(FakeEventSource.instances).toHaveLength(5)
173
+
174
+ expect(last(statuses)?.state).toBe('reconnecting')
175
+ })
176
+
177
+ it('start() is idempotent-safe: a second start() supersedes the first socket', () => {
178
+ const { conn, statuses } = setup()
179
+ conn.start()
180
+ const first = lastInstance()
181
+
182
+ conn.start()
183
+ const second = lastInstance()
184
+
185
+ expect(first).not.toBe(second)
186
+ expect(first.closed).toBe(true)
187
+
188
+ // The superseded socket's late callbacks must not affect state.
189
+ const countBefore = statuses.length
190
+ first.emitOpen()
191
+ expect(statuses.length).toBe(countBefore)
192
+
193
+ // Only the current socket's events matter.
194
+ second.emitOpen()
195
+ expect(last(statuses)).toMatchObject({ state: 'live' })
196
+ })
197
+
198
+ it('stop() closes the socket, cancels pending retries, and silences further callbacks', () => {
199
+ const { conn, statuses } = setup()
200
+ conn.start()
201
+ lastInstance().emitOpen()
202
+ lastInstance().emitError()
203
+ expect(last(statuses)?.state).toBe('disconnected')
204
+
205
+ conn.stop()
206
+ expect(lastInstance().closed).toBe(true)
207
+
208
+ const countBeforeAdvance = statuses.length
209
+ vi.advanceTimersByTime(10_000)
210
+ // No retry socket should have been created, and no further status changes.
211
+ expect(FakeEventSource.instances).toHaveLength(1)
212
+ expect(statuses.length).toBe(countBeforeAdvance)
213
+ })
214
+
215
+ it('resets intent/backoff state on every start(), mirroring the StrictMode double-mount replay', () => {
216
+ const { conn, statuses } = setup()
217
+
218
+ // Mount -> effect -> cleanup -> mount, exactly as StrictMode replays in dev.
219
+ conn.start()
220
+ conn.stop()
221
+ conn.start()
222
+
223
+ expect(FakeEventSource.instances).toHaveLength(2)
224
+ expect(FakeEventSource.instances[0].closed).toBe(true)
225
+
226
+ // The live connection is the second socket; opening it must report
227
+ // attempt 0 -- proof that stop()+start() didn't leak stale backoff state.
228
+ lastInstance().emitOpen()
229
+ expect(last(statuses)).toMatchObject({ state: 'live', attempt: 0 })
230
+ })
231
+
232
+ it('treats a synchronously-throwing EventSource factory as a failed attempt', () => {
233
+ let calls = 0
234
+ const statusesFor: ConnectionStatus[] = []
235
+ const conn = createSseConnection({
236
+ url: '/events',
237
+ eventTypes: [],
238
+ onEvent: () => {},
239
+ onStatusChange: (s) => statusesFor.push({ ...s }),
240
+ createEventSource: () => {
241
+ calls += 1
242
+ if (calls === 1) throw new Error('boom')
243
+ return new FakeEventSource('/events') as unknown as SseHandle
244
+ },
245
+ backoffScheduleMs: [50],
246
+ jitterFraction: 0,
247
+ })
248
+
249
+ conn.start()
250
+ expect(last(statusesFor)?.state).toBe('disconnected')
251
+
252
+ vi.advanceTimersByTime(50)
253
+ expect(calls).toBe(2)
254
+ })
255
+ })