- C1: trace только на повторе 0 при числе повторов > 1 (lib/trace.ts shouldTrace) в scroll и scroll-coauthor: admin-контроль, control4x и PERF_S2_REPEATS=1 больше не теряют единственную запись (traced=1). - I1: масштаб 2 - PERF_LOAD_TIMEOUT_MS=40 мин и PERF_TEST_TIMEOUT_MS=90 мин, если не заданы. - I2: шаг aggregate падает при сбое perfkit.aggregate/копирования и если summary.json не создан; тесты run-all (perf/tests/test_run_all.py). - M2: после modals_profiling останавливается preview :8081. - README, design.md: решения раунда. Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
8.9 KiB
TypeScript
162 lines
8.9 KiB
TypeScript
/**
|
||
* Сценарий «скролл при живом соавторе» (Task 6): автор perf_rf (role rf) скроллит лист, пока бот perf_rf_coauthor
|
||
* правит ячейки того же листа по WebSocket с частотой rate правок/с (perf/perfkit/coauthor.py).
|
||
*
|
||
* Параметры (env): PERF_SCALE, PERF_TARGETS (glob по `<form_type>/<sheet>/<direction|null>`), 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), PERF_TRACE=0 (без trace; по умолчанию trace на повторе 0, если повторов > 1).
|
||
* Цели: самый большой лист каждой формы из доступных роли 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, startProbe, perStep, type ProbeResult } from '../lib/probe';
|
||
import { throttle, startTrace, stopTrace } from '../lib/cdp';
|
||
import { attachNetlog } from '../lib/netlog';
|
||
import { append, runDir } from '../lib/results';
|
||
import { scrollThrough } from '../lib/scroll';
|
||
import { shouldTrace } from '../lib/trace';
|
||
|
||
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<string, Sheet>();
|
||
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<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(`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<Bot> {
|
||
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<void>((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;
|
||
const nl = attachNetlog(ctx, { scenario: 'scroll_coauthor', target: targetName, role: 'rf' });
|
||
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 = shouldTrace({ repeat, repeats: REPEATS }); // повтор 0 при REPEATS > 1; PERF_TRACE=0 - никогда
|
||
const traceFile = path.join(runDir(), 'artifacts', `${safe(targetName)}-rf-cpu${cpu}.trace.json`);
|
||
if (trace) await startTrace(page);
|
||
const pr = await startProbe(page);
|
||
const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS);
|
||
const probe: ProbeResult = await pr.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, ...perStep(probe, r.steps), 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 nl.flush();
|
||
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);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|