diff --git a/src/reporter/src/api/client.js b/src/reporter/src/api/client.js index b9a99cd..d24abce 100644 --- a/src/reporter/src/api/client.js +++ b/src/reporter/src/api/client.js @@ -9,6 +9,8 @@ * - api.auth.* - Authentication */ +import { normalizeReportData } from '../utils/report-data.js'; + /** * Make a JSON API request * @param {string} url - Request URL @@ -45,7 +47,8 @@ export const tdd = { * @returns {Promise} */ async getReportData() { - return fetchJson('/api/report-data'); + let data = await fetchJson('/api/report-data'); + return normalizeReportData(data); }, /** diff --git a/src/reporter/src/components/comparison/comparison-viewer.jsx b/src/reporter/src/components/comparison/comparison-viewer.jsx index abb070b..fd17f74 100644 --- a/src/reporter/src/components/comparison/comparison-viewer.jsx +++ b/src/reporter/src/components/comparison/comparison-viewer.jsx @@ -5,6 +5,7 @@ import { } from '@vizzly-testing/observatory'; import { useCallback, useMemo, useState } from 'react'; import { VIEW_MODES } from '../../utils/constants.js'; +import { withImageVersion } from '../../utils/image-url.js'; /** * Comparison Viewer for inline card display @@ -36,12 +37,20 @@ export default function ComparisonViewer({ comparison, viewMode }) { [comparison] ); - // Build image URLs - no memoization needed, object creation is cheap - const imageUrls = { - current: comparison.current, - baseline: comparison.baseline, - diff: comparison.diff, - }; + // Build image URLs once per comparison update. + const imageUrls = useMemo( + () => ({ + current: withImageVersion(comparison.current, comparison.timestamp), + baseline: withImageVersion(comparison.baseline, comparison.timestamp), + diff: withImageVersion(comparison.diff, comparison.timestamp), + }), + [ + comparison.current, + comparison.baseline, + comparison.diff, + comparison.timestamp, + ] + ); // For new screenshots, just show the current image (no baseline exists yet) if (comparison.status === 'new' || comparison.status === 'baseline-created') { @@ -52,7 +61,7 @@ export default function ComparisonViewer({ comparison, viewMode }) { First screenshot - creating new baseline

New baseline screenshot diff --git a/src/reporter/src/components/comparison/fullscreen-viewer.jsx b/src/reporter/src/components/comparison/fullscreen-viewer.jsx index 9f09cb5..9f02b4d 100644 --- a/src/reporter/src/components/comparison/fullscreen-viewer.jsx +++ b/src/reporter/src/components/comparison/fullscreen-viewer.jsx @@ -45,6 +45,7 @@ import { } from '@vizzly-testing/observatory'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { VIEW_MODES } from '../../utils/constants.js'; +import { withImageVersion } from '../../utils/image-url.js'; import { ScreenshotDisplay } from './screenshot-display.jsx'; /** @@ -641,7 +642,10 @@ function FullscreenViewerInner({ onNavigate(item)} /> diff --git a/src/reporter/src/components/comparison/screenshot-display.jsx b/src/reporter/src/components/comparison/screenshot-display.jsx index f134291..f5e49f0 100644 --- a/src/reporter/src/components/comparison/screenshot-display.jsx +++ b/src/reporter/src/components/comparison/screenshot-display.jsx @@ -6,6 +6,7 @@ import { ToggleMode, } from '@vizzly-testing/observatory'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { withImageVersion } from '../../utils/image-url.js'; /** * Unified Screenshot Display Component - matches Observatory architecture @@ -123,14 +124,21 @@ export function ScreenshotDisplay({ setImageLoadStates(prev => new Map(prev).set(imageKey, 'loaded')); }, []); - // Build image URLs from comparison object - no memoization needed, object creation is cheap - const imageUrls = comparison - ? { - current: comparison.current, - baseline: comparison.baseline, - diff: comparison.diff, - } - : {}; + // Build image URLs once per comparison update. + const imageUrls = useMemo( + () => + comparison + ? { + current: withImageVersion(comparison.current, comparison.timestamp), + baseline: withImageVersion( + comparison.baseline, + comparison.timestamp + ), + diff: withImageVersion(comparison.diff, comparison.timestamp), + } + : {}, + [comparison] + ); // Create a screenshot-like object for the comparison modes const screenshot = useMemo(() => { @@ -213,7 +221,7 @@ export function ScreenshotDisplay({ > {comparison && ( {comparison.name handleImageLoad(`current-${screenshot?.id}`)} diff --git a/src/reporter/src/components/comparison/screenshot-list.jsx b/src/reporter/src/components/comparison/screenshot-list.jsx index 6435df5..3ff0593 100644 --- a/src/reporter/src/components/comparison/screenshot-list.jsx +++ b/src/reporter/src/components/comparison/screenshot-list.jsx @@ -7,6 +7,7 @@ import { XCircleIcon, } from '@heroicons/react/24/outline'; import { useMemo } from 'react'; +import { withImageVersion } from '../../utils/image-url.js'; import { Badge, Button } from '../design-system/index.js'; import SmartImage from '../ui/smart-image.jsx'; @@ -238,7 +239,10 @@ function ScreenshotGroupRow({ }) { let { primary, hasChanges, hasNew, maxDiff } = group; let needsAction = hasChanges || hasNew; - let thumbnailSrc = primary.current || primary.baseline; + let thumbnailSrc = withImageVersion( + primary.current || primary.baseline, + primary.timestamp + ); // Generate test ID from primary comparison let testId = `screenshot-group-${(primary.id || primary.signature || group.name).replace(/[^a-zA-Z0-9-]/g, '-')}`; diff --git a/src/reporter/src/providers/sse-provider.jsx b/src/reporter/src/providers/sse-provider.jsx index 999bb21..1a5780b 100644 --- a/src/reporter/src/providers/sse-provider.jsx +++ b/src/reporter/src/providers/sse-provider.jsx @@ -1,6 +1,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { createContext, useEffect, useRef, useState } from 'react'; import { queryKeys } from '../lib/query-keys.js'; +import { + normalizeComparisonUpdate, + normalizeReportData, +} from '../utils/report-data.js'; export let SSE_STATE = { CONNECTING: 'connecting', @@ -57,7 +61,10 @@ export function SSEProvider({ enabled = true, children }) { eventSource.addEventListener('reportData', event => { try { let data = JSON.parse(event.data); - queryClient.setQueryData(queryKeys.reportData(), data); + queryClient.setQueryData( + queryKeys.reportData(), + normalizeReportData(data) + ); } catch { // Ignore parse errors } @@ -66,9 +73,13 @@ export function SSEProvider({ enabled = true, children }) { // Incremental: single comparison added or changed eventSource.addEventListener('comparisonUpdate', event => { try { - let comparison = JSON.parse(event.data); + let incomingComparison = JSON.parse(event.data); queryClient.setQueryData(queryKeys.reportData(), old => { if (!old) return old; + let comparison = normalizeComparisonUpdate( + incomingComparison, + old.timestamp + ); let comparisons = old.comparisons || []; let idx = comparisons.findIndex(c => c.id === comparison.id); if (idx >= 0) { diff --git a/src/reporter/src/utils/image-url.js b/src/reporter/src/utils/image-url.js new file mode 100644 index 0000000..defb3fa --- /dev/null +++ b/src/reporter/src/utils/image-url.js @@ -0,0 +1,29 @@ +/** + * Add a version query param for local image URLs so updated screenshots + * are re-fetched when report data changes. + * + * This is a no-op for non-local URLs. + */ +export let LOCAL_IMAGE_PREFIX = '/images/'; + +export function withImageVersion(url, version) { + if (!url || typeof url !== 'string') { + return url; + } + + // Only rewrite local TDD image paths. + if (!url.startsWith(LOCAL_IMAGE_PREFIX)) { + return url; + } + + if (version === null || version === undefined) { + return url; + } + + let [path, queryString = ''] = url.split('?'); + let params = new URLSearchParams(queryString); + params.set('v', String(version)); + let query = params.toString(); + + return query ? `${path}?${query}` : path; +} diff --git a/src/reporter/src/utils/report-data.js b/src/reporter/src/utils/report-data.js new file mode 100644 index 0000000..f7971ff --- /dev/null +++ b/src/reporter/src/utils/report-data.js @@ -0,0 +1,40 @@ +/** + * Ensure each comparison has a timestamp for image cache-busting. + */ +export function normalizeReportData(reportData) { + if (!reportData || !Array.isArray(reportData.comparisons)) { + return reportData; + } + + let needsNormalization = reportData.comparisons.some( + comparison => comparison && comparison.timestamp == null + ); + + if (!needsNormalization) { + return reportData; + } + + let fallbackTimestamp = reportData.timestamp ?? Date.now(); + let comparisons = reportData.comparisons.map(comparison => + normalizeComparisonUpdate(comparison, fallbackTimestamp) + ); + + return { + ...reportData, + comparisons, + }; +} + +/** + * Ensure a single comparison includes a timestamp. + */ +export function normalizeComparisonUpdate(comparison, fallbackTimestamp) { + if (!comparison || comparison.timestamp != null) { + return comparison; + } + + return { + ...comparison, + timestamp: fallbackTimestamp ?? Date.now(), + }; +} diff --git a/src/server/routers/assets.js b/src/server/routers/assets.js index 4b7b3ff..9023b54 100644 --- a/src/server/routers/assets.js +++ b/src/server/routers/assets.js @@ -84,6 +84,10 @@ export function createAssetsRouter() { if (existsSync(fullImagePath)) { try { const imageData = readFileSync(fullImagePath); + // Images are rewritten in place between TDD runs, so disable browser caching. + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); sendFile(res, imageData, 'image/png'); return true; } catch (error) { diff --git a/tests/reporter/utils/image-url.test.js b/tests/reporter/utils/image-url.test.js new file mode 100644 index 0000000..ba6136c --- /dev/null +++ b/tests/reporter/utils/image-url.test.js @@ -0,0 +1,57 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { withImageVersion } from '../../../src/reporter/src/utils/image-url.js'; + +describe('reporter/utils/image-url', () => { + it('returns original value for non-string urls', () => { + assert.strictEqual(withImageVersion(null, 1), null); + assert.strictEqual(withImageVersion(undefined, 1), undefined); + assert.strictEqual(withImageVersion(42, 1), 42); + }); + + it('returns original url for non-local images', () => { + let url = 'https://cdn.example.com/image.png'; + assert.strictEqual(withImageVersion(url, 123), url); + }); + + it('returns original url when version is missing', () => { + let url = '/images/current/homepage.png'; + assert.strictEqual(withImageVersion(url, null), url); + assert.strictEqual(withImageVersion(url, undefined), url); + }); + + it('appends v query param for local image urls', () => { + assert.strictEqual( + withImageVersion('/images/current/homepage.png', 123), + '/images/current/homepage.png?v=123' + ); + }); + + it('supports zero as a valid cache-busting version', () => { + assert.strictEqual( + withImageVersion('/images/current/homepage.png', 0), + '/images/current/homepage.png?v=0' + ); + }); + + it('appends v query param using ampersand when query already exists', () => { + assert.strictEqual( + withImageVersion('/images/current/homepage.png?mode=thumb', 456), + '/images/current/homepage.png?mode=thumb&v=456' + ); + }); + + it('replaces existing v query param instead of duplicating', () => { + assert.strictEqual( + withImageVersion('/images/current/homepage.png?v=old&mode=thumb', 456), + '/images/current/homepage.png?v=456&mode=thumb' + ); + }); + + it('encodes non-numeric version values', () => { + assert.strictEqual( + withImageVersion('/images/current/homepage.png', 'run 1'), + '/images/current/homepage.png?v=run+1' + ); + }); +}); diff --git a/tests/reporter/utils/report-data.test.js b/tests/reporter/utils/report-data.test.js new file mode 100644 index 0000000..f786bc9 --- /dev/null +++ b/tests/reporter/utils/report-data.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + normalizeComparisonUpdate, + normalizeReportData, +} from '../../../src/reporter/src/utils/report-data.js'; + +describe('reporter/utils/report-data', () => { + describe('normalizeReportData', () => { + it('returns input when report data is nullish', () => { + assert.strictEqual(normalizeReportData(null), null); + assert.strictEqual(normalizeReportData(undefined), undefined); + }); + + it('returns input when comparisons is not an array', () => { + let data = { timestamp: 123 }; + assert.strictEqual(normalizeReportData(data), data); + }); + + it('adds missing comparison timestamps from report timestamp', () => { + let data = { + timestamp: 123, + comparisons: [{ id: 'a' }, { id: 'b', timestamp: 456 }], + }; + + let result = normalizeReportData(data); + + assert.strictEqual(result.comparisons[0].timestamp, 123); + assert.strictEqual(result.comparisons[1].timestamp, 456); + }); + + it('preserves existing comparison timestamps', () => { + let data = { + timestamp: 123, + comparisons: [ + { id: 'a', timestamp: 111 }, + { id: 'b', timestamp: 222 }, + ], + }; + + let result = normalizeReportData(data); + + assert.deepStrictEqual(result.comparisons, data.comparisons); + }); + }); + + describe('normalizeComparisonUpdate', () => { + it('returns input when comparison already has timestamp', () => { + let comparison = { id: 'a', timestamp: 111 }; + assert.strictEqual( + normalizeComparisonUpdate(comparison, 999), + comparison + ); + }); + + it('adds fallback timestamp when missing', () => { + let comparison = { id: 'a' }; + let result = normalizeComparisonUpdate(comparison, 999); + + assert.strictEqual(result.timestamp, 999); + }); + }); +}); diff --git a/tests/server/routers/assets.test.js b/tests/server/routers/assets.test.js index 88b6462..63da92d 100644 --- a/tests/server/routers/assets.test.js +++ b/tests/server/routers/assets.test.js @@ -148,6 +148,12 @@ describe('server/routers/assets', () => { assert.strictEqual(res.statusCode, 200); assert.strictEqual(res.getHeader('Content-Type'), 'image/png'); + assert.strictEqual( + res.getHeader('Cache-Control'), + 'no-store, no-cache, must-revalidate' + ); + assert.strictEqual(res.getHeader('Pragma'), 'no-cache'); + assert.strictEqual(res.getHeader('Expires'), '0'); }); it('returns 404 for non-existent image', async () => { @@ -177,6 +183,12 @@ describe('server/routers/assets', () => { assert.strictEqual(res.statusCode, 200); assert.strictEqual(res.getHeader('Content-Type'), 'image/png'); + assert.strictEqual( + res.getHeader('Cache-Control'), + 'no-store, no-cache, must-revalidate' + ); + assert.strictEqual(res.getHeader('Pragma'), 'no-cache'); + assert.strictEqual(res.getHeader('Expires'), '0'); }); }); });