/** * Сценарии открытия модалок (Task 7). Перечень - perf/e2e/modals.json (строится по коду web/src, проверен на стенде). * * Параметры (env): * PERF_SCALE масштаб манифеста (по умолчанию 1) * PERF_RUN_ID каталог прогона perf/results/ * PERF_MODALS glob(ы) через запятую по id модалки (по умолчанию все); у варианта на большом листе id оканчивается на @big * PERF_PRIORITY '1' (по умолчанию), '2' или '1,2' * PERF_REPEATS число повторов вместо матричного (priority 1 - 5, priority 2 - 3, profiling - 1) * PERF_CPU_RATES '1' по умолчанию (без троттлинга, решение человека); '1,4,6' - вручную * PERF_ROLES фильтр ролей открытия (rf,admin) * PERF_PROFILING=1 профилирующий прогон: PERF_WEB должен указывать на профилирующую сборку (:8081); * пишет scenario 'modal_renders' (metrics.renders_top - топ-20 компонентов по self_ms) * PERF_TEST_TIMEOUT_MS таймаут одного замера (по умолчанию 3 мин) */ import { test, type Browser, type Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { loadManifest, scale, type Manifest, type User } from '../lib/manifest'; import { loginAs, WEB } from '../lib/auth'; import { throttle } from '../lib/cdp'; import { measureOpen, type Appear } from '../lib/modal'; import { REACT_HOOK_SCRIPT, hookInfo, profileStart, profileStop } from '../lib/reactProfile'; import { attachNetlog } from '../lib/netlog'; import { append } from '../lib/results'; interface Step { selector: string; action: 'click' } interface ModalDef { id: string; title: string; page: string; source: string; priority: 1 | 2; role: string; page_ref?: string; sheet_ref?: string; big_sheet?: string; open: { selector: string; action: 'click'; pre?: Step[] }; appear?: Appear; skip_reason?: string; note?: string; } 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 PRIORITIES = envList('PERF_PRIORITY', ['1']).map(Number); const ROLES = envList('PERF_ROLES', ['rf', 'admin']); const PROFILING = process.env.PERF_PROFILING === '1'; const globRe = (g: string) => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); const ID_RES = envList('PERF_MODALS', ['*']).map(globRe); const repeatsFor = (prio: number) => (process.env.PERF_REPEATS ? Number(process.env.PERF_REPEATS) : PROFILING ? 1 : prio === 1 ? 5 : 3); const TEST_TIMEOUT = Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 60_000); const defs: ModalDef[] = JSON.parse(readFileSync(path.resolve(__dirname, '../modals.json'), 'utf-8')); const sheetKey = (s: { form_type: string; sheet: string; direction: string | null }) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; function urlFor(m: Manifest, d: ModalDef, sheetRef?: string): string { const sref = sheetRef ?? d.sheet_ref; if (sref) { const cands = m.sheets.filter((s) => sheetKey(s) === sref); const s = cands.find((x) => x.owner === 'rf') ?? cands[0]; if (!s) throw new Error(`sheet ${sref} not in manifest`); return s.url; } const p = m.modal_pages.find((x) => x.name === d.page_ref); if (!p) throw new Error(`modal page ${d.page_ref} not in manifest`); return p.url; } /** Страница готова: опенер виден, у листов - загружены строки; затем короткая пауза на «остывание» рендеров. */ async function prepare(page: Page, d: ModalDef, isSheet: boolean) { const first = d.open.pre?.[0]?.selector ?? d.open.selector; await page.locator(first).first().waitFor({ state: 'visible', timeout: 120_000 }); if (isSheet) await page.locator('tbody tr').first().waitFor({ state: 'visible', timeout: 120_000 }); await page.waitForTimeout(isSheet ? 3000 : 1500); } async function measure(browser: Browser, d: ModalDef, target: string, sheetRef: string | undefined, role: string, cpu: number, repeat: number) { const m = loadManifest(); const user: User = m.users[role]; const rec = { scenario: PROFILING ? 'modal_renders' : 'modal', target, role, 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 }); try { await loginAs(ctx, user); if (PROFILING) await ctx.addInitScript(REACT_HOOK_SCRIPT); const page = await ctx.newPage(); await page.goto(WEB + urlFor(m, d, sheetRef)); try { await prepare(page, d, !!(sheetRef ?? d.sheet_ref)); for (const s of d.open.pre ?? []) { await page.locator(s.selector).first().click({ timeout: 15_000 }); await page.waitForTimeout(600); } const opener = page.locator(d.open.selector).first(); await opener.waitFor({ state: 'visible', timeout: 15_000 }); // клик Playwright сам скроллит/наводит курсор: делаем это заранее, чтобы окно main thread не включало подготовку await opener.scrollIntoViewIfNeeded(); await opener.hover(); await page.waitForTimeout(400); await throttle(page, cpu); if (PROFILING) { const info = await hookInfo(page); if (!info.saw_actual_duration) throw new Error(`not a profiling build (renderers=${info.renderers}, commits=${info.commits_total}, no actualDuration) at ${WEB}`); await profileStart(page); } // клик мышью по координатам (курсор уже наведён): locator.click() добавляет в окно замера свои проверки actionability const box = await opener.boundingBox(); if (!box) throw new Error('opener has no bounding box'); const r = await measureOpen(page, () => page.mouse.click(box.x + box.width / 2, box.y + box.height / 2), { id: d.id, appear: d.appear }); if (PROFILING) { await page.waitForTimeout(500); // догоняющие рендеры (загрузка данных модалки) const p = await profileStop(page); append({ ...rec, metrics: { ...r, commits: p.commits, total_renders: p.total_renders, total_self_ms: p.total_self_ms, renders_top: p.top as any }, artifacts: [], incomplete: !!p.error, ...(p.error ? { skip_reason: p.error } : {}) }); } else { append({ ...rec, metrics: { ...r }, artifacts: [] }); } } catch (e) { const msg = String(e instanceof Error ? e.message : e).split('\n')[0].slice(0, 300); append({ ...rec, metrics: {}, artifacts: [], incomplete: true, skip_reason: msg }); console.log(`INCOMPLETE ${target} ${role} cpu${cpu} #${repeat}: ${msg}`); } } finally { await nl.flush(); await ctx.close(); } } test.describe(PROFILING ? 'modal_renders' : 'modal', () => { for (const d of defs) { if (d.skip_reason || !PRIORITIES.includes(d.priority) || !ROLES.includes(d.role)) continue; const variants: Array<{ target: string; sheetRef?: string }> = [{ target: d.id }]; if (d.big_sheet) variants.push({ target: `${d.id}@big`, sheetRef: d.big_sheet }); for (const v of variants) { if (!ID_RES.some((r) => r.test(v.target))) continue; for (const cpu of CPU_RATES) { for (let repeat = 0; repeat < repeatsFor(d.priority); repeat++) { test(`${v.target} ${d.role} cpu${cpu} #${repeat}`, async ({ browser }) => { test.setTimeout(TEST_TIMEOUT); await measure(browser, d, v.target, v.sheetRef ?? d.sheet_ref, d.role, cpu, repeat); }); } } } } });