Сетевой лог сценариев, bench запросов, pg_stat_statements и EXPLAIN, aggregate исключает трассировочные повторы (O-9). Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
8.0 KiB
TypeScript
138 lines
8.0 KiB
TypeScript
/**
|
||
* Сценарии открытия модалок (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);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|