diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 6188eea..6a3ae55 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -126,3 +126,15 @@ | 2026-10-25 | `probe.stop()` ждёт 100 мс перед сбором (PerformanceObserver доставляет longtask асинхронно) | Иначе последняя длинная задача перед `stop` теряется | +100 мс к duration_ms | | 2026-10-25 | `heapUsed` делает GC перед `Runtime.getHeapUsage`; `startTrace/stopTrace` через `browser.startTracing` Playwright | Стабильные значения кучи; штатный API трассы | Трасса содержит события всего браузера | | 2026-10-25 | В `.gitignore` добавлены `!perf/e2e/lib/` и `perf/e2e/test-results/` | Корневой `lib/` игнорирует каталог библиотек каркаса; test-results — артефакты Playwright | Библиотеки не попадут в коммит / мусор в коммите | +| 2026-10-25 | Ось и фильтр кодируются в `target` суффиксом (`.../AHR/null@y`, `svod//filter@y`); `axis` дополнительно пишется в метрики (0 = y, 1 = x) | Агрегатор группирует по `target`, а не по оси: без суффикса оси y и x смешались бы в одну группу | Другие имена target, чем в брифе; Task 6+ должны учитывать суффикс | +| 2026-10-25 | Target отчёта проекта — `project//` (в плане `project/{report_type}`); target листа — `//` | В манифесте 12 отчётов с 6 одинаковыми `report_type`: без project_id замеры разных проектов смешались бы | Другие имена target, чем в брифе | +| 2026-10-25 | Admin-контроль идёт по всем листам манифеста (включая owner=ssp), rf — только по owner=rf; svod1 — вкладки MAIN, FORM_1..4, svod2 — GO и RF, `svods_navigator` не измеряется | Админ видит все листы; навигатор — не таблица | Объём матрицы | +| 2026-10-25 | Вкладка свода выбирается кликом `role=tab` по индексу, `#hash` из манифеста игнорируется | Приложение не читает hash (`useState('MAIN')` в SvodPage/Svod2Page): `#FORM_1` открывал MAIN | Если порядок вкладок изменится, замер уйдёт на другую вкладку | +| 2026-10-25 | CPU-троттлинг включается после загрузки данных (после ≥ 20 строк), перед скроллом | Измеряется скролл, а не загрузка 31–47 МБ свода под замедлением | Загрузка не троттлится | +| 2026-10-25 | Не набрались 20 строк за `PERF_LOAD_TIMEOUT_MS` (15 мин по умолчанию) — запись `incomplete:true` с `skip_reason`, тест не падает | Матрица на часы не должна обрываться на одной странице; пропуск виден в данных | Пропуск надо заметить в итоговой таблице | +| 2026-10-25 | Контейнер скролла — `.MuiTableContainer-root` (первый на странице); `testDir` Playwright расширен до `{tests,scenarios}/**/*.spec.ts` | Подтверждено на стенде: единственный скроллируемый элемент; сценарии лежат в `scenarios/` | Модалка с таблицей перехватила бы селектор | +| 2026-10-25 | `json_bytes_sheet` — размер распакованного тела ответа загрузки данных (максимум по совпавшим URL) | Брифом задан «размер ответа»; распакованный — то, что парсит JS | Не равен размеру на проводе (gzip) | +| 2026-10-25 | Смена фильтра свода = ввод `1` в поле поиска, стабильность = ≥ 1 мутация DOM контейнера + 2 подряд кадра без long task и мутаций (таймаут 120 с) | Фильтр «ССП/РФ»/год требуют данных, которых может не быть; поиск стабилен и клиентский | Поиск может быть дешевле смены организации/года | +| 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 с была бы принята за «мало строк» | diff --git a/perf/README.md b/perf/README.md index 78295cb..d936cba 100644 --- a/perf/README.md +++ b/perf/README.md @@ -49,3 +49,21 @@ npx playwright test tests/harness.spec.ts # самопро - `PERF_API`, `PERF_WEB` — адреса стенда (`http://localhost:8000`, `http://localhost:8080`). - Агрегация: `cd perf && .venv/bin/python -m perfkit.aggregate results/`. - Библиотеки `perf/e2e/lib/`: `auth.loginAs`, `cdp.*` (throttle, trace, gc, heap), `probe` (long tasks > 50 мс, кадры rAF; тяжёлый кадр — интервал > 50 мс), `results.append`, `manifest.loadManifest`. + +### Сценарии скролла + +`perf/e2e/scenarios/scroll.spec.ts`, библиотека `lib/scroll.ts` (`scrollThrough`: колесо мыши шагами по 200 px вниз/вправо до конца и обратно, `complete` = `scrollTop + clientHeight >= scrollHeight - 2`). Самопроверка: `npx playwright test tests/scroll-lib.spec.ts`. + +``` +cd perf/e2e +PERF_RUN_ID=s1 PERF_SCALE=1 npx playwright test scenarios/scroll.spec.ts # вся матрица масштаба (долго!) +PERF_RUN_ID=t PERF_TARGETS='FORM_2/AHR/*' PERF_CPU_RATES=1 PERF_REPEATS=1 npx playwright test scenarios/scroll.spec.ts +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`. +- Каждый замер - новый 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 не попадают). +- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`. diff --git a/perf/e2e/fixtures/clipped-list.html b/perf/e2e/fixtures/clipped-list.html new file mode 100644 index 0000000..4b4432e --- /dev/null +++ b/perf/e2e/fixtures/clipped-list.html @@ -0,0 +1,9 @@ + + +clipped-list + +
diff --git a/perf/e2e/fixtures/long-list.html b/perf/e2e/fixtures/long-list.html new file mode 100644 index 0000000..ccaea59 --- /dev/null +++ b/perf/e2e/fixtures/long-list.html @@ -0,0 +1,9 @@ + + +long-list + +
diff --git a/perf/e2e/lib/results.ts b/perf/e2e/lib/results.ts index 5c8f84e..9590db2 100644 --- a/perf/e2e/lib/results.ts +++ b/perf/e2e/lib/results.ts @@ -26,6 +26,7 @@ export interface ResultRecord { metrics: Record; artifacts: string[]; incomplete?: boolean; + skip_reason?: string; ts?: string; } diff --git a/perf/e2e/lib/scroll.ts b/perf/e2e/lib/scroll.ts new file mode 100644 index 0000000..3ae742f --- /dev/null +++ b/perf/e2e/lib/scroll.ts @@ -0,0 +1,64 @@ +import type { Page } from '@playwright/test'; + +export type Axis = 'y' | 'x'; + +const raf = () => new Promise((r) => requestAnimationFrame(() => r())); + +async function pos(page: Page, sel: string, axis: Axis) { + return page.evaluate( + ([s, a]) => { + const el = document.querySelector(s as string) as HTMLElement | null; + if (!el) return null; + return a === 'y' + ? { p: el.scrollTop, size: el.clientHeight, total: el.scrollHeight } + : { p: el.scrollLeft, size: el.clientWidth, total: el.scrollWidth }; + }, + [sel, axis] as const, + ); +} + +/** + * Прокрутка колесом мыши шагами stepPx: вниз (вправо) до конца и обратно. + * После каждого шага ждём requestAnimationFrame. + * complete: в конце прямого прохода scrollTop + clientHeight >= scrollHeight - 2 + * (для x - scrollLeft/clientWidth/scrollWidth). maxSteps ограничивает прямой проход + * (при его исчерпании обратный проход не выполняется, позиция сбрасывается без замера). + * steps - число шагов колеса (вниз + обратно). + */ +export async function scrollThrough( + page: Page, containerSelector: string, axis: Axis, stepPx = 200, maxSteps = 5000, +): Promise<{ complete: boolean; steps: number }> { + 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); + + let steps = 0; + let complete = false; + while (steps < maxSteps) { + const s = await pos(page, containerSelector, axis); + if (!s) break; + if (s.p + s.size >= s.total - 2) { complete = true; break; } + await wheel(stepPx); await tick(); steps++; + } + // финальная проверка (последний шаг мог дойти до конца) + if (!complete) { + const s = await pos(page, containerSelector, axis); + complete = !!s && s.p + s.size >= s.total - 2; + } + if (!complete) { + await page.evaluate(([s, a]) => { + const el = document.querySelector(s as string) as HTMLElement; + if (a === 'y') el.scrollTop = 0; else el.scrollLeft = 0; + }, [containerSelector, axis] as const); + return { complete, steps }; + } + // обратно + while (steps < 2 * maxSteps) { + const s = await pos(page, containerSelector, axis); + if (!s || s.p <= 2) break; + await wheel(-stepPx); await tick(); steps++; + } + return { complete, steps }; +} diff --git a/perf/e2e/playwright.config.ts b/perf/e2e/playwright.config.ts index 4d80eec..821a01c 100644 --- a/perf/e2e/playwright.config.ts +++ b/perf/e2e/playwright.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ - testDir: './tests', + testDir: '.', + testMatch: '{tests,scenarios}/**/*.spec.ts', workers: 1, fullyParallel: false, retries: 0, diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts new file mode 100644 index 0000000..c97cbbd --- /dev/null +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -0,0 +1,226 @@ +/** + * Сценарии скролла (Task 5). + * + * Параметры (env): + * PERF_SCALE масштаб манифеста (по умолчанию 1) + * PERF_TARGETS glob(ы) через запятую по target, напр. 'FORM_1/*,svod/*' (по умолчанию все); + * сопоставляется с target без суффикса оси и с полным (`.../@y`) + * PERF_RUN_ID каталог прогона perf/results/ + * PERF_REPEATS число повторов вместо матричного (3 для rf/свод, 1 для admin-контроля) + * PERF_CPU_RATES '1,4,6' - список CPU throttling rate + * PERF_ROLES фильтр ролей через запятую (rf,admin,dfip) + * PERF_AXES 'y,x' - оси для rf (по умолчанию y,x) + * PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000) + * PERF_MAX_STEPS лимит шагов прямого прохода (по умолчанию 5000) + */ +import { test, type Page } from '@playwright/test'; +import path from 'node:path'; +import { loadManifest, scale, type User } from '../lib/manifest'; +import { loginAs, WEB } from '../lib/auth'; +import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe'; +import { throttle, startTrace, stopTrace } from '../lib/cdp'; +import { append, runDir } from '../lib/results'; +import { scrollThrough, type Axis } from '../lib/scroll'; + +const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page +const MIN_ROWS = 20; +const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); +const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000); + +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', '4', '6']).map(Number); +const ROLES = envList('PERF_ROLES', ['rf', 'admin', 'dfip']); +const AXES = envList('PERF_AXES', ['y', 'x']) as Axis[]; +const repeats = (dflt: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : dflt); + +const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); +const TARGET_RES = envList('PERF_TARGETS', ['*']).map(globRe); +const wanted = (t: string, axis: Axis) => TARGET_RES.some((r) => r.test(t) || r.test(`${t}@${axis}`)); + +type Kind = 'sheet' | 'project' | 'svod1' | 'svod2' | 'summaries'; +interface Target { + name: string; // target без оси + kind: Kind; + url: string; // без hash: приложение его не читает + tabIndex?: number; // вкладка свода (клик по role=tab) + respRe: RegExp; // ответ загрузки данных (для json_bytes_sheet) + rolesAxes: Array<{ role: string; axes: Axis[]; repeats: number }>; +} + +const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; + +function buildTargets(): Target[] { + const m = loadManifest(); + const out: Target[] = []; + const hasRole = (r: string) => ROLES.includes(r) && !!m.users[r]; + for (const s of m.sheets) { + const ra: Target['rolesAxes'] = []; + if (s.owner === 'rf' && hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) }); + if (scale() === 1 && hasRole('admin')) ra.push({ role: 'admin', axes: ['y'], repeats: repeats(1) }); + out.push({ name: sheetName(s), kind: 'sheet', url: s.url, respRe: new RegExp(`/api/v1/form/${s.form_id}/sheet/${s.sheet}(\\?|$)`), rolesAxes: ra }); + } + for (const p of m.project_reports) { + const ra: Target['rolesAxes'] = []; + if (hasRole('rf')) ra.push({ role: 'rf', axes: AXES, repeats: repeats(3) }); + out.push({ name: `project/${p.report_type}/${p.project_id}`, kind: 'project', url: p.url, respRe: new RegExp(`/api/v1/projects/${p.project_id}/report/${p.year}/${p.report_type}(\\?|$)`), rolesAxes: ra }); + } + const svodRoles = ['admin', 'dfip'].filter(hasRole).map((role) => ({ role, axes: ['y'] as Axis[], repeats: repeats(3) })); + const svod1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4']; + svod1.forEach((sh, i) => out.push({ name: `svod/svod1_${sh}`, kind: 'svod1', url: '/svod', tabIndex: i, respRe: new RegExp(`/api/v1/svod/1/${sh}/`), rolesAxes: svodRoles })); + ['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: new RegExp(`/api/v1/svod/2/${sh}/`), rolesAxes: svodRoles })); + out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles }); + return out; +} + +/** + * Ждём >= MIN_ROWS строк тела таблицы; возвращает фактическое число. + * Если строки появились, но их число не растёт SETTLE_MS (на странице реально < MIN_ROWS строк) - бросаем ошибку, + * не дожидаясь LOAD_TIMEOUT (сам по себе он нужен для медленной обработки свода 2, когда строк ещё 0). + */ +const SETTLE_MS = 20_000; +async function waitRows(page: Page): Promise { + const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); + const t0 = Date.now(); + let last = -1, lastChange = t0; + for (;;) { + const n = await count(); + 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}`); + await page.waitForTimeout(500); + } +} + +/** + * Ждёт стабилизации: после первой мутации DOM контейнера - 2 подряд кадра без long task и без мутаций. + * Возвращает false при таймауте. + */ +async function waitStable(page: Page, timeoutMs: number): Promise { + return page.evaluate( + ([sel, 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 tick = () => { + if (done) return; + if (mutated && !dirty) quiet++; else quiet = 0; + dirty = false; + if (quiet >= 2) return finish(true); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), + [CONTAINER, timeoutMs] as const, + ); +} + +const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); + +interface RunOpts { t: Target; role: string; axis: Axis; cpu: number; repeat: number; filter?: boolean } + +async function measure(browser: import('@playwright/test').Browser, o: RunOpts) { + const { t, role, axis, cpu, repeat, filter } = o; + const m = loadManifest(); + const user: User = m.users[role]; + const targetName = (filter ? `${t.name}/filter` : t.name) + `@${axis}`; + const rec = { scenario: 'scroll', target: targetName, role, scale: scale(), cpu_rate: cpu, repeat }; + const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); + const artifacts: string[] = []; + try { + await loginAs(ctx, user); + await ctx.addInitScript(PROBE_SCRIPT); + const page = await ctx.newPage(); + // размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived + // (response.body() для 30-47 МБ недоступен: "evicted from inspector cache") + let jsonBytes = 0; + const net = await ctx.newCDPSession(page); + await net.send('Network.enable'); + const reqUrl = new Map(); + const reqBytes = new Map(); + net.on('Network.requestWillBeSent', (e: any) => { reqUrl.set(e.requestId, e.request.url); }); + net.on('Network.dataReceived', (e: any) => { + const u = reqUrl.get(e.requestId); + if (u && t.respRe.test(u)) { + const v = (reqBytes.get(e.requestId) ?? 0) + e.dataLength; + reqBytes.set(e.requestId, v); + jsonBytes = Math.max(jsonBytes, v); + } + }); + await page.goto(WEB + t.url); + if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click(); + if (t.kind === 'svod2') { + // свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx) + await page.getByLabel('ССП/РФ').click(); + await page.getByRole('option', { name: 'Все ССП/РФ' }).click(); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Загрузить' }).click(); + } + let rows: number; + try { + rows = await waitRows(page); + } catch (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()})` }); + return; + } + await page.waitForTimeout(500); + 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; + 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); + } else { + ({ complete, steps } = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS)); + } + 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 }, + artifacts, + incomplete: !complete || !stable, + ...(filter && !stable ? { skip_reason: 'filter did not stabilize in 120s' } : {}), + }); + } finally { + await ctx.close(); + } +} + +test.describe('scroll', () => { + test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000)); + for (const t of buildTargets()) { + for (const ra of t.rolesAxes) { + for (const axis of ra.axes) { + if (!wanted(t.name, axis)) continue; + 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 }) => { + await measure(browser, { t, role: ra.role, axis, cpu, repeat }); + }); + } + } + // смена фильтра - только своды, один раз на CPU 6 + if (t.kind !== 'sheet' && t.kind !== 'project' && CPU_RATES.includes(6)) { + test(`${t.name} ${ra.role} filter cpu6`, async ({ browser }) => { + 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 new file mode 100644 index 0000000..6c0cd71 --- /dev/null +++ b/perf/e2e/tests/scroll-lib.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import path from 'node:path'; +import { scrollThrough } from '../lib/scroll'; + +const fx = (n: string) => 'file://' + path.resolve(__dirname, '../fixtures', n); + +test('scrollThrough: длинный список y проходится до конца', async ({ page }) => { + await page.goto(fx('long-list.html')); + const r = await scrollThrough(page, '#box', 'y'); + expect(r.complete).toBe(true); + expect(r.steps).toBeGreaterThanOrEqual(100); + // вернулись к началу + expect(await page.evaluate(() => document.querySelector('#box')!.scrollTop)).toBeLessThanOrEqual(2); +}); + +test('scrollThrough: ось x', async ({ page }) => { + await page.goto(fx('long-list.html')); + const r = await scrollThrough(page, '#box', 'x'); + expect(r.complete).toBe(true); +}); + +test('scrollThrough: при исчерпании maxSteps complete === false', async ({ page }) => { + await page.goto(fx('clipped-list.html')); + const r = await scrollThrough(page, '#box', 'y', 200, 10); + expect(r.complete).toBe(false); + expect(r.steps).toBeLessThanOrEqual(10); +});