From 5f2e6538ee38181dc088588d6672cc203a9e2252 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:57:49 +0200 Subject: [PATCH] test: add unit test coverage for API routes and lib utilities Add a vitest setup and unit tests covering the chat, subscribe, and unsubscribe API routes, the feedback server action, and the rate-limit and supabase helpers. Wire the suite into CI as a dedicated unit-test step. --- .github/workflows/ci.yml | 3 + __tests__/helpers.ts | 51 ++ app/actions/__tests__/feedback.test.ts | 124 +++ app/api/chat/__tests__/route.test.ts | 157 ++++ app/api/subscribe/__tests__/route.test.ts | 139 ++++ app/api/unsubscribe/__tests__/route.test.ts | 126 +++ lib/__tests__/rate-limit.test.ts | 74 ++ lib/__tests__/supabase.test.ts | 23 + package.json | 3 + pnpm-lock.yaml | 801 +++++++++++++++++++- vitest.config.ts | 14 + 11 files changed, 1513 insertions(+), 2 deletions(-) create mode 100644 __tests__/helpers.ts create mode 100644 app/actions/__tests__/feedback.test.ts create mode 100644 app/api/chat/__tests__/route.test.ts create mode 100644 app/api/subscribe/__tests__/route.test.ts create mode 100644 app/api/unsubscribe/__tests__/route.test.ts create mode 100644 lib/__tests__/rate-limit.test.ts create mode 100644 lib/__tests__/supabase.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0e481e93..5936a0420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Lint run: pnpm lint + - name: Unit tests + run: pnpm test + e2e: name: End-to-end tests runs-on: ubuntu-latest diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts new file mode 100644 index 000000000..068c64ee1 --- /dev/null +++ b/__tests__/helpers.ts @@ -0,0 +1,51 @@ +import { vi } from 'vitest' + +export function jsonRequest( + url: string, + body: unknown, + init: RequestInit & { headers?: Record } = {}, +): Request { + const headers = new Headers(init.headers) + if (!headers.has('content-type')) { + headers.set('content-type', 'application/json') + } + + return new Request(url, { + method: init.method ?? 'POST', + headers, + body: JSON.stringify(body), + }) +} + +export interface SupabaseMockOptions { + existing?: { id: string; status: string } | null + fetchError?: boolean + insertError?: boolean + updateError?: boolean +} + +export function createSupabaseMock(options: SupabaseMockOptions = {}) { + const eqAfterUpdate = vi + .fn() + .mockResolvedValue( + options.updateError ? { error: { message: 'update failed' } } : { error: null }, + ) + const update = vi.fn().mockReturnValue({ eq: eqAfterUpdate }) + const insert = vi + .fn() + .mockResolvedValue( + options.insertError ? { error: { message: 'insert failed' } } : { error: null }, + ) + const single = vi + .fn() + .mockResolvedValue( + options.fetchError || !options.existing + ? { data: null, error: { message: 'not found' } } + : { data: options.existing, error: null }, + ) + const eqAfterSelect = vi.fn().mockReturnValue({ single }) + const select = vi.fn().mockReturnValue({ eq: eqAfterSelect }) + const from = vi.fn().mockReturnValue({ select, update, insert }) + + return { from, select, eqAfterSelect, single, update, eqAfterUpdate, insert } +} diff --git a/app/actions/__tests__/feedback.test.ts b/app/actions/__tests__/feedback.test.ts new file mode 100644 index 000000000..1397f2ba5 --- /dev/null +++ b/app/actions/__tests__/feedback.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mockIsRateLimited = vi.fn(() => false) + +vi.mock('@/lib/rate-limit', () => ({ + createRateLimiter: () => mockIsRateLimited, +})) + +const { submitFeedback } = await import('../feedback') + +describe('submitFeedback', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + mockIsRateLimited.mockReturnValue(false) + globalThis.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + delete process.env.GITHUB_FEEDBACK_TOKEN + delete process.env.GITHUB_DISCUSSION_CATEGORY_ID + delete process.env.GITHUB_DISCUSSION_REPO_ID + delete process.env.DISCORD_FEEDBACK_WEBHOOK + }) + + it('rejects invalid payloads before rate limiting', async () => { + const result = await submitFeedback({ + opinion: 'great', + message: 'nice', + url: '/docs/test', + } as never) + + expect(result).toEqual({ success: false }) + expect(mockIsRateLimited).not.toHaveBeenCalled() + }) + + it('rejects external URLs to prevent open redirects', async () => { + const result = await submitFeedback({ + opinion: 'good', + message: 'helpful', + url: 'https://evil.example/docs', + }) + + expect(result).toEqual({ success: false }) + expect(mockIsRateLimited).not.toHaveBeenCalled() + }) + + it('rejects protocol-relative URLs', async () => { + const result = await submitFeedback({ + opinion: 'bad', + message: 'confusing', + url: '//evil.example/docs', + }) + + expect(result).toEqual({ success: false }) + }) + + it('returns false when rate limited by page URL', async () => { + mockIsRateLimited.mockReturnValue(true) + + const result = await submitFeedback({ + opinion: 'good', + message: 'clear docs', + url: '/docs/quick-start', + }) + + expect(result).toEqual({ success: false }) + expect(mockIsRateLimited).toHaveBeenCalledWith('/docs/quick-start') + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + + it('accepts valid feedback and posts to configured integrations', async () => { + process.env.GITHUB_FEEDBACK_TOKEN = 'gh-token' + process.env.GITHUB_DISCUSSION_CATEGORY_ID = 'category-id' + process.env.GITHUB_DISCUSSION_REPO_ID = 'repo-id' + process.env.DISCORD_FEEDBACK_WEBHOOK = 'https://discord.test/webhook' + + const result = await submitFeedback({ + opinion: 'bad', + message: ' Needs more examples ', + url: '/docs/configuration', + }) + + expect(result).toEqual({ success: true }) + expect(globalThis.fetch).toHaveBeenCalledTimes(2) + }) + + it('truncates overly long messages', async () => { + process.env.GITHUB_FEEDBACK_TOKEN = 'gh-token' + process.env.GITHUB_DISCUSSION_CATEGORY_ID = 'category-id' + process.env.GITHUB_DISCUSSION_REPO_ID = 'repo-id' + + const longMessage = 'a'.repeat(2500) + + const result = await submitFeedback({ + opinion: 'good', + message: longMessage, + url: '/docs/limits', + }) + + expect(result).toEqual({ success: true }) + + const githubCall = vi + .mocked(globalThis.fetch) + .mock.calls.find(([url]) => url === 'https://api.github.com/graphql') + expect(githubCall).toBeDefined() + + const payload = JSON.parse(String(githubCall?.[1]?.body)) + expect(payload.variables.body.length).toBeLessThan(longMessage.length) + expect(payload.variables.body).not.toContain('a'.repeat(2500)) + }) + + it('treats non-string messages as empty', async () => { + const result = await submitFeedback({ + opinion: 'good', + message: 123 as never, + url: '/docs/no-message', + }) + + expect(result).toEqual({ success: true }) + }) +}) diff --git a/app/api/chat/__tests__/route.test.ts b/app/api/chat/__tests__/route.test.ts new file mode 100644 index 000000000..c9290d7a4 --- /dev/null +++ b/app/api/chat/__tests__/route.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { jsonRequest } from '@/__tests__/helpers' + +const mockCheckRateLimit = vi.fn() +const mockStreamText = vi.fn() +const mockConvertToModelMessages = vi.fn() +const mockToUIMessageStreamResponse = vi.fn() + +vi.mock('@/lib/ratelimit', () => ({ + checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), +})) + +vi.mock('ai', () => ({ + streamText: (...args: unknown[]) => mockStreamText(...args), + tool: (definition: unknown) => definition, + stepCountIs: (count: number) => count, + convertToModelMessages: (...args: unknown[]) => mockConvertToModelMessages(...args), +})) + +vi.mock('@openrouter/ai-sdk-provider', () => ({ + createOpenRouter: () => ({ + chat: () => 'mock-model', + }), +})) + +vi.mock('@/lib/source', () => ({ + docsSource: { + getPages: () => [], + }, +})) + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})) + +const { POST } = await import('../route') + +function chatRequest(messages: unknown[], headers: Record = {}): Request { + return jsonRequest('https://example.com/api/chat', { messages }, { headers }) +} + +describe('POST /api/chat', () => { + const originalApiKey = process.env.OPENROUTER_API_KEY + + beforeEach(() => { + vi.clearAllMocks() + process.env.OPENROUTER_API_KEY = 'test-key' + mockCheckRateLimit.mockResolvedValue({ allowed: true }) + mockConvertToModelMessages.mockResolvedValue([{ role: 'user', content: 'hello' }]) + mockToUIMessageStreamResponse.mockReturnValue( + new Response('0:"Hello from the docs assistant"\n', { + status: 200, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }), + ) + mockStreamText.mockReturnValue({ + toUIMessageStreamResponse: mockToUIMessageStreamResponse, + }) + }) + + afterEach(() => { + process.env.OPENROUTER_API_KEY = originalApiKey + }) + + it('returns 429 when rate limited', async () => { + const reset = Date.now() + 30_000 + mockCheckRateLimit.mockResolvedValue({ allowed: false, reset }) + + const response = await POST(chatRequest([{ role: 'user', content: 'hello' }])) + const body = await response.json() + + expect(response.status).toBe(429) + expect(body).toEqual({ error: 'Too many requests. Please try again shortly.' }) + expect(response.headers.get('Retry-After')).toBe(String(Math.ceil((reset - Date.now()) / 1000))) + expect(mockStreamText).not.toHaveBeenCalled() + }) + + it('returns 503 when OPENROUTER_API_KEY is not configured', async () => { + delete process.env.OPENROUTER_API_KEY + + const response = await POST(chatRequest([{ role: 'user', content: 'hello' }])) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toEqual({ error: 'OPENROUTER_API_KEY is not configured' }) + expect(mockStreamText).not.toHaveBeenCalled() + }) + + it('returns a UI message stream response in search mode', async () => { + const response = await POST(chatRequest([{ role: 'user', content: 'How do I deploy?' }])) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('Hello from the docs assistant') + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + search: expect.any(Object), + navigate: expect.any(Object), + }), + toolChoice: 'auto', + }), + ) + expect(mockToUIMessageStreamResponse).toHaveBeenCalledOnce() + }) + + it('uses page mode without tools when a valid docs page header is provided', async () => { + const response = await POST( + chatRequest([{ role: 'user', content: 'Summarize this page' }], { + 'x-chat-mode': 'page', + 'x-chat-page': '/docs/quick-start/install', + }), + ) + + expect(response.status).toBe(200) + expect(mockStreamText).toHaveBeenCalledWith( + expect.not.objectContaining({ + tools: expect.anything(), + }), + ) + expect(mockToUIMessageStreamResponse).toHaveBeenCalledOnce() + }) + + it('rejects traversal in x-chat-page and falls back to search mode', async () => { + await POST( + chatRequest([{ role: 'user', content: 'Show me docs' }], { + 'x-chat-mode': 'page', + 'x-chat-page': '/docs/../secrets', + }), + ) + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + search: expect.any(Object), + navigate: expect.any(Object), + }), + }), + ) + }) + + it('rejects external URLs in x-chat-page and falls back to search mode', async () => { + await POST( + chatRequest([{ role: 'user', content: 'Show me docs' }], { + 'x-chat-mode': 'page', + 'x-chat-page': 'https://evil.example/docs', + }), + ) + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + search: expect.any(Object), + }), + }), + ) + }) +}) diff --git a/app/api/subscribe/__tests__/route.test.ts b/app/api/subscribe/__tests__/route.test.ts new file mode 100644 index 000000000..d47f380f2 --- /dev/null +++ b/app/api/subscribe/__tests__/route.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSupabaseMock, jsonRequest } from '@/__tests__/helpers' + +const mockIsRateLimited = vi.fn(() => false) +const mockGetClientIp = vi.fn((_request: Request) => '127.0.0.1') +let supabaseClient: ReturnType | null = createSupabaseMock() + +vi.mock('@/lib/rate-limit', () => ({ + createRateLimiter: () => mockIsRateLimited, + getClientIp: (request: Request) => mockGetClientIp(request), +})) + +vi.mock('@/lib/supabase', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSupabaseClient: () => supabaseClient, + } +}) + +const { POST } = await import('../route') + +describe('POST /api/subscribe', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsRateLimited.mockReturnValue(false) + supabaseClient = createSupabaseMock() + }) + + it('returns 429 when rate limited', async () => { + mockIsRateLimited.mockReturnValue(true) + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(429) + expect(body).toEqual({ message: 'Too many requests' }) + }) + + it('returns 422 when email is missing', async () => { + const response = await POST(jsonRequest('https://example.com/api/subscribe', {})) + const body = await response.json() + + expect(response.status).toBe(422) + expect(body).toEqual({ message: 'Valid email is required' }) + }) + + it('returns 422 when email is invalid', async () => { + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'not-valid' }), + ) + const body = await response.json() + + expect(response.status).toBe(422) + expect(body).toEqual({ message: 'Valid email is required' }) + }) + + it('returns 503 when Supabase is not configured', async () => { + supabaseClient = null + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toEqual({ message: 'Subscription service is not configured' }) + }) + + it('returns 409 when the email is already subscribed', async () => { + supabaseClient = createSupabaseMock({ existing: { id: '1', status: 'subscribed' } }) + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toEqual({ message: 'Email already subscribed' }) + }) + + it('re-subscribes a previously unsubscribed email', async () => { + supabaseClient = createSupabaseMock({ existing: { id: '1', status: 'unsubscribed' } }) + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'User@Example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ message: 'Subscription successful' }) + expect(supabaseClient.update).toHaveBeenCalledWith({ status: 'subscribed' }) + expect(supabaseClient.eqAfterUpdate).toHaveBeenCalledWith('email', 'user@example.com') + }) + + it('creates a new subscriber', async () => { + supabaseClient = createSupabaseMock({ existing: null }) + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'new@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body).toEqual({ message: 'Subscription successful' }) + expect(supabaseClient.insert).toHaveBeenCalledWith({ + email: 'new@example.com', + status: 'subscribed', + }) + }) + + it('returns 500 when insert fails', async () => { + supabaseClient = createSupabaseMock({ existing: null, insertError: true }) + + const response = await POST( + jsonRequest('https://example.com/api/subscribe', { email: 'new@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ message: 'Subscription failed' }) + }) + + it('returns 500 for malformed JSON', async () => { + const response = await POST( + new Request('https://example.com/api/subscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }), + ) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ message: 'Subscription failed' }) + }) +}) diff --git a/app/api/unsubscribe/__tests__/route.test.ts b/app/api/unsubscribe/__tests__/route.test.ts new file mode 100644 index 000000000..c5e26ff38 --- /dev/null +++ b/app/api/unsubscribe/__tests__/route.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSupabaseMock, jsonRequest } from '@/__tests__/helpers' + +const mockIsRateLimited = vi.fn(() => false) +const mockGetClientIp = vi.fn((_request: Request) => '127.0.0.1') +let supabaseClient: ReturnType | null = createSupabaseMock() + +vi.mock('@/lib/rate-limit', () => ({ + createRateLimiter: () => mockIsRateLimited, + getClientIp: (request: Request) => mockGetClientIp(request), +})) + +vi.mock('@/lib/supabase', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSupabaseClient: () => supabaseClient, + } +}) + +const { POST } = await import('../route') + +describe('POST /api/unsubscribe', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsRateLimited.mockReturnValue(false) + supabaseClient = createSupabaseMock() + }) + + it('returns 429 when rate limited', async () => { + mockIsRateLimited.mockReturnValue(true) + + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(429) + expect(body).toEqual({ message: 'Too many requests' }) + }) + + it('returns 400 when email is missing', async () => { + const response = await POST(jsonRequest('https://example.com/api/unsubscribe', {})) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toEqual({ message: 'Invalid email format' }) + }) + + it('returns 400 when email is invalid', async () => { + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'not-valid' }), + ) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body).toEqual({ message: 'Invalid email format' }) + }) + + it('returns 503 when Supabase is not configured', async () => { + supabaseClient = null + + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toEqual({ message: 'Unsubscription service is not configured' }) + }) + + it('returns 404 when the subscriber does not exist', async () => { + supabaseClient = createSupabaseMock({ existing: null, fetchError: true }) + + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'missing@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body).toEqual({ message: 'Subscriber not found' }) + }) + + it('unsubscribes an existing subscriber', async () => { + supabaseClient = createSupabaseMock({ existing: { id: '1', status: 'subscribed' } }) + + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'User@Example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ message: 'Unsubscription successful' }) + expect(supabaseClient.update).toHaveBeenCalledWith({ status: 'unsubscribed' }) + expect(supabaseClient.eqAfterUpdate).toHaveBeenCalledWith('email', 'user@example.com') + }) + + it('returns 500 when update fails', async () => { + supabaseClient = createSupabaseMock({ + existing: { id: '1', status: 'subscribed' }, + updateError: true, + }) + + const response = await POST( + jsonRequest('https://example.com/api/unsubscribe', { email: 'user@example.com' }), + ) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ message: 'Unsubscription failed' }) + }) + + it('returns 500 for malformed JSON', async () => { + const response = await POST( + new Request('https://example.com/api/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }), + ) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toEqual({ message: 'Unsubscription failed' }) + }) +}) diff --git a/lib/__tests__/rate-limit.test.ts b/lib/__tests__/rate-limit.test.ts new file mode 100644 index 000000000..1f3ba56d7 --- /dev/null +++ b/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRateLimiter, getClientIp } from '@/lib/rate-limit' + +describe('createRateLimiter', () => { + it('allows requests within the limit', () => { + const isRateLimited = createRateLimiter(3, 60_000) + + expect(isRateLimited('client-a')).toBe(false) + expect(isRateLimited('client-a')).toBe(false) + expect(isRateLimited('client-a')).toBe(false) + }) + + it('blocks requests that exceed the limit', () => { + const isRateLimited = createRateLimiter(2, 60_000) + + expect(isRateLimited('client-b')).toBe(false) + expect(isRateLimited('client-b')).toBe(false) + expect(isRateLimited('client-b')).toBe(true) + }) + + it('tracks limits independently per key', () => { + const isRateLimited = createRateLimiter(1, 60_000) + + expect(isRateLimited('key-1')).toBe(false) + expect(isRateLimited('key-2')).toBe(false) + expect(isRateLimited('key-1')).toBe(true) + expect(isRateLimited('key-2')).toBe(true) + }) + + it('resets the window after expiry', () => { + vi.useFakeTimers() + const isRateLimited = createRateLimiter(1, 1_000) + + expect(isRateLimited('client-c')).toBe(false) + expect(isRateLimited('client-c')).toBe(true) + + vi.advanceTimersByTime(1_001) + + expect(isRateLimited('client-c')).toBe(false) + vi.useRealTimers() + }) +}) + +describe('getClientIp', () => { + it('prefers the first x-forwarded-for address', () => { + const request = new Request('https://example.com', { + headers: { 'x-forwarded-for': '203.0.113.1, 10.0.0.1' }, + }) + + expect(getClientIp(request)).toBe('203.0.113.1') + }) + + it('falls back to x-real-ip', () => { + const request = new Request('https://example.com', { + headers: { 'x-real-ip': '198.51.100.42' }, + }) + + expect(getClientIp(request)).toBe('198.51.100.42') + }) + + it('falls back to cf-connecting-ip', () => { + const request = new Request('https://example.com', { + headers: { 'cf-connecting-ip': '192.0.2.10' }, + }) + + expect(getClientIp(request)).toBe('192.0.2.10') + }) + + it('returns unknown when no ip headers are present', () => { + const request = new Request('https://example.com') + + expect(getClientIp(request)).toBe('unknown') + }) +}) diff --git a/lib/__tests__/supabase.test.ts b/lib/__tests__/supabase.test.ts new file mode 100644 index 000000000..afdab038c --- /dev/null +++ b/lib/__tests__/supabase.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isValidEmail, normalizeEmail } from '@/lib/supabase' + +describe('isValidEmail', () => { + it('accepts well-formed addresses', () => { + expect(isValidEmail('user@example.com')).toBe(true) + expect(isValidEmail('first.last+tag@mail.example.co.uk')).toBe(true) + }) + + it('rejects malformed addresses', () => { + expect(isValidEmail('')).toBe(false) + expect(isValidEmail('not-an-email')).toBe(false) + expect(isValidEmail('missing-at.example.com')).toBe(false) + expect(isValidEmail('user@')).toBe(false) + expect(isValidEmail('a@b.c')).toBe(false) + }) +}) + +describe('normalizeEmail', () => { + it('lowercases and trims the address', () => { + expect(normalizeEmail(' User@Example.COM ')).toBe('user@example.com') + }) +}) diff --git a/package.json b/package.json index be0eaf311..9073d9936 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "next start -p 3333", "analyze": "cross-env ANALYZE=true next build", "optimize:images": "node scripts/optimize-images.mjs", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint --cache --max-warnings 0 .", "lint:prettier": "prettier --cache --check --ignore-path .gitignore --ignore-path .prettierignore .", @@ -99,6 +101,7 @@ "prettier": "^3.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8", + "vitest": "^4.1.9", "webpack": "^5.104.1" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29168de23..903e3cd3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,7 +107,7 @@ importers: version: 14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) fumadocs-mdx: specifier: 11.10.1 - version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 11.10.1(fumadocs-core@14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) fumadocs-ui: specifier: 14.7.7 version: 14.7.7(patch_hash=eug5cntrgxatea7cgl6ypztoau)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.18(yaml@2.8.2)) @@ -263,6 +263,9 @@ importers: typescript-eslint: specifier: ^8 version: 8.56.1(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@18.16.0)(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) webpack: specifier: '>=5.104.1' version: 5.105.2 @@ -329,156 +332,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1422,6 +1581,131 @@ packages: resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==} engines: {node: '>= 10'} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1490,9 +1774,15 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1505,6 +1795,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1645,6 +1938,35 @@ packages: '@types/react': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1809,6 +2131,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1896,6 +2222,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1991,6 +2321,9 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} @@ -2177,6 +2510,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2349,6 +2687,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2950,6 +3292,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -3271,6 +3616,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3338,6 +3687,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3652,6 +4004,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3745,6 +4102,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3790,6 +4150,12 @@ packages: spdx-license-ids@3.0.22: resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3940,6 +4306,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3948,6 +4317,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4098,6 +4471,87 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -4145,6 +4599,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4257,81 +4716,159 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.3(jiti@1.21.7))': dependencies: eslint: 9.39.3(jiti@1.21.7) @@ -5210,6 +5747,81 @@ snapshots: '@resvg/resvg-wasm@2.4.0': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + '@rtsao/scc@1.1.0': {} '@shikijs/core@2.5.0': @@ -5314,10 +5926,17 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -5334,6 +5953,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5497,6 +6118,47 @@ snapshots: media-captions: 1.0.4 react: 19.2.7 + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -5718,6 +6380,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + astring@1.9.0: {} async-function@1.0.0: {} @@ -5793,6 +6457,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5879,6 +6545,8 @@ snapshots: compute-scroll-into-view@3.1.1: {} + convert-source-map@2.0.0: {} + core-js-compat@3.47.0: dependencies: browserslist: 4.28.1 @@ -6151,6 +6819,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -6384,6 +7081,8 @@ snapshots: eventsource-parser@3.0.6: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -6492,7 +7191,7 @@ snapshots: - '@types/react' - supports-color - fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + fumadocs-mdx@11.10.1(fumadocs-core@14.7.7(@types/react@19.2.17)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 @@ -6513,6 +7212,7 @@ snapshots: optionalDependencies: next: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 + vite: 7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -7020,6 +7720,10 @@ snapshots: dependencies: react: 19.2.7 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -7602,6 +8306,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.3: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7681,6 +8387,8 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8039,6 +8747,37 @@ snapshots: rfdc@1.4.1: {} + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8222,6 +8961,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-swizzle@0.2.4: @@ -8266,6 +9007,10 @@ snapshots: spdx-license-ids@3.0.22: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8450,6 +9195,8 @@ snapshots: tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -8457,6 +9204,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8635,6 +9384,49 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.62.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 18.16.0 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.44.1 + yaml: 2.8.2 + + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@18.16.0)(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@18.16.0)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 18.16.0 + transitivePeerDependencies: + - msw + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -8740,6 +9532,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@9.0.2: diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..a64a2bfc2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/__tests__/**/*.test.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, +})