Add Vitest + React Testing Library setup for viewer-frontend #63
@ -380,6 +380,25 @@ For complete documentation, see:
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run check:permissions` - Check database permissions and provide fix instructions
|
||||
- `npm test` - Run the unit test suite once (Vitest + React Testing Library)
|
||||
- `npm run test:watch` - Run the unit test suite in watch mode
|
||||
- `npm run test:coverage` - Run the unit test suite with coverage
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Unit tests live alongside the code they cover, in `__tests__/` folders (e.g.
|
||||
`lib/__tests__/`, `hooks/__tests__/`, `components/__tests__/`). They use
|
||||
[Vitest](https://vitest.dev/) with a `jsdom` environment and
|
||||
[React Testing Library](https://testing-library.com/react) for components/hooks.
|
||||
|
||||
- `vitest.config.ts` — test runner config (jsdom environment, `@/` path alias)
|
||||
- `vitest.setup.ts` — global test setup: `@testing-library/jest-dom` matchers,
|
||||
plus jsdom polyfills that Radix UI components and layout-aware hooks need
|
||||
(`matchMedia`, `ResizeObserver`, pointer capture, `offsetParent`)
|
||||
|
||||
This covers frontend *unit* tests only — end-to-end browser tests live in
|
||||
`../e2e` (Playwright + `@levkin/playkit`), and backend API tests live in
|
||||
`../tests` (pytest).
|
||||
|
||||
### Prisma Commands
|
||||
|
||||
|
||||
66
viewer-frontend/components/__tests__/ThemeToggle.test.tsx
Normal file
66
viewer-frontend/components/__tests__/ThemeToggle.test.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
const { useThemeMock } = vi.hoisted(() => ({ useThemeMock: vi.fn() }));
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: useThemeMock,
|
||||
}));
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
afterEach(() => {
|
||||
useThemeMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders a light-mode toggle with the correct accessible label', () => {
|
||||
const setTheme = vi.fn();
|
||||
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme });
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Switch to dark mode' });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('renders a dark-mode toggle with the correct accessible label', () => {
|
||||
const setTheme = vi.fn();
|
||||
useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme });
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Switch to light mode' });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('calls setTheme with the opposite theme when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setTheme = vi.fn();
|
||||
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme });
|
||||
|
||||
render(<ThemeToggle />);
|
||||
await user.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
|
||||
|
||||
expect(setTheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('toggles from dark back to light', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setTheme = vi.fn();
|
||||
useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme });
|
||||
|
||||
render(<ThemeToggle />);
|
||||
await user.click(screen.getByRole('button', { name: 'Switch to light mode' }));
|
||||
|
||||
expect(setTheme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('forwards a custom className to the trigger button', () => {
|
||||
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme: vi.fn() });
|
||||
|
||||
render(<ThemeToggle className="my-toggle-class" />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('my-toggle-class');
|
||||
});
|
||||
});
|
||||
87
viewer-frontend/hooks/__tests__/useFocusTrap.test.tsx
Normal file
87
viewer-frontend/hooks/__tests__/useFocusTrap.test.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useRef } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
|
||||
function TrapHarness({ active }: { active: boolean }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(containerRef, active);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button>outside-before</button>
|
||||
<div ref={containerRef} tabIndex={-1}>
|
||||
<button>first</button>
|
||||
<button>middle</button>
|
||||
<button>last</button>
|
||||
</div>
|
||||
<button>outside-after</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
it('moves focus into the container once activated', async () => {
|
||||
render(<TrapHarness active={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('first')).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when inactive, leaving focus wherever it was', () => {
|
||||
render(<TrapHarness active={false} />);
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
|
||||
it('wraps Tab from the last element back to the first', async () => {
|
||||
render(<TrapHarness active={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('first')).toHaveFocus();
|
||||
});
|
||||
|
||||
const last = screen.getByText('last');
|
||||
last.focus();
|
||||
fireEvent.keyDown(last, { key: 'Tab', code: 'Tab' });
|
||||
|
||||
expect(screen.getByText('first')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('wraps Shift+Tab from the first element back to the last', async () => {
|
||||
render(<TrapHarness active={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('first')).toHaveFocus();
|
||||
});
|
||||
|
||||
const first = screen.getByText('first');
|
||||
fireEvent.keyDown(first, { key: 'Tab', code: 'Tab', shiftKey: true });
|
||||
|
||||
expect(screen.getByText('last')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('restores focus to the previously-focused element once deactivated', async () => {
|
||||
function Wrapper({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<button>trigger</button>
|
||||
<TrapHarness active={active} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<Wrapper active={false} />);
|
||||
const trigger = screen.getByText('trigger');
|
||||
trigger.focus();
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
rerender(<Wrapper active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('first')).toHaveFocus();
|
||||
});
|
||||
|
||||
rerender(<Wrapper active={false} />);
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
92
viewer-frontend/lib/__tests__/photo-utils.test.ts
Normal file
92
viewer-frontend/lib/__tests__/photo-utils.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Photo } from '@prisma/client';
|
||||
import {
|
||||
getImageSrc,
|
||||
getVideoSrc,
|
||||
getWebPlaybackStreamUrl,
|
||||
isUrl,
|
||||
isVideo,
|
||||
} from '@/lib/photo-utils';
|
||||
|
||||
function makePhoto(overrides: Partial<Photo> = {}): Photo {
|
||||
return {
|
||||
id: 1,
|
||||
path: '/data/photos/2024/img.jpg',
|
||||
media_type: 'image',
|
||||
...overrides,
|
||||
} as Photo;
|
||||
}
|
||||
|
||||
describe('isUrl', () => {
|
||||
it('treats http(s) paths as URLs', () => {
|
||||
expect(isUrl('http://example.com/a.jpg')).toBe(true);
|
||||
expect(isUrl('https://example.com/a.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats filesystem paths as non-URLs', () => {
|
||||
expect(isUrl('/data/photos/img.jpg')).toBe(false);
|
||||
expect(isUrl('data/photos/img.jpg')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVideo', () => {
|
||||
it('recognizes the snake_case media_type field (raw DB access / Prisma default)', () => {
|
||||
expect(isVideo(makePhoto({ media_type: 'video' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes a camelCase mediaType field, in case a caller normalizes casing', () => {
|
||||
const photo = { ...makePhoto(), media_type: null, mediaType: 'video' } as unknown as Photo;
|
||||
expect(isVideo(photo)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for images', () => {
|
||||
expect(isVideo(makePhoto({ media_type: 'image' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageSrc', () => {
|
||||
it('returns the raw URL directly for URL-backed photos', () => {
|
||||
const photo = makePhoto({ path: 'https://cdn.example.com/a.jpg' });
|
||||
expect(getImageSrc(photo)).toBe('https://cdn.example.com/a.jpg');
|
||||
});
|
||||
|
||||
it('routes filesystem-backed photos through the API proxy', () => {
|
||||
const photo = makePhoto({ id: 42 });
|
||||
expect(getImageSrc(photo)).toBe('/api/photos/42/image');
|
||||
});
|
||||
|
||||
it('adds a watermark query param when requested', () => {
|
||||
const photo = makePhoto({ id: 42 });
|
||||
expect(getImageSrc(photo, { watermark: true })).toBe('/api/photos/42/image?watermark=true');
|
||||
});
|
||||
|
||||
it('prefers the thumbnail endpoint for grid display, ignoring the URL/proxy split', () => {
|
||||
const urlPhoto = makePhoto({ id: 5, path: 'https://cdn.example.com/a.jpg' });
|
||||
expect(getImageSrc(urlPhoto, { thumbnail: true })).toBe('/api/photos/5/image?thumbnail=true');
|
||||
});
|
||||
|
||||
it('combines thumbnail and watermark params', () => {
|
||||
const photo = makePhoto({ id: 7 });
|
||||
const src = getImageSrc(photo, { thumbnail: true, watermark: true });
|
||||
expect(src).toContain('thumbnail=true');
|
||||
expect(src).toContain('watermark=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVideoSrc', () => {
|
||||
it('returns the raw URL directly for URL-backed videos', () => {
|
||||
const photo = makePhoto({ path: 'https://cdn.example.com/clip.mp4' });
|
||||
expect(getVideoSrc(photo)).toBe('https://cdn.example.com/clip.mp4');
|
||||
});
|
||||
|
||||
it('routes filesystem-backed videos through the API proxy', () => {
|
||||
const photo = makePhoto({ id: 9 });
|
||||
expect(getVideoSrc(photo)).toBe('/api/photos/9/image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebPlaybackStreamUrl', () => {
|
||||
it('builds the transcoded-playback endpoint for the given photo id', () => {
|
||||
expect(getWebPlaybackStreamUrl(makePhoto({ id: 13 }))).toBe('/api/photos/13/web-playback');
|
||||
});
|
||||
});
|
||||
66
viewer-frontend/lib/__tests__/utils.test.ts
Normal file
66
viewer-frontend/lib/__tests__/utils.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cn, isValidEmail } from '@/lib/utils';
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges class names', () => {
|
||||
expect(cn('a', 'b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('drops falsy values', () => {
|
||||
expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('resolves conflicting tailwind classes, keeping the last one', () => {
|
||||
expect(cn('p-2', 'p-4')).toBe('p-4');
|
||||
});
|
||||
|
||||
it('applies conditional classes via object syntax', () => {
|
||||
expect(cn('base', { active: true, hidden: false })).toBe('base active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it.each([
|
||||
'user@example.com',
|
||||
'first.last@example.co.uk',
|
||||
'user+tag@example.com',
|
||||
'user_name@example.io',
|
||||
])('accepts a valid address: %s', (email) => {
|
||||
expect(isValidEmail(email)).toBe(true);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before validating', () => {
|
||||
expect(isValidEmail(' user@example.com ')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'empty string'],
|
||||
['not-an-email', 'missing @ and domain'],
|
||||
['user@', 'missing domain'],
|
||||
['@example.com', 'missing local part'],
|
||||
['user@example', 'domain missing TLD'],
|
||||
['user..name@example.com', 'consecutive dots in local part'],
|
||||
['.user@example.com', 'local part starts with a dot'],
|
||||
['user.@example.com', 'local part ends with a dot'],
|
||||
['user@example..com', 'consecutive dots in domain'],
|
||||
['user@.example.com', 'domain starts with a dot'],
|
||||
['user@example.c', 'TLD shorter than 2 chars'],
|
||||
])('rejects an invalid address: %s (%s)', (email) => {
|
||||
expect(isValidEmail(email)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input without throwing', () => {
|
||||
expect(isValidEmail(null as unknown as string)).toBe(false);
|
||||
expect(isValidEmail(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a local part longer than 64 characters', () => {
|
||||
const longLocal = 'a'.repeat(65);
|
||||
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an address longer than 254 characters overall', () => {
|
||||
const longDomain = 'a'.repeat(250) + '.com';
|
||||
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
1918
viewer-frontend/package-lock.json
generated
1918
viewer-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,9 @@
|
||||
"start:3001": "PORT=3001 ./scripts/with-sharp-libpath.sh next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:generate:auth": "prisma generate --schema=prisma/schema-auth.prisma",
|
||||
"prisma:generate:all": "prisma generate && prisma generate --schema=prisma/schema-auth.prisma",
|
||||
@ -52,18 +55,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"node-addon-api": "^8.5.0",
|
||||
"node-gyp": "^12.1.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
19
viewer-frontend/vitest.config.ts
Normal file
19
viewer-frontend/vitest.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules', '.next', 'e2e'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
54
viewer-frontend/vitest.setup.ts
Normal file
54
viewer-frontend/vitest.setup.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// jsdom doesn't implement these, but Radix UI primitives (Tooltip, Popover,
|
||||
// Select, etc.) call them during mount/positioning. Stub them out so
|
||||
// component tests don't have to know about Radix's internals.
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
window.matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
|
||||
if (typeof (globalThis as any).ResizeObserver === 'undefined') {
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = ResizeObserverStub;
|
||||
}
|
||||
|
||||
for (const method of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
|
||||
if (!(method in Element.prototype)) {
|
||||
Object.defineProperty(Element.prototype, method, {
|
||||
value: () => false,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!('scrollIntoView' in Element.prototype)) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
value: () => {},
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom never computes layout, so its native `offsetParent` getter always
|
||||
// returns null. Code that uses it as a "is this element visible?" check
|
||||
// (e.g. useFocusTrap's focusable-element filter) would otherwise treat
|
||||
// everything as hidden. Override it to approximate "visible" as "attached
|
||||
// to the document" instead.
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
|
||||
get() {
|
||||
return this.parentNode;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user