perf: каркас замеров фронта

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 22:12:25 +04:00
parent fb80d8c636
commit efa537e008
13 changed files with 806 additions and 0 deletions

2
.gitignore vendored
View File

@ -341,3 +341,5 @@ perf/.venv/
perf/e2e/node_modules/
perf/.run/
!perf/docker-compose.yml
perf/e2e/test-results/
!perf/e2e/lib/

View File

@ -121,3 +121,8 @@
| 2026-10-25 | В `Sheet` манифеста добавлено поле `owner` ("rf"/"ssp", по умолчанию "rf"); в манифест попадают листы только ССП-01 и РФ-01 | Манифест без признака владельца не позволяет отличить листы, недоступные `rf`; полный список форм раздул бы сценарии | Сценарии T5 должны фильтровать по owner |
| 2026-10-25 | Лист свода кодируется в `Page.url` после `#` (`/svod#FORM_1`, `/svod/2#RF`) | Вкладка свода — состояние страницы, не маршрут; хеш не влияет на роутинг, имена `svod1_*`, `svod2_*` уникальны | Сценарий обязан выбирать вкладку по хешу/имени |
| 2026-10-25 | В `perf/stand.sh` добавлена `reset`; `start_bg` отвязывает подоболочку от stdout | Требование задачи; без отвязки `stand.sh api \| tail` висел | Правка поведения стенда Task 1 |
| 2026-10-25 | Версии `@playwright/test`, `@types/node`, `typescript` в `perf/e2e/package.json` зафиксированы точно (без `^`) | Воспроизводимость замеров между прогонами «до/после» | Обновление версий вручную |
| 2026-10-25 | Тест `results format` пишет в каталог через `PERF_RESULTS_DIR` и после проверки удаляет `perf/report-data/<имя>` | aggregate пишет в `perf/report-data/<run_id>`; не должно оставаться мусора | Мусор в report-data при падении теста |
| 2026-10-25 | `probe.stop()` ждёт 100 мс перед сбором (PerformanceObserver доставляет longtask асинхронно) | Иначе последняя длинная задача перед `stop` теряется | +100 мс к duration_ms |
| 2026-10-25 | `heapUsed` делает GC перед `Runtime.getHeapUsage`; `startTrace/stopTrace` через `browser.startTracing` Playwright | Стабильные значения кучи; штатный API трассы | Трасса содержит события всего браузера |
| 2026-10-25 | В `.gitignore` добавлены `!perf/e2e/lib/` и `perf/e2e/test-results/` | Корневой `lib/` игнорирует каталог библиотек каркаса; test-results — артефакты Playwright | Библиотеки не попадут в коммит / мусор в коммите |

View File

@ -34,3 +34,18 @@ cd perf && .venv/bin/python -m perfkit.gen.verify --scale 1 # должен н
у них нет HTTP-ручки). Пользователи: `perf_rf`, `perf_rf_coauthor` (одна РФ, роль 3) и `perf_dfip`
(роль 2, все оргединицы), пароль `perf`; в манифесте они под ключами `rf`, `rf_coauthor`, `dfip`.
Числа плана и их обоснование — в `perf/perfkit/gen/plan.py`.
## Фронт: замеры
Каркас: `perf/e2e` (Playwright + CDP). Версии: `@playwright/test` 1.64.1, Chromium 156.0.8078.4 (headless shell, revision 1248).
```
cd perf/e2e && npm install && npx playwright install chromium # npm_config_registry=https://registry.npmjs.org
npx playwright test tests/harness.spec.ts # самопроверка каркаса
```
- `PERF_RUN_ID` — имя каталога прогона `perf/results/<run_id>/` (по умолчанию `YYYYMMDD-HHMM`).
- `PERF_SCALE` — масштаб манифеста `perf/results/manifest-s<scale>.json` (по умолчанию 1).
- `PERF_API`, `PERF_WEB` — адреса стенда (`http://localhost:8000`, `http://localhost:8080`).
- Агрегация: `cd perf && .venv/bin/python -m perfkit.aggregate results/<run_id>`.
- Библиотеки `perf/e2e/lib/`: `auth.loginAs`, `cdp.*` (throttle, trace, gc, heap), `probe` (long tasks > 50 мс, кадры rAF; тяжёлый кадр — интервал > 50 мс), `results.append`, `manifest.loadManifest`.

