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>
172 lines
7.0 KiB
JavaScript
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));
|