/** * Сценарий «скролл при живом соавторе» (Task 6): автор perf_rf (role rf) скроллит лист, пока бот perf_rf_coauthor * правит ячейки того же листа по WebSocket с частотой rate правок/с (perf/perfkit/coauthor.py). * * Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `//`), PERF_RUN_ID, * PERF_REPEATS (3), PERF_CPU_RATES ('1'; троттлинг отключён решением человека, PERF_CPU_RATES=6 включает вручную), PERF_COAUTHOR_RATES ('1,5'), PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, * PERF_TEST_TIMEOUT_MS (45 мин), PERF_PYTHON (по умолчанию perf/.venv/bin/python). * Цели: самый большой лист каждой формы из доступных роли rf (owner=rf). * Запись не пишется (тест падает), если бот не получил эха своих правок или страница не увидела ни одного cell_updated. */ import { test, type Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import path from 'node:path'; import { loadManifest, scale, PERF_DIR, type Sheet } from '../lib/manifest'; import { loginAs, WEB } from '../lib/auth'; import { PROBE_SCRIPT, type ProbeMetrics } from '../lib/probe'; import { throttle, startTrace, stopTrace } from '../lib/cdp'; import { append, runDir } from '../lib/results'; import { scrollThrough } from '../lib/scroll'; const CONTAINER = '.MuiTableContainer-root'; const MIN_ROWS = 20; const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000); const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000); const PYTHON = process.env.PERF_PYTHON ?? path.join(PERF_DIR, '.venv', 'bin', 'python'); const BOT_READY_MS = 30_000; 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 CO_RATES = envList('PERF_COAUTHOR_RATES', ['1', '5']).map(Number); const 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', ['*']).map(globRe); const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`; const safe = (s: string) => s.replace(/[^\w.-]+/g, '_'); /** Самый большой лист каждой формы (form_type) среди owner=rf. */ function targets(): Sheet[] { const best = new Map(); for (const s of loadManifest().sheets) { if (s.owner !== 'rf') continue; const cur = best.get(s.form_type); if (!cur || s.rows > cur.rows) best.set(s.form_type, s); } return [...best.values()].filter((s) => TARGET_RES.some((r) => r.test(sheetName(s)))); } async function waitRows(page: Page): 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(`rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`); await page.waitForTimeout(500); } } interface Bot { proc: ChildProcess; stop: () => Promise<{ sent: number; echoed: number; errors: number; code: number | null }> } /** Запускает бота и ждёт строку READY (эхо первой правки). Нет READY за BOT_READY_MS или выход бота - исключение. */ async function startBot(s: Sheet, rate: number): Promise { const args = ['-m', 'perfkit.coauthor', '--form-id', String(s.form_id), '--sheet', s.sheet, '--rate', String(rate), '--duration', '86400', '--scale', String(scale())]; if (s.direction) args.push('--direction', s.direction); const proc = spawn(PYTHON, args, { cwd: PERF_DIR, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; let exited: number | null | undefined; const exitP = new Promise((res) => proc.on('exit', (c) => { exited = c; res(); })); proc.stdout!.on('data', (d) => { out += d; }); proc.stderr!.on('data', (d) => { err += d; }); const t0 = Date.now(); while (!out.includes('READY')) { if (exited !== undefined) throw new Error(`бот-соавтор завершился (код ${exited}) до эха первой правки: ${err.trim()}`); if (Date.now() - t0 > BOT_READY_MS) { proc.kill('SIGKILL'); throw new Error(`бот-соавтор не получил эха за ${BOT_READY_MS} мс: ${err.trim()}`); } await new Promise((r) => setTimeout(r, 100)); } return { proc, stop: async () => { if (exited === undefined) proc.kill('SIGTERM'); await Promise.race([exitP, new Promise((r) => setTimeout(r, 10_000))]); if (exited === undefined) proc.kill('SIGKILL'); const last = out.trim().split('\n').filter((l) => l.startsWith('{')).pop(); const st = last ? JSON.parse(last) : { sent: 0, echoed: 0, errors: 0 }; return { ...st, code: exited ?? null }; }, }; } async function measure(browser: import('@playwright/test').Browser, s: Sheet, cpu: number, rate: number, repeat: number) { const m = loadManifest(); const name = sheetName(s); const targetName = `${name}/coauthor${rate}@y`; const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } }); const artifacts: string[] = []; let bot: Bot | undefined; try { await loginAs(ctx, m.users['rf']); await ctx.addInitScript(PROBE_SCRIPT); const page = await ctx.newPage(); // сколько cell_updated другого пользователя дошло до страницы автора (WS должен быть подключён) let pageUpdates = 0; 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' && d.is_self === false && !d.error) pageUpdates++; } catch { /* не JSON */ } })); await page.goto(WEB + s.url); const rows = await waitRows(page); await page.waitForTimeout(500); await throttle(page, cpu); bot = await startBot(s, rate); const trace = repeat === 0; const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`); if (trace) await startTrace(page); await page.evaluate(() => (window as any).__perf.start()); const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS); const probe: ProbeMetrics = await page.evaluate(() => (window as any).__perf.stop()); if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); } const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER); const co = await bot.stop(); bot = undefined; if (co.echoed === 0) throw new Error(`coauthor_echoed=0 (sent=${co.sent}, errors=${co.errors}, код бота ${co.code}): сценарий измерил бы «без соавтора»`); if (pageUpdates === 0) throw new Error('страница автора не получила ни одного cell_updated соавтора (WS не подключён?)'); const reason = r.reason ?? (r.complete ? undefined : 'scroll_incomplete'); append({ scenario: 'scroll_coauthor', target: targetName, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, metrics: { ...probe, axis: 0, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, steps: r.steps, coauthor_rate: rate, coauthor_sent: co.sent, coauthor_echoed: co.echoed, coauthor_errors: co.errors, page_ws_updates: pageUpdates, }, artifacts, incomplete: !r.complete, ...(reason ? { skip_reason: reason } : {}), }); } finally { if (bot) await bot.stop().catch(() => {}); await ctx.close(); } } test.describe('scroll_coauthor', () => { for (const s of targets()) { for (const cpu of CPU_RATES) { for (const rate of CO_RATES) { for (let repeat = 0; repeat < REPEATS; repeat++) { test(`${sheetName(s)} rf y cpu${cpu} coauthor${rate} #${repeat}`, async ({ browser }) => { test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 45 * 60_000)); await measure(browser, s, cpu, rate, repeat); }); } } } } });