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

138 lines
8.0 KiB
TypeScript
Raw Permalink 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 7). Перечень - perf/e2e/modals.json (строится по коду web/src, проверен на стенде).
*
* Параметры (env):
* PERF_SCALE масштаб манифеста (по умолчанию 1)
* PERF_RUN_ID каталог прогона perf/results/<run_id>
* PERF_MODALS glob(ы) через запятую по id модалки (по умолчанию все); у варианта на большом листе id оканчивается на @big
* PERF_PRIORITY '1' (по умолчанию), '2' или '1,2'
* PERF_REPEATS число повторов вместо матричного (priority 1 - 5, priority 2 - 3, profiling - 1)
* PERF_CPU_RATES '1' по умолчанию (без троттлинга, решение человека); '1,4,6' - вручную
* PERF_ROLES фильтр ролей открытия (rf,admin)
* PERF_PROFILING=1 профилирующий прогон: PERF_WEB должен указывать на профилирующую сборку (:8081);
* пишет scenario 'modal_renders' (metrics.renders_top - топ-20 компонентов по self_ms)
* PERF_TEST_TIMEOUT_MS таймаут одного замера (по умолчанию 3 мин)
*/
import { test, type Browser, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { loadManifest, scale, type Manifest, type User } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { throttle } from '../lib/cdp';
import { measureOpen, type Appear } from '../lib/modal';
import { REACT_HOOK_SCRIPT, hookInfo, profileStart, profileStop } from '../lib/reactProfile';
import { attachNetlog } from '../lib/netlog';
import { append } from '../lib/results';
interface Step { selector: string; action: 'click' }
interface ModalDef {
id: string; title: string; page: string; source: string; priority: 1 | 2; role: string;
page_ref?: string; sheet_ref?: string; big_sheet?: string;
open: { selector: string; action: 'click'; pre?: Step[] };
appear?: Appear; skip_reason?: string; note?: string;
}
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 PRIORITIES = envList('PERF_PRIORITY', ['1']).map(Number);
const ROLES = envList('PERF_ROLES', ['rf', 'admin']);
const PROFILING = process.env.PERF_PROFILING === '1';
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const ID_RES = envList('PERF_MODALS', ['*']).map(globRe);
const repeatsFor = (prio: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : PROFILING ? 1 : prio === 1 ? 5 : 3);
const TEST_TIMEOUT = Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 60_000);
const defs: ModalDef[] = JSON.parse(readFileSync(path.resolve(__dirname, '../modals.json'), 'utf-8'));
const sheetKey = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
function urlFor(m: Manifest, d: ModalDef, sheetRef?: string): string {
const sref = sheetRef ?? d.sheet_ref;
if (sref) {
const cands = m.sheets.filter((s) => sheetKey(s) === sref);
const s = cands.find((x) => x.owner === 'rf') ?? cands[0];
if (!s) throw new Error(`sheet ${sref} not in manifest`);
return s.url;
}
const p = m.modal_pages.find((x) => x.name === d.page_ref);
if (!p) throw new Error(`modal page ${d.page_ref} not in manifest`);
return p.url;
}
/** Страница готова: опенер виден, у листов - загружены строки; затем короткая пауза на «остывание» рендеров. */
async function prepare(page: Page, d: ModalDef, isSheet: boolean) {
const first = d.open.pre?.[0]?.selector ?? d.open.selector;
await page.locator(first).first().waitFor({ state: 'visible', timeout: 120_000 });
if (isSheet) await page.locator('tbody tr').first().waitFor({ state: 'visible', timeout: 120_000 });
await page.waitForTimeout(isSheet ? 3000 : 1500);
}
async function measure(browser: Browser, d: ModalDef, target: string, sheetRef: string | undefined, role: string, cpu: number, repeat: number) {
const m = loadManifest();
const user: User = m.users[role];
const rec = { scenario: PROFILING ? 'modal_renders' : 'modal', target, role, scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role });
try {
await loginAs(ctx, user);
if (PROFILING) await ctx.addInitScript(REACT_HOOK_SCRIPT);
const page = await ctx.newPage();
await page.goto(WEB + urlFor(m, d, sheetRef));
try {
await prepare(page, d, !!(sheetRef ?? d.sheet_ref));
for (const s of d.open.pre ?? []) {
await page.locator(s.selector).first().click({ timeout: 15_000 });
await page.waitForTimeout(600);
}
const opener = page.locator(d.open.selector).first();
await opener.waitFor({ state: 'visible', timeout: 15_000 });
// клик Playwright сам скроллит/наводит курсор: делаем это заранее, чтобы окно main thread не включало подготовку
await opener.scrollIntoViewIfNeeded();
await opener.hover();
await page.waitForTimeout(400);
await throttle(page, cpu);
if (PROFILING) {
const info = await hookInfo(page);
if (!info.saw_actual_duration) throw new Error(`not a profiling build (renderers=${info.renderers}, commits=${info.commits_total}, no actualDuration) at ${WEB}`);
await profileStart(page);
}
// клик мышью по координатам (курсор уже наведён): locator.click() добавляет в окно замера свои проверки actionability
const box = await opener.boundingBox();
if (!box) throw new Error('opener has no bounding box');
const r = await measureOpen(page, () => page.mouse.click(box.x + box.width / 2, box.y + box.height / 2), { id: d.id, appear: d.appear });
if (PROFILING) {
await page.waitForTimeout(500); // догоняющие рендеры (загрузка данных модалки)
const p = await profileStop(page);
append({ ...rec, metrics: { ...r, commits: p.commits, total_renders: p.total_renders, total_self_ms: p.total_self_ms, renders_top: p.top as any }, artifacts: [], incomplete: !!p.error, ...(p.error ? { skip_reason: p.error } : {}) });
} else {
append({ ...rec, metrics: { ...r }, artifacts: [] });
}
} catch (e) {
const msg = String(e instanceof Error ? e.message : e).split('\n')[0].slice(0, 300);
append({ ...rec, metrics: {}, artifacts: [], incomplete: true, skip_reason: msg });
console.log(`INCOMPLETE ${target} ${role} cpu${cpu} #${repeat}: ${msg}`);
}
} finally {
await nl.flush();
await ctx.close();
}
}
test.describe(PROFILING ? 'modal_renders' : 'modal', () => {
for (const d of defs) {
if (d.skip_reason || !PRIORITIES.includes(d.priority) || !ROLES.includes(d.role)) continue;
const variants: Array<{ target: string; sheetRef?: string }> = [{ target: d.id }];
if (d.big_sheet) variants.push({ target: `${d.id}@big`, sheetRef: d.big_sheet });
for (const v of variants) {
if (!ID_RES.some((r) => r.test(v.target))) continue;
for (const cpu of CPU_RATES) {
for (let repeat = 0; repeat < repeatsFor(d.priority); repeat++) {
test(`${v.target} ${d.role} cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(TEST_TIMEOUT);
await measure(browser, d, v.target, v.sheetRef ?? d.sheet_ref, d.role, cpu, repeat);
});
}
}
}
}
});