From 1b4d17d0ee1419e9d75e6ba3036e3ed6d49c1df5 Mon Sep 17 00:00:00 2001
From: Robert DeLuca
Date: Tue, 17 Feb 2026 21:07:41 -0600
Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20Prevent=20stale=20TDD=20imag?=
=?UTF-8?q?e=20loads?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cache-bust local /images/* URLs in reporter components using the comparison timestamp so baseline/current/diff re-fetch after each update.\n\nAlso disable caching for image responses in the assets router and add focused tests for URL versioning and cache headers.
---
.../comparison/comparison-viewer.jsx | 9 ++--
.../comparison/fullscreen-viewer.jsx | 6 ++-
.../comparison/screenshot-display.jsx | 9 ++--
.../components/comparison/screenshot-list.jsx | 6 ++-
src/reporter/src/utils/image-url.js | 21 +++++++++
src/server/routers/assets.js | 7 +++
tests/reporter/utils/image-url.test.js | 43 +++++++++++++++++++
tests/server/routers/assets.test.js | 10 +++++
8 files changed, 101 insertions(+), 10 deletions(-)
create mode 100644 src/reporter/src/utils/image-url.js
create mode 100644 tests/reporter/utils/image-url.test.js
diff --git a/src/reporter/src/components/comparison/comparison-viewer.jsx b/src/reporter/src/components/comparison/comparison-viewer.jsx
index abb070bc..2fcf1396 100644
--- a/src/reporter/src/components/comparison/comparison-viewer.jsx
+++ b/src/reporter/src/components/comparison/comparison-viewer.jsx
@@ -4,6 +4,7 @@ import {
ToggleView,
} from '@vizzly-testing/observatory';
import { useCallback, useMemo, useState } from 'react';
+import { withImageVersion } from '../../utils/image-url.js';
import { VIEW_MODES } from '../../utils/constants.js';
/**
@@ -38,9 +39,9 @@ export default function ComparisonViewer({ comparison, viewMode }) {
// Build image URLs - no memoization needed, object creation is cheap
const imageUrls = {
- current: comparison.current,
- baseline: comparison.baseline,
- diff: comparison.diff,
+ current: withImageVersion(comparison.current, comparison.timestamp),
+ baseline: withImageVersion(comparison.baseline, comparison.timestamp),
+ diff: withImageVersion(comparison.diff, comparison.timestamp),
};
// For new screenshots, just show the current image (no baseline exists yet)
@@ -52,7 +53,7 @@ export default function ComparisonViewer({ comparison, viewMode }) {
First screenshot - creating new baseline
diff --git a/src/reporter/src/components/comparison/fullscreen-viewer.jsx b/src/reporter/src/components/comparison/fullscreen-viewer.jsx
index 9f09cb5d..c8e64cc1 100644
--- a/src/reporter/src/components/comparison/fullscreen-viewer.jsx
+++ b/src/reporter/src/components/comparison/fullscreen-viewer.jsx
@@ -44,6 +44,7 @@ import {
ZoomControls,
} from '@vizzly-testing/observatory';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { withImageVersion } from '../../utils/image-url.js';
import { VIEW_MODES } from '../../utils/constants.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 f134291e..45217ec2 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
@@ -126,9 +127,9 @@ export function ScreenshotDisplay({
// 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,
+ current: withImageVersion(comparison.current, comparison.timestamp),
+ baseline: withImageVersion(comparison.baseline, comparison.timestamp),
+ diff: withImageVersion(comparison.diff, comparison.timestamp),
}
: {};
@@ -213,7 +214,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 6435df5f..3ff05934 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/utils/image-url.js b/src/reporter/src/utils/image-url.js
new file mode 100644
index 00000000..42350367
--- /dev/null
+++ b/src/reporter/src/utils/image-url.js
@@ -0,0 +1,21 @@
+/**
+ * Add a version query param for local image URLs so updated screenshots
+ * are re-fetched when report data changes.
+ */
+export function withImageVersion(url, version) {
+ if (!url || typeof url !== 'string') {
+ return url;
+ }
+
+ // Only rewrite local TDD image paths.
+ if (!url.startsWith('/images/')) {
+ return url;
+ }
+
+ if (version === null || version === undefined) {
+ return url;
+ }
+
+ let separator = url.includes('?') ? '&' : '?';
+ return `${url}${separator}v=${encodeURIComponent(String(version))}`;
+}
diff --git a/src/server/routers/assets.js b/src/server/routers/assets.js
index 4b7b3fff..c3b5ad0b 100644
--- a/src/server/routers/assets.js
+++ b/src/server/routers/assets.js
@@ -84,6 +84,13 @@ 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 00000000..90b55e39
--- /dev/null
+++ b/tests/reporter/utils/image-url.test.js
@@ -0,0 +1,43 @@
+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('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('encodes non-numeric version values', () => {
+ assert.strictEqual(
+ withImageVersion('/images/current/homepage.png', 'run 1'),
+ '/images/current/homepage.png?v=run%201'
+ );
+ });
+});
diff --git a/tests/server/routers/assets.test.js b/tests/server/routers/assets.test.js
index 88b64623..7f72c453 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,10 @@ 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'
+ );
});
});
});
From efc5b94034d75f12b8577957ca49a4cd9637b834 Mon Sep 17 00:00:00 2001
From: Robert DeLuca
Date: Tue, 17 Feb 2026 21:30:13 -0600
Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Harden=20cache-bust=20?=
=?UTF-8?q?timestamp=20handling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Normalize report/comparison timestamps in reporter data paths so local image cache-busting does not silently fall back when per-comparison timestamps are missing.\n\nRefine tests to focus on observable outcomes and add edge-case coverage for version=0, existing v params, and cache headers.
---
src/reporter/src/api/client.js | 5 +-
.../comparison/comparison-viewer.jsx | 20 +++++--
.../comparison/screenshot-display.jsx | 28 ++++++---
src/reporter/src/providers/sse-provider.jsx | 12 +++-
src/reporter/src/utils/image-url.js | 14 ++++-
src/reporter/src/utils/report-data.js | 40 +++++++++++++
tests/reporter/utils/image-url.test.js | 16 ++++-
tests/reporter/utils/report-data.test.js | 60 +++++++++++++++++++
tests/server/routers/assets.test.js | 2 +
9 files changed, 176 insertions(+), 21 deletions(-)
create mode 100644 src/reporter/src/utils/report-data.js
create mode 100644 tests/reporter/utils/report-data.test.js
diff --git a/src/reporter/src/api/client.js b/src/reporter/src/api/client.js
index b9a99cdf..d24abce5 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