/** * Init-script для page.addInitScript(PROBE_SCRIPT). * window.__perf.start() / stop() -> {long_task_n, long_task_ms, frame_n, heavy_frame_n, * frame_p50_ms, frame_p95_ms, frame_excess_ms, duration_ms}. * Long task: > 50 мс (PerformanceObserver 'longtask'). Тяжёлый кадр: интервал между rAF > 50 мс. * frame_excess_ms: сумма превышений интервала кадра над бюджетом 16,7 мс (60 Гц). * * Основная метрика (решение человека 2026-10-25, design.md): суммарное время main thread на действие. * Её даёт Node-сторона: startProbe(page) / stop() добавляет main_thread_ms = прирост CDP Performance.TaskDuration * (все задачи main thread: скрипт, стили, layout, paint-подготовка; задачи < 50 мс тоже) между start и концом действия * (до 100 мс ожидания longtask в __perf.stop). perStep() даёт main_thread_ms_per_step для скролла. */ import type { Page } from '@playwright/test'; import { mainThreadMs } from './cdp'; export const HEAVY_FRAME_MS = 50; export const FRAME_BUDGET_MS = 1000 / 60; export interface ProbeMetrics { long_task_n: number; long_task_ms: number; frame_n: number; heavy_frame_n: number; frame_p50_ms: number; frame_p95_ms: number; frame_excess_ms: number; duration_ms: number; } export interface ProbeResult extends ProbeMetrics { main_thread_ms: number } export const PROBE_SCRIPT = `(() => { if (window.__perf) return; const HEAVY = ${HEAVY_FRAME_MS}, BUDGET = ${FRAME_BUDGET_MS}; let active = false, t0 = 0, tasks = [], frames = [], last = 0, raf = 0; try { new PerformanceObserver((list) => { if (!active) return; for (const e of list.getEntries()) tasks.push(e.duration); }).observe({ type: 'longtask', buffered: false }); } catch (e) {} const tick = (now) => { if (!active) return; if (last) frames.push(now - last); last = now; raf = requestAnimationFrame(tick); }; const pct = (s, p) => s.length ? s[Math.max(Math.ceil(p * s.length), 1) - 1] : 0; window.__perf = { start() { tasks = []; frames = []; last = 0; t0 = performance.now(); active = true; raf = requestAnimationFrame(tick); }, async stop() { // дать долгим задачам, завершившимся перед stop, доехать до observer await new Promise((r) => setTimeout(r, 100)); active = false; cancelAnimationFrame(raf); const s = frames.slice().sort((a, b) => a - b); return { long_task_n: tasks.length, long_task_ms: tasks.reduce((a, b) => a + b, 0), frame_n: frames.length, heavy_frame_n: frames.filter((f) => f > HEAVY).length, frame_p50_ms: pct(s, 0.5), frame_p95_ms: pct(s, 0.95), frame_excess_ms: frames.reduce((a, f) => a + Math.max(0, f - BUDGET), 0), duration_ms: performance.now() - t0, }; }, }; })();`; /** * Замер действия: const p = await startProbe(page); ...действие...; const m = await p.stop(); * Требует PROBE_SCRIPT в init-скриптах страницы. main_thread_ms снимается сразу по окончании действия. */ export async function startProbe(page: Page): Promise<{ stop: () => Promise }> { const t0 = await mainThreadMs(page); await page.evaluate(() => (window as any).__perf.start()); return { stop: async () => { const t1 = await mainThreadMs(page); const m: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop()); return { ...m, main_thread_ms: t1 - t0 }; }, }; } /** Метрики на шаг скролла; при steps = 0 (фильтр, no_overflow) - пусто. */ export function perStep(m: ProbeResult, steps: number): { main_thread_ms_per_step?: number } { return steps > 0 ? { main_thread_ms_per_step: m.main_thread_ms / steps } : {}; }