Andrey Epifancev 627ea82321 perf: замеры открытия модалок
Перечень модалок (modals.json), measureOpen с main_thread_ms, профиль
перерисовок React (reactProfile), сценарий modals.spec.ts, подкоманда
stand.sh web-profiling (:8081), генератор modals-inventory.md.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:53:55 +04:00

98 lines
5.6 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 7).
* measureOpen(page, opener, {id, appear?, timeoutMs?}) -> {click_to_visible_ms, long_task_n, long_task_ms, main_thread_ms}
*
* - начало: performance.now() в обработчике pointerdown (capture, на window);
* - появление: MutationObserver (+ проверка на каждом кадре) ловит видимый элемент по `appear`
* (по умолчанию `[role=dialog]`; у common/Modal и emotion-модалок приложения role нет, тогда css + text);
* - конец: кадр после кадра с появившейся модалкой (двойной rAF = «модалка отрисована»);
* - long tasks: PerformanceObserver 'longtask', засчитываются задачи, начавшиеся между pointerdown и концом;
* - main_thread_ms: прирост CDP Performance.TaskDuration от клика до конца окна (все задачи main thread:
* скрипт + стили + layout + paint-подготовка).
* Нет модалки за timeoutMs (5 с) - ошибка с id модалки.
*/
import type { Page } from '@playwright/test';
import { mainThreadMs } from './cdp';
export interface Appear { css?: string; text?: string }
export interface OpenOpts { id: string; appear?: Appear; timeoutMs?: number }
export interface OpenMetrics {
click_to_visible_ms: number;
long_task_n: number;
long_task_ms: number;
main_thread_ms: number;
}
export const OPEN_TIMEOUT_MS = 5000;
export async function measureOpen(page: Page, opener: () => Promise<unknown>, opts: OpenOpts): Promise<OpenMetrics> {
const timeout = opts.timeoutMs ?? OPEN_TIMEOUT_MS;
const appear: Required<Appear> = { css: opts.appear?.css ?? '[role=dialog]', text: opts.appear?.text ?? '' };
await page.evaluate(
({ css, text, tmo }) => {
const w = window as any;
const visible = () => {
for (const el of Array.from(document.querySelectorAll(css)) as HTMLElement[]) {
if (text && !(el.textContent ?? '').includes(text)) continue;
const r = el.getBoundingClientRect();
const st = getComputedStyle(el);
if (r.width > 0 && r.height > 0 && st.display !== 'none' && st.visibility !== 'hidden') return true;
}
return false;
};
const st: any = { t0: -1, tEnd: -1, tasks: [] as Array<{ start: number; dur: number }>, timedOut: false };
const onDown = () => { if (st.t0 < 0) st.t0 = performance.now(); };
window.addEventListener('pointerdown', onDown, true);
let po: PerformanceObserver | null = null;
try {
po = new PerformanceObserver((l) => { for (const e of l.getEntries()) st.tasks.push({ start: e.startTime, dur: e.duration }); });
po.observe({ type: 'longtask', buffered: false });
} catch { /* longtask недоступен */ }
let resolveShown: () => void = () => {};
st.shown = new Promise<void>((r) => { resolveShown = r; }); // кадр с модалкой отрисован (tEnd зафиксирован)
st.done = new Promise<void>((resolve) => { // + 100 мс на доставку longtask
let finished = false;
const cleanup = () => { finished = true; mo.disconnect(); window.removeEventListener('pointerdown', onDown, true); };
const end = () => {
// кадр с модалкой уже построен; следующий rAF - после его отрисовки
requestAnimationFrame(() => requestAnimationFrame(() => {
st.tEnd = performance.now();
resolveShown();
cleanup();
setTimeout(() => { po?.disconnect(); resolve(); }, 100); // longtask доставляется асинхронно
}));
};
const check = () => { if (!finished && st.t0 >= 0 && visible()) { finished = true; mo.disconnect(); end(); } };
const mo = new MutationObserver(check);
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
// страховка: модалка могла стать видимой без мутации (transition/стили)
const poll = () => { if (finished) return; check(); if (!finished && !st.timedOut) requestAnimationFrame(poll); };
requestAnimationFrame(poll);
setTimeout(() => { if (!finished) { st.timedOut = true; cleanup(); po?.disconnect(); resolveShown(); resolve(); } }, tmo);
});
w.__modalOpen = st;
},
{ ...appear, tmo: timeout },
);
const cpu0 = await mainThreadMs(page);
await opener();
// main thread меряем сразу по отрисовке модалки (без 100 мс ожидания longtask и без последующих задач страницы)
await page.evaluate(() => (window as any).__modalOpen.shown);
const cpu1 = await mainThreadMs(page);
const res = await page.evaluate(async () => {
const st = (window as any).__modalOpen;
await st.done;
return { t0: st.t0, tEnd: st.tEnd, timedOut: st.timedOut, tasks: st.tasks as Array<{ start: number; dur: number }> };
});
if (res.timedOut || res.tEnd < 0) {
throw new Error(`modal '${opts.id}' did not appear within ${timeout} ms (selector ${appear.css}${appear.text ? ` + text '${appear.text}'` : ''}, pointerdown ${res.t0 >= 0 ? 'seen' : 'not seen'})`);
}
const inWin = res.tasks.filter((t) => t.start >= res.t0 - 1 && t.start <= res.tEnd);
return {
click_to_visible_ms: res.tEnd - res.t0,
long_task_n: inWin.length,
long_task_ms: inWin.reduce((a, t) => a + t.dur, 0),
main_thread_ms: cpu1 - cpu0,
};
}