diff --git a/src/reporter/src/components/dashboard/dashboard-filters.jsx b/src/reporter/src/components/dashboard/dashboard-filters.jsx
index 10ac3800..11d4a608 100644
--- a/src/reporter/src/components/dashboard/dashboard-filters.jsx
+++ b/src/reporter/src/components/dashboard/dashboard-filters.jsx
@@ -25,14 +25,8 @@ function getDeviceIcon(width) {
return ComputerDesktopIcon;
}
-/**
- * Get device label for viewport
- */
-function _getDeviceLabel(viewportStr) {
- let width = parseInt(viewportStr.split('×')[0], 10);
- if (width <= 480) return 'Mobile';
- if (width <= 1024) return 'Tablet';
- return 'Desktop';
+function getViewportWidth(viewport) {
+ return Number(viewport.split('×')[0]);
}
/**
@@ -299,7 +293,7 @@ export default function DashboardFilters({
// Render viewport option with device icon
let renderViewportOption = useCallback(viewport => {
- let DeviceIcon = getDeviceIcon(parseInt(viewport.split('×')[0], 10));
+ let DeviceIcon = getDeviceIcon(getViewportWidth(viewport));
return (
<>
@@ -313,7 +307,7 @@ export default function DashboardFilters({
if (value === 'all') {
return All viewports;
}
- let DeviceIcon = getDeviceIcon(parseInt(value.split('×')[0], 10));
+ let DeviceIcon = getDeviceIcon(getViewportWidth(value));
return (
@@ -553,7 +547,7 @@ export default function DashboardFilters({
{selectedViewport !== 'all' && (
setSelectedViewport('all')}
/>
)}
diff --git a/src/server/routers/events.js b/src/server/routers/events.js
index 97fda285..e15ce07e 100644
--- a/src/server/routers/events.js
+++ b/src/server/routers/events.js
@@ -11,130 +11,207 @@ let FILE_POLL_INTERVAL_MS = 50;
let REPORT_READ_RETRY_MS = 25;
let MAX_REPORT_READ_RETRIES = 3;
+let defaultTimers = {
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+};
+
/**
- * Create events router for SSE
- * @param {Object} context - Router context
- * @param {string} context.workingDir - Working directory for report data
- * @returns {Function} Route handler
+ * Read and parse JSON from disk, returning null on missing or invalid files.
*/
-export function createEventsRouter(context) {
- const { workingDir = process.cwd() } = context || {};
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
- const baselineMetadataPath = join(
- workingDir,
- '.vizzly',
- 'baselines',
- 'metadata.json'
- );
+export function readJsonFile(path) {
+ if (!existsSync(path)) {
+ return null;
+ }
- /**
- * Read and parse baseline metadata, returning null on error
- */
- const readBaselineMetadata = () => {
- if (!existsSync(baselineMetadataPath)) {
- return null;
- }
- try {
- return JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
- } catch {
- return null;
- }
- };
+ try {
+ return JSON.parse(readFileSync(path, 'utf8'));
+ } catch {
+ return null;
+ }
+}
- /**
- * Read and parse report data with baseline metadata included
- */
- const readReportData = () => {
- if (!existsSync(reportDataPath)) {
- return null;
- }
- try {
- const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
- // Include baseline metadata for stats view
- data.baseline = readBaselineMetadata();
- return data;
- } catch {
- return null;
- }
- };
+/**
+ * Read report data and attach baseline metadata for the stats view.
+ */
+export function readReportDataFile({ reportDataPath, baselineMetadataPath }) {
+ let data = readJsonFile(reportDataPath);
+ if (!data) {
+ return null;
+ }
- /**
- * Send SSE event to response
- */
- const sendEvent = (res, eventType, data) => {
- if (res.writableEnded) return;
- res.write(`event: ${eventType}\n`);
- res.write(`data: ${JSON.stringify(data)}\n\n`);
+ return {
+ ...data,
+ baseline: readJsonFile(baselineMetadataPath),
};
+}
- /**
- * 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);
+/**
+ * Build a lookup map from comparisons array keyed by id
+ */
+function buildComparisonMap(comparisons) {
+ let map = new Map();
+ for (let c of comparisons) {
+ map.set(c.id, c);
+ }
+ return map;
+}
+
+function comparisonChanged(oldComp, newComp) {
+ return JSON.stringify(oldComp) !== JSON.stringify(newComp);
+}
+
+/**
+ * Extract summary fields (everything except comparisons) for diffing
+ */
+function extractSummary(data) {
+ let { comparisons: _c, ...summary } = data;
+ return summary;
+}
+
+/**
+ * Check if summary-level fields changed between old and new data
+ */
+function summaryChanged(oldData, newData) {
+ let oldSummary = extractSummary(oldData);
+ let newSummary = extractSummary(newData);
+ return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
+}
+
+/**
+ * Build incremental SSE events by diffing old vs new report data.
+ */
+export function buildReportDataEvents(oldData, newData) {
+ if (!oldData) {
+ return [{ type: 'reportData', data: newData }];
+ }
+
+ let events = [];
+ let oldComparisons = oldData.comparisons || [];
+ let newComparisons = newData.comparisons || [];
+
+ let oldMap = buildComparisonMap(oldComparisons);
+ let newMap = buildComparisonMap(newComparisons);
+
+ // New or changed comparisons send the full comparison object, not a partial delta.
+ for (let [id, newComp] of newMap) {
+ let oldComp = oldMap.get(id);
+ if (!oldComp || comparisonChanged(oldComp, newComp)) {
+ events.push({ type: 'comparisonUpdate', data: newComp });
}
- return map;
- };
+ }
- const comparisonChanged = (oldComp, newComp) => {
- return JSON.stringify(oldComp) !== JSON.stringify(newComp);
- };
+ for (let [id] of oldMap) {
+ if (!newMap.has(id)) {
+ events.push({ type: 'comparisonRemoved', data: { id } });
+ }
+ }
- /**
- * Extract summary fields (everything except comparisons) for diffing
- */
- const extractSummary = data => {
- let { comparisons: _c, ...summary } = data;
- return summary;
- };
+ if (summaryChanged(oldData, newData)) {
+ events.push({ type: 'summaryUpdate', data: extractSummary(newData) });
+ }
- /**
- * 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);
- };
+ return events;
+}
- /**
- * 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;
- }
+/**
+ * Watch report-data.json with fs.watch plus a lightweight mtime fallback.
+ */
+export function watchReportDataFile({
+ workingDir,
+ reportDataPath,
+ onReportDataChanged,
+ timers = defaultTimers,
+}) {
+ let watcher = null;
+ let filePollInterval = null;
+ let vizzlyDir = join(workingDir, '.vizzly');
+
+ if (existsSync(vizzlyDir)) {
+ try {
+ watcher = watch(
+ vizzlyDir,
+ { recursive: false },
+ (_eventType, filename) => {
+ // Some platforms occasionally omit the filename for directory watch
+ // events. In that case, fall back to re-reading report data.
+ if (!filename || filename === 'report-data.json') {
+ onReportDataChanged();
+ }
+ }
+ );
+ watcher.on('error', () => {
+ if (watcher) {
+ watcher.close();
+ watcher = null;
+ }
+ });
+ } catch {
+ // File watching not available, mtime polling remains as fallback.
}
+ }
- // Removed comparisons
- for (let [id] of oldMap) {
- if (!newMap.has(id)) {
- sendEvent(res, 'comparisonRemoved', { id });
- sent = true;
+ let lastPolledMtime = existsSync(reportDataPath)
+ ? statSync(reportDataPath).mtimeMs
+ : null;
+
+ filePollInterval = timers.setInterval(() => {
+ let nextMtime = null;
+ if (existsSync(reportDataPath)) {
+ try {
+ nextMtime = statSync(reportDataPath).mtimeMs;
+ } catch {
+ nextMtime = null;
}
}
+ if (nextMtime !== lastPolledMtime) {
+ lastPolledMtime = nextMtime;
+ onReportDataChanged();
+ }
+ }, FILE_POLL_INTERVAL_MS);
- // Summary-level changes (total, passed, failed, etc.)
- if (summaryChanged(oldData, newData)) {
- sendEvent(res, 'summaryUpdate', extractSummary(newData));
- sent = true;
+ return () => {
+ if (watcher) {
+ watcher.close();
+ watcher = null;
}
+ if (filePollInterval) {
+ timers.clearInterval(filePollInterval);
+ filePollInterval = null;
+ }
+ };
+}
- return sent;
+/**
+ * Create events router for SSE
+ * @param {Object} context - Router context
+ * @param {string} context.workingDir - Working directory for report data
+ * @returns {Function} Route handler
+ */
+export function createEventsRouter(context) {
+ let {
+ workingDir = process.cwd(),
+ readReportData = readReportDataFile,
+ watchReportData = watchReportDataFile,
+ timers = defaultTimers,
+ } = context || {};
+ let reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
+ let baselineMetadataPath = join(
+ workingDir,
+ '.vizzly',
+ 'baselines',
+ 'metadata.json'
+ );
+
+ /**
+ * Send SSE event to response
+ */
+ let sendEvent = (res, eventType, data) => {
+ if (res.writableEnded) return;
+ res.write(`event: ${eventType}\n`);
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
};
return async function handleEventsRoute(req, res, pathname) {
@@ -151,30 +228,28 @@ export function createEventsRouter(context) {
});
// Send initial full data immediately
- let lastSentData = readReportData();
+ let lastSentData = readReportData({ reportDataPath, baselineMetadataPath });
if (lastSentData) {
sendEvent(res, 'reportData', lastSentData);
}
// Debounce file change events (fs.watch can fire multiple times)
let debounceTimer = null;
- let watcher = null;
- let filePollInterval = null;
- const scheduleUpdate = () => {
+ let scheduleUpdate = () => {
if (debounceTimer) {
- clearTimeout(debounceTimer);
+ timers.clearTimeout(debounceTimer);
}
- debounceTimer = setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
+ debounceTimer = timers.setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
};
- const sendUpdate = (retryCount = 0) => {
- const newData = readReportData();
+ let sendUpdate = (retryCount = 0) => {
+ let newData = readReportData({ reportDataPath, baselineMetadataPath });
if (!newData) {
if (
existsSync(reportDataPath) &&
retryCount < MAX_REPORT_READ_RETRIES
) {
- debounceTimer = setTimeout(
+ debounceTimer = timers.setTimeout(
() => sendUpdate(retryCount + 1),
REPORT_READ_RETRY_MS
);
@@ -182,84 +257,39 @@ export function createEventsRouter(context) {
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;
+ let events = buildReportDataEvents(lastSentData, newData);
+ if (events.length === 0) {
+ return;
}
- lastSentData = newData;
- };
- // Watch for file changes
- const vizzlyDir = join(workingDir, '.vizzly');
- if (existsSync(vizzlyDir)) {
- try {
- watcher = watch(
- vizzlyDir,
- { recursive: false },
- (_eventType, filename) => {
- // Some platforms occasionally omit the filename for directory watch
- // events. In that case, fall back to re-reading report data.
- if (!filename || filename === 'report-data.json') {
- scheduleUpdate();
- }
- }
- );
- watcher.on('error', () => {
- if (watcher) {
- watcher.close();
- watcher = null;
- }
- });
- } catch {
- // File watching not available, client will fall back to polling
+ for (let event of events) {
+ sendEvent(res, event.type, event.data);
}
- }
+ lastSentData = newData;
+ };
- let lastPolledMtime = existsSync(reportDataPath)
- ? statSync(reportDataPath).mtimeMs
- : null;
- filePollInterval = setInterval(() => {
- let nextMtime = null;
- if (existsSync(reportDataPath)) {
- try {
- nextMtime = statSync(reportDataPath).mtimeMs;
- } catch {
- nextMtime = null;
- }
- }
- if (nextMtime !== lastPolledMtime) {
- lastPolledMtime = nextMtime;
- scheduleUpdate();
- }
- }, FILE_POLL_INTERVAL_MS);
+ let cleanupReportWatcher = watchReportData({
+ workingDir,
+ reportDataPath,
+ onReportDataChanged: scheduleUpdate,
+ timers,
+ });
// Heartbeat to keep connection alive (every 30 seconds)
- const heartbeatInterval = setInterval(() => {
+ let heartbeatInterval = timers.setInterval(() => {
if (!res.writableEnded) {
sendEvent(res, 'heartbeat', { timestamp: Date.now() });
}
}, 30000);
// Cleanup on connection close
- const cleanup = () => {
+ let cleanup = () => {
if (debounceTimer) {
- clearTimeout(debounceTimer);
+ timers.clearTimeout(debounceTimer);
debounceTimer = null;
}
- clearInterval(heartbeatInterval);
- if (watcher) {
- watcher.close();
- watcher = null;
- }
- if (filePollInterval) {
- clearInterval(filePollInterval);
- filePollInterval = null;
- }
+ timers.clearInterval(heartbeatInterval);
+ cleanupReportWatcher();
};
req.on('close', cleanup);
diff --git a/tests/server/routers/assets.test.js b/tests/server/routers/assets.test.js
index 63da92d0..faf6e2a7 100644
--- a/tests/server/routers/assets.test.js
+++ b/tests/server/routers/assets.test.js
@@ -3,49 +3,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createAssetsRouter } from '../../../src/server/routers/assets.js';
-
-/**
- * Creates a mock HTTP request
- */
-function createMockRequest(method = 'GET') {
- return { method };
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body && typeof body === 'string' ? JSON.parse(body) : body;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
describe('server/routers/assets', () => {
let testDir = join(process.cwd(), '.test-assets-router');
diff --git a/tests/server/routers/auth.test.js b/tests/server/routers/auth.test.js
index 013da59d..4f06ebae 100644
--- a/tests/server/routers/auth.test.js
+++ b/tests/server/routers/auth.test.js
@@ -1,60 +1,10 @@
import assert from 'node:assert';
-import { EventEmitter } from 'node:events';
import { describe, it } from 'node:test';
import { createAuthRouter } from '../../../src/server/routers/auth.js';
-
-/**
- * Creates a mock HTTP request with body support
- */
-function createMockRequest(method = 'GET', body = null) {
- let emitter = new EventEmitter();
- emitter.method = method;
-
- if (body !== null) {
- process.nextTick(() => {
- emitter.emit('data', JSON.stringify(body));
- emitter.emit('end');
- });
- }
-
- return emitter;
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body ? JSON.parse(body) : null;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
/**
* Creates a mock auth service
diff --git a/tests/server/routers/cloud-proxy.test.js b/tests/server/routers/cloud-proxy.test.js
index 497b7b09..e6a03b55 100644
--- a/tests/server/routers/cloud-proxy.test.js
+++ b/tests/server/routers/cloud-proxy.test.js
@@ -1,60 +1,10 @@
import assert from 'node:assert';
-import { EventEmitter } from 'node:events';
import { describe, it } from 'node:test';
import { createCloudProxyRouter } from '../../../src/server/routers/cloud-proxy.js';
-
-/**
- * Creates a mock HTTP request with body support
- */
-function createMockRequest(method = 'GET', body = null) {
- let emitter = new EventEmitter();
- emitter.method = method;
-
- if (body !== null) {
- process.nextTick(() => {
- emitter.emit('data', JSON.stringify(body));
- emitter.emit('end');
- });
- }
-
- return emitter;
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body ? JSON.parse(body) : null;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
/**
* Creates a mock URL object
diff --git a/tests/server/routers/config.test.js b/tests/server/routers/config.test.js
index fad17dd2..d7a75c26 100644
--- a/tests/server/routers/config.test.js
+++ b/tests/server/routers/config.test.js
@@ -1,60 +1,10 @@
import assert from 'node:assert';
-import { EventEmitter } from 'node:events';
import { describe, it } from 'node:test';
import { createConfigRouter } from '../../../src/server/routers/config.js';
-
-/**
- * Creates a mock HTTP request with body support
- */
-function createMockRequest(method = 'GET', body = null) {
- let emitter = new EventEmitter();
- emitter.method = method;
-
- if (body !== null) {
- process.nextTick(() => {
- emitter.emit('data', JSON.stringify(body));
- emitter.emit('end');
- });
- }
-
- return emitter;
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body ? JSON.parse(body) : null;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
/**
* Creates a mock config service
diff --git a/tests/server/routers/dashboard.test.js b/tests/server/routers/dashboard.test.js
index 591a009b..f059f501 100644
--- a/tests/server/routers/dashboard.test.js
+++ b/tests/server/routers/dashboard.test.js
@@ -3,49 +3,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createDashboardRouter } from '../../../src/server/routers/dashboard.js';
-
-/**
- * Creates a mock HTTP request
- */
-function createMockRequest(method = 'GET') {
- return { method };
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body && typeof body === 'string' ? JSON.parse(body) : body;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
describe('server/routers/dashboard', () => {
let testDir = join(process.cwd(), '.test-dashboard-router');
diff --git a/tests/server/routers/events.test.js b/tests/server/routers/events.test.js
index 669d5d37..f8c279b3 100644
--- a/tests/server/routers/events.test.js
+++ b/tests/server/routers/events.test.js
@@ -62,6 +62,66 @@ function createMockResponse() {
};
}
+function createManualTimers() {
+ let timeouts = [];
+ let intervals = new Set();
+
+ return {
+ setTimeout(fn) {
+ let timeout = { fn };
+ timeouts.push(timeout);
+ return timeout;
+ },
+ clearTimeout(timeout) {
+ timeouts = timeouts.filter(candidate => candidate !== timeout);
+ },
+ setInterval() {
+ let interval = {};
+ intervals.add(interval);
+ return interval;
+ },
+ clearInterval(interval) {
+ intervals.delete(interval);
+ },
+ flushTimeouts() {
+ let pending = timeouts;
+ timeouts = [];
+ for (let timeout of pending) {
+ timeout.fn();
+ }
+ },
+ };
+}
+
+function createEventsHarness(workingDir) {
+ let timers = createManualTimers();
+ let triggerReportDataChanged = () => {
+ throw new Error('report data watcher was not registered');
+ };
+ let cleanupCalled = false;
+ let handler = createEventsRouter({
+ workingDir,
+ timers,
+ watchReportData({ onReportDataChanged }) {
+ triggerReportDataChanged = onReportDataChanged;
+ return () => {
+ cleanupCalled = true;
+ };
+ },
+ });
+
+ return {
+ handler,
+ timers,
+ triggerReportDataChanged() {
+ triggerReportDataChanged();
+ },
+ get cleanupCalled() {
+ return cleanupCalled;
+ },
+ };
+}
+
describe('server/routers/events', () => {
let testDir = join(process.cwd(), '.test-events-router');
let originalCwd = process.cwd();
@@ -230,20 +290,19 @@ describe('server/routers/events', () => {
});
it('sends update when report-data.json changes', async () => {
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
- // Write initial data - this triggers the file watcher
writeFileSync(
join(testDir, '.vizzly', 'report-data.json'),
JSON.stringify({ comparisons: [{ id: 'updated' }] })
);
- // Wait for debounce (100ms) + buffer
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
let output = res.getOutput();
assert.ok(output.includes('event: reportData'));
@@ -254,33 +313,35 @@ describe('server/routers/events', () => {
});
it('debounces rapid file changes', async () => {
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Rapid file changes
writeFileSync(
join(testDir, '.vizzly', 'report-data.json'),
JSON.stringify({ version: 1 })
);
+ harness.triggerReportDataChanged();
writeFileSync(
join(testDir, '.vizzly', 'report-data.json'),
JSON.stringify({ version: 2 })
);
+ harness.triggerReportDataChanged();
writeFileSync(
join(testDir, '.vizzly', 'report-data.json'),
JSON.stringify({ version: 3 })
);
+ harness.triggerReportDataChanged();
- // Wait for debounce
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.timers.flushTimeouts();
let output = res.getOutput();
// Should only have one event with the final version
let eventCount = (output.match(/event: reportData/g) || []).length;
- assert.ok(eventCount >= 1, 'Should have at least one event');
+ assert.strictEqual(eventCount, 1, 'Should send one debounced event');
assert.ok(output.includes('"version":3'), 'Should contain final version');
// Clean up
@@ -288,11 +349,11 @@ describe('server/routers/events', () => {
});
it('does not send to closed connections', async () => {
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Mark connection as ended
res.end();
@@ -303,11 +364,10 @@ describe('server/routers/events', () => {
JSON.stringify({ test: true })
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
- // Should not have written after end()
- // The initial chunks should be empty since no initial data
- assert.ok(true, 'Should not crash when writing to closed connection');
+ assert.strictEqual(res.getOutput(), '');
// Clean up
req.emit('close');
@@ -341,11 +401,11 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Add a new comparison
let updatedData = {
@@ -360,7 +420,8 @@ describe('server/routers/events', () => {
JSON.stringify(updatedData)
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonUpdate'));
@@ -382,11 +443,11 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Change the comparison's status
let updatedData = {
@@ -400,7 +461,8 @@ describe('server/routers/events', () => {
JSON.stringify(updatedData)
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonUpdate'));
@@ -429,11 +491,11 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Remove comparison b
let updatedData = {
@@ -445,7 +507,8 @@ describe('server/routers/events', () => {
JSON.stringify(updatedData)
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
let output = res.getOutput();
assert.ok(output.includes('event: comparisonRemoved'));
@@ -466,11 +529,11 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Change summary fields only, same comparisons
let updatedData = {
@@ -484,7 +547,8 @@ describe('server/routers/events', () => {
JSON.stringify(updatedData)
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
let output = res.getOutput();
assert.ok(output.includes('event: summaryUpdate'));
@@ -508,11 +572,11 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
let chunksAfterInitial = res.chunks.length;
@@ -522,7 +586,8 @@ describe('server/routers/events', () => {
JSON.stringify(initialData)
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.triggerReportDataChanged();
+ harness.timers.flushTimeouts();
// No new chunks should have been written
assert.strictEqual(
@@ -535,11 +600,11 @@ describe('server/routers/events', () => {
});
it('ignores changes to other files in .vizzly directory', async () => {
- let handler = createEventsRouter({ workingDir: testDir });
+ let harness = createEventsHarness(testDir);
let req = createMockRequest('GET');
let res = createMockResponse();
- await handler(req, res, '/api/events');
+ await harness.handler(req, res, '/api/events');
// Write to a different file
writeFileSync(
@@ -547,7 +612,7 @@ describe('server/routers/events', () => {
JSON.stringify({ ignored: true })
);
- await new Promise(resolve => setTimeout(resolve, 200));
+ harness.timers.flushTimeouts();
// Should not have sent any events
let output = res.getOutput();
diff --git a/tests/server/routers/health.test.js b/tests/server/routers/health.test.js
index 0b14ebf4..7634e91f 100644
--- a/tests/server/routers/health.test.js
+++ b/tests/server/routers/health.test.js
@@ -3,49 +3,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { createHealthRouter } from '../../../src/server/routers/health.js';
-
-/**
- * Creates a mock HTTP request
- */
-function createMockRequest(method = 'GET') {
- return { method };
-}
-
-/**
- * Creates a mock HTTP response with tracking
- */
-function createMockResponse() {
- let headers = {};
- let statusCode = null;
- let body = null;
-
- return {
- get statusCode() {
- return statusCode;
- },
- set statusCode(code) {
- statusCode = code;
- },
- setHeader(name, value) {
- headers[name] = value;
- },
- getHeader(name) {
- return headers[name];
- },
- end(content) {
- body = content;
- },
- get headers() {
- return headers;
- },
- get body() {
- return body;
- },
- getParsedBody() {
- return body ? JSON.parse(body) : null;
- },
- };
-}
+import {
+ createMockRequest,
+ createMockResponse,
+} from '../../helpers/http-mocks.js';
describe('server/routers/health', () => {
let testDir = join(process.cwd(), '.test-health-router');