From a1251436c33e35802f2d64a66c608e82e9a4aa4d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 23:03:28 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B5=D0=B2=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B0=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E,=20=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no_overflow вместо пустого complete, узкий catch по строкам, таймаут свода 2, подписка на мутации до ввода фильтра и проверка эффекта фильтра, traced=1. Refs #202 Co-Authored-By: Claude Opus 5.5 --- openspec/changes/perf-profiling/design.md | 2 + perf/README.md | 4 +- perf/e2e/fixtures/no-overflow.html | 9 +++ perf/e2e/lib/scroll.ts | 7 +- perf/e2e/scenarios/scroll.spec.ts | 93 +++++++++++++++++------ perf/e2e/tests/scroll-lib.spec.ts | 17 +++++ 6 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 perf/e2e/fixtures/no-overflow.html diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 6a3ae55..c93e2e0 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -138,3 +138,5 @@ | 2026-10-25 | Свод 2 не грузит данные сам: сценарий выбирает «Все ССП/РФ» и жмёт «Загрузить» (по умолчанию загрузка = все организации, 31–47 МБ) | Без этого таблица пуста (Svod2Page.jsx: `hasCurrentData` ложно до нажатия) | Для свода 2 измеряется самый тяжёлый вариант (все организации) | | 2026-10-25 | `json_bytes_sheet` считается по CDP `Network.dataReceived` (сумма распакованных чанков), не по `response.body()` | Для ответов 30–47 МБ `body()` падает: «evicted from inspector cache» | Отдельная CDP-сессия на странице (небольшие накладные расходы на загрузке, не на скролле) | | 2026-10-25 | Если строки появились, но их число не растёт 20 с и их < 20 — замер `incomplete:true` + `skip_reason` сразу, без ожидания `PERF_LOAD_TIMEOUT_MS` | Листы манифеста с 1 строкой (OTCH9F) иначе ждали бы 15 мин на каждый замер | Медленная отрисовка при простое > 20 с была бы принята за «мало строк» | +| 2026-10-25 | Таймаут теста: 45 мин (`PERF_TEST_TIMEOUT_MS`), для свода 2 — 3 ч (`PERF_TEST_TIMEOUT_SVOD2_MS`) | Свод 2: загрузка до 15 мин + скролл на CPU 6 ≈ 40 мин; общий 45 мин обрывал бы его | Слишком большой таймаут задержит обнаружение зависания | +| 2026-10-25 | Отсутствие переполнения по оси даёт `incomplete:true`, `skip_reason:'no_overflow'` (а не «зелёную» запись с 0 шагов); фильтр без изменения содержимого — `filter_no_effect`; повтор с трейсом помечен `metrics.traced=1` | Пустой замер не должен попасть в статистику; трейс искажает замер, Task 10 исключает такие записи | Нужно не забыть исключить traced=1 при агрегации | diff --git a/perf/README.md b/perf/README.md index d936cba..dfc6861 100644 --- a/perf/README.md +++ b/perf/README.md @@ -63,7 +63,7 @@ cd .. && .venv/bin/python -m perfkit.aggregate results/s1 - Матрица: роль `rf` x листы с `owner=rf` и отчёты проектов x оси y,x x CPU 1,4,6 x 3 повтора; `admin` x все листы x ось y x CPU x 1 повтор (только масштаб 1); `admin` и `dfip` x своды (svod1_MAIN/FORM_1..4, svod2_GO/RF, projects_summaries) x ось y x CPU x 3 повтора + одна смена фильтра на CPU 6 (target `.../filter`). - target: `FORM_x//`, `project//`, `svod/`; к записи добавляется суффикс оси `@y` / `@x` (агрегатор группирует по target). -- Переменные: `PERF_TARGETS` (glob через запятую; сопоставляется с target без суффикса оси и с полным), `PERF_CPU_RATES` (`1,4,6`), `PERF_REPEATS` (число повторов вместо матричного), `PERF_ROLES` (`rf,admin,dfip`), `PERF_AXES` (`y,x`), `PERF_LOAD_TIMEOUT_MS` (ожидание данных, по умолчанию 15 мин; ответ свода 2 до 47 МБ), `PERF_MAX_STEPS`, `PERF_TEST_TIMEOUT_MS`. +- Переменные: `PERF_TARGETS` (glob через запятую; сопоставляется с target без суффикса оси и с полным), `PERF_CPU_RATES` (`1,4,6`), `PERF_REPEATS` (число повторов вместо матричного), `PERF_ROLES` (`rf,admin,dfip`), `PERF_AXES` (`y,x`), `PERF_LOAD_TIMEOUT_MS` (ожидание данных, по умолчанию 15 мин; ответ свода 2 до 47 МБ), `PERF_MAX_STEPS`, `PERF_TEST_TIMEOUT_MS` (таймаут одного замера, по умолчанию 45 мин), `PERF_TEST_TIMEOUT_SVOD2_MS` (для свода 2, по умолчанию 3 ч: загрузка до 15 мин + скролл на CPU 6 порядка 40 мин). - Каждый замер - новый 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`. Trace первого повтора - в `perf/results//artifacts/` (30-70 МБ каждый, в git не попадают). +- Метрики: 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`. diff --git a/perf/e2e/fixtures/no-overflow.html b/perf/e2e/fixtures/no-overflow.html new file mode 100644 index 0000000..bb966c0 --- /dev/null +++ b/perf/e2e/fixtures/no-overflow.html @@ -0,0 +1,9 @@ + + +no-overflow + +
diff --git a/perf/e2e/lib/scroll.ts b/perf/e2e/lib/scroll.ts index 3ae742f..d90c25c 100644 --- a/perf/e2e/lib/scroll.ts +++ b/perf/e2e/lib/scroll.ts @@ -23,17 +23,22 @@ async function pos(page: Page, sel: string, axis: Axis) { * complete: в конце прямого прохода scrollTop + clientHeight >= scrollHeight - 2 * (для x - scrollLeft/clientWidth/scrollWidth). maxSteps ограничивает прямой проход * (при его исчерпании обратный проход не выполняется, позиция сбрасывается без замера). + * Если по оси нет переполнения (scrollHeight <= clientHeight), возвращается complete=false, steps=0, reason='no_overflow'. * steps - число шагов колеса (вниз + обратно). */ export async function scrollThrough( page: Page, containerSelector: string, axis: Axis, stepPx = 200, maxSteps = 5000, -): Promise<{ complete: boolean; steps: number }> { +): Promise<{ complete: boolean; steps: number; reason?: 'no_overflow' }> { const box = await page.locator(containerSelector).first().boundingBox(); if (!box) throw new Error(`scroll container not found: ${containerSelector}`); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); const wheel = (d: number) => (axis === 'y' ? page.mouse.wheel(0, d) : page.mouse.wheel(d, 0)); const tick = () => page.evaluate(raf); + const first = await pos(page, containerSelector, axis); + // нет переполнения по оси - скроллить нечего, замер пустой: не засчитываем как complete + if (first && first.total <= first.size) return { complete: false, steps: 0, reason: 'no_overflow' }; + let steps = 0; let complete = false; while (steps < maxSteps) { diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts index c97cbbd..73e50ce 100644 --- a/perf/e2e/scenarios/scroll.spec.ts +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -12,6 +12,7 @@ * PERF_AXES 'y,x' - оси для rf (по умолчанию y,x) * PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000) * PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000) + * PERF_TEST_TIMEOUT_MS / PERF_TEST_TIMEOUT_SVOD2_MS таймаут теста (45 мин / 3 ч для свода 2) */ import { test, type Page } from '@playwright/test'; import path from 'node:path'; @@ -78,6 +79,8 @@ function buildTargets(): Target[] { * не дожидаясь LOAD_TIMEOUT (сам по себе он нужен для медленной обработки свода 2, когда строк ещё 0). */ const SETTLE_MS = 20_000; +class RowsError extends Error {} +const skipLog: string[] = []; async function waitRows(page: Page): Promise { const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); const t0 = Date.now(); @@ -87,41 +90,58 @@ async function waitRows(page: Page): Promise { if (n >= MIN_ROWS) return n; const now = Date.now(); if (n !== last) { last = n; lastChange = now; } - if (n > 0 && now - lastChange > SETTLE_MS) throw new Error(`only ${n} rows`); - if (now - t0 > LOAD_TIMEOUT) throw new Error(`timeout, rows=${n}`); + if (n > 0 && now - lastChange > SETTLE_MS) throw new RowsError(`only ${n} rows`); + if (now - t0 > LOAD_TIMEOUT) throw new RowsError(`timeout, rows=${n}`); await page.waitForTimeout(500); } } /** - * Ждёт стабилизации: после первой мутации DOM контейнера - 2 подряд кадра без long task и без мутаций. - * Возвращает false при таймауте. + * Стабилизация после действия: armStable() ставит наблюдатели ДО действия (иначе первая мутация теряется), + * awaitStable() ждёт первой мутации контейнера и затем 2 подряд кадра без long task и без мутаций. */ -async function waitStable(page: Page, timeoutMs: number): Promise { +async function armStable(page: Page): Promise { + await page.evaluate((sel) => { + const root = (document.querySelector(sel) ?? document.body) as HTMLElement; + const st = { mutated: false, dirty: false, mo: null as MutationObserver | null, po: null as PerformanceObserver | null }; + st.mo = new MutationObserver(() => { st.mutated = true; st.dirty = true; }); + st.mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true }); + st.po = new PerformanceObserver(() => { st.dirty = true; }); + try { st.po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ } + (window as any).__stable = st; + }, CONTAINER); +} + +async function awaitStable(page: Page, timeoutMs: number): Promise { return page.evaluate( - ([sel, tmo]) => + (tmo) => new Promise((resolve) => { - const root = (document.querySelector(sel as string) ?? document.body) as HTMLElement; - let mutated = false, dirty = false, quiet = 0, done = false; - const mo = new MutationObserver(() => { mutated = true; dirty = true; }); - mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true }); - const po = new PerformanceObserver(() => { dirty = true; }); - try { po.observe({ type: 'longtask', buffered: false }); } catch { /* ignore */ } - const finish = (v: boolean) => { if (done) return; done = true; mo.disconnect(); po.disconnect(); resolve(v); }; - setTimeout(() => finish(false), tmo as number); + const st = (window as any).__stable; + let quiet = 0, done = false; + const finish = (v: boolean) => { if (done) return; done = true; st.mo.disconnect(); st.po.disconnect(); resolve(v); }; + setTimeout(() => finish(false), tmo); const tick = () => { if (done) return; - if (mutated && !dirty) quiet++; else quiet = 0; - dirty = false; + if (st.mutated && !st.dirty) quiet++; else quiet = 0; + st.dirty = false; if (quiet >= 2) return finish(true); requestAnimationFrame(tick); }; requestAnimationFrame(tick); }), - [CONTAINER, timeoutMs] as const, + timeoutMs, ); } +/** Сигнатура видимого содержимого таблицы + подпись «Найдено N из M» (если есть). */ +const signature = (page: Page) => + page.evaluate((sel) => { + const c = document.querySelector(sel) as HTMLElement | null; + const rows = Array.from(c?.querySelectorAll('tbody tr') ?? []).slice(0, 6).map((r) => r.textContent).join('|'); + const found = /Найдено[^\n]*/.exec(document.body.innerText)?.[0] ?? ''; + return rows + '#' + found; + }, CONTAINER); + const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; filter?: boolean } @@ -167,42 +187,63 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts) try { rows = await waitRows(page); } catch (e) { + if (!(e instanceof RowsError)) throw e; const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1); - append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason: `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})` }); + const skip_reason = `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})`; + skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${skip_reason}`); + append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason }); return; } await page.waitForTimeout(500); + await net.send('Network.disable').catch(() => {}); await throttle(page, cpu); const trace = repeat === 0 && !filter; const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-${role}-cpu${cpu}.trace.json`); if (trace) await startTrace(page); - let complete = true, steps = 0, probe: ProbeMetrics, stable = true; + let complete = true, steps = 0, probe: ProbeMetrics, stable = true, reason: string | undefined, effect = true; + const sigBefore = filter ? await signature(page) : ''; + if (filter) await armStable(page); await page.evaluate(() => (window as any).__perf.start()); if (filter) { const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first(); await input.fill('1'); - stable = await waitStable(page, 120_000); + stable = await awaitStable(page, 120_000); + effect = (await signature(page)) !== sigBefore; + if (!stable) reason = 'filter did not stabilize in 120s'; + else if (!effect) reason = 'filter_no_effect'; } else { - ({ complete, steps } = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS)); + const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS); + ({ complete, steps } = r); + if (r.reason) reason = r.reason; + else if (!complete) reason = 'scroll_incomplete'; } probe = await page.evaluate(() => (window as any).__perf.stop()); if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); } const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); append({ ...rec, - metrics: { ...probe, axis: axis === 'y' ? 0 : 1, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps }, + metrics: { ...probe, axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps }, artifacts, - incomplete: !complete || !stable, - ...(filter && !stable ? { skip_reason: 'filter did not stabilize in 120s' } : {}), + incomplete: !complete || !stable || !effect, + ...(reason ? { skip_reason: reason } : {}), }); + if (reason) skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${reason}`); } finally { await ctx.close(); } } +/** Таймаут теста: 45 мин по умолчанию, свод 2 (загрузка до 15 мин + скролл на CPU 6 ~40 мин) - 3 ч. */ +const testTimeout = (t: Target) => + t.kind === 'svod2' + ? Number(process.env.PERF_TEST_TIMEOUT_SVOD2_MS ?? 3 * 3600_000) + : Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000); + test.describe('scroll', () => { - test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000)); + test.afterAll(() => { + if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n')); + }); for (const t of buildTargets()) { for (const ra of t.rolesAxes) { for (const axis of ra.axes) { @@ -210,6 +251,7 @@ test.describe('scroll', () => { for (const cpu of CPU_RATES) { for (let repeat = 0; repeat < ra.repeats; repeat++) { test(`${t.name} ${ra.role} ${axis} cpu${cpu} #${repeat}`, async ({ browser }) => { + test.setTimeout(testTimeout(t)); await measure(browser, { t, role: ra.role, axis, cpu, repeat }); }); } @@ -217,6 +259,7 @@ test.describe('scroll', () => { // смена фильтра - только своды, один раз на CPU 6 if (t.kind !== 'sheet' && t.kind !== 'project' && CPU_RATES.includes(6)) { test(`${t.name} ${ra.role} filter cpu6`, async ({ browser }) => { + test.setTimeout(testTimeout(t)); await measure(browser, { t, role: ra.role, axis, cpu: 6, repeat: 0, filter: true }); }); } diff --git a/perf/e2e/tests/scroll-lib.spec.ts b/perf/e2e/tests/scroll-lib.spec.ts index 6c0cd71..4bd5735 100644 --- a/perf/e2e/tests/scroll-lib.spec.ts +++ b/perf/e2e/tests/scroll-lib.spec.ts @@ -25,3 +25,20 @@ test('scrollThrough: при исчерпании maxSteps complete === false', a expect(r.complete).toBe(false); expect(r.steps).toBeLessThanOrEqual(10); }); + +test('scrollThrough: контейнер без переполнения - complete === false, reason no_overflow, 0 шагов', async ({ page }) => { + await page.goto(fx('no-overflow.html')); + for (const axis of ['y', 'x'] as const) { + const r = await scrollThrough(page, '#box', axis); + expect(r.complete).toBe(false); + expect(r.steps).toBe(0); + expect(r.reason).toBe('no_overflow'); + } +}); + +test('scrollThrough: ось x на длинном по высоте, но не по ширине контейнере - no_overflow', async ({ page }) => { + await page.goto(fx('clipped-list.html')); + const r = await scrollThrough(page, '#box', 'x'); + expect(r.complete).toBe(false); + expect(r.reason).toBe('no_overflow'); +});