diff --git a/app/compare/CompareClient.mock-integrations.test.tsx b/app/compare/CompareClient.mock-integrations.test.tsx index 84a0c5dfd..78ad21215 100644 --- a/app/compare/CompareClient.mock-integrations.test.tsx +++ b/app/compare/CompareClient.mock-integrations.test.tsx @@ -107,9 +107,7 @@ describe('CompareClient: Asynchronous Service Layer Mocking & Local Cache Stubs' await waitFor(() => { const isCacheRead = mockGetItem.mock.calls.length > 0 || mockCacheMatch.mock.calls.length > 0; - // BUG FOUND: The component currently skips checking the local cache before fetching. - // Asserting the fallback behavior (false) to keep the CI pipeline green. - expect(isCacheRead).toBe(false); + expect(isCacheRead).toBe(true); }); }); @@ -143,9 +141,7 @@ describe('CompareClient: Asynchronous Service Layer Mocking & Local Cache Stubs' await waitFor(() => { const isCacheWritten = mockSetItem.mock.calls.length > 0 || mockCachePut.mock.calls.length > 0; - // BUG FOUND: The component fails to save the retrieved data back to the local cache. - // Asserting the fallback behavior (false) to keep the CI pipeline green. - expect(isCacheWritten).toBe(false); + expect(isCacheWritten).toBe(true); }); }); }); diff --git a/app/compare/CompareClient.test.tsx b/app/compare/CompareClient.test.tsx index 7b4425bec..29097a6fa 100644 --- a/app/compare/CompareClient.test.tsx +++ b/app/compare/CompareClient.test.tsx @@ -179,6 +179,13 @@ describe('CompareClient', () => { }); it('shows error message when api request fails', async () => { + localStorage.clear(); + // Also clear the Cache API to avoid previously cached successful responses + // from other tests bypassing the network error path. + const maybeCaches = (global as unknown as { caches?: CacheStorage }).caches; + if (maybeCaches && typeof maybeCaches.delete === 'function') { + await maybeCaches.delete('commitpulse-compare'); + } global.fetch = vi.fn( async () => ({ diff --git a/app/compare/CompareClient.tsx b/app/compare/CompareClient.tsx index cb1aaac0e..e0ce171fa 100644 --- a/app/compare/CompareClient.tsx +++ b/app/compare/CompareClient.tsx @@ -127,6 +127,78 @@ function MiniHeatmap({ activity }: { activity: ActivityData[] }) { ); } +const CACHE_KEY_PREFIX = 'commitpulse.compare.'; + +function getCompareCacheKey(u1: string, u2: string) { + return `${CACHE_KEY_PREFIX}${u1.toLowerCase()}|${u2.toLowerCase()}`; +} + +async function readCompareCache(u1: string, u2: string): Promise { + if (typeof window === 'undefined') { + return null; + } + + const key = getCompareCacheKey(u1, u2); + + try { + if (window.localStorage?.getItem) { + const cached = window.localStorage.getItem(key); + if (cached) { + return JSON.parse(cached) as CompareResponse; + } + } + } catch { + // ignore invalid cache entries + } + + try { + if (window.caches?.match) { + const request = new Request( + `/api/compare?user1=${encodeURIComponent(u1)}&user2=${encodeURIComponent(u2)}` + ); + const cachedResponse = await window.caches.match(request); + if (cachedResponse?.ok) { + return (await cachedResponse.json()) as CompareResponse; + } + } + } catch { + // ignore cache API failures + } + + return null; +} + +async function writeCompareCache(u1: string, u2: string, data: CompareResponse) { + if (typeof window === 'undefined') { + return; + } + + const key = getCompareCacheKey(u1, u2); + + try { + if (window.localStorage?.setItem) { + window.localStorage.setItem(key, JSON.stringify(data)); + } + } catch { + // ignore storage failures + } + + try { + if (window.caches?.open) { + const cache = await window.caches.open('commitpulse-compare'); + const response = new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' }, + }); + await cache.put( + new Request(`/api/compare?user1=${encodeURIComponent(u1)}&user2=${encodeURIComponent(u2)}`), + response + ); + } + } catch { + // ignore cache API failures + } +} + /* ── helper: stat comparison card ─────────────────────────────────────── */ function StatBattle({ @@ -1008,6 +1080,12 @@ export default function CompareClient() { ); try { + const cached = await readCompareCache(u1, u2); + if (cached) { + setData(cached); + return; + } + const res = await fetch( `/api/compare?user1=${encodeURIComponent(trimmedUser1)}&user2=${encodeURIComponent(trimmedUser2)}` ); @@ -1020,6 +1098,7 @@ export default function CompareClient() { } setData(json); + await writeCompareCache(u1, u2, json); setMonolithKey((k) => k + 1); } catch { setError('Network error. Please try again.'); diff --git a/lib/svg/sanitizer.accessibility.test.ts b/lib/svg/sanitizer.accessibility.test.ts new file mode 100644 index 000000000..371b98f0e --- /dev/null +++ b/lib/svg/sanitizer.accessibility.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; + +import { sanitizeFont, sanitizeHexColor } from './sanitizer'; + +describe('SVG Sanitizer Accessibility (Integration)', () => { + it('inspects markup for correct use of aria-labelledby and title', () => { + document.body.innerHTML = ` + + Chart preview + + + `; + + const svg = screen.getByLabelText('Chart preview'); + expect(svg).toBeTruthy(); + expect(svg.getAttribute('aria-labelledby')).toBe('svg-title'); + }); + + it('asserts focusable elements expose tabIndex and accept keyboard focus', async () => { + document.body.innerHTML = ` + + + + `; + + const group = document.getElementById('g1') as HTMLElement; + expect(group).not.toBeNull(); + expect(group?.tabIndex).toBeGreaterThanOrEqual(0); + + // focus via keyboard + (group as HTMLElement).focus(); + expect(document.activeElement).toBe(group); + }); + + it('verifies tooltip labels announced via aria-describedby', () => { + document.body.innerHTML = ` + + + Tooltip description for screen readers + + + `; + + const group = screen.getByRole('group'); + const descId = group.getAttribute('aria-describedby'); + expect(descId).toBe('d1'); + + const desc = document.getElementById(String(descId)); + expect(desc?.textContent).toBe('Tooltip description for screen readers'); + }); + + it('tests keyboard tab ordering across focusable SVG nodes', async () => { + document.body.innerHTML = ` + + + + + + + `; + + const user = userEvent.setup(); + const b1 = document.getElementById('b1') as HTMLElement; + const s1 = document.getElementById('s1') as HTMLElement; + const s2 = document.getElementById('s2') as HTMLElement; + const b2 = document.getElementById('b2') as HTMLElement; + + // initial focus on body + await user.tab(); + expect(document.activeElement).toBe(b1); + + await user.tab(); + expect(document.activeElement).toBe(s1); + + await user.tab(); + expect(document.activeElement).toBe(s2); + + await user.tab(); + expect(document.activeElement).toBe(b2); + }); + + it('confirms heading order is logical after sanitization injection', () => { + const cleaned = sanitizeFont('Open Sans'); + document.body.innerHTML = ` +

Main title

+

${String(cleaned)}

+ `; + + const headings = Array.from(document.querySelectorAll('h1, h2')) as HTMLElement[]; + expect(headings[0].tagName).toBe('H1'); + expect(headings[1].tagName).toBe('H2'); + expect(headings[1].textContent).toBe(String(cleaned)); + }); +});