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:
parent
a24326d367
commit
627ea82321
@ -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
|
||||
|
||||
|
||||
@ -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 вырастет втрое.
|
||||
|
||||
30
perf/e2e/fixtures/modal.html
Normal file
30
perf/e2e/fixtures/modal.html
Normal 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>
|
||||
@ -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
97
perf/e2e/lib/modal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
110
perf/e2e/lib/reactProfile.ts
Normal file
110
perf/e2e/lib/reactProfile.ts
Normal 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
640
perf/e2e/modals.json
Normal 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": "Экспорт ВСП"
|
||||
}
|
||||
}
|
||||
]
|
||||
134
perf/e2e/scenarios/modals.spec.ts
Normal file
134
perf/e2e/scenarios/modals.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
76
perf/e2e/tests/modal-lib.spec.ts
Normal file
76
perf/e2e/tests/modal-lib.spec.ts
Normal 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);
|
||||
});
|
||||
94
perf/perfkit/modals_inventory.py
Normal file
94
perf/perfkit/modals_inventory.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
44
perf/tests/test_modals_inventory.py
Normal file
44
perf/tests/test_modals_inventory.py
Normal 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")))
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user