Skip to content

Commit a3c53b4

Browse files
Merge branch 'main' into fix/og-bypass-cache
2 parents a61a7bf + 1f96066 commit a3c53b4

3 files changed

Lines changed: 200 additions & 3 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import React, { type ReactNode } from 'react';
3+
import { render, screen } from '@testing-library/react';
4+
import '@testing-library/jest-dom/vitest';
5+
6+
vi.mock('next/navigation', () => ({
7+
useRouter: () => ({
8+
push: vi.fn(),
9+
replace: vi.fn(),
10+
prefetch: vi.fn(),
11+
}),
12+
useSearchParams: () => ({
13+
get: vi.fn(() => null),
14+
}),
15+
usePathname: () => '',
16+
}));
17+
18+
vi.mock('framer-motion', () => ({
19+
motion: new Proxy(
20+
{},
21+
{
22+
get: (_, tag) => {
23+
return ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) =>
24+
React.createElement(tag as string, props, children);
25+
},
26+
}
27+
),
28+
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
29+
}));
30+
31+
// This uses a dynamic import to completely bypass module hoisting safely without using require()
32+
const { default: CompareClient } = await import('./CompareClient');
33+
34+
describe('CompareClient Accessibility Standards', () => {
35+
it('ensures inputs and controls have accessible names', () => {
36+
render(<CompareClient />);
37+
38+
const input1 = screen.getByPlaceholderText(/github username #1/i);
39+
const input2 = screen.getByPlaceholderText(/github username #2/i);
40+
41+
expect(input1).toHaveAttribute('placeholder');
42+
expect(input2).toHaveAttribute('placeholder');
43+
});
44+
45+
it('keeps primary actions keyboard focusable with visible outline', () => {
46+
render(<CompareClient />);
47+
48+
const button = screen.getByRole('button', { name: /compare/i });
49+
50+
button.focus();
51+
52+
expect(document.activeElement).toBe(button);
53+
expect(button).toBeVisible();
54+
});
55+
56+
it('associates tooltip content using aria-describedby', () => {
57+
render(<CompareClient />);
58+
59+
const tooltipTrigger = screen.queryByRole('button', {
60+
name: /tooltip|info|help/i,
61+
});
62+
63+
if (tooltipTrigger) {
64+
const describedBy = tooltipTrigger.getAttribute('aria-describedby');
65+
expect(describedBy).toBeTruthy();
66+
67+
const tooltip = describedBy ? document.getElementById(describedBy) : null;
68+
69+
if (tooltip) {
70+
expect(tooltip.textContent?.length).toBeGreaterThan(0);
71+
}
72+
} else {
73+
expect(true).toBe(true);
74+
}
75+
});
76+
77+
it('maintains logical keyboard tab order for interactive elements', () => {
78+
render(<CompareClient />);
79+
80+
const focusables = document.querySelectorAll(
81+
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
82+
);
83+
84+
expect(focusables.length).toBeGreaterThan(0);
85+
86+
focusables.forEach((el) => {
87+
const tabIndex = el.getAttribute('tabindex');
88+
expect(tabIndex).not.toBe('-1');
89+
});
90+
});
91+
92+
it('renders proper heading hierarchy without skipping levels', () => {
93+
render(<CompareClient />);
94+
95+
const headings = screen.getAllByRole('heading');
96+
97+
expect(headings.length).toBeGreaterThan(0);
98+
99+
const levels = headings.map((h) => {
100+
const tag = h.tagName;
101+
return Number(tag.replace('H', ''));
102+
});
103+
104+
for (let i = 1; i < levels.length; i++) {
105+
expect(levels[i] - levels[i - 1]).toBeLessThanOrEqual(1);
106+
}
107+
});
108+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { cleanup, render, screen, fireEvent, act } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import CopyRepoButton, { REPO_URL } from './CopyRepoButton';
4+
5+
beforeEach(() => {
6+
vi.stubGlobal('navigator', {
7+
...navigator,
8+
clipboard: { writeText: vi.fn() },
9+
});
10+
});
11+
12+
afterEach(() => {
13+
vi.clearAllTimers();
14+
vi.useRealTimers();
15+
vi.unstubAllGlobals();
16+
vi.restoreAllMocks();
17+
cleanup();
18+
});
19+
20+
describe('CopyRepoButton', () => {
21+
it('renders with the default "Copy URL" label on mount', () => {
22+
render(<CopyRepoButton />);
23+
expect(screen.getByRole('button').textContent).toContain('Copy URL');
24+
});
25+
26+
it('transitions to "Copied!" immediately after a successful clipboard write', async () => {
27+
vi.useFakeTimers();
28+
vi.mocked(navigator.clipboard.writeText).mockResolvedValue(undefined);
29+
render(<CopyRepoButton />);
30+
31+
await act(async () => {
32+
fireEvent.click(screen.getByRole('button'));
33+
});
34+
35+
expect(screen.getByRole('button').textContent).toContain('Copied!');
36+
});
37+
38+
it('resets to "Copy URL" exactly after the 2000 ms timeout boundary', async () => {
39+
vi.useFakeTimers();
40+
vi.mocked(navigator.clipboard.writeText).mockResolvedValue(undefined);
41+
render(<CopyRepoButton />);
42+
43+
await act(async () => {
44+
fireEvent.click(screen.getByRole('button'));
45+
});
46+
47+
expect(screen.getByRole('button').textContent).toContain('Copied!');
48+
49+
await act(async () => {
50+
vi.advanceTimersByTime(1999);
51+
});
52+
expect(screen.getByRole('button').textContent).toContain('Copied!');
53+
54+
await act(async () => {
55+
vi.advanceTimersByTime(1);
56+
});
57+
expect(screen.getByRole('button').textContent).toContain('Copy URL');
58+
});
59+
60+
it('shows "Copy failed" and resets after 2000 ms when the clipboard API rejects', async () => {
61+
vi.useFakeTimers();
62+
vi.mocked(navigator.clipboard.writeText).mockRejectedValue(new Error('Not allowed'));
63+
render(<CopyRepoButton />);
64+
65+
await act(async () => {
66+
fireEvent.click(screen.getByRole('button'));
67+
});
68+
69+
expect(screen.getByRole('button').textContent).toContain('Copy failed');
70+
71+
await act(async () => {
72+
vi.advanceTimersByTime(2000);
73+
});
74+
expect(screen.getByRole('button').textContent).toContain('Copy URL');
75+
});
76+
77+
it('writes the exact repo URL exported from the component to the clipboard', async () => {
78+
vi.useFakeTimers();
79+
vi.mocked(navigator.clipboard.writeText).mockResolvedValue(undefined);
80+
render(<CopyRepoButton />);
81+
82+
await act(async () => {
83+
fireEvent.click(screen.getByRole('button'));
84+
});
85+
86+
expect(navigator.clipboard.writeText).toHaveBeenCalledOnce();
87+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(REPO_URL);
88+
});
89+
});

app/components/CopyRepoButton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import { useState } from 'react';
44
import { Copy } from 'lucide-react';
55

6+
export const REPO_URL = 'https://github.com/JhaSourav07/commitpulse';
7+
68
export default function CopyRepoButton() {
79
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle');
810

9-
const repoUrl = 'https://github.com/JhaSourav07/commitpulse';
10-
1111
const handleCopy = async () => {
1212
try {
13-
await navigator.clipboard.writeText(repoUrl);
13+
await navigator.clipboard.writeText(REPO_URL);
1414
setCopyState('copied');
1515
} catch {
1616
setCopyState('error');

0 commit comments

Comments
 (0)