diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 9080120..0d8582c 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -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` | Троттлинг отключён по умолчанию, но `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 diff --git a/perf/README.md b/perf/README.md index 4ebd1bc..1799ff9 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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//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; `@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 вырастет втрое. diff --git a/perf/e2e/fixtures/modal.html b/perf/e2e/fixtures/modal.html new file mode 100644 index 0000000..cec7a21 --- /dev/null +++ b/perf/e2e/fixtures/modal.html @@ -0,0 +1,30 @@ + + +modal + + + + + diff --git a/perf/e2e/lib/cdp.ts b/perf/e2e/lib/cdp.ts index 81fa369..e42cdbb 100644 --- a/perf/e2e/lib/cdp.ts +++ b/perf/e2e/lib/cdp.ts @@ -61,3 +61,11 @@ export async function heapSnapshot(page: Page, file: string): Promise { s.off('HeapProfiler.addHeapSnapshotChunk', onChunk); await new Promise((res) => out.end(res)); } + +/** Суммарное время задач main thread (CDP Performance.TaskDuration), мс; монотонный счётчик страницы. */ +export async function mainThreadMs(page: Page): Promise { + 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; +} diff --git a/perf/e2e/lib/modal.ts b/perf/e2e/lib/modal.ts new file mode 100644 index 0000000..488b0c4 --- /dev/null +++ b/perf/e2e/lib/modal.ts @@ -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, opts: OpenOpts): Promise { + const timeout = opts.timeoutMs ?? OPEN_TIMEOUT_MS; + const appear: Required = { 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((r) => { resolveShown = r; }); // кадр с модалкой отрисован (tEnd зафиксирован) + st.done = new Promise((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, + }; +} diff --git a/perf/e2e/lib/reactProfile.ts b/perf/e2e/lib/reactProfile.ts new file mode 100644 index 0000000..f74bc9e --- /dev/null +++ b/perf/e2e/lib/reactProfile.ts @@ -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 { + 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 { + 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 { + 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'; diff --git a/perf/e2e/modals.json b/perf/e2e/modals.json new file mode 100644 index 0000000..6213032 --- /dev/null +++ b/perf/e2e/modals.json @@ -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": "Экспорт ВСП" + } + } +] diff --git a/perf/e2e/scenarios/modals.spec.ts b/perf/e2e/scenarios/modals.spec.ts new file mode 100644 index 0000000..c05f49a --- /dev/null +++ b/perf/e2e/scenarios/modals.spec.ts @@ -0,0 +1,134 @@ +/** + * Сценарии открытия модалок (Task 7). Перечень - perf/e2e/modals.json (строится по коду web/src, проверен на стенде). + * + * Параметры (env): + * PERF_SCALE масштаб манифеста (по умолчанию 1) + * PERF_RUN_ID каталог прогона perf/results/ + * 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); + }); + } + } + } + } +}); diff --git a/perf/e2e/tests/modal-lib.spec.ts b/perf/e2e/tests/modal-lib.spec.ts new file mode 100644 index 0000000..53ec8b7 --- /dev/null +++ b/perf/e2e/tests/modal-lib.spec.ts @@ -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); +}); diff --git a/perf/perfkit/modals_inventory.py b/perf/perfkit/modals_inventory.py new file mode 100644 index 0000000..b7ac35d --- /dev/null +++ b/perf/perfkit/modals_inventory.py @@ -0,0 +1,94 @@ +"""Перечень модалок: python -m perfkit.modals_inventory -> perf/report-data//modals-inventory.md. + +Источник - perf/e2e/modals.json (строится по коду web/src); если в есть modal.jsonl, в таблицу +добавляются n и медиана click_to_visible_ms по каждому target (включая варианты `@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 ", 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()) diff --git a/perf/stand.sh b/perf/stand.sh index 72fa325..8a4e413 100755 --- a/perf/stand.sh +++ b/perf/stand.sh @@ -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 diff --git a/perf/tests/test_modals_inventory.py b/perf/tests/test_modals_inventory.py new file mode 100644 index 0000000..ce7c0cd --- /dev/null +++ b/perf/tests/test_modals_inventory.py @@ -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"))) diff --git a/perf/web/rsbuild.perf.config.ts b/perf/web/rsbuild.perf.config.ts index 06d4c09..6d05c07 100644 --- a/perf/web/rsbuild.perf.config.ts +++ b/perf/web/rsbuild.perf.config.ts @@ -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',