233 lines
12 KiB
TypeScript
233 lines
12 KiB
TypeScript
/**
|
||
* Сценарий «память при длительной работе» (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();
|
||
}
|
||
});
|