DFiP_Budget_planing/perf/e2e/scenarios/pinned-resize.spec.ts

209 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Перетаскивание ширины закреплённой колонки (AURORA-1839, spec sheet-table).
*
* Закрепляет две первые колонки данных листа (роль rf), тянет границу первой из них 50 x (+4 px) и 50 x (-4 px) с ожиданием кадра
* после каждого шага, после отпускания проверяет, что не менее 4 td[data-pinned="true"] одной строки примыкают друг к другу.
*
* Параметры (env):
* PERF_TARGETS glob(ы) через запятую по имени листа `FORM/SHEET/direction` (по умолчанию 'FORM_2/AHR/*');
* сопоставляется с именем и с `<имя>@pinned`
* PERF_REPEATS число повторов (по умолчанию 3)
* PERF_CPU_RATES '1' по умолчанию
* PERF_LOAD_TIMEOUT_MS ожидание данных после навигации (по умолчанию 900000)
* PERF_TEST_TIMEOUT_MS таймаут теста (по умолчанию 45 мин)
* PERF_RUN_ID, PERF_SCALE - как у scroll.spec.ts
*
* Метрика: main_thread_ms_per_step (шаг = движение мыши), frame_p95_ms, heavy_frame_n.
* skip_reason: column_not_found, second_column_not_found, resizer_not_found, resize_no_effect, pinned_cells_missing,
* pinned_overlap (+ pinned_gap_index), rows<20.
*/
import { test, type Page } from '@playwright/test';
import { loadManifest, scale } from '../lib/manifest';
import { loginAs, WEB } from '../lib/auth';
import { PROBE_SCRIPT, startProbe, perStep } from '../lib/probe';
import { throttle } from '../lib/cdp';
import { attachNetlog } from '../lib/netlog';
import { append } from '../lib/results';
import { findPinnedGap, type PinnedCellRect } from '../lib/pinnedGeometry';
const CONTAINER = '.MuiTableContainer-root';
const MIN_ROWS = 20;
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
const STEP_PX = 4;
const HALF_STEPS = 50;
const MIN_PINNED_CELLS = 4;
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']).map(Number);
const REPEATS = process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : 3;
const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
const TARGET_RES = envList('PERF_TARGETS', ['FORM_2/AHR/*']).map(globRe);
const wanted = (name: string) => TARGET_RES.some((r) => r.test(name) || r.test(`${name}@pinned`));
const sheetName = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
const nextFrame = (page: Page) => page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())));
type Sheet = ReturnType<typeof loadManifest>['sheets'][number];
const skipLog: string[] = [];
async function measure(browser: import('@playwright/test').Browser, s: Sheet, name: string, cpu: number, repeat: number) {
const m = loadManifest();
const user = m.users['rf'];
const target = `${name}@pinned`;
const rec = { scenario: 'pinned_resize', target, role: 'rf', scale: scale(), cpu_rate: cpu, repeat };
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
const nl = attachNetlog(ctx, { scenario: rec.scenario, target, role: 'rf' });
const skip = (reason: string, metrics: Record<string, number> = {}) => {
skipLog.push(`${target} rf cpu${cpu} #${repeat}: ${reason}`);
append({ ...rec, metrics, artifacts: [], incomplete: true, skip_reason: reason });
};
try {
await loginAs(ctx, user);
// раскладка по умолчанию: ключи может оставить прошлый прогон; direction в ключе бывает строкой null/undefined - чистим по префиксу
await ctx.addInitScript(
(prefix: string) => {
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith(prefix) && /_(columnPinning|columnSizing|columnVisibility)$/.test(k)) localStorage.removeItem(k);
}
} catch { /* ignore */ }
},
`${s.form_id}_${s.form_type}_${s.sheet}_`,
);
await ctx.addInitScript(PROBE_SCRIPT);
const page = await ctx.newPage();
await page.goto(WEB + s.url);
try {
await page.waitForFunction(
([sel, min]) => document.querySelectorAll(`${sel} tbody tr`).length >= (min as number),
[CONTAINER, MIN_ROWS] as const,
{ timeout: LOAD_TIMEOUT },
);
} catch {
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
skip(`rows<${MIN_ROWS}: timeout, rows=${have} (url ${page.url()})`, { rows_visible_before: have });
return;
}
await page.waitForTimeout(500);
await throttle(page, cpu);
// колонки: две первые data.* в DOM-порядке; закрепляются обе, тянется первая (у неё есть закреплённый сосед справа,
// поэтому устаревший left у соседа делает проверку findPinnedGap способной упасть)
const dataIds = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-col-select-id]'))
.map((e) => e.getAttribute('data-col-select-id') as string)
.filter((id) => id.startsWith('data.')),
);
if (!dataIds.length) { skip('column_not_found'); return; }
if (dataIds.length < 2) { skip('second_column_not_found'); return; }
const colId = dataIds[0];
for (const id of dataIds.slice(0, 2)) {
await page.locator(`[data-col-select-id="${id}"]`).first().click();
await page.locator('[data-pin-panel] button[aria-label="Закрепить область"]').first().click();
await page.waitForSelector('[data-pin-panel] button[aria-label="Закрепить область"][data-active="true"]');
}
// ресайзер: атрибут из ColumnResizer, иначе запасной поиск по cursor: col-resize (сборка базы AURORA-1837)
let box: { x: number; y: number; width: number; height: number } | null = null;
const byAttr = page.locator(`[data-col-resize-id="${colId}"]`).first();
if (await byAttr.count()) box = await byAttr.boundingBox();
else {
box = await page.evaluate((id) => {
const th = document.querySelector(`[data-col-select-id="${id}"]`) as HTMLElement | null;
if (!th) return null;
const thRect = th.getBoundingClientRect();
const letters = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getBoundingClientRect().top);
const lettersTop = Math.min(...letters);
const cands = Array.from(document.querySelectorAll('div')).filter((d) => {
if ((d as HTMLElement).style.cursor !== 'col-resize') return false;
const r = d.getBoundingClientRect();
return Math.abs(r.right - thRect.right) <= 2 && r.top > lettersTop;
});
if (!cands.length) return null;
const best = cands.reduce((a, b) => (b.getBoundingClientRect().top > a.getBoundingClientRect().top ? b : a));
const r = best.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}, colId);
}
if (!box) { skip('resizer_not_found'); return; }
const width0 = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
const x0 = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x0, y);
await page.mouse.down();
const pr = await startProbe(page);
let x = x0;
let widthPeak = 0;
const steps = HALF_STEPS * 2;
for (let i = 0; i < steps; i++) {
x += i < HALF_STEPS ? STEP_PX : -STEP_PX;
await page.mouse.move(x, y);
await nextFrame(page);
// один boundingBox на пике хода (шаг 50): его цена входит в пробу один раз из 100 шагов
if (i === HALF_STEPS - 1) widthPeak = (await page.locator(`[data-col-select-id="${colId}"]`).first().boundingBox())?.width ?? 0;
}
await page.mouse.up();
await nextFrame(page);
await nextFrame(page);
const probe = await pr.stop();
const stepMetrics = { ...probe, ...perStep(probe, steps), steps };
// ход 50 x 4 px = 200 px вправо; ширина должна вырасти хотя бы на половину
if (widthPeak - width0 < (STEP_PX * HALF_STEPS) / 2) {
skip('resize_no_effect', { ...stepMetrics, width_before: width0, width_peak: widthPeak });
return;
}
// строка с наибольшим числом закреплённых ячеек: mrt-row-expand, sort_order и две data-колонки
const rects: PinnedCellRect[] = await page.evaluate(() => {
let best: { left: number; width: number }[] = [];
for (const tr of Array.from(document.querySelectorAll('tbody tr'))) {
const tds = Array.from(tr.querySelectorAll('td[data-pinned="true"]'));
if (tds.length > best.length) best = tds.map((td) => { const r = td.getBoundingClientRect(); return { left: r.left, width: r.width }; });
}
return best;
});
if (rects.length < MIN_PINNED_CELLS) {
skip('pinned_cells_missing', { ...stepMetrics, pinned_cells_n: rects.length });
return;
}
const gap = findPinnedGap(rects);
const overlap = gap >= 0;
if (overlap) skipLog.push(`${target} rf cpu${cpu} #${repeat}: pinned_overlap at ${gap}`);
append({
...rec,
metrics: { ...stepMetrics, pinned_cells_n: rects.length, ...(overlap ? { pinned_gap_index: gap } : {}) },
artifacts: [],
incomplete: overlap,
...(overlap ? { skip_reason: 'pinned_overlap' } : {}),
});
} finally {
await nl.flush();
await ctx.close();
}
}
const testTimeout = () => Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000);
test.describe('pinned_resize', () => {
test.afterAll(() => {
if (skipLog.length) console.log(`\n=== записи с skip_reason / incomplete (${skipLog.length}) ===\n` + skipLog.join('\n'));
});
const m = loadManifest();
if (!m.users['rf']) return;
for (const s of m.sheets) {
if (s.owner !== 'rf') continue;
const name = sheetName(s);
if (!wanted(name)) continue;
for (const cpu of CPU_RATES) {
for (let repeat = 0; repeat < REPEATS; repeat++) {
test(`${name} rf pinned cpu${cpu} #${repeat}`, async ({ browser }) => {
test.setTimeout(testTimeout());
await measure(browser, s, name, cpu, repeat);
});
}
}
}
});