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
128 changes: 120 additions & 8 deletions app/compare/CompareClient.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import CompareClient from './CompareClient';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React, { type ReactNode } from 'react';
import CompareClient from './CompareClient';

const replaceMock = vi.fn();

const mockSearchParams = {
get: vi.fn((key) => {
get: vi.fn((key: string) => {
if (key === 'user1') return 'userA';
if (key === 'user2') return 'userB';
return null;
}),
toString: vi.fn(() => 'user1=userA&user2=userB'),
};

vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: replaceMock,
Expand Down Expand Up @@ -108,6 +109,7 @@ describe('CompareClient', () => {
localStorage.clear();

const maybeCaches = (global as unknown as { caches?: CacheStorage }).caches;

if (maybeCaches && typeof maybeCaches.delete === 'function') {
await maybeCaches.delete('commitpulse-compare');
}
Expand All @@ -121,6 +123,10 @@ describe('CompareClient', () => {
);
});

afterEach(() => {
vi.useRealTimers();
});

it('renders comparison page', () => {
render(<CompareClient />);

Expand Down Expand Up @@ -166,8 +172,13 @@ describe('CompareClient', () => {
expect(screen.getByText(/stats showdown/i)).toBeInTheDocument();
});

await waitFor(() => expect(screen.getByText(/5[,\s ]?000/)).toBeInTheDocument());
await waitFor(() => expect(screen.getByText(/3[,\s ]?000/)).toBeInTheDocument());
await waitFor(() => {
expect(screen.getByText(/5[,\s\u00a0]?000/)).toBeInTheDocument();
});

await waitFor(() => {
expect(screen.getByText(/3[,\s\u00a0]?000/)).toBeInTheDocument();
});
});

it('updates route when compare button is clicked', async () => {
Expand All @@ -192,12 +203,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 All @@ -222,4 +234,104 @@ describe('CompareClient', () => {

expect(await screen.findByText(/failed to fetch comparison data/i)).toBeInTheDocument();
});

it('stops loading and shows an error when the comparison request times out', async () => {
vi.useFakeTimers();

global.fetch = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;

if (!signal) {
reject(new Error('Expected the comparison request to include an AbortSignal.'));
return;
}

if (signal.aborted) {
reject(new DOMException('Request aborted', 'AbortError'));
return;
}

signal.addEventListener(
'abort',
() => {
reject(new DOMException('Request aborted', 'AbortError'));
},
{ once: true }
);
})
);

render(<CompareClient />);

// Allow the initial URL-driven comparison effect and cache lookup to finish.
await act(async () => {
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
});

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(screen.getByText('Comparing...')).toBeInTheDocument();

await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});

expect(screen.getByText(/comparison timed out\. please try again\./i)).toBeInTheDocument();

expect(
screen.getByRole('button', {
name: /compare two github profiles/i,
})
).not.toBeDisabled();
});

it('does not start a duplicate request while the same comparison is already loading', async () => {
let resolveRequest: ((response: Response) => void) | undefined;

global.fetch = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveRequest = resolve;
})
);

render(<CompareClient />);

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});

