From 246e4b273186f59442f9ccff111db830d8eb0af8 Mon Sep 17 00:00:00 2001 From: Jeevitha S Date: Thu, 4 Jun 2026 22:21:28 +0530 Subject: [PATCH 1/4] fix(compare): add local cache read/write behavior for CompareClient --- .../CompareClient.mock-integrations.test.tsx | 8 +- app/compare/CompareClient.tsx | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) 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.tsx b/app/compare/CompareClient.tsx index c18928eb7..d93f8dfeb 100644 --- a/app/compare/CompareClient.tsx +++ b/app/compare/CompareClient.tsx @@ -125,6 +125,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({ @@ -951,6 +1023,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(u1)}&user2=${encodeURIComponent(u2)}` ); @@ -962,6 +1040,7 @@ export default function CompareClient() { } setData(json); + await writeCompareCache(u1, u2, json); } catch { setError('Network error. Please try again.'); } finally { From c7b26f567946f602e36bb3074543c37000ea58d2 Mon Sep 17 00:00:00 2001 From: Jeevitha S Date: Sun, 7 Jun 2026 14:41:19 +0530 Subject: [PATCH 2/4] test: isolate compare API failure case from cache --- app/compare/CompareClient.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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 () => ({ From 7c692e45b683115d1e67b288c87853d8a862ab93 Mon Sep 17 00:00:00 2001 From: Jeevitha S Date: Sun, 7 Jun 2026 15:31:25 +0530 Subject: [PATCH 3/4] test(sanitizer-accessibility): add accessibility coverage for SVG sanitizer --- lib/svg/sanitizer.accessibility.test.ts | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 lib/svg/sanitizer.accessibility.test.ts diff --git a/lib/svg/sanitizer.accessibility.test.ts b/lib/svg/sanitizer.accessibility.test.ts new file mode 100644 index 000000000..a3c6244f4 --- /dev/null +++ b/lib/svg/sanitizer.accessibility.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + sanitizeHexColor, + sanitizeFont, + sanitizeGoogleFontUrl, + normalizeHexColor, + parseGradientStops, + getGradientCoordinates, +} from './sanitizer'; + +describe('SVG Sanitizer Accessibility', () => { + it('returns safe hex colors for accessible SVG label styling', () => { + expect(sanitizeHexColor('#4a90e2', '000000')).toBe('4a90e2'); + expect(sanitizeHexColor('##4a90e2', '000000')).toBe('4a90e2'); + expect(sanitizeHexColor('invalid-color', 'ffffff')).toBe('ffffff'); + }); + + it('filters unsafe font names while preserving accessible text labels', () => { + expect(sanitizeFont('Open Sans')).toBe('Open Sans'); + expect(sanitizeFont('Arial-Bold')).toBe('Arial-Bold'); + expect(sanitizeFont('Inter')).toBe('Interscriptalert1script'); + }); + + it('rejects unsafe Google Font names for secure external font loading', () => { + expect(sanitizeGoogleFontUrl('Roboto')).toBe('Roboto'); + expect(sanitizeGoogleFontUrl('Open Sans')).toBe('Open+Sans'); + expect(sanitizeGoogleFontUrl('Open Sans; @import url(http://evil.com)')).toBe(null); + }); + + it('preserves gradient stop order for predictable accessible narration', () => { + expect(parseGradientStops('#ff0000,#00ff00,0000ff')).toEqual(['ff0000', '00ff00', '0000ff']); + expect(parseGradientStops('invalid,#abc,123456')).toEqual(['abc', '123456']); + }); + + it('maps gradient directions to stable coordinate pairs for accessible rendering', () => { + expect(getGradientCoordinates('horizontal')).toEqual({ + x1: '0%', + y1: '0%', + x2: '100%', + y2: '0%', + }); + expect(getGradientCoordinates('diagonal')).toEqual({ + x1: '0%', + y1: '0%', + x2: '100%', + y2: '100%', + }); + expect(getGradientCoordinates('unknown')).toEqual({ x1: '0%', y1: '0%', x2: '0%', y2: '100%' }); + }); +}); From 43c1516518e391a8206e14c06def1abb623c0c8c Mon Sep 17 00:00:00 2001 From: Jeevitha S Date: Sun, 7 Jun 2026 16:03:41 +0530 Subject: [PATCH 4/4] test(sanitizer-accessibility): add accessibility coverage for SVG sanitizer --- lib/svg/sanitizer.accessibility.test.ts | 126 ++++++++++++++++-------- 1 file changed, 86 insertions(+), 40 deletions(-) diff --git a/lib/svg/sanitizer.accessibility.test.ts b/lib/svg/sanitizer.accessibility.test.ts index a3c6244f4..371b98f0e 100644 --- a/lib/svg/sanitizer.accessibility.test.ts +++ b/lib/svg/sanitizer.accessibility.test.ts @@ -1,50 +1,96 @@ -import { describe, expect, it } from 'vitest'; -import { - sanitizeHexColor, - sanitizeFont, - sanitizeGoogleFontUrl, - normalizeHexColor, - parseGradientStops, - getGradientCoordinates, -} from './sanitizer'; - -describe('SVG Sanitizer Accessibility', () => { - it('returns safe hex colors for accessible SVG label styling', () => { - expect(sanitizeHexColor('#4a90e2', '000000')).toBe('4a90e2'); - expect(sanitizeHexColor('##4a90e2', '000000')).toBe('4a90e2'); - expect(sanitizeHexColor('invalid-color', 'ffffff')).toBe('ffffff'); +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('filters unsafe font names while preserving accessible text labels', () => { - expect(sanitizeFont('Open Sans')).toBe('Open Sans'); - expect(sanitizeFont('Arial-Bold')).toBe('Arial-Bold'); - expect(sanitizeFont('Inter')).toBe('Interscriptalert1script'); + 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('rejects unsafe Google Font names for secure external font loading', () => { - expect(sanitizeGoogleFontUrl('Roboto')).toBe('Roboto'); - expect(sanitizeGoogleFontUrl('Open Sans')).toBe('Open+Sans'); - expect(sanitizeGoogleFontUrl('Open Sans; @import url(http://evil.com)')).toBe(null); + 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('preserves gradient stop order for predictable accessible narration', () => { - expect(parseGradientStops('#ff0000,#00ff00,0000ff')).toEqual(['ff0000', '00ff00', '0000ff']); - expect(parseGradientStops('invalid,#abc,123456')).toEqual(['abc', '123456']); + 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('maps gradient directions to stable coordinate pairs for accessible rendering', () => { - expect(getGradientCoordinates('horizontal')).toEqual({ - x1: '0%', - y1: '0%', - x2: '100%', - y2: '0%', - }); - expect(getGradientCoordinates('diagonal')).toEqual({ - x1: '0%', - y1: '0%', - x2: '100%', - y2: '100%', - }); - expect(getGradientCoordinates('unknown')).toEqual({ x1: '0%', y1: '0%', x2: '0%', y2: '100%' }); + 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)); }); });