/** * Сценарий «память при длительной работе» (Task 8): одна SPA-сессия роли rf без перезагрузки страницы, * N циклов (PERF_MEM_CYCLES, по умолчанию 20) + базовая точка (цикл 0) = N+1 запись scenario "memory". * * Цикл: самый большой лист №1 -> скролл вниз (и обратно) -> 3 правки ячеек через UI -> /tasks -> * самый большой лист №2 -> скролл -> /tasks -> gc -> метрики. * Листы: у rf нет листов FORM_1 (они ССП), поэтому берутся самые большие листы owner=rf: №1 - FORM_2 (AHR), * №2 - FORM_4 (PERF_MEM_SHEET1 / PERF_MEM_SHEET2 - переопределение `//`). * Навигация - клиентский роутер (history.pushState + popstate), не page.goto. * Правка: клик по редактируемой числовой ячейке (в приложении редактор открывается кликом), ввод числа, Enter; * ждём эхо cell_updated is_self=true от сервера по WS. Не удалось - сценарий падает. * Метрики: cycle, heap_used_mb (после gc), documents, nodes, js_event_listeners (CDP Memory.getDOMCounters), * ws_open (обёртка над window.WebSocket), edits_ok. Heap snapshots на циклах 0, 10, 20 (artifacts). * Env: PERF_SCALE, PERF_RUN_ID, PERF_MEM_CYCLES, PERF_MEM_SNAPSHOTS ('0,10,20'), PERF_CPU_RATES (только '1'), * PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, PERF_TEST_TIMEOUT_MS (3 ч). */ import { test, type Page } from '@playwright/test'; import path from 'node:path'; import { loadManifest, scale, type Sheet } from '../lib/manifest'; import { loginAs, WEB, API, ACCESS_KEY, REFRESH_KEY } from '../lib/auth'; import { throttle, heapUsed, domCounters, heapSnapshot } from '../lib/cdp'; import { attachNetlog } from '../lib/netlog'; import { append, runDir } from '../lib/results'; import { scrollThrough } from '../lib/scroll'; const CONTAINER = '.MuiTableContainer-root'; const MIN_ROWS = 20; const EDITS_PER_SHEET = 3; const CYCLES = Number(process.env.PERF_MEM_CYCLES ?? 20); const SNAP_CYCLES = (process.env.PERF_MEM_SNAPSHOTS ?? '0,10,20').split(',').map((s) => Number(s.trim())); const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000); const TOKEN_MIN_LEFT_S = 600; const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); function pickSheet(formType: string, override?: string): Sheet { const all = loadManifest().sheets.filter((s) => s.owner === 'rf'); if (override) { const s = all.find((x) => sheetName(x) === override); if (!s) throw new Error(`лист ${override} не найден среди owner=rf`); return s; } const best = all.filter((s) => s.form_type === formType).sort((a, b) => b.rows - a.rows)[0]; if (!best) throw new Error(`нет листов ${formType} с owner=rf`); return best; } const WS_SCRIPT = `(() => { if (window.__wsStat) return; const st = { open: 0, created: 0, closed: 0 }; const Orig = window.WebSocket; function Wrapped(url, protocols) { const ws = protocols === undefined ? new Orig(url) : new Orig(url, protocols); st.created++; let counted = false; ws.addEventListener('open', () => { counted = true; st.open++; }); ws.addEventListener('close', () => { if (counted) { counted = false; st.open--; st.closed++; } }); return ws; } Wrapped.prototype = Orig.prototype; for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) Wrapped[k] = Orig[k]; window.WebSocket = Wrapped; window.__wsStat = st; })();`; /** Клиентская навигация без перезагрузки: pushState + popstate (BrowserRouter реагирует на popstate). */ async function spaGo(page: Page, url: string): Promise { await page.evaluate((u) => { window.history.pushState({}, '', u); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, url); } async function waitRows(page: Page, what: string): Promise { const t0 = Date.now(); for (;;) { const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); if (n >= MIN_ROWS) return n; if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`${what}: rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`); await page.waitForTimeout(500); } } async function waitTasks(page: Page): Promise { await page.waitForFunction(() => location.pathname === '/tasks' && document.querySelector('#root')!.children.length > 0, null, { timeout: 60_000 }); await page.waitForTimeout(300); } /** Если access-токен скоро истечёт - перелогиниться по API и положить токены в localStorage (без перезагрузки). */ async function ensureToken(page: Page, user: { username: string; password: string }): Promise { const left = await page.evaluate((k) => { try { const t = localStorage.getItem(k); if (!t) return -1; const p = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); return p.exp - Date.now() / 1000; } catch { return -1; } }, ACCESS_KEY); if (left > TOKEN_MIN_LEFT_S) return false; const r = await page.context().request.post(`${API}/api/v1/auth/login`, { data: { username: user.username, password: user.password } }); if (!r.ok()) throw new Error(`relogin ${user.username}: ${r.status()}`); const d = await r.json(); await page.evaluate(([a, rk, at, rt]) => { localStorage.setItem(a, at); if (rt) localStorage.setItem(rk, rt); }, [ACCESS_KEY, REFRESH_KEY, d.access_token, d.refresh_token ?? ''] as const); return true; } interface WsLog { updates: Array<{ self: boolean; error: boolean }> } /** Правка n числовых редактируемых ячеек среди видимых строк: клик, ввод, Enter, ожидание эха. */ async function editCells(page: Page, wsLog: WsLog, n: number, seed: number, what: string): Promise { // кандидаты: ячейки с cursor:pointer (редактируемые, не заблокированные), числовой текст const marks = await page.evaluate(({ sel, need }) => { const cont = document.querySelector(sel) as HTMLElement; const cr = cont.getBoundingClientRect(); const out: Array<{ r: number; c: number; row: string }> = []; document.querySelectorAll(`${sel} tbody tr`).forEach((tr, ri) => { const b = tr.getBoundingClientRect(); if (b.top < cr.top + 80 || b.bottom > cr.bottom - 10) return; tr.querySelectorAll('div.cell').forEach((el, ci) => { const e = el as HTMLElement; if (e.style.cursor !== 'pointer') return; if (e.querySelector('span div')) return; const t = (e.textContent ?? '').trim(); if (!/^-?[\d\s .,]*$/.test(t) || t === '') return; if (out.filter((o) => o.r === ri).length) return; // не больше одной на строку out.push({ r: ri, c: ci, row: t }); }); }); return out.slice(0, need); }, { sel: CONTAINER, need: n }); if (marks.length < n) throw new Error(`${what}: найдено ${marks.length} редактируемых числовых ячеек из ${n}`); for (let i = 0; i < marks.length; i++) { const m = marks[i]; const cell = page.locator(`${CONTAINER} tbody tr`).nth(m.r).locator('div.cell').nth(m.c); const before = wsLog.updates.length; await cell.dblclick(); const ta = page.locator('textarea').first(); await ta.waitFor({ state: 'visible', timeout: 10_000 }); await ta.fill(String(1000 + seed * 10 + i)); await ta.press('Enter'); const t0 = Date.now(); while (wsLog.updates.length === before) { if (Date.now() - t0 > 15_000) throw new Error(`${what}: нет эха cell_updated за 15 с (правка ${i + 1}/${n})`); await page.waitForTimeout(50); } const u = wsLog.updates.slice(before).find((x) => x.self); if (!u) throw new Error(`${what}: эхо без is_self (правка ${i + 1})`); if (u.error) throw new Error(`${what}: сервер отклонил правку (${i + 1}/${n})`); await ta.waitFor({ state: 'detached', timeout: 10_000 }); // редактор закрыт - правка сохранена } } async function openAndScroll(page: Page, s: Sheet, what: string): Promise { await spaGo(page, s.url); const rows = await waitRows(page, what); await page.waitForTimeout(300); const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS); if (!r.complete) throw new Error(`${what}: скролл не дошёл до конца (${r.reason ?? 'incomplete'})`); return rows; } test('memory: 20 циклов, rf, CPU 1', async ({ browser }) => { test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 3600_000)); const cpu = Number(process.env.PERF_CPU_RATES ?? 1); if (cpu !== 1) throw new Error('сценарий памяти идёт только на CPU 1 (решение человека)'); const m = loadManifest(); const user = m.users['rf']; const sheet1 = pickSheet('FORM_2', process.env.PERF_MEM_SHEET1); const sheet2 = pickSheet('FORM_4', process.env.PERF_MEM_SHEET2); const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); const nl = attachNetlog(ctx, { scenario: 'memory', target: `${safe(sheetName(sheet1))}+${safe(sheetName(sheet2))}`, role: 'rf' }); try { await loginAs(ctx, user); await ctx.addInitScript(WS_SCRIPT); const page = await ctx.newPage(); const wsLog: WsLog = { updates: [] }; page.on('websocket', (ws) => ws.on('framereceived', (f) => { const p = typeof f.payload === 'string' ? f.payload : f.payload.toString(); if (!p.includes('cell_updated')) return; try { const d = JSON.parse(p); if (d.event === 'cell_updated') wsLog.updates.push({ self: d.is_self === true, error: !!d.error }); } catch { /* не JSON */ } })); page.on('pageerror', (e) => console.log('pageerror:', e.message)); await page.goto(WEB + '/tasks'); // единственная загрузка страницы за сценарий await waitTasks(page); await page.evaluate(() => { (window as any).__sessionMark = 1; }); // пропадёт при перезагрузке страницы await throttle(page, 1); const sample = async (cycle: number, extra: Record) => { const heap = await heapUsed(page); // gc внутри const dom = await domCounters(page); const ws = await page.evaluate(() => (window as any).__wsStat); const artifacts: string[] = []; if (SNAP_CYCLES.includes(cycle)) { const f = path.join(runDir(), 'artifacts', `memory-cycle${String(cycle).padStart(2, '0')}.heapsnapshot`); await heapSnapshot(page, f); artifacts.push(path.relative(runDir(), f)); } append({ scenario: 'memory', target: `${safe(sheetName(sheet1))}+${safe(sheetName(sheet2))}`, role: 'rf', scale: scale(), cpu_rate: 1, repeat: 0, metrics: { cycle, heap_used_mb: heap / 1048576, documents: dom.documents, nodes: dom.nodes, js_event_listeners: dom.jsEventListeners, ws_open: ws.open, ws_created: ws.created, ...extra, }, artifacts, }); console.log(`cycle ${cycle}: heap ${(heap / 1048576).toFixed(1)} MB, nodes ${dom.nodes}, listeners ${dom.jsEventListeners}, ws_open ${ws.open}`); }; await sample(0, {}); for (let cycle = 1; cycle <= CYCLES; cycle++) { const t0 = Date.now(); const relogin = await ensureToken(page, user); await openAndScroll(page, sheet1, `цикл ${cycle} ${sheetName(sheet1)}`); await editCells(page, wsLog, EDITS_PER_SHEET, cycle, `цикл ${cycle} ${sheetName(sheet1)}`); await spaGo(page, '/tasks'); await waitTasks(page); await openAndScroll(page, sheet2, `цикл ${cycle} ${sheetName(sheet2)}`); await spaGo(page, '/tasks'); await waitTasks(page); await page.waitForTimeout(500); // дать закрыться WS и отработать размонтированию if (!(await page.evaluate(() => (window as any).__sessionMark === 1))) throw new Error('страница перезагружалась: сценарий требует одну SPA-сессию'); await sample(cycle, { edits_ok: EDITS_PER_SHEET, relogin: relogin ? 1 : 0, cycle_s: (Date.now() - t0) / 1000 }); } } finally { await nl.flush(); await ctx.close(); } });