feat: add unit tests and CI configuration
This commit is contained in:
27
.github/workflows/test.yml
vendored
Normal file
27
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# * Runs unit tests on push/PR to main and staging.
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, staging]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, staging]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: unit-tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
|
||||||
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
|
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
|
||||||
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
|
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
|
||||||
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
|
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
|
||||||
|
|||||||
99
__tests__/middleware.test.ts
Normal file
99
__tests__/middleware.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { middleware } from '../middleware'
|
||||||
|
|
||||||
|
function createRequest(path: string, cookies: Record<string, string> = {}): NextRequest {
|
||||||
|
const url = new URL(path, 'http://localhost:3000')
|
||||||
|
const req = new NextRequest(url)
|
||||||
|
for (const [name, value] of Object.entries(cookies)) {
|
||||||
|
req.cookies.set(name, value)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('middleware', () => {
|
||||||
|
describe('public routes', () => {
|
||||||
|
const publicPaths = [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/auth/callback',
|
||||||
|
'/pricing',
|
||||||
|
'/features',
|
||||||
|
'/about',
|
||||||
|
'/faq',
|
||||||
|
'/changelog',
|
||||||
|
'/installation',
|
||||||
|
'/script.js',
|
||||||
|
]
|
||||||
|
|
||||||
|
publicPaths.forEach((path) => {
|
||||||
|
it(`allows unauthenticated access to ${path}`, () => {
|
||||||
|
const res = middleware(createRequest(path))
|
||||||
|
// NextResponse.next() does not set a Location header
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('public prefixes', () => {
|
||||||
|
it('allows /share/* without auth', () => {
|
||||||
|
const res = middleware(createRequest('/share/abc123'))
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /integrations without auth', () => {
|
||||||
|
const res = middleware(createRequest('/integrations'))
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows /docs without auth', () => {
|
||||||
|
const res = middleware(createRequest('/docs'))
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('protected routes', () => {
|
||||||
|
it('redirects unauthenticated users to /login', () => {
|
||||||
|
const res = middleware(createRequest('/sites'))
|
||||||
|
expect(res.headers.get('Location')).toContain('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated users from /settings to /login', () => {
|
||||||
|
const res = middleware(createRequest('/settings'))
|
||||||
|
expect(res.headers.get('Location')).toContain('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access with access_token cookie', () => {
|
||||||
|
const res = middleware(createRequest('/sites', { access_token: 'tok' }))
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access with refresh_token cookie only', () => {
|
||||||
|
const res = middleware(createRequest('/sites', { refresh_token: 'tok' }))
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth-only route redirects', () => {
|
||||||
|
it('redirects authenticated user from /login to /', () => {
|
||||||
|
const res = middleware(createRequest('/login', { access_token: 'tok' }))
|
||||||
|
const location = res.headers.get('Location')
|
||||||
|
expect(location).not.toBeNull()
|
||||||
|
expect(new URL(location!).pathname).toBe('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects authenticated user from /signup to /', () => {
|
||||||
|
const res = middleware(createRequest('/signup', { access_token: 'tok' }))
|
||||||
|
const location = res.headers.get('Location')
|
||||||
|
expect(location).not.toBeNull()
|
||||||
|
expect(new URL(location!).pathname).toBe('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT redirect from /login with only refresh_token (stale session)', () => {
|
||||||
|
const res = middleware(createRequest('/login', { refresh_token: 'tok' }))
|
||||||
|
// Should allow through to /login since only refresh_token is present
|
||||||
|
expect(res.headers.get('Location')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal file
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useOnlineStatus } from '../useOnlineStatus'
|
||||||
|
|
||||||
|
describe('useOnlineStatus', () => {
|
||||||
|
it('returns true initially', () => {
|
||||||
|
const { result } = renderHook(() => useOnlineStatus())
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when offline event fires', () => {
|
||||||
|
const { result } = renderHook(() => useOnlineStatus())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event('offline'))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when online event fires after offline', () => {
|
||||||
|
const { result } = renderHook(() => useOnlineStatus())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event('offline'))
|
||||||
|
})
|
||||||
|
expect(result.current).toBe(false)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event('online'))
|
||||||
|
})
|
||||||
|
expect(result.current).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal file
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useVisibilityPolling } from '../useVisibilityPolling'
|
||||||
|
|
||||||
|
describe('useVisibilityPolling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts polling and calls callback at the visible interval', () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useVisibilityPolling(callback, {
|
||||||
|
visibleInterval: 1000,
|
||||||
|
hiddenInterval: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial call might not happen immediately; advance to trigger interval
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports isPolling as true when active', () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useVisibilityPolling(callback, {
|
||||||
|
visibleInterval: 1000,
|
||||||
|
hiddenInterval: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.current.isPolling).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback multiple times over multiple intervals', () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useVisibilityPolling(callback, {
|
||||||
|
visibleInterval: 500,
|
||||||
|
hiddenInterval: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggerPoll calls callback immediately', () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useVisibilityPolling(callback, {
|
||||||
|
visibleInterval: 10000,
|
||||||
|
hiddenInterval: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.triggerPoll()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled()
|
||||||
|
expect(result.current.lastPollTime).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up intervals on unmount', () => {
|
||||||
|
const callback = vi.fn()
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useVisibilityPolling(callback, {
|
||||||
|
visibleInterval: 1000,
|
||||||
|
hiddenInterval: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
callback.mockClear()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
95
lib/utils/__tests__/errorHandler.test.ts
Normal file
95
lib/utils/__tests__/errorHandler.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
getRequestIdFromError,
|
||||||
|
formatErrorMessage,
|
||||||
|
logErrorWithRequestId,
|
||||||
|
getSupportMessage,
|
||||||
|
} from '../errorHandler'
|
||||||
|
import { setLastRequestId, clearLastRequestId } from '../requestId'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearLastRequestId()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRequestIdFromError', () => {
|
||||||
|
it('extracts request ID from error response body', () => {
|
||||||
|
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||||
|
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to last stored request ID when not in response', () => {
|
||||||
|
setLastRequestId('REQfallback_xyz')
|
||||||
|
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to last stored request ID when no error data', () => {
|
||||||
|
setLastRequestId('REQfallback_xyz')
|
||||||
|
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no ID available anywhere', () => {
|
||||||
|
expect(getRequestIdFromError()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatErrorMessage', () => {
|
||||||
|
it('returns plain message when no request ID available', () => {
|
||||||
|
expect(formatErrorMessage('Something failed')).toBe('Something failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends request ID in development mode', () => {
|
||||||
|
const original = process.env.NODE_ENV
|
||||||
|
process.env.NODE_ENV = 'development'
|
||||||
|
setLastRequestId('REQ123_abc')
|
||||||
|
|
||||||
|
const msg = formatErrorMessage('Something failed')
|
||||||
|
expect(msg).toContain('Something failed')
|
||||||
|
expect(msg).toContain('REQ123_abc')
|
||||||
|
|
||||||
|
process.env.NODE_ENV = original
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends request ID when showRequestId option is set', () => {
|
||||||
|
setLastRequestId('REQ123_abc')
|
||||||
|
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
|
||||||
|
expect(msg).toContain('REQ123_abc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logErrorWithRequestId', () => {
|
||||||
|
it('logs with request ID when available', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
setLastRequestId('REQ123_abc')
|
||||||
|
|
||||||
|
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('REQ123_abc'),
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs without request ID when not available', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
logErrorWithRequestId('TestContext', new Error('fail'))
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSupportMessage', () => {
|
||||||
|
it('includes request ID when available', () => {
|
||||||
|
const errorData = { error: { request_id: 'REQ123_abc' } }
|
||||||
|
const msg = getSupportMessage(errorData)
|
||||||
|
expect(msg).toContain('REQ123_abc')
|
||||||
|
expect(msg).toContain('contact support')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns generic message when no request ID', () => {
|
||||||
|
const msg = getSupportMessage()
|
||||||
|
expect(msg).toBe('If this persists, please contact support.')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
lib/utils/__tests__/logger.test.ts
Normal file
29
lib/utils/__tests__/logger.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
describe('logger', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls console.error in development', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
process.env.NODE_ENV = 'development'
|
||||||
|
|
||||||
|
const { logger } = await import('../logger')
|
||||||
|
logger.error('test error')
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('test error')
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls console.warn in development', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
process.env.NODE_ENV = 'development'
|
||||||
|
|
||||||
|
const { logger } = await import('../logger')
|
||||||
|
logger.warn('test warning')
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith('test warning')
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
61
lib/utils/__tests__/requestId.test.ts
Normal file
61
lib/utils/__tests__/requestId.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
generateRequestId,
|
||||||
|
getRequestIdHeader,
|
||||||
|
setLastRequestId,
|
||||||
|
getLastRequestId,
|
||||||
|
clearLastRequestId,
|
||||||
|
} from '../requestId'
|
||||||
|
|
||||||
|
describe('generateRequestId', () => {
|
||||||
|
it('returns a string starting with REQ', () => {
|
||||||
|
const id = generateRequestId()
|
||||||
|
expect(id).toMatch(/^REQ/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains a timestamp and random segment separated by underscore', () => {
|
||||||
|
const id = generateRequestId()
|
||||||
|
const parts = id.replace('REQ', '').split('_')
|
||||||
|
expect(parts).toHaveLength(2)
|
||||||
|
expect(parts[0].length).toBeGreaterThan(0)
|
||||||
|
expect(parts[1].length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique IDs across calls', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 100 }, () => generateRequestId()))
|
||||||
|
expect(ids.size).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRequestIdHeader', () => {
|
||||||
|
it('returns X-Request-ID', () => {
|
||||||
|
expect(getRequestIdHeader()).toBe('X-Request-ID')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lastRequestId storage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearLastRequestId()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no ID has been set', () => {
|
||||||
|
expect(getLastRequestId()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores and retrieves a request ID', () => {
|
||||||
|
setLastRequestId('REQ123_abc')
|
||||||
|
expect(getLastRequestId()).toBe('REQ123_abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites previous ID on set', () => {
|
||||||
|
setLastRequestId('first')
|
||||||
|
setLastRequestId('second')
|
||||||
|
expect(getLastRequestId()).toBe('second')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the stored ID', () => {
|
||||||
|
setLastRequestId('REQ123_abc')
|
||||||
|
clearLastRequestId()
|
||||||
|
expect(getLastRequestId()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
2326
package-lock.json
generated
2326
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -7,7 +7,9 @@
|
|||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.0.78",
|
"@ciphera-net/ui": "^0.0.78",
|
||||||
@@ -44,16 +46,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^16.1.1",
|
"eslint-config-next": "^16.1.1",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
include: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1
vitest.setup.ts
Normal file
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
Reference in New Issue
Block a user