perf: сценарии скролла

Библиотека scroll.ts (scrollThrough) с тестами на фикстурах, сценарий
scroll.spec.ts: листы форм, отчёты проектов, своды 1/2, projects/summaries,
admin-контроль, смена фильтра свода. Параметры PERF_TARGETS/CPU_RATES/REPEATS/ROLES/AXES.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 22:58:44 +04:00
parent efa537e008
commit bd4d42bffb
9 changed files with 368 additions and 1 deletions

View File

@ -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/<name>/filter@y`); `axis` дополнительно пишется в метрики (0 = y, 1 = x) | Агрегатор группирует по `target`, а не по оси: без суффикса оси y и x смешались бы в одну группу | Другие имена target, чем в брифе; Task 6+ должны учитывать суффикс |
| 2026-10-25 | Target отчёта проекта — `project/<report_type>/<project_id>` (в плане `project/{report_type}`); target листа — `<form_type>/<sheet>/<direction\|null>` | В манифесте 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 с была бы принята за «мало строк» |

View File

@ -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/<run_id>`.
- Библиотеки `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/<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`.
- Каждый замер - новый 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 не попадают).
- Сайт свода не читает `#hash` из URL: вкладка (`FORM_1`, `GO`...) выбирается кликом по `role=tab`.

View File

@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>clipped-list</title>
<style>
body { margin: 0 }
#box { width: 600px; height: 400px; overflow: auto; border: 1px solid #888 }
#inner { height: 5000px; background: repeating-linear-gradient(#eee 0 99px, #ccc 99px 100px) }
</style>
<div id="box"><div id="inner"></div></div>

View File

@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>long-list</title>
<style>
body { margin: 0 }
#box { width: 600px; height: 400px; overflow: auto; border: 1px solid #888 }
#inner { height: 20000px; width: 3000px; background: repeating-linear-gradient(#eee 0 99px, #ccc 99px 100px) }
</style>
<div id="box"><div id="inner"></div></div>

View File

@ -26,6 +26,7 @@ export interface ResultRecord {
metrics: Record<string, number>;
artifacts: string[];
incomplete?: boolean;
skip_reason?: string;
ts?: string;
}

64
perf/e2e/lib/scroll.ts Normal file
View File

@ -0,0 +1,64 @@
import type { Page } from '@playwright/test';
export type Axis = 'y' | 'x';
const raf = () => new Promise<void>((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 };
}

View File

@ -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,

View File

@ -0,0 +1,226 @@
/**
* Сценарии скролла (Task 5).
*
* Параметры (env):
* PERF_SCALE масштаб манифеста (по умолчанию 1)
* PERF_TARGETS glob(ы) через запятую по target, напр. 'FORM_1/*,svod/*' (по умолчанию все);
* сопоставляется с target без суффикса оси и с полным (`.../@y`)
* PERF_RUN_ID каталог прогона perf/results/<run_id>
* 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<number> {
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<boolean> {
return page.evaluate(
([sel, 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 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<string, string>();
const reqBytes = new Map<string, number>();
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 });
});
}
}
}
}
});

View File

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