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 { append, runDir } from '../lib/results';
|
||||||
import { scrollThrough, type Axis } from '../lib/scroll';
|
import { scrollThrough, type Axis } from '../lib/scroll';
|
||||||
import { shouldTrace } from '../lib/trace';
|
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 CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
|
||||||
const MIN_ROWS = 20;
|
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 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'];
|
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 }));
|
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 });
|
out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -95,8 +96,8 @@ function buildTargets(): Target[] {
|
|||||||
const SETTLE_MS = 20_000;
|
const SETTLE_MS = 20_000;
|
||||||
class RowsError extends Error {}
|
class RowsError extends Error {}
|
||||||
const skipLog: string[] = [];
|
const skipLog: string[] = [];
|
||||||
async function waitRows(page: Page): Promise<number> {
|
async function waitRows(page: Page, rowSel = 'tbody tr'): Promise<number> {
|
||||||
const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
|
const count = () => page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
let last = -1, lastChange = t0;
|
let last = -1, lastChange = t0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@ -175,42 +176,47 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
|
|||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
|
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
|
||||||
// (response.body() для 30-47 МБ недоступен: "evicted from inspector cache")
|
// (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);
|
const net = await ctx.newCDPSession(page);
|
||||||
await net.send('Network.enable');
|
await net.send('Network.enable');
|
||||||
const reqUrl = new Map<string, string>();
|
const reqUrl = new Map<string, string>();
|
||||||
const reqBytes = new Map<string, number>();
|
net.on('Network.requestWillBeSent', (e: any) => {
|
||||||
net.on('Network.requestWillBeSent', (e: any) => { reqUrl.set(e.requestId, e.request.url); });
|
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) => {
|
net.on('Network.dataReceived', (e: any) => {
|
||||||
const u = reqUrl.get(e.requestId);
|
const u = reqUrl.get(e.requestId);
|
||||||
if (u && t.respRe.test(u)) {
|
if (u && t.respRe.test(u)) bytes.add(e.requestId, e.dataLength);
|
||||||
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);
|
await page.goto(WEB + t.url);
|
||||||
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
|
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') {
|
if (t.kind === 'svod2') {
|
||||||
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
|
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
|
||||||
await page.getByLabel('ССП/РФ').click();
|
await page.getByLabel('ССП/РФ').click();
|
||||||
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
|
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
tLoad = Date.now();
|
||||||
await page.getByRole('button', { name: 'Загрузить' }).click();
|
await page.getByRole('button', { name: 'Загрузить' }).click();
|
||||||
}
|
}
|
||||||
let rows: number;
|
let rows: number;
|
||||||
|
let firstRowsMs = 0;
|
||||||
try {
|
try {
|
||||||
rows = await waitRows(page);
|
rows = await waitRows(page, rowSel);
|
||||||
|
if (t.kind === 'svod2') { firstRowsMs = Date.now() - tLoad; bytes.markFirstRows(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof RowsError)) throw 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()})`;
|
const skip_reason = `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})`;
|
||||||
skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${skip_reason}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await page.waitForTimeout(500);
|
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);
|
await throttle(page, cpu);
|
||||||
|
|
||||||
// trace только на повторе 0 при повторах > 1 (lib/trace.ts): при одном повторе запись traced=1 была бы единственной
|
// 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) : '';
|
const sigBefore = filter ? await signature(page) : '';
|
||||||
if (filter) await armStable(page);
|
if (filter) await armStable(page);
|
||||||
const pr = await startProbe(page);
|
const pr = await startProbe(page);
|
||||||
|
let filterResultMs: number | undefined;
|
||||||
|
let placeholderSamples: boolean[] = [];
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
|
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');
|
await input.fill('1');
|
||||||
|
if (searchResp) await searchResp;
|
||||||
stable = await awaitStable(page, 120_000);
|
stable = await awaitStable(page, 120_000);
|
||||||
|
if (t.kind === 'svod2') filterResultMs = Date.now() - tFilter;
|
||||||
effect = (await signature(page)) !== sigBefore;
|
effect = (await signature(page)) !== sigBefore;
|
||||||
if (!stable) reason = 'filter did not stabilize in 120s';
|
if (!stable) reason = 'filter did not stabilize in 120s';
|
||||||
else if (!effect) reason = 'filter_no_effect';
|
else if (!effect) reason = 'filter_no_effect';
|
||||||
} else {
|
} 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);
|
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);
|
({ complete, steps } = r);
|
||||||
if (r.reason) reason = r.reason;
|
if (r.reason) reason = r.reason;
|
||||||
else if (!complete) reason = 'scroll_incomplete';
|
else if (!complete) reason = 'scroll_incomplete';
|
||||||
}
|
}
|
||||||
probe = await pr.stop();
|
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)); }
|
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({
|
append({
|
||||||
...rec,
|
...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,
|
artifacts,
|
||||||
incomplete: !complete || !stable || !effect,
|
incomplete: !complete || !stable || !effect,
|
||||||
...(reason ? { skip_reason: reason } : {}),
|
...(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