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:
2026-09-26 18:40:46 +03:00
parent b9d458e133
commit 8cf6bedf58
2 changed files with 123 additions and 2 deletions
+5 -2
View File
@@ -228,10 +228,13 @@ cmd_verify_backup() {
# app_cli выполняет подкоманду обслуживания внутри контейнера приложения
# (AGENT.md 10.7): команды, которым нужна БД, живут в Go-бинарнике, а shell-обёртка
# доставляет их с хоста.
# доставляет их с хоста. `</dev/null` обязателен: `docker compose exec -T`
# подключает stdin и без этого съедает его у вызывающего скрипта — а обёртку
# часто зовут из скрипта, поданного на stdin (`ssh … bash -s`), и он обрывался
# после первой же команды.
app_cli() {
require_stack
${COMPOSE} exec -T app glchat "$@"
${COMPOSE} exec -T app glchat "$@" </dev/null
}
# cmd_reindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
+118
View File
@@ -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();
}
});
});