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');