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:
parent
bd4d42bffb
commit
a1251436c3
@ -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 при агрегации |
|
||||
|
||||
@ -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`.
|
||||
|
||||
9
perf/e2e/fixtures/no-overflow.html
Normal file
9
perf/e2e/fixtures/no-overflow.html
Normal 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>
|
||||
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user