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>
This commit is contained in:
Andrey Epifancev 2026-10-26 00:53:55 +04:00
parent a24326d367
commit 627ea82321
13 changed files with 1269 additions and 4 deletions

View File

@ -150,6 +150,13 @@
| 2026-10-25 | Только вертикальный скролл (ось y), роль `rf` | Бриф: «скролл при соавторе»; горизонтальная ось не зависит от потока входящих правок | Нет замера оси x при соавторе |
| 2026-10-25 | Вспомогательный `waitRows` продублирован в `scroll-coauthor.spec.ts`, а не вынесен в lib/ | Не трогать `scroll.spec.ts` Task 5; функция короткая | Дубли при правке |
| 2026-10-25 | Фильтр свода по умолчанию мерится на `max(PERF_CPU_RATES)` (при умолчании `1`), имя теста `filter cpu<N>` | Троттлинг отключён по умолчанию, но `PERF_CPU_RATES=1,4,6` должен вести себя как прежде | Нет |
| 2026-10-25 | `main_thread_ms` модалки = прирост CDP `Performance.TaskDuration` (`Performance.getMetrics`) между кликом и кадром с отрисованной модалкой (без 100 мс ожидания longtask) | TaskDuration включает скрипт, стили, layout, paint-подготовку; не зависит от порога 50 мс long task | Метрика чуть шире «реакции React»: в окно попадают фоновые задачи страницы |
| 2026-10-25 | Признак появления модалки в `measureOpen` — `opts.appear {css, text}` (по умолчанию `[role=dialog]`); для common/Modal и emotion-модалок (`ModalBackdrop`) без role используется `h2` + заголовок | В приложении role=dialog есть только у MUI Dialog; иначе замер падал бы по таймауту | Ложное срабатывание на одноимённом заголовке вне модалки |
| 2026-10-25 | Клик в замере — `page.mouse.click` по координатам после `hover` и `scrollIntoViewIfNeeded`; каждый замер в новом browser context | Иначе `locator.click()` включает в окно проверки actionability | Нет |
| 2026-10-25 | Профилирующая сборка собирается с `minify:false` и портом из `PERF_WEB_PORT` (8081); `stand.sh web-profiling` | Без неминификации `displayName` компонентов превращаются в однобуквенные имена | Другой размер бандла, чем у prod: сравнимы только счётчики рендеров |
| 2026-10-25 | Рендер компонента в коммите: `flags & PerformedWork` для тегов 0/1/11/15 + обход как в DevTools; `self_ms = actualDuration - сумма детей` | Единственный способ получить renders/self_ms без React DevTools extension | Приближение: не учитывает memo-bailout'ы вне обхода |
| 2026-10-25 | `modals.json`: Menu/Popover-контекстные меню (RowActionMenu, StagesTable Menu, ValidationErrorsAlert) не включены; Popover выбора колонок (`sheet_columns_tree`) включён как единственный оверлей, доступный на большом листе | Меню не модалки; select_vsp/select_expense/create_* на самом большом листе rf (FORM_2/AHR) не открываются (проверено на стенде) | Неполное покрытие «модалка поверх большого листа» |
| 2026-10-25 | Для открытия под admin без права на лист — роль admin только для модалок страниц /tasks, задач и админки; остальные под rf | Права строк есть у rf | Нет |
## Decisions made with the human

View File

