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