350 lines
20 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';
import { ByteCounter, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR, countPagesRequested, filterResultMs as filterResultMsOf, isSvod2SearchUrl, placeholderShare, svod2RespRe } from '../lib/svod2';
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: svod2RespRe(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, rowSel = 'tbody tr'): Promise<number> {
const count = () => page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
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")
const bytes = new ByteCounter();
const pageUrls: string[] = []; // запросы свода 2 (для pages_requested)
const net = await ctx.newCDPSession(page);
await net.send('Network.enable');
const reqUrl = new Map<string, string>();
net.on('Network.requestWillBeSent', (e: any) => {
reqUrl.set(e.requestId, e.request.url);
if (t.kind === 'svod2' && t.respRe.test(e.request.url)) pageUrls.push(e.request.url);
});
net.on('Network.dataReceived', (e: any) => {
const u = reqUrl.get(e.requestId);
if (u && t.respRe.test(u)) bytes.add(e.requestId, e.dataLength);
});
await page.goto(WEB + t.url);
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
const rowSel = t.kind === 'svod2' ? SVOD2_DATA_ROW_SELECTOR : 'tbody tr';
let tLoad = 0;
if (t.kind === 'svod2') {
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
await page.getByLabel('ССП/РФ').click();
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
await page.keyboard.press('Escape');
// first_rows_ms: raf-наблюдатель в странице (момент, когда строк данных >= MIN_ROWS); ожидание и отказ - прежний waitRows
await page.evaluate(([sel, rs, min]) => {
const st = { at: 0 };
(window as any).__firstRows = st;
const tick = () => {
if (document.querySelectorAll(`${sel} ${rs}`).length >= (min as number)) { st.at = Date.now(); return; }
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}, [CONTAINER, rowSel, MIN_ROWS]);
tLoad = Date.now();
await page.getByRole('button', { name: 'Загрузить' }).click();
}
let rows: number;
let firstRowsMs = 0;
try {
rows = await waitRows(page, rowSel);
if (t.kind === 'svod2') {
const at = await page.evaluate(() => (window as any).__firstRows?.at as number | undefined);
firstRowsMs = (at || Date.now()) - tLoad;
bytes.markFirstRows();
}
} catch (e) {
if (!(e instanceof RowsError)) throw e;
const have = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]).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: bytes.sheet }, artifacts, incomplete: true, skip_reason });
return;
}
await page.waitForTimeout(500);
// свод 2 дозагружает страницы при прокрутке: сеть слушаем до конца действия
if (t.kind !== 'svod2') 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);
let filterResultMs: number | undefined;
let placeholderSamples: boolean[] = [];
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
const tFilter = Date.now();
// свод 2 «после»: поиск уходит на сервер; нет запроса (сборка «до») - идём дальше
// ожидание ответа идёт параллельно со стабилизацией: таймаут 5 с на «до» в метрику не попадает
let respAt: number | null = null;
const searchResp = t.kind === 'svod2'
? page.waitForResponse((r) => isSvod2SearchUrl(r.url()), { timeout: 5_000 }).then(() => { respAt = Date.now(); }, () => {})
: null;
await input.fill('1');
stable = await awaitStable(page, 120_000);
const stableAt = Date.now();
if (searchResp) await searchResp;
if (t.kind === 'svod2') filterResultMs = filterResultMsOf(tFilter, stableAt, respAt);
effect = (await signature(page)) !== sigBefore;
if (!stable) reason = 'filter did not stabilize in 120s';
else if (!effect) reason = 'filter_no_effect';
} else {
if (t.kind === 'svod2') {
// сэмпл в requestAnimationFrame: виден ли хотя бы один ряд-заглушка
await page.evaluate(([sel, ph]) => {
const st = { on: true, samples: [] as boolean[] };
const tick = () => {
if (!st.on) return;
st.samples.push(document.querySelector(`${sel} ${ph}`) !== null);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
(window as any).__ph = st;
}, [CONTAINER, SVOD2_PLACEHOLDER_SELECTOR]);
}
const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS);
if (t.kind === 'svod2') {
placeholderSamples = await page.evaluate(() => { const st = (window as any).__ph; st.on = false; return st.samples as boolean[]; });
}
({ complete, steps } = r);
if (r.reason) reason = r.reason;
else if (!complete) reason = 'scroll_incomplete';
}
probe = await pr.stop();
if (t.kind === 'svod2') await net.send('Network.disable').catch(() => {});
if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); }
const rowsAfter = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
const svod2Metrics = t.kind === 'svod2'
? {
first_rows_ms: firstRowsMs,
// в записи фильтра счётчики включали бы исходную загрузку (до fill) - пишем их только в записи скролла
...(filter
? { filter_result_ms: filterResultMs ?? 0 }
: {
json_bytes_total: bytes.total,
json_bytes_first: bytes.first,
pages_requested: countPagesRequested(pageUrls),
placeholder_frame_share: placeholderShare(placeholderSamples),
}),
}
: {};
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: bytes.sheet, steps, ...svod2Metrics },
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 });
});
}
}
}
}
});