Andrey Epifancev 05d1586006 perf: исправления run-all по ревью (раунд 1)
- C1: trace только на повторе 0 при числе повторов > 1 (lib/trace.ts
  shouldTrace) в scroll и scroll-coauthor: admin-контроль, control4x и
  PERF_S2_REPEATS=1 больше не теряют единственную запись (traced=1).
- I1: масштаб 2 - PERF_LOAD_TIMEOUT_MS=40 мин и PERF_TEST_TIMEOUT_MS=90 мин,
  если не заданы.
- I2: шаг aggregate падает при сбое perfkit.aggregate/копирования и если
  summary.json не создан; тесты run-all (perf/tests/test_run_all.py).
- M2: после modals_profiling останавливается preview :8081.
- README, design.md: решения раунда.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:12:40 +04:00

287 lines
16 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 5).
*
* Параметры (env):
* PERF_SCALE масштаб манифеста (по умолчанию 1)
* PERF_TARGETS glob(ы) через запятую по target, напр. 'FORM_1/*,svod/*' (по умолчанию все);
* сопоставляется с target без суффикса оси и с полным (`.../@y`)
* PERF_RUN_ID каталог прогона perf/results/<run_id>
* PERF_REPEATS число повторов вместо матричного (3 для rf/свод, 1 для admin-контроля)
* PERF_CPU_RATES '1' по умолчанию (без троттлинга, решение человека); '1,4,6' включает троттлинг вручную
* PERF_ROLES фильтр ролей через запятую (rf,admin,dfip)
* PERF_AXES 'y,x' - оси для rf (по умолчанию y,x)
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000)
* PERF_TEST_TIMEOUT_MS / PERF_TEST_TIMEOUT_SVOD2_MS таймаут теста (45 мин / 3 ч для свода 2)
* PERF_TRACE=0 не снимать trace вообще (по умолчанию - на повторе 0, если повторов > 1: запись traced=1 aggregate
* исключает, поэтому при 1 повторе, как у admin-контроля, trace не снимается)
*
* Основная метрика: main_thread_ms (CDP TaskDuration за скролл) и main_thread_ms_per_step (на шаг колеса 200 px);
* для смены фильтра - main_thread_ms на действие (шагов нет).
*/
import { test, type Page } from '@playwright/test';
import path from 'node:path';
import { loadManifest, scale, type User } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, startProbe, perStep, type ProbeResult } from '../lib/probe';
import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough, type Axis } from '../lib/scroll';
import { shouldTrace } from '../lib/trace';
const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
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 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 FILTER_CPU = Math.max(...CPU_RATES); // замер смены фильтра: на самом тяжёлом из заданных CPU (по умолчанию 1)
const ROLES = envList('PERF_ROLES', ['rf', 'admin', 'dfip']);
const AXES = envList('PERF_AXES', ['y', 'x']) as Axis[];
const repeats = (dflt: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : dflt);
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const TARGET_RES = envList('PERF_TARGETS', ['*']).map(globRe);
const wanted = (t: string, axis: Axis) => TARGET_RES.some((r) => r.test(t) || r.test(`${t}@${axis}`));
type Kind = 'sheet' | 'project' | 'svod1' | 'svod2' | 'summaries';
interface Target {
name: string; // target без оси
kind: Kind;
url: string; // без hash: приложение его не читает
tabIndex?: number; // вкладка свода (клик по role=tab)
respRe: RegExp; // ответ загрузки данных (для json_bytes_sheet)
rolesAxes: Array<{ role: string; axes: Axis[]; repeats: number }>;
}
const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
function buildTargets(): Target[] {
const m = loadManifest();
const out: Target[] = [];
const hasRole = (r: string) => ROLES.includes(r) && !!m.users[r];
// FORM_2 есть у ССП-01 и у РФ-01 с одинаковыми именами листов: лист не-rf владельца при совпадении имени получает
// суффикс владельца (`FORM_2/AHR/null/ssp`), иначе admin-контроль дал бы одинаковые названия тестов и смешал группы
const nameCount = new Map<string, number>();
for (const s of m.sheets) nameCount.set(sheetName(s), (nameCount.get(sheetName(s)) ?? 0) + 1);
for (const s of m.sheets) {
const name = nameCount.get(sheetName(s))! > 1 && s.owner !== 'rf' ? `${sheetName(s)}/${s.owner}` : sheetName(s);
const ra: Target['rolesAxes'] = [];
if (s.owner === 'rf' && hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) });
if (scale() === 1 && hasRole('admin')) ra.push({ role: 'admin', axes: ['y'], repeats: repeats(1) });
out.push({ name, kind: 'sheet', url: s.url, respRe: new RegExp(`/api/v1/form/${s.form_id}/sheet/${s.sheet}(\\?|$)`), rolesAxes: ra });
}
for (const p of m.project_reports) {
const ra: Target['rolesAxes'] = [];
if (hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) });
// год в имени: у проекта отчёты одного типа за 2026 и 2027 (иначе одинаковые названия тестов и смешанные группы)
out.push({ name: `project/${p.report_type}/${p.project_id}/${p.year}`, kind: 'project', url: p.url, respRe: new RegExp(`/api/v1/projects/${p.project_id}/report/${p.year}/${p.report_type}(\\?|$)`), rolesAxes: ra });
}
const svodRoles = ['admin', 'dfip'].filter(hasRole).map((role) => ({ role, axes: ['y'] as Axis[], repeats: repeats(3) }));
const svod1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
svod1.forEach((sh, i) => out.push({ name: `svod/svod1_${sh}`, kind: 'svod1', url: '/svod', tabIndex: i, respRe: new RegExp(`/api/v1/svod/1/${sh}/`), rolesAxes: svodRoles }));
['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: new RegExp(`/api/v1/svod/2/${sh}/`), rolesAxes: svodRoles }));
out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles });
return out;
}
/**
* Ждём >= MIN_ROWS строк тела таблицы; возвращает фактическое число.
* Если строки появились, но их число не растёт SETTLE_MS (на странице реально < MIN_ROWS строк) - бросаем ошибку,
* не дожидаясь LOAD_TIMEOUT (сам по себе он нужен для медленной обработки свода 2, когда строк ещё 0).
*/
const SETTLE_MS = 20_000;
class RowsError extends Error {}
const skipLog: string[] = [];
async function waitRows(page: Page): Promise<number> {
const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
const t0 = Date.now();
let last = -1, lastChange = t0;
for (;;) {
const n = await count();
if (n >= MIN_ROWS) return n;
const now = Date.now();
if (n !== last) { last = n; lastChange = now; }
if (n > 0 && now - lastChange > SETTLE_MS) throw new RowsError(`only ${n} rows`);
if (now - t0 > LOAD_TIMEOUT) throw new RowsError(`timeout, rows=${n}`);
await page.waitForTimeout(500);
}
}
/**
* Стабилизация после действия: armStable() ставит наблюдатели ДО действия (иначе первая мутация теряется),
* awaitStable() ждёт первой мутации контейнера и затем 2 подряд кадра без long task и без мутаций.
*/
async function armStable(page: Page): Promise<void> {
await page.evaluate((sel) => {
const root = (document.querySelector(sel) ?? document.body) as HTMLElement;
const st = { mutated: false, dirty: false, mo: null as MutationObserver | null, po: null as PerformanceObserver | null };
st.mo = new MutationObserver(() => { st.mutated = true; st.dirty = true; });
st.mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true });
st.po = new PerformanceObserver(() => { st.dirty = true; });
try { st.po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ }
(window as any).__stable = st;
}, CONTAINER);
}
async function awaitStable(page: Page, timeoutMs: number): Promise<boolean> {
return page.evaluate(
(tmo) =>
new Promise<boolean>((resolve) => {
const st = (window as any).__stable;
let quiet = 0, done = false;
const finish = (v: boolean) => { if (done) return; done = true; st.mo.disconnect(); st.po.disconnect(); resolve(v); };
setTimeout(() => finish(false), tmo);
const tick = () => {
if (done) return;
if (st.mutated && !st.dirty) quiet++; else quiet = 0;
st.dirty = false;
if (quiet >= 2) return finish(true);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
timeoutMs,
);
}
/** Сигнатура видимого содержимого таблицы + подпись «Найдено N из M» (если есть). */
const signature = (page: Page) =>
page.evaluate((sel) => {
const c = document.querySelector(sel) as HTMLElement | null;
const rows = Array.from(c?.querySelectorAll('tbody tr') ?? []).slice(0, 6).map((r) => r.textContent).join('|');
const found = /Найдено[^\n]*/.exec(document.body.innerText)?.[0] ?? '';
return rows + '#' + found;
}, CONTAINER);
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; repeats: number; filter?: boolean }
async function measure(browser: import('@playwright/test').Browser, o: RunOpts) {
const { t, role, axis, cpu, repeat, repeats: nRepeats, filter } = o;
const m = loadManifest();
const user: User = m.users[role];
const targetName = (filter ? `${t.name}/filter` : t.name) + `@${axis}`;
const rec = { scenario: 'scroll', target: targetName, role, scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const artifacts: string[] = [];
const nl = attachNetlog(ctx, { scenario: rec.scenario, target: targetName, role });
try {
await loginAs(ctx, user);
await ctx.addInitScript(PROBE_SCRIPT);
const page = await ctx.newPage();
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
// (response.body() для 30-47 МБ недоступен: "evicted from inspector cache")
let jsonBytes = 0;
const net = await ctx.newCDPSession(page);
await net.send('Network.enable');
const reqUrl = new Map<string, string>();
const reqBytes = new Map<string, number>();
net.on('Network.requestWillBeSent', (e: any) => { reqUrl.set(e.requestId, e.request.url); });
net.on('Network.dataReceived', (e: any) => {
const u = reqUrl.get(e.requestId);
if (u && t.respRe.test(u)) {
const v = (reqBytes.get(e.requestId) ?? 0) + e.dataLength;
reqBytes.set(e.requestId, v);
jsonBytes = Math.max(jsonBytes, v);
}
});
await page.goto(WEB + t.url);
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
if (t.kind === 'svod2') {
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
await page.getByLabel('ССП/РФ').click();
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Загрузить' }).click();
}
let rows: number;
try {
rows = await waitRows(page);
} catch (e) {
if (!(e instanceof RowsError)) throw e;
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
const skip_reason = `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})`;
skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${skip_reason}`);
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason });
return;
}
await page.waitForTimeout(500);
await net.send('Network.disable').catch(() => {});
await throttle(page, cpu);
// trace только на повторе 0 при повторах > 1 (lib/trace.ts): при одном повторе запись traced=1 была бы единственной
const trace = shouldTrace({ repeat, repeats: nRepeats, filter });
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-${role}-cpu${cpu}.trace.json`);
if (trace) await startTrace(page);
let complete = true, steps = 0, probe: ProbeResult, stable = true, reason: string | undefined, effect = true;
const sigBefore = filter ? await signature(page) : '';
if (filter) await armStable(page);
const pr = await startProbe(page);
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
await input.fill('1');
stable = await awaitStable(page, 120_000);
effect = (await signature(page)) !== sigBefore;
if (!stable) reason = 'filter did not stabilize in 120s';
else if (!effect) reason = 'filter_no_effect';
} else {
const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS);
({ complete, steps } = r);
if (r.reason) reason = r.reason;
else if (!complete) reason = 'scroll_incomplete';
}
probe = await pr.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);
append({
...rec,
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
artifacts,
incomplete: !complete || !stable || !effect,
...(reason ? { skip_reason: reason } : {}),
});
if (reason) skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${reason}`);
} finally {
await nl.flush();
await ctx.close();
}
}
/** Таймаут теста: 45 мин по умолчанию, свод 2 (загрузка до 15 мин + скролл на CPU 6 ~40 мин, если включён вручную) - 3 ч. */
const testTimeout = (t: Target) =>
t.kind === 'svod2'
? Number(process.env.PERF_TEST_TIMEOUT_SVOD2_MS ?? 3 * 3600_000)
: Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000);
test.describe('scroll', () => {
test.afterAll(() => {
if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n'));
});
for (const t of buildTargets()) {
for (const ra of t.rolesAxes) {
for (const axis of ra.axes) {
if (!wanted(t.name, axis)) continue;
for (const cpu of CPU_RATES) {
for (let repeat = 0; repeat < ra.repeats; repeat++) {
test(`${t.name} ${ra.role} ${axis} cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(testTimeout(t));
await measure(browser, { t, role: ra.role, axis, cpu, repeat, repeats: ra.repeats });
});
}
}
// смена фильтра - только своды, один раз на FILTER_CPU
if (t.kind !== 'sheet' && t.kind !== 'project') {
test(`${t.name} ${ra.role} filter cpu${FILTER_CPU}`, async ({ browser }) => {
test.setTimeout(testTimeout(t));
await measure(browser, { t, role: ra.role, axis, cpu: FILTER_CPU, repeat: 0, repeats: 1, filter: true });
});
}
}
}
}
});