perf: scroll.spec — свод 2 со страницами: заглушки, время до первых строк, суммарные байты

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-29 00:26:56 +04:00
parent a663da505a
commit 85c544b350
3 changed files with 193 additions and 17 deletions

71
perf/e2e/lib/svod2.ts Normal file
View File

@ -0,0 +1,71 @@
/** Чистые помощники замеров свода 2 (без импортов, стираемый TS: проверяются `node --test`). */
export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])';
export const SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]';
/** Ответы свода 2: страница `/svod/2/<sheet>/...` и `/svod/2/<sheet>/<year>/facets`. */
export function svod2RespRe(sheet: string): RegExp {
return new RegExp(`/api/v1/svod/2/${sheet}/`);
}
/** Запрос свода 2 с параметром search= (серверный поиск). */
export function isSvod2SearchUrl(url: string): boolean {
const q = url.indexOf('?');
if (q < 0 || !/\/api\/v1\/svod\/2\//.test(url.slice(0, q))) return false;
return url
.slice(q + 1)
.split('#')[0]
.split('&')
.some((p) => p === 'search' || p.startsWith('search='));
}
/** Число запросов страниц (offset= в query). */
export function countPagesRequested(urls: string[]): number {
let n = 0;
for (const u of urls) {
const q = u.indexOf('?');
if (q < 0) continue;
if (u.slice(q + 1).split('#')[0].split('&').some((p) => p === 'offset' || p.startsWith('offset='))) n++;
}
return n;
}
/** Доля кадров, где видна хотя бы одна заглушка. */
export function placeholderShare(samples: boolean[]): number {
if (samples.length === 0) return 0;
let k = 0;
for (const s of samples) if (s) k++;
return k / samples.length;
}
/** Байты ответов по requestId: sheet - максимум одного ответа, total - сумма, first - сумма до markFirstRows. */
export class ByteCounter {
perRequest: Map<string, number> = new Map();
totalBytes = 0;
firstBytes = 0;
marked = false;
add(requestId: string, bytes: number): void {
this.perRequest.set(requestId, (this.perRequest.get(requestId) ?? 0) + bytes);
this.totalBytes += bytes;
if (!this.marked) this.firstBytes += bytes;
}
markFirstRows(): void {
this.marked = true;
}
get sheet(): number {
let m = 0;
for (const v of this.perRequest.values()) if (v > m) m = v;
return m;
}
get total(): number {
return this.totalBytes;
}
get first(): number {
return this.firstBytes;
}
}

View File

@ -29,6 +29,7 @@ import { attachNetlog } from '../lib/netlog';
import { append, runDir } from '../lib/results';
import { scrollThrough, type Axis } from '../lib/scroll';
import { shouldTrace } from '../lib/trace';
import { ByteCounter, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR, countPagesRequested, isSvod2SearchUrl, placeholderShare, svod2RespRe } from '../lib/svod2';
const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
const MIN_ROWS = 20;
@ -82,7 +83,7 @@ function buildTargets(): Target[] {
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 }));
['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: svod2RespRe(sh), rolesAxes: svodRoles }));
out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles });
return out;
}
@ -95,8 +96,8 @@ function buildTargets(): Target[] {
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);
async function waitRows(page: Page, rowSel = 'tbody tr'): Promise<number> {
const count = () => page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
const t0 = Date.now();
let last = -1, lastChange = t0;
for (;;) {
@ -175,42 +176,47 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
const page = await ctx.newPage();
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
// (response.body() для 30-47 МБ недоступен: "evicted from inspector cache")
let jsonBytes = 0;
const bytes = new ByteCounter();
const pageUrls: string[] = []; // запросы свода 2 (для pages_requested)
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.requestWillBeSent', (e: any) => {
reqUrl.set(e.requestId, e.request.url);
if (t.kind === 'svod2' && t.respRe.test(e.request.url)) pageUrls.push(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);
}
if (u && t.respRe.test(u)) bytes.add(e.requestId, e.dataLength);
});
await page.goto(WEB + t.url);
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
const rowSel = t.kind === 'svod2' ? SVOD2_DATA_ROW_SELECTOR : 'tbody tr';
let tLoad = 0;
if (t.kind === 'svod2') {
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
await page.getByLabel('ССП/РФ').click();
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
await page.keyboard.press('Escape');
tLoad = Date.now();
await page.getByRole('button', { name: 'Загрузить' }).click();
}
let rows: number;
let firstRowsMs = 0;
try {
rows = await waitRows(page);
rows = await waitRows(page, rowSel);
if (t.kind === 'svod2') { firstRowsMs = Date.now() - tLoad; bytes.markFirstRows(); }
} catch (e) {
if (!(e instanceof RowsError)) throw e;
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
const have = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]).catch(() => -1);
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 });
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: bytes.sheet }, artifacts, incomplete: true, skip_reason });
return;
}
await page.waitForTimeout(500);
await net.send('Network.disable').catch(() => {});
// свод 2 дозагружает страницы при прокрутке: сеть слушаем до конца действия
if (t.kind !== 'svod2') await net.send('Network.disable').catch(() => {});
await throttle(page, cpu);
// trace только на повторе 0 при повторах > 1 (lib/trace.ts): при одном повторе запись traced=1 была бы единственной
@ -221,25 +227,60 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
const sigBefore = filter ? await signature(page) : '';
if (filter) await armStable(page);
const pr = await startProbe(page);
let filterResultMs: number | undefined;
let placeholderSamples: boolean[] = [];
if (filter) {
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
const tFilter = Date.now();
// свод 2 «после»: поиск уходит на сервер; нет запроса (сборка «до») - идём дальше
const searchResp = t.kind === 'svod2'
? page.waitForResponse((r) => isSvod2SearchUrl(r.url()), { timeout: 5_000 }).catch(() => null)
: null;
await input.fill('1');
if (searchResp) await searchResp;
stable = await awaitStable(page, 120_000);
if (t.kind === 'svod2') filterResultMs = Date.now() - tFilter;
effect = (await signature(page)) !== sigBefore;
if (!stable) reason = 'filter did not stabilize in 120s';
else if (!effect) reason = 'filter_no_effect';
} else {
if (t.kind === 'svod2') {
// сэмпл в requestAnimationFrame: виден ли хотя бы один ряд-заглушка
await page.evaluate(([sel, ph]) => {
const st = { on: true, samples: [] as boolean[] };
const tick = () => {
if (!st.on) return;
st.samples.push(document.querySelector(`${sel} ${ph}`) !== null);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
(window as any).__ph = st;
}, [CONTAINER, SVOD2_PLACEHOLDER_SELECTOR]);
}
const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS);
if (t.kind === 'svod2') {
placeholderSamples = await page.evaluate(() => { const st = (window as any).__ph; st.on = false; return st.samples as boolean[]; });
}
({ complete, steps } = r);
if (r.reason) reason = r.reason;
else if (!complete) reason = 'scroll_incomplete';
}
probe = await pr.stop();
if (t.kind === 'svod2') await net.send('Network.disable').catch(() => {});
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);
const rowsAfter = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
const svod2Metrics = t.kind === 'svod2'
? {
first_rows_ms: firstRowsMs,
json_bytes_total: bytes.total,
json_bytes_first: bytes.first,
pages_requested: countPagesRequested(pageUrls),
...(filter ? { filter_result_ms: filterResultMs ?? 0 } : { placeholder_frame_share: placeholderShare(placeholderSamples) }),
}
: {};
append({
...rec,
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: bytes.sheet, steps, ...svod2Metrics },
artifacts,
incomplete: !complete || !stable || !effect,
...(reason ? { skip_reason: reason } : {}),

View File

@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ByteCounter,
placeholderShare,
isSvod2SearchUrl,
svod2RespRe,
countPagesRequested,
SVOD2_DATA_ROW_SELECTOR,
SVOD2_PLACEHOLDER_SELECTOR,
} from '../lib/svod2.ts';
test('ByteCounter: sheet=max, total=sum, first=sum до markFirstRows', () => {
const c = new ByteCounter();
c.add('a', 10);
c.add('b', 30);
c.markFirstRows();
c.add('c', 20);
assert.equal(c.sheet, 30);
assert.equal(c.total, 60);
assert.equal(c.first, 40);
});
test('ByteCounter: чанки одного запроса суммируются для sheet', () => {
const c = new ByteCounter();
c.add('a', 10);
c.add('a', 15);
assert.equal(c.sheet, 25);
assert.equal(c.total, 25);
});
test('placeholderShare', () => {
assert.equal(placeholderShare([true, false, false, false]), 0.25);
assert.equal(placeholderShare([]), 0);
});
test('isSvod2SearchUrl', () => {
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/2/GO/2026?view=compact&search=1'), true);
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/2/GO/2026?view=compact'), false);
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/1/GO/2026?search=1'), false);
});
test('svod2RespRe', () => {
const re = svod2RespRe('GO');
assert.ok(re.test('http://h/api/v1/svod/2/GO/2026?offset=0'));
assert.ok(re.test('http://h/api/v1/svod/2/GO/2026/facets'));
assert.ok(!re.test('http://h/api/v1/svod/2/RF/2026'));
});
test('countPagesRequested', () => {
assert.equal(
countPagesRequested([
'http://h/api/v1/svod/2/GO/2026?offset=0&limit=100',
'http://h/api/v1/svod/2/GO/2026?limit=100&offset=100',
'http://h/api/v1/svod/2/GO/2026/facets',
]),
2,
);
});
test('селекторы', () => {
assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder])');
assert.equal(SVOD2_PLACEHOLDER_SELECTOR, 'tbody tr[data-svod2-placeholder]');
});