View File

@ -0,0 +1,8 @@
<!doctype html>
<meta charset="utf-8">
<title>busy</title>
<button id="busy">busy</button>
<script>
window.busyLoop = (ms) => { const t = performance.now(); while (performance.now() - t < ms) {} return performance.now() - t; };
document.getElementById('busy').addEventListener('click', () => window.busyLoop(120));
</script>

25
perf/e2e/lib/auth.ts Normal file
View File

@ -0,0 +1,25 @@
import type { BrowserContext } from '@playwright/test';
import type { User } from './manifest';
export const API = process.env.PERF_API ?? 'http://localhost:8000';
export const WEB = process.env.PERF_WEB ?? 'http://localhost:8080';
// Ключи localStorage: web/src/api/utils/token.js (saveAuthTokens), их читает AuthProvider.jsx.
export const ACCESS_KEY = 'access_token';
export const REFRESH_KEY = 'refresh_token';
/** Логин через API и запись токенов в localStorage origin'а фронта (init-script, до загрузки страницы). */
export async function loginAs(context: BrowserContext, user: User): Promise<void> {
const r = await context.request.post(`${API}/api/v1/auth/login`, {
data: { username: user.username, password: user.password },
});
if (!r.ok()) throw new Error(`login ${user.username}: ${r.status()} ${await r.text()}`);
const d = await r.json();
await context.addInitScript(
([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,
);
}

63
perf/e2e/lib/cdp.ts Normal file
View File

@ -0,0 +1,63 @@
import type { CDPSession, Page } from '@playwright/test';
import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const sessions = new WeakMap<Page, CDPSession>();
async function cdp(page: Page): Promise<CDPSession> {
let s = sessions.get(page);
if (!s) {
s = await page.context().newCDPSession(page);
sessions.set(page, s);
}
return s;
}
/** CPU throttling: rate=1 — без замедления, 4 — в 4 раза медленнее. */
export async function throttle(page: Page, rate: number): Promise<void> {
await (await cdp(page)).send('Emulation.setCPUThrottlingRate', { rate });
}
export async function startTrace(page: Page): Promise<void> {
await page.context().browser()!.startTracing(page, {
categories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8.execute', 'blink.user_timing'],
screenshots: false,
});
}
export async function stopTrace(page: Page, file: string): Promise<void> {
mkdirSync(path.dirname(file), { recursive: true });
const buf = await page.context().browser()!.stopTracing();
writeFileSync(file, buf);
}
export async function gc(page: Page): Promise<void> {
const s = await cdp(page);
await s.send('HeapProfiler.enable');
await s.send('HeapProfiler.collectGarbage');
}
export async function domCounters(page: Page): Promise<{ documents: number; nodes: number; jsEventListeners: number }> {
return (await (await cdp(page)).send('Memory.getDOMCounters')) as any;
}
/** Размер JS-кучи после GC, байты. */
export async function heapUsed(page: Page): Promise<number> {
await gc(page);
const s = await cdp(page);
await s.send('Runtime.enable');
const r = (await s.send('Runtime.getHeapUsage')) as { usedSize: number };
return r.usedSize;
}
export async function heapSnapshot(page: Page, file: string): Promise<void> {
mkdirSync(path.dirname(file), { recursive: true });
const s = await cdp(page);
const out = createWriteStream(file);
const onChunk = (e: { chunk: string }) => { out.write(e.chunk); };
s.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
await s.send('HeapProfiler.enable');
await s.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
s.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
await new Promise<void>((res) => out.end(res));
}

37
perf/e2e/lib/manifest.ts Normal file
View File

@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
export interface User { username: string; password: string; role_id: number }
export interface Sheet {
form_id: number; form_type: string; sheet: string; direction: string | null; year: number;
url: string; rows: number; owner: 'rf' | 'ssp';
}
export interface ProjectReport { project_id: number; year: number; report_type: string; url: string; rows: number }
export interface Page { name: string; url: string }
export interface Manifest {
scale: number;
users: Record<string, User>;
sheets: Sheet[];
project_reports: ProjectReport[];
svods: Page[];
modal_pages: Page[];
}
export const PERF_DIR = path.resolve(__dirname, '../..');
export function scale(): number {
return Number(process.env.PERF_SCALE ?? 1);
}
export function loadManifest(sc: number = scale()): Manifest {
const p = path.join(PERF_DIR, 'results', `manifest-s${sc}.json`);
const d = JSON.parse(readFileSync(p, 'utf-8'));
return {
scale: d.scale,
users: d.users ?? {},
sheets: (d.sheets ?? []).map((s: Sheet) => ({ owner: 'rf', ...s })),
project_reports: d.project_reports ?? [],
svods: d.svods ?? [],
modal_pages: d.modal_pages ?? [],
};
}

57
perf/e2e/lib/probe.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* Init-script для page.addInitScript(PROBE_SCRIPT).
* window.__perf.start() / stop() -> {long_task_n, long_task_ms, frame_n, heavy_frame_n,
* frame_p50_ms, frame_p95_ms, duration_ms}.
* Long task: > 50 мс (PerformanceObserver 'longtask'). Тяжёлый кадр: интервал между rAF > 50 мс.
*/
export const HEAVY_FRAME_MS = 50;
export interface ProbeMetrics {
long_task_n: number;
long_task_ms: number;
frame_n: number;
heavy_frame_n: number;
frame_p50_ms: number;
frame_p95_ms: number;
duration_ms: number;
}
export const PROBE_SCRIPT = `(() => {
if (window.__perf) return;
const HEAVY = ${HEAVY_FRAME_MS};
let active = false, t0 = 0, tasks = [], frames = [], last = 0, raf = 0;
try {
new PerformanceObserver((list) => {
if (!active) return;
for (const e of list.getEntries()) tasks.push(e.duration);
}).observe({ type: 'longtask', buffered: false });
} catch (e) {}
const tick = (now) => {
if (!active) return;
if (last) frames.push(now - last);
last = now;
raf = requestAnimationFrame(tick);
};
const pct = (s, p) => s.length ? s[Math.max(Math.ceil(p * s.length), 1) - 1] : 0;
window.__perf = {
start() {
tasks = []; frames = []; last = 0; t0 = performance.now(); active = true;
raf = requestAnimationFrame(tick);
},
async stop() {
// дать долгим задачам, завершившимся перед stop, доехать до observer
await new Promise((r) => setTimeout(r, 100));
active = false; cancelAnimationFrame(raf);
const s = frames.slice().sort((a, b) => a - b);
return {
long_task_n: tasks.length,
long_task_ms: tasks.reduce((a, b) => a + b, 0),
frame_n: frames.length,
heavy_frame_n: frames.filter((f) => f > HEAVY).length,
frame_p50_ms: pct(s, 0.5),
frame_p95_ms: pct(s, 0.95),
duration_ms: performance.now() - t0,
};
},
};
})();`;

45
perf/e2e/lib/results.ts Normal file
View File

@ -0,0 +1,45 @@
import { appendFileSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { PERF_DIR } from './manifest';
function pad(n: number) { return String(n).padStart(2, '0'); }
export function runId(): string {
if (process.env.PERF_RUN_ID) return process.env.PERF_RUN_ID;
const d = new Date();
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
}
/** Каталог прогона: PERF_RESULTS_DIR (тесты) или perf/results/<run_id>. */
export function runDir(): string {
return process.env.PERF_RESULTS_DIR ?? path.join(PERF_DIR, 'results', runId());
}
export interface ResultRecord {
run_id?: string;
scenario: string;
target: string;
role: string;
scale: number;
cpu_rate: number;
repeat: number;
metrics: Record<string, number>;
artifacts: string[];
incomplete?: boolean;
ts?: string;
}
/** Формат — docstring perf/perfkit/results.py: perf/results/<run_id>/<scenario>.jsonl. */
export function append(rec: ResultRecord): string {
const dir = runDir();
mkdirSync(dir, { recursive: true });
const full = {
run_id: runId(),
incomplete: false,
ts: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
...rec,
};
const file = path.join(dir, `${rec.scenario}.jsonl`);
appendFileSync(file, JSON.stringify(full) + '\n', 'utf-8');
return file;
}

454
perf/e2e/package-lock.json generated Normal file
View File

@ -0,0 +1,454 @@
{
"name": "perf-e2e",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "perf-e2e",
"version": "0.0.0",
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "26.6.5",
"typescript": "7.0.2"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
}
}

13
perf/e2e/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "perf-e2e",
"private": true,
"version": "0.0.0",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "26.6.5",
"typescript": "7.0.2"
}
}

