Files
ozone-tech_owl_prime/scripts/collect-perf-metrics.mjs
Даня Архипов b5985f33bb feat: ship production deploy, Playwright e2e, and agent implementer
Add Docker atomic deploy tooling, CI, Playwright smoke/visual suite,
shared industrial theme, physics invariants, quality-mode wiring,
and a worktree-isolated agent implementer MVP with hard safety limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 17:14:01 +00:00

172 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Collect objective performance / asset metrics for the demo.
* Writes docs/PERFORMANCE_AFTER_MAXIMUM_DEMO.md (partial) and agent/reports/perf-metrics.json
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const DIST = join(ROOT, 'dist');
const ASSETS = join(DIST, 'assets');
function gzipSize(file) {
const r = spawnSync('gzip', ['-c', file], { encoding: 'buffer', maxBuffer: 50 * 1024 * 1024 });
return r.stdout?.length ?? 0;
}
function collectBundleMetrics() {
if (!existsSync(ASSETS)) throw new Error('dist/assets missing — run npm run build first');
const files = readdirSync(ASSETS);
const rows = files.map((name) => {
const p = join(ASSETS, name);
const st = statSync(p);
return {
name,
bytes: st.size,
gzip: name.match(/\.(js|css)$/) ? gzipSize(p) : null,
};
});
const js = rows.filter((r) => r.name.endsWith('.js'));
const css = rows.filter((r) => r.name.endsWith('.css'));
const totalJs = js.reduce((a, b) => a + b.bytes, 0);
const totalJsGzip = js.reduce((a, b) => a + (b.gzip ?? 0), 0);
const main = js.find((r) => r.name.startsWith('index-')) ?? js[0];
const r3f = js.find((r) => r.name.includes('react-three-fiber')) ?? null;
return { rows, totalJs, totalJsGzip, main, r3f, css };
}
function runPlaywrightPerf() {
const base = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3101';
const script = `
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', e => errors.push(String(e)));
const t0 = Date.now();
await page.goto(process.env.BASE, { waitUntil: 'networkidle', timeout: 60000 });
const loadMs = Date.now() - t0;
await page.waitForTimeout(2000);
const heap1 = await page.evaluate(() => performance.memory ? performance.memory.usedJSHeapSize : null);
const play = page.getByTestId('demo-play').or(page.getByRole('button', { name: /play demo/i }));
await play.click({ timeout: 10000 }).catch(()=>{});
// sample rAF fps for 3s
const fpsSample = await page.evaluate(async () => {
return await new Promise(resolve => {
let frames = 0;
let last = performance.now();
const times = [];
function tick(now) {
frames++;
times.push(now - last);
last = now;
if (frames < 180) requestAnimationFrame(tick);
else {
const avg = 1000 / (times.reduce((a,b)=>a+b,0) / times.length);
const sorted = [...times].sort((a,b)=>a-b);
const p95 = sorted[Math.floor(sorted.length * 0.95)];
resolve({ avgFps: avg, p95FrameMs: p95, minFps: 1000 / Math.max(...times) });
}
}
requestAnimationFrame(tick);
});
});
// jump to end-ish via seek if available
for (let i = 0; i < 3; i++) {
await play.click({ timeout: 3000 }).catch(()=>{});
await page.waitForTimeout(500);
}
const heap2 = await page.evaluate(() => performance.memory ? performance.memory.usedJSHeapSize : null);
const canvas = await page.locator('canvas').count();
await browser.close();
console.log(JSON.stringify({ loadMs, heap1, heap2, fpsSample, canvas, errors }));
})().catch(e => { console.error(e); process.exit(1); });
`;
const tmp = join(ROOT, 'scripts', '.perf-tmp.cjs');
writeFileSync(tmp, script.replace('process.env.BASE', JSON.stringify(base)));
const r = spawnSync('node', [tmp], {
cwd: ROOT,
encoding: 'utf8',
timeout: 120000,
env: { ...process.env, BASE: base },
});
try { /* keep tmp for debug */ } catch {}
if (r.status !== 0) {
return { error: r.stderr || r.stdout || 'playwright perf failed', raw: r.stdout };
}
const line = r.stdout.trim().split('\n').filter(Boolean).pop();
try {
return JSON.parse(line);
} catch {
return { error: 'parse', raw: r.stdout };
}
}
mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true });
const bundle = collectBundleMetrics();
let runtime = { skipped: true };
try {
runtime = runPlaywrightPerf();
} catch (e) {
runtime = { error: String(e) };
}
const report = {
measuredAt: new Date().toISOString(),
commit: spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim(),
bundle: {
totalJsBytes: bundle.totalJs,
totalJsGzip: bundle.totalJsGzip,
main: bundle.main,
r3f: bundle.r3f,
},
runtime,
};
writeFileSync(join(ROOT, 'agent', 'reports', 'perf-metrics.json'), JSON.stringify(report, null, 2));
const fps = runtime?.fpsSample;
const md = `# PERFORMANCE_AFTER_MAXIMUM_DEMO
Measured: ${report.measuredAt}
Commit: \`${report.commit}\`
## Bundle (measured)
| Метрика | Значение |
| ------- | -------: |
| Total JS | ${(bundle.totalJs / 1024).toFixed(1)} KB |
| Total JS gzip | ${(bundle.totalJsGzip / 1024).toFixed(1)} KB |
| Main chunk | ${bundle.main?.name ?? '—'} (${((bundle.main?.gzip ?? 0) / 1024).toFixed(1)} KB gzip) |
| R3F chunk gzip | ${bundle.r3f ? ((bundle.r3f.gzip ?? 0) / 1024).toFixed(1) + ' KB' : '—'} |
## Runtime (Playwright Chromium headless)
| Метрика | До | После | Цель | Статус |
| ------- | -: | ----: | ---: | ------ |
| Average FPS | n/a (не измерялось) | ${fps ? fps.avgFps.toFixed(1) : 'n/a'} | ≥30 | ${fps && fps.avgFps >= 30 ? 'OK' : 'CHECK'} |
| Minimum FPS | n/a | ${fps ? fps.minFps.toFixed(1) : 'n/a'} | ≥20 | ${fps && fps.minFps >= 20 ? 'OK' : 'CHECK'} |
| p95 frame time | n/a | ${fps ? fps.p95FrameMs.toFixed(1) + ' ms' : 'n/a'} | ≤33 ms | ${fps && fps.p95FrameMs <= 33 ? 'OK' : 'CHECK'} |
| Load time | n/a | ${runtime.loadMs ?? 'n/a'} ms | ≤5000 | ${(runtime.loadMs ?? 99999) <= 5000 ? 'OK' : 'CHECK'} |
| JS heap start | n/a | ${runtime.heap1 ?? 'n/a'} | — | measured |
| JS heap after interaction | n/a | ${runtime.heap2 ?? 'n/a'} | no runaway | measured |
| Canvas present | — | ${runtime.canvas ?? 0} | ≥1 | ${(runtime.canvas ?? 0) >= 1 ? 'OK' : 'FAIL'} |
| JS gzip (main+vendor) | ~335 KB main previously | ${(bundle.totalJsGzip / 1024).toFixed(1)} KB | — | measured |
## Notes
- Draw calls / triangles require WebGL inspector in headed mode; not available in this headless pass.
- \`performance.memory\` may be null outside Chromium with \`--enable-precise-memory-info\`.
- Demo quality mode keeps \`shadows=false\` and \`effectsEnabled=false\` for stable FPS (see \`qualityMode.ts\`).
- Raw JSON: \`agent/reports/perf-metrics.json\`
- Runtime error (if any): ${runtime.error ? String(runtime.error).slice(0, 200) : 'none'}
`;
writeFileSync(join(ROOT, 'docs', 'PERFORMANCE_AFTER_MAXIMUM_DEMO.md'), md);
console.log(JSON.stringify({ ok: true, fps, loadMs: runtime.loadMs, totalJsGzip: bundle.totalJsGzip }, null, 2));