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
{ event.preventDefault(); window.submitted = true; }}> setValue(event.target.value)}>
; } 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, [], 'Браузер не смог сфокусировать обязательный селект'); });