View File

@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
workers: 1,
fullyParallel: false,
retries: 0,
timeout: 10 * 60 * 1000,
reporter: 'list',
use: {
browserName: 'chromium',
baseURL: process.env.PERF_WEB ?? 'http://localhost:8080',
},
});

View File

@ -0,0 +1,68 @@
import { test, expect, chromium } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PROBE_SCRIPT } from '../lib/probe';
import { throttle } from '../lib/cdp';
import { append } from '../lib/results';
const BUSY = 'file://' + path.resolve(__dirname, '../fixtures/busy.html');
const PERF_DIR = path.resolve(__dirname, '../..');
test('probe detects long task', async ({ page }) => {
await page.addInitScript(PROBE_SCRIPT);
await page.goto(BUSY);
await page.evaluate(() => (window as any).__perf.start());
await page.click('#busy');
const m = await page.evaluate(() => (window as any).__perf.stop());
expect(m.long_task_n).toBeGreaterThanOrEqual(1);
expect(m.long_task_ms).toBeGreaterThanOrEqual(100);
expect(m.frame_n).toBeGreaterThan(0);
expect(m.duration_ms).toBeGreaterThan(0);
});
test('throttle slows script', async ({ page }) => {
await page.goto(BUSY);
const loop = () => page.evaluate(() => {
const t = performance.now();
let x = 0;
for (let i = 0; i < 3e7; i++) x += i % 7;
return performance.now() - t + x * 0;
});
await throttle(page, 1);
const base = await loop();
await throttle(page, 4);
const slow = await loop();
expect(slow).toBeGreaterThanOrEqual(2 * base);
});
test('fresh context isolation', async ({ browser }) => {
const a = await browser.newContext();
const pa = await a.newPage();
await pa.goto(BUSY);
await pa.evaluate(() => localStorage.setItem('k', 'v'));
const b = await browser.newContext();
const pb = await b.newPage();
await pb.goto(BUSY);
expect(await pb.evaluate(() => localStorage.getItem('k'))).toBeNull();
await a.close();
await b.close();
});
test('results format', async () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'perf-'));
const runDir = path.join(tmp, `harness-${Date.now()}`);
process.env.PERF_RESULTS_DIR = runDir;
append({
scenario: 'harness', target: 'prod', role: 'admin', scale: 1, cpu_rate: 1, repeat: 0,
metrics: { long_task_ms: 123.4 }, artifacts: [],
});
const py = path.join(PERF_DIR, '.venv/bin/python');
const out = execFileSync(py, ['-m', 'perfkit.aggregate', runDir], { cwd: PERF_DIR }).toString().trim();
const summary = JSON.parse(execFileSync('cat', [path.join(out, 'summary.json')]).toString());
expect(summary.groups).toHaveLength(1);
expect(summary.groups[0].n).toBe(1);
rmSync(tmp, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});