Andrey Epifancev bd4d42bffb perf: сценарии скролла
Библиотека scroll.ts (scrollThrough) с тестами на фикстурах, сценарий
scroll.spec.ts: листы форм, отчёты проектов, своды 1/2, projects/summaries,
admin-контроль, смена фильтра свода. Параметры PERF_TARGETS/CPU_RATES/REPEATS/ROLES/AXES.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:58:44 +04:00

227 lines
12 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,4,6' - список CPU throttling rate
* PERF_ROLES фильтр ролей через запятую (rf,admin,dfip)
* PERF_AXES 'y,x' - оси для rf (по умолчанию y,x)
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000)
*/
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, type ProbeMetrics } from '../lib/probe';
import { throttle, startTrace, stopTrace } from '../lib/cdp';
import { append, runDir } from '../lib/results';
import { scrollThrough, type Axis } from '../lib/scroll';
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', '4', '6']).map(Number);
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];
for (const s of m.sheets) {
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: sheetName(s), 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) });
out.push({ name: `project/${p.report_type}/${p.project_id}`, 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;
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 Error(`only ${n} rows`);
if (now - t0 > LOAD_TIMEOUT) throw new Error(`timeout, rows=${n}`);
await page.waitForTimeout(500);
}
}
/**
* Ждёт стабилизации: после первой мутации DOM контейнера - 2 подряд кадра без long task и без мутаций.
* Возвращает false при таймауте.
*/
async function waitStable(page: Page, timeoutMs: number): Promise<boolean> {
return page.evaluate(
([sel, tmo]) =>
new Promise<boolean>((resolve) => {
const root = (document.querySelector(sel as string) ?? document.body) as HTMLElement;
let mutated = false, dirty = false, quiet = 0, done = false;
const mo = new MutationObserver(() => { mutated = true; dirty = true; });
mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true });
const po = new PerformanceObserver(() => { dirty = true; });
try { po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ }
const finish = (v: boolean) => { if (done) return; done = true; mo.disconnect(); po.disconnect(); resolve(v); };
setTimeout(() => finish(false), tmo as number);
const tick = () => {
if (done) return;
if (mutated && !dirty) quiet++; else quiet = 0;
dirty = false;
if (quiet >= 2) return finish(true);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
[CONTAINER, timeoutMs] as const,
);
}
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; filter?: boolean }
async function measure(browser: import('@playwright/test').Browser, o: RunOpts) {
const { t, role, axis, cpu, repeat, 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[] = [];
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) {
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason: `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})` });
return;
}
await page.waitForTimeout(500);
await throttle(page, cpu);
const trace = repeat === 0 && !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: ProbeMetrics, stable = true;
await page.evaluate(() => (window as any).__perf.start());
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
await input.fill('1');
stable = await waitStable(page, 120_000);
} else {
({ complete, steps } = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS));
}
probe = 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);
append({
...rec,
metrics: { ...probe, axis: axis === 'y' ? 0 : 1, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
artifacts,
incomplete: !complete || !stable,
...(filter && !stable ? { skip_reason: 'filter did not stabilize in 120s' } : {}),
});
} finally {
await ctx.close();
}
}
test.describe('scroll', () => {
test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000));
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 }) => {
await measure(browser, { t, role: ra.role, axis, cpu, repeat });
});
}
}
// смена фильтра - только своды, один раз на CPU 6
if (t.kind !== 'sheet' && t.kind !== 'project' && CPU_RATES.includes(6)) {
test(`${t.name} ${ra.role} filter cpu6`, async ({ browser }) => {
await measure(browser, { t, role: ra.role, axis, cpu: 6, repeat: 0, filter: true });
});
}
}
}
}
});