@ -67,3 +67,20 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1
- Каждый замер - новый browser context; CPU-троттлинг включается после загрузки данных (до скролла). Перед замером ждём >= 20 строк `tbody tr`; если не дождались - запись `incomplete: true` с `skip_reason`.
- Метрики: probe (long tasks, кадры), `axis` (0 = y, 1 = x), `rows_visible_before/after`, `json_bytes_sheet` (размер распакованного ответа загрузки данных из сетевого лога), `steps`. Записи с `skip_reason` (`no_overflow`, `rows<20`, `filter_no_effect`, `scroll_incomplete`...) всегда `incomplete:true`; в конце прогона они выводятся списком. Метрика `traced=1` - замер с включённым трейсом (повтор 0), Task 10 может исключать его из статистики. Trace первого повтора - в `perf/results/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.
### Модалки
Перечень - `perf/e2e/modals.json` (id, page, source `file:line`, `open{selector, action, pre}`, priority, роль; `skip_reason` для недостижимых из UI). `lib/modal.ts` (`measureOpen`: `click_to_visible_ms`, `long_task_n`, `long_task_ms`, `main_thread_ms` = прирост CDP `Performance.TaskDuration` от клика до отрисовки), `lib/reactProfile.ts` (минимальный `__REACT_DEVTOOLS_GLOBAL_HOOK__`: renders и self_ms по компонентам, нужна профилирующая сборка). Самопроверка: `npx playwright test tests/modal-lib.spec.ts`.
```
cd perf/e2e
PERF_RUN_ID=m PERF_PRIORITY=1 PERF_REPEATS=2 npx playwright test scenarios/modals.spec.ts # priority 1 (по умолчанию 5 повторов; priority 2 - 3)
PERF_RUN_ID=m PERF_PRIORITY=2 npx playwright test scenarios/modals.spec.ts
../stand.sh web-profiling # профилирующая сборка на :8081
PERF_WEB=http://localhost:8081 PERF_PROFILING=1 PERF_RUN_ID=m PERF_MODALS='sheet_*,projects_create' npx playwright test scenarios/modals.spec.ts # scenario modal_renders
cd .. && .venv/bin/python -m perfkit.modals_inventory results/m # report-data/m/modals-inventory.md
```
- Переменные: `PERF_MODALS` (glob по id; `<id>@big` - вариант на самом большом листе), `PERF_PRIORITY` (`1`, `2`, `1,2`), `PERF_ROLES`, `PERF_REPEATS`, `PERF_CPU_RATES` (по умолчанию `1`).
- Каждый замер - новый browser context. Записи: `scenario: modal` (метрики `measureOpen`) и `modal_renders` (`metrics.renders_top` - топ-20 по self_ms, `commits`, `total_renders`).
- Оценка полной матрицы на CPU 1: priority 1 (17 целей x 5) около 6 мин, priority 2 (15 x 3) около 2 мин, profiling около 2 мин; всего 10-12 мин. С `PERF_CPU_RATES=1,4,6` priority 1 вырастет втрое.

View File

@ -0,0 +1,30 @@
<!doctype html>
<meta charset="utf-8">
<title>modal</title>
<button id="open">open</button>
<button id="open-busy">open busy</button>
<button id="open-plain">open plain div</button>
<button id="nothing">nothing</button>
<script>
const busy = (ms) => { const t = performance.now(); while (performance.now() - t < ms) {} };
const dialog = () => {
const d = document.createElement('div');
d.setAttribute('role', 'dialog');
d.style.cssText = 'position:fixed;left:50px;top:50px;width:200px;height:100px;background:#eee';
d.textContent = 'dialog';
document.body.appendChild(d);
};
// 150 мс до появления role=dialog
document.getElementById('open').addEventListener('click', () => setTimeout(dialog, 150));
// 90 мс синхронной работы (long task) + 150 мс до появления
document.getElementById('open-busy').addEventListener('click', () => { busy(90); setTimeout(dialog, 150); });
// модалка без role (как common/Modal приложения): div.fixed с заголовком
document.getElementById('open-plain').addEventListener('click', () => setTimeout(() => {
const d = document.createElement('div');
d.className = 'plain-modal';
d.style.cssText = 'position:fixed;left:50px;top:50px;width:200px;height:100px;background:#eee';
d.innerHTML = '<h2>Заголовок модалки</h2>';
document.body.appendChild(d);
}, 150));
document.getElementById('nothing').addEventListener('click', () => {});
</script>

View File

@ -61,3 +61,11 @@ export async function heapSnapshot(page: Page, file: string): Promise<void> {
s.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
await new Promise<void>((res) => out.end(res));
}
/** Суммарное время задач main thread (CDP Performance.TaskDuration), мс; монотонный счётчик страницы. */
export async function mainThreadMs(page: Page): Promise<number> {
const s = await cdp(page);
await s.send('Performance.enable');
const r = (await s.send('Performance.getMetrics')) as { metrics: Array<{ name: string; value: number }> };
return (r.metrics.find((m) => m.name === 'TaskDuration')?.value ?? 0) * 1000;
}

97
perf/e2e/lib/modal.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Замер открытия модалки (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,
};
}

View File

@ -0,0 +1,110 @@
/**
* Профиль перерисовок React через минимальный __REACT_DEVTOOLS_GLOBAL_HOOK__ (Task 7).
* Нужна профилирующая сборка (react-dom/profiling): только в ней fiber'ы несут actualDuration.
* Init-script REACT_HOOK_SCRIPT ставится ДО загрузки приложения (ctx.addInitScript).
* profileStart(page) / profileStop(page) -> {commits, top: [{component, renders, self_ms}] (топ-20 по self_ms), ...}.
*
* Правило «компонент отрисовался в коммите»: fiber.flags & PerformedWork (1) для function/class/forwardRef/
* simple-memo (теги 0, 1, 11, 15). Обход как у DevTools: в потомков идём, только если fiber новый (нет alternate)
* или fiber.child !== alternate.child (иначе поддерево пропущено bailout'ом, а его флаги устарели).
* self_ms = actualDuration - сумма actualDuration обойдённых детей.
*/
export const REACT_HOOK_SCRIPT = `(() => {
if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__) return;
const COMPONENT_TAGS = { 0: 1, 1: 1, 11: 1, 15: 1 };
const st = {
active: false, renderers: 0, commitsTotal: 0, commits: 0, sawActualDuration: false,
stats: new Map(),
};
const nameOf = (f) => {
const t = f.type;
if (typeof t === 'function') return t.displayName || t.name || '(anonymous)';
if (t && typeof t === 'object') {
const r = t.render;
const n = t.displayName || (r && (r.displayName || r.name));
return n ? (f.tag === 11 ? 'ForwardRef(' + n + ')' : n) : '(anonymous)';
}
return '(anonymous)';
};
const visit = (root) => {
const stack = [root];
while (stack.length) {
const f = stack.pop();
const alt = f.alternate;
const descend = alt === null || alt === undefined || f.child !== alt.child;
if (COMPONENT_TAGS[f.tag] && (f.flags & 1) === 1) {
let kids = 0;
if (descend) for (let c = f.child; c; c = c.sibling) kids += c.actualDuration || 0;
const self = Math.max((f.actualDuration || 0) - kids, 0);
const name = nameOf(f);
const s = st.stats.get(name) || { renders: 0, self_ms: 0 };
s.renders += 1; s.self_ms += self;
st.stats.set(name, s);
}
if (descend) for (let c = f.child; c; c = c.sibling) stack.push(c);
}
};
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject(r) { st.renderers += 1; this.renderers.set(st.renderers, r); return st.renderers; },
checkDCE() {},
onScheduleFiberRoot() {},
onCommitFiberUnmount() {},
onPostCommitFiberRoot() {},
onCommitFiberRoot(id, root) {
st.commitsTotal += 1;
// профилирующая сборка: у HostRoot есть числовой actualDuration (в prod-сборке поля нет)
if (!st.sawActualDuration) { const cur = root.current; if (cur && typeof cur.actualDuration === 'number' && cur.actualDuration >= 0) st.sawActualDuration = true; }
if (!st.active) return;
st.commits += 1;
try { visit(root.current); } catch (e) { st.error = String(e); }
},
};
window.__reactProfile = st;
})();`;
export interface RenderRow { component: string; renders: number; self_ms: number }
export interface ProfileResult {
commits: number;
total_renders: number;
total_self_ms: number;
top: RenderRow[];
error?: string;
}
export interface HookInfo { renderers: number; commits_total: number; saw_actual_duration: boolean }
export async function profileStart(page: Page_): Promise<void> {
await page.evaluate(() => {
const st = (window as any).__reactProfile;
if (!st) throw new Error('React hook is not installed (REACT_HOOK_SCRIPT)');
st.stats = new Map(); st.commits = 0; st.error = undefined; st.active = true;
});
}
export async function profileStop(page: Page_, topN = 20): Promise<ProfileResult> {
return page.evaluate((n) => {
const st = (window as any).__reactProfile;
st.active = false;
const rows = Array.from(st.stats.entries() as Iterable<[string, { renders: number; self_ms: number }]>)
.map(([component, v]) => ({ component, renders: v.renders, self_ms: v.self_ms }))
.sort((a, b) => b.self_ms - a.self_ms || b.renders - a.renders);
return {
commits: st.commits as number,
total_renders: rows.reduce((a, r) => a + r.renders, 0),
total_self_ms: rows.reduce((a, r) => a + r.self_ms, 0),
top: rows.slice(0, n),
...(st.error ? { error: st.error as string } : {}),
};
}, topN);
}
export async function hookInfo(page: Page_): Promise<HookInfo> {
return page.evaluate(() => {
const st = (window as any).__reactProfile;
return { renderers: st?.renderers ?? 0, commits_total: st?.commitsTotal ?? 0, saw_actual_duration: !!st?.sawActualDuration };
});
}
import type { Page as Page_ } from '@playwright/test';

640
perf/e2e/modals.json Normal file
View File

@ -0,0 +1,640 @@
[
{
"id": "sheet_select_vsp",
"title": "Выбор ВСП (добавление строки)",
"page": "sheet",
"source": "web/src/components/RealtimeTable/Modals/SelectVspModal.jsx:51",
"priority": 1,
"role": "rf",
"sheet_ref": "FORM_2/AHR_RENT/null",
"open": {
"selector": "button[aria-label=\"Добавить строку\"]",
"action": "click"
},
"appear": {
"css": "h2",
"text": "Выбор ВСП"
},
"note": "открывается из RealtimeTable.jsx:652; только при праве править строки (rf); на самом большом листе rf (FORM_2/AHR, 1154 строк) модалка не открывается (проверено: кнопка «Добавить строку» добавляет строку, ВСП-выбор только для ветки isVspAdditionRow)"
},
{
"id": "sheet_select_expense",
"title": "Добавление строки (статья расходов)",
"page": "sheet",
"source": "web/src/components/RealtimeTable/Modals/SelectExpenseItemModal.jsx:51",
"priority": 1,
"role": "rf",
"sheet_ref": "FORM_4/AHR/null",
"open": {
"selector": "button[aria-label=\"Добавить строку\"]",
"action": "click",
"pre": [
{
"selector": "tbody tr:has-text(\"ПроектAHR\") >> nth=0 >> td >> nth=1",
"action": "click"
}
]
},
"appear": {
"css": "h2",
"text": "Добавление строки"
},
"note": "нужна выделенная строка проекта (row_type ITEM); RealtimeTable.jsx:656"
},
{
"id": "sheet_create_program",
"title": "Создание программы",
"page": "sheet",
"source": "web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx:38",
"priority": 1,
"role": "rf",
"sheet_ref": "FORM_4/AHR/null",
"open": {
"selector": "button[aria-label=\"Добавить программу\"]",
"action": "click"
},
"appear": {
"css": "h2",
"text": "Создание программы"
},
"note": "RealtimeTable.jsx:697, только FORM_4"
},
{
"id": "sheet_create_project",
"title": "Создание проекта (в программе)",
"page": "sheet",
"source": "web/src/components/RealtimeTable/Modals/CreateProgramProjectModal.jsx:38",
"priority": 1,
"role": "rf",
"sheet_ref": "FORM_4/AHR/null",
"open": {
"selector": "button[aria-label=\"Добавить проект\"]",
"action": "click",
"pre": [
{
"selector": "tbody tr:has-text(\"ПрограммаAHR\") >> nth=0 >> td >> nth=1",
"action": "click"
}
]
},
"appear": {
"css": "h2",
"text": "Создание проекта"
},
"note": "нужна выделенная строка программы (GROUP); RealtimeTable.jsx:705"
},
{
"id": "sheet_columns_tree",
"title": "Выбор столбцов (Popover с деревом)",
"page": "sheet",
"source": "web/src/components/common/CollapsibleTree.jsx:203",
"priority": 1,
"role": "rf",
"sheet_ref": "FORM_2/AHR_RENT/null",
"open": {
"selector": "[data-pin-panel] input[readonly]",
"action": "click"
},
"appear": {
"css": ".MuiPopover-root .MuiPaper-root"
},
"big_sheet": "FORM_2/AHR/null",
"note": "не модалка, а MUI Popover: единственный оверлей, доступный на больших листах; замер на малом и на самом большом листе rf"
},
{
"id": "task_stages",
"title": "Этапы задачи (SettingModal)",
"page": "task",
"source": "web/src/components/Stages/SettingModal.jsx:102",
"priority": 1,
"role": "rf",
"page_ref": "task_rf_form_2",
"open": {
"selector": "role=button[name=\"Этапы\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Этапы"
},
"note": "TaskPage.jsx:171"
},
{
"id": "task_stage_add",
"title": "Добавление этапа (задача)",
"page": "task",
"source": "web/src/components/Stages/AddStagesModal.jsx:156",
"priority": 1,
"role": "admin",
"page_ref": "task_rf_form_2",
"open": {
"selector": "role=button[name=\"Добавить этап\"]",
"action": "click",
"pre": [
{
"selector": "role=button[name=\"Этапы\"]",
"action": "click"
}
]
},
"appear": {
"css": "h2",
"text": "Добавление этапа"
},
"note": "кнопка только у admin"
},
{
"id": "task_stage_edit",
"title": "Редактирование этапа (задача)",
"page": "task",
"source": "web/src/components/Stages/AddStagesModal.jsx:156",
"priority": 1,
"role": "admin",
"page_ref": "task_rf_form_2",
"open": {
"selector": "role=menuitem[name=\"Редактировать\"]",
"action": "click",
"pre": [
{
"selector": "role=button[name=\"Этапы\"]",
"action": "click"
},
{
"selector": ".fixed.inset-0.z-50 button.MuiIconButton-root >> nth=0",
"action": "click"
}
]
},
"appear": {
"css": "h2",
"text": "Редактирование этапа"
},
"note": "меню строки этапа только у admin"
},
{
"id": "project_stages",
"title": "Этапы отчёта проекта (SettingModal)",
"page": "project",
"source": "web/src/components/Stages/SettingModal.jsx:102",
"priority": 1,
"role": "rf",
"page_ref": "project_card",
"open": {
"selector": "role=button[name=\"Этапы\"] >> nth=0",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Этапы"
},
"note": "TaskPage.jsx:195 (по строке отчёта)"
},
{
"id": "project_add_year",
"title": "Добавить год",
"page": "project",
"source": "web/src/pages/TaskPage/TaskPage.jsx:217",
"priority": 1,
"role": "rf",
"page_ref": "project_card",
"open": {
"selector": "role=button[name=\"Добавить год\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Вы точно хотите добавить год"
}
},
{
"id": "project_stage_delete",
"title": "Удаление этапа (проект)",
"page": "project",
"source": "web/src/components/Stages/DeleteStageModal.jsx:38",
"priority": 1,
"role": "admin",
"page_ref": "project_card",
"open": {
"selector": "role=menuitem[name=\"Удалить\"]",
"action": "click",
"pre": [
{
"selector": "role=button[name=\"Этапы\"] >> nth=0",
"action": "click"
},
{
"selector": ".fixed.inset-0.z-50 button.MuiIconButton-root >> nth=0",
"action": "click"
}
]
},
"appear": {
"css": ".MuiTypography-root",
"text": "Удалить этот элемент?"
},
"note": "удаление этапа только в режиме project; открытие без подтверждения"
},
{
"id": "projects_create",
"title": "Создание проекта",
"page": "projects",
"source": "web/src/pages/ProjectsPage/ModalCreateProject.jsx:247",
"priority": 1,
"role": "rf",
"page_ref": "projects",
"open": {
"selector": "role=button[name=\"Создать проект\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Создание проекта"
},
"note": "NavigationProjectPage.jsx:504"
},
{
"id": "projects_edit",
"title": "Редактирование строки проекта (MUI Dialog)",
"page": "projects",
"source": "web/src/pages/ProjectPages/components/EditModal/EditModal.jsx:170",
"priority": 1,
"role": "rf",
"page_ref": "projects",
"open": {
"selector": "role=button[name=\"Редактировать\"] >> nth=0",
"action": "click"
},
"appear": {
"css": "[role=dialog]",
"text": "Редактирование строки"
},
"note": "NavigationProjectPage.jsx:496"
},
{
"id": "projects_delete",
"title": "Удалить проект?",
"page": "projects",
"source": "web/src/pages/ProjectPages/NavigationProjectPage.jsx:510",
"priority": 1,
"role": "rf",
"page_ref": "projects",
"open": {
"selector": "role=button[name=\"Удалить проект\"] >> nth=0",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Удалить проект?"
}
},
{
"id": "tasks_create",
"title": "Создать задачу",
"page": "tasks",
"source": "web/src/pages/TasksPage/TasksPage.jsx:374",
"priority": 1,
"role": "admin",
"page_ref": "tasks",
"open": {
"selector": "role=button[name=\"Создать задачу\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Создать задачу"
},
"note": "кнопка только у admin"
},
{
"id": "tasks_confirm_mark",
"title": "Отправить задачу на удаление (подтверждение)",
"page": "tasks",
"source": "web/src/pages/TasksPage/TasksTable.jsx:371",
"priority": 1,
"role": "admin",
"page_ref": "tasks",
"open": {
"selector": "text=Отправить на удаление",
"action": "click",
"pre": [
{
"selector": "role=button[name^=\"Действия с задачей\"] >> nth=0",
"action": "click"
}
]
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Отправить задачу на удаление"
},
"note": "меню действий только у admin"
},
{
"id": "forms_create",
"title": "Создание формы (FormsPage)",
"page": "forms",
"source": "web/src/pages/FormsPage.jsx:133",
"priority": 1,
"role": "admin",
"open": {
"selector": "",
"action": "click"
},
"skip_reason": "страница FormsPage не подключена в app/Routes.jsx - открыть из UI нельзя"
},
{
"id": "taskcard_confirm",
"title": "Подтверждение действия над карточкой задачи",
"page": "forms",
"source": "web/src/components/common/TaskCardComponent.jsx:265",
"priority": 1,
"role": "admin",
"open": {
"selector": "",
"action": "click"
},
"skip_reason": "TaskCardComponent используется только в FormsPage (не в маршрутах)"
},
{
"id": "infoheader_rename",
"title": "Переименование таблицы (InfoForHeader)",
"page": "forms",
"source": "web/src/pages/TableTempPage/InfoForHeader.jsx:44",
"priority": 1,
"role": "admin",
"open": {
"selector": "",
"action": "click"
},
"skip_reason": "TableTempPage не в маршрутах, ModalRename не импортирован - мёртвый код"
},
{
"id": "admin_users_add",
"title": "Добавить пользователей",
"page": "admin_users",
"source": "web/src/pages/AdminPanel/UsersPage/ModalAddUsers.jsx:71",
"priority": 2,
"role": "admin",
"page_ref": "admin_users",
"open": {
"selector": "role=button[name=\"Добавить\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Добавить пользователей"
}
},
{
"id": "admin_users_edit",
"title": "Информация о пользователе",
"page": "admin_users",
"source": "web/src/pages/AdminPanel/UsersPage/UserTable/EditUserModal.jsx:311",
"priority": 2,
"role": "admin",
"page_ref": "admin_users",
"open": {
"selector": "role=menuitem[name=\"Редактировать\"]",
"action": "click",
"pre": [
{
"selector": "tbody tr >> nth=1 >> button >> nth=-1",
"action": "click"
}
]
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Информация о пользователе"
}
},
{
"id": "admin_users_delete",
"title": "Удалить пользователя?",
"page": "admin_users",
"source": "web/src/pages/AdminPanel/UsersPage/UserTable/DeleteUserModal.jsx:48",
"priority": 2,
"role": "admin",
"page_ref": "admin_users",
"open": {
"selector": "role=menuitem[name=\"Удалить\"]",
"action": "click",
"pre": [
{
"selector": "tbody tr >> nth=1 >> button >> nth=-1",
"action": "click"
}
]
},
"appear": {
"css": ".MuiTypography-root",
"text": "Удалить пользователя?"
}
},
{
"id": "admin_users_ssp",
"title": "Выбор подразделения пользователя",
"page": "admin_users",
"source": "web/src/pages/AdminPanel/UsersPage/ModalEditUserSsp.jsx:93",
"priority": 2,
"role": "admin",
"page_ref": "admin_users",
"open": {
"selector": "tbody button:has-text(\"Выбрать\") >> nth=0",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Выбор подразделения"
}
},
{
"id": "admin_ssps_add",
"title": "Добавить ССП/РФ",
"page": "admin_ssps",
"source": "web/src/pages/AdminPanel/SppsPage/ModalAddSsp.jsx:51",
"priority": 2,
"role": "admin",
"page_ref": "admin_ssps",
"open": {
"selector": "role=button[name=\"Добавить\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Добавить ССП/Рег. филиал"
}
},
{
"id": "admin_ssps_edit",
"title": "Редактировать ССП/РФ",
"page": "admin_ssps",
"source": "web/src/pages/AdminPanel/SppsPage/ModalEditSsp.jsx:41",
"priority": 2,
"role": "admin",
"page_ref": "admin_ssps",
"open": {
"selector": "tbody tr >> nth=0 >> button >> nth=-1",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Редактировать ССП/Рег. филиал"
}
},
{
"id": "admin_ssps_users",
"title": "Список пользователей ССП/РФ",
"page": "admin_ssps",
"source": "web/src/pages/AdminPanel/SppsPage/ModalChangeUsersInSsp.jsx:121",
"priority": 2,
"role": "admin",
"page_ref": "admin_ssps",
"open": {
"selector": "tbody tr >> nth=0 >> button >> nth=0",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Редактировать список пользователей"
}
},
{
"id": "admin_ssps_archive",
"title": "Подтверждение архивации ССП",
"page": "admin_ssps",
"source": "web/src/pages/AdminPanel/SppsPage/ModalConfirmArchiveSsp.jsx:27",
"priority": 2,
"role": "admin",
"page_ref": "admin_ssps",
"open": {
"selector": "tbody tr >> nth=0 >> button:has-text(\"Активен\")",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Отправить ссп в архив"
}
},
{
"id": "admin_phase_add",
"title": "Добавление этапа (шаблон)",
"page": "admin_default_stage",
"source": "web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplateDialog.jsx:120",
"priority": 2,
"role": "admin",
"page_ref": "admin_default_stage",
"open": {
"selector": "role=button[name=\"Добавить этап\"]",
"action": "click"
},
"appear": {
"css": "[role=dialog]",
"text": "Добавление этапа"
}
},
{
"id": "admin_phase_edit",
"title": "Редактирование этапа (шаблон)",
"page": "admin_default_stage",
"source": "web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplateDialog.jsx:120",
"priority": 2,
"role": "admin",
"page_ref": "admin_default_stage",
"open": {
"selector": "role=button[name^=\"Редактировать этап\"] >> nth=0",
"action": "click"
},
"appear": {
"css": "[role=dialog]",
"text": "Редактирование этапа"
}
},
{
"id": "admin_phase_delete",
"title": "Удалить предопределённый этап?",
"page": "admin_default_stage",
"source": "web/src/pages/AdminPanel/PhaseTemplatesPage/components/DeletePhaseTemplateDialog.jsx:8",
"priority": 2,
"role": "admin",
"page_ref": "admin_default_stage",
"open": {
"selector": "role=button[name^=\"Удалить этап\"] >> nth=0",
"action": "click"
},
"appear": {
"css": "[role=dialog]",
"text": "Удалить предопределённый этап"
}
},
{
"id": "admin_audit_json",
"title": "Детали события аудита",
"page": "admin_audit_log",
"source": "web/src/pages/AdminPanel/AuditLogsPage/components/JsonViewModal.jsx:95",
"priority": 2,
"role": "admin",
"page_ref": "admin_audit_log",
"open": {
"selector": "text=Посмотреть >> nth=0",
"action": "click"
},
"appear": {
"css": ".MuiModal-root",
"text": "Детали события"
},
"note": "MUI Modal; страница грузится дольше"
},
{
"id": "dict_vsp_add",
"title": "Добавление ВСП",
"page": "dict_vsp",
"source": "web/src/pages/DictVspPage/components/Modals/ModalEditAddVsp.jsx:239",
"priority": 2,
"role": "admin",
"page_ref": "dict_vsp",
"open": {
"selector": "role=button[name=\"+ Добавить объект\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Добавление ВСП"
}
},
{
"id": "dict_vsp_edit",
"title": "Редактирование ВСП",
"page": "dict_vsp",
"source": "web/src/pages/DictVspPage/components/Modals/ModalEditAddVsp.jsx:239",
"priority": 2,
"role": "admin",
"page_ref": "dict_vsp",
"open": {
"selector": "tbody tr >> nth=0 >> button >> nth=-1",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Редактирование ВСП"
},
"note": "нужны строки справочника (после выбора ССП)"
},
{
"id": "dict_vsp_export",
"title": "Экспорт ВСП",
"page": "dict_vsp",
"source": "web/src/pages/DictVspPage/components/Modals/ModalExport.jsx:71",
"priority": 2,
"role": "admin",
"page_ref": "dict_vsp",
"open": {
"selector": "role=button[name=\"Экспортировать\"]",
"action": "click"
},
"appear": {
"css": ".fixed.inset-0.z-50",
"text": "Экспорт ВСП"
}
}
]

View File

@ -0,0 +1,134 @@
/**
* Сценарии открытия модалок (Task 7). Перечень - perf/e2e/modals.json (строится по коду web/src, проверен на стенде).
*
* Параметры (env):
* PERF_SCALE масштаб манифеста (по умолчанию 1)
* PERF_RUN_ID каталог прогона perf/results/<run_id>
* PERF_MODALS glob(ы) через запятую по id модалки (по умолчанию все); у варианта на большом листе id оканчивается на @big
* PERF_PRIORITY '1' (по умолчанию), '2' или '1,2'
* PERF_REPEATS число повторов вместо матричного (priority 1 - 5, priority 2 - 3, profiling - 1)
* PERF_CPU_RATES '1' по умолчанию (без троттлинга, решение человека); '1,4,6' - вручную
* PERF_ROLES фильтр ролей открытия (rf,admin)
* PERF_PROFILING=1 профилирующий прогон: PERF_WEB должен указывать на профилирующую сборку (:8081);
* пишет scenario 'modal_renders' (metrics.renders_top - топ-20 компонентов по self_ms)
* PERF_TEST_TIMEOUT_MS таймаут одного замера (по умолчанию 3 мин)
*/
import { test, type Browser, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { loadManifest, scale, type Manifest, type User } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { throttle } from '../lib/cdp';
import { measureOpen, type Appear } from '../lib/modal';
import { REACT_HOOK_SCRIPT, hookInfo, profileStart, profileStop } from '../lib/reactProfile';
import { append } from '../lib/results';
interface Step { selector: string; action: 'click' }
interface ModalDef {
id: string; title: string; page: string; source: string; priority: 1 | 2; role: string;
page_ref?: string; sheet_ref?: string; big_sheet?: string;
open: { selector: string; action: 'click'; pre?: Step[] };
appear?: Appear; skip_reason?: string; note?: string;
}
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 PRIORITIES = envList('PERF_PRIORITY', ['1']).map(Number);
const ROLES = envList('PERF_ROLES', ['rf', 'admin']);
const PROFILING = process.env.PERF_PROFILING === '1';
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const ID_RES = envList('PERF_MODALS', ['*']).map(globRe);
const repeatsFor = (prio: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : PROFILING ? 1 : prio === 1 ? 5 : 3);
const TEST_TIMEOUT = Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 60_000);
const defs: ModalDef[] = JSON.parse(readFileSync(path.resolve(__dirname, '../modals.json'), 'utf-8'));
const sheetKey = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
function urlFor(m: Manifest, d: ModalDef, sheetRef?: string): string {
const sref = sheetRef ?? d.sheet_ref;
if (sref) {
const cands = m.sheets.filter((s) => sheetKey(s) === sref);
const s = cands.find((x) => x.owner === 'rf') ?? cands[0];
if (!s) throw new Error(`sheet ${sref} not in manifest`);
return s.url;
}
const p = m.modal_pages.find((x) => x.name === d.page_ref);
if (!p) throw new Error(`modal page ${d.page_ref} not in manifest`);
return p.url;
}
/** Страница готова: опенер виден, у листов - загружены строки; затем короткая пауза на «остывание» рендеров. */
async function prepare(page: Page, d: ModalDef, isSheet: boolean) {
const first = d.open.pre?.[0]?.selector ?? d.open.selector;
await page.locator(first).first().waitFor({ state: 'visible', timeout: 120_000 });
if (isSheet) await page.locator('tbody tr').first().waitFor({ state: 'visible', timeout: 120_000 });
await page.waitForTimeout(isSheet ? 3000 : 1500);
}
async function measure(browser: Browser, d: ModalDef, target: string, sheetRef: string | undefined, role: string, cpu: number, repeat: number) {
const m = loadManifest();
const user: User = m.users[role];
const rec = { scenario: PROFILING ? 'modal_renders' : 'modal', target, role, scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
try {
await loginAs(ctx, user);
if (PROFILING) await ctx.addInitScript(REACT_HOOK_SCRIPT);
const page = await ctx.newPage();
await page.goto(WEB + urlFor(m, d, sheetRef));
try {
await prepare(page, d, !!(sheetRef ?? d.sheet_ref));
for (const s of d.open.pre ?? []) {
await page.locator(s.selector).first().click({ timeout: 15_000 });
await page.waitForTimeout(600);
}
const opener = page.locator(d.open.selector).first();
await opener.waitFor({ state: 'visible', timeout: 15_000 });
// клик Playwright сам скроллит/наводит курсор: делаем это заранее, чтобы окно main thread не включало подготовку
await opener.scrollIntoViewIfNeeded();
await opener.hover();
await page.waitForTimeout(400);
await throttle(page, cpu);
if (PROFILING) {
const info = await hookInfo(page);
if (!info.saw_actual_duration) throw new Error(`not a profiling build (renderers=${info.renderers}, commits=${info.commits_total}, no actualDuration) at ${WEB}`);
await profileStart(page);
}
// клик мышью по координатам (курсор уже наведён): locator.click() добавляет в окно замера свои проверки actionability
const box = await opener.boundingBox();
if (!box) throw new Error('opener has no bounding box');
const r = await measureOpen(page, () => page.mouse.click(box.x + box.width / 2, box.y + box.height / 2), { id: d.id, appear: d.appear });
if (PROFILING) {
await page.waitForTimeout(500); // догоняющие рендеры (загрузка данных модалки)
const p = await profileStop(page);
append({ ...rec, metrics: { ...r, commits: p.commits, total_renders: p.total_renders, total_self_ms: p.total_self_ms, renders_top: p.top as any }, artifacts: [], incomplete: !!p.error, ...(p.error ? { skip_reason: p.error } : {}) });
} else {
append({ ...rec, metrics: { ...r }, artifacts: [] });
}
} catch (e) {
const msg = String(e instanceof Error ? e.message : e).split('\n')[0].slice(0, 300);
append({ ...rec, metrics: {}, artifacts: [], incomplete: true, skip_reason: msg });
console.log(`INCOMPLETE ${target} ${role} cpu${cpu} #${repeat}: ${msg}`);
}
} finally {
await ctx.close();
}
}
test.describe(PROFILING ? 'modal_renders' : 'modal', () => {
for (const d of defs) {
if (d.skip_reason || !PRIORITIES.includes(d.priority) || !ROLES.includes(d.role)) continue;
const variants: Array<{ target: string; sheetRef?: string }> = [{ target: d.id }];
if (d.big_sheet) variants.push({ target: `${d.id}@big`, sheetRef: d.big_sheet });
for (const v of variants) {
if (!ID_RES.some((r) => r.test(v.target))) continue;
for (const cpu of CPU_RATES) {
for (let repeat = 0; repeat < repeatsFor(d.priority); repeat++) {
test(`${v.target} ${d.role} cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(TEST_TIMEOUT);
await measure(browser, d, v.target, v.sheetRef ?? d.sheet_ref, d.role, cpu, repeat);
});
}
}
}
}
});

View File

@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
import path from 'node:path';
import { measureOpen } from '../lib/modal';
import { REACT_HOOK_SCRIPT, profileStart, profileStop } from '../lib/reactProfile';
const fx = (n: string) => 'file://' + path.resolve(__dirname, '../fixtures', n);
test('measureOpen: role=dialog через 150 мс - click_to_visible_ms в [150, 400], main_thread_ms > 0 и <= click_to_visible_ms', async ({ page }) => {
await page.goto(fx('modal.html'));
const r = await measureOpen(page, () => page.click('#open'), { id: 'fixture_open' });
expect(r.click_to_visible_ms).toBeGreaterThanOrEqual(150);
expect(r.click_to_visible_ms).toBeLessThanOrEqual(400);
expect(r.main_thread_ms).toBeGreaterThan(0);
expect(r.main_thread_ms).toBeLessThanOrEqual(r.click_to_visible_ms);
expect(r.long_task_n).toBe(0);
});
test('measureOpen: синхронная работа 90 мс даёт long task и main_thread_ms >= 90', async ({ page }) => {
await page.goto(fx('modal.html'));
const r = await measureOpen(page, () => page.click('#open-busy'), { id: 'fixture_busy' });
expect(r.long_task_n).toBeGreaterThanOrEqual(1);
expect(r.long_task_ms).toBeGreaterThanOrEqual(80);
expect(r.main_thread_ms).toBeGreaterThanOrEqual(85);
expect(r.main_thread_ms).toBeLessThanOrEqual(r.click_to_visible_ms);
});
test('measureOpen: произвольный признак (css + text) вместо role=dialog', async ({ page }) => {
await page.goto(fx('modal.html'));
const r = await measureOpen(page, () => page.click('#open-plain'), { id: 'fixture_plain', appear: { css: '.plain-modal h2', text: 'Заголовок' } });
expect(r.click_to_visible_ms).toBeGreaterThanOrEqual(150);
// неверный текст - не считается появлением
await page.reload();
await expect(
measureOpen(page, () => page.click('#open-plain'), { id: 'fixture_wrong_text', appear: { css: '.plain-modal h2', text: 'Нет такого' }, timeoutMs: 1500 }),
).rejects.toThrow(/fixture_wrong_text/);
});
test('measureOpen: кнопка без модалки падает по таймауту 5 с, id модалки в сообщении', async ({ page }) => {
await page.goto(fx('modal.html'));
const t0 = Date.now();
await expect(measureOpen(page, () => page.click('#nothing'), { id: 'fixture_none' })).rejects.toThrow(/fixture_none/);
const dt = Date.now() - t0;
expect(dt).toBeGreaterThanOrEqual(4800);
expect(dt).toBeLessThan(9000);
});
test('reactProfile: хук получает коммиты, топ-20 по displayName с renders и self_ms', async ({ page }) => {
await page.addInitScript(REACT_HOOK_SCRIPT);
await page.goto(fx('modal.html'));
await profileStart(page);
// синтетический коммит: App(10 мс, self 4) -> [Row(3), Row(3)], без alternate => новые fiber'ы
await page.evaluate(() => {
const mk = (name: string, actual: number, children: any[] = [], flags = 1) => {
const f: any = { tag: 0, type: Object.assign(function () {}, { displayName: name }), flags, actualDuration: actual, child: null, sibling: null, alternate: null };
children.forEach((c, i) => { c.return = f; if (i === 0) f.child = c; else children[i - 1].sibling = c; });
return f;
};
const root: any = { current: { tag: 3, flags: 0, actualDuration: 0, alternate: null, child: null, sibling: null } };
root.current.child = mk('App', 10, [mk('Row', 3), mk('Row', 3)]);
root.current.child.return = root.current;
const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
const id = hook.inject({ version: 'test' });
hook.onCommitFiberRoot(id, root, 0, false);
hook.onCommitFiberRoot(id, root, 0, false);
});
const p = await profileStop(page);
expect(p.commits).toBe(2);
const row = p.top.find((x) => x.component === 'Row')!;
const app = p.top.find((x) => x.component === 'App')!;
expect(row.renders).toBe(4);
expect(row.self_ms).toBeCloseTo(12, 5);
expect(app.renders).toBe(2);
expect(app.self_ms).toBeCloseTo(8, 5);
expect(p.top[0].component).toBe('Row'); // сортировка по self_ms
expect(p.top.length).toBeLessThanOrEqual(20);
});

View File

@ -0,0 +1,94 @@
"""Перечень модалок: python -m perfkit.modals_inventory <run_dir> -> perf/report-data/<run_id>/modals-inventory.md.
Источник - perf/e2e/modals.json (строится по коду web/src); если в <run_dir> есть modal.jsonl, в таблицу
добавляются n и медиана click_to_visible_ms по каждому target (включая варианты `<id>@big`).
"""
from __future__ import annotations
import json
import statistics
import sys
from pathlib import Path
from . import PERF_DIR, REPORT_DATA_DIR
from .results import read_all
MODALS_JSON = PERF_DIR / "e2e" / "modals.json"
def load_modals(path: Path = MODALS_JSON) -> list[dict]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _measured(run_dir) -> dict[str, list[float]]:
out: dict[str, list[float]] = {}
if run_dir is None:
return out
for r in read_all(run_dir):
if r.get("scenario") != "modal" or r.get("incomplete"):
continue
v = r.get("metrics", {}).get("click_to_visible_ms")
if isinstance(v, (int, float)):
out.setdefault(r["target"], []).append(v)
return out
def build(modals: list[dict], run_dir=None) -> list[dict]:
meas = _measured(run_dir)
rows = []
for m in modals:
row = {k: m.get(k) for k in ("id", "title", "page", "source", "priority", "role", "skip_reason", "note")}
row["open"] = m["open"]["selector"]
row["pre"] = len(m["open"].get("pre") or [])
vals = meas.get(m["id"], [])
if m.get("skip_reason"):
row.update(status="skip", n=0, median_ms=None)
elif vals:
row.update(status="measured", n=len(vals), median_ms=statistics.median(vals))
else:
row.update(status="not_run", n=0, median_ms=None)
rows.append(row)
if m.get("big_sheet"):
bv = meas.get(f"{m['id']}@big", [])
rows.append({**row, "id": f"{m['id']}@big", "title": f"{m['title']} (самый большой лист {m['big_sheet']})",
"status": "measured" if bv else "not_run", "n": len(bv),
"median_ms": statistics.median(bv) if bv else None})
return rows
def render_md(rows: list[dict]) -> str:
lines = [
"# Перечень модалок",
"",
"Источник: `perf/e2e/modals.json` (поиск по `web/src`: `common/Modal`, emotion-модалки `ModalBackdrop`, MUI `Dialog`/`Modal`/`Popover`).",
"priority 1 - листы форм, задача, проект, проекты, задачи; priority 2 - админка и справочник ВСП.",
"",
"| id | страница | source | priority | роль | открытие | статус | n | медиана, мс | skip_reason |",
"|---|---|---|---|---|---|---|---|---|---|",
]
for r in rows:
med = "" if r["median_ms"] is None else f"{r['median_ms']:.0f}"
opn = f"`{r['open']}`" + (f" (+{r['pre']} шаг.)" if r["pre"] else "") if r["open"] else ""
lines.append(
f"| `{r['id']}` | {r['page']} | `{r['source']}` | {r['priority']} | {r['role']} | {opn} | {r['status']} | {r['n']} | {med} | {r.get('skip_reason') or ''} |"
)
skipped = sum(1 for r in rows if r["status"] == "skip")
lines += ["", f"Всего: {len(rows)}, без skip_reason: {len(rows) - skipped}, skip_reason: {skipped}."]
return "\n".join(lines) + "\n"
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: python -m perfkit.modals_inventory <run_dir>", file=sys.stderr)
return 2
run_dir = Path(argv[0])
out = REPORT_DATA_DIR / run_dir.resolve().name
out.mkdir(parents=True, exist_ok=True)
(out / "modals-inventory.md").write_text(render_md(build(load_modals(), run_dir)), encoding="utf-8")
print(out / "modals-inventory.md")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Стенд профилирования: up | migrate | bootstrap | api | web-build | web | smoke | reset | down | all
# Стенд профилирования: up | migrate | bootstrap | api | web-build | web | web-profiling | smoke | reset | down | all
set -euo pipefail
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
cd "$(dirname "$0")/.."
@ -71,6 +71,12 @@ case "$cmd" in
start_bg web "$ROOT/web" npx rsbuild preview -c ../perf/web/rsbuild.perf.config.ts
wait_http "$PERF_WEB/" 60
;;
web-profiling)
# профилирующая сборка (react-dom/profiling, без минификации) на :8081, prod на :8080 не трогаем
[ -f "$PERF/.build/profiling/index.html" ] || (cd "$ROOT/web" && PERF_PROFILING=1 npx rsbuild build -c ../perf/web/rsbuild.perf.config.ts)
PERF_PROFILING=1 PERF_WEB_PORT=8081 start_bg web-profiling "$ROOT/web" npx rsbuild preview -c ../perf/web/rsbuild.perf.config.ts
wait_http "http://localhost:8081/" 60
;;
smoke)
venv
"$VENV/bin/python" "$PERF/stand/smoke.py"
@ -87,7 +93,7 @@ case "$cmd" in
"$SELF" up; "$SELF" migrate; "$SELF" bootstrap; "$SELF" api
;;
down)
for n in web api; do
for n in web web-profiling api; do
if [ -f "$RUN/$n.pid" ]; then
pkill -P "$(cat "$RUN/$n.pid")" 2>/dev/null || true
kill "$(cat "$RUN/$n.pid")" 2>/dev/null || true
@ -100,6 +106,6 @@ case "$cmd" in
"$SELF" up; "$SELF" migrate; "$SELF" bootstrap; "$SELF" web-build; "$SELF" api; "$SELF" web; "$SELF" smoke
;;
*)
echo "usage: $0 up|migrate|bootstrap|api|web-build|web|smoke|reset|down|all" >&2; exit 2
echo "usage: $0 up|migrate|bootstrap|api|web-build|web|web-profiling|smoke|reset|down|all" >&2; exit 2
;;
esac

View File

@ -0,0 +1,44 @@
import json
from perfkit import results
from perfkit.modals_inventory import build, render_md, load_modals
MODALS = [
{"id": "a", "title": "A", "page": "task", "source": "web/src/a.jsx:1", "priority": 1, "role": "rf",
"open": {"selector": "button", "action": "click"}},
{"id": "b", "title": "B", "page": "forms", "source": "web/src/b.jsx:2", "priority": 1, "role": "admin",
"open": {"selector": "", "action": "click"}, "skip_reason": "мёртвый код"},
{"id": "c", "title": "C", "page": "admin_users", "source": "web/src/c.jsx:3", "priority": 2, "role": "admin",
"open": {"selector": "x", "action": "click"}, "big_sheet": "FORM_2/AHR/null"},
]
def rec(target, v, **kw):
r = {"run_id": "r", "scenario": "modal", "target": target, "role": "rf", "scale": 1, "cpu_rate": 1,
"repeat": 0, "metrics": {"click_to_visible_ms": v, "main_thread_ms": v + 5}, "artifacts": [], "incomplete": False, "ts": "x"}
r.update(kw)
return r
def test_build_marks_skipped_and_measured(tmp_path):
for v in (10, 30, 20):
results.append(tmp_path, rec("a", v))
results.append(tmp_path, rec("a", 999, incomplete=True, metrics={}))
rows = {r["id"]: r for r in build(MODALS, tmp_path)}
assert rows["a"]["status"] == "measured" and rows["a"]["n"] == 3 and rows["a"]["median_ms"] == 20
assert rows["b"]["status"] == "skip" and rows["b"]["skip_reason"] == "мёртвый код"
assert rows["c"]["status"] == "not_run"
def test_render_md_table(tmp_path):
md = render_md(build(MODALS, None))
assert "| id |" in md and "`a`" in md and "мёртвый код" in md
assert md.count("\n|") >= 4 # заголовок + разделитель + 3 строки
def test_load_real_modals_json_is_consistent():
ms = load_modals()
ids = [m["id"] for m in ms]
assert len(ids) == len(set(ids))
for m in ms:
assert m["priority"] in (1, 2) and m["source"].startswith("web/src/")
assert m.get("skip_reason") or (m["open"]["selector"] and (m.get("page_ref") or m.get("sheet_ref")))

View File

@ -8,10 +8,12 @@ const root = path.resolve(__dirname, '..', '.build', profiling ? 'profiling' : '
export default mergeRsbuildConfig(baseConfig, {
output: {
distPath: { root },
// профилирующая сборка: имена компонентов нужны для отчёта «что перерисовывается» (lib/reactProfile.ts)
...(profiling ? { minify: false } : {}),
},
resolve: profiling ? { alias: { 'react-dom$': 'react-dom/profiling' } } : {},
server: {
port: 8080,
port: Number(process.env.PERF_WEB_PORT ?? 8080),
proxy: {
'/api': {
target: 'http://localhost:8000',