import assert from 'node:assert/strict'; import { after, before, test } from 'node:test'; import { chromium } from 'playwright'; const baseURL = process.env.FRONTEND_TEST_BASE_URL || 'https://localhost'; let browser; let context; let page; const errors = []; before(async () => { browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true }); context = await browser.newContext({ baseURL, ignoreHTTPSErrors: true, viewport: { width: 1440, height: 1000 } }); page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); }); after(async () => { await browser?.close(); assert.deepEqual(errors, [], 'Во время проверки возникли ошибки JavaScript'); }); async function assertStableHover(locator) { if (!await locator.isVisible() || !await locator.isEnabled()) return; await locator.scrollIntoViewIfNeeded(); await page.mouse.move(0, 0); await page.waitForTimeout(250); const before = await locator.boundingBox(); await locator.evaluate(element => { window.hoverLeaves = 0; element.addEventListener('mouseleave', () => { window.hoverLeaves += 1; }, { once: true }); }); // Курсор внутри нижней кромки: старый translateY(-1px) выводил кнопку из-под него. await page.mouse.move(before.x + before.width / 2, before.y + before.height - 0.2); await page.waitForTimeout(500); const after = await locator.boundingBox(); assert(Math.abs(before.x - after.x) < 0.01, 'Элемент сместился по горизонтали при наведении'); assert(Math.abs(before.y - after.y) < 0.01, 'Кнопка сместилась при наведении'); assert.equal(await page.evaluate(() => window.hoverLeaves), 0, 'Кнопка теряет наведение у края'); } async function assertSelectsSynchronized() { await page.waitForTimeout(150); const values = await page.locator('.react-custom-select').evaluateAll(roots => roots.map(root => { const select = root.querySelector('select'); return { id: select.id, text: select.options[select.selectedIndex]?.text || '—', triggerText: root.querySelector('.custom-select-text').textContent, disabled: select.disabled, triggerDisabled: root.querySelector('.custom-select-trigger').disabled }; })); for (const value of values) { assert.equal(value.triggerText, value.text, `Устаревшая подпись ${value.id}`); assert.equal(value.triggerDisabled, value.disabled, `Устаревшее состояние disabled ${value.id}`); } } async function choose(id, name) { const root = page.locator('.react-custom-select').filter({ has: page.locator(id) }); const value = await root.locator('select').evaluate((select, text) => ( Array.from(select.options).find(option => option.text === text)?.value ), name); await root.locator('.custom-select-trigger').click(); await root.getByRole('option', { name, exact: true }).click(); await page.waitForFunction(({ id, value }) => document.querySelector(id)?.value === value, { id, value }); } test('кнопки входа стабильны при наведении в обеих темах', async () => { await page.goto('/'); await page.locator('#btn-submit').waitFor(); await page.waitForTimeout(1000); for (const theme of ['light', 'dark']) { await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme); for (const selector of ['#btn-submit', '.toggle-password', '.theme-toggle']) { await assertStableHover(page.locator(selector)); } } assert.deepEqual(await page.locator('#btn-submit').evaluate(element => ({ position: getComputedStyle(element).position, overflow: getComputedStyle(element).overflow })), { position: 'relative', overflow: 'hidden' }, 'Волна нажатия должна оставаться внутри кнопки'); }); test('вкладки админки сохраняют стабильные кнопки и актуальные селекты', async () => { await page.locator('#username').fill(process.env.FRONTEND_TEST_LOGIN || 'admin'); await page.locator('#password').fill(process.env.FRONTEND_TEST_PASSWORD || 'admin'); await page.locator('#btn-submit').click(); await page.waitForURL('**/admin/**'); await page.locator('.nav-item').first().waitFor(); assert.equal(await page.locator('.menu-toggle').isVisible(), false, 'Десктопная кнопка мобильного меню скрыта'); const tabs = await page.locator('.nav-item').evaluateAll(items => items.map(item => item.dataset.tab)); assert(tabs.length > 0, 'Не появились вкладки админки'); for (const theme of ['light', 'dark']) { await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme); await assertStableHover(page.locator('.sidebar .nav-item').first()); for (const tab of tabs) { await page.locator(`.nav-item[data-tab="${tab}"]`).click(); await page.locator('.tab-loading').waitFor({ state: 'hidden' }); await page.waitForLoadState('networkidle'); await assertSelectsSynchronized(); await assertStableHover(page.locator('.theme-toggle')); const button = page.locator('#app-content .btn:visible').first(); if (await button.count()) await assertStableHover(button); } } await page.locator('.nav-item[data-tab="users"]').click(); await page.locator('#new-role').waitFor({ state: 'attached' }); await choose('#new-role', 'Преподаватель'); await assertSelectsSynchronized(); await choose('#new-role', 'Студент'); await assertSelectsSynchronized(); await choose('#users-role-filter', 'Преподаватель'); assert.equal(await page.locator('#users-role-filter').inputValue(), 'TEACHER'); await choose('#users-role-filter', 'Все роли'); assert.equal(await page.locator('#users-role-filter').inputValue(), ''); await assertSelectsSynchronized(); }); test('мобильное меню админки и настроек открывается и закрывается', async () => { await page.setViewportSize({ width: 390, height: 844 }); for (const path of ['/admin/', '/admin/settings/']) { await page.goto(path); const toggle = page.getByRole('button', { name: 'Открыть меню', exact: true }); await toggle.click(); assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), true); const destination = path === '/admin/' ? page.locator('.nav-item[data-tab="groups"]') : page.locator('.sidebar .nav-item').first(); await destination.click(); await page.locator('.tab-loading').waitFor({ state: 'hidden' }); await page.waitForLoadState('networkidle'); assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false); await toggle.click(); await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 300 } }); assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false); await toggle.click(); await page.keyboard.press('Escape'); assert.equal(await page.locator('.sidebar').evaluate(element => element.classList.contains('open')), false); } }); test('кнопки календаря пожеланий преподавателя не смещаются при наведении', async () => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/'); await page.locator('#username').fill(process.env.FRONTEND_TEST_TEACHER_LOGIN || 'Тестовый преподаватель'); await page.locator('#password').fill(process.env.FRONTEND_TEST_TEACHER_PASSWORD || '1234567890'); await page.locator('#btn-submit').click(); await page.waitForURL('**/teacher/**'); await page.locator('.workspace-tab').filter({ hasText: 'Пожелания' }).click(); await page.locator('.preference-cell').first().waitFor(); for (const theme of ['light', 'dark']) { await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme); await assertStableHover(page.locator('.preference-cell:enabled').first()); } }); test('карточки дашборда не перерисовываются при наведении на боковое меню', async () => { await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto('/'); await page.locator('#username').fill(process.env.FRONTEND_TEST_LOGIN || 'admin'); await page.locator('#password').fill(process.env.FRONTEND_TEST_PASSWORD || 'admin'); await page.locator('#btn-submit').click(); await page.waitForURL('**/admin/**'); await page.locator('.nav-item[data-tab="dashboard"]').click(); await page.locator('.dashboard-metric-card').first().waitFor(); await page.waitForLoadState('networkidle'); await page.locator('[aria-busy="true"]').waitFor({ state: 'hidden' }); for (const theme of ['dark', 'light']) { await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme); await page.mouse.move(0, 0); await page.waitForTimeout(600); const surfaces = await page.locator('.card, .sidebar').evaluateAll(elements => elements.map(element => { const style = getComputedStyle(element); return { filter: style.backdropFilter, transform: style.transform, color: style.backgroundColor }; })); for (const surface of surfaces) { assert.equal(surface.filter, 'none', 'На постоянной поверхности остался фоновой фильтр'); assert.equal(surface.transform, 'none', 'На карточке осталась трансформация после появления'); assert.match(surface.color, /^rgb\(/, 'Фон постоянной поверхности должен быть непрозрачным'); } for (const selector of ['.dashboard-metrics-grid', '.dashboard-monitor-card', '.red-zone-card']) { const card = page.locator(selector); await page.mouse.move(0, 0); const before = await card.screenshot(); await page.locator('.nav-item[data-tab="schedule"]').hover(); await page.waitForTimeout(500); const after = await card.screenshot(); assert(before.equals(after), `${theme}: изображение ${selector} изменилось при наведении на меню`); } } }); test('быстрые изменения фильтра и сортировки сохраняют оба параметра запроса', async () => { await page.locator('.nav-item[data-tab="users"]').click(); await page.waitForLoadState('networkidle'); const requests = []; let releaseFirst; const firstBlocked = new Promise(resolve => { releaseFirst = resolve; }); await page.route('**/api/users/page?**', async route => { requests.push(new URL(route.request().url())); if (requests.length === 1) await firstBlocked; await route.fulfill({ json: { items: [], totalItems: 0, totalPages: 0, page: 0, size: 25 } }); }); try { await choose('#users-role-filter', 'Преподаватель'); const sortedResponse = page.waitForResponse(response => response.url().includes('/api/users/page?') && response.url().includes('sort=username')); await choose('#users-sort', 'По логину'); await sortedResponse; assert.equal(requests.at(-1).searchParams.get('role'), 'TEACHER', 'Сортировка отменила выбранную роль'); assert.equal(requests.at(-1).searchParams.get('sort'), 'username'); releaseFirst(); await page.waitForLoadState('networkidle'); assert.equal(await page.locator('#users-role-filter').inputValue(), 'TEACHER', 'Старый ответ отменил роль'); assert.equal(await page.locator('#users-sort').inputValue(), 'username,asc'); } finally { releaseFirst(); await page.unroute('**/api/users/page?**'); } }); test('сброс фильтров групп отменяет отложенный поиск', async () => { await page.locator('.nav-item[data-tab="groups"]').click(); await page.waitForLoadState('networkidle'); const queries = []; await page.route('**/api/groups/page?**', async route => { queries.push(new URL(route.request().url()).searchParams.get('query')); await route.fulfill({ json: { items: [], totalItems: 0, totalPages: 0, page: 0, size: 25 } }); }); try { await page.locator('#groups-search').fill('Отменённый поиск'); await page.getByRole('button', { name: 'Сбросить фильтры', exact: true }).click(); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); assert.equal(await page.locator('#groups-search').inputValue(), ''); assert(queries.length > 0, 'Сброс не обновил список'); assert(queries.every(query => query === ''), 'После сброса запустился старый поиск'); } finally { await page.unroute('**/api/groups/page?**'); } });