Перечень модалок (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>
98 lines
5.6 KiB
TypeScript
98 lines
5.6 KiB
TypeScript
/**
|
||
* Замер открытия модалки (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,
|
||
};
|
||
}
|