perf: исправления сценариев скролла (ревью, раунд 1)

no_overflow вместо пустого complete, узкий catch по строкам, таймаут свода 2,
подписка на мутации до ввода фильтра и проверка эффекта фильтра, traced=1.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 23:03:28 +04:00
parent bd4d42bffb
commit a1251436c3
6 changed files with 104 additions and 28 deletions

View File

@ -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 при агрегации |

View File

@ -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/<sheet>/<direction|null>`, `project/<report_type>/<project_id>`, `svod/<name>`; к записи добавляется суффикс оси `@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/<run_id>/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/<run_id>/artifacts/` (30-70 МБ каждый, в git не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.

View File

@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>no-overflow</title>
<style>
body { margin: 0 }
#box { width: 600px; height: 400px; overflow: auto; border: 1px solid #888 }
#inner { height: 300px; width: 500px; background: #eee }
</style>
<div id="box"><div id="inner"></div></div>

View File

@ -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) {

View File

@ -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<number> {
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<number> {
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<boolean> {
async function armStable(page: Page): Promise<void> {
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<boolean> {
return page.evaluate(
([sel, tmo]) =>
(tmo) =>
new Promise<boolean>((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 });
});
}

View File

@ -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');
});