Merge pull request 'Add Vitest + React Testing Library setup for viewer-frontend' (#63) from test/viewer-frontend-vitest-setup into dev

This commit is contained in:
ilia 2026-07-14 17:39:59 -05:00
commit 62d5a66ffa
10 changed files with 2303 additions and 30 deletions

View File

@ -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

View 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');
});
});

View 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();
});
});

View 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');
});
});

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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"plugins": [
{
"name": "next"

View 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, '.'),
},
},
});

View 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,
});