/** * Профиль горячих точек (Task 10b): что именно работает на main thread во время одного действия. * Не входит в run-all: это разбор, а не матрица замеров. Цифры времени - из scroll.spec (prod-сборка без профайлера). * * PERF_HOT_MODE=cpu CDP Profiler (сэмплы 200 мкс) на prod-сборке с source maps: * perf/stand.sh web-sourcemap; PERF_WEB=http://localhost:8082 * -> artifacts/hot--.cpuprofile, разбор - analysis/cpuprofile_top.py * PERF_HOT_MODE=react счётчики рендеров React (lib/reactProfile.ts) на профилирующей сборке: * perf/stand.sh web-profiling; PERF_WEB=http://localhost:8081 * PERF_HOT_TARGETS через запятую `:`; target как в scroll.spec без оси * (FORM_2/AHR/null, project/LIMIT/1/2026, svod/svod1_MAIN, svod/svod2_GO, ...), * action: y | x | filter (ввод «1» в поиск свода и наблюдение 30 с) | * cssgrowth (циклы «лист -> скролл 40 шагов -> /tasks» без перезагрузки; после каждого цикла - * число CSS-правил документа, в т.ч. @media, примеры последних @media-правил и main thread * на шаг скролла этого цикла) * PERF_HOT_CYCLES циклов для cssgrowth (по умолчанию 6) * PERF_HOT_SHEET2 второй лист цикла cssgrowth (например FORM_4/STRUCTURE/null, как в memory.spec); счётчик * window.matchMedia (вызовы, нетто addEventListener, топ мест вызова) пишется по циклам * PERF_HOT_STEPS шагов колеса 200 px вперёд, затем столько же обратно (по умолчанию 40) * PERF_HOT_FILTER_MS окно наблюдения после ввода фильтра (по умолчанию 30000) * PERF_HOT_ROLE роль (по умолчанию rf для листов и отчётов, dfip для сводов) * * Запись: results//hotspot.jsonl, scenario hotspot_cpu | hotspot_react; метрики main_thread_ms, steps; * react - renders_top (топ-30 по self_ms), commits, total_renders; filter - хронология мутаций DOM контейнера * (по секундам), самая длинная серия кадров без мутаций, примеры изменяемых узлов/атрибутов. */ import { test, type Page } from '@playwright/test'; import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { loadManifest, scale } from '../lib/manifest'; import { loginAs, WEB } from '../lib/auth'; import { mainThreadMs } from '../lib/cdp'; import { REACT_HOOK_SCRIPT, profileStart, profileStop } from '../lib/reactProfile'; import { append, runDir } from '../lib/results'; const CONTAINER = '.MuiTableContainer-root'; const MODE = (process.env.PERF_HOT_MODE ?? 'cpu') as 'cpu' | 'react'; const STEPS = Number(process.env.PERF_HOT_STEPS ?? 40); const FILTER_MS = Number(process.env.PERF_HOT_FILTER_MS ?? 30_000); const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); const TARGETS = (process.env.PERF_HOT_TARGETS ?? 'FORM_2/AHR/null:y').split(',').map((s) => s.trim()).filter(Boolean); interface Resolved { name: string; url: string; kind: 'sheet' | 'project' | 'svod1' | 'svod2' | 'summaries'; tabIndex?: number } function resolve(name: string): Resolved { const m = loadManifest(); const sh = m.sheets.find((s) => s.owner === 'rf' && `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}` === name); if (sh) return { name, url: sh.url, kind: 'sheet' }; const pm = /^project\/(\w+)\/(\d+)\/(\d+)$/.exec(name); if (pm) { const p = m.project_reports.find((r) => r.report_type === pm[1] && r.project_id === Number(pm[2]) && r.year === Number(pm[3])); if (p) return { name, url: p.url, kind: 'project' }; } const s1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'].indexOf(name.replace('svod/svod1_', '')); if (name.startsWith('svod/svod1_') && s1 >= 0) return { name, url: '/svod', kind: 'svod1', tabIndex: s1 }; const s2 = ['GO', 'RF'].indexOf(name.replace('svod/svod2_', '')); if (name.startsWith('svod/svod2_') && s2 >= 0) return { name, url: '/svod/2', kind: 'svod2', tabIndex: s2 }; if (name === 'svod/projects_summaries') return { name, url: '/projects/summaries', kind: 'summaries' }; throw new Error(`неизвестная цель ${name}`); } const raf = () => new Promise((r) => requestAnimationFrame(() => r())); async function waitRows(page: Page) { const t0 = Date.now(); for (;;) { const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); if (n >= 20) return n; if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`rows=${n} после ${LOAD_TIMEOUT} мс`); await page.waitForTimeout(500); } } async function wheelSteps(page: Page, axis: 'y' | 'x', n: number): Promise { const box = await page.locator(CONTAINER).first().boundingBox(); if (!box) throw new Error('нет контейнера'); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); let steps = 0; for (const dir of [1, -1]) { for (let i = 0; i < n; i++) { if (axis === 'y') await page.mouse.wheel(0, 200 * dir); else await page.mouse.wheel(200 * dir, 0); await page.evaluate(raf); steps++; } } return steps; } /** Наблюдение за DOM после ввода фильтра: мутации по секундам, кадры без мутаций, примеры узлов. */ const ARM_MUTATIONS = (sel: string) => { const root = (document.querySelector(sel) ?? document.body) as HTMLElement; const st: any = { t0: performance.now(), perSec: {} as Record, frames: [] as number[], samples: {} as Record, longtasks: [] as number[][], dirty: 0 }; const desc = (n: Node | null): string => { if (!n) return '?'; const el = (n.nodeType === 1 ? n : n.parentElement) as HTMLElement | null; if (!el) return '#text'; const cls = (typeof el.className === 'string' ? el.className : '').split(/\s+/).filter((c) => c && !/^css-/.test(c)).slice(0, 2).join('.'); return `${el.tagName.toLowerCase()}${cls ? '.' + cls : ''}`; }; st.mo = new MutationObserver((list) => { const sec = Math.floor((performance.now() - st.t0) / 1000); st.perSec[sec] = (st.perSec[sec] ?? 0) + list.length; st.dirty += list.length; for (const m of list) { const k = `${m.type}:${desc(m.target)}${m.attributeName ? '@' + m.attributeName : ''}`; st.samples[k] = (st.samples[k] ?? 0) + 1; } }); st.mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true }); try { st.po = new PerformanceObserver((l) => { for (const e of l.getEntries()) st.longtasks.push([Math.round(e.startTime - st.t0), Math.round(e.duration)]); }); st.po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ } const tick = () => { st.frames.push(st.dirty); st.dirty = 0; if (!st.stop) requestAnimationFrame(tick); }; requestAnimationFrame(tick); (window as any).__mut = st; }; const COLLECT_MUTATIONS = () => { const st = (window as any).__mut; st.stop = true; st.mo.disconnect(); st.po?.disconnect(); let run = 0, best = 0, firstMut = -1, quietPairs = 0; st.frames.forEach((d: number, i: number) => { if (d > 0 && firstMut < 0) firstMut = i; if (d === 0) { run++; if (run === 2 && firstMut >= 0) quietPairs++; } else run = 0; if (firstMut >= 0) best = Math.max(best, run); }); const top = Object.entries(st.samples as Record).sort((a, b) => b[1] - a[1]).slice(0, 15); return { per_sec: st.perSec, frames: st.frames.length, frames_with_mutation: st.frames.filter((d: number) => d > 0).length, first_mutation_frame: firstMut, max_quiet_run_after_first: best, quiet_pairs_after_first: quietPairs, mutation_kinds_top: top, long_tasks: st.longtasks.slice(0, 50), long_task_n: st.longtasks.length, }; }; const CSS_STATS = () => { let rules = 0, media = 0; const lastMedia: string[] = []; const walk = (list: CSSRuleList) => { for (const r of Array.from(list)) { rules++; if (r instanceof CSSMediaRule) { media++; lastMedia.push(r.cssText.slice(0, 220)); if (lastMedia.length > 400) lastMedia.shift(); } const inner = (r as any).cssRules as CSSRuleList | undefined; if (inner && !(r instanceof CSSMediaRule)) walk(inner); } }; for (const sh of Array.from(document.styleSheets)) { try { walk(sh.cssRules); } catch { /* cross-origin */ } } // уникальные «шаблоны» последних @media-правил (класс css- заменён на css-*) const shapes: Record = {}; for (const t of lastMedia) { const k = t.replace(/css-[\w-]+/g, 'css-*').replace(/\d+(\.\d+)?px/g, 'Npx'); shapes[k] = (shapes[k] ?? 0) + 1; } return { css_rules: rules, css_media_rules: media, style_elements: document.querySelectorAll('style').length, media_shapes_last400: Object.entries(shapes).sort((a, b) => b[1] - a[1]).slice(0, 8) }; }; /** Счётчик window.matchMedia (каждый вызов создаёт MediaQueryList/MediaQuerySet в Blink) и топ мест вызова. */ const MATCHMEDIA_SCRIPT = `(() => { if (window.__mm) return; const st = { calls: 0, listeners: 0, stacks: {} }; const orig = window.matchMedia.bind(window); window.matchMedia = (q) => { st.calls++; const fr = (new Error().stack || '').split('\\n').slice(2, 5).map((l) => l.trim().replace(/https?:\\/\\/[^/]+\\//, '')).join(' < '); const k = q + ' @ ' + fr; st.stacks[k] = (st.stacks[k] || 0) + 1; const mql = orig(q); const add = mql.addEventListener.bind(mql); mql.addEventListener = (...a) => { st.listeners++; return add(...a); }; const rem = mql.removeEventListener.bind(mql); mql.removeEventListener = (...a) => { st.listeners--; return rem(...a); }; return mql; }; window.__mm = st; })();`; const MM_STATS = () => { const st = (window as any).__mm; if (!st) return {}; const top = Object.entries(st.stacks as Record).sort((a, b) => b[1] - a[1]).slice(0, 6); return { matchmedia_calls: st.calls, matchmedia_listeners_net: st.listeners, matchmedia_top: top }; }; async function spaGo(page: Page, url: string): Promise { await page.evaluate((u) => { window.history.pushState({}, '', u); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, url); } const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); for (const spec of TARGETS) { const [name, action = 'y'] = spec.split(':'); test(`hotspot ${MODE} ${name} ${action}`, async ({ browser }) => { test.setTimeout(LOAD_TIMEOUT + 30 * 60_000); const t = resolve(name); const m = loadManifest(); const role = process.env.PERF_HOT_ROLE ?? (t.kind === 'sheet' || t.kind === 'project' ? 'rf' : 'dfip'); const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); try { await loginAs(ctx, m.users[role]); if (MODE === 'react') await ctx.addInitScript(REACT_HOOK_SCRIPT); if (action === 'cssgrowth') await ctx.addInitScript(MATCHMEDIA_SCRIPT); const page = await ctx.newPage(); await page.goto(WEB + t.url); if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click(); if (t.kind === 'svod2') { await page.getByLabel('ССП/РФ').click(); await page.getByRole('option', { name: 'Все ССП/РФ' }).click(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Загрузить' }).click(); } const rows = await waitRows(page); await page.waitForTimeout(1500); const cdp = await ctx.newCDPSession(page); const profiling = action !== 'cssgrowth'; // cssgrowth - долгие циклы, профиль не нужен if (!profiling) { /* только счётчики CSS */ } else if (MODE === 'cpu') { await cdp.send('Profiler.enable'); await cdp.send('Profiler.setSamplingInterval', { interval: 200 }); await cdp.send('Profiler.start'); } else { await profileStart(page); } const mt0 = await mainThreadMs(page); let steps = 0; let mutations: any; const metricsExtra: Record = {}; if (action === 'cssgrowth') { const sheet2 = process.env.PERF_HOT_SHEET2 ? resolve(process.env.PERF_HOT_SHEET2) : null; const cycles: any[] = [{ cycle: 0, ...(await page.evaluate(CSS_STATS)), ...(await page.evaluate(MM_STATS)) }]; for (let c = 1; c <= Number(process.env.PERF_HOT_CYCLES ?? 6); c++) { const c0 = await mainThreadMs(page); const st = await wheelSteps(page, 'y', STEPS); const scroll_ms_per_step = (await mainThreadMs(page) - c0) / st; steps += st; await spaGo(page, '/tasks'); await page.waitForFunction(() => location.pathname === '/tasks', null, { timeout: 60_000 }); await page.waitForTimeout(1000); if (sheet2) { // второй лист цикла, как в memory.spec (FORM_4/STRUCTURE): открыть, прокрутить, уйти на /tasks await spaGo(page, sheet2.url); await waitRows(page); await wheelSteps(page, 'y', STEPS); await spaGo(page, '/tasks'); await page.waitForFunction(() => location.pathname === '/tasks', null, { timeout: 60_000 }); await page.waitForTimeout(1000); } await spaGo(page, t.url); await waitRows(page); await page.waitForTimeout(1000); cycles.push({ cycle: c, scroll_ms_per_step: Math.round(scroll_ms_per_step * 10) / 10, ...(await page.evaluate(CSS_STATS)), ...(await page.evaluate(MM_STATS)) }); } mutations = undefined; (metricsExtra as any).css_cycles = cycles; } else if (action === 'filter') { await page.evaluate(ARM_MUTATIONS, CONTAINER); const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first(); await input.fill('1'); await page.waitForTimeout(FILTER_MS); mutations = await page.evaluate(COLLECT_MUTATIONS); } else { steps = await wheelSteps(page, action as 'y' | 'x', STEPS); } const mt1 = await mainThreadMs(page); const metrics: Record = { main_thread_ms: mt1 - mt0, steps, rows_visible_before: rows }; if (steps) metrics.main_thread_ms_per_step = (mt1 - mt0) / steps; const artifacts: string[] = []; if (!profiling) { /* нет профиля */ } else if (MODE === 'cpu') { const { profile } = (await cdp.send('Profiler.stop')) as any; const file = path.join(runDir(), 'artifacts', `hot-${safe(name)}-${action}.cpuprofile`); mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(profile)); artifacts.push(path.relative(runDir(), file)); } else { const p = await profileStop(page, 30); Object.assign(metrics, { commits: p.commits, total_renders: p.total_renders, total_self_ms: p.total_self_ms, renders_per_step: steps ? p.total_renders / steps : undefined, commits_per_step: steps ? p.commits / steps : undefined }); metrics.renders_top = p.top as unknown as number; // нечисловое - aggregate игнорирует } if (mutations) metrics.mutations = mutations; Object.assign(metrics, metricsExtra); append({ scenario: `hotspot_${MODE}`, target: `${name}:${action}`, role, scale: scale(), cpu_rate: 1, repeat: 0, metrics, artifacts, incomplete: false } as any); } finally { await ctx.close(); } }); }