153 lines
8.3 KiB
JavaScript
153 lines
8.3 KiB
JavaScript
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, [], 'Браузер не смог сфокусировать обязательный селект');
|
||
});
|