diff --git a/.gitignore b/.gitignore index 6eb9d66..a36e235 100644 --- a/.gitignore +++ b/.gitignore @@ -341,3 +341,5 @@ perf/.venv/ perf/e2e/node_modules/ perf/.run/ !perf/docker-compose.yml +perf/e2e/test-results/ +!perf/e2e/lib/ diff --git a/openspec/changes/perf-profiling/design.md b/openspec/changes/perf-profiling/design.md index 7e22156..6188eea 100644 --- a/openspec/changes/perf-profiling/design.md +++ b/openspec/changes/perf-profiling/design.md @@ -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/`; не должно оставаться мусора | Мусор в 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 | Библиотеки не попадут в коммит / мусор в коммите | diff --git a/perf/README.md b/perf/README.md index d227de9..78295cb 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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//` (по умолчанию `YYYYMMDD-HHMM`). +- `PERF_SCALE` — масштаб манифеста `perf/results/manifest-s.json` (по умолчанию 1). +- `PERF_API`, `PERF_WEB` — адреса стенда (`http://localhost:8000`, `http://localhost:8080`). +- Агрегация: `cd perf && .venv/bin/python -m perfkit.aggregate results/`. +- Библиотеки `perf/e2e/lib/`: `auth.loginAs`, `cdp.*` (throttle, trace, gc, heap), `probe` (long tasks > 50 мс, кадры rAF; тяжёлый кадр — интервал > 50 мс), `results.append`, `manifest.loadManifest`. diff --git a/perf/e2e/fixtures/busy.html b/perf/e2e/fixtures/busy.html new file mode 100644 index 0000000..fd12f3b --- /dev/null +++ b/perf/e2e/fixtures/busy.html @@ -0,0 +1,8 @@ + + +busy + + diff --git a/perf/e2e/lib/auth.ts b/perf/e2e/lib/auth.ts new file mode 100644 index 0000000..c1313cb --- /dev/null +++ b/perf/e2e/lib/auth.ts @@ -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 { + 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, + ); +} diff --git a/perf/e2e/lib/cdp.ts b/perf/e2e/lib/cdp.ts new file mode 100644 index 0000000..81fa369 --- /dev/null +++ b/perf/e2e/lib/cdp.ts @@ -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(); + +async function cdp(page: Page): Promise { + 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 { + await (await cdp(page)).send('Emulation.setCPUThrottlingRate', { rate }); +} + +export async function startTrace(page: Page): Promise { + 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 { + mkdirSync(path.dirname(file), { recursive: true }); + const buf = await page.context().browser()!.stopTracing(); + writeFileSync(file, buf); +} + +export async function gc(page: Page): Promise { + 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 { + 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 { + 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((res) => out.end(res)); +} diff --git a/perf/e2e/lib/manifest.ts b/perf/e2e/lib/manifest.ts new file mode 100644 index 0000000..64beb02 --- /dev/null +++ b/perf/e2e/lib/manifest.ts @@ -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; + 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 ?? [], + }; +} diff --git a/perf/e2e/lib/probe.ts b/perf/e2e/lib/probe.ts new file mode 100644 index 0000000..09db64e --- /dev/null +++ b/perf/e2e/lib/probe.ts @@ -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, + }; + }, + }; +})();`; diff --git a/perf/e2e/lib/results.ts b/perf/e2e/lib/results.ts new file mode 100644 index 0000000..5c8f84e --- /dev/null +++ b/perf/e2e/lib/results.ts @@ -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/. */ +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; + artifacts: string[]; + incomplete?: boolean; + ts?: string; +} + +/** Формат — docstring perf/perfkit/results.py: perf/results//.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; +} diff --git a/perf/e2e/package-lock.json b/perf/e2e/package-lock.json new file mode 100644 index 0000000..31aa49e --- /dev/null +++ b/perf/e2e/package-lock.json @@ -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" + } + } +} diff --git a/perf/e2e/package.json b/perf/e2e/package.json new file mode 100644 index 0000000..57f0688 --- /dev/null +++ b/perf/e2e/package.json @@ -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" + } +} diff --git a/perf/e2e/playwright.config.ts b/perf/e2e/playwright.config.ts new file mode 100644 index 0000000..4d80eec --- /dev/null +++ b/perf/e2e/playwright.config.ts @@ -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', + }, +}); diff --git a/perf/e2e/tests/harness.spec.ts b/perf/e2e/tests/harness.spec.ts new file mode 100644 index 0000000..b38d847 --- /dev/null +++ b/perf/e2e/tests/harness.spec.ts @@ -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 }); +});