DFiP_Budget_planing/perf/e2e/scenarios/pinned-resize.spec.ts
Andrey Epifancev d2d546525e perf(e2e): сценарий pinned-resize — перетаскивание ширины закреплённой колонки
Закрепляет первую колонку данных FORM_2/AHR (rf), тянет её границу 50×(+4 px) и 50×(−4 px)
с ожиданием кадра, после отпускания проверяет примыкание td[data-pinned].
Для сборки базы (без data-col-resize-id) — запасной поиск ресайзера. Замеры не запускались.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:32:53 +04:00

184 lines
9.1 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) с ожиданием кадра
* после каждого шага, после отпускания проверяет, что 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: resizer_not_found, 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 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())));
const skipLog: string[] = [];
async function measure(browser: import('@playwright/test').Browser, s: ReturnType<typeof loadManifest>['sheets'][number], 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-порядке, иначе первая кроме служебных
const colId = await page.evaluate(() => {
const ids = Array.from(document.querySelectorAll('[data-col-select-id]')).map((e) => e.getAttribute('data-col-select-id') as string);
return ids.find((id) => id.startsWith('data.')) ?? ids.find((id) => id !== 'mrt-row-expand' && id !== 'sort_order') ?? null;
});
if (!colId) { skip('column_not_found'); return; }
await page.locator(`[data-col-select-id="${colId}"]`).first().click();
const pinBtn = page.locator('[data-pin-panel] button[aria-label="Закрепить область"]').first();
await pinBtn.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 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;
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);
}
await page.mouse.up();
await nextFrame(page);
await nextFrame(page);
const probe = await pr.stop();
const rects: PinnedCellRect[] = await page.evaluate(() => {
for (const tr of Array.from(document.querySelectorAll('tbody tr'))) {
const tds = Array.from(tr.querySelectorAll('td[data-pinned="true"]'));
if (tds.length >= 2) return tds.map((td) => { const r = td.getBoundingClientRect(); return { left: r.left, width: r.width }; });
}
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: { ...probe, ...perStep(probe, steps), steps, ...(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);
});
}
}
}
});