diff --git a/src/reporter/src/hooks/queries/use-tdd-queries.js b/src/reporter/src/hooks/queries/use-tdd-queries.js index 2f68638..33d2034 100644 --- a/src/reporter/src/hooks/queries/use-tdd-queries.js +++ b/src/reporter/src/hooks/queries/use-tdd-queries.js @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { tdd } from '../../api/client.js'; import { queryKeys } from '../../lib/query-keys.js'; -import { SSE_STATE, useReportDataSSE } from '../use-sse.js'; +import { SSE_STATE, useSSEState } from '../use-sse.js'; export function useComparison(id, options = {}) { return useQuery({ @@ -14,10 +14,8 @@ export function useComparison(id, options = {}) { } export function useReportData(options = {}) { - // Use SSE for real-time updates - let { state: sseState } = useReportDataSSE({ - enabled: options.polling !== false, - }); + // Read SSE state from the singleton provider + let { state: sseState } = useSSEState(); // SSE is connected - it updates the cache directly, no polling needed // Fall back to polling only when SSE is not connected diff --git a/src/reporter/src/hooks/use-sse.js b/src/reporter/src/hooks/use-sse.js index 5f4d90e..7ff9c73 100644 --- a/src/reporter/src/hooks/use-sse.js +++ b/src/reporter/src/hooks/use-sse.js @@ -1,109 +1,17 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef, useState } from 'react'; -import { queryKeys } from '../lib/query-keys.js'; +import { useContext } from 'react'; +import { SSE_STATE, SSEContext } from '../providers/sse-provider.jsx'; -/** - * SSE connection states - */ -export let SSE_STATE = { - CONNECTING: 'connecting', - CONNECTED: 'connected', - DISCONNECTED: 'disconnected', - ERROR: 'error', -}; +// Re-export for consumers that import SSE_STATE from here +export { SSE_STATE }; /** - * Hook to manage SSE connection for real-time report data updates - * @param {Object} options - * @param {boolean} [options.enabled=true] - Whether SSE should be enabled + * Read SSE connection state from the singleton provider. * @returns {{ state: string, error: Error|null }} */ -export function useReportDataSSE(options = {}) { - let shouldEnable = options.enabled ?? true; - - const queryClient = useQueryClient(); - const eventSourceRef = useRef(null); - const reconnectAttemptRef = useRef(0); - const reconnectTimerRef = useRef(null); - - const [state, setState] = useState(SSE_STATE.DISCONNECTED); - const [error, setError] = useState(null); - - useEffect(() => { - if (!shouldEnable) { - // Clean up if disabled - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setState(SSE_STATE.DISCONNECTED); - return; - } - - const connect = () => { - // Don't reconnect if already connected or connecting - // EventSource.CLOSED (2) is well-supported in all modern browsers - if (eventSourceRef.current && eventSourceRef.current.readyState !== 2) { - return; - } - - setState(SSE_STATE.CONNECTING); - setError(null); - - const eventSource = new EventSource('/api/events'); - eventSourceRef.current = eventSource; - - eventSource.onopen = () => { - setState(SSE_STATE.CONNECTED); - setError(null); - reconnectAttemptRef.current = 0; - }; - - eventSource.addEventListener('reportData', event => { - try { - const data = JSON.parse(event.data); - // Update React Query cache directly - queryClient.setQueryData(queryKeys.reportData(), data); - } catch { - // Ignore parse errors - } - }); - - eventSource.addEventListener('heartbeat', () => { - // Heartbeat received - connection is alive - }); - - eventSource.onerror = () => { - eventSource.close(); - eventSourceRef.current = null; - setState(SSE_STATE.ERROR); - setError(new Error('SSE connection failed')); - - // Exponential backoff for reconnection (max 30 seconds) - const attempt = reconnectAttemptRef.current; - const delay = Math.min(1000 * 2 ** attempt, 30000); - reconnectAttemptRef.current = attempt + 1; - - reconnectTimerRef.current = window.setTimeout(() => { - connect(); - }, delay); - }; - }; - - connect(); - - return () => { - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setState(SSE_STATE.DISCONNECTED); - }; - }, [shouldEnable, queryClient]); - - return { state, error }; +export function useSSEState() { + let context = useContext(SSEContext); + if (!context) { + throw new Error('useSSEState must be used within SSEProvider'); + } + return context; } diff --git a/src/reporter/src/main.jsx b/src/reporter/src/main.jsx index d61f550..0c6183f 100644 --- a/src/reporter/src/main.jsx +++ b/src/reporter/src/main.jsx @@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client'; import AppRouter from './components/app-router.jsx'; import { ToastProvider } from './components/ui/toast.jsx'; import { queryClient } from './lib/query-client.js'; +import { SSEProvider } from './providers/sse-provider.jsx'; import './reporter.css'; let initializeReporter = () => { @@ -18,9 +19,11 @@ let initializeReporter = () => { ReactDOM.createRoot(root).render( - - - + + + + + ); diff --git a/src/reporter/src/providers/sse-provider.jsx b/src/reporter/src/providers/sse-provider.jsx new file mode 100644 index 0000000..999bb21 --- /dev/null +++ b/src/reporter/src/providers/sse-provider.jsx @@ -0,0 +1,157 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, useEffect, useRef, useState } from 'react'; +import { queryKeys } from '../lib/query-keys.js'; + +export let SSE_STATE = { + CONNECTING: 'connecting', + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + ERROR: 'error', +}; + +export let SSEContext = createContext(null); + +/** + * Singleton SSE provider — manages one EventSource connection for the entire app. + * Updates React Query cache on reportData, comparisonUpdate, comparisonRemoved, + * and summaryUpdate events. + */ +export function SSEProvider({ enabled = true, children }) { + let queryClient = useQueryClient(); + let eventSourceRef = useRef(null); + let reconnectAttemptRef = useRef(0); + let reconnectTimerRef = useRef(null); + + let [state, setState] = useState(SSE_STATE.DISCONNECTED); + let [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setState(SSE_STATE.DISCONNECTED); + return; + } + + let connect = () => { + // EventSource.CLOSED === 2 + if (eventSourceRef.current && eventSourceRef.current.readyState !== 2) { + return; + } + + setState(SSE_STATE.CONNECTING); + setError(null); + + let eventSource = new EventSource('/api/events'); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setState(SSE_STATE.CONNECTED); + setError(null); + reconnectAttemptRef.current = 0; + }; + + // Full report data — sent on initial connection + eventSource.addEventListener('reportData', event => { + try { + let data = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), data); + } catch { + // Ignore parse errors + } + }); + + // Incremental: single comparison added or changed + eventSource.addEventListener('comparisonUpdate', event => { + try { + let comparison = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old) return old; + let comparisons = old.comparisons || []; + let idx = comparisons.findIndex(c => c.id === comparison.id); + if (idx >= 0) { + comparisons = comparisons.map((c, i) => + i === idx ? { ...c, ...comparison } : c + ); + } else { + comparisons = [...comparisons, comparison]; + } + return { ...old, comparisons }; + }); + } catch { + // Ignore parse errors + } + }); + + // Incremental: comparison removed + eventSource.addEventListener('comparisonRemoved', event => { + try { + let { id } = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old?.comparisons) return old; + return { + ...old, + comparisons: old.comparisons.filter(c => c.id !== id), + }; + }); + } catch { + // Ignore parse errors + } + }); + + // Incremental: summary fields changed + eventSource.addEventListener('summaryUpdate', event => { + try { + let summary = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old) return old; + return { ...old, ...summary, comparisons: old.comparisons }; + }); + } catch { + // Ignore parse errors + } + }); + + eventSource.addEventListener('heartbeat', () => { + // Connection alive + }); + + eventSource.onerror = () => { + eventSource.close(); + eventSourceRef.current = null; + setState(SSE_STATE.ERROR); + setError(new Error('SSE connection failed')); + + let attempt = reconnectAttemptRef.current; + let delay = Math.min(1000 * 2 ** attempt, 30000); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = window.setTimeout(() => { + connect(); + }, delay); + }; + }; + + connect(); + + return () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setState(SSE_STATE.DISCONNECTED); + }; + }, [enabled, queryClient]); + + return ( + + {children} + + ); +} diff --git a/src/server/routers/events.js b/src/server/routers/events.js index cca79c6..386bc71 100644 --- a/src/server/routers/events.js +++ b/src/server/routers/events.js @@ -62,6 +62,76 @@ export function createEventsRouter(context) { res.write(`data: ${JSON.stringify(data)}\n\n`); }; + /** + * Build a lookup map from comparisons array keyed by id + */ + const buildComparisonMap = comparisons => { + let map = new Map(); + for (let c of comparisons) { + map.set(c.id, c); + } + return map; + }; + + const comparisonChanged = (oldComp, newComp) => { + return JSON.stringify(oldComp) !== JSON.stringify(newComp); + }; + + /** + * Extract summary fields (everything except comparisons) for diffing + */ + const extractSummary = data => { + let { comparisons: _c, ...summary } = data; + return summary; + }; + + /** + * Check if summary-level fields changed between old and new data + */ + const summaryChanged = (oldData, newData) => { + let oldSummary = extractSummary(oldData); + let newSummary = extractSummary(newData); + return JSON.stringify(oldSummary) !== JSON.stringify(newSummary); + }; + + /** + * Send incremental updates by diffing old vs new report data. + * Returns true if any events were sent. + */ + const sendIncrementalUpdates = (res, oldData, newData) => { + let sent = false; + let oldComparisons = oldData.comparisons || []; + let newComparisons = newData.comparisons || []; + + let oldMap = buildComparisonMap(oldComparisons); + let newMap = buildComparisonMap(newComparisons); + + // New or changed comparisons — sends the full comparison object, not a partial delta + for (let [id, newComp] of newMap) { + let oldComp = oldMap.get(id); + if (!oldComp || comparisonChanged(oldComp, newComp)) { + sendEvent(res, 'comparisonUpdate', newComp); + sent = true; + } + } + + // Removed comparisons + for (let [id] of oldMap) { + if (!newMap.has(id)) { + sendEvent(res, 'comparisonRemoved', { id }); + sent = true; + } + } + + // Summary-level changes (total, passed, failed, etc.) + if (summaryChanged(oldData, newData)) { + sendEvent(res, 'summaryUpdate', extractSummary(newData)); + sent = true; + } + + return sent; + }; + return async function handleEventsRoute(req, res, pathname) { if (req.method !== 'GET' || pathname !== '/api/events') { return false; @@ -75,10 +145,10 @@ export function createEventsRouter(context) { 'X-Accel-Buffering': 'no', // Disable nginx buffering }); - // Send initial data immediately - const initialData = readReportData(); - if (initialData) { - sendEvent(res, 'reportData', initialData); + // Send initial full data immediately + let lastSentData = readReportData(); + if (lastSentData) { + sendEvent(res, 'reportData', lastSentData); } // Debounce file change events (fs.watch can fire multiple times) @@ -86,10 +156,19 @@ export function createEventsRouter(context) { let watcher = null; const sendUpdate = () => { - const data = readReportData(); - if (data) { - sendEvent(res, 'reportData', data); + const newData = readReportData(); + if (!newData) return; + + if (!lastSentData) { + // No previous data — send full payload + sendEvent(res, 'reportData', newData); + } else { + // Diff and send incremental updates + let sent = sendIncrementalUpdates(res, lastSentData, newData); + // If nothing changed, skip (no event needed) + if (!sent) return; } + lastSentData = newData; }; // Watch for file changes diff --git a/tests/server/routers/events.test.js b/tests/server/routers/events.test.js index c372e92..669d5d3 100644 --- a/tests/server/routers/events.test.js +++ b/tests/server/routers/events.test.js @@ -331,6 +331,209 @@ describe('server/routers/events', () => { req.emit('close'); }); + it('sends comparisonUpdate for new comparisons', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'existing', status: 'passed' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Add a new comparison + let updatedData = { + comparisons: [ + { id: 'a', name: 'existing', status: 'passed' }, + { id: 'b', name: 'new-one', status: 'failed' }, + ], + total: 2, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonUpdate')); + assert.ok(output.includes('"id":"b"')); + assert.ok(output.includes('"name":"new-one"')); + + req.emit('close'); + }); + + it('sends comparisonUpdate for changed comparisons', async () => { + let initialData = { + comparisons: [ + { id: 'a', name: 'test', status: 'passed', diffPercentage: 0 }, + ], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Change the comparison's status + let updatedData = { + comparisons: [ + { id: 'a', name: 'test', status: 'failed', diffPercentage: 5.2 }, + ], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonUpdate')); + assert.ok(output.includes('"status":"failed"')); + // Should not send full reportData for incremental changes + let reportDataCount = (output.match(/event: reportData/g) || []).length; + assert.strictEqual( + reportDataCount, + 1, + 'Only initial reportData should be sent' + ); + + req.emit('close'); + }); + + it('sends comparisonRemoved when comparison is deleted', async () => { + let initialData = { + comparisons: [ + { id: 'a', name: 'keep' }, + { id: 'b', name: 'remove' }, + ], + total: 2, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Remove comparison b + let updatedData = { + comparisons: [{ id: 'a', name: 'keep' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonRemoved')); + assert.ok(output.includes('"id":"b"')); + + req.emit('close'); + }); + + it('sends summaryUpdate when summary fields change', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'test' }], + total: 1, + passed: 1, + failed: 0, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Change summary fields only, same comparisons + let updatedData = { + comparisons: [{ id: 'a', name: 'test' }], + total: 1, + passed: 0, + failed: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: summaryUpdate')); + assert.ok(output.includes('"failed":1')); + // Summary should not include comparisons + let summaryLine = output + .split('\n') + .find(l => l.startsWith('data:') && l.includes('"failed":1')); + assert.ok(!summaryLine.includes('"comparisons"')); + + req.emit('close'); + }); + + it('sends no events when nothing changed', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'test', status: 'passed' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + let chunksAfterInitial = res.chunks.length; + + // Write identical data + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // No new chunks should have been written + assert.strictEqual( + res.chunks.length, + chunksAfterInitial, + 'No events sent for identical data' + ); + + req.emit('close'); + }); + it('ignores changes to other files in .vizzly directory', async () => { let handler = createEventsRouter({ workingDir: testDir }); let req = createMockRequest('GET');