баги
This commit is contained in:
152
frontend/tests/browser/shared-controls.test.mjs
Normal file
152
frontend/tests/browser/shared-controls.test.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { build } from 'esbuild';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
let browser;
|
||||
let bundle;
|
||||
|
||||
before(async () => {
|
||||
const result = await build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AsyncCombobox } from './react/shared/ui/AsyncCombobox.jsx';
|
||||
import { CustomSelect } from './react/shared/ui/CustomSelect.jsx';
|
||||
window.requests = [];
|
||||
window.selected = null;
|
||||
window.submitted = false;
|
||||
function RequiredSelectForm() {
|
||||
const [value, setValue] = React.useState('');
|
||||
return <form onSubmit={event => { event.preventDefault(); window.submitted = true; }}>
|
||||
<label htmlFor="required-select">Обязательный выбор</label>
|
||||
<CustomSelect id="required-select" name="choice" required value={value} onChange={event => setValue(event.target.value)}>
|
||||
<option value="">Выберите вариант</option>
|
||||
<option value="valid">Правильный вариант</option>
|
||||
</CustomSelect>
|
||||
<button type="submit">Отправить</button>
|
||||
</form>;
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<><AsyncCombobox
|
||||
selectedLabel="Выбранная группа"
|
||||
selectedValue="1"
|
||||
load={query => new Promise((resolve, reject) => {
|
||||
window.requests.push({ query, resolve, reject });
|
||||
})}
|
||||
onSelect={item => { window.selected = item; }}
|
||||
/><RequiredSelectForm /></>
|
||||
);
|
||||
`,
|
||||
resolveDir: process.cwd(),
|
||||
loader: 'jsx'
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'iife',
|
||||
jsx: 'automatic',
|
||||
define: { 'process.env.NODE_ENV': '"test"' }
|
||||
});
|
||||
bundle = result.outputFiles[0].text;
|
||||
browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium' });
|
||||
});
|
||||
|
||||
after(async () => { await browser?.close(); });
|
||||
|
||||
async function fixture(t) {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
t.after(async () => {
|
||||
await page.close();
|
||||
assert.deepEqual(errors, [], 'Компонент вызвал ошибку JavaScript');
|
||||
});
|
||||
await page.setContent('<style>.async-combobox-menu { position: absolute; }</style><div id="root"></div><button id="outside">Другой элемент</button>');
|
||||
await page.addScriptTag({ content: bundle });
|
||||
const input = page.getByRole('combobox', { name: 'Поисковый список', exact: true });
|
||||
await input.focus();
|
||||
await page.waitForFunction(() => window.requests.length === 1);
|
||||
return { page, input };
|
||||
}
|
||||
|
||||
test('устаревший ответ или ошибка не заменяют новый поиск во время задержки', async t => {
|
||||
for (const outcome of ['resolve', 'reject']) {
|
||||
const { page, input } = await fixture(t);
|
||||
await input.fill('Новая группа');
|
||||
await page.evaluate(outcome => {
|
||||
const request = window.requests[0];
|
||||
if (outcome === 'resolve') request.resolve([{ id: 2, name: 'Устаревшая группа' }]);
|
||||
else request.reject(new Error('Ошибка старого запроса'));
|
||||
}, outcome);
|
||||
assert.equal(await page.getByRole('option').count(), 0, 'Устаревший вариант доступен для выбора');
|
||||
await page.getByText('Поиск...', { exact: true }).waitFor();
|
||||
await page.waitForFunction(() => window.requests.length === 2);
|
||||
await page.evaluate(() => window.requests[1].resolve([{ id: 3, name: 'Новая группа' }]));
|
||||
await page.getByRole('option', { name: 'Новая группа' }).waitFor();
|
||||
assert.equal(await page.getByRole('option').count(), 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Escape из списка возвращает фокус и оставляет меню закрытым', async t => {
|
||||
const { page, input } = await fixture(t);
|
||||
await page.evaluate(() => window.requests[0].resolve([{ id: 2, name: 'Группа' }]));
|
||||
await page.getByRole('option').waitFor();
|
||||
await input.press('ArrowDown');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(await input.evaluate(element => element === document.activeElement), true);
|
||||
assert.equal(await page.evaluate(() => window.requests.length), 1, 'Возврат фокуса запустил лишний поиск');
|
||||
});
|
||||
|
||||
test('возврат стрелкой вверх сохраняет поисковый текст и результаты', async t => {
|
||||
const { page, input } = await fixture(t);
|
||||
await input.fill('Запрос');
|
||||
await page.waitForFunction(() => window.requests.length === 2);
|
||||
await page.evaluate(() => window.requests[1].resolve([{ id: 2, name: 'Результат' }]));
|
||||
await page.getByRole('option').waitFor();
|
||||
await input.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
assert.equal(await input.inputValue(), 'Запрос');
|
||||
assert.equal(await page.evaluate(() => window.requests.length), 2);
|
||||
});
|
||||
|
||||
test('ввод после Escape открывает поиск, а уход фокуса отменяет отложенный запрос', async t => {
|
||||
const { page, input } = await fixture(t);
|
||||
await input.press('Escape');
|
||||
await input.fill('Новый запрос');
|
||||
assert.equal(await input.getAttribute('aria-expanded'), 'true');
|
||||
await page.locator('#outside').focus();
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(await page.evaluate(() => window.requests.length), 1);
|
||||
});
|
||||
|
||||
test('выбор варианта сохраняет значение и возвращает фокус без нового поиска', async t => {
|
||||
const { page, input } = await fixture(t);
|
||||
await page.evaluate(() => window.requests[0].resolve([{ id: 2, name: 'Группа' }]));
|
||||
await page.getByRole('option').click();
|
||||
assert.deepEqual(await page.evaluate(() => window.selected), { id: 2, name: 'Группа' });
|
||||
assert.equal(await input.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(await input.evaluate(element => element === document.activeElement), true);
|
||||
assert.equal(await page.evaluate(() => window.requests.length), 1);
|
||||
});
|
||||
|
||||
test('обязательный селект показывает ошибку, получает фокус и разрешает отправку после выбора', async t => {
|
||||
const { page } = await fixture(t);
|
||||
const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
const select = page.getByRole('combobox', { name: 'Обязательный выбор', exact: true });
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => window.submitted), false);
|
||||
await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === 'Обязательный выбор');
|
||||
assert.equal(await select.evaluate(element => element === document.activeElement), true);
|
||||
assert.equal(await select.getAttribute('aria-invalid'), 'true');
|
||||
await page.getByRole('alert').filter({ hasText: 'Выберите значение' }).waitFor();
|
||||
await select.click();
|
||||
await page.getByRole('option', { name: 'Правильный вариант', exact: true }).click();
|
||||
assert.equal(await select.getAttribute('aria-invalid'), null);
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => window.submitted), true);
|
||||
assert.deepEqual(errors, [], 'Браузер не смог сфокусировать обязательный селект');
|
||||
});
|
||||
254
frontend/tests/browser/ui-regressions.test.mjs
Normal file
254
frontend/tests/browser/ui-regressions.test.mjs
Normal file
@@ -0,0 +1,254 @@
|
||||
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?**');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user