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
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 && (
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');
});
});
});