From d7e144620e8524076eabca9878c9abfa8d99dd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beatrice=20Dellac=C3=A0?= Date: Tue, 24 Feb 2026 11:14:24 +0100 Subject: [PATCH] add unit tests --- .drone.yml | 8 + eslint.config.mjs | 10 + package.json | 14 +- tests/api/createApiClient.test.ts | 154 +++++ tests/api/query.test.ts | 25 + tests/auth/createAuthContext.test.tsx | 143 ++++ tests/auth/jwt.test.ts | 60 ++ tests/contexts/LeftMenuContext.test.tsx | 246 +++++++ tests/contexts/RightSidebarContext.test.tsx | 249 +++++++ tests/errors/createErrorResolver.test.ts | 143 ++++ tests/helpers/renderHook.tsx | 36 + tests/hooks/useCooldownTimer.test.tsx | 64 ++ tests/hooks/useEditableForm.test.tsx | 81 +++ tests/hooks/usePaginatedResource.test.tsx | 170 +++++ tests/hooks/useSorting.test.tsx | 97 +++ tests/hooks/useSubmitState.test.tsx | 44 ++ tests/hooks/useValidatedFields.test.tsx | 158 +++++ tests/panels/useSidePanelMachine.test.tsx | 239 +++++++ tests/setup.ts | 15 + tests/utils/formatting.test.ts | 43 ++ tests/utils/verifiedEmail.test.ts | 46 ++ vite.config.ts | 16 +- yarn.lock | 722 +++++++++++++++++++- 23 files changed, 2766 insertions(+), 17 deletions(-) create mode 100644 tests/api/createApiClient.test.ts create mode 100644 tests/api/query.test.ts create mode 100644 tests/auth/createAuthContext.test.tsx create mode 100644 tests/auth/jwt.test.ts create mode 100644 tests/contexts/LeftMenuContext.test.tsx create mode 100644 tests/contexts/RightSidebarContext.test.tsx create mode 100644 tests/errors/createErrorResolver.test.ts create mode 100644 tests/helpers/renderHook.tsx create mode 100644 tests/hooks/useCooldownTimer.test.tsx create mode 100644 tests/hooks/useEditableForm.test.tsx create mode 100644 tests/hooks/usePaginatedResource.test.tsx create mode 100644 tests/hooks/useSorting.test.tsx create mode 100644 tests/hooks/useSubmitState.test.tsx create mode 100644 tests/hooks/useValidatedFields.test.tsx create mode 100644 tests/panels/useSidePanelMachine.test.tsx create mode 100644 tests/setup.ts create mode 100644 tests/utils/formatting.test.ts create mode 100644 tests/utils/verifiedEmail.test.ts diff --git a/.drone.yml b/.drone.yml index 96c2ea5..f8c07ec 100644 --- a/.drone.yml +++ b/.drone.yml @@ -27,6 +27,14 @@ steps: commands: - yarn build + - name: unit-tests + image: node:25 + environment: + NODE_OPTIONS: --no-webstorage + commands: + - yarn test:coverage + - test -f coverage/lcov.info + --- kind: pipeline type: docker diff --git a/eslint.config.mjs b/eslint.config.mjs index f70af4a..f3e5ef2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,4 +48,14 @@ export default tseslint.config( 'react-refresh/only-export-components': 'off', }, }, + { + files: ['tests/**/*.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + }, + }, ); diff --git a/package.json b/package.json index de48017..d186aa7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && vite build && tsc -p tsconfig.build.json", + "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary", + "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier . --write", @@ -35,17 +38,24 @@ }, "devDependencies": { "@eslint/js": "^10", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^10", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220", "eslint-plugin-react-refresh": "^0.5.1", "globals": "^17.3.0", + "jsdom": "^28.1.0", "prettier": "^3.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.6.2", "typescript-eslint": "^8.56.0", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^4.0.18" } } diff --git a/tests/api/createApiClient.test.ts b/tests/api/createApiClient.test.ts new file mode 100644 index 0000000..3851b9a --- /dev/null +++ b/tests/api/createApiClient.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, createApiClient } from '../../src/api/createApiClient'; + +describe('createApiClient', () => { + it('sends json requests and returns parsed payload', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as Response); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + }); + + const result = await client.request<{ ok: boolean }>('/users', { + method: 'POST', + token: 'token-123', + body: { hello: 'world' }, + }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token-123', + }, + body: JSON.stringify({ hello: 'world' }), + }); + }); + + it('maps api error payload through custom resolver', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: vi.fn().mockResolvedValue({ + code: 'AUTH_UNAUTHORIZED', + error: 'unauthorized', + requestId: 'req-1', + details: { reason: 'expired' }, + }), + } as unknown as Response); + const resolveError = vi.fn(() => 'Unauthorized access. Please sign in again.'); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + resolveError, + }); + + await expect(client.request('/users')).rejects.toMatchObject({ + name: 'ApiError', + status: 401, + code: 'AUTH_UNAUTHORIZED', + requestId: 'req-1', + details: { reason: 'expired' }, + rawMessage: 'unauthorized', + message: 'Unauthorized access. Please sign in again.', + }); + expect(resolveError).toHaveBeenCalledWith({ + code: 'AUTH_UNAUTHORIZED', + status: 401, + fallbackMessage: 'unauthorized', + }); + }); + + it('infers an error code from status when payload has no code', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: vi.fn().mockResolvedValue({ error: 'missing resource' }), + } as unknown as Response); + const inferErrorCodeFromStatus = vi.fn((status?: number | null) => + status === 404 ? 'USER_NOT_FOUND' : undefined, + ); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + inferErrorCodeFromStatus, + resolveError: ({ code }) => (code === 'USER_NOT_FOUND' ? 'User not found.' : 'Unknown'), + }); + + await expect(client.request('/users/missing')).rejects.toMatchObject({ + code: 'USER_NOT_FOUND', + message: 'User not found.', + }); + }); + + it('falls back to default messages when response is not valid json', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: vi.fn().mockRejectedValue(new Error('invalid json')), + } as unknown as Response); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + }); + + await expect(client.request('/users')).rejects.toMatchObject({ + code: undefined, + rawMessage: undefined, + message: 'Request failed (500).', + }); + }); + + it('uses generic default message when status is unavailable', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: undefined, + json: vi.fn().mockResolvedValue(null), + } as unknown as Response); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + }); + + let thrown: unknown; + try { + await client.request('/users'); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ApiError); + expect((thrown as ApiError).message).toBe('Request failed. Please try again.'); + }); + + it('uses raw fallback error text with the default resolver when present', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: vi.fn().mockResolvedValue({ + error: 'Validation failed in backend.', + }), + } as unknown as Response); + + const client = createApiClient({ + baseUrl: 'https://api.example.com', + fetchImpl: fetchMock as typeof fetch, + }); + + await expect(client.request('/users')).rejects.toMatchObject({ + message: 'Validation failed in backend.', + rawMessage: 'Validation failed in backend.', + }); + }); +}); diff --git a/tests/api/query.test.ts b/tests/api/query.test.ts new file mode 100644 index 0000000..7363088 --- /dev/null +++ b/tests/api/query.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { buildListQuery } from '../../src/api/query'; + +describe('buildListQuery', () => { + it('builds query with trimmed search and explicit sort', () => { + const result = buildListQuery({ + q: ' jane ', + page: 3, + pageSize: 20, + sort: ' createdAt ', + defaultSort: '-createdAt', + }); + + expect(result).toBe('q=jane&page=3&pageSize=20&sort=createdAt'); + }); + + it('falls back to defaults when query is blank and sort missing', () => { + const result = buildListQuery({ + q: ' ', + defaultSort: '-createdAt', + }); + + expect(result).toBe('page=1&pageSize=10&sort=-createdAt'); + }); +}); diff --git a/tests/auth/createAuthContext.test.tsx b/tests/auth/createAuthContext.test.tsx new file mode 100644 index 0000000..7085064 --- /dev/null +++ b/tests/auth/createAuthContext.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { createAuthContext } from '../../src/auth/createAuthContext'; + +type User = { + id: string; + username: string; +}; + +const defaultAuth = createAuthContext(); + +function createUser(username: string): User { + return { + id: `id-${username}`, + username, + }; +} + +function AuthHarness() { + const { authToken, refreshToken, currentUser, setSession, setCurrentUser, clearSession } = + defaultAuth.useAuth(); + + return ( +
+ {authToken ?? 'none'} + {refreshToken ?? 'none'} + {currentUser?.username ?? 'none'} + + + + +
+ ); +} + +describe('createAuthContext', () => { + it('throws when hook is used outside the provider', () => { + function Invalid() { + defaultAuth.useAuth(); + return null; + } + + expect(() => render()).toThrow('useAuth must be used within AuthProvider'); + }); + + it('reads persisted tokens and cleans default legacy keys', () => { + localStorage.setItem('authToken', 'auth-1'); + localStorage.setItem('refreshToken', 'refresh-1'); + localStorage.setItem('auth_token', 'legacy'); + localStorage.setItem('auth_user', 'legacy'); + localStorage.setItem('token', 'legacy'); + + render( + + + , + ); + + expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-1'); + expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-1'); + expect(localStorage.getItem('auth_token')).toBeNull(); + expect(localStorage.getItem('auth_user')).toBeNull(); + expect(localStorage.getItem('token')).toBeNull(); + }); + + it('supports session lifecycle updates', () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'set-session' })); + expect(screen.getByTestId('auth-token')).toHaveTextContent('auth-next'); + expect(screen.getByTestId('refresh-token')).toHaveTextContent('refresh-next'); + expect(screen.getByTestId('username')).toHaveTextContent('after-set'); + expect(localStorage.getItem('authToken')).toBe('auth-next'); + expect(localStorage.getItem('refreshToken')).toBe('refresh-next'); + + fireEvent.click(screen.getByRole('button', { name: 'set-user' })); + expect(screen.getByTestId('username')).toHaveTextContent('patched'); + + fireEvent.click(screen.getByRole('button', { name: 'clear' })); + expect(screen.getByTestId('auth-token')).toHaveTextContent('none'); + expect(screen.getByTestId('refresh-token')).toHaveTextContent('none'); + expect(screen.getByTestId('username')).toHaveTextContent('none'); + expect(localStorage.getItem('authToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + }); + + it('supports custom token keys and legacy cleanup keys', () => { + const customAuth = createAuthContext({ + authTokenKey: 'custom-auth', + refreshTokenKey: 'custom-refresh', + legacyKeys: ['legacy-a', 'legacy-b'], + }); + + function CustomHarness() { + const { authToken, refreshToken, setSession } = customAuth.useAuth(); + return ( +
+ {authToken ?? 'none'} + {refreshToken ?? 'none'} + +
+ ); + } + + localStorage.setItem('custom-auth', 'auth-1'); + localStorage.setItem('custom-refresh', 'refresh-1'); + localStorage.setItem('legacy-a', 'legacy'); + localStorage.setItem('legacy-b', 'legacy'); + + render( + + + , + ); + + expect(screen.getByTestId('custom-auth-token')).toHaveTextContent('auth-1'); + expect(screen.getByTestId('custom-refresh-token')).toHaveTextContent('refresh-1'); + expect(localStorage.getItem('legacy-a')).toBeNull(); + expect(localStorage.getItem('legacy-b')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'set-custom-session' })); + expect(localStorage.getItem('custom-auth')).toBe('next-auth'); + expect(localStorage.getItem('custom-refresh')).toBe('next-refresh'); + }); +}); diff --git a/tests/auth/jwt.test.ts b/tests/auth/jwt.test.ts new file mode 100644 index 0000000..9ffaa98 --- /dev/null +++ b/tests/auth/jwt.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { decodeJwtPayload, isJwtExpired } from '../../src/auth/jwt'; + +function createJwt(payload: Record) { + const header = { alg: 'HS256', typ: 'JWT' }; + const encode = (value: object) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + + return `${encode(header)}.${encode(payload)}.signature`; +} + +describe('decodeJwtPayload', () => { + it('returns null for malformed tokens or invalid payloads', () => { + expect(decodeJwtPayload('not-a-jwt')).toBeNull(); + expect(decodeJwtPayload('a.b')).toBeNull(); + expect(decodeJwtPayload('a.b.c.d')).toBeNull(); + + const nonJson = 'header.' + btoa('nope') + '.sig'; + expect(decodeJwtPayload(nonJson)).toBeNull(); + + const nonObjectPayload = createJwt({ value: 1 }).replace( + /\.[^.]+\./, + `.${btoa('1').replace(/=+$/g, '')}.`, + ); + expect(decodeJwtPayload(nonObjectPayload)).toBeNull(); + }); + + it('decodes valid base64url payloads', () => { + const payload = { sub: 'user-1', role: 'ADMIN', exp: 1735689600 }; + expect(decodeJwtPayload(createJwt(payload))).toEqual(payload); + }); +}); + +describe('isJwtExpired', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('returns false for malformed tokens and invalid exp claim', () => { + expect(isJwtExpired('not-a-jwt')).toBe(false); + expect(isJwtExpired(createJwt({}))).toBe(false); + expect(isJwtExpired(createJwt({ exp: 'nope' }))).toBe(false); + expect(isJwtExpired(createJwt({ exp: Number.POSITIVE_INFINITY }))).toBe(false); + }); + + it('returns true when token is expired', () => { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const exp = Math.floor(new Date('2025-12-31T23:59:00Z').getTime() / 1000); + + expect(isJwtExpired(createJwt({ exp }))).toBe(true); + }); + + it('applies skew seconds when evaluating expiration', () => { + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const exp = Math.floor(new Date('2026-01-01T00:00:10Z').getTime() / 1000); + + expect(isJwtExpired(createJwt({ exp }), 15)).toBe(true); + expect(isJwtExpired(createJwt({ exp }), 5)).toBe(false); + }); +}); diff --git a/tests/contexts/LeftMenuContext.test.tsx b/tests/contexts/LeftMenuContext.test.tsx new file mode 100644 index 0000000..fc5ce82 --- /dev/null +++ b/tests/contexts/LeftMenuContext.test.tsx @@ -0,0 +1,246 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + LeftMenuProvider, + type LeftMenuContent, + useLeftMenu, +} from '../../src/contexts/LeftMenuContext'; + +function buildDefaultContent(label = 'Default menu'): LeftMenuContent { + return { + ariaLabel: label, + render: ({ collapsed }) =>
{collapsed ? `${label} (collapsed)` : `${label} (expanded)`}
, + }; +} + +function setViewportWidth(width: number) { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: undefined, + }); +} + +function renderLeftMenuHarness(initialPathname = '/users') { + let currentPathname = initialPathname; + let currentValue: ReturnType | null = null; + const defaultContent = buildDefaultContent(); + + function Probe() { + currentValue = useLeftMenu(); + const renderState = { + collapsed: currentValue.collapsed, + mobileOpen: currentValue.mobileOpen, + isDesktop: window.innerWidth >= 1024, + closeMenu: currentValue.closeMenu, + }; + + return
{currentValue.content.render(renderState)}
; + } + + function Wrapper({ pathname }: Readonly<{ pathname: string }>) { + return ( + + + + ); + } + + const rendered = render(); + + return { + getCurrent() { + if (!currentValue) { + throw new Error('Left menu context value not initialized'); + } + return currentValue; + }, + reroute(nextPathname: string) { + currentPathname = nextPathname; + rendered.rerender(); + }, + }; +} + +describe('LeftMenuContext', () => { + beforeEach(() => { + localStorage.removeItem('authSidebarWidth'); + localStorage.removeItem('authSidebarCollapsed'); + setViewportWidth(1024); + }); + + it('throws when useLeftMenu is used outside provider', () => { + function Invalid() { + useLeftMenu(); + return null; + } + + expect(() => render()).toThrow('useLeftMenu must be used within LeftMenuProvider'); + }); + + it('supports desktop collapse/expand/toggle semantics', () => { + const harness = renderLeftMenuHarness('/users'); + + expect(harness.getCurrent().collapsed).toBe(false); + + act(() => { + harness.getCurrent().closeMenu(); + }); + expect(harness.getCurrent().collapsed).toBe(true); + + act(() => { + harness.getCurrent().openMenu(); + }); + expect(harness.getCurrent().collapsed).toBe(false); + + act(() => { + harness.getCurrent().toggleMenu(); + }); + expect(harness.getCurrent().collapsed).toBe(true); + }); + + it('restores collapsed state from storage and clamps persisted width', () => { + localStorage.setItem('authSidebarCollapsed', '1'); + localStorage.setItem('authSidebarWidth', '999'); + const harness = renderLeftMenuHarness('/users'); + + expect(harness.getCurrent().collapsed).toBe(true); + expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('56px'); + }); + + it('locks body scroll on mobile open and unlocks on close', () => { + setViewportWidth(768); + + const harness = renderLeftMenuHarness('/users'); + + act(() => { + harness.getCurrent().openMenu(); + }); + expect(harness.getCurrent().mobileOpen).toBe(true); + expect(document.body.style.overflow).toBe('hidden'); + + act(() => { + harness.getCurrent().closeMenu(); + }); + expect(harness.getCurrent().mobileOpen).toBe(false); + expect(document.body.style.overflow).toBe(''); + }); + + it('reads, clamps, and persists sidebar width through pointer resize', async () => { + localStorage.setItem('authSidebarWidth', '999'); + const harness = renderLeftMenuHarness('/users'); + + expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('420px'); + + const preventDefault = vi.fn(); + act(() => { + harness.getCurrent().startResize({ + clientX: 420, + preventDefault, + } as never); + }); + + act(() => { + fireEvent.pointerMove(window, { clientX: -1000 }); + fireEvent.pointerUp(window); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('authSidebarWidth')).toBe('220'); + expect(harness.getCurrent().desktopMenuStyle['--auth-sidebar-width']).toBe('220px'); + }); + + it('accepts custom content and resets to default on route changes', () => { + setViewportWidth(768); + const harness = renderLeftMenuHarness('/users'); + + act(() => { + harness.getCurrent().openMenu(); + harness.getCurrent().setMenuContent({ + ariaLabel: 'Custom menu', + render: () =>
Custom menu content
, + }); + }); + + expect(harness.getCurrent().mobileOpen).toBe(true); + expect(screen.getByText('Custom menu content')).toBeInTheDocument(); + + harness.reroute('/posts'); + + expect(harness.getCurrent().mobileOpen).toBe(false); + expect(screen.queryByText('Custom menu content')).not.toBeInTheDocument(); + expect(screen.getByText('Default menu (expanded)')).toBeInTheDocument(); + }); + + it('applies provided content in openMenu/toggleMenu and toggles mobile open state', () => { + setViewportWidth(768); + const harness = renderLeftMenuHarness('/users'); + + act(() => { + harness.getCurrent().openMenu({ + render: () =>
Open payload
, + }); + }); + expect(screen.getByText('Open payload')).toBeInTheDocument(); + expect(harness.getCurrent().mobileOpen).toBe(true); + + act(() => { + harness.getCurrent().toggleMenu({ + render: () =>
Toggle payload
, + }); + }); + expect(screen.getByText('Toggle payload')).toBeInTheDocument(); + expect(harness.getCurrent().mobileOpen).toBe(false); + }); + + it('updates default content only when current content is still default', () => { + function Harness() { + const menu = useLeftMenu(); + const state = { + collapsed: menu.collapsed, + mobileOpen: menu.mobileOpen, + isDesktop: true, + closeMenu: menu.closeMenu, + }; + + return ( +
+ +
{menu.content.render(state)}
+
+ ); + } + + function Wrapper({ label }: Readonly<{ label: string }>) { + return ( + + + + ); + } + + const rendered = render(); + expect(screen.getByTestId('content')).toHaveTextContent('Menu A (expanded)'); + + rendered.rerender(); + expect(screen.getByTestId('content')).toHaveTextContent('Menu B (expanded)'); + + fireEvent.click(screen.getByRole('button', { name: 'custom' })); + expect(screen.getByTestId('content')).toHaveTextContent('Custom'); + + rendered.rerender(); + expect(screen.getByTestId('content')).toHaveTextContent('Custom'); + }); +}); diff --git a/tests/contexts/RightSidebarContext.test.tsx b/tests/contexts/RightSidebarContext.test.tsx new file mode 100644 index 0000000..7bb599b --- /dev/null +++ b/tests/contexts/RightSidebarContext.test.tsx @@ -0,0 +1,249 @@ +import { fireEvent, render } from '@testing-library/react'; +import { act } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + RightSidebarProvider, + useRightSidebar, +} from '../../src/contexts/RightSidebarContext'; + +type RightSidebarHarnessOptions = { + pathname?: string; + onMobileOpenRequest?: () => void; +}; + +function setViewportWidth(width: number) { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: undefined, + }); +} + +function renderRightSidebarHarness(options: RightSidebarHarnessOptions = {}) { + const { pathname = '/users', onMobileOpenRequest } = options; + let currentPathname = pathname; + let currentValue: ReturnType | null = null; + + function Probe() { + currentValue = useRightSidebar(); + return null; + } + + function Wrapper({ pathname }: Readonly<{ pathname: string }>) { + return ( + + + + ); + } + + const rendered = render(); + + return { + getCurrent() { + if (!currentValue) { + throw new Error('Right sidebar context value not initialized'); + } + return currentValue; + }, + reroute(nextPathname: string) { + currentPathname = nextPathname; + rendered.rerender(); + }, + unmount: rendered.unmount, + }; +} + +describe('RightSidebarContext', () => { + beforeEach(() => { + localStorage.removeItem('authRightSidebarWidth'); + setViewportWidth(1024); + }); + + it('throws when useRightSidebar is used outside provider', () => { + function Invalid() { + useRightSidebar(); + return null; + } + + expect(() => render()).toThrow('useRightSidebar must be used within RightSidebarProvider'); + }); + + it('opens and closes with content', () => { + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + act(() => { + harness.getCurrent().openSidebar({ + title: 'Meta', + content:
Body
, + }); + }); + + expect(harness.getCurrent().isOpen).toBe(true); + expect(harness.getCurrent().content?.title).toBe('Meta'); + + act(() => { + harness.getCurrent().closeSidebar(); + }); + + expect(harness.getCurrent().isOpen).toBe(false); + expect(harness.getCurrent().content).toBeNull(); + }); + + it('does not open without current or next content', () => { + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + act(() => { + harness.getCurrent().openSidebar(); + }); + + expect(harness.getCurrent().isOpen).toBe(false); + expect(harness.getCurrent().content).toBeNull(); + }); + + it('updates content live and supports toggle semantics', () => { + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + act(() => { + harness.getCurrent().openSidebar({ + title: 'Meta', + content:
Body
, + }); + }); + + act(() => { + harness.getCurrent().setSidebarContent({ + title: 'Meta Updated', + content:
Updated
, + }); + }); + + expect(harness.getCurrent().isOpen).toBe(true); + expect(harness.getCurrent().content?.title).toBe('Meta Updated'); + + act(() => { + harness.getCurrent().toggleSidebar(); + }); + expect(harness.getCurrent().isOpen).toBe(false); + expect(harness.getCurrent().content).toBeNull(); + }); + + it('toggleSidebar opens with provided content when closed', () => { + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + act(() => { + harness.getCurrent().toggleSidebar({ + title: 'Toggle open', + content:
Toggle body
, + }); + }); + + expect(harness.getCurrent().isOpen).toBe(true); + expect(harness.getCurrent().content?.title).toBe('Toggle open'); + }); + + it('closes on pathname changes', () => { + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + act(() => { + harness.getCurrent().openSidebar({ + title: 'Meta', + content:
Body
, + }); + }); + expect(harness.getCurrent().isOpen).toBe(true); + + harness.reroute('/posts'); + expect(harness.getCurrent().isOpen).toBe(false); + expect(harness.getCurrent().content).toBeNull(); + }); + + it('reads/clamps persisted width, persists resized widths, and reacts to escape', async () => { + localStorage.setItem('authRightSidebarWidth', '999'); + const harness = renderRightSidebarHarness({ pathname: '/users' }); + + expect(harness.getCurrent().desktopSidebarStyle['--auth-right-sidebar-width']).toBe( + '480px', + ); + + act(() => { + harness.getCurrent().openSidebar({ + title: 'Meta', + content:
Body
, + }); + }); + + const preventDefault = vi.fn(); + act(() => { + harness.getCurrent().startResize({ + clientX: 480, + preventDefault, + } as never); + }); + + act(() => { + fireEvent.pointerMove(window, { clientX: -400 }); + fireEvent.pointerUp(window); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('authRightSidebarWidth')).toBe('480'); + + act(() => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + expect(harness.getCurrent().isOpen).toBe(false); + }); + + it('notifies mobile open requests when opening on mobile', () => { + setViewportWidth(768); + const onMobileOpenRequest = vi.fn(); + const harness = renderRightSidebarHarness({ + pathname: '/users', + onMobileOpenRequest, + }); + + act(() => { + harness.getCurrent().openSidebar({ + title: 'Meta', + content:
Body
, + }); + }); + + expect(harness.getCurrent().isOpen).toBe(true); + expect(onMobileOpenRequest).toHaveBeenCalledTimes(1); + }); + + it('normalizes custom sizing values', () => { + function Probe() { + const sidebar = useRightSidebar(); + return
{sidebar.desktopSidebarStyle['--auth-right-sidebar-width']}
; + } + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('width').textContent).toBe('500px'); + }); +}); diff --git a/tests/errors/createErrorResolver.test.ts b/tests/errors/createErrorResolver.test.ts new file mode 100644 index 0000000..a60494a --- /dev/null +++ b/tests/errors/createErrorResolver.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { createErrorResolver } from '../../src/errors/createErrorResolver'; + +const CATALOG = { + AUTH_UNAUTHORIZED: 'Unauthorized access. Please sign in again.', + FORBIDDEN: 'You do not have permission to perform this action.', + USER_NOT_FOUND: 'User not found.', + INTERNAL_ERROR: 'Unexpected request error.', + REQUEST_FAILED: 'Request failed. Please try again.', +}; + +const resolver = createErrorResolver({ + catalog: CATALOG, + fallbackCode: 'REQUEST_FAILED', + defaultContext: 'default', + contextOverrides: { + session: { + AUTH_UNAUTHORIZED: 'Session expired. Please sign in again.', + }, + }, + inferCodeFromStatus: (status?: number | null) => { + switch (status) { + case 401: + return 'AUTH_UNAUTHORIZED'; + case 403: + return 'FORBIDDEN'; + case 404: + return 'USER_NOT_FOUND'; + default: + if (status != null && status >= 500) { + return 'INTERNAL_ERROR'; + } + return undefined; + } + }, + inferCodeFromLegacyMessage: (message?: string | null) => { + if (message?.toLowerCase() === 'unauthorized') { + return 'AUTH_UNAUTHORIZED'; + } + return undefined; + }, +}); + +describe('createErrorResolver', () => { + it('recognizes known error codes', () => { + expect(resolver.isKnownErrorCode('AUTH_UNAUTHORIZED')).toBe(true); + expect(resolver.isKnownErrorCode('NOPE')).toBe(false); + }); + + it('returns context override for known code', () => { + expect( + resolver.resolveErrorMessage({ + code: 'AUTH_UNAUTHORIZED', + context: 'session', + }), + ).toBe('Session expired. Please sign in again.'); + }); + + it('falls back from unknown code to legacy message inference', () => { + expect( + resolver.resolveErrorMessage({ + code: 'UNKNOWN_CODE', + fallbackMessage: 'unauthorized', + }), + ).toBe('Unauthorized access. Please sign in again.'); + }); + + it('falls back to status mapping when code is missing', () => { + expect(resolver.resolveErrorMessage({ status: 404 })).toBe('User not found.'); + }); + + it('falls back to status mapping when legacy inference returns an unmapped code', () => { + const resolverWithUnmappedLegacyCode = createErrorResolver({ + catalog: CATALOG, + inferCodeFromStatus: resolver.inferErrorCodeFromStatus, + inferCodeFromLegacyMessage: () => 'LEGACY_ONLY_CODE', + }); + + expect( + resolverWithUnmappedLegacyCode.resolveErrorMessage({ + status: 403, + fallbackMessage: 'legacy message', + }), + ).toBe('You do not have permission to perform this action.'); + }); + + it('uses fallbackCode when no code/status resolve and fallback message is missing', () => { + expect(resolver.resolveErrorMessage({ status: 418 })).toBe('Request failed. Please try again.'); + }); + + it('uses fallback message when no mapping exists and fallback code is unavailable', () => { + const noFallbackCodeResolver = createErrorResolver({ + catalog: CATALOG, + inferCodeFromStatus: () => undefined, + }); + + expect( + noFallbackCodeResolver.resolveErrorMessage({ + code: 'UNKNOWN', + fallbackMessage: 'raw backend message', + }), + ).toBe('raw backend message'); + }); + + it('returns default request failure message when no signal is available', () => { + const emptyResolver = createErrorResolver({ + catalog: {}, + }); + + expect(emptyResolver.resolveErrorMessage({})).toBe('Request failed. Please try again.'); + }); + + it('resolveOptionalErrorMessage returns undefined for empty inputs', () => { + expect(resolver.resolveOptionalErrorMessage(undefined)).toBeUndefined(); + expect(resolver.resolveOptionalErrorMessage(null)).toBeUndefined(); + }); + + it('resolveOptionalErrorMessage resolves known codes', () => { + expect(resolver.resolveOptionalErrorMessage('FORBIDDEN')).toBe( + 'You do not have permission to perform this action.', + ); + }); + + it('toErrorMessage prefers rawMessage over message and handles unknown values', () => { + expect( + resolver.toErrorMessage({ + status: 403, + rawMessage: 'unauthorized', + message: 'ignored message', + }), + ).toBe('Unauthorized access. Please sign in again.'); + + expect(resolver.toErrorMessage('boom')).toBe('Request failed. Please try again.'); + expect(resolver.toErrorMessage(null)).toBe('Request failed. Please try again.'); + }); + + it('exposes inferErrorCodeFromStatus', () => { + expect(resolver.inferErrorCodeFromStatus(401)).toBe('AUTH_UNAUTHORIZED'); + expect(resolver.inferErrorCodeFromStatus(500)).toBe('INTERNAL_ERROR'); + expect(resolver.inferErrorCodeFromStatus(418)).toBeUndefined(); + expect(resolver.inferErrorCodeFromStatus(null)).toBeUndefined(); + }); +}); diff --git a/tests/helpers/renderHook.tsx b/tests/helpers/renderHook.tsx new file mode 100644 index 0000000..139098b --- /dev/null +++ b/tests/helpers/renderHook.tsx @@ -0,0 +1,36 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +export function renderHook(useHook: () => T) { + let currentValue: T; + + function TestComponent() { + currentValue = useHook(); + return null; + } + + const container = document.createElement('div'); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + return { + result: { + get current() { + return currentValue; + }, + }, + rerender() { + act(() => { + root.render(); + }); + }, + unmount() { + act(() => { + root.unmount(); + }); + }, + }; +} diff --git a/tests/hooks/useCooldownTimer.test.tsx b/tests/hooks/useCooldownTimer.test.tsx new file mode 100644 index 0000000..56edabe --- /dev/null +++ b/tests/hooks/useCooldownTimer.test.tsx @@ -0,0 +1,64 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCooldownTimer } from '../../src/hooks/useCooldownTimer'; +import { renderHook } from '../helpers/renderHook'; + +describe('useCooldownTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('decrements cooldown every second while enabled', () => { + const { result } = renderHook(() => useCooldownTimer(2, true)); + + expect(result.current.cooldown).toBe(2); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.cooldown).toBe(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.cooldown).toBe(0); + }); + + it('does not decrement when disabled', () => { + const { result } = renderHook(() => useCooldownTimer(2, false)); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current.cooldown).toBe(2); + }); + + it('startCooldown floors values and clamps negatives', () => { + const { result } = renderHook(() => useCooldownTimer(0, true)); + + act(() => { + result.current.startCooldown(2.8); + }); + expect(result.current.cooldown).toBe(2); + + act(() => { + result.current.startCooldown(-4); + }); + expect(result.current.cooldown).toBe(0); + }); + + it('stays at zero when already expired', () => { + const { result } = renderHook(() => useCooldownTimer(0, true)); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.cooldown).toBe(0); + }); +}); diff --git a/tests/hooks/useEditableForm.test.tsx b/tests/hooks/useEditableForm.test.tsx new file mode 100644 index 0000000..703a8e7 --- /dev/null +++ b/tests/hooks/useEditableForm.test.tsx @@ -0,0 +1,81 @@ +import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { useEditableForm } from '../../src/hooks/useEditableForm'; +import { renderHook } from '../helpers/renderHook'; + +type FormValues = { + username: string; + email: string; +}; + +const INITIAL_VALUES: FormValues = { + username: '', + email: '', +}; + +function validate(values: FormValues) { + return { + username: values.username.trim().length < 3 ? 'Username too short' : undefined, + email: values.email.includes('@') ? undefined : 'Invalid email', + }; +} + +describe('useEditableForm', () => { + it('supports load/start/discard/commit edit lifecycle', () => { + const { result } = renderHook(() => + useEditableForm({ + initialValues: INITIAL_VALUES, + validate, + }), + ); + + expect(result.current.isEditing).toBe(false); + + act(() => { + result.current.loadFromSource({ username: 'alice', email: 'alice@example.com' }); + }); + expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' }); + expect(result.current.errors).toEqual({}); + + act(() => { + result.current.startEditing({ username: 'al', email: 'aliceexample.com' }); + }); + expect(result.current.isEditing).toBe(true); + + act(() => { + result.current.setFieldValue('username', 'a'); + }); + expect(result.current.errors.username).toBe('Username too short'); + + act(() => { + result.current.discardChanges({ username: 'alice', email: 'alice@example.com' }); + }); + expect(result.current.isEditing).toBe(false); + expect(result.current.values).toEqual({ username: 'alice', email: 'alice@example.com' }); + + act(() => { + result.current.startEditing({ username: 'alice', email: 'alice@example.com' }); + result.current.setFieldValue('username', 'alice_2'); + result.current.commitSaved({ username: 'alice_2', email: 'alice@example.com' }); + }); + expect(result.current.isEditing).toBe(false); + expect(result.current.values.username).toBe('alice_2'); + }); + + it('exposes direct editing toggles and field error injection', () => { + const { result } = renderHook(() => + useEditableForm({ + initialValues: INITIAL_VALUES, + validate, + }), + ); + + act(() => { + result.current.setIsEditing(true); + result.current.setFieldError('username', 'Username already taken'); + }); + + expect(result.current.isEditing).toBe(true); + expect(result.current.errors.username).toBe('Username already taken'); + }); +}); diff --git a/tests/hooks/usePaginatedResource.test.tsx b/tests/hooks/usePaginatedResource.test.tsx new file mode 100644 index 0000000..2a05321 --- /dev/null +++ b/tests/hooks/usePaginatedResource.test.tsx @@ -0,0 +1,170 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePaginatedResource } from '../../src/hooks/usePaginatedResource'; +import { renderHook } from '../helpers/renderHook'; + +type Item = { + id: string; +}; + +async function flushDebounce(delay = 250) { + await act(async () => { + vi.advanceTimersByTime(delay); + await Promise.resolve(); + }); +} + +describe('usePaginatedResource', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('loads first page after debounce and stores response fields', async () => { + const loadMock = vi.fn(async () => ({ + items: [{ id: '1' }] as Item[], + page: 1, + pageSize: 10, + total: 25, + totalPages: 3, + })); + + const { result } = renderHook(() => + usePaginatedResource({ + load: loadMock, + sort: '-createdAt', + }), + ); + + await flushDebounce(); + + expect(loadMock).toHaveBeenCalledWith({ + q: '', + page: 1, + pageSize: 10, + sort: '-createdAt', + }); + expect(result.current.items).toEqual([{ id: '1' }]); + expect(result.current.total).toBe(25); + expect(result.current.totalPages).toBe(3); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('resets page to 1 when query or page size changes', async () => { + const loadMock = vi.fn( + async ({ q, page, pageSize }: { q: string; page: number; pageSize: number }) => ({ + items: [{ id: `${q}:${page}:${pageSize}` }] as Item[], + page, + pageSize, + total: 1, + totalPages: 1, + }), + ); + + const { result } = renderHook(() => + usePaginatedResource({ + load: loadMock, + }), + ); + + await flushDebounce(); + + act(() => { + result.current.setPage(2); + }); + await flushDebounce(); + expect(loadMock).toHaveBeenLastCalledWith({ + q: '', + page: 2, + pageSize: 10, + sort: undefined, + }); + + act(() => { + result.current.setQuery('search'); + }); + await flushDebounce(); + expect(loadMock).toHaveBeenLastCalledWith({ + q: 'search', + page: 1, + pageSize: 10, + sort: undefined, + }); + + act(() => { + result.current.setPage(3); + }); + await flushDebounce(); + + act(() => { + result.current.setPageSize(20); + }); + await flushDebounce(); + expect(loadMock).toHaveBeenLastCalledWith({ + q: 'search', + page: 1, + pageSize: 20, + sort: undefined, + }); + }); + + it('cancels stale debounce timers when inputs change quickly', async () => { + const loadMock = vi.fn(async () => ({ + items: [] as Item[], + page: 1, + pageSize: 10, + total: 0, + totalPages: 0, + })); + + const { result } = renderHook(() => + usePaginatedResource({ + load: loadMock, + debounceMs: 100, + }), + ); + + act(() => { + result.current.setQuery('latest'); + }); + + await flushDebounce(100); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock).toHaveBeenCalledWith({ + q: 'latest', + page: 1, + pageSize: 10, + sort: undefined, + }); + }); + + it('maps thrown errors into string error state', async () => { + const loadMock = vi + .fn() + .mockRejectedValueOnce(new Error('Load failed')) + .mockRejectedValueOnce('unknown'); + + const { result } = renderHook(() => + usePaginatedResource({ + load: loadMock, + }), + ); + + await flushDebounce(); + expect(result.current.error).toBe('Load failed'); + expect(result.current.isLoading).toBe(false); + + act(() => { + result.current.setPage(2); + }); + await flushDebounce(); + + expect(result.current.error).toBe('Request failed. Please try again.'); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/tests/hooks/useSorting.test.tsx b/tests/hooks/useSorting.test.tsx new file mode 100644 index 0000000..9df4d79 --- /dev/null +++ b/tests/hooks/useSorting.test.tsx @@ -0,0 +1,97 @@ +import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { formatSortParam, useSorting } from '../../src/hooks/useSorting'; +import { renderHook } from '../helpers/renderHook'; + +describe('useSorting', () => { + it('starts from default sort and cycles asc/desc/default', () => { + const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'asc' })); + + expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' }); + expect(result.current.sortParam).toBe('createdAt'); + + act(() => { + result.current.toggleSort('name'); + }); + expect(result.current.activeSort).toEqual({ field: 'name', direction: 'asc' }); + expect(result.current.sortParam).toBe('name'); + + act(() => { + result.current.toggleSort('name'); + }); + expect(result.current.activeSort).toEqual({ field: 'name', direction: 'desc' }); + expect(result.current.sortParam).toBe('-name'); + + act(() => { + result.current.toggleSort('name'); + }); + expect(result.current.activeSort).toEqual({ field: 'createdAt', direction: 'asc' }); + expect(result.current.sortParam).toBe('createdAt'); + }); + + it('cycles sort state without a baseline default', () => { + const { result } = renderHook(() => useSorting(null)); + + expect(result.current.activeSort).toBeNull(); + expect(result.current.sortParam).toBeUndefined(); + + act(() => { + result.current.toggleSort('updatedAt'); + }); + expect(result.current.sortParam).toBe('updatedAt'); + + act(() => { + result.current.toggleSort('updatedAt'); + }); + expect(result.current.sortParam).toBe('-updatedAt'); + + act(() => { + result.current.toggleSort('updatedAt'); + }); + expect(result.current.sortParam).toBeUndefined(); + }); + + it('supports manual setSort and resetSort', () => { + const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' })); + + expect(result.current.sortParam).toBe('-createdAt'); + + act(() => { + result.current.setSort({ field: 'title', direction: 'asc' }); + }); + expect(result.current.sortParam).toBe('title'); + + act(() => { + result.current.resetSort(); + }); + expect(result.current.sortParam).toBe('-createdAt'); + }); + + it('toggles baseline field directly between baseline and opposite direction', () => { + const { result } = renderHook(() => useSorting({ field: 'createdAt', direction: 'desc' })); + + expect(result.current.sortParam).toBe('-createdAt'); + + act(() => { + result.current.toggleSort('createdAt'); + }); + expect(result.current.sortParam).toBe('createdAt'); + + act(() => { + result.current.toggleSort('createdAt'); + }); + expect(result.current.sortParam).toBe('-createdAt'); + + act(() => { + result.current.toggleSort('createdAt'); + }); + expect(result.current.sortParam).toBe('createdAt'); + }); + + it('formats sort params safely', () => { + expect(formatSortParam(null)).toBeUndefined(); + expect(formatSortParam(undefined)).toBeUndefined(); + expect(formatSortParam({ field: 'updatedAt', direction: 'desc' })).toBe('-updatedAt'); + expect(formatSortParam({ field: 'updatedAt', direction: 'asc' })).toBe('updatedAt'); + }); +}); diff --git a/tests/hooks/useSubmitState.test.tsx b/tests/hooks/useSubmitState.test.tsx new file mode 100644 index 0000000..ed0824c --- /dev/null +++ b/tests/hooks/useSubmitState.test.tsx @@ -0,0 +1,44 @@ +import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { useSubmitState } from '../../src/hooks/useSubmitState'; +import { renderHook } from '../helpers/renderHook'; + +describe('useSubmitState', () => { + it('tracks submit lifecycle and feedback state', () => { + const { result } = renderHook(() => useSubmitState(null)); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.submitError).toBeNull(); + expect(result.current.status).toBeNull(); + + act(() => { + result.current.startSubmitting(); + result.current.setSubmitError('Oops'); + result.current.setStatus('Done'); + }); + + expect(result.current.isSubmitting).toBe(true); + expect(result.current.submitError).toBe('Oops'); + expect(result.current.status).toBe('Done'); + + act(() => { + result.current.finishSubmitting(); + result.current.clearFeedback(); + }); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.submitError).toBeNull(); + expect(result.current.status).toBeNull(); + }); + + it('restores the typed initial status in clearFeedback', () => { + const { result } = renderHook(() => useSubmitState<'idle' | 'done'>('idle')); + + act(() => { + result.current.setStatus('done'); + result.current.clearFeedback(); + }); + + expect(result.current.status).toBe('idle'); + }); +}); diff --git a/tests/hooks/useValidatedFields.test.tsx b/tests/hooks/useValidatedFields.test.tsx new file mode 100644 index 0000000..7a9d196 --- /dev/null +++ b/tests/hooks/useValidatedFields.test.tsx @@ -0,0 +1,158 @@ +import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { useValidatedFields } from '../../src/hooks/useValidatedFields'; +import { renderHook } from '../helpers/renderHook'; + +type FormValues = { + password: string; + confirmPassword: string; +}; + +function validate(values: FormValues) { + return { + password: values.password.length < 3 ? 'Password too short' : undefined, + confirmPassword: + values.confirmPassword !== values.password ? 'Passwords do not match' : undefined, + }; +} + +describe('useValidatedFields', () => { + it('initializes values and keeps errors hidden until fields are touched', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: '', confirmPassword: '' }, + validate, + }), + ); + + expect(result.current.values).toEqual({ password: '', confirmPassword: '' }); + expect(result.current.errors).toEqual({}); + expect(result.current.isValid).toBe(false); + }); + + it('setFieldValue touches and validates by default', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: '', confirmPassword: '' }, + validate, + }), + ); + + act(() => { + result.current.setFieldValue('password', 'ab'); + }); + + expect(result.current.values.password).toBe('ab'); + expect(result.current.errors.password).toBe('Password too short'); + }); + + it('setFieldValue can skip touch and validation', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: '', confirmPassword: '' }, + validate, + }), + ); + + act(() => { + result.current.setFieldValue('password', 'ab', { touch: false, validate: false }); + }); + + expect(result.current.values.password).toBe('ab'); + expect(result.current.errors).toEqual({}); + }); + + it('validateAll can avoid touching fields when requested', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: 'abcd', confirmPassword: 'abce' }, + validate, + }), + ); + + let validationErrors: ReturnType | undefined; + act(() => { + validationErrors = result.current.validateAll({ touchAll: false }); + }); + + expect(validationErrors).toEqual({ + password: undefined, + confirmPassword: 'Passwords do not match', + }); + expect(result.current.errors).toEqual({}); + }); + + it('validateAll touches all fields by default', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: '', confirmPassword: '' }, + validate, + }), + ); + + act(() => { + result.current.validateAll(); + }); + + expect(result.current.errors.password).toBe('Password too short'); + expect(result.current.errors.confirmPassword).toBeUndefined(); + }); + + it('supports setFieldError, setErrors and clearErrors', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: 'abcd', confirmPassword: 'abcd' }, + validate, + }), + ); + + act(() => { + result.current.setFieldError('password', 'Server-side password issue'); + }); + expect(result.current.errors.password).toBe('Server-side password issue'); + + act(() => { + result.current.setErrors({ + password: undefined, + confirmPassword: 'Still invalid', + }); + }); + expect(result.current.errors.confirmPassword).toBe('Still invalid'); + + act(() => { + result.current.clearErrors(); + }); + expect(result.current.errors).toEqual({}); + }); + + it('setValues with clearErrors resets touched state and revalidates', () => { + const { result } = renderHook(() => + useValidatedFields({ + initialValues: { password: '', confirmPassword: '' }, + validate, + }), + ); + + act(() => { + result.current.setFieldValue('password', 'ab'); + }); + expect(result.current.errors.password).toBe('Password too short'); + + act(() => { + result.current.setValues( + { + password: 'abcd', + confirmPassword: 'abcd', + }, + { clearErrors: true }, + ); + }); + + expect(result.current.values).toEqual({ + password: 'abcd', + confirmPassword: 'abcd', + }); + expect(result.current.errors).toEqual({}); + expect(result.current.isValid).toBe(true); + }); +}); diff --git a/tests/panels/useSidePanelMachine.test.tsx b/tests/panels/useSidePanelMachine.test.tsx new file mode 100644 index 0000000..84e8cd8 --- /dev/null +++ b/tests/panels/useSidePanelMachine.test.tsx @@ -0,0 +1,239 @@ +import { fireEvent } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { isDesktopViewport, useSidePanelMachine } from '../../src/panels/useSidePanelMachine'; +import { renderHook } from '../helpers/renderHook'; + +function setViewportWidth(width: number) { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }); +} + +function resetMatchMedia() { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: undefined, + }); +} + +describe('isDesktopViewport', () => { + it('uses matchMedia when available', () => { + const matchMedia = vi.fn(() => ({ matches: false })); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: matchMedia, + }); + + expect(isDesktopViewport(1200)).toBe(false); + expect(matchMedia).toHaveBeenCalledWith('(min-width: 1200px)'); + }); + + it('falls back to window.innerWidth when matchMedia is unavailable', () => { + resetMatchMedia(); + setViewportWidth(1024); + expect(isDesktopViewport(1024)).toBe(true); + + setViewportWidth(800); + expect(isDesktopViewport(1024)).toBe(false); + }); +}); + +describe('useSidePanelMachine', () => { + it('reads stored width, clamps resize values, and persists final width', async () => { + resetMatchMedia(); + setViewportWidth(1200); + localStorage.setItem('panel-width', '999'); + + const preventDefault = vi.fn(); + const { result } = renderHook(() => + useSidePanelMachine({ + storageKey: 'panel-width', + defaultWidth: 300, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-left', + resizingBodyClass: 'resizing', + isOpen: false, + canResize: true, + shouldPersistWidth: true, + closeOnPathname: undefined, + }), + ); + + expect(result.current.width).toBe(400); + expect(result.current.isDesktop).toBe(true); + expect(localStorage.getItem('panel-width')).toBe('400'); + + act(() => { + result.current.startResize({ + clientX: 400, + preventDefault, + } as never); + }); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(document.body.classList.contains('resizing')).toBe(true); + + act(() => { + fireEvent.pointerMove(window, { clientX: 100 }); + fireEvent.pointerUp(window); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.width).toBe(200); + expect(localStorage.getItem('panel-width')).toBe('200'); + expect(document.body.classList.contains('resizing')).toBe(false); + }); + + it('falls back to default width for empty or invalid stored values', () => { + resetMatchMedia(); + setViewportWidth(1200); + + localStorage.setItem('panel-width', ''); + const invalid = renderHook(() => + useSidePanelMachine({ + storageKey: 'panel-width', + defaultWidth: 310, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-left', + resizingBodyClass: 'resizing', + isOpen: false, + canResize: true, + shouldPersistWidth: false, + closeOnPathname: undefined, + }), + ); + expect(invalid.result.current.width).toBe(310); + invalid.unmount(); + + localStorage.setItem('panel-width', 'NaN-value'); + const nonFinite = renderHook(() => + useSidePanelMachine({ + storageKey: 'panel-width', + defaultWidth: 315, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-left', + resizingBodyClass: 'resizing', + isOpen: false, + canResize: true, + shouldPersistWidth: false, + closeOnPathname: undefined, + }), + ); + expect(nonFinite.result.current.width).toBe(315); + }); + + it('does not start resizing when viewport is mobile or resizing is disabled', () => { + resetMatchMedia(); + setViewportWidth(700); + + const preventDefault = vi.fn(); + const { result } = renderHook(() => + useSidePanelMachine({ + storageKey: 'panel-width', + defaultWidth: 300, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-left', + resizingBodyClass: 'resizing', + isOpen: false, + canResize: false, + shouldPersistWidth: false, + closeOnPathname: undefined, + }), + ); + + act(() => { + result.current.startResize({ + clientX: 300, + preventDefault, + } as never); + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(document.body.classList.contains('resizing')).toBe(false); + expect(localStorage.getItem('panel-width')).toBeNull(); + }); + + it('ignores pointer events when not currently resizing', () => { + resetMatchMedia(); + setViewportWidth(1200); + + renderHook(() => + useSidePanelMachine({ + storageKey: 'panel-width', + defaultWidth: 300, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-left', + resizingBodyClass: 'resizing', + isOpen: false, + canResize: true, + shouldPersistWidth: true, + closeOnPathname: undefined, + }), + ); + + act(() => { + fireEvent.pointerMove(window, { clientX: 1000 }); + fireEvent.pointerUp(window); + }); + + expect(document.body.classList.contains('resizing')).toBe(false); + }); + + it('handles closeOnPathname, escape key callbacks and body overflow lock', () => { + resetMatchMedia(); + setViewportWidth(700); + + const onCloseOnPathname = vi.fn(); + const onEscape = vi.fn(); + let options = { + storageKey: 'panel-width', + defaultWidth: 300, + minWidth: 200, + maxWidth: 400, + resizeAxis: 'from-right' as const, + resizingBodyClass: 'resizing', + isOpen: true, + canResize: true, + shouldPersistWidth: false, + closeOnPathname: '/users', + onCloseOnPathname, + onEscape, + }; + + const { rerender } = renderHook(() => useSidePanelMachine(options)); + + expect(onCloseOnPathname).toHaveBeenCalledTimes(1); + expect(document.body.style.overflow).toBe('hidden'); + + act(() => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + expect(onEscape).toHaveBeenCalledTimes(1); + + options = { + ...options, + closeOnPathname: '/posts', + }; + rerender(); + expect(onCloseOnPathname).toHaveBeenCalledTimes(2); + + options = { + ...options, + isOpen: false, + }; + rerender(); + expect(document.body.style.overflow).toBe(''); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..a7d2f58 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,15 @@ +// Required by React to silence act(...) warnings in jsdom tests. +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +afterEach(() => { + cleanup(); + localStorage.clear(); + document.body.className = ''; + document.body.style.overflow = ''; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); diff --git a/tests/utils/formatting.test.ts b/tests/utils/formatting.test.ts new file mode 100644 index 0000000..9a0e015 --- /dev/null +++ b/tests/utils/formatting.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { capitalize, formatDate, splitAndCapitalize } from '../../src/utils/formatting'; + +describe('formatDate', () => { + it('formats date in it-IT locale and includes seconds when requested', () => { + const withoutSeconds = formatDate('2026-01-01T12:34:56.000Z'); + const withSeconds = formatDate('2026-01-01T12:34:56.000Z', true); + + expect(withoutSeconds).toContain('2026'); + expect(withSeconds).toContain('56'); + }); +}); + +describe('capitalize', () => { + it('capitalizes every space-delimited word', () => { + expect(capitalize('hello WORLD')).toBe('Hello World'); + expect(capitalize('mUlTi SPACES')).toBe('Multi Spaces'); + }); +}); + +describe('splitAndCapitalize', () => { + it('splits underscore and hyphen mode', () => { + expect(splitAndCapitalize('hello_world-test', 'underscore')).toBe('Hello World Test'); + }); + + it('splits camel mode with acronym and number boundaries', () => { + expect(splitAndCapitalize('helloWorldXML2Http', 'camel')).toBe('Hello World Xml 2 Http'); + }); + + it('auto mode prefers underscore splitting when underscore-like chars exist', () => { + expect(splitAndCapitalize('user_name')).toBe('User Name'); + expect(splitAndCapitalize('kebab-case')).toBe('Kebab Case'); + }); + + it('auto mode falls back to camel splitting when underscore-like chars are absent', () => { + expect(splitAndCapitalize('helloWorldTest')).toBe('Hello World Test'); + }); + + it('returns empty string for empty or missing input', () => { + expect(splitAndCapitalize('')).toBe(''); + expect(splitAndCapitalize(undefined)).toBe(''); + }); +}); diff --git a/tests/utils/verifiedEmail.test.ts b/tests/utils/verifiedEmail.test.ts new file mode 100644 index 0000000..839156a --- /dev/null +++ b/tests/utils/verifiedEmail.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { shouldShowVerifiedEmailBadge } from '../../src/utils/verifiedEmail'; + +describe('shouldShowVerifiedEmailBadge', () => { + it('returns false when email has not been verified', () => { + expect( + shouldShowVerifiedEmailBadge({ + verifiedAt: null, + persistedEmail: 'a@example.com', + currentEmail: 'a@example.com', + isEditing: false, + }), + ).toBe(false); + }); + + it('returns true when verified and not editing', () => { + expect( + shouldShowVerifiedEmailBadge({ + verifiedAt: '2025-01-01T10:00:00Z', + persistedEmail: 'a@example.com', + currentEmail: 'other@example.com', + isEditing: false, + }), + ).toBe(true); + }); + + it('while editing, returns true only when trimmed current email matches persisted email', () => { + expect( + shouldShowVerifiedEmailBadge({ + verifiedAt: '2025-01-01T10:00:00Z', + persistedEmail: 'a@example.com', + currentEmail: ' a@example.com ', + isEditing: true, + }), + ).toBe(true); + + expect( + shouldShowVerifiedEmailBadge({ + verifiedAt: '2025-01-01T10:00:00Z', + persistedEmail: 'a@example.com', + currentEmail: 'other@example.com', + isEditing: true, + }), + ).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index a8fa1f0..2ba9e56 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; @@ -15,4 +15,18 @@ export default defineConfig({ external: ['react'], }, }, + test: { + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/index.ts'], + thresholds: { + lines: 95, + functions: 95, + branches: 90, + }, + }, + }, }); diff --git a/yarn.lock b/yarn.lock index f2b1f87..d83f303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,44 @@ # yarn lockfile v1 -"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": +"@acemir/cssom@^0.9.31": + version "0.9.31" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@acemir/cssom/-/cssom-0.9.31.tgz#bd5337d290fb8be2ac18391f37386bc53778b0bc" + integrity sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA== + +"@adobe/css-tools@^4.4.0": + version "4.4.4" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9" + integrity sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg== + +"@asamuzakjp/css-color@^5.0.0": + version "5.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@asamuzakjp/css-color/-/css-color-5.0.1.tgz#3b9462a9b52f3c6680a0945a3d0851881017550f" + integrity sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw== + dependencies: + "@csstools/css-calc" "^3.1.1" + "@csstools/css-color-parser" "^4.0.2" + "@csstools/css-parser-algorithms" "^4.0.0" + "@csstools/css-tokenizer" "^4.0.0" + lru-cache "^11.2.6" + +"@asamuzakjp/dom-selector@^6.8.1": + version "6.8.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz#39b20993672b106f7cd9a3a9a465212e87e0bfd1" + integrity sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ== + dependencies: + "@asamuzakjp/nwsapi" "^2.3.9" + bidi-js "^1.0.3" + css-tree "^3.1.0" + is-potential-custom-element-name "^1.0.1" + lru-cache "^11.2.6" + +"@asamuzakjp/nwsapi@^2.3.9": + version "2.3.9" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24" + integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== + +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== @@ -130,6 +167,11 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/runtime@^7.12.5": + version "7.28.6" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" + integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== + "@babel/template@^7.28.6": version "7.28.6" resolved "https://nexus.beatrice.wtf/repository/npm-group/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" @@ -160,6 +202,51 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + +"@bramus/specificity@^2.4.2": + version "2.4.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648" + integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw== + dependencies: + css-tree "^3.0.0" + +"@csstools/color-helpers@^6.0.2": + version "6.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/color-helpers/-/color-helpers-6.0.2.tgz#82c59fd30649cf0b4d3c82160489748666e6550b" + integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q== + +"@csstools/css-calc@^3.1.1": + version "3.1.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/css-calc/-/css-calc-3.1.1.tgz#78b494996dac41a02797dcca18ac3b46d25b3fd7" + integrity sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ== + +"@csstools/css-color-parser@^4.0.2": + version "4.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz#c27e03a3770d0352db92d668d6dde427a37859e5" + integrity sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw== + dependencies: + "@csstools/color-helpers" "^6.0.2" + "@csstools/css-calc" "^3.1.1" + +"@csstools/css-parser-algorithms@^4.0.0": + version "4.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164" + integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== + +"@csstools/css-syntax-patches-for-csstree@^1.0.28": + version "1.0.28" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz#cd239a16f95c0ed7c6d74315da4e38f2e93bbf19" + integrity sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg== + +"@csstools/css-tokenizer@^4.0.0": + version "4.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" + integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== + "@esbuild/aix-ppc64@0.27.3": version "0.27.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" @@ -343,6 +430,11 @@ "@eslint/core" "^1.1.0" levn "^0.4.1" +"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.6.0": + version "1.14.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@exodus/bytes/-/bytes-1.14.1.tgz#9b5c29077162a35f1bd25613e0cd3c239f6e7ad8" + integrity sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ== + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -387,12 +479,12 @@ resolved "https://nexus.beatrice.wtf/repository/npm-group/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://nexus.beatrice.wtf/repository/npm-group/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" resolved "https://nexus.beatrice.wtf/repository/npm-group/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -530,6 +622,49 @@ resolved "https://nexus.beatrice.wtf/repository/npm-group/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^6.9.1": + version "6.9.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" + integrity sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/react@^16.3.0": + version "16.3.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" + integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== + dependencies: + "@babel/runtime" "^7.12.5" + +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -563,12 +698,25 @@ dependencies: "@babel/types" "^7.28.2" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + "@types/esrecurse@^4.3.1": version "4.3.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== -"@types/estree@1.0.8", "@types/estree@^1.0.6", "@types/estree@^1.0.8": +"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.8" resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -578,6 +726,11 @@ resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/react-dom@^19.0.0": + version "19.2.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + "@types/react@^19.0.0": version "19.2.14" resolved "https://nexus.beatrice.wtf/repository/npm-group/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" @@ -693,6 +846,80 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.18.0" +"@vitest/coverage-v8@^4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz#b9c4db7479acd51d5f0ced91b2853c29c3d0cda7" + integrity sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg== + dependencies: + "@bcoe/v8-coverage" "^1.0.2" + "@vitest/utils" "4.0.18" + ast-v8-to-istanbul "^0.3.10" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.2.0" + magicast "^0.5.1" + obug "^2.1.1" + std-env "^3.10.0" + tinyrainbow "^3.0.3" + +"@vitest/expect@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/expect/-/expect-4.0.18.tgz#361510d99fbf20eb814222e4afcb8539d79dc94d" + integrity sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + chai "^6.2.1" + tinyrainbow "^3.0.3" + +"@vitest/mocker@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/mocker/-/mocker-4.0.18.tgz#b9735da114ef65ea95652c5bdf13159c6fab4865" + integrity sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ== + dependencies: + "@vitest/spy" "4.0.18" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/pretty-format/-/pretty-format-4.0.18.tgz#fbccd4d910774072ec15463553edb8ca5ce53218" + integrity sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== + dependencies: + tinyrainbow "^3.0.3" + +"@vitest/runner@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/runner/-/runner-4.0.18.tgz#c2c0a3ed226ec85e9312f9cc8c43c5b3a893a8b1" + integrity sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw== + dependencies: + "@vitest/utils" "4.0.18" + pathe "^2.0.3" + +"@vitest/snapshot@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/snapshot/-/snapshot-4.0.18.tgz#bcb40fd6d742679c2ac927ba295b66af1c6c34c5" + integrity sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA== + dependencies: + "@vitest/pretty-format" "4.0.18" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/spy/-/spy-4.0.18.tgz#ba0f20503fb6d08baf3309d690b3efabdfa88762" + integrity sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== + +"@vitest/utils@4.0.18": + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/@vitest/utils/-/utils-4.0.18.tgz#9636b16d86a4152ec68a8d6859cff702896433d4" + integrity sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== + dependencies: + "@vitest/pretty-format" "4.0.18" + tinyrainbow "^3.0.3" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://nexus.beatrice.wtf/repository/npm-group/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -703,6 +930,11 @@ acorn@^8.16.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://nexus.beatrice.wtf/repository/npm-group/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ajv@^6.12.4: version "6.14.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" @@ -713,6 +945,42 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +aria-query@5.3.0: + version "5.3.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +aria-query@^5.0.0: + version "5.3.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +ast-v8-to-istanbul@^0.3.10: + version "0.3.11" + resolved "https://nexus.beatrice.wtf/repository/npm-group/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz#725b1f5e2ffdc8d71620cb5e78d6dc976d65e97a" + integrity sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + estree-walker "^3.0.3" + js-tokens "^10.0.0" + balanced-match@^4.0.2: version "4.0.4" resolved "https://nexus.beatrice.wtf/repository/npm-group/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" @@ -723,6 +991,13 @@ baseline-browser-mapping@^2.9.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== +bidi-js@^1.0.3: + version "1.0.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2" + integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw== + dependencies: + require-from-string "^2.0.2" + brace-expansion@^5.0.2: version "5.0.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/brace-expansion/-/brace-expansion-5.0.3.tgz#6a9c6c268f85b53959ec527aeafe0f7300258eef" @@ -746,6 +1021,11 @@ caniuse-lite@^1.0.30001759: resolved "https://nexus.beatrice.wtf/repository/npm-group/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz#aa8a176eba0006e78c965a8215c7a1ceb030122d" integrity sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg== +chai@^6.2.1: + version "6.2.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + convert-source-map@^2.0.0: version "2.0.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" @@ -760,28 +1040,89 @@ cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" +css-tree@^3.0.0, css-tree@^3.1.0: + version "3.1.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/css-tree/-/css-tree-3.1.0.tgz#7aabc035f4e66b5c86f54570d55e05b1346eb0fd" + integrity sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w== + dependencies: + mdn-data "2.12.2" + source-map-js "^1.0.1" + +css.escape@^1.5.1: + version "1.5.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + +cssstyle@^6.0.1: + version "6.1.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/cssstyle/-/cssstyle-6.1.0.tgz#07ae112dd2fbc590d11f6e11c73699bd50f27d51" + integrity sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg== + dependencies: + "@asamuzakjp/css-color" "^5.0.0" + "@csstools/css-syntax-patches-for-csstree" "^1.0.28" + css-tree "^3.1.0" + lru-cache "^11.2.6" + csstype@^3.2.2: version "3.2.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: +data-urls@^7.0.0: + version "7.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3" + integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA== + dependencies: + whatwg-mimetype "^5.0.0" + whatwg-url "^16.0.0" + +debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.3: version "4.4.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" +decimal.js@^10.6.0: + version "10.6.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + deep-is@^0.1.3: version "0.1.4" resolved "https://nexus.beatrice.wtf/repository/npm-group/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +dequal@^2.0.3: + version "2.0.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://nexus.beatrice.wtf/repository/npm-group/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + electron-to-chromium@^1.5.263: version "1.5.302" resolved "https://nexus.beatrice.wtf/repository/npm-group/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== +entities@^6.0.0: + version "6.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + esbuild@^0.27.0: version "0.27.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" @@ -824,10 +1165,10 @@ escape-string-regexp@^4.0.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-plugin-react-hooks@^7.0.1: - version "7.0.1" - resolved "https://nexus.beatrice.wtf/repository/npm-group/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169" - integrity sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== +eslint-plugin-react-hooks@^7.1.0-canary-ab18f33d-20260220: + version "7.1.0-canary-fd524fe0-20251121" + resolved "https://nexus.beatrice.wtf/repository/npm-group/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.0-canary-fd524fe0-20251121.tgz#976c8feb747505e7eae45e15c5f0f16df4553ef9" + integrity sha512-G5we0+XjZTKpjkLL9AgdWxzmo4mqelVDIYzoR1dBlhhiN8Lf5PQ+l8frr+BmX02nU4g0AEez3eGSF/LNfHokEw== dependencies: "@babel/core" "^7.24.4" "@babel/parser" "^7.24.4" @@ -924,11 +1265,23 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +expect-type@^1.2.2: + version "1.3.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -999,6 +1352,11 @@ globals@^17.3.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9" integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw== +has-flag@^4.0.0: + version "4.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + hermes-estree@0.25.1: version "0.25.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" @@ -1011,6 +1369,34 @@ hermes-parser@^0.25.1: dependencies: hermes-estree "0.25.1" +html-encoding-sniffer@^6.0.0: + version "6.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882" + integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg== + dependencies: + "@exodus/bytes" "^1.6.0" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + +https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://nexus.beatrice.wtf/repository/npm-group/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + ignore@^5.2.0: version "5.3.2" resolved "https://nexus.beatrice.wtf/repository/npm-group/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -1026,6 +1412,11 @@ imurmurhash@^0.1.4: resolved "https://nexus.beatrice.wtf/repository/npm-group/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +indent-string@^4.0.0: + version "4.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + is-extglob@^2.1.1: version "2.1.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -1038,16 +1429,75 @@ is-glob@^4.0.0, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + isexe@^2.0.0: version "2.0.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +js-tokens@^10.0.0: + version "10.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/js-tokens/-/js-tokens-10.0.0.tgz#dffe7599b4a8bb7fe30aff8d0235234dffb79831" + integrity sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== + js-tokens@^4.0.0: version "4.0.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +jsdom@^28.1.0: + version "28.1.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/jsdom/-/jsdom-28.1.0.tgz#ac4203e58fd24d7b0f34359ab00d6d9caebd4b62" + integrity sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug== + dependencies: + "@acemir/cssom" "^0.9.31" + "@asamuzakjp/dom-selector" "^6.8.1" + "@bramus/specificity" "^2.4.2" + "@exodus/bytes" "^1.11.0" + cssstyle "^6.0.1" + data-urls "^7.0.0" + decimal.js "^10.6.0" + html-encoding-sniffer "^6.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.6" + is-potential-custom-element-name "^1.0.1" + parse5 "^8.0.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^6.0.0" + undici "^7.21.0" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^8.0.1" + whatwg-mimetype "^5.0.0" + whatwg-url "^16.0.0" + xml-name-validator "^5.0.0" + jsesc@^3.0.2: version "3.1.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" @@ -1095,6 +1545,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lru-cache@^11.2.6: + version "11.2.6" + resolved "https://nexus.beatrice.wtf/repository/npm-group/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58" + integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -1102,6 +1557,44 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.30.21: + version "0.30.21" + resolved "https://nexus.beatrice.wtf/repository/npm-group/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.5.1: + version "0.5.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/magicast/-/magicast-0.5.2.tgz#70cea9df729c164485049ea5df85a390281dfb9d" + integrity sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + source-map-js "^1.2.1" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +mdn-data@2.12.2: + version "2.12.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/mdn-data/-/mdn-data-2.12.2.tgz#9ae6c41a9e65adf61318b32bff7b64fbfb13f8cf" + integrity sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA== + +min-indent@^1.0.0: + version "1.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + minimatch@^10.2.1: version "10.2.2" resolved "https://nexus.beatrice.wtf/repository/npm-group/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" @@ -1136,6 +1629,11 @@ node-releases@^2.0.27: resolved "https://nexus.beatrice.wtf/repository/npm-group/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== +obug@^2.1.1: + version "2.1.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" + integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + optionator@^0.9.3: version "0.9.4" resolved "https://nexus.beatrice.wtf/repository/npm-group/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -1162,6 +1660,13 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +parse5@^8.0.0: + version "8.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/parse5/-/parse5-8.0.0.tgz#aceb267f6b15f9b6e6ba9e35bfdd481fc2167b12" + integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA== + dependencies: + entities "^6.0.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -1172,7 +1677,12 @@ path-key@^3.1.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -picocolors@^1.1.1: +pathe@^2.0.3: + version "2.0.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -1201,7 +1711,16 @@ prettier@^3.8.1: resolved "https://nexus.beatrice.wtf/repository/npm-group/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -punycode@^2.1.0: +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -1213,6 +1732,11 @@ react-dom@^19.0.0: dependencies: scheduler "^0.27.0" +react-is@^17.0.1: + version "17.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-refresh@^0.18.0: version "0.18.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" @@ -1223,6 +1747,19 @@ react@^19.0.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== +redent@^3.0.0: + version "3.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + rollup@^4.43.0: version "4.59.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" @@ -1257,6 +1794,13 @@ rollup@^4.43.0: "@rollup/rollup-win32-x64-msvc" "4.59.0" fsevents "~2.3.2" +saxes@^6.0.0: + version "6.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.27.0: version "0.27.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" @@ -1267,7 +1811,7 @@ semver@^6.3.1: resolved "https://nexus.beatrice.wtf/repository/npm-group/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.7.3: +semver@^7.5.3, semver@^7.7.3: version "7.7.4" resolved "https://nexus.beatrice.wtf/repository/npm-group/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -1284,11 +1828,55 @@ shebang-regex@^3.0.0: resolved "https://nexus.beatrice.wtf/repository/npm-group/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -source-map-js@^1.2.1: +siginfo@^2.0.0: + version "2.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +source-map-js@^1.0.1, source-map-js@^1.2.1: version "1.2.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +stackback@0.0.2: + version "0.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.10.0: + version "3.10.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://nexus.beatrice.wtf/repository/npm-group/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.0.2" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== + tinyglobby@^0.2.15: version "0.2.15" resolved "https://nexus.beatrice.wtf/repository/npm-group/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" @@ -1297,6 +1885,37 @@ tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" +tinyrainbow@^3.0.3: + version "3.0.3" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tinyrainbow/-/tinyrainbow-3.0.3.tgz#984a5b1c1b25854a9b6bccbe77964d0593d1ea42" + integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + +tldts-core@^7.0.23: + version "7.0.23" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tldts-core/-/tldts-core-7.0.23.tgz#47bf18282a44641304a399d247703413b5d3e309" + integrity sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ== + +tldts@^7.0.5: + version "7.0.23" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tldts/-/tldts-7.0.23.tgz#444f0f0720fa777839a23ea665e04f61ee57217a" + integrity sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw== + dependencies: + tldts-core "^7.0.23" + +tough-cookie@^6.0.0: + version "6.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tough-cookie/-/tough-cookie-6.0.0.tgz#11e418b7864a2c0d874702bc8ce0f011261940e5" + integrity sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w== + dependencies: + tldts "^7.0.5" + +tr46@^6.0.0: + version "6.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6" + integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw== + dependencies: + punycode "^2.3.1" + ts-api-utils@^2.4.0: version "2.4.0" resolved "https://nexus.beatrice.wtf/repository/npm-group/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" @@ -1324,6 +1943,11 @@ typescript@^5.6.2: resolved "https://nexus.beatrice.wtf/repository/npm-group/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +undici@^7.21.0: + version "7.22.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/undici/-/undici-7.22.0.tgz#7a82590a5908e504a47d85c60b0f89ca14240e60" + integrity sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg== + update-browserslist-db@^1.2.0: version "1.2.3" resolved "https://nexus.beatrice.wtf/repository/npm-group/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" @@ -1339,7 +1963,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -vite@^7.0.0: +"vite@^6.0.0 || ^7.0.0", vite@^7.0.0: version "7.3.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== @@ -1353,6 +1977,58 @@ vite@^7.0.0: optionalDependencies: fsevents "~2.3.3" +vitest@^4.0.18: + version "4.0.18" + resolved "https://nexus.beatrice.wtf/repository/npm-group/vitest/-/vitest-4.0.18.tgz#56f966353eca0b50f4df7540cd4350ca6d454a05" + integrity sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ== + dependencies: + "@vitest/expect" "4.0.18" + "@vitest/mocker" "4.0.18" + "@vitest/pretty-format" "4.0.18" + "@vitest/runner" "4.0.18" + "@vitest/snapshot" "4.0.18" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + es-module-lexer "^1.7.0" + expect-type "^1.2.2" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^3.10.0" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.0.3" + vite "^6.0.0 || ^7.0.0" + why-is-node-running "^2.3.0" + +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + +webidl-conversions@^8.0.1: + version "8.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" + integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ== + +whatwg-mimetype@^5.0.0: + version "5.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48" + integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw== + +whatwg-url@^16.0.0: + version "16.0.1" + resolved "https://nexus.beatrice.wtf/repository/npm-group/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd" + integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw== + dependencies: + "@exodus/bytes" "^1.11.0" + tr46 "^6.0.0" + webidl-conversions "^8.0.1" + which@^2.0.1: version "2.0.2" resolved "https://nexus.beatrice.wtf/repository/npm-group/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -1360,11 +2036,29 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + word-wrap@^1.2.5: version "1.2.5" resolved "https://nexus.beatrice.wtf/repository/npm-group/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://nexus.beatrice.wtf/repository/npm-group/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + yallist@^3.0.2: version "3.1.1" resolved "https://nexus.beatrice.wtf/repository/npm-group/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"