perf: бот-соавтор и скролл при соавторе

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 23:20:48 +04:00
parent a1251436c3
commit 419a20e950
5 changed files with 412 additions and 5 deletions

View File

@ -30,7 +30,7 @@
## Ограничения и известные факты
- Машина заказчика сильно медленнее машины разработчика. Фронт мерить с замедлением CPU через CDP (`Emulation.setCPUThrottlingRate`) в двух режимах — 4× и 6×, а также без замедления для сравнения.
- Замеры без троттлинга CPU — решение человека 2026-10-25 (слабые места видны и на 1×); код throttle оставлен в perf/e2e/lib/cdp.ts для ручного включения.
- Прод-сервер — около 1 vCPU / 4 GB (`raisa_config.yml` устарел). Стенд по указанию владельца ресурсами **не ограничивается**. Цифры бэка в отчёте помечаются как полученные на быстром железе, без лимитов.
- БД — только Postgres, схема `v3`. SQLite не годится: миграции на PL/pgSQL. Postgres 16 в docker (образ доступен).
- Зависимости: npm берётся из публичного registry, для Python — `api/requirements-dev.txt`, без корпоративного Nexus. `raisa-fastapi-protected-api` не нужен (закомментирован в коде).
@ -58,8 +58,8 @@
- Генератор создаёт пользователей, оргединицы и этапы (`form_phase`) с окнами, открытыми на дату замера.
### Сценарии фронта (Playwright + CDP)
1. **Скролл.** Для каждого листа каждой формы (FORM_1: AHR/CAP/OPER/SMETA с direction; FORM_2: основные листы и сателлиты; FORM_4: листы и STRUCTURE) и отчёта проекта (FORM_3): вертикальный скролл от начала до конца и обратно, горизонтальный по всем колонкам. Метрики: кадры и потерянные кадры, long tasks (>50 мс) и их суммарное время, время скриптов, стиля и layout из trace. Каждый прогон — в трёх режимах CPU.
1a. **Скролл при живом соавторе.** Тот же скролл на базовом масштабе в режиме 6×, пока второй пользователь (скрипт через WebSocket, без браузера) правит ячейки этого листа с заданной частотой (1 и 5 правок/с). Входящие `cell_updated`/`cell_edit_start`/`row_added` обрабатываются в `RealtimeContext.js:105–160` и могут перерисовывать таблицу во время скролла. Это один фоновый соавтор, а не нагрузочный тест.
1. **Скролл.** Для каждого листа каждой формы (FORM_1: AHR/CAP/OPER/SMETA с direction; FORM_2: основные листы и сателлиты; FORM_4: листы и STRUCTURE) и отчёта проекта (FORM_3): вертикальный скролл от начала до конца и обратно, горизонтальный по всем колонкам. Метрики: кадры и потерянные кадры, long tasks (>50 мс) и их суммарное время, время скриптов, стиля и layout из trace. Каждый прогон — на CPU 1×.
1a. **Скролл при живом соавторе.** Тот же скролл на базовом масштабе на CPU 1×, пока второй пользователь (скрипт через WebSocket, без браузера) правит ячейки этого листа с заданной частотой (1 и 5 правок/с). Входящие `cell_updated`/`cell_edit_start`/`row_added` обрабатываются в `RealtimeContext.js:105–160` и могут перерисовывать таблицу во время скролла. Это один фоновый соавтор, а не нагрузочный тест.
1b. **Своды** (вторым приоритетом после листов форм): свод 1 (`pages/SvodPage/SummaryTable.jsx`), свод 2 (`pages/Svod2Page/Svod2Page.jsx`), `ProjectSummariesPage` — скролл и смена фильтров; данные генератора должны давать правдоподобно заполненные своды по всем оргединицам. SQL сводных функций — в замеры бэка.
2. **Модалки.** Перечень строится по коду: все модалки и диалоги, доступные со страниц форм, задачи и проекта — этапы (`SettingModal`), добавление года, экспорт, редактирование проекта, создание проекта и т.д. Модалки админки — вторым приоритетом. Метрика: от клика до первого кадра с видимой модалкой (`requestAnimationFrame` после появления в DOM), long tasks в этом окне, какие компоненты перерисовались (по профилирующей сборке). Отдельно — открытие модалки поверх открытого большого листа, если такое есть.
3. **Память.** Длительный сценарий — циклы «открыть лист → скролл → правка ячеек через WebSocket → уйти на список задач → открыть другой лист», около 20 циклов. Метрики: `JSHeapUsedSize` по циклам после принудительного GC, heap snapshots в начале, середине и конце, detached DOM nodes, число слушателей событий, число открытых WebSocket. Рост между снимками разбирается до retainers.
@ -76,7 +76,7 @@
### Отчёт `perf/REPORT.md`
По-русски, для заказчика и команды:
1. Короткое резюме: что тормозит, почему и насколько, топ-5 мест.
2. Методика и окружение: железо, версии, масштаб данных, режимы CPU, оговорка про прод-ресурсы.
2. Методика и окружение: железо, версии, масштаб данных, CPU 1× без троттлинга, оговорка про прод-ресурсы.
3. Таблица проблемных мест: № | место (`file:line`) | симптом | доказательство | влияние | приоритет (P1–P3) | подтверждено замером (да/нет).
4. Подробности по каждому месту.
5. Что не удалось проверить и почему.
@ -95,7 +95,7 @@
|---|---|---|---|
| 2026-10-25 | Стенд коммитится в `perf/` в репозиторий | Нужен повтор замеров «после» на тех же данных | Лишний каталог в репо; удаляется одним коммитом |
| 2026-10-25 | «Стенд не ограничивай» понято как «без лимитов CPU и памяти у бэка и БД» | Ближайшая трактовка реплики владельца сразу после обсуждения лимитов | Цифры бэка оптимистичнее прода; оговорено в отчёте |
| 2026-10-25 | Замедление CPU фронта 4× и 6× | Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна | Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется |
| 2026-10-25 | Замедление CPU фронта 4× и 6× (отменено решением человека 2026-10-25, см. раздел Ограничения) | Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна | Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется |
| 2026-10-25 | Отчёт — markdown в `perf/REPORT.md` | Формат для заказчика владелец не выбрал; markdown легко превратить в документ | Переоформление отчёта |
| 2026-10-25 | Масштаб данных задаёт исполнитель, плюс прогон на удвоенном масштабе | Реальные объёмы неизвестны | Неверная абсолютная величина; удвоение показывает чувствительность |
| 2026-10-25 | `skip_specs: true` | Change не меняет поведение системы | — |
@ -140,3 +140,13 @@
| 2026-10-25 | Если строки появились, но их число не растёт 20 с и их < 20 — замер `incomplete:true` + `skip_reason` сразу, без ожидания `PERF_LOAD_TIMEOUT_MS` | Листы манифеста с 1 строкой (OTCH9F) иначе ждали бы 15 мин на каждый замер | Медленная отрисовка при простое > 20 с была бы принята за «мало строк» |
| 2026-10-25 | Таймаут теста: 45 мин (`PERF_TEST_TIMEOUT_MS`), для свода 2 — 3 ч (`PERF_TEST_TIMEOUT_SVOD2_MS`) | Свод 2: загрузка до 15 мин + скролл на CPU 6 ≈ 40 мин; общий 45 мин обрывал бы его | Слишком большой таймаут задержит обнаружение зависания |
| 2026-10-25 | Отсутствие переполнения по оси даёт `incomplete:true`, `skip_reason:'no_overflow'` (а не «зелёную» запись с 0 шагов); фильтр без изменения содержимого — `filter_no_effect`; повтор с трейсом помечен `metrics.traced=1` | Пустой замер не должен попасть в статистику; трейс искажает замер, Task 10 исключает такие записи | Нужно не забыть исключить traced=1 при агрегации |
| 2026-10-25 | Цели «скролла при соавторе» — самый большой rf-лист каждой доступной формы: FORM_2 (форма 7, AHR, 1154 строки) и FORM_4 (форма 13, STRUCTURE, 775); FORM_1 не берётся | FORM_1 принадлежит ССП: у `perf_rf`/`perf_rf_coauthor` его нет (GET листа форм 1 даёт 404) | FORM_1 без замера при соавторе |
| 2026-10-25 | Тест бота `test_coauthor_first_edit_echoed` идёт на первом rf-листе манифеста, а не на «первом листе FORM_1» | Тот же довод: соавтор не видит FORM_1 | Тест не проверяет FORM_1 |
| 2026-10-25 | Бот правит только числовые колонки (без `comment`/`justification`/`date`/`note` в имени) у строк `INPUT`, значение — случайное целое 1000–999999; колонки берёт из META.editable GET листа | Типы текстовых/датовых колонок не проверять; значения не имеют смысла, стенд тестовый | Данные листа меняются в ходе прогонов (суммы пересчитываются) |
| 2026-10-25 | «Эхо» = `cell_updated` с `is_self:true` без поля `error`; отказ сервера считается в `coauthor_errors`, не в `coauthor_echoed`; бот до первого эха повторяет правку (другие ячейки) раз в 0,7 с, но не дольше 5 с с запуска, иначе код 2 | Сервер при отказе возвращает то же событие с `error`; одиночный отказ по одной ячейке не должен ронять сценарий | При постоянных отказах сценарий падает через 5 с |
| 2026-10-25 | `--token` бота подменяет токен только для WS; чтение листа идёт по настоящему логину | Нужно проверить неверный токен (Review Focus), не сломав получение ячеек | Нет |
| 2026-10-25 | Запись `scroll_coauthor` не пишется (тест падает), если `coauthor_echoed==0` или страница автора не получила ни одного чужого `cell_updated` (`page_ws_updates==0`) | Иначе сценарий измерил бы «без соавтора» | Ложные падения при сбое WS-подключения страницы |
| 2026-10-25 | Target записи — `<form_type>/<sheet>/<direction\|null>/coauthor<rate>@y`; бот запускается после загрузки листа и ждёт READY до старта замера; бот работает до SIGTERM после скролла; в метрики также `coauthor_errors`, `page_ws_updates`; трейс только на повторе 0 (`traced=1`) | Rate 1 и 5 не должны смешиваться в одну группу агрегатора | Task 10 исключает traced=1 |
| 2026-10-25 | Только вертикальный скролл (ось y), роль `rf` | Бриф: «скролл при соавторе»; горизонтальная ось не зависит от потока входящих правок | Нет замера оси x при соавторе |
| 2026-10-25 | Вспомогательный `waitRows` продублирован в `scroll-coauthor.spec.ts`, а не вынесен в lib/ | Не трогать `scroll.spec.ts` Task 5; функция короткая | Дубли при правке |
| 2026-10-25 | Фильтр свода по умолчанию мерится на `max(PERF_CPU_RATES)` (при умолчании `1`), имя теста `filter cpu<N>` | Троттлинг отключён по умолчанию, но `PERF_CPU_RATES=1,4,6` должен вести себя как прежде | Нет |

