271 lines
14 KiB
TypeScript
271 lines
14 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' по умолчанию (без троттлинга, решение человека); '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)
|
||
*/
|
||
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']).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];
|
||
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;
|
||
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; 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) {
|
||
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);
|
||
|
||
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, reason: string | undefined, effect = true;
|
||
const sigBefore = filter ? await signature(page) : '';
|
||
if (filter) await armStable(page);
|
||
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 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 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, 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 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 });
|
||
});
|
||
}
|
||
}
|
||
// смена фильтра - только своды, один раз на 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, filter: true });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|