Files
magistr/frontend/tests/browser/ui-regressions.test.mjs
2026-10-02 04:07:56 +03:00

255 lines
14 KiB
JavaScript
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.
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?**');
}
});