fix(cli): не отдавать stdin обёртки командам обслуживания
docker compose exec -T подключает stdin и съедал его у скрипта, поданного на stdin (ssh … bash -s): обёртка обрывала такой скрипт после первой команды. Найдено живой проверкой build/cli-live-check.sh (create-user выполнялся, дальше скрипт молча заканчивался). Командам обслуживания stdin не нужен: </dev/null. Плюс e2e web/e2e/query-retry.spec.ts: живая проверка повтора при 429 — запрос участников дважды получает 429 с retry_after_ms и проходит с третьей попытки, раздел «Участники» заполняется без кнопки «Повторить».
This commit is contained in:
@@ -228,10 +228,13 @@ cmd_verify_backup() {
|
|||||||
|
|
||||||
# app_cli выполняет подкоманду обслуживания внутри контейнера приложения
|
# app_cli выполняет подкоманду обслуживания внутри контейнера приложения
|
||||||
# (AGENT.md 10.7): команды, которым нужна БД, живут в Go-бинарнике, а shell-обёртка
|
# (AGENT.md 10.7): команды, которым нужна БД, живут в Go-бинарнике, а shell-обёртка
|
||||||
# доставляет их с хоста.
|
# доставляет их с хоста. `</dev/null` обязателен: `docker compose exec -T`
|
||||||
|
# подключает stdin и без этого съедает его у вызывающего скрипта — а обёртку
|
||||||
|
# часто зовут из скрипта, поданного на stdin (`ssh … bash -s`), и он обрывался
|
||||||
|
# после первой же команды.
|
||||||
app_cli() {
|
app_cli() {
|
||||||
require_stack
|
require_stack
|
||||||
${COMPOSE} exec -T app glchat "$@"
|
${COMPOSE} exec -T app glchat "$@" </dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# cmd_reindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
|
# cmd_reindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
apiAs,
|
||||||
|
createGuild,
|
||||||
|
displayName,
|
||||||
|
dropStaleGuilds,
|
||||||
|
ensureProbe,
|
||||||
|
openApp,
|
||||||
|
openGuild,
|
||||||
|
stepUp,
|
||||||
|
type Account,
|
||||||
|
type ProbeLogin,
|
||||||
|
type Session,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лимит частоты API (AGENT.md 8.6): запрос, получивший 429, клиент повторяет
|
||||||
|
* сам, выждав паузу `retry_after_ms` из ответа сервера. До этой правки раздел
|
||||||
|
* «Участники» под нагрузкой оставался пустым с кнопкой «Повторить» — именно
|
||||||
|
* так падал пункт 3 матрицы 11.6 в финальном прогоне 26.09.
|
||||||
|
*
|
||||||
|
* Проверка живая: запрос участников перехватывается в браузере и дважды
|
||||||
|
* получает 429 с настоящим конвертом ошибки, после чего уходит на стенд.
|
||||||
|
* Раздел обязан показать участника без действий пользователя.
|
||||||
|
*
|
||||||
|
* GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \
|
||||||
|
* PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
|
||||||
|
* npx playwright test e2e/query-retry.spec.ts
|
||||||
|
*/
|
||||||
|
const enabled = process.env.GLCHAT_E2E_REALTIME === '1';
|
||||||
|
const API = '/api/v1';
|
||||||
|
const PREFIX = 'Retry E2E ';
|
||||||
|
const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' };
|
||||||
|
|
||||||
|
let owner: Account;
|
||||||
|
|
||||||
|
test.describe('лимит частоты: клиент повторяет запрос без действий пользователя', () => {
|
||||||
|
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1');
|
||||||
|
|
||||||
|
test.beforeAll(async ({ playwright }) => {
|
||||||
|
const ownerApi = await playwright.request.newContext({ baseURL: process.env.GLCHAT_URL });
|
||||||
|
try {
|
||||||
|
const probe = await ensureProbe(ownerApi, OWNER_LOGIN.username, OWNER_LOGIN.password);
|
||||||
|
const name = await displayName(ownerApi);
|
||||||
|
await stepUp(ownerApi, OWNER_LOGIN.password);
|
||||||
|
await dropStaleGuilds(ownerApi, PREFIX);
|
||||||
|
owner = {
|
||||||
|
...probe,
|
||||||
|
displayName: name,
|
||||||
|
state: await ownerApi.storageState(),
|
||||||
|
browserState: await ownerApi.storageState(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await ownerApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('429 на списке участников: раздел заполняется сам', async ({ browser, playwright }) => {
|
||||||
|
const ownerApi = await apiAs(playwright, owner.state);
|
||||||
|
let session: Session | null = null;
|
||||||
|
let guildId = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await createGuild(ownerApi, `${PREFIX}${String(Date.now() % 100_000)}`);
|
||||||
|
guildId = guild.id;
|
||||||
|
session = await openApp(browser, owner.browserState);
|
||||||
|
const page = session.page;
|
||||||
|
|
||||||
|
// Перехват ставим до первого запроса участников: его делает уже список
|
||||||
|
// участников в ленте сервера, и без перехвата данные легли бы в кэш
|
||||||
|
// раньше проверки. Запрос обязан получить 429 дважды и уйти на стенд
|
||||||
|
// только с третьей попытки.
|
||||||
|
let blocked = 0;
|
||||||
|
await page.route('**/api/v1/guilds/*/members*', async (route) => {
|
||||||
|
if (blocked >= 2) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocked += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: 'rate_limited',
|
||||||
|
message: 'Слишком много запросов. Подождите немного.',
|
||||||
|
retry_after_ms: 400,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Открываем настройки сервера так же, как пользователь: выбрать сервер в
|
||||||
|
// рейке и нажать ⚙. Адресный переход `/settings/servers/<id>` здесь не
|
||||||
|
// годится: он на мгновение монтирует вкладку «Мои серверы», та заводит
|
||||||
|
// запрос участников по тому же ключу и, размонтируясь, обрывает его
|
||||||
|
// повтор — запрос остаётся в состоянии ошибки.
|
||||||
|
await openGuild(page, guild.name);
|
||||||
|
await page.getByRole('button', { name: 'Настройки сервера' }).click();
|
||||||
|
await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Кнопка «Повторить» не нажимается: строку даёт автоматический повтор.
|
||||||
|
const row = page
|
||||||
|
.getByTestId('guild-members')
|
||||||
|
.locator('li', { hasText: `@${owner.username}` });
|
||||||
|
await expect(row).toContainText(owner.displayName, { timeout: 30_000 });
|
||||||
|
expect(blocked, 'оба ответа 429 были отданы перехватчиком').toBe(2);
|
||||||
|
} finally {
|
||||||
|
await session?.context.close();
|
||||||
|
if (guildId !== '') {
|
||||||
|
await stepUp(ownerApi, OWNER_LOGIN.password).catch(() => undefined);
|
||||||
|
await ownerApi.delete(`${API}/guilds/${guildId}`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await ownerApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user