// @vitest-environment jsdom /** * Базовый axe-проход по ключевым компонентам: форма входа, онбординг-чеклист, * страница уведомлений. Проверяем отсутствие нарушений уровня critical/serious. * Это smoke-задел, а не полный аудит всего приложения. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import axe from 'axe-core'; // jsdom не имеет ResizeObserver, а Radix-контролы (Checkbox и др.) его требуют if (typeof globalThis.ResizeObserver === 'undefined') { globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; } // --- Моки хуков: тестируем разметку без сети и auth --- // Текущий «пользователь» для useAuth — переключается между тестами const mockAuthState = vi.hoisted(() => ({ user: null as null | Record, })); vi.mock('@/hooks/useAuth', () => ({ useAuth: () => ({ user: mockAuthState.user, login: vi.fn(), isLoading: false, isExpiredOffline: false, }), })); vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }), toast: vi.fn(), })); // Layout в странице уведомлений заменяем на passthrough — // сайдбар с SSE/офлайн-контекстами в jsdom не нужен vi.mock('@/components/Layout', () => ({ default: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), })); const FIXTURE_NOTIFICATIONS = [ { id: 1, taskId: 10, type: 'task.assigned', title: 'Вас назначили ответственным', message: 'Задача «Проверить отчёт» назначена вам', isRead: false, createdAt: '2026-03-01T10:00:00.000Z', task: { id: 10, title: 'Проверить отчёт', formId: 5 }, }, { id: 2, taskId: 11, type: 'task_mention', title: 'Вас упомянули', isRead: true, createdAt: '2026-02-28T09:00:00.000Z', task: { id: 11, title: 'Согласовать договор', formId: 5 }, taskMessage: { id: 100, message: 'Посмотрите, пожалуйста', author: { firstName: 'Иван', lastName: 'Петров' }, }, }, ]; vi.mock('@/hooks/useNotifications', () => ({ CHAT_EVENT_TYPES: ['task.mentioned', 'task.comment.created', 'task.comment.replied', 'task_mention', 'task_reply'], useNotifications: () => ({ allNotifications: FIXTURE_NOTIFICATIONS, chatNotifications: [], systemNotifications: FIXTURE_NOTIFICATIONS, unreadChatCount: 0, unreadSystemCount: 1, isLoading: false, sseConnected: true, }), })); // Импорты компонентов — после vi.mock (hoisting учтён) import Login from '../client/src/pages/Login'; import { OnboardingChecklist } from '../client/src/components/OnboardingChecklist'; import NotificationsPage from '../client/src/pages/NotificationsPage'; afterEach(cleanup); function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, // Заглушка queryFn: тесты работают на предзагруженном кэше, сеть не нужна queryFn: () => Promise.resolve({}), }, }, }); } async function runAxe(container: HTMLElement) { const results = await axe.run(container, { // color-contrast в jsdom не вычисляется (нет реальных стилей/canvas) — исключаем, // чтобы не ловить ложные срабатывания; контраст проверяется скинами дизайн-системы rules: { 'color-contrast': { enabled: false } }, }); return results.violations.filter( v => v.impact === 'critical' || v.impact === 'serious' ); } describe('a11y smoke (axe)', () => { it('Login: форма входа без критичных нарушений', async () => { const { container } = render(); // Защита от вакуумного прохода: форма действительно отрендерилась expect(container.querySelector('[data-testid="input-email"]')).toBeTruthy(); const violations = await runAxe(container); expect( violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) ).toEqual([]); }); it('OnboardingChecklist: чеклист без критичных нарушений', async () => { mockAuthState.user = { id: 1, firstName: 'Тест', lastName: 'Тестов', organization: { id: 1 }, }; const queryClient = makeQueryClient(); // Данные предзагружены — компонент рендерит шаги без сетевых запросов queryClient.setQueryData(['/api/forms'], { success: true, forms: [{}] }); queryClient.setQueryData(['/api/users/list'], { success: true, users: [{}, {}] }); queryClient.setQueryData(['/api/tasks', 'onboarding-first'], { success: true, tasks: [] }); const { container } = render( ); // Чеклист действительно виден (не скрыт по loading/dismissed) expect(container.textContent).toContain('Начало работы'); const violations = await runAxe(container); expect( violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) ).toEqual([]); }); it('NotificationsPage: центр уведомлений без критичных нарушений', async () => { const queryClient = makeQueryClient(); const { container } = render( ); // Список действительно отрендерился expect(container.querySelector('[data-testid="notification-1"]')).toBeTruthy(); const violations = await runAxe(container); expect( violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`) ).toEqual([]); }); });