View File

@ -0,0 +1,157 @@
/**
* Сценарий «скролл при живом соавторе» (Task 6): автор perf_rf (role rf) скроллит лист, пока бот perf_rf_coauthor
* правит ячейки того же листа по WebSocket с частотой rate правок/с (perf/perfkit/coauthor.py).
*
* Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `<form_type>/<sheet>/<direction|null>`), PERF_RUN_ID,
* PERF_REPEATS (3), PERF_CPU_RATES ('1'; троттлинг отключён решением человека, PERF_CPU_RATES=6 включает вручную), PERF_COAUTHOR_RATES ('1,5'), PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS,
* PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python).
* Цели: самый большой лист каждой формы из доступных роли rf (owner=rf).
* Запись не пишется (тест падает), если бот не получил эха своих правок или страница не увидела ни одного cell_updated.
*/
import { test, type Page } from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { loadManifest, scale, PERF_DIR, type Sheet } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe';
import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { append, runDir } from '../lib/results';
import { scrollThrough } from '../lib/scroll';
const CONTAINER = '.MuiTableContainer-root';
const MIN_ROWS = 20;
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000);
const PYTHON = process.env.PERF_PYTHON ?? path.join(PERF_DIR, '.venv', 'bin', 'python');
const BOT_READY_MS = 30_000;
const envList = (n: string, d: string[]) => (process.env[n] ? process.env[n]!.split(',').map((s) => s.trim()).filter(Boolean) : d);
const CPU_RATES = envList('PERF_CPU_RATES', ['1']).map(Number);
const CO_RATES = envList('PERF_COAUTHOR_RATES', ['1', '5']).map(Number);
const REPEATS = Number(process.env.PERF_REPEATS ?? 3);
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const TARGET_RES = envList('PERF_TARGETS', ['*']).map(globRe);
const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
/** Самый большой лист каждой формы (form_type) среди owner=rf. */
function targets(): Sheet[] {
const best = new Map<string, Sheet>();
for (const s of loadManifest().sheets) {
if (s.owner !== 'rf') continue;
const cur = best.get(s.form_type);
if (!cur || s.rows > cur.rows) best.set(s.form_type, s);
}
return [...best.values()].filter((s) => TARGET_RES.some((r) => r.test(sheetName(s))));
}
async function waitRows(page: Page): Promise<number> {
const t0 = Date.now();
for (;;) {
const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
if (n >= MIN_ROWS) return n;
if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`);
await page.waitForTimeout(500);
}
}
interface Bot { proc: ChildProcess; stop: () => Promise<{ sent: number; echoed: number; errors: number; code: number | null }> }
/** Запускает бота и ждёт строку READY (эхо первой правки). Нет READY за BOT_READY_MS или выход бота - исключение. */
async function startBot(s: Sheet, rate: number): Promise<Bot> {
const args = ['-m', 'perfkit.coauthor', '--form-id', String(s.form_id), '--sheet', s.sheet, '--rate', String(rate), '--duration', '86400', '--scale', String(scale())];
if (s.direction) args.push('--direction', s.direction);
const proc = spawn(PYTHON, args, { cwd: PERF_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = '';
let exited: number | null | undefined;
const exitP = new Promise<void>((res) => proc.on('exit', (c) => { exited = c; res(); }));
proc.stdout!.on('data', (d) => { out += d; });
proc.stderr!.on('data', (d) => { err += d; });
const t0 = Date.now();
while (!out.includes('READY')) {
if (exited !== undefined) throw new Error(`бот-соавтор завершился (код ${exited}) до эха первой правки: ${err.trim()}`);
if (Date.now() - t0 > BOT_READY_MS) { proc.kill('SIGKILL'); throw new Error(`бот-соавтор не получил эха за ${BOT_READY_MS} мс: ${err.trim()}`); }
await new Promise((r) => setTimeout(r, 100));
}
return {
proc,
stop: async () => {
if (exited === undefined) proc.kill('SIGTERM');
await Promise.race([exitP, new Promise((r) => setTimeout(r, 10_000))]);
if (exited === undefined) proc.kill('SIGKILL');
const last = out.trim().split('\n').filter((l) => l.startsWith('{')).pop();
const st = last ? JSON.parse(last) : { sent: 0, echoed: 0, errors: 0 };
return { ...st, code: exited ?? null };
},
};
}
async function measure(browser: import('@playwright/test').Browser, s: Sheet, cpu: number, rate: number, repeat: number) {
const m = loadManifest();
const name = sheetName(s);
const targetName = `${name}/coauthor${rate}@y`;
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const artifacts: string[] = [];
let bot: Bot | undefined;
try {
await loginAs(ctx, m.users['rf']);
await ctx.addInitScript(PROBE_SCRIPT);
const page = await ctx.newPage();
// сколько cell_updated другого пользователя дошло до страницы автора (WS должен быть подключён)
let pageUpdates = 0;
page.on('websocket', (ws) => ws.on('framereceived', (f) => {
const p = typeof f.payload === 'string' ? f.payload : f.payload.toString();
if (!p.includes('cell_updated')) return;
try { const d = JSON.parse(p); if (d.event === 'cell_updated' && d.is_self === false && !d.error) pageUpdates++; } catch { /* не JSON */ }
}));
await page.goto(WEB + s.url);
const rows = await waitRows(page);
await page.waitForTimeout(500);
await throttle(page, cpu);
bot = await startBot(s, rate);
const trace = repeat === 0;
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
await page.evaluate(() => (window as any).__perf.start());
const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS);
const probe: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop());
if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); }
const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
const co = await bot.stop();
bot = undefined;
if (co.echoed === 0) throw new Error(`coauthor_echoed=0 (sent=${co.sent}, errors=${co.errors}, код бота ${co.code}): сценарий измерил бы «без соавтора»`);
if (pageUpdates === 0) throw new Error('страница автора не получила ни одного cell_updated соавтора (WS не подключён?)');
const reason = r.reason ?? (r.complete ? undefined : 'scroll_incomplete');
append({
scenario: 'scroll_coauthor', target: targetName, role: 'rf', scale: scale(), cpu_rate: cpu, repeat,
metrics: {
...probe, axis: 0, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, steps: r.steps,
coauthor_rate: rate, coauthor_sent: co.sent, coauthor_echoed: co.echoed, coauthor_errors: co.errors, page_ws_updates: pageUpdates,
},
artifacts,
incomplete: !r.complete,
...(reason ? { skip_reason: reason } : {}),
});
} finally {
if (bot) await bot.stop().catch(() => {});
await ctx.close();
}
}
test.describe('scroll_coauthor', () => {
for (const s of targets()) {
for (const cpu of CPU_RATES) {
for (const rate of CO_RATES) {
for (let repeat = 0; repeat < REPEATS; repeat++) {
test(`${sheetName(s)} rf y cpu${cpu} coauthor${rate} #${repeat}`, async ({ browser }) => {
test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000));
await measure(browser, s, cpu, rate, repeat);
});
}
}
}
}
});

195
perf/perfkit/coauthor.py Normal file
View File

@ -0,0 +1,195 @@
"""Бот-соавтор: правит редактируемые ячейки листа по WebSocket с заданной частотой.
CLI: python -m perfkit.coauthor --form-id N --sheet S [--direction D] --rate 1|5 --duration SEC
[--user rf_coauthor] [--scale 1] [--token T]
Протокол (api/src/api/v1/websocket.py, web/.../RealtimeContext.js):
ws://HOST/api/v1/ws/form/{id}/sheet/{sheet}?direction=D
-> {"event":"user_login","data":{"token":...}}
-> {"event":"cell_edit_start","data":{"line_id","column"}} (эхо отправителю, блокировка ячейки)
-> {"event":"cell_updated","data":{"line_id","line_id_code","column","value"}}
(эхо отправителю с "is_self": true; при отказе то же событие с полем "error")
-> {"event":"cell_edit_end","data":{"line_id","column"}}
Редактируемые колонки берутся из строки META листа (GET sheet: data.editable, роль EXECUTOR_RF),
правятся числовые колонки у строк типа INPUT.
Коды выхода: 0 - были подтверждённые эхом правки; 2 - нет эха первой правки за --ack-timeout (в т.ч. неверный токен
или закрытое соединение); 3 - ошибка подготовки (вход по HTTP, нет редактируемых ячеек).
Печатает "READY" после первого эха и в конце последней строкой JSON {"sent","echoed","errors"}.
SIGTERM/SIGINT завершают работу штатно (итог печатается).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import signal
import sys
import time
from urllib.parse import urlencode
import httpx
import websockets
from . import RESULTS_DIR
from .manifest import load as load_manifest
API = os.environ.get("PERF_API", "http://localhost:8000")
TEXT_HINTS = ("comment", "justification", "date", "note") # нечисловые колонки не трогаем
def log(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def pick_cells(rows: list[dict]) -> list[tuple[int, str]]:
"""(line_id, column) для INPUT-строк в числовых редактируемых колонках META."""
meta = next((r for r in rows if r.get("row_type") == "META"), None)
if not meta:
return []
cols = [e["column"] for e in meta["data"].get("editable", []) if not any(h in e["column"] for h in TEXT_HINTS)]
cells = []
for r in rows:
if r.get("row_type") != "INPUT":
continue
d = r.get("data", {})
lid = d.get("line_id") or d.get("id")
if lid is None:
continue
cells.extend((lid, c) for c in cols)
return cells
async def fetch_cells(args) -> list[tuple[int, str]]:
m = load_manifest(RESULTS_DIR / f"manifest-s{args.scale}.json")
u = m.users[args.user]
async with httpx.AsyncClient(base_url=API + "/api/v1", timeout=120) as c:
r = await c.post("/auth/login", json={"username": u.username, "password": u.password})
r.raise_for_status()
tok = r.json()["access_token"]
params = {"direction": args.direction} if args.direction else None
r = await c.get(f"/form/{args.form_id}/sheet/{args.sheet}", params=params, headers={"Authorization": f"Bearer {tok}"})
r.raise_for_status()
args._http_token = tok
return pick_cells(r.json()["result"])
def ws_url(args) -> str:
base = API.replace("http://", "ws://").replace("https://", "wss://")
q = "?" + urlencode({"direction": args.direction}) if args.direction else ""
return f"{base}/api/v1/ws/form/{args.form_id}/sheet/{args.sheet}{q}"
async def run(args) -> int:
t0 = time.monotonic()
try:
cells = await fetch_cells(args)
except Exception as e: # noqa: BLE001
log(f"coauthor: подготовка не удалась: {e!r}")
return 3
if not cells:
log("coauthor: нет редактируемых ячеек для роли пользователя")
return 3
st = {"sent": 0, "echoed": 0, "errors": 0}
first_echo = asyncio.Event()
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
token = args.token if args.token is not None else args._http_token
try:
ws = await asyncio.wait_for(websockets.connect(ws_url(args), max_size=None), 5)
except Exception as e: # noqa: BLE001
log(f"coauthor: WS недоступен: {e!r}")
return 2
async def reader():
try:
async for raw in ws:
msg = json.loads(raw)
if msg.get("event") != "cell_updated" or not msg.get("is_self"):
continue
if msg.get("error"):
st["errors"] += 1
if st["errors"] <= 3:
log(f"coauthor: отказ сервера: {msg['error']}")
continue
st["echoed"] += 1
if not first_echo.is_set():
first_echo.set()
print("READY", flush=True)
except websockets.ConnectionClosed as e:
log(f"coauthor: соединение закрыто: {e.rcvd}")
finally:
stop.set()
rd = asyncio.create_task(reader())
await ws.send(json.dumps({"event": "user_login", "data": {"token": token}}))
async def edit():
lid, col = random.choice(cells)
key = {"line_id": lid, "column": col}
await ws.send(json.dumps({"event": "cell_edit_start", "data": key}))
await ws.send(json.dumps({"event": "cell_updated", "data": {**key, "line_id_code": lid, "value": random.randint(1000, 999_999)}}))
st["sent"] += 1
await ws.send(json.dumps({"event": "cell_edit_end", "data": key}))
rc = 0
try:
# фаза 1: до первого эха, не дольше ack_timeout с момента старта процесса
while not first_echo.is_set():
if stop.is_set() or time.monotonic() - t0 > args.ack_timeout:
log("coauthor: нет эха первой правки (неверный токен, нет прав или окно закрыто)")
rc = 2
break
await edit()
try:
await asyncio.wait_for(first_echo.wait(), 0.7)
except asyncio.TimeoutError:
pass
if rc == 0:
# фаза 2: ровная частота rate правок/с до конца duration
period = 1.0 / args.rate
end = time.monotonic() + args.duration
nxt = time.monotonic() + period
while not stop.is_set() and time.monotonic() < end:
await asyncio.sleep(max(0.0, min(nxt, end) - time.monotonic()))
if time.monotonic() >= end or stop.is_set():
break
await edit()
nxt += period
await asyncio.sleep(0.5) # дождаться эха последних правок
if st["echoed"] == 0:
rc = 2
except websockets.ConnectionClosed:
rc = 2 if st["echoed"] == 0 else 0
finally:
try:
await ws.close()
except Exception: # noqa: BLE001
pass
rd.cancel()
print(json.dumps(st), flush=True)
return rc
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--form-id", type=int, required=True)
ap.add_argument("--sheet", required=True)
ap.add_argument("--direction", default=None)
ap.add_argument("--rate", type=float, default=1.0, help="правок в секунду (1 или 5)")
ap.add_argument("--duration", type=float, default=10.0, help="секунд работы; SIGTERM завершает раньше")
ap.add_argument("--user", default="rf_coauthor", help="ключ пользователя в манифесте")
ap.add_argument("--scale", type=int, default=int(os.environ.get("PERF_SCALE", 1)))
ap.add_argument("--token", default=None, help="токен для WS вместо полученного входом (проверка неверного токена)")
ap.add_argument("--ack-timeout", type=float, default=5.0, help="сколько ждать эха первой правки")
return asyncio.run(run(ap.parse_args(argv)))
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,2 +1,4 @@
[pytest]
pythonpath = .
markers =
stand: нужен поднятый стенд (api :8000, БД с манифестом perf/results/manifest-s1.json)

View File

@ -0,0 +1,43 @@
"""Интеграционные тесты бота-соавтора (нужен поднятый стенд): pytest -m stand tests/test_coauthor.py"""
import json
import subprocess
import sys
import time
import pytest
from perfkit import PERF_DIR, RESULTS_DIR
from perfkit.manifest import load
pytestmark = pytest.mark.stand
def _sheet():
m = load(RESULTS_DIR / "manifest-s1.json")
# соавтор - rf_coauthor, права только на листы owner=rf (FORM_1 принадлежит ССП)
first = next(s for s in m.sheets if s.owner == "rf")
return first
def _run(*extra, timeout=30):
s = _sheet()
cmd = [sys.executable, "-m", "perfkit.coauthor", "--form-id", str(s.form_id), "--sheet", s.sheet, *extra]
if s.direction:
cmd += ["--direction", s.direction]
t0 = time.monotonic()
p = subprocess.run(cmd, cwd=PERF_DIR, capture_output=True, text=True, timeout=timeout)
return p, time.monotonic() - t0
def test_coauthor_first_edit_echoed():
p, _ = _run("--rate", "1", "--duration", "3")
assert p.returncode == 0, p.stderr
stats = json.loads(p.stdout.strip().splitlines()[-1])
assert stats["echoed"] >= 1
assert stats["sent"] >= stats["echoed"]
def test_coauthor_bad_token_fails_fast():
p, dt = _run("--rate", "1", "--duration", "3", "--token", "not-a-token")
assert p.returncode == 2, p.stderr # 2 - нет эха первой правки (в т.ч. неверный токен)
assert dt <= 5.5, f"бот завершился за {dt:.1f} с"