# Тести вебу ```bash npm test # vitest run — один прогін npm run test:watch # у режимі спостереження npm run check # типи + тести + збірка sh ../scripts/check.sh web # те саме, але тим самим шляхом, що й CI ``` Раннер — **vitest**, а не jest: проєкт на Vite, і `vitest.config.ts` зроблено через `mergeConfig(viteConfig, …)`. Тобто тести проходять ТУ САМУ трансформацію, що й збірка. З jest вийшло б два різні конвеєри, і розбіжність між ними знаходили б не тестом, а на стенді. Середовище — jsdom. Тести лежать у `src/test/`. --- ## Головне про цей файл Сьогодні на проєкті сталося ось що: перед зміною ролі БД прогнали ретельний тест ізоляції, він був зелений — і пропустив поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє». **Зелена перевірка доводить рівно те, що вона перевіряє.** Тому нижче спершу йде перелік того, чого тести НЕ покривають. Він важливіший за перелік покритого: доки він тут і чесний, зелений прогін не можна прийняти за доказ справності. --- ## Чого тести НЕ покривають ### Мапа: перетягування вузлів і все, що робить d3-drag `MapCanvas` тягне вузли через React Flow, а той — через **d3-drag**. d3-drag не запускається синтетичними подіями jsdom: він читає `pointerId`, `PointerEvent`, `elementFromPoint` і матрицю перетворення SVG, а jsdom нічого з цього не рахує. Наслідок практичний: **тесту на перетягування немає й не буде** без справжнього браузера. Що саме через це лишається неперевіреним: - запис нової позиції вузла після перетягування (`MapCanvas.tsx`, порівняння `Math.round(x/y)` з попереднім і рішення «слати чи ні»); - групове перетягування виділених вузлів; - малювання нового звʼязку мишею від порту до порту; - вибір бока підключення при перетягуванні (`autoSides` перевірено як функція, але не перевірено, що полотно взагалі її кличе й із тими координатами). ### Усе, що вимагає справжнього рушія - **Мінікарта конфігу (`Minimap.tsx`)** — перевірено лише `buildShape`. Саме малювання йде в ``, а jsdom не має 2D-контексту: `getContext('2d')` повертає `null`, і всі три шари мовчки нічого не роблять. Тобто **розкладка й вигляд мінікарти не перевірені ніяк**: помилка в `yOfRow`, `rowAtY`, у межах вікна прокрутки або в колонці щільності позначок пройде зеленою. - **Точка підпису на кривій (`pointsOnPath` у `mapStyle.ts`)** — код сам повертає `[]` під jsdom, бо `SVGPathElement.getTotalLength` там не реалізовано. Гілка з реальними вимірами не виконується жодного разу. - **Ширина символа (`useCharWidth`)** і будь-яка залежність від `getBoundingClientRect`: у jsdom усі розміри нульові. Віртуальна прокрутка `ConfigViewer` перевірена лише в арифметичній частині (`rowAt`, `uniformOffsets`), не в тій, що читає реальні розміри. - **Медіазапити Tailwind.** `DataTable` малює кожен рядок ДВІЧІ — таблицею для десктопа й карткою для телефона, — а ховає зайве CSS. У тесті видно обидві копії; який саме варіант побачить людина на 375 px, тест не знає. - **`ResizeObserver`** підмінено заглушкою в `src/test/setup.ts`. Логіка, що залежить від зміни розміру, не спрацьовує. ### Мережа й живі дані Мережевий шар тепер покритий (див. «Що покрито»), але покритий він **проти підставного `fetch`**, а не проти сервера. Тому лишається непокритим: - **Жодного тесту зі справжнім сервером.** Підставні відповіді написані за `server/API.md` і за обробниками `server/internal/httpapi/`, і звірка ця зроблена **очима, один раз**. Ніщо не тримає її актуальною: якщо сервер перейменує поле, тести лишаться зеленими на старій формі. Розбіжність між `types.ts` і реальною відповіддю API тут не ловиться в принципі — так само, як і раніше. - **Справжній `WebSocket` не бере участі.** Перевірено логіку `LiveConnection` проти підставного сокета: рукостискання, підпротоколи, поведінка проксі, `bufferedAmount`, порядок подій у реальному браузері — ні. - **Черга подій під навантаженням.** Що буде, коли сервер надішле тисячі повідомлень за секунду, тест не знає: усі перевірки — на одиницях подій. - **Ротація refresh-токена в часі.** Перевірено, що обмін ОДИН на сплеск запитів; що буде, коли access-токен протухне рівно між `fetch` і читанням тіла, — не перевірено. - **Режим кіоска (`VITE_API_TOKEN`).** `staticTokenPresent` читається з `import.meta.env` під час імпорту модуля, тому в тестах він завжди порожній: жодна перевірка цієї гілки не виконується (див. «знайдене» у звіті — там про неї є що сказати). ### Сторінки Покрито п’ять сторінок із двадцяти семи (`AuditPage`, `DevicesPage`, `TeamPage`, `RolesPage`, `AlertsPage`) і два вікна незворотних дій. **Решта двадцять дві не покриті нічим:** `MapPage`, `DevicePage`, `ConfigsPage`, `CommandsPage`, `RulesPage`, `TemplatesPage`, `TrapsPage`, `StoragePage`, `QueuesPage`, `MetricsPage`, `DashboardPage`, `GroupsPage`, `CredentialsPage`, `ProfilesPage`, `CompliancePage`, `MirrorPage`, `ChannelsPage`, `EscalationsPage`, `AgentsPage`, `ServerFilesPage`, `ProfilePage`, `TvPage`. Для них не перевірено ні права, ні форми, ні фільтри. Окремо про те, чого немає навіть на покритих сторінках: - **Фільтри в адресі перевірені лише в `AuditPage`.** `DevicesPage` тримає свої фільтри в стані компонента, а не в адресі, — тобто відфільтрований перелік хостів колезі не перешлеш. Це не поломка тесту, це властивість продукту, і тест її не покриває, бо покривати нічого. - **`DeviceFilterPanel` і фільтри метрик** — перевірено лише чисті функції (`filters.test.ts`), не зв’язку «панель → запит». - **Створення хоста, шаблона, правила, каналу** — форми не покриті. Перевірено лише те, що кнопка створення з’являється за правом. - **Порядок і кількість запитів при монтуванні сторінки** перевірено лише там, де це саме предмет тесту (зонди на `DevicesPage`). Зайвий запит на решті сторінок пройде зеленим. ### Права доступу Перевірено пари «є право / немає права» для хостів, користувачів, ролей і алертів. **Не перевірено:** - **звуження групами доступу** (`writable`, `access: read|write` на мапі): тести працюють із хостами, у яких `writable: true`; - **розбіжність між тим, що ховає клієнт, і тим, що відхиляє сервер.** Клієнтський `session.can()` і серверний `requirePerm()` — два різні переліки, і ніщо не звіряє їх між собою. Право, яке сервер уже вимагає, а клієнт ще ні (або навпаки), пройде зеленим по обидва боки; - **право, що змінилось під час роботи вкладки.** Сервер перечитує членство при кожній ротації токена, клієнт — при `session.set`. Що бачить людина в проміжку, не перевірено. ### Доступність і клавіатура - **Модальне вікно не переносить фокус усередину й не тримає його там.** Тесту на це немає, бо й поведінки немає (див. «знайдене» нижче). Перевірено лише закриття: Esc, клік повз панель, хрестик. - Порядок обходу Tab, читачі з екрана, контраст — не перевірено ніяк. ### Дрібне, але варте згадки - `download()` (збереження конфігу файлом) — не покрито: `URL.createObjectURL` у jsdom існує, але завантаження не відбувається. - Часові пояси: `RollbackDialog.fmtDate` і `AuditPage.fmt*` користуються `toLocaleString('uk-UA')`, результат залежить від пояса машини. Тестів немає саме тому — вони були б зелені локально й червоні в CI. - `MetricChart.build` (розрахунок меж осей, накопичення, розриви) — не покрито. Покрито лише `fmtValue` і `seriesLabel`. --- ## Знайдене, але не закріплене тестом Правило 3 нижче забороняє закріплювати ваду зеленим `expect`. Це знайдено під час написання тестів мережевого шару й сторінок; тестів на це навмисно немає — або вони були б червоні, або зафіксували б неправильну поведінку. 1. **«Будь-який машинний токен» у журналі аудиту їде не тим параметром.** `AuditPage` кладе значення `-` у `filter.tokens`, звідки `api.audit()` надсилає `token=-`. Сервер шукає `-` серед `actor` (`httpapi/audit.go`, гілка `f.AnyToken`), а `token` кладе в `ActorTokenIDs` — і той іде в SQL як `actor_token_id = ANY(...)` по стовпцю `uuid` (`migrations/0001_core.sql:215`). Тобто вибір цієї позначки дає не «рядки без людини», а помилку розбору UUID. Показово: коментар у `types.ts` над `AuditFilter.actors` описує ПРАВИЛЬНУ поведінку — `-` мав лежати серед `actors`. 2. **Режим кіоска нікому нічого не показує.** При заданому `VITE_API_TOKEN` `App` пропускає відновлення сесії, тож `session.me()` лишається `null` назавжди — а `session.can()` без `me` повертає `false` на будь-яке право. Наслідок: бічне меню порожнє, а кожен маршрут показує «Розділ недоступний». `api.me()` у клієнті є, але не викликається звідки-небудь жодного разу. 3. **`api.logout()` кидає помилку в порожнечу.** `AppShell` кличе його як `void api.logout()`; сесія чиститься в `finally`, але відмова сервера стає необробленим відхиленням промісу. Тестів на п.1 і п.2 немає навмисно: вони були б червоні, а червоний тест у `check.sh` зупиняє роботу всім. --- ## Що покрито ### Чиста логіка | Файл тесту | Що саме | |---|---| | `format.test.ts` | `plural`, `ago`, `humanInterval`, `fmtBytes`, `formatBps`, `fmtValue`, `fmtBps`, `uniq` — межі одиниць, знак, дати з майбутнього | | `linediff.test.ts` | `splitLines`, `diffSegs`, `buildDiff`, `wordDiff` | | `configview.test.ts` | `rowAt`, `uniformOffsets`, `maxLen`, `gutterWidth`, `safeName`, `buildShape` | | `map.test.ts` | `autoSides`, `autoLabelPositions`, `labelCandidates`, `estimateLabelBox`, `edgeState` | | `labelLayout.test.ts` | жадібна розкладка підписів: зсув, невміщення, пріоритет, масштаб, звільнення місця | | `filters.test.ts` | `matchesMetricQuery`, `matchesMetricFilter`, `defaultsFromSchema` | Найважливіше тут — **порівняння конфігів**. Воно перевіряється не прикладами, а інваріантами на 500 випадкових парах із детермінованим генератором: із ділянок має точно відновлюватись і стара версія, і нова; нумерація обох колонок має йти без пропусків і повторів; текст у рядку має відповідати своєму номеру. Приклади ловлять те, про що встиг подумати автор тесту; інваріант ловить те, про що не подумав ніхто. Окремо перевірено розрахунковий випадок Myers — один змінений рядок серед 30 000. ### Поведінка компонентів | Файл тесту | Що саме | |---|---| | `modal.test.tsx` | Esc (у т.ч. коли фокус у полі), клік повз панель, виділення тексту за край вікна НЕ закриває, зняття слухача при розмонтуванні, `role=dialog`/`aria-modal` | | `confirm.test.tsx` | подвійний клік не шле другий запит, помилка сервера НЕ закриває вікно, Esc = скасувати, підпис кнопки | | `datatable.test.tsx` | порожній стан, подвійна відмальовка рядків, `hideOnMobile`, `Toggle` | ### Мережевий шар | Файл тесту | Що саме | |---|---| | `apiclient.test.ts` | заголовок `Bearer`, `credentials: same-origin`, 204 без тіла, розбір `{"error":{code,message}}`, `isConflict`/`isPlanLimit`/`isForbidden` **і навпаки**, тихий обмін на `401 token_expired`, відсутність обміну на `no_session` і `403`, один обмін на десять паралельних запитів, `restore()` один на завантаження, `logout` чистить сесію навіть при відмові сервера, `session.can`, `meFromLogin` | | `ws.test.ts` | адреса й токен у підпротоколі, читання токена **заново** після реконекту, `connecting/online/offline`, затримка 1→2→4→8→15 с зі стелею й скиданням, `stop()` зупиняє реконект і забуває підписку, повторна підписка на мапу після кожного розриву, пошкоджене повідомлення, слухач, що впав | | `hooks.test.tsx` | злиття сплеску подій в один перечит, перечит після паузи, чужі повідомлення ігноруються, розмонтування скасовує заплановане, `useAlerts` без `alerts:read` не питає нічого | Найважливіше тут — **негативні половини**. «Обмін відбувся» нічого не варте без «на `no_session` не відбувся»; «реконект стався» — без «після `stop()` не стався». Саме асиметрична перевірка й пропустила зламаний вхід у тесті ізоляції RLS. ### Сторінки | Файл тесту | Що саме | |---|---| | `routing.test.tsx` | неавторизований на закритій адресі бачить вхід і сторінка **даних не питає**; після входу відкривається та сама адреса; `Guard` без права не малює сторінку й не робить запиту; домівка залежить від ролі; пункт меню без права відсутній; вихід повертає до входу й **розриває сокет** | | `pagepermissions.test.tsx` | пари «є право / немає права» для хостів, користувачів, ролей, алертів; себе й власника прибрати не можна; право, якого немає в тебе, не можна віддати ролі; зміна ролі шле **лише те, що змінилось**; порожня правка запиту не робить; подвійний клік не шле двох PATCH; `409 shared_user` лишає вікно з набраним | | `auditfilter.test.tsx` | фільтр із посилання й той самий фільтр, обраний руками, дають **однаковий** запит і однакову адресу; типовий період в адресу не пишеться; скидання чистить і адресу, і запит; гортання йде у вікні, яке зібрав сервер | | `destructive.test.tsx` | видалення хоста: набір звіряється з сервером, повне видалення заблоковане до підтвердження втрати, без зібраного галочки немає, типово — повне, підпис кнопки називає режим, подвійний клік не дублює запит, помилка не закриває вікно; відкат конфігу: план, непідтримуваний профіль, ручні рядки, `plan_hash`, збережена причина після відмови | Підставні відповіді написані не «схоже», а за формою сервера: помилка — з `writeError()` (`httpapi/server.go`), вхід і `refresh` — з `API.md`, масові дії над хостами — з `devices_bulk.go` і `store.BulkDeviceTarget`, журнал — з `handleListAudit`. Спільні підпори лежать у `src/test/support.ts` — щоб розбіжність із сервером правилась в одному місці й ламала всі тести одразу. Обрано саме ці два вікна не випадково: `Modal` — єдине місце, де самовільне закриття зʼїдає набране в довгій формі, а `ConfirmDialog` — останній екран перед незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття після невдачі, яке читається як «виконано») коштують найдорожче. --- ## Правила для нових тестів 1. **Перевіряй те, що ламається, а не те, що легко перевірити.** Відсоток покриття тут не рахується навмисно: він винагороджує тести на гетери. 2. **Назва тесту — це твердження про поведінку**, а не про виклик функції. «нумерація обох колонок іде без пропусків і без повторів», а не «buildDiff works». 3. **Не закріплюй ваду зеленим тестом.** Якщо поведінка неправильна — місце їй у розділі «знайдене» звіту або в цьому файлі, а не в `expect`. Виняток — коли поточне правило треба зафіксувати свідомо (як сортування `uniq` кодами символів); тоді це має бути сказано в коментарі. 4. **Випадкові дані — лише з детермінованим генератором.** Тест, що падає раз на сто прогонів і не відтворюється, гірший за відсутність тесту: у нього перестають вірити й починають перезапускати CI, доки не позеленіє. 5. **Модульний стан прибирай за собою** (див. `labelLayout.test.ts`): інакше падатиме не той тест, що зламався.