Skip to content
Closed
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
8 changes: 2 additions & 6 deletions app/compare/CompareClient.mock-integrations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});
});
7 changes: 7 additions & 0 deletions app/compare/CompareClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
({
Expand Down
79 changes: 79 additions & 0 deletions app/compare/CompareClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
Tent,
Camera,
} from 'lucide-react';
import html2canvas from 'html2canvas';

Check warning on line 46 in app/compare/CompareClient.tsx

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'html2canvas' is defined but never used
import { validateGitHubUsername } from '@/lib/validations';
import { toPng } from 'html-to-image';

Expand Down Expand Up @@ -127,6 +127,78 @@
);
}

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<CompareResponse | null> {
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({
Expand Down Expand Up @@ -1008,6 +1080,12 @@
);

try {
const cached = await readCompareCache(u1, u2);
if (cached) {
setData(cached);
return;
}

const res = await fetch(
`/api/compare?user1=${encodeURIComponent(trimmedUser1)}&user2=${encodeURIComponent(trimmedUser2)}`
);
Expand All @@ -1020,6 +1098,7 @@
}

setData(json);
await writeCompareCache(u1, u2, json);
setMonolithKey((k) => k + 1);
} catch {
setError('Network error. Please try again.');
Expand Down Expand Up @@ -1355,7 +1434,7 @@
@{user.profile.username}
</span>
</div>
<img

Check warning on line 1437 in app/compare/CompareClient.tsx

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
data-monolith-img="true"
key={`${user.profile.username}-${monolithKey}`}
src={`${BASE_URL}/api/streak?user=${encodeURIComponent(user.profile.username)}&theme=neon&entrance=none&_k=${monolithKey}`}
Expand Down
96 changes: 96 additions & 0 deletions lib/svg/sanitizer.accessibility.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<svg role="img" aria-labelledby="svg-title">
<title id="svg-title">Chart preview</title>
<circle cx="10" cy="10" r="5"></circle>
</svg>
`;

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 = `
<svg>
<g role="group" id="g1" tabIndex="0"></g>
</svg>
`;

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 = `
<svg>
<g role="group" aria-describedby="d1" id="tool">
<desc id="d1">Tooltip description for screen readers</desc>
</g>
</svg>
`;

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 = `
<button id="b1">before</button>
<svg>
<g role="button" tabIndex="0" id="s1"></g>
<g role="button" tabIndex="0" id="s2"></g>
</svg>
<button id="b2">after</button>
`;

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 = `
<h1>Main title</h1>
<h2>${String(cleaned)}</h2>
`;

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));
});
});
Loading