Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/reporter/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* - api.auth.* - Authentication
*/

import { normalizeReportData } from '../utils/report-data.js';

/**
* Make a JSON API request
* @param {string} url - Request URL
Expand Down Expand Up @@ -45,7 +47,8 @@ export const tdd = {
* @returns {Promise<Object|null>}
*/
async getReportData() {
return fetchJson('/api/report-data');
let data = await fetchJson('/api/report-data');
return normalizeReportData(data);
},

/**
Expand Down
23 changes: 16 additions & 7 deletions src/reporter/src/components/comparison/comparison-viewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -52,7 +61,7 @@ export default function ComparisonViewer({ comparison, viewMode }) {
First screenshot - creating new baseline
</p>
<img
src={comparison.current}
src={imageUrls.current}
alt="New baseline screenshot"
className="mx-auto max-w-full block"
/>
Expand Down
6 changes: 5 additions & 1 deletion src/reporter/src/components/comparison/fullscreen-viewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -641,7 +642,10 @@ function FullscreenViewerInner({
<QueueItem
item={item}
isActive={isActive}
thumbnailUrl={item.current || item.baseline}
thumbnailUrl={withImageVersion(
item.current || item.baseline,
item.timestamp
)}
onClick={() => onNavigate(item)}
/>
</div>
Expand Down
26 changes: 17 additions & 9 deletions src/reporter/src/components/comparison/screenshot-display.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -213,7 +221,7 @@ export function ScreenshotDisplay({
>
{comparison && (
<img
src={comparison.current}
src={imageUrls.current}
alt={comparison.name || 'New screenshot'}
className="block"
onLoad={() => handleImageLoad(`current-${screenshot?.id}`)}
Expand Down
6 changes: 5 additions & 1 deletion src/reporter/src/components/comparison/screenshot-list.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, '-')}`;
Expand Down
15 changes: 13 additions & 2 deletions src/reporter/src/providers/sse-provider.jsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions src/reporter/src/utils/image-url.js
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions src/reporter/src/utils/report-data.js
Original file line number Diff line number Diff line change
@@ -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(),
};
}
4 changes: 4 additions & 0 deletions src/server/routers/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions tests/reporter/utils/image-url.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
63 changes: 63 additions & 0 deletions tests/reporter/utils/report-data.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading