DFiP_Budget_planing/perf/e2e/scenarios/scroll-coauthor.spec.ts
Andrey Epifancev fdc8ecb2a3 perf: замеры бэка
Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN,
aggregate исключает трассировочные повторы (O-9).

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:36:46 +04:00

161 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Сценарий «скролл при живом соавторе» (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 { attachNetlog } from '../lib/netlog';
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;
const nl = attachNetlog(ctx, { scenario: 'scroll_coauthor', target: targetName, role: 'rf' });
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 nl.flush();
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);
});
}
}
}
}
});