Files
iistwin/tests/a11y.test.tsx
Ильяс Султанов 42e5ace655 feat(notifications+a11y): центр уведомлений /notifications, skip-link, axe smoke
Шаг 2.5 плана production-готовности:
- страница уведомлений (фильтр, догрузка, прочтение) + ссылки из dropdown/мобильного меню
- общие хелперы notification-utils (дедупликация dropdown)
- skip-link + id=content на main
- axe smoke-тесты (0 critical/serious), исправлены 2 нарушения в новой странице
- 3 новых теста (107/107), i18n отложен по решению пользователя
2026-09-08 09:32:36 +03:00

173 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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<string, unknown>,
}));
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(<Login />);
// Защита от вакуумного прохода: форма действительно отрендерилась
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(
<QueryClientProvider client={queryClient}>
<OnboardingChecklist />
</QueryClientProvider>
);
// Чеклист действительно виден (не скрыт по 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(
<QueryClientProvider client={queryClient}>
<NotificationsPage />
</QueryClientProvider>
);
// Список действительно отрендерился
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([]);
});
});