const firstUsernameInput = screen.getByPlaceholderText(/github username #1/i);

// Enter can invoke handleCompare even while the submit button is disabled.
// These repeated requests must be ignored while the original one is active.
fireEvent.keyDown(firstUsernameInput, {
key: 'Enter',
code: 'Enter',
});

fireEvent.keyDown(firstUsernameInput, {
key: 'Enter',
code: 'Enter',
});

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(resolveRequest).toBeDefined();

await act(async () => {
resolveRequest?.({
ok: true,
json: async () => mockResponse,
} as Response);

await Promise.resolve();
});

await waitFor(() => {
expect(screen.getByText(/stats showdown/i)).toBeInTheDocument();
});
});
});
63 changes: 53 additions & 10 deletions app/compare/CompareClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,10 @@ export default function CompareClient() {
// and limits the history size automatically.
const { searches, addSearch, clearSearches, removeSearch } = useRecentSearches();
const lastComparedRef = useRef({ user1: '', user2: '' });
const activeRequestRef = useRef<{
key: string;
controller: AbortController;
} | null>(null);
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
Expand Down Expand Up @@ -1086,7 +1090,7 @@ export default function CompareClient() {
};

const handleCompare = useCallback(
async (u1: string, u2: string) => {
async (u1: string, u2: string): Promise<void> => {
const trimmedUser1 = u1.trim();
const trimmedUser2 = u2.trim();

Expand Down Expand Up @@ -1115,6 +1119,12 @@ export default function CompareClient() {
return;
}

const requestKey = `${trimmedUser1.toLowerCase()}|${trimmedUser2.toLowerCase()}`;

if (activeRequestRef.current?.key === requestKey) {
return;
}

if (
lastComparedRef.current.user1.toLowerCase() === trimmedUser1.toLowerCase() &&
lastComparedRef.current.user2.toLowerCase() === trimmedUser2.toLowerCase() &&
Expand All @@ -1123,6 +1133,12 @@ export default function CompareClient() {
return;
}

activeRequestRef.current?.controller.abort();

const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 20_000);

activeRequestRef.current = { key: requestKey, controller };
lastComparedRef.current = { user1: trimmedUser1, user2: trimmedUser2 };
setLoading(true);
setData(null);
Expand All @@ -1133,7 +1149,7 @@ export default function CompareClient() {
);

try {
const cached = await readCompareCache(u1, u2);
const cached = await readCompareCache(trimmedUser1, trimmedUser2);

if (cached) {
setData(cached);
Expand All @@ -1145,10 +1161,11 @@ export default function CompareClient() {
}

const res = await fetch(
`/api/compare?user1=${encodeURIComponent(trimmedUser1)}&user2=${encodeURIComponent(trimmedUser2)}`
`/api/compare?user1=${encodeURIComponent(trimmedUser1)}&user2=${encodeURIComponent(trimmedUser2)}`,
{ signal: controller.signal }
);

const json = await res.json();
const json = (await res.json()) as CompareResponse;

if (!res.ok) {
setError(json.error || 'Failed to fetch comparison data.');
Expand All @@ -1161,30 +1178,56 @@ export default function CompareClient() {
// revisit previously compared developers.
addSearch(`${trimmedUser1} vs ${trimmedUser2}`);

await writeCompareCache(u1, u2, json);
await writeCompareCache(trimmedUser1, trimmedUser2, json);
setMonolithKey((k) => k + 1);
} catch {
setError('Network error. Please try again.');
} catch (err: unknown) {
if (activeRequestRef.current?.key !== requestKey) {
return;
}

if (err instanceof DOMException && err.name === 'AbortError') {
setError('Comparison timed out. Please try again.');
} else {
setError('Network error. Please try again.');
}
} finally {
setLoading(false);
window.clearTimeout(timeoutId);

if (activeRequestRef.current?.key === requestKey) {
activeRequestRef.current = null;
setLoading(false);
}
}
},
[router, addSearch]
);

// Auto-compare if URL has params on mount or param changes
// Auto-compare if URL has params on mount or param changes.
useEffect(() => {
const u1 = searchParams.get('user1');
const u2 = searchParams.get('user2');

if (u1 && u2) {
setUser1(u1); // eslint-disable-line react-hooks/set-state-in-effect -- syncing URL params to input state
setUser2(u2);
handleCompare(u1, u2);

const requestKey = `${u1.trim().toLowerCase()}|${u2.trim().toLowerCase()}`;
const lastRequestKey = `${lastComparedRef.current.user1.toLowerCase()}|${lastComparedRef.current.user2.toLowerCase()}`;

if (requestKey !== lastRequestKey && activeRequestRef.current?.key !== requestKey) {
void handleCompare(u1, u2);
}
} else {
setData(null);
}
}, [searchParams, handleCompare]);

useEffect(() => {
return () => {
activeRequestRef.current?.controller.abort();
};
}, []);

const d1 = data?.user1;
const d2 = data?.user2;

Expand Down
6 changes: 3 additions & 3 deletions components/DeveloperArena.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ const SHOWDOWNS = [
badgeColor: 'border-purple-500/30 text-purple-400 bg-purple-500/5',
},
{
u1: 'vercel',
u2: 'netlify',
u1: 'rauchg',
u2: 'biilmann',
label: 'Vercel vs Netlify',
desc: 'Platform Wars',
desc: 'Founder Showdown',
Comment on lines +41 to +44
badgeColor: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/5',
},
{
Expand Down
2 changes: 1 addition & 1 deletion components/TopRivalriesTicker.accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('components/TopRivalriesTicker Accessibility Standards & Screen Reader
render(<TopRivalriesTicker />);

expect(screen.getAllByText('VS').length).toBeGreaterThan(0);
expect(screen.getAllByText('Platform Wars').length).toBeGreaterThan(0);
expect(screen.getAllByText('Vercel & Netlify Founders').length).toBeGreaterThan(0);
});

it('preserves logical content order for rivalry information', () => {
Expand Down
4 changes: 2 additions & 2 deletions components/TopRivalriesTicker.mouse-interactivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('TopRivalriesTicker Mouse Interactivity', () => {
render(<TopRivalriesTicker />);

const kernelVsReact = screen.getAllByText('Kernel vs React')[0];
const platformWars = screen.getAllByText('Platform Wars')[0];
const platformWars = screen.getAllByText('Vercel & Netlify Founders')[0];

const firstContainer = kernelVsReact.closest('div.group');
const secondContainer = platformWars.closest('div.group');
Expand All @@ -88,7 +88,7 @@ describe('TopRivalriesTicker Mouse Interactivity', () => {

expect(mockPush).toHaveBeenNthCalledWith(1, '/compare?user1=torvalds&user2=gaearon');

expect(mockPush).toHaveBeenNthCalledWith(2, '/compare?user1=vercel&user2=netlify');
expect(mockPush).toHaveBeenNthCalledWith(2, '/compare?user1=rauchg&user2=biilmann');
});

it('renders empty state safely without triggering navigation actions', () => {
Expand Down
Loading
Loading