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>
This commit is contained in:
171
scripts/collect-perf-metrics.mjs
Normal file
171
scripts/collect-perf-metrics.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/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));
|
||||
Reference in New Issue
Block a user