/** * Перетаскивание ширины закреплённой колонки (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((r) => requestAnimationFrame(() => r()))); const skipLog: string[] = []; async function measure(browser: import('@playwright/test').Browser, s: ReturnType['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 = {}) => { 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); }); } } } });