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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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
|
+
})
|