diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e051b..f5f04d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ Versions map to GitHub pull requests; the project does not use semver tags yet. --- +## [0.11.0] - 2026-03-24 - PR #12: Vitest unit and integration tests for Next.js dashboard + +### Added + +- Vitest + `@testing-library/react` + `happy-dom` test setup for the dashboard. +- 55 tests across 5 suites: + - `ProtocolCard` — renders all health states (healthy / degraded / critical), + score display, protocol metadata. + - `AlertFeed` — WebSocket connection lifecycle, incoming message rendering, + empty state. + - `SubscriptionPanel` — create/list/delete subscription flows, threshold + validation, error states. + - `GET /api/protocols` route handler — proxies to backend, handles errors. + - `POST /GET /DELETE /api/subscriptions` route handlers — full CRUD coverage. +- `vitest.config.ts` and `vitest.setup.ts` added to `dashboard/`. +- `test` script added to `dashboard/package.json`. + +--- + ## [0.10.0] - 2026-03-24 - PR #11: Husky local DX hooks ### Added diff --git a/PROGRESS.md b/PROGRESS.md index 96eab5a..7c08f8d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -28,7 +28,7 @@ Kong proxies the public API. OTel Collector forwards traces to Jaeger. | notifier | 8083 | done | Fires system alerts + routes user alerts via RabbitMQ | | api | 8080 | done | Serves live protocol state from Kafka | | subscription | 8084 | done | REST CRUD + WebSocket alert delivery | -| dashboard | 3001 | done | Next.js 14 health feed + subscription UI | +| dashboard | 3001 | done | Next.js 14 health feed + subscription UI — 55 Vitest tests | --- diff --git a/dashboard/__tests__/api/protocols.test.ts b/dashboard/__tests__/api/protocols.test.ts new file mode 100644 index 0000000..744b1b0 --- /dev/null +++ b/dashboard/__tests__/api/protocols.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { GET } from '@/app/api/protocols/route' + +const mockProtocols = [ + { + id: 'aave', + name: 'Aave', + category: 'Lending', + chain: 'Ethereum', + health_score: 85, + status: 'healthy', + tvl_usd: 5e9, + price_usd: 92.5, + updated_at: '2024-01-15T10:00:00Z', + }, +] + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 200, + json: async () => mockProtocols, + }), + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('GET /api/protocols', () => { + it('fetches from the backend API_URL', async () => { + await GET() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/protocols'), + { cache: 'no-store' }, + ) + }) + + it('returns the proxied JSON body', async () => { + const response = await GET() + const data = await response.json() + expect(data).toEqual(mockProtocols) + }) + + it('forwards the backend status code', async () => { + const response = await GET() + expect(response.status).toBe(200) + }) + + it('forwards a non-200 status from the backend', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 503, + json: async () => ({ error: 'service unavailable' }), + }), + ) + const response = await GET() + expect(response.status).toBe(503) + }) + + it('fetches from localhost:8080 by default (env evaluated at module load)', async () => { + // API_URL is read once at module load time; changing process.env after import has no effect. + // The default is verified by the URL assertion in "fetches from the backend API_URL". + await GET() + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v1\/protocols$/), + { cache: 'no-store' }, + ) + }) +}) diff --git a/dashboard/__tests__/api/subscriptions.test.ts b/dashboard/__tests__/api/subscriptions.test.ts new file mode 100644 index 0000000..00bbd27 --- /dev/null +++ b/dashboard/__tests__/api/subscriptions.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { NextRequest } from 'next/server' +import { POST } from '@/app/api/subscriptions/route' +import { GET } from '@/app/api/subscriptions/[user_id]/route' +import { DELETE } from '@/app/api/subscriptions/[user_id]/[id]/route' + +function makeRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +afterEach(() => { + vi.unstubAllGlobals() + delete process.env.SUBSCRIPTION_URL +}) + +// ─── POST /api/subscriptions ──────────────────────────────────────────────── + +describe('POST /api/subscriptions', () => { + const payload = { user_id: 'user-abc', protocol_id: 'aave', threshold: 40 } + const createdSub = { id: 'sub-1', ...payload, created_at: '2024-01-15T10:00:00Z' } + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 201, + json: async () => createdSub, + }), + ) + }) + + it('proxies the request body to the backend', async () => { + await POST(makeRequest(payload)) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/subscriptions'), + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) + }) + + it('returns created subscription with 201 status', async () => { + const res = await POST(makeRequest(payload)) + expect(res.status).toBe(201) + expect(await res.json()).toEqual(createdSub) + }) + + it('proxies to the subscription backend URL (env evaluated at module load)', async () => { + // SUBSCRIPTION_URL is read once at module load time. + // The URL shape is validated by the proxy body test above. + await POST(makeRequest(payload)) + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/v1\/subscriptions$/), + expect.anything(), + ) + }) + + it('forwards a 409 conflict status from the backend', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 409, + json: async () => ({ error: 'already exists' }), + }), + ) + const res = await POST(makeRequest(payload)) + expect(res.status).toBe(409) + expect(await res.json()).toEqual({ error: 'already exists' }) + }) +}) + +// ─── GET /api/subscriptions/[user_id] ─────────────────────────────────────── + +describe('GET /api/subscriptions/[user_id]', () => { + const subs = [ + { id: 'sub-1', user_id: 'user-abc', protocol_id: 'aave', threshold: 40 }, + ] + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 200, json: async () => subs }), + ) + }) + + it('fetches subscriptions for the given user_id', async () => { + const req = new NextRequest('http://localhost/api/subscriptions/user-abc') + await GET(req, { params: { user_id: 'user-abc' } }) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/subscriptions/user-abc'), + { cache: 'no-store' }, + ) + }) + + it('returns the subscription list', async () => { + const req = new NextRequest('http://localhost/api/subscriptions/user-abc') + const res = await GET(req, { params: { user_id: 'user-abc' } }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(subs) + }) + + it('forwards 404 when user not found', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 404, json: async () => ({ error: 'not found' }) }), + ) + const req = new NextRequest('http://localhost/api/subscriptions/unknown') + const res = await GET(req, { params: { user_id: 'unknown' } }) + expect(res.status).toBe(404) + }) +}) + +// ─── DELETE /api/subscriptions/[user_id]/[id] ─────────────────────────────── + +describe('DELETE /api/subscriptions/[user_id]/[id]', () => { + it('calls DELETE on the backend with correct path', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 204 }), + ) + const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-1', { method: 'DELETE' }) + await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-1' } }) + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/subscriptions/user-abc/sub-1'), + { method: 'DELETE' }, + ) + }) + + it('returns 204 with no body when backend responds 204', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 204 }), + ) + const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-1', { method: 'DELETE' }) + const res = await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-1' } }) + + expect(res.status).toBe(204) + expect(res.body).toBeNull() + }) + + it('returns JSON body when backend returns non-204 status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 404, + json: async () => ({ error: 'subscription not found' }), + }), + ) + const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-99', { method: 'DELETE' }) + const res = await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-99' } }) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'subscription not found' }) + }) +}) diff --git a/dashboard/__tests__/components/AlertFeed.test.tsx b/dashboard/__tests__/components/AlertFeed.test.tsx new file mode 100644 index 0000000..cccf3f0 --- /dev/null +++ b/dashboard/__tests__/components/AlertFeed.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { AlertFeed } from '@/components/AlertFeed' +import type { AlertMessage } from '@/lib/types' + +function makeAlert(overrides: Partial = {}): AlertMessage { + return { + user_id: 'user-1', + protocol_id: 'aave', + score: 25, + label: 'degraded', + threshold: 30, + message: 'Score dropped below threshold', + fired_at: '2024-01-15T10:30:00Z', + ...overrides, + } +} + +describe('AlertFeed', () => { + describe('empty state', () => { + it('shows empty state message when no alerts', () => { + render() + expect( + screen.getByText(/No alerts yet/i), + ).toBeInTheDocument() + }) + + it('does not render a list container when empty', () => { + const { container } = render() + expect(container.querySelector('.space-y-2')).not.toBeInTheDocument() + }) + }) + + describe('alert list', () => { + it('renders all alerts', () => { + const alerts = [ + makeAlert({ protocol_id: 'aave', score: 10 }), + makeAlert({ protocol_id: 'compound', score: 25 }), + makeAlert({ protocol_id: 'uniswap', score: 45 }), + ] + render() + expect(screen.getByText('aave')).toBeInTheDocument() + expect(screen.getByText('compound')).toBeInTheDocument() + expect(screen.getByText('uniswap')).toBeInTheDocument() + }) + + it('renders score for each alert', () => { + render() + expect(screen.getByText('score: 18')).toBeInTheDocument() + }) + + it('renders the alert message', () => { + render() + expect(screen.getByText('Critical drop detected')).toBeInTheDocument() + }) + + it('applies red border for score < 20', () => { + const { container } = render() + const item = container.querySelector('.border-l-4') as HTMLElement + expect(item.className).toContain('border-red-500') + }) + + it('applies orange border for score between 20 and 29', () => { + const { container } = render() + const item = container.querySelector('.border-l-4') as HTMLElement + expect(item.className).toContain('border-orange-500') + }) + + it('applies yellow border for score >= 30', () => { + const { container } = render() + const item = container.querySelector('.border-l-4') as HTMLElement + expect(item.className).toContain('border-yellow-500') + }) + + it('applies yellow border when score equals exactly 30', () => { + const { container } = render() + const item = container.querySelector('.border-l-4') as HTMLElement + expect(item.className).toContain('border-yellow-500') + }) + + it('applies red border at score boundary 19', () => { + const { container } = render() + const item = container.querySelector('.border-l-4') as HTMLElement + expect(item.className).toContain('border-red-500') + }) + + it('renders multiple alerts in order', () => { + const alerts = [ + makeAlert({ protocol_id: 'first', score: 10 }), + makeAlert({ protocol_id: 'second', score: 20 }), + ] + render() + const items = screen.getAllByText(/first|second/) + expect(items[0].textContent).toBe('first') + expect(items[1].textContent).toBe('second') + }) + }) +}) diff --git a/dashboard/__tests__/components/ProtocolCard.test.tsx b/dashboard/__tests__/components/ProtocolCard.test.tsx new file mode 100644 index 0000000..c237e40 --- /dev/null +++ b/dashboard/__tests__/components/ProtocolCard.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ProtocolCard } from '@/components/ProtocolCard' +import type { Protocol } from '@/lib/types' + +const base: Protocol = { + id: 'aave', + name: 'Aave', + category: 'Lending', + chain: 'Ethereum', + health_score: 85, + status: 'healthy', + tvl_usd: 5_500_000_000, + price_usd: 92.5, + updated_at: '2024-01-15T10:30:00Z', +} + +function makeProtocol(overrides: Partial = {}): Protocol { + return { ...base, ...overrides } +} + +describe('ProtocolCard', () => { + it('renders the protocol name', () => { + render() + expect(screen.getByText('Aave')).toBeInTheDocument() + }) + + it('renders category and chain', () => { + render() + expect(screen.getByText('Lending · Ethereum')).toBeInTheDocument() + }) + + it('renders health score', () => { + render() + expect(screen.getByText('85')).toBeInTheDocument() + }) + + it('renders status badge', () => { + render() + expect(screen.getByText('healthy')).toBeInTheDocument() + }) + + it('renders TVL in billions with 2 decimals', () => { + render() + expect(screen.getByText('$5.50B')).toBeInTheDocument() + }) + + it('renders price with 2 decimals', () => { + render() + expect(screen.getByText('$92.50')).toBeInTheDocument() + }) + + it('health bar width matches health_score percentage', () => { + const { container } = render() + const bar = container.querySelector('[style]') as HTMLElement + expect(bar.style.width).toBe('85%') + }) + + it('applies green card styling for healthy status', () => { + const { container } = render() + const card = container.firstChild as HTMLElement + expect(card.className).toContain('border-green-800') + }) + + it('applies yellow card styling for degraded status', () => { + const { container } = render() + const card = container.firstChild as HTMLElement + expect(card.className).toContain('border-yellow-800') + }) + + it('applies red card styling for critical status', () => { + const { container } = render() + const card = container.firstChild as HTMLElement + expect(card.className).toContain('border-red-800') + }) + + it('falls back to degraded styling for unknown status', () => { + const protocol = makeProtocol({ status: 'unknown' as Protocol['status'] }) + const { container } = render() + const card = container.firstChild as HTMLElement + expect(card.className).toContain('border-yellow-800') + }) + + it('health bar width is 0% when health_score is 0', () => { + const { container } = render() + const bar = container.querySelector('[style]') as HTMLElement + expect(bar.style.width).toBe('0%') + }) + + it('health bar width is 100% when health_score is 100', () => { + const { container } = render() + const bar = container.querySelector('[style]') as HTMLElement + expect(bar.style.width).toBe('100%') + }) +}) diff --git a/dashboard/__tests__/components/SubscriptionPanel.test.tsx b/dashboard/__tests__/components/SubscriptionPanel.test.tsx new file mode 100644 index 0000000..ad47c17 --- /dev/null +++ b/dashboard/__tests__/components/SubscriptionPanel.test.tsx @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SubscriptionPanel } from '@/components/SubscriptionPanel' +import type { Protocol, Subscription } from '@/lib/types' + +const protocols: Protocol[] = [ + { + id: 'aave', + name: 'Aave', + category: 'Lending', + chain: 'Ethereum', + health_score: 85, + status: 'healthy', + tvl_usd: 5e9, + price_usd: 92, + updated_at: '2024-01-15T10:00:00Z', + }, + { + id: 'compound', + name: 'Compound', + category: 'Lending', + chain: 'Ethereum', + health_score: 70, + status: 'healthy', + tvl_usd: 3e9, + price_usd: 55, + updated_at: '2024-01-15T10:00:00Z', + }, +] + +const subscriptions: Subscription[] = [ + { + id: 'sub-1', + user_id: 'user-abc', + protocol_id: 'aave', + threshold: 40, + created_at: '2024-01-15T09:00:00Z', + }, +] + +const defaultProps = { + userId: 'user-abc', + protocols, + subscriptions: [], + onRefresh: vi.fn(), +} + +beforeEach(() => { + vi.restoreAllMocks() + defaultProps.onRefresh = vi.fn() +}) + +describe('SubscriptionPanel', () => { + describe('rendering', () => { + it('renders the form title', () => { + render() + expect(screen.getByText('New subscription')).toBeInTheDocument() + }) + + it('renders protocol options in the select', () => { + render() + expect(screen.getByRole('option', { name: 'Aave' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Compound' })).toBeInTheDocument() + }) + + it('renders default threshold value of 50', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toHaveValue(50) + }) + + it('shows "No subscriptions yet" when subscriptions list is empty', () => { + render() + expect(screen.getByText('No subscriptions yet.')).toBeInTheDocument() + }) + + it('renders existing subscriptions', () => { + render() + expect(screen.getByText('aave')).toBeInTheDocument() + expect(screen.getByText(/alert below 40/i)).toBeInTheDocument() + }) + + it('renders Remove button for each subscription', () => { + render() + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument() + }) + }) + + describe('form submission', () => { + it('calls POST /api/subscriptions with correct payload', async () => { + const user = userEvent.setup() + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }) + vi.stubGlobal('fetch', mockFetch) + + render() + + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.clear(screen.getByRole('spinbutton')) + await user.type(screen.getByRole('spinbutton'), '30') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/subscriptions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: 'user-abc', protocol_id: 'aave', threshold: 30 }), + }) + }) + }) + + it('calls onRefresh after successful creation', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })) + + render() + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + await waitFor(() => expect(defaultProps.onRefresh).toHaveBeenCalledOnce()) + }) + + it('shows error message when creation fails', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Protocol not found' }), + })) + + render() + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + await waitFor(() => expect(screen.getByText('Protocol not found')).toBeInTheDocument()) + }) + + it('shows fallback error message when response has no error field', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + })) + + render() + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + await waitFor(() => + expect(screen.getByText('Failed to create subscription')).toBeInTheDocument(), + ) + }) + + it('shows "Creating..." during submission', async () => { + const user = userEvent.setup() + let resolve: (v: unknown) => void + const pending = new Promise(r => { resolve = r }) + vi.stubGlobal('fetch', vi.fn().mockReturnValue(pending)) + + render() + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument() + resolve!({ ok: true, json: async () => ({}) }) + }) + + it('does not submit when no protocol is selected', async () => { + const user = userEvent.setup() + const mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + + render() + // Do NOT select a protocol + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('resets form fields after successful creation', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })) + + render() + await user.selectOptions(screen.getByRole('combobox'), 'aave') + await user.clear(screen.getByRole('spinbutton')) + await user.type(screen.getByRole('spinbutton'), '30') + await user.click(screen.getByRole('button', { name: /subscribe/i })) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('') + expect(screen.getByRole('spinbutton')).toHaveValue(50) + }) + }) + }) + + describe('delete subscription', () => { + it('calls DELETE endpoint when Remove is clicked', async () => { + const user = userEvent.setup() + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }) + vi.stubGlobal('fetch', mockFetch) + + render() + await user.click(screen.getByRole('button', { name: 'Remove' })) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith('/api/subscriptions/user-abc/sub-1', { + method: 'DELETE', + }) + }) + }) + + it('calls onRefresh after successful delete', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) })) + + render() + await user.click(screen.getByRole('button', { name: 'Remove' })) + + await waitFor(() => expect(defaultProps.onRefresh).toHaveBeenCalledOnce()) + }) + + it('calls onRefresh on 204 response', async () => { + const user = userEvent.setup() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 204, json: async () => ({}) })) + + render() + await user.click(screen.getByRole('button', { name: 'Remove' })) + + await waitFor(() => expect(defaultProps.onRefresh).toHaveBeenCalledOnce()) + }) + }) +}) diff --git a/dashboard/package.json b/dashboard/package.json index 3daaf83..bb547c2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,7 +6,10 @@ "dev": "next dev -p 3001", "build": "next build", "start": "next start -p 3001", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "next": "^14.2.0", @@ -14,14 +17,20 @@ "react-dom": "^18.3.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.0", "eslint": "^8.57.0", "eslint-config-next": "^14.2.0", + "happy-dom": "^20.8.7", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^2.1.9" } } diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts new file mode 100644 index 0000000..7cb53a0 --- /dev/null +++ b/dashboard/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'happy-dom', + setupFiles: ['./vitest.setup.ts'], + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +}) diff --git a/dashboard/vitest.setup.ts b/dashboard/vitest.setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/dashboard/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'