Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, apiFetch } from '../src/api/client'
|
||||
|
||||
/* fetch is stubbed with Response-shaped objects — the client only needs
|
||||
status/ok/json, and this keeps the test independent of jsdom's fetch. */
|
||||
|
||||
function stubFetch(body: unknown, status = 200) {
|
||||
const mock = vi.fn().mockResolvedValue({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
statusText: 'status text',
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
vi.stubGlobal('fetch', mock)
|
||||
return mock
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('decodes a JSON response', async () => {
|
||||
stubFetch([{ id: 1, title: 'First' }])
|
||||
|
||||
await expect(apiFetch('/api/v1/items')).resolves.toEqual([{ id: 1, title: 'First' }])
|
||||
})
|
||||
|
||||
it('sends method, JSON headers and a JSON body', async () => {
|
||||
const mock = stubFetch({ id: 1 })
|
||||
|
||||
await apiFetch('/api/v1/items', { method: 'POST', body: JSON.stringify({ title: 'x' }) })
|
||||
|
||||
const [path, init] = mock.mock.calls[0]! as [string, RequestInit]
|
||||
expect(path).toBe('/api/v1/items')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify({ title: 'x' }))
|
||||
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
it('returns undefined for 204 responses', async () => {
|
||||
stubFetch(null, 204)
|
||||
|
||||
await expect(apiFetch<void>('/api/v1/items/1', { method: 'DELETE' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('turns the backend error envelope into an ApiError', async () => {
|
||||
stubFetch({ error: { code: 'validation_failed', message: 'title: required' } }, 400)
|
||||
|
||||
const err = await apiFetch('/api/v1/items', { method: 'POST' }).catch(e => e)
|
||||
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err).toMatchObject({
|
||||
status: 400,
|
||||
code: 'validation_failed',
|
||||
message: 'title: required',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to statusText when the body is not the envelope', async () => {
|
||||
stubFetch('<html>gateway timeout</html>', 504)
|
||||
|
||||
const err = await apiFetch('/api/v1/items').catch(e => e)
|
||||
|
||||
expect(err).toMatchObject({ status: 504, code: 'unknown', message: 'status text' })
|
||||
})
|
||||
|
||||
it('wraps network failures (backend down) as a network ApiError', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockRejectedValue(new TypeError('fetch failed')),
|
||||
)
|
||||
|
||||
const err = await apiFetch('/api/v1/items').catch(e => e)
|
||||
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err.code).toBe('network')
|
||||
expect(err.message).toContain('Go server')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppStateProvider, useAppState } from '../src/app'
|
||||
|
||||
function Probe() {
|
||||
const { theme, viewMode, sidebarCollapsed, searchOpen, toggleTheme, setViewMode, toggleSidebar } = useAppState()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="state">{`${theme}|${viewMode}|${sidebarCollapsed}|${searchOpen}`}</span>
|
||||
<button onClick={toggleTheme}>theme</button>
|
||||
<button onClick={() => setViewMode('compact')}>compact</button>
|
||||
<button onClick={toggleSidebar}>sidebar</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
return render(
|
||||
<AppStateProvider>
|
||||
<Probe />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const state = () => screen.getByTestId('state').textContent
|
||||
|
||||
describe('AppStateProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.removeAttribute('data-view-mode')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('throws when useAppState is called outside the provider', () => {
|
||||
// React logs the render error too — keep the output readable.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<Probe />)).toThrow(/AppStateProvider/)
|
||||
})
|
||||
|
||||
it('starts from the defaults and mirrors them onto <html>', () => {
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('dark|default|false|false')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(document.documentElement.dataset.viewMode).toBe('default')
|
||||
})
|
||||
|
||||
it('persists settings on user action only, so prefers-color-scheme keeps working', () => {
|
||||
renderApp()
|
||||
|
||||
// Nothing was stored by the initial mount — an effect-driven write would
|
||||
// freeze the OS-derived theme for users who never toggled anything.
|
||||
expect(localStorage.getItem('ns-theme')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'theme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'compact' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar' }))
|
||||
|
||||
expect(localStorage.getItem('ns-theme')).toBe('light')
|
||||
expect(localStorage.getItem('ns-view-mode')).toBe('compact')
|
||||
expect(localStorage.getItem('ns-sidebar-collapsed')).toBe('true')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(document.documentElement.dataset.viewMode).toBe('compact')
|
||||
expect(state()).toBe('light|compact|true|false')
|
||||
})
|
||||
|
||||
it('rehydrates stored settings on the next mount', () => {
|
||||
localStorage.setItem('ns-theme', 'light')
|
||||
localStorage.setItem('ns-view-mode', 'compact')
|
||||
localStorage.setItem('ns-sidebar-collapsed', 'true')
|
||||
// Theme boots from the attribute index.html already painted (it resolved
|
||||
// storage / prefers-color-scheme), so state can never contradict the DOM.
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('light|compact|true|false')
|
||||
})
|
||||
|
||||
it('ignores stored values outside the allowed set', () => {
|
||||
localStorage.setItem('ns-view-mode', 'space')
|
||||
localStorage.setItem('ns-sidebar-collapsed', 'yes')
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('dark|default|false|false')
|
||||
})
|
||||
|
||||
it('toggles the search palette with ⌘K / Ctrl+K from anywhere', () => {
|
||||
function SearchProbe() {
|
||||
const { searchOpen } = useAppState()
|
||||
return <span data-testid="search">{String(searchOpen)}</span>
|
||||
}
|
||||
render(
|
||||
<AppStateProvider>
|
||||
<SearchProbe />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'k', metaKey: true })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('false')
|
||||
|
||||
// A plain "k" must not hijack typing.
|
||||
fireEvent.keyDown(window, { key: 'k' })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { UserMenu } from '../src/auth/UserMenu'
|
||||
import { SessionCard } from '../src/auth/SessionCard'
|
||||
import { login } from '../src/auth/useSession'
|
||||
|
||||
const anonymousBody = JSON.stringify({ authenticated: false, user: null })
|
||||
const userBody = JSON.stringify({
|
||||
authenticated: true,
|
||||
user: {
|
||||
sub: 'user-1',
|
||||
name: 'Mats Test',
|
||||
email: 'mats@example.com',
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
|
||||
function stubFetch(body: string, ok = true): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: () => Promise.resolve(JSON.parse(body)),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// jsdom's window.location is unforgeable; replace the whole global so the
|
||||
// login() navigation target can be asserted.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('location', { href: '', pathname: '/patterns', search: '?pattern=table' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('login()', () => {
|
||||
it('navigates to the backend login with the current path as returnTo', () => {
|
||||
login()
|
||||
expect(window.location.href).toBe('/api/auth/login?returnTo=' + encodeURIComponent('/patterns?pattern=table'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserMenu', () => {
|
||||
it('shows Sign in when anonymous', async () => {
|
||||
stubFetch(anonymousBody)
|
||||
render(<UserMenu />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows the avatar and a sign-out popover when authenticated', async () => {
|
||||
stubFetch(userBody)
|
||||
render(<UserMenu />)
|
||||
|
||||
const avatar = await waitFor(() => screen.getByRole('button', { name: 'Mats Test' }))
|
||||
expect(avatar).toHaveTextContent('MT')
|
||||
|
||||
fireEvent.click(avatar)
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByText('mats@example.com')).toBeInTheDocument()
|
||||
|
||||
const fetchMock = vi.mocked(globalThis.fetch)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /sign out/i }))
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', { method: 'POST' }),
|
||||
)
|
||||
// Back to anonymous.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('treats an unreachable backend as anonymous', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('backend down')))
|
||||
render(<UserMenu />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('closes the popover on Escape', async () => {
|
||||
stubFetch(userBody)
|
||||
render(<UserMenu />)
|
||||
fireEvent.click(await waitFor(() => screen.getByRole('button', { name: 'Mats Test' })))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionCard', () => {
|
||||
it('offers sign-in with a note when anonymous', async () => {
|
||||
stubFetch(anonymousBody)
|
||||
render(<SessionCard />)
|
||||
const button = await waitFor(() => screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText(/not signed in/i)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(window.location.href).toBe('/api/auth/login?returnTo=' + encodeURIComponent('/'))
|
||||
})
|
||||
|
||||
it('shows the signed-in user, roles, and sub when authenticated', async () => {
|
||||
stubFetch(userBody)
|
||||
render(<SessionCard />)
|
||||
expect(await screen.findByText('Mats Test')).toBeInTheDocument()
|
||||
expect(screen.getByText('mats@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('sub: user-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('admin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('signs out and flips back to anonymous', async () => {
|
||||
stubFetch(userBody)
|
||||
render(<SessionCard />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Sign out' }))
|
||||
const fetchMock = vi.mocked(globalThis.fetch)
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', { method: 'POST' }),
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const GUARD = join(process.cwd(), 'scripts', 'check-tokens.mjs')
|
||||
|
||||
function runIn(files) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'token-guard-'))
|
||||
mkdirSync(join(dir, 'src'))
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, 'src', name), content)
|
||||
}
|
||||
try {
|
||||
execFileSync(process.execPath, [GUARD, join(dir, 'src')], { encoding: 'utf8' })
|
||||
return { code: 0, output: '' }
|
||||
} catch (err) {
|
||||
return { code: err.status, output: err.stdout + String(err.stderr ?? '') }
|
||||
}
|
||||
}
|
||||
|
||||
const TOKENS = ':root { --primary: lab(50% 0 0); --space-2: 0.5rem; }\n'
|
||||
|
||||
describe('check-tokens guard', () => {
|
||||
it('passes a clean file', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'a.tsx': `export const A = () => <div style={{ color: 'var(--primary)', padding: 'var(--space-2)' }} />\n`,
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
})
|
||||
|
||||
it('flags hex, named colors and hsl()/oklch() inside style={{ }}', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'a.tsx': `export const A = () => (
|
||||
<div style={{
|
||||
color: '#fff',
|
||||
background: 'oklch(70% 0.1 200)',
|
||||
borderColor: 'red',
|
||||
}} />
|
||||
)\n`,
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.output).toMatch(/hex color #fff/)
|
||||
expect(result.output).toMatch(/oklch\(\) color/)
|
||||
expect(result.output).toMatch(/named color "red"/)
|
||||
})
|
||||
|
||||
it('allows color-mix over tokens and neutral rgba shines', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'a.tsx': `export const A = () => (
|
||||
<div style={{
|
||||
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
|
||||
}} />
|
||||
)\n`,
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
})
|
||||
|
||||
it('reads values split across lines in css', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'b.css': `.b {\n background:\n linear-gradient(\n to right,\n crimson,\n navy\n );\n}\n`,
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.output).toMatch(/named color "crimson"/)
|
||||
})
|
||||
|
||||
it('flags px font sizes in css and tsx', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'b.css': `.b { font-size: 14px; }\n`,
|
||||
'a.tsx': `export const A = () => <div style={{ fontSize: '14px' }} />\n`,
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.output.match(/px font size/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('fails on var(--x) that no css file defines', () => {
|
||||
const result = runIn({
|
||||
'tokens.css': TOKENS,
|
||||
'a.tsx': `export const A = () => <div style={{ color: 'var(--priamry)' }} />\n`,
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.output).toMatch(/var\(--priamry\) is never defined/)
|
||||
})
|
||||
|
||||
it('keeps the real repo clean', () => {
|
||||
expect(() => execFileSync(process.execPath, [GUARD], { stdio: 'pipe' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Icon, type IconName } from '../src/components/Icon'
|
||||
import { iconMap, iconRegistry } from '../src/components/icon-registry'
|
||||
|
||||
describe('icon registry (single source of truth)', () => {
|
||||
it('has unique names', () => {
|
||||
const names = iconRegistry.map(entry => entry.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
})
|
||||
|
||||
it('maps every name (including aliases) to a real registry component', () => {
|
||||
const components = new Set(iconRegistry.map(entry => entry.component))
|
||||
for (const [name, component] of Object.entries(iconMap)) {
|
||||
expect(components.has(component), `icon "${name}" has no canonical twin in the registry`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves every registry entry through iconMap', () => {
|
||||
for (const entry of iconRegistry) {
|
||||
expect(iconMap[entry.name]).toBe(entry.component)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a registry icon by name', () => {
|
||||
const { container } = render(<Icon name="sparkles" data-testid="sparkles" />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('throws on unknown names in dev instead of silently rendering a wrong glyph', () => {
|
||||
expect(() => render(<Icon name={'definitely-not-an-icon' as IconName} />)).toThrow(/Unknown icon name/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ItemsPage } from '../src/pages/ItemsPage'
|
||||
import { ApiError } from '../src/api/client'
|
||||
import * as itemsApi from '../src/api/items'
|
||||
import type { Item } from '../src/api/items'
|
||||
|
||||
/* Page-level wiring through the same seam the hooks tests use: the api
|
||||
module is mocked, everything else (SWR, page states, dialog) is real. */
|
||||
vi.mock('../src/api/items', () => ({
|
||||
listItems: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
createItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
}))
|
||||
|
||||
function item(overrides: Partial<Item> = {}): Item {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'First',
|
||||
description: '',
|
||||
status: 'active',
|
||||
createdAt: '2026-09-16T10:00:00Z',
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
|
||||
<ItemsPage />
|
||||
</SWRConfig>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(itemsApi.listItems).mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ItemsPage states', () => {
|
||||
it('shows a sign-in card when the API answers 401 (OIDC enabled)', async () => {
|
||||
vi.mocked(itemsApi.listItems).mockRejectedValue(
|
||||
new ApiError(401, 'unauthorized', 'sign in via /api/auth/login'),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Sign in required')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cannot reach the Go API')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the backend-down card with a retry when the API is unreachable', async () => {
|
||||
vi.mocked(itemsApi.listItems).mockRejectedValue(
|
||||
new ApiError(0, 'network', 'Could not reach the API (fetch failed)'),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Cannot reach the Go API')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the empty state and creates an item through the dialog', async () => {
|
||||
vi.mocked(itemsApi.createItem).mockResolvedValue(item({ title: 'From dialog' }))
|
||||
|
||||
renderPage()
|
||||
|
||||
// Empty state with its own CTA (the toolbar has a second "New item").
|
||||
expect(await screen.findByText('No items yet')).toBeInTheDocument()
|
||||
const cta = screen.getAllByRole('button', { name: /New item/ }).at(-1)!
|
||||
fireEvent.click(cta)
|
||||
|
||||
// Dialog: fill the form and submit.
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText(/Title/), { target: { value: 'From dialog' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create item/ }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(itemsApi.createItem).toHaveBeenCalledWith({ title: 'From dialog', description: undefined }),
|
||||
)
|
||||
// Success closes the dialog and revalidates the list.
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(itemsApi.listItems).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../src/components/ui/Dialog'
|
||||
import { Sheet, SheetClose, SheetContent, SheetTitle } from '../src/components/ui/Sheet'
|
||||
import { SearchPalette } from '../src/components/SearchPalette'
|
||||
|
||||
describe('Dialog (Overlay primitive)', () => {
|
||||
it('renders a modal dialog with the panel focused when open', () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
const panel = screen.getByRole('dialog')
|
||||
expect(panel).toHaveClass('dialog-panel')
|
||||
expect(panel).toHaveAttribute('aria-modal', 'true')
|
||||
expect(panel).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wires aria-labelledby only when a title is rendered', () => {
|
||||
const { rerender } = render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
const panel = screen.getByRole('dialog')
|
||||
const labelledBy = panel.getAttribute('aria-labelledby')
|
||||
expect(labelledBy).toBeTruthy()
|
||||
expect(document.getElementById(labelledBy!)).toHaveTextContent('Deploy')
|
||||
|
||||
// No DialogTitle → a dangling IDREF would announce nothing.
|
||||
rerender(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<div>body only</div>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-labelledby')
|
||||
})
|
||||
|
||||
it('wires aria-describedby only when a description is rendered', () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
<DialogDescription>To production</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
const describedBy = screen.getByRole('dialog').getAttribute('aria-describedby')
|
||||
expect(describedBy).toBeTruthy()
|
||||
expect(document.getElementById(describedBy!)).toHaveTextContent('To production')
|
||||
})
|
||||
|
||||
it('restores focus to the trigger after closing', () => {
|
||||
function Controlled() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
render(<Controlled />)
|
||||
const trigger = screen.getByRole('button', { name: 'Open' })
|
||||
// jsdom does not move focus on synthetic clicks — focus the trigger like a real user would.
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('dialog')).toHaveFocus()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes on Escape when open', () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on backdrop click', () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement!)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('locks body scroll while open and restores it on close', () => {
|
||||
const { rerender } = render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
rerender(
|
||||
<Dialog open={false}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Deploy</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
expect(document.body.style.overflow).not.toBe('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sheet (Overlay skin)', () => {
|
||||
it('renders with sheet classes and its own close label', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetContent>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
<SheetClose>Done</SheetClose>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
)
|
||||
const panel = screen.getByRole('dialog')
|
||||
expect(panel).toHaveClass('sheet-panel')
|
||||
expect(screen.getByRole('button', { name: 'Close sheet' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Tab focus inside the panel (wrap from last to first)', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetContent>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
<button>Only control</button>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
)
|
||||
const panel = screen.getByRole('dialog')
|
||||
const closeBtn = screen.getByRole('button', { name: 'Close sheet' })
|
||||
const last = screen.getByRole('button', { name: 'Only control' })
|
||||
|
||||
// Tab from the last control wraps to the first.
|
||||
last.focus()
|
||||
fireEvent.keyDown(panel, { key: 'Tab' })
|
||||
expect(closeBtn).toHaveFocus()
|
||||
|
||||
// Shift+Tab from the first control wraps to the last.
|
||||
fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchPalette (Overlay behavior)', () => {
|
||||
it('renders nothing while closed and no backdrop is left over the page', () => {
|
||||
const { rerender } = render(
|
||||
<SearchPalette isOpen={false} onClose={() => {}} onSelect={() => {}} />,
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.querySelector('.search-palette-backdrop')).toBeNull()
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
|
||||
rerender(<SearchPalette isOpen onClose={() => {}} onSelect={() => {}} />)
|
||||
expect(screen.getByRole('dialog')).toHaveClass('search-palette-modal')
|
||||
})
|
||||
|
||||
it('closes on Escape / backdrop click and unlocks body scroll', () => {
|
||||
function Controlled() {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open</button>
|
||||
<SearchPalette isOpen={open} onClose={() => setOpen(false)} onSelect={() => {}} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const { container } = render(<Controlled />)
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
fireEvent.click(container.querySelector('.search-palette-backdrop')!)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from '../src/App'
|
||||
import {
|
||||
AppStateProvider,
|
||||
PATTERNS_PATH,
|
||||
activeHref,
|
||||
navigationGroups,
|
||||
patternFromSearch,
|
||||
routeForPath,
|
||||
routes,
|
||||
} from '../src/app'
|
||||
|
||||
function renderAt(url: string) {
|
||||
window.history.pushState(null, '', url)
|
||||
return render(
|
||||
<AppStateProvider>
|
||||
<App />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
/** The sidebar and the overview cards can link to the same page — scope the
|
||||
queries that must land on one of them. */
|
||||
const sidebar = () => within(document.querySelector('.sidebar') as HTMLElement)
|
||||
const overview = () => within(document.querySelector('.overview-grid') as HTMLElement)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
/* jsdom has no layout — the shell's scroll-to-top effect would hit the
|
||||
"not implemented" hook on every navigation. */
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('route table', () => {
|
||||
it('keeps every sidebar entry pointed at a route that exists', () => {
|
||||
const known = new Set(routes.map(route => route.path))
|
||||
|
||||
for (const group of navigationGroups) {
|
||||
for (const item of group.items) {
|
||||
const [path] = item.href.split('?')
|
||||
expect(known.has(path!)).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves no route unreachable from the sidebar', () => {
|
||||
const navPaths = new Set(
|
||||
navigationGroups.flatMap(group => group.items.map(item => item.href.split('?')[0]!)),
|
||||
)
|
||||
|
||||
for (const route of routes) {
|
||||
expect(navPaths.has(route.path)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('looks routes up by path and rejects unknown ones', () => {
|
||||
expect(routeForPath('/components/buttons')?.label).toBe('Buttons')
|
||||
expect(routeForPath('/nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pattern URLs', () => {
|
||||
it('accepts a known pattern, falls back on anything else', () => {
|
||||
expect(patternFromSearch('?pattern=table')).toBe('table')
|
||||
expect(patternFromSearch('?pattern=space')).toBe('metrics')
|
||||
expect(patternFromSearch('')).toBe('metrics')
|
||||
})
|
||||
|
||||
it('marks the matching pattern entry active', () => {
|
||||
expect(activeHref(PATTERNS_PATH, '?pattern=deploy')).toBe('/patterns?pattern=deploy')
|
||||
expect(activeHref(PATTERNS_PATH, '')).toBe('/patterns?pattern=metrics')
|
||||
expect(activeHref('/components/icons', '')).toBe('/components/icons')
|
||||
})
|
||||
})
|
||||
|
||||
describe('routing', () => {
|
||||
it('renders the overview at /', () => {
|
||||
renderAt('/')
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Build with clarity.')
|
||||
expect(overview().getByText('Foundations')).toBeInTheDocument()
|
||||
expect(overview().getByRole('link', { name: /Color tokens/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/foundations/colors',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the page registered for a URL', () => {
|
||||
for (const route of routes) {
|
||||
const { unmount } = renderAt(route.path)
|
||||
expect(screen.getByText(route.title, { selector: '.section-heading h2' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the sidebar entry for the current URL as current', () => {
|
||||
renderAt('/foundations/colors')
|
||||
|
||||
expect(sidebar().getByRole('link', { name: 'Color tokens' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(sidebar().getByRole('link', { name: 'Typography' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('navigates on a sidebar click without a full page load', () => {
|
||||
renderAt('/')
|
||||
const link = sidebar().getByRole('link', { name: 'Typography' })
|
||||
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(window.location.pathname).toBe('/foundations/typography')
|
||||
expect(link).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByText('Typography', { selector: '.section-heading h2' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the pattern named in the URL', () => {
|
||||
renderAt('/patterns?pattern=table')
|
||||
|
||||
expect(screen.getByRole('button', { name: /Service Monitor Table/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: /Metrics Dashboard/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
// Sidebar pattern entries share /patterns and differ only by the query.
|
||||
expect(sidebar().getByRole('link', { name: 'Service Monitor Table' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
})
|
||||
|
||||
it('writes the chosen pattern into the URL', () => {
|
||||
renderAt('/patterns')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Deployment Flow/ }))
|
||||
|
||||
expect(window.location.search).toBe('?pattern=deploy')
|
||||
})
|
||||
|
||||
it('falls back to a 404 page instead of a blank screen', () => {
|
||||
renderAt('/definitely/not/a/route')
|
||||
|
||||
expect(screen.getByText('Page not found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,124 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useItemActions, useItems } from '../src/hooks/useItems'
|
||||
import type { Item } from '../src/api/items'
|
||||
|
||||
/* The api module is the seam: hooks are tested against a mocked module,
|
||||
exactly like pages will consume the hooks. */
|
||||
vi.mock('../src/api/items', () => ({
|
||||
listItems: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
createItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
}))
|
||||
|
||||
import * as itemsApi from '../src/api/items'
|
||||
|
||||
/* Fresh cache per test so revalidation assertions never share state. */
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
||||
}
|
||||
|
||||
function item(overrides: Partial<Item> = {}): Item {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'First',
|
||||
description: '',
|
||||
status: 'active',
|
||||
createdAt: '2026-09-15T10:00:00Z',
|
||||
updatedAt: '2026-09-15T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/* useItems and useItemActions must share one SWR cache for action
|
||||
revalidation to be observable — render both through one harness. */
|
||||
function useItemsHarness() {
|
||||
const list = useItems()
|
||||
const actions = useItemActions()
|
||||
return { list, actions }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(itemsApi.listItems).mockResolvedValue([item()])
|
||||
vi.mocked(itemsApi.createItem).mockResolvedValue(item({ id: 2, title: 'Second' }))
|
||||
vi.mocked(itemsApi.updateItem).mockResolvedValue(item({ status: 'archived' }))
|
||||
vi.mocked(itemsApi.deleteItem).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useItems', () => {
|
||||
it('loads the list through the api module', async () => {
|
||||
const { result } = renderHook(() => useItems(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1))
|
||||
|
||||
expect(itemsApi.listItems).toHaveBeenCalledWith({})
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('feeds the filter into the api query', async () => {
|
||||
const { result } = renderHook(() => useItems({ status: 'archived' }), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1))
|
||||
|
||||
expect(itemsApi.listItems).toHaveBeenCalledWith({ status: 'archived' })
|
||||
})
|
||||
|
||||
it('exposes load failures as errors', async () => {
|
||||
vi.mocked(itemsApi.listItems).mockRejectedValueOnce(new Error('backend down'))
|
||||
|
||||
const { result } = renderHook(() => useItems(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
|
||||
expect(result.current.items).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useItemActions', () => {
|
||||
it('creates through the api module and revalidates the list', async () => {
|
||||
const { result } = renderHook(() => useItemsHarness(), { wrapper })
|
||||
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
|
||||
|
||||
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
|
||||
await act(() => result.current.actions.create({ title: 'Second' }))
|
||||
|
||||
expect(itemsApi.createItem).toHaveBeenCalledWith({ title: 'Second' })
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
|
||||
)
|
||||
})
|
||||
|
||||
it('updates through the api module and revalidates the list', async () => {
|
||||
const { result } = renderHook(() => useItemsHarness(), { wrapper })
|
||||
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
|
||||
|
||||
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
|
||||
await act(() => result.current.actions.update(1, { status: 'archived' }))
|
||||
|
||||
expect(itemsApi.updateItem).toHaveBeenCalledWith(1, { status: 'archived' })
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
|
||||
)
|
||||
})
|
||||
|
||||
it('removes through the api module and revalidates the list', async () => {
|
||||
const { result } = renderHook(() => useItemsHarness(), { wrapper })
|
||||
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
|
||||
|
||||
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
|
||||
await act(() => result.current.actions.remove(1))
|
||||
|
||||
expect(itemsApi.deleteItem).toHaveBeenCalledWith(1)
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useTimeout } from '../src/utils/useTimeout'
|
||||
|
||||
describe('useTimeout', () => {
|
||||
it('fires the callback after the delay', () => {
|
||||
vi.useFakeTimers()
|
||||
const { result } = renderHook(() => useTimeout())
|
||||
const fn = vi.fn()
|
||||
act(() => {
|
||||
result.current.schedule(fn, 1000)
|
||||
})
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clear() cancels pending timers', () => {
|
||||
vi.useFakeTimers()
|
||||
const { result } = renderHook(() => useTimeout())
|
||||
const fn = vi.fn()
|
||||
act(() => {
|
||||
result.current.schedule(fn, 1000)
|
||||
result.current.clear()
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears pending timers on unmount (no setState-after-unmount leaks)', () => {
|
||||
vi.useFakeTimers()
|
||||
const { result, unmount } = renderHook(() => useTimeout())
|
||||
const fn = vi.fn()
|
||||
act(() => {
|
||||
result.current.schedule(fn, 1000)
|
||||
})
|
||||
unmount()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user