/** * Сценарии скролла (Task 5). * * Параметры (env): * PERF_SCALE масштаб манифеста (по умолчанию 1) * PERF_TARGETS glob(ы) через запятую по target, напр. 'FORM_1/*,svod/*' (по умолчанию все); * сопоставляется с target без суффикса оси и с полным (`.../@y`) * PERF_RUN_ID каталог прогона perf/results/ * 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(); 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 { 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 { 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 { return page.evaluate( (tmo) => new Promise((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(); const reqBytes = new Map(); 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 }); }); } } } } });