Библиотека 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>
227 lines
12 KiB
TypeScript
227 lines
12 KiB
TypeScript
/**
|
||
* Сценарии скролла (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 });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|