Andrey Epifancev fa5dff8c6a perf: сценарий памяти
Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:17:23 +04:00

233 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* Сценарий «память при длительной работе» (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 - переопределение `<form_type>/<sheet>/<direction|null>`).
* Навигация - клиентский роутер (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 { 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<void> {
await page.evaluate((u) => {
window.history.pushState({}, '', u);
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
}, url);
}
async function waitRows(page: Page, what: string): Promise<number> {
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<void> {
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<boolean> {
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<void> {
// кандидаты: ячейки с 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<number> {
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 } });
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<string, number>) => {
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 ctx.close();
}
});