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
;
}
createRoot(document.getElementById('root')).render(
<> new Promise((resolve, reject) => {
window.requests.push({ query, resolve, reject });
})}
onSelect={item => { window.selected = item; }}
/>>
);
`,
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('');
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, [], 'Браузер не смог сфокусировать обязательный селект');
});