diff --git a/perf/e2e/lib/svod2.ts b/perf/e2e/lib/svod2.ts new file mode 100644 index 0000000..ed61405 --- /dev/null +++ b/perf/e2e/lib/svod2.ts @@ -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//...` и `/svod/2///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 = 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; + } +} diff --git a/perf/e2e/scenarios/scroll.spec.ts b/perf/e2e/scenarios/scroll.spec.ts index 211dd68..d6df2f8 100644 --- a/perf/e2e/scenarios/scroll.spec.ts +++ b/perf/e2e/scenarios/scroll.spec.ts @@ -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 { - const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); +async function waitRows(page: Page, rowSel = 'tbody tr'): Promise { + 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(); - const reqBytes = new Map(); - 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 } : {}), diff --git a/perf/e2e/tests/svod2-metrics.test.ts b/perf/e2e/tests/svod2-metrics.test.ts new file mode 100644 index 0000000..b4b4e8c --- /dev/null +++ b/perf/e2e/tests/svod2-metrics.test.ts @@ -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]'); +});