From 23f9d4a8fb09d2489841c3d60a227894c5820407 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 13 May 2026 18:10:38 -0400 Subject: [PATCH 1/2] CUB-125: implement real-time SSE/WebSocket in React frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useSSE hook with exponential back-off reconnect (1s → 30s) - Add useRealtimeSync hook: maps SSE events to React Query invalidation (agent.status → agents; agent.task/agent.progress → tasks+agents; fleet.update → all) - Add SSEContext/SSEProvider so connection status is available app-wide - Mount SSEProvider in main.tsx inside QueryClientProvider (no polling) - Show live/connecting/reconnecting/disconnected badge in sidebar + mobile header - Update SettingsPage: replace polling interval UI with SSE status panel - Disable React Query polling (staleTime 60s); all updates pushed via SSE Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Layout.tsx | 34 +++++- frontend/src/contexts/SSEContext.tsx | 23 ++++ frontend/src/hooks/useRealtimeSync.ts | 52 +++++++++ frontend/src/hooks/useSSE.ts | 159 ++++++++++++++++++++++++++ frontend/src/main.tsx | 15 ++- frontend/src/pages/SettingsPage.tsx | 84 +++++++------- frontend/src/services/sse.ts | 55 +++++++++ 7 files changed, 377 insertions(+), 45 deletions(-) create mode 100644 frontend/src/contexts/SSEContext.tsx create mode 100644 frontend/src/hooks/useRealtimeSync.ts create mode 100644 frontend/src/hooks/useSSE.ts create mode 100644 frontend/src/services/sse.ts diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 34ad59c..76d61c1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { NavLink } from 'react-router-dom' -import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react' +import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react' +import { useSSEContext } from '../contexts/SSEContext' +import type { SSEStatus } from '../hooks/useSSE' const navItems = [ { to: '/', icon: Command, label: 'Hub' }, @@ -10,9 +12,29 @@ const navItems = [ { to: '/settings', icon: Settings, label: 'Settings' }, ] +/** Small status pill shown in the sidebar footer and mobile header. */ +function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) { + const cfg = { + connected: { icon: Wifi, color: 'text-green-500', label: 'Live' }, + connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' }, + reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' }, + error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + }[status] + + const Icon = cfg.icon + + return ( +
+ + {showLabel && {cfg.label}} +
+ ) +} + export default function Layout({ children }: { children: React.ReactNode }) { const [expanded, setExpanded] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const { sseStatus } = useSSEContext() return (
@@ -46,6 +68,15 @@ export default function Layout({ children }: { children: React.ReactNode }) { ))} + {/* SSE connection status — footer of sidebar */} +
+ + {expanded && ( + + {sseStatus === 'connected' ? 'Live updates on' : sseStatus} + + )} +
{/* Mobile Header + Bottom Nav */} @@ -54,6 +85,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
Control Center +
- ))} +
+
+

SSE Connection

+

{sseInfo.description}

+ + {sseInfo.label} +
+

+ Endpoint: /api/events +  · Events: agent.status, agent.task, agent.progress, fleet.update +

+

+ Polling is disabled. All status updates are pushed from the server over a persistent SSE connection. + The client reconnects automatically with exponential back-off on drop. +

diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts new file mode 100644 index 0000000..63e2afb --- /dev/null +++ b/frontend/src/services/sse.ts @@ -0,0 +1,55 @@ +/** + * SSE event payload types matching the Go backend (internal/handler/sse.go). + * + * Event format on the wire: + * event: + * data: + */ + +import type { AgentStatus } from '../types' + +/** agent.status — agent came online, went offline, changed state */ +export interface AgentStatusEvent { + agentId: string + status: AgentStatus + /** Optional human-readable reason (e.g. error message) */ + reason?: string +} + +/** agent.task — a task was assigned to or completed by an agent */ +export interface AgentTaskEvent { + agentId: string + taskId: string + title: string + action: 'assigned' | 'completed' | 'failed' +} + +/** agent.progress — incremental progress update for a running task */ +export interface AgentProgressEvent { + agentId: string + taskId: string + progress: number + /** Optional description of what is currently happening */ + message?: string +} + +/** + * fleet.update — bulk refresh of all agents (e.g. after a deployment). + * The backend may send partial or complete agent state. + */ +export interface FleetUpdateEvent { + /** ISO timestamp of when the snapshot was taken */ + timestamp: string + /** Number of agents in the fleet */ + agentCount: number +} + +/** Union of all SSE data payloads keyed by event type. */ +export type SSEPayloadMap = { + 'agent.status': AgentStatusEvent + 'agent.task': AgentTaskEvent + 'agent.progress': AgentProgressEvent + 'fleet.update': FleetUpdateEvent + connected: { clientCount: number } + message: unknown +} From 010408cc45bc8f3642f2ba8c6c6bcce678bf0433 Mon Sep 17 00:00:00 2001 From: rex-bot Date: Wed, 20 May 2026 16:51:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?CUB-125:=20address=20Grimm=20review=20?= =?UTF-8?q?=E2=80=94=20tests,=20type=20fixes,=20error=20state=20circuit=20?= =?UTF-8?q?breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing 'offline' to AgentStatus union type (types/index.ts) - Add max-retry circuit breaker to useSSE; error state is now reachable - Wire typed SSE payloads (SSEPayloadMap discriminated union) into useRealtimeSync - Add Vitest + 20 unit tests: useSSE lifecycle, back-off, circuit breaker, event parsing, cleanup; useRealtimeSync event-to-invalidation mapping - Rebase on dev to remove stale CUB-119 legacy-deletion commit and align CI workflow (dev already consolidated into single dev.yml) - Tests: npm test → 20/20 pass; Build: npm run build → 0 errors --- frontend/package-lock.json | 1129 ++++++++++++++++++- frontend/package.json | 10 +- frontend/src/hooks/useRealtimeSync.test.tsx | 129 +++ frontend/src/hooks/useRealtimeSync.ts | 16 +- frontend/src/hooks/useSSE.test.ts | 267 +++++ frontend/src/hooks/useSSE.ts | 35 +- frontend/src/services/sse.ts | 17 + frontend/src/test-setup.ts | 1 + frontend/src/types/index.ts | 2 +- frontend/tsconfig.app.json | 2 +- frontend/tsconfig.node.json | 2 +- frontend/vitest.config.ts | 11 + 12 files changed, 1606 insertions(+), 15 deletions(-) create mode 100644 frontend/src/hooks/useRealtimeSync.test.tsx create mode 100644 frontend/src/hooks/useSSE.test.ts create mode 100644 frontend/src/test-setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffb57fd..4bc415f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -27,13 +29,73 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -213,6 +275,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -258,6 +330,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -408,6 +633,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -789,6 +1032,13 @@ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1070,6 +1320,82 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1080,6 +1406,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1381,6 +1733,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1418,6 +1883,51 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1490,6 +2000,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1567,6 +2087,16 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1610,12 +2140,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1633,6 +2198,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1647,6 +2219,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1656,6 +2238,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1688,6 +2278,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1704,6 +2307,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1913,6 +2523,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1922,6 +2542,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2209,6 +2839,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2227,6 +2870,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2248,6 +2901,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2269,6 +2929,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2614,6 +3325,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2631,6 +3353,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2650,6 +3379,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2701,6 +3440,17 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2748,6 +3498,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2766,6 +3529,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2827,6 +3597,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2863,6 +3649,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -2899,6 +3693,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", @@ -2938,6 +3756,19 @@ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2978,6 +3809,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2987,6 +3825,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -3006,6 +3878,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3022,6 +3911,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3089,6 +4034,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3211,6 +4166,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3226,6 +4319,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3235,6 +4345,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fe0db58..541327b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.100.9", @@ -20,6 +22,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -29,10 +33,12 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.5.14", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.7" } } diff --git a/frontend/src/hooks/useRealtimeSync.test.tsx b/frontend/src/hooks/useRealtimeSync.test.tsx new file mode 100644 index 0000000..65bbe47 --- /dev/null +++ b/frontend/src/hooks/useRealtimeSync.test.tsx @@ -0,0 +1,129 @@ +/** + * Tests for useRealtimeSync — event → query invalidation mapping. + * + * Uses .tsx extension so Vite/OXC can parse JSX in the wrapper component. + */ +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import * as useSSEModule from './useSSE' +import { useRealtimeSync } from './useRealtimeSync' +import React from 'react' +import type { SSEMessage } from '../services/sse' + +describe('useRealtimeSync', () => { + let queryClient: QueryClient + let mockSSEOnMessage: ((msg: { type: string; data: unknown }) => void) | null = null + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + mockSSEOnMessage = null + + // Spy on useSSE to capture the onMessage callback + vi.spyOn(useSSEModule, 'useSSE').mockImplementation((opts) => { + mockSSEOnMessage = opts?.onMessage ?? null + return { status: 'connected' } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function render() { + return renderHook(() => useRealtimeSync(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + React.createElement(QueryClientProvider, { client: queryClient }, children) + ), + }) + } + + it('invalidates ["agents"] on agent.status event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.status', + data: { agentId: 'a1', status: 'active' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(1) + }) + + it('invalidates ["tasks"] and ["agents"] on agent.task event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.task', + data: { agentId: 'a1', taskId: 't1', title: 'Test', action: 'assigned' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(2) + }) + + it('invalidates ["tasks"] and ["agents"] on agent.progress event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'agent.progress', + data: { agentId: 'a1', taskId: 't1', progress: 50, message: 'working' }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(2) + }) + + it('invalidates ["agents"], ["sessions"], ["tasks"] on fleet.update event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'fleet.update', + data: { timestamp: '2026-05-20T12:00:00Z', agentCount: 5 }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['agents'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['sessions'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tasks'] }) + expect(invalidateSpy).toHaveBeenCalledTimes(3) + }) + + it('does nothing on connected event', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + const msg: SSEMessage = { + type: 'connected', + data: { clientCount: 1 }, + } + mockSSEOnMessage!(msg) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('does nothing on unknown event types', async () => { + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + render() + + mockSSEOnMessage!({ type: 'unknown.event', data: {} }) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('returns sseStatus from useSSE', () => { + const { result } = render() + expect(result.current.sseStatus).toBe('connected') + }) +}) diff --git a/frontend/src/hooks/useRealtimeSync.ts b/frontend/src/hooks/useRealtimeSync.ts index e5cf496..b7998bd 100644 --- a/frontend/src/hooks/useRealtimeSync.ts +++ b/frontend/src/hooks/useRealtimeSync.ts @@ -10,29 +10,41 @@ */ import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { useSSE, type SSEMessage, type SSEStatus } from './useSSE' +import { useSSE, type SSEStatus } from './useSSE' +import type { SSEMessage } from '../services/sse' export function useRealtimeSync(): { sseStatus: SSEStatus } { const queryClient = useQueryClient() const handleMessage = useCallback( - (msg: SSEMessage) => { + (raw: { type: string; data: unknown }) => { + // Cast to discriminated union — the backend contract guarantees these shapes + const msg = raw as SSEMessage + switch (msg.type) { case 'agent.status': + // msg.data: AgentStatusEvent { agentId, status, reason? } + void msg.data.agentId // retained for type-narrowing — ensures payload matches contract queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.task': + // msg.data: AgentTaskEvent { agentId, taskId, title, action } + void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'agent.progress': + // msg.data: AgentProgressEvent { agentId, taskId, progress, message? } + void msg.data.agentId queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['agents'] }) break case 'fleet.update': + // msg.data: FleetUpdateEvent { timestamp, agentCount } + void msg.data.agentCount queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['sessions'] }) queryClient.invalidateQueries({ queryKey: ['tasks'] }) diff --git a/frontend/src/hooks/useSSE.test.ts b/frontend/src/hooks/useSSE.test.ts new file mode 100644 index 0000000..d4db51d --- /dev/null +++ b/frontend/src/hooks/useSSE.test.ts @@ -0,0 +1,267 @@ +/** + * Tests for useSSE — SSE connection lifecycle, back-off, event parsing, and cleanup. + * + * jsdom does not include EventSource, so we mock it completely. + */ +import { renderHook, act, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSSE } from './useSSE' + +// --------------------------------------------------------------------------- +// Mock EventSource — defined as a plain class so `new EventSource()` works +// --------------------------------------------------------------------------- +class MockEventSource { + url: string + onopen: (() => void) | null = null + onerror: ((evt: Event) => void) | null = null + onmessage: ((evt: MessageEvent) => void) | null = null + private listeners: Map void>> = new Map() + readyState: number = 0 + + constructor(url: string) { + this.url = url + } + + addEventListener(type: string, handler: (evt: Event) => void) { + if (!this.listeners.has(type)) this.listeners.set(type, []) + this.listeners.get(type)!.push(handler) + } + + removeEventListener() { /* no-op for tests */ } + + close() { + this.readyState = 2 + this.onopen = null + this.onerror = null + this.onmessage = null + this.listeners.clear() + } + + // Test helpers + _simulateOpen() { this.onopen?.() } + _simulateError() { this.onerror?.(new Event('error')) } + _simulateNamedEvent(type: string, data: string) { + const handlers = this.listeners.get(type) + if (handlers) { + const evt = new MessageEvent(type, { data }) as Event + handlers.forEach((h) => h(evt)) + } + } + _simulateMessage(data: string) { + this.onmessage?.(new MessageEvent('message', { data }) as MessageEvent) + } + + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +let esInstances: MockEventSource[] + +describe('useSSE', () => { + beforeEach(() => { + esInstances = [] + // Replace global EventSource with our mock class + Object.defineProperty(globalThis, 'EventSource', { + // The mock must use a class for `new EventSource()` to work + value: class extends MockEventSource { + constructor(url: string) { + super(url) + esInstances.push(this) + } + }, + writable: true, + configurable: true, + }) + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + // ── Initial connection ────────────────────────────────────────────────── + it('starts in "connecting" state and creates an EventSource', () => { + const { result } = renderHook(() => useSSE({ url: '/api/events' })) + + expect(result.current.status).toBe('connecting') + expect(esInstances.length).toBeGreaterThanOrEqual(1) + expect(esInstances[0].url).toBe('/api/events') + }) + + it('transitions to "connected" on open', async () => { + const onOpen = vi.fn() + const { result } = renderHook(() => useSSE({ url: '/api/events', onOpen })) + + act(() => { esInstances[0]._simulateOpen() }) + + await waitFor(() => { + expect(result.current.status).toBe('connected') + }) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + // ── Reconnection with exponential back-off ────────────────────────────── + it('retries after error with exponential back-off', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 30000 }), + ) + + // First error → reconnecting, retry at 1s + act(() => { esInstances[0]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + expect(esInstances).toHaveLength(1) + + // Advance 1000ms → second EventSource created + act(() => { vi.advanceTimersByTime(1000) }) + expect(esInstances).toHaveLength(2) + + // Second error → reconnecting, retry at 2s + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + act(() => { vi.advanceTimersByTime(2000) }) + expect(esInstances).toHaveLength(3) + + // Third error → reconnecting, retry at 4s + act(() => { esInstances[2]._simulateError() }) + act(() => { vi.advanceTimersByTime(4000) }) + expect(esInstances).toHaveLength(4) + }) + + it('caps reconnect delay at reconnectMaxMs', async () => { + renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 1000, reconnectMaxMs: 10000 }), + ) + + // Force 5 errors to push the exponent past the cap + for (let i = 0; i < 5; i++) { + act(() => { esInstances[i]._simulateError() }) + const expectedDelay = Math.min(1000 * 2 ** i, 10000) + act(() => { vi.advanceTimersByTime(expectedDelay) }) + } + + // 6 ES instances created (initial + 5 retries) + expect(esInstances).toHaveLength(6) + }) + + // ── Circuit-breaker (max retries) ─────────────────────────────────────── + it('transitions to "error" after reconnectLimit is exceeded', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 2 }), + ) + + // First error → reconnecting + act(() => { esInstances[0]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + + // Advance → retry + act(() => { vi.advanceTimersByTime(100) }) + + // Second error → reconnecting (attempt 2, still ≤ limit) + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + act(() => { vi.advanceTimersByTime(200) }) + + // Third error → limit exceeded (3 > 2) → error + act(() => { esInstances[2]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('error') }) + }) + + it('resets reconnect counter on successful connection', async () => { + const { result } = renderHook(() => + useSSE({ url: '/api/events', reconnectBaseMs: 100, reconnectLimit: 3 }), + ) + + // Two errors then a successful connect + act(() => { esInstances[0]._simulateError() }) + act(() => { vi.advanceTimersByTime(100) }) + + act(() => { esInstances[1]._simulateOpen() }) + await waitFor(() => { expect(result.current.status).toBe('connected') }) + + // Now error again — counter should be reset, so we get fresh attempts + act(() => { esInstances[1]._simulateError() }) + await waitFor(() => { expect(result.current.status).toBe('reconnecting') }) + expect(result.current.status).toBe('reconnecting') + }) + + // ── Cleanup on unmount ─────────────────────────────────────────────────── + it('closes EventSource on unmount', () => { + const closeSpy = vi.spyOn(MockEventSource.prototype, 'close') + const { unmount } = renderHook(() => useSSE({ url: '/api/events' })) + + unmount() + expect(closeSpy).toHaveBeenCalled() + }) + + it('does not update state after unmount', async () => { + const { result, unmount } = renderHook(() => useSSE({ url: '/api/events' })) + + unmount() + + // These should be no-ops after unmount (mountedRef guards) + act(() => { esInstances[0]._simulateOpen() }) + act(() => { esInstances[0]._simulateError() }) + + // State should not have changed + expect(result.current.status).toBe('connecting') + }) + + // ── Event parsing ─────────────────────────────────────────────────────── + it('parses valid JSON data into objects', async () => { + const onMessage = vi.fn() + renderHook(() => useSSE({ url: '/api/events', onMessage })) + + act(() => { + esInstances[0]._simulateNamedEvent('agent.status', JSON.stringify({ agentId: 'a1', status: 'active' })) + }) + + expect(onMessage).toHaveBeenCalledWith({ + type: 'agent.status', + data: { agentId: 'a1', status: 'active' }, + }) + }) + + it('passes invalid JSON through as raw string', async () => { + const onMessage = vi.fn() + renderHook(() => useSSE({ url: '/api/events', onMessage })) + + act(() => { + esInstances[0]._simulateNamedEvent('agent.status', 'not valid json {{{') + }) + + expect(onMessage).toHaveBeenCalledWith({ + type: 'agent.status', + data: 'not valid json {{{', + }) + }) + + // ── enabled=false skips connection ────────────────────────────────────── + it('does not create EventSource when enabled=false', () => { + const { result } = renderHook(() => useSSE({ url: '/api/events', enabled: false })) + + expect(esInstances).toHaveLength(0) + expect(result.current.status).toBe('connecting') + }) + + // ── onError callback ──────────────────────────────────────────────────── + it('calls onError on connection failure', async () => { + const onError = vi.fn() + renderHook(() => + useSSE({ url: '/api/events', onError, reconnectBaseMs: 100 }), + ) + + act(() => { esInstances[0]._simulateError() }) + expect(onError).toHaveBeenCalledTimes(1) + }) + + // ── Default URL ───────────────────────────────────────────────────────── + it('uses /api/events as default URL', () => { + renderHook(() => useSSE()) + expect(esInstances[0].url).toBe('/api/events') + }) +}) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 3289e06..1c45307 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -18,12 +18,18 @@ export interface UseSSEOptions { onMessage?: (msg: SSEMessage) => void /** Called when connection opens or reconnects */ onOpen?: () => void - /** Called on unrecoverable error */ + /** Called on every connection error (both transient and terminal) */ onError?: (err: Event) => void /** Base delay in ms before the first reconnect attempt (default 1 000) */ reconnectBaseMs?: number /** Maximum reconnect delay in ms (default 30 000) */ reconnectMaxMs?: number + /** + * Maximum number of consecutive reconnect attempts before giving up. + * When the limit is reached, status transitions to 'error'. + * Default undefined (unlimited). + */ + reconnectLimit?: number /** Set false to disable auto-connect (useful in tests) */ enabled?: boolean } @@ -35,9 +41,14 @@ const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.updat * * Handles: * - Initial connection on mount - * - Exponential back-off reconnection on drop + * - Exponential back-off reconnection on drop (1s → 2s → 4s … capped at reconnectMaxMs) + * - Circuit-breaker: after reconnectLimit consecutive failures, transitions to 'error' * - Cleanup on unmount - * - All four event types: agent.status, agent.task, agent.progress, fleet.update + * - All five event types: agent.status, agent.task, agent.progress, fleet.update, connected + * + * The 'connected' SSE event is an application-level handshake sent by the backend + * after the transport opens. This is distinct from onOpen, which fires at the + * transport level when the EventSource HTTP connection is established. */ export function useSSE({ url = '/api/events', @@ -46,6 +57,7 @@ export function useSSE({ onError, reconnectBaseMs = 1_000, reconnectMaxMs = 30_000, + reconnectLimit, enabled = true, }: UseSSEOptions = {}): { status: SSEStatus } { const [status, setStatus] = useState('connecting') @@ -100,12 +112,20 @@ export function useSSE({ onErrorRef.current?.(evt) + reconnectAttemptRef.current += 1 + + // Circuit-breaker: give up after reconnectLimit consecutive failures + if (reconnectLimit !== undefined && reconnectAttemptRef.current > reconnectLimit) { + setStatus('error') + return + } + // Exponential back-off: 1s, 2s, 4s … capped at reconnectMaxMs + // Note: attempt is 1-based here (already incremented), so we use attempt-1 for the exponent const delay = Math.min( - reconnectBaseMs * 2 ** reconnectAttemptRef.current, + reconnectBaseMs * 2 ** (reconnectAttemptRef.current - 1), reconnectMaxMs, ) - reconnectAttemptRef.current += 1 setStatus('reconnecting') clearReconnectTimer() @@ -128,7 +148,8 @@ export function useSSE({ }) } - // Catch-all for unnamed events (type == 'message') + // Catch-all for unnamed events (type == 'message'). + // Won't fire for the named events registered via addEventListener above. es.onmessage = (evt: MessageEvent) => { if (!mountedRef.current) return let data: unknown = evt.data @@ -139,7 +160,7 @@ export function useSSE({ } onMessageRef.current?.({ type: 'message', data }) } - }, [url, enabled, reconnectBaseMs, reconnectMaxMs, clearReconnectTimer]) + }, [url, enabled, reconnectBaseMs, reconnectMaxMs, reconnectLimit, clearReconnectTimer]) useEffect(() => { mountedRef.current = true diff --git a/frontend/src/services/sse.ts b/frontend/src/services/sse.ts index 63e2afb..6243e9f 100644 --- a/frontend/src/services/sse.ts +++ b/frontend/src/services/sse.ts @@ -4,6 +4,11 @@ * Event format on the wire: * event: * data: + * + * The types below define the backend contract. The SSEPayloadMap maps + * each event type string to its expected payload shape. SSEMessage is a + * discriminated union on `type` — when you switch on msg.type, TypeScript + * narrows msg.data to the correct payload interface automatically. */ import type { AgentStatus } from '../types' @@ -53,3 +58,15 @@ export type SSEPayloadMap = { connected: { clientCount: number } message: unknown } + +/** + * Discriminated SSE message — the `type` field narrows `data` via SSEPayloadMap. + * + * Usage: + * if (msg.type === 'agent.status') { + * msg.data.agentId // ✅ TypeScript knows this is AgentStatusEvent + * } + */ +export type SSEMessage = { + [K in keyof SSEPayloadMap]: { type: K; data: SSEPayloadMap[K] } +}[keyof SSEPayloadMap] diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 64c6f46..e04426d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,4 @@ -export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' +export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline' export interface Agent { id: string diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 7f42e5f..dccb228 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "skipLibCheck": true, /* Bundler mode */ diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index d3c52ea..5a66f0e 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -20,5 +20,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..45cc983 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + }, +})