perf: scroll.spec — свод 2 со страницами: заглушки, время до первых строк, суммарные байты
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a663da505a
commit
85c544b350
71
perf/e2e/lib/svod2.ts
Normal file
71
perf/e2e/lib/svod2.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 } : {}),
|
||||
|
||||
64
perf/e2e/tests/svod2-metrics.test.ts
Normal file
64
perf/e2e/tests/svod2-metrics.test.ts
Normal 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]');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user