Merge branch 'main' into reproduce/issue-1720
Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
@@ -18,8 +18,8 @@
|
||||
// The sentence wraps automatically; the version + sentence block is
|
||||
// bottom-anchored over a readability scrim so the glowing plate stays visible.
|
||||
//
|
||||
// Fonts (IBM Plex Sans, Instrument Serif) are fetched once from Fontsource
|
||||
// into ./.fonts (gitignored) and wired into fontconfig for Pango.
|
||||
// Accent fonts are fetched once from Fontsource into ./.fonts (gitignored)
|
||||
// and wired into fontconfig for Pango. Main text uses the system sans stack.
|
||||
|
||||
import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -30,10 +30,6 @@ const repoRoot = path.resolve(toolDir, '..', '..');
|
||||
const fontsDir = path.join(toolDir, '.fonts');
|
||||
|
||||
const FONTS = [
|
||||
{
|
||||
file: 'IBMPlexSans-SemiBold.ttf',
|
||||
url: 'https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-sans@latest/latin-600-normal.ttf',
|
||||
},
|
||||
{
|
||||
file: 'InstrumentSerif-Italic.ttf',
|
||||
url: 'https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.ttf',
|
||||
@@ -142,7 +138,7 @@ async function main() {
|
||||
const sentenceBuf = await sharp({
|
||||
text: {
|
||||
text: toPangoMarkup(sentence),
|
||||
font: 'IBM Plex Sans 92',
|
||||
font: 'system-ui 92',
|
||||
rgba: true,
|
||||
width: wrapWidth,
|
||||
wrap: 'word',
|
||||
@@ -160,7 +156,7 @@ async function main() {
|
||||
text: `<span foreground="${AMBER}" letter_spacing="2048">${escapeMarkup(
|
||||
title
|
||||
)}</span>`,
|
||||
font: 'IBM Plex Sans 64',
|
||||
font: 'system-ui 64',
|
||||
rgba: true,
|
||||
align: 'left',
|
||||
},
|
||||
|
||||
@@ -17,6 +17,13 @@ const repoRoot = resolve(__dirname, "..")
|
||||
const remixPath = resolve(repoRoot, "node_modules/@remixicon/react/index.mjs")
|
||||
const outPath = resolve(repoRoot, "packages/ui/src/components/icon/sprite.ts")
|
||||
|
||||
const customIconData = new Map([
|
||||
[
|
||||
"openchamber",
|
||||
`<polygon points="12 2.5 3.5 7.4 3.5 17.2 12 22.1 20.5 17.2 20.5 7.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><polyline points="3.5 7.4 12 12.3 20.5 7.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="12.3" x2="12" y2="22.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="m12 5.5 3.7 2.1L12 9.7 8.3 7.6 12 5.5Zm0 1.5-1 .6 1 .6 1-.6-1-.6Z" fill="currentColor" fill-rule="evenodd"/>`,
|
||||
],
|
||||
])
|
||||
|
||||
const source = readFileSync(remixPath, "utf-8")
|
||||
|
||||
// --- Step 1: extract variable → path mapping ---
|
||||
@@ -142,7 +149,13 @@ function findAllSourceFiles(dir) {
|
||||
|
||||
const allSrcFiles = findAllSourceFiles(srcDir)
|
||||
const usedIcons = new Set()
|
||||
const usedCustomIcons = new Set()
|
||||
const addKebabIcon = (kebab) => {
|
||||
if (customIconData.has(kebab)) {
|
||||
usedCustomIcons.add(kebab)
|
||||
return true
|
||||
}
|
||||
|
||||
const exactRiName = spriteNameToRi.get(kebab)
|
||||
if (exactRiName && !hasRemixVariantSuffix(exactRiName)) {
|
||||
usedIcons.add(exactRiName)
|
||||
@@ -352,11 +365,18 @@ for (const iconName of [...usedIcons].sort()) {
|
||||
iconEntries.push({ name: iconName, content: svgContent })
|
||||
}
|
||||
|
||||
for (const iconName of [...usedCustomIcons].sort()) {
|
||||
iconEntries.push({ name: iconName, content: customIconData.get(iconName) })
|
||||
}
|
||||
|
||||
// --- Step 5: write sprite.ts ---
|
||||
const spriteLines = iconEntries.map(({ name, content }) => {
|
||||
const spriteName = remixToSpriteName(name)
|
||||
return ` "${spriteName}": \`${content}\`,`
|
||||
})
|
||||
const spriteLines = iconEntries
|
||||
.map(({ name, content }) => ({
|
||||
name: name.startsWith("Ri") ? remixToSpriteName(name) : name,
|
||||
content,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(({ name, content }) => ` "${name}": \`${content}\`,`)
|
||||
|
||||
const spriteContent = `// This file is auto-generated by scripts/generate-icon-sprite.mjs
|
||||
// Do not edit manually. Run the script to update.
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* harmonize-theme.mjs
|
||||
*
|
||||
* Aligns a theme's accent palette in OKLCH space so every accent shares one
|
||||
* saturation (chroma) and one brightness (lightness) target while keeping its
|
||||
* own hue. This is how you make colors borrowed from different themes feel like
|
||||
* one family — e.g. pulling Flexoki's punchy status hues down to Vitesse's
|
||||
* calmer saturation.
|
||||
*
|
||||
* It only touches the accent roles listed in ACCENT_ROLES (primary / status /
|
||||
* pr). Surfaces, borders, text, and syntax are left untouched — syntax is
|
||||
* usually already the reference you are matching to.
|
||||
*
|
||||
* Pipeline per color: hex -> linear sRGB -> OKLab -> OKLCH
|
||||
* -> set L = target.l, C = target.c, keep H
|
||||
* -> gamut-fit (reduce C until it fits sRGB) -> hex (alpha preserved)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/harmonize-theme.mjs <theme.json> # dry-run table
|
||||
* node scripts/harmonize-theme.mjs <theme.json> --write # rewrite in place
|
||||
* node scripts/harmonize-theme.mjs <theme.json> --out=x.json
|
||||
* Overrides: --l=0.70 --c=0.085 (defaults are chosen per light/dark variant)
|
||||
*
|
||||
* No dependencies; math ported from the OpenCode desktop color engine.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
|
||||
// ---- sRGB <-> OKLCH ---------------------------------------------------------
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const hue = (v) => ((v % 360) + 360) % 360;
|
||||
|
||||
function parseHex(hex) {
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
|
||||
const a = h.length === 8 ? h.slice(6, 8) : null;
|
||||
const n = parseInt(h.slice(0, 6), 16);
|
||||
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, alpha: a };
|
||||
}
|
||||
function toHex(r, g, b, alpha) {
|
||||
const c = (v) => Math.round(clamp(v, 0, 1) * 255).toString(16).padStart(2, '0');
|
||||
return `#${c(r)}${c(g)}${c(b)}${alpha ?? ''}`;
|
||||
}
|
||||
const srgbToLinear = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
||||
const linearToSrgb = (v) => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
|
||||
|
||||
function rgbToOklch(r, g, b) {
|
||||
const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
|
||||
const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
|
||||
const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
|
||||
const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
|
||||
const l = Math.cbrt(l_), m = Math.cbrt(m_), s = Math.cbrt(s_);
|
||||
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
||||
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
let H = Math.atan2(bb, a) * (180 / Math.PI);
|
||||
if (H < 0) H += 360;
|
||||
return { l: L, c: Math.sqrt(a * a + bb * bb), h: H };
|
||||
}
|
||||
function oklchToRgb({ l: L, c: C, h: H }) {
|
||||
const a = C * Math.cos((H * Math.PI) / 180);
|
||||
const b = C * Math.sin((H * Math.PI) / 180);
|
||||
const l = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s = L - 0.0894841775 * a - 1.291485548 * b;
|
||||
const l3 = l * l * l, m3 = m * m * m, s3 = s * s * s;
|
||||
return {
|
||||
r: linearToSrgb(4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3),
|
||||
g: linearToSrgb(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3),
|
||||
b: linearToSrgb(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3),
|
||||
};
|
||||
}
|
||||
const inGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
|
||||
function fitOklch(o) {
|
||||
const base = { l: clamp(o.l, 0, 1), c: Math.max(0, o.c), h: hue(o.h) };
|
||||
if (inGamut(oklchToRgb(base))) return base;
|
||||
let c = base.c;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
c *= 0.9;
|
||||
const next = { ...base, c };
|
||||
if (inGamut(oklchToRgb(next))) return next;
|
||||
}
|
||||
return { ...base, c: 0 };
|
||||
}
|
||||
function oklchToHex(o, alpha) {
|
||||
const { r, g, b } = oklchToRgb(fitOklch(o));
|
||||
return toHex(r, g, b, alpha);
|
||||
}
|
||||
|
||||
// ---- harmonization ----------------------------------------------------------
|
||||
// Per variant: the single chroma (saturation) and lightness every accent lands
|
||||
// on. Dark values match Vitesse Dark's calm accents (~L .68, C .085); light
|
||||
// values match Vitesse Light (~L .55, C .10) and stay readable on white.
|
||||
const TARGETS = {
|
||||
dark: { l: 0.68, c: 0.135 },
|
||||
light: { l: 0.55, c: 0.145 },
|
||||
};
|
||||
|
||||
// Status accent roles to align. Each re-derives its translucent background,
|
||||
// border, and on-fill text color from the harmonized base.
|
||||
const STATUS_ROLES = ['error', 'warning', 'success', 'info'];
|
||||
// pr accents that carry a hue (draft stays a neutral grey, so it is skipped).
|
||||
const PR_ROLES = ['open', 'blocked', 'merged', 'closed'];
|
||||
const STATUS_BG_ALPHA = '20';
|
||||
const STATUS_BORDER_ALPHA = '50';
|
||||
// Lightness offsets applied to the harmonized primary base to derive its
|
||||
// interaction shades (dark themes lift on hover/press, light themes deepen).
|
||||
const PRIMARY_OFFSETS = {
|
||||
dark: { hover: 0.05, active: 0.1, emphasis: 0.1 },
|
||||
light: { hover: -0.06, active: -0.11, emphasis: -0.06 },
|
||||
};
|
||||
|
||||
function harmonize(hex, target) {
|
||||
const { r, g, b, alpha } = parseHex(hex);
|
||||
const src = rgbToOklch(r, g, b);
|
||||
const out = { l: target.l, c: target.c, h: src.h };
|
||||
return { hex: oklchToHex(out, alpha), src, out };
|
||||
}
|
||||
// shift a harmonized base to a new lightness, keeping chroma + hue
|
||||
function atLightness(hex, l) {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
const o = rgbToOklch(r, g, b);
|
||||
return oklchToHex({ l: clamp(l, 0, 1), c: o.c, h: o.h });
|
||||
}
|
||||
// pick #000 / #fff for text on a solid fill
|
||||
function onColor(hex) {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
const L = rgbToOklch(r, g, b).l;
|
||||
return L > 0.6 ? '#000000' : '#ffffff';
|
||||
}
|
||||
// multiply chroma by a factor, keeping lightness + hue (gamut-fit clamps the
|
||||
// already-saturated ones, so pastels gain the most). Used to de-pastel syntax.
|
||||
function scaleChroma(hex, factor) {
|
||||
const { r, g, b, alpha } = parseHex(hex);
|
||||
const src = rgbToOklch(r, g, b);
|
||||
const out = { l: src.l, c: src.c * factor, h: src.h };
|
||||
return { hex: oklchToHex(out, alpha), src, out };
|
||||
}
|
||||
|
||||
// Syntax token colors are boosted, but not the code panel background, the
|
||||
// default text color, or the deliberately-muted local-variable tone.
|
||||
const SYNTAX_SKIP_BASE = new Set(['background', 'foreground']);
|
||||
const SYNTAX_SKIP_TOKENS = new Set(['variableLocal']);
|
||||
|
||||
function saturateSyntax(theme, factor) {
|
||||
const syntax = theme.colors?.syntax;
|
||||
const rows = [];
|
||||
const bump = (bag, skip, prefix) => {
|
||||
if (!bag) return;
|
||||
for (const [key, val] of Object.entries(bag)) {
|
||||
if (typeof val !== 'string' || skip.has(key)) continue;
|
||||
const { hex, src, out } = scaleChroma(val, factor);
|
||||
bag[key] = hex;
|
||||
rows.push([`${prefix}.${key}`, val, hex, src, out]);
|
||||
}
|
||||
};
|
||||
bump(syntax?.base, SYNTAX_SKIP_BASE, 'base');
|
||||
bump(syntax?.tokens, SYNTAX_SKIP_TOKENS, 'tokens');
|
||||
return rows;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find((a) => !a.startsWith('--'));
|
||||
if (!file) {
|
||||
console.error(
|
||||
'usage: node scripts/harmonize-theme.mjs <theme.json> [--write] [--out=path] [--l=..] [--c=..]\n' +
|
||||
' node scripts/harmonize-theme.mjs <theme.json> --syntax[=factor] [--write] (boost syntax saturation only)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const write = args.includes('--write');
|
||||
const outArg = args.find((a) => a.startsWith('--out='));
|
||||
const lArg = args.find((a) => a.startsWith('--l='));
|
||||
const cArg = args.find((a) => a.startsWith('--c='));
|
||||
|
||||
const theme = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const variant = theme.metadata?.variant === 'light' ? 'light' : 'dark';
|
||||
const target = { ...TARGETS[variant] };
|
||||
if (lArg) target.l = parseFloat(lArg.split('=')[1]);
|
||||
if (cArg) target.c = parseFloat(cArg.split('=')[1]);
|
||||
|
||||
// --- syntax-only mode: boost token saturation, leave accents untouched ---
|
||||
const synArg = args.find((a) => a === '--syntax' || a.startsWith('--syntax='));
|
||||
if (synArg) {
|
||||
const factor = synArg.includes('=') ? parseFloat(synArg.split('=')[1]) : 1.3;
|
||||
const rows = saturateSyntax(theme, factor);
|
||||
console.log(`\n${file} (syntax saturation ×${factor}, variant: ${variant})\n`);
|
||||
console.log(' token before -> after C (chroma)');
|
||||
for (const [name, before, after, src, out] of rows) {
|
||||
console.log(
|
||||
` ${name.padEnd(20)} ${before.slice(0, 7)} -> ${after.slice(0, 7)} ${src.c.toFixed(3)}→${out.c.toFixed(3)}`,
|
||||
);
|
||||
}
|
||||
const outPath = outArg ? outArg.split('=')[1] : file;
|
||||
if (write || outArg) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(theme, null, 2) + '\n');
|
||||
console.log(`\nwrote ${outPath}`);
|
||||
} else {
|
||||
console.log('\n(dry run — pass --write to save)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const record = (name, before, after, src, out) => rows.push([name, before, after, src, out]);
|
||||
|
||||
// --- primary: harmonize base, derive interaction shades from it ----------
|
||||
const primary = theme.colors?.primary;
|
||||
if (primary && typeof primary.base === 'string') {
|
||||
const { hex, src, out } = harmonize(primary.base, target);
|
||||
record('primary.base', primary.base, hex, src, out);
|
||||
primary.base = hex;
|
||||
const off = PRIMARY_OFFSETS[variant];
|
||||
if ('hover' in primary) primary.hover = atLightness(hex, target.l + off.hover);
|
||||
if ('active' in primary) primary.active = atLightness(hex, target.l + off.active);
|
||||
if ('emphasis' in primary) primary.emphasis = atLightness(hex, target.l + off.emphasis);
|
||||
if ('muted' in primary) primary.muted = hex.slice(0, 7) + '80';
|
||||
if ('foreground' in primary) primary.foreground = onColor(hex);
|
||||
|
||||
// Wire the interactive focus/selection accent to the harmonized primary.
|
||||
const inter = theme.colors?.interactive;
|
||||
if (inter) {
|
||||
if ('borderFocus' in inter) inter.borderFocus = hex;
|
||||
if ('focus' in inter) inter.focus = hex;
|
||||
if ('focusRing' in inter) inter.focusRing = hex.slice(0, 7) + '55';
|
||||
if ('selection' in inter) inter.selection = hex.slice(0, 7) + '2b';
|
||||
}
|
||||
}
|
||||
|
||||
// --- status: harmonize base, re-derive tints + on-fill text --------------
|
||||
const status = theme.colors?.status;
|
||||
if (status) {
|
||||
for (const key of STATUS_ROLES) {
|
||||
if (typeof status[key] !== 'string') continue;
|
||||
const { hex, src, out } = harmonize(status[key], target);
|
||||
record(`status.${key}`, status[key], hex, src, out);
|
||||
status[key] = hex;
|
||||
const bare = hex.slice(0, 7);
|
||||
if (`${key}Background` in status) status[`${key}Background`] = bare + STATUS_BG_ALPHA;
|
||||
if (`${key}Border` in status) status[`${key}Border`] = bare + STATUS_BORDER_ALPHA;
|
||||
if (`${key}Foreground` in status) status[`${key}Foreground`] = onColor(hex);
|
||||
}
|
||||
}
|
||||
|
||||
// --- pr: harmonize the hued roles ----------------------------------------
|
||||
const pr = theme.colors?.pr;
|
||||
if (pr) {
|
||||
for (const key of PR_ROLES) {
|
||||
if (typeof pr[key] !== 'string') continue;
|
||||
const { hex, src, out } = harmonize(pr[key], target);
|
||||
record(`pr.${key}`, pr[key], hex, src, out);
|
||||
pr[key] = hex;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${file} (variant: ${variant}, target L=${target.l} C=${target.c})\n`);
|
||||
console.log(' token before -> after ΔC (chroma)');
|
||||
for (const [name, before, after, src, out] of rows) {
|
||||
console.log(
|
||||
` ${name.padEnd(20)} ${before.slice(0, 7)} -> ${after.slice(0, 7)} ` +
|
||||
`${src.c.toFixed(3)}→${out.c.toFixed(3)} L ${src.l.toFixed(2)}→${out.l.toFixed(2)} H${Math.round(src.h)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const outPath = outArg ? outArg.split('=')[1] : file;
|
||||
if (write || outArg) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(theme, null, 2) + '\n');
|
||||
console.log(`\nwrote ${outPath}`);
|
||||
} else {
|
||||
console.log('\n(dry run — pass --write to save, or --out=path)');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+94
-16
@@ -44,7 +44,7 @@ const isMac = process.platform === 'darwin';
|
||||
function printHelp() {
|
||||
console.log(`Usage:
|
||||
bun run oc-dev [action] [options]
|
||||
bun scripts/oc-dev.mjs [action] [options]
|
||||
node scripts/oc-dev.mjs [action] [options]
|
||||
|
||||
Actions:
|
||||
build-deploy-web Build web package and deploy
|
||||
@@ -53,6 +53,7 @@ Actions:
|
||||
start-mobile-dev Start mobile app with dev server live reload
|
||||
mobile-tools Mobile build/sync/deploy helper menu
|
||||
start-electron-app Start Electron app in dev mode
|
||||
prepare-opencode-cli Download/cache bundled OpenCode CLI for Electron
|
||||
build-electron-app Build Electron app artifacts
|
||||
start-vscode-extension Build + launch VS Code extension host
|
||||
install-vscode-extension-local Build, package, and install local VSIX
|
||||
@@ -63,15 +64,16 @@ Options:
|
||||
--deployment-mode <global|testing>
|
||||
--remote-id <id> Remote deployment id from ${configPath}
|
||||
--target <test-api|test-ui> Compatibility alias for remote deployment selection
|
||||
--web-mode <hmr|hmr-lan|full>
|
||||
--web-mode <hmr|hmr-react-scan|hmr-lan|full>
|
||||
--mobile-mode <ios-sim-local|ios-sim-lan|android-local|android-lan>
|
||||
--mobile-task <task>
|
||||
--adb-address <host:port> Wireless ADB address for android-connect
|
||||
--vsix-cleanup <delete|keep>
|
||||
--version <semver>
|
||||
-h, --help
|
||||
|
||||
Mobile tasks:
|
||||
build, sync, android-devices, android-deploy-usb, android-run, android-logcat,
|
||||
build, sync, android-devices, android-connect, android-deploy-usb, android-run, android-logcat,
|
||||
ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug
|
||||
`);
|
||||
}
|
||||
@@ -114,6 +116,9 @@ function parseArgs(argv) {
|
||||
case '--mobile-task':
|
||||
options.mobileTask = readValue();
|
||||
break;
|
||||
case '--adb-address':
|
||||
options.adbAddress = readValue();
|
||||
break;
|
||||
case '--vsix-cleanup':
|
||||
options.vsixCleanup = readValue();
|
||||
break;
|
||||
@@ -168,6 +173,18 @@ function step(label, fn) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function printReleaseNextSteps(version) {
|
||||
log.success(`Release v${version} prepared locally`);
|
||||
log.info('Next steps:');
|
||||
console.log(` git add -A`);
|
||||
console.log(` git commit -m "release v${version}"`);
|
||||
console.log(` git tag v${version}`);
|
||||
console.log(` git push origin main --tags`);
|
||||
console.log('');
|
||||
console.log('This will trigger the GitHub Actions release workflow.');
|
||||
console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`);
|
||||
}
|
||||
|
||||
function normalizeAction(action = '') {
|
||||
const normalized = action.toLowerCase();
|
||||
const aliases = {
|
||||
@@ -180,6 +197,8 @@ function normalizeAction(action = '') {
|
||||
'mobile-menu': 'mobile-tools',
|
||||
'remote-deploy-web': 'remote-deploy-web',
|
||||
'electron-dev': 'start-electron-app',
|
||||
'opencode-cli': 'prepare-opencode-cli',
|
||||
'electron-opencode-cli': 'prepare-opencode-cli',
|
||||
'electron-build': 'build-electron-app',
|
||||
'vscode-dev': 'start-vscode-extension',
|
||||
'vscode-install-local': 'install-vscode-extension-local',
|
||||
@@ -203,6 +222,29 @@ async function chooseValue(current, choices, message) {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function chooseText(current, message, placeholder) {
|
||||
if (current) return current;
|
||||
ensurePromptable();
|
||||
const value = await text({ message, placeholder });
|
||||
if (isCancel(value)) {
|
||||
cancel('Operation cancelled.');
|
||||
process.exit(130);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateAdbAddress(address) {
|
||||
const normalized = String(address || '').trim();
|
||||
if (!/^[^\s:]+:\d{1,5}$/.test(normalized)) {
|
||||
throw new Error('Invalid wireless ADB address. Use host:port, e.g. 192.168.1.139:38181');
|
||||
}
|
||||
const port = Number(normalized.split(':').at(-1));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('Invalid wireless ADB port. Use a port between 1 and 65535.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function detectLanIp() {
|
||||
for (const addresses of Object.values(os.networkInterfaces())) {
|
||||
for (const address of addresses || []) {
|
||||
@@ -244,6 +286,11 @@ function installedWebCli(directory) {
|
||||
return existsSync(cliPath) ? cliPath : '';
|
||||
}
|
||||
|
||||
function installedGlobalWebCli() {
|
||||
const bunInstall = process.env.BUN_INSTALL || path.join(os.homedir(), '.bun');
|
||||
return installedWebCli(path.join(bunInstall, 'install', 'global'));
|
||||
}
|
||||
|
||||
function stopInstalledInstance(directory, port) {
|
||||
const cliPath = installedWebCli(directory);
|
||||
if (!cliPath) return;
|
||||
@@ -328,7 +375,11 @@ async function deployWeb(options, config) {
|
||||
run('bun', ['remove', '-g', 'openchamber'], { allowFail: true, label: 'remove openchamber' });
|
||||
});
|
||||
step('Installing package globally', () => run('bun', ['add', '-g', packageFile]));
|
||||
step(`Starting global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } }));
|
||||
step(`Starting global instance on ${GLOBAL_PORT}`, () => {
|
||||
const cliPath = installedGlobalWebCli();
|
||||
if (!cliPath) throw new Error('Global OpenChamber CLI was not installed by bun add -g');
|
||||
run('node', [cliPath, '--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } });
|
||||
});
|
||||
}
|
||||
|
||||
async function deployRemoteWeb(options, config) {
|
||||
@@ -358,11 +409,14 @@ async function deployRemoteWeb(options, config) {
|
||||
async function startWebDev(options) {
|
||||
const mode = await chooseValue(options.webMode, [
|
||||
{ value: 'hmr', label: 'Web HMR' },
|
||||
{ value: 'hmr-react-scan', label: 'Web HMR + React Scan' },
|
||||
{ value: 'hmr-lan', label: 'Web HMR LAN/mobile' },
|
||||
{ value: 'full', label: 'Web prod-like' },
|
||||
], 'Select web dev mode');
|
||||
|
||||
if (mode === 'hmr-lan') {
|
||||
if (mode === 'hmr-react-scan') {
|
||||
run('bun', ['run', 'dev:web:hmr'], { env: { VITE_ENABLE_REACT_SCAN: '1' } });
|
||||
} else if (mode === 'hmr-lan') {
|
||||
log.info('Starting web HMR LAN/mobile loop. Open the LAN URL printed after startup.');
|
||||
run('bun', ['run', 'dev:web:hmr'], { env: { OPENCHAMBER_HMR_HOST: '0.0.0.0' } });
|
||||
} else if (mode === 'full') {
|
||||
@@ -429,6 +483,7 @@ async function mobileTools(options, config) {
|
||||
{ value: 'build', label: 'Build mobile web assets' },
|
||||
{ value: 'sync', label: 'Sync native projects' },
|
||||
{ value: 'android-devices', label: 'Android: list USB devices' },
|
||||
{ value: 'android-connect', label: 'Android: connect wireless ADB device' },
|
||||
{ value: 'android-deploy-usb', label: 'Android: rebuild + deploy to USB device' },
|
||||
{ value: 'android-run', label: 'Android: install + launch existing APK' },
|
||||
{ value: 'android-logcat', label: 'Android: logcat' },
|
||||
@@ -450,6 +505,11 @@ async function mobileTools(options, config) {
|
||||
case 'build': return mobileRun('Building mobile web assets', 'build');
|
||||
case 'sync': return mobileRun('Syncing native projects', 'sync');
|
||||
case 'android-devices': return mobileRun('Listing Android USB devices', 'android:devices');
|
||||
case 'android-connect': {
|
||||
const address = validateAdbAddress(await chooseText(options.adbAddress, 'Enter wireless ADB address', '192.168.1.139:38181'));
|
||||
step(`Connecting wireless ADB device at ${address}`, () => run('node', ['scripts/with-mobile-env.mjs', `adb connect ${quote(address)}`], { cwd: mobileCwd }));
|
||||
return mobileRun('Listing Android devices', 'android:devices');
|
||||
}
|
||||
case 'android-deploy-usb':
|
||||
mobileRun('Building Android debug APK', 'build:android:debug');
|
||||
return mobileRun('Installing and launching Android app on USB device', 'android:run');
|
||||
@@ -474,10 +534,16 @@ async function mobileTools(options, config) {
|
||||
}
|
||||
|
||||
function startElectronApp() {
|
||||
prepareOpenCodeCli();
|
||||
run('bun', ['run', 'electron:dev']);
|
||||
}
|
||||
|
||||
function prepareOpenCodeCli() {
|
||||
step('Preparing bundled OpenCode CLI', () => run('bun', ['--filter', '@openchamber/electron', 'prepare:opencode-cli']));
|
||||
}
|
||||
|
||||
function buildElectronApp() {
|
||||
prepareOpenCodeCli();
|
||||
run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } });
|
||||
const distDir = path.join(repoRoot, 'packages/electron/dist');
|
||||
if (!existsSync(distDir) || !isMac) return;
|
||||
@@ -493,19 +559,27 @@ function startVsCodeExtension() {
|
||||
}
|
||||
|
||||
async function installVsCodeExtensionLocal(options) {
|
||||
const cleanup = await chooseValue(options.vsixCleanup, [
|
||||
{ value: 'delete', label: 'Delete VSIX after install' },
|
||||
{ value: 'keep', label: 'Keep VSIX after install' },
|
||||
], 'Select VSIX cleanup mode');
|
||||
let cleanup = options.vsixCleanup;
|
||||
if (!cleanup && isTty) {
|
||||
cleanup = await chooseValue('', [
|
||||
{ value: 'delete', label: 'Delete VSIX after install' },
|
||||
{ value: 'keep', label: 'Keep VSIX after install' },
|
||||
], 'Select VSIX cleanup mode');
|
||||
}
|
||||
cleanup ||= 'delete';
|
||||
if (!['delete', 'keep'].includes(cleanup)) throw new Error('Invalid --vsix-cleanup. Use delete or keep.');
|
||||
|
||||
const vscodeDir = path.join(repoRoot, 'packages/vscode');
|
||||
step('Building VS Code extension', () => run('bun', ['run', '--cwd', 'packages/vscode', 'build']));
|
||||
removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix');
|
||||
step('Removing found VSIX package(s) before install flow', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'));
|
||||
step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir }));
|
||||
run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true });
|
||||
const vsix = latestFileByExtensions(vscodeDir, ['.vsix']);
|
||||
if (!vsix) throw new Error('VSIX package was not created.');
|
||||
step('Installing VSIX locally', () => run('code', ['--install-extension', vsix]));
|
||||
if (cleanup === 'delete') removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix');
|
||||
step('Installing VSIX locally', () => {
|
||||
run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true });
|
||||
run('code --install-extension packages/vscode/openchamber-*.vsix', [], { shell: true, label: 'install VSIX' });
|
||||
});
|
||||
if (cleanup === 'delete') {
|
||||
step('Removing local VSIX package(s) after install', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'));
|
||||
}
|
||||
}
|
||||
|
||||
async function createRelease(options) {
|
||||
@@ -525,7 +599,7 @@ async function createRelease(options) {
|
||||
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1');
|
||||
step('Validating codebase', () => run('bun', ['run', 'release:prepare']));
|
||||
step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version]));
|
||||
log.success(`Release v${version} prepared locally`);
|
||||
printReleaseNextSteps(version);
|
||||
}
|
||||
|
||||
async function chooseAction(config) {
|
||||
@@ -535,6 +609,7 @@ async function chooseAction(config) {
|
||||
{ value: 'start-mobile-dev', label: 'Start mobile dev' },
|
||||
{ value: 'mobile-tools', label: 'Mobile tools' },
|
||||
{ value: 'start-electron-app', label: 'Start Electron app' },
|
||||
{ value: 'prepare-opencode-cli', label: 'Prepare bundled OpenCode CLI' },
|
||||
{ value: 'build-electron-app', label: 'Build Electron app' },
|
||||
{ value: 'start-vscode-extension', label: 'Start VS Code extension' },
|
||||
{ value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' },
|
||||
@@ -581,6 +656,9 @@ async function main() {
|
||||
case 'start-electron-app':
|
||||
startElectronApp();
|
||||
break;
|
||||
case 'prepare-opencode-cli':
|
||||
prepareOpenCodeCli();
|
||||
break;
|
||||
case 'build-electron-app':
|
||||
buildElectronApp();
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Performance Measurement Tooling
|
||||
|
||||
Owns the unattended performance capture commands and their shared Chrome
|
||||
DevTools Protocol plumbing. Read this before measuring OpenChamber performance
|
||||
or extending these scripts. The methodology rules they enforce come from
|
||||
`.agents/skills/performance-engineering/SKILL.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Answers |
|
||||
|---|---|
|
||||
| `bun run profile:idle` | What the app does while nobody interacts with it. |
|
||||
| `bun run profile:session` | What receiving and rendering a live assistant response costs. |
|
||||
| `bun run profile:animation` | What a CSS animation costs, isolated from the app. |
|
||||
| `bun run profile:browser` | A manually driven capture, for interactions that cannot be scripted. |
|
||||
|
||||
All of them measure a real browser over CDP. Pass `--help` to any of them for
|
||||
the full option list.
|
||||
|
||||
## Before Measuring Anything
|
||||
|
||||
**Measure a production build.** A development build's render and bundle
|
||||
behaviour does not represent what users run.
|
||||
|
||||
```bash
|
||||
bun run build:ui && bun run build:web
|
||||
cd <a project directory> && node <repo>/packages/web/bin/cli.js serve --port 4599 --foreground
|
||||
```
|
||||
|
||||
`profile:idle` and `profile:session` need a running server; `profile:animation`
|
||||
serves its own fixture and needs nothing.
|
||||
|
||||
## profile:idle
|
||||
|
||||
Loads the app, lets it settle, then records a window during which no input is
|
||||
delivered. Everything it reports is therefore work the app performs while the
|
||||
user is doing nothing — the class of regression users notice as fan noise,
|
||||
battery drain, and a permanently busy tab.
|
||||
|
||||
Reports per second of idle time: main-thread busy time, script, style
|
||||
recalculation and layout time and counts, DOM node / document / frame /
|
||||
listener growth, heap trajectory including a least-squares growth rate, a CPU
|
||||
sampling profile with self time per function, and attribution of timer,
|
||||
animation-frame and observer work to the call site that scheduled it.
|
||||
|
||||
```bash
|
||||
# Baseline, then compare a change against it and fail on a budget.
|
||||
bun run profile:idle -- --url http://127.0.0.1:4599 --output artifacts/before
|
||||
bun run profile:idle -- --url http://127.0.0.1:4599 --baseline artifacts/before --budget-cpu 5
|
||||
```
|
||||
|
||||
Scenario options reach a specific mounted state, because idle cost depends on
|
||||
what is mounted: `--session`, `--tab`, `--panel <mode>`, `--expand-projects`,
|
||||
`--expand-sessions`, and `--then-tab` (navigate away after settling, to measure
|
||||
what a surface keeps doing once the user has left it).
|
||||
|
||||
## profile:session
|
||||
|
||||
Creates a session, opens it in a browser, dispatches a prompt through the
|
||||
supported `openchamber session` CLI, and records until the session reports
|
||||
itself idle. No input is synthesised; the prompt is the only stimulus.
|
||||
|
||||
Streaming is judged by responsiveness, not totals, so the report leads with the
|
||||
long-task distribution, a timeline-trace breakdown naming where time went,
|
||||
running animations, the application's own stream counters, and output-normalised
|
||||
metrics.
|
||||
|
||||
```bash
|
||||
bun run profile:session -- --url http://127.0.0.1:4599 --dir <project directory>
|
||||
# What an idle session costs while a different session is active elsewhere:
|
||||
bun run profile:session -- --view-session <idle session id> --expand-projects --expand-sessions
|
||||
```
|
||||
|
||||
This command calls a real model. Use a cheap one; `--model` overrides the
|
||||
configured selection.
|
||||
|
||||
## profile:animation
|
||||
|
||||
Serves an isolated fixture and measures each animation variant directly, so a
|
||||
comparison takes seconds instead of an application rebuild plus a streamed
|
||||
response.
|
||||
|
||||
```bash
|
||||
bun run profile:animation
|
||||
bun run profile:animation -- --variant border-color --count 8
|
||||
```
|
||||
|
||||
Measured on this repository's fixture, at any element count from 1 to 32:
|
||||
|
||||
| Animated property | Style recalculations/sec | Layouts/sec |
|
||||
|---|---|---|
|
||||
| none | 0 | 0 |
|
||||
| `transform` (rotate, translate, scale) | 0 | 0 |
|
||||
| `opacity`, `filter` | 0 | 0 |
|
||||
| `rotate` (the individual property) | 60 | 0 |
|
||||
| `background-position` | 60 | 0 |
|
||||
| `border-color` | 60 | 0 |
|
||||
| `box-shadow` | 60 | 0 |
|
||||
| `width` | 60 | 60 |
|
||||
|
||||
Animate `transform` and `opacity`. Anything else recalculates style on every
|
||||
frame for as long as the animation runs, and geometry properties add layout on
|
||||
top. Note that `rotate: 360deg` is *not* equivalent to
|
||||
`transform: rotate(360deg)` in cost.
|
||||
|
||||
Add a variant to `animation-fixture.html` to measure a property or technique
|
||||
that is not listed.
|
||||
|
||||
## Reading The Results
|
||||
|
||||
Every run writes a JSON summary next to any raw capture, so results can be
|
||||
compared later without re-running:
|
||||
|
||||
- `profile:idle` → `idle-summary.json`, `cpu-profile.cpuprofile`
|
||||
- `profile:session` → `session-summary.json`, `cpu-profile.cpuprofile`
|
||||
|
||||
`--baseline <directory>` prints a per-metric delta table against a previous run
|
||||
of the same command. `--budget-*` options make the command exit non-zero, so the
|
||||
same invocation works as an investigation tool and as a regression gate.
|
||||
|
||||
Artifacts can reveal project paths and endpoint names. They are gitignored; do
|
||||
not publish them without review.
|
||||
|
||||
## Validity Guarantees
|
||||
|
||||
These commands fail loudly rather than reporting a clean result, because each
|
||||
of these failure modes once produced a confident, wrong "everything is fast":
|
||||
|
||||
- **Throttled renderer.** Chrome stops producing frames and throttles timers for
|
||||
windows it considers backgrounded or occluded. Launch flags disable that, and
|
||||
every run measures frame liveness and warns when the renderer was not
|
||||
producing frames.
|
||||
- **Missing trace data.** `RunTask` is only emitted under the
|
||||
disabled-by-default timeline category. A capture without it would report zero
|
||||
long tasks; the missing-task case is reported instead.
|
||||
- **A scenario that never ran.** A session belonging to a directory the browser
|
||||
is not viewing renders nothing and produces a perfectly quiet profile.
|
||||
`profile:session` verifies both new message elements in the DOM and
|
||||
message-list render counters before believing a quiet result.
|
||||
|
||||
Preserve this property when extending these scripts. A metric reading zero must
|
||||
be a measurement, never a disabled instrument.
|
||||
|
||||
## Methodology Rules
|
||||
|
||||
- **Never report an "after" without a "before" on the identical scenario and
|
||||
build.** Rebuild the unchanged version and re-run it, however inconvenient.
|
||||
Expect plausible fixes to change nothing.
|
||||
- **A sampling profiler cannot explain native work.** Self time in `(program)`
|
||||
only means the time was not in interpreted JavaScript. Use the trace
|
||||
breakdown, which names parsing, style, layout, layerization, paint and raster.
|
||||
- **Normalise when the workload varies.** Assistant responses differ in length
|
||||
between runs, so per-second totals are not comparable; `profile:session`
|
||||
reports output-normalised metrics for this reason.
|
||||
- **Revert what you cannot measure.** A change that does not move its target
|
||||
metric is unvalidated complexity, not a small win.
|
||||
- **Reproduction may need production scale you do not have.** A threshold effect
|
||||
is invisible below its threshold. Compare the reporter's scale against yours
|
||||
on the dimension the code keys on before concluding a bug is absent.
|
||||
|
||||
## Module Layout
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `cdp.mjs` | Chrome launch, target discovery, minimal CDP client. Owns the anti-throttling launch flags. |
|
||||
| `metrics.mjs` | Metric derivations shared by the profilers: growth rates, percentiles, long-task and trace-event summaries. |
|
||||
| `cpu-profile.mjs` | Aggregates `Profiler.stop()` output into self time per function. |
|
||||
| `idle-probe.mjs` | Page-side instrumentation installed before application code runs; attributes scheduled work to the call site that scheduled it. Must never change observable behaviour. |
|
||||
| `scenario.mjs` | Shared scenario setup, currently sidebar expansion. Setup always runs before the measured window. |
|
||||
| `animation-fixture.html` | Isolated animation variants for `profile:animation`. |
|
||||
@@ -0,0 +1,133 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OpenChamber animation cost fixture</title>
|
||||
<style>
|
||||
body { margin: 0; background: #111; color: #eee; font: 12px ui-monospace, monospace; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px; }
|
||||
.cell { width: 24px; height: 24px; }
|
||||
svg { width: 24px; height: 24px; display: block; }
|
||||
.wrap { display: block; width: 24px; height: 24px; }
|
||||
|
||||
/*
|
||||
One variant per animated property, so a run answers "what does animating
|
||||
this cost" rather than "is this particular component slow". Compositor-
|
||||
driven properties should cost a small constant; anything the compositor
|
||||
cannot handle recalculates style every frame.
|
||||
*/
|
||||
@keyframes oc-transform-rotate { to { transform: rotate(360deg); } }
|
||||
@keyframes oc-rotate-property { to { rotate: 360deg; } }
|
||||
@keyframes oc-opacity { 0%, 100% { opacity: 0.2; } 50% { opacity: 1; } }
|
||||
@keyframes oc-translate { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } }
|
||||
@keyframes oc-scale { 0%, 100% { transform: scale(0.7); } 50% { transform: scale(1); } }
|
||||
@keyframes oc-background-position { to { background-position: 200% 0; } }
|
||||
@keyframes oc-border-color { 0%, 100% { border-color: #345; } 50% { border-color: #8ab; } }
|
||||
@keyframes oc-box-shadow { 0%, 100% { box-shadow: 0 0 0 0 #8ab; } 50% { box-shadow: 0 0 8px 2px #8ab; } }
|
||||
@keyframes oc-filter { 0%, 100% { filter: brightness(0.6); } 50% { filter: brightness(1.4); } }
|
||||
@keyframes oc-width { 0%, 100% { width: 12px; } 50% { width: 24px; } }
|
||||
|
||||
.v-none svg { animation: none; }
|
||||
.v-transform-rotate svg { animation: oc-transform-rotate 1s linear infinite; }
|
||||
.v-transform-rotate-willchange svg { animation: oc-transform-rotate 1s linear infinite; will-change: transform; }
|
||||
.v-transform-rotate-steps svg { animation: oc-transform-rotate 1s steps(12) infinite; }
|
||||
.v-transform-rotate-wrapper .wrap { animation: oc-transform-rotate 1s linear infinite; }
|
||||
.v-rotate-property svg { animation: oc-rotate-property 1s linear infinite; }
|
||||
.v-opacity svg { animation: oc-opacity 1.2s ease-in-out infinite; }
|
||||
.v-translate svg { animation: oc-translate 1.2s ease-in-out infinite; }
|
||||
.v-scale svg { animation: oc-scale 1.2s ease-in-out infinite; }
|
||||
.v-background-position .cell { background: linear-gradient(90deg, #223, #8ab, #223); background-size: 200% 100%; animation: oc-background-position 1.5s linear infinite; }
|
||||
.v-border-color .cell { border: 2px solid #345; animation: oc-border-color 1.5s linear infinite; }
|
||||
.v-box-shadow .cell { animation: oc-box-shadow 1.5s linear infinite; }
|
||||
.v-filter svg { animation: oc-filter 1.5s linear infinite; }
|
||||
.v-width svg { animation: oc-width 1.5s linear infinite; }
|
||||
|
||||
/*
|
||||
Context variants. The same composited animation can stop being composited
|
||||
because of an ancestor, so these reproduce the surroundings a spinner
|
||||
actually lives in. Each keeps the identical transform animation and varies
|
||||
only the context.
|
||||
*/
|
||||
.v-ctx-button .cell svg,
|
||||
.v-ctx-filter .cell svg,
|
||||
.v-ctx-overflow .cell svg,
|
||||
.v-ctx-backdrop .cell svg,
|
||||
.v-ctx-opacity .cell svg,
|
||||
.v-ctx-transformed-parent .cell svg,
|
||||
.v-ctx-sibling-translatez .cell svg,
|
||||
.v-ctx-currentcolor .cell svg { animation: oc-transform-rotate 1s linear infinite; }
|
||||
|
||||
/* Mirrors the repository rule that gives every other button SVG a GPU hint. */
|
||||
.v-ctx-sibling-translatez button svg:not(.spin) { transform: translateZ(0); }
|
||||
|
||||
.v-ctx-filter .cell { filter: brightness(1.05); }
|
||||
.v-ctx-overflow .cell { overflow: hidden; }
|
||||
.v-ctx-backdrop .cell { backdrop-filter: blur(2px); }
|
||||
.v-ctx-opacity .cell { opacity: 0.9; }
|
||||
.v-ctx-transformed-parent .cell { transform: translateY(1px); }
|
||||
.v-ctx-currentcolor .cell { color: color-mix(in srgb, #8ab 60%, #345); }
|
||||
.v-ctx-currentcolor .cell svg { stroke: currentColor; }
|
||||
|
||||
/*
|
||||
The repository's own spinner rules, isolated. The application overrides
|
||||
Tailwind's animate-spin with these, so each piece is measured separately to
|
||||
find which one stops the rotation being composited.
|
||||
*/
|
||||
@keyframes oc-webkit-spin {
|
||||
0% { transform: translateZ(0) rotate(0deg); }
|
||||
100% { transform: translateZ(0) rotate(360deg); }
|
||||
}
|
||||
.v-app-keyframes svg { animation: oc-webkit-spin 1s linear infinite; }
|
||||
.v-app-fillbox svg {
|
||||
animation: oc-transform-rotate 1s linear infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.v-app-full svg {
|
||||
animation: oc-webkit-spin 1s linear infinite;
|
||||
will-change: transform;
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 50%;
|
||||
overflow: visible;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid" id="grid"></div>
|
||||
<script>
|
||||
const SPINNER = '<svg viewBox="0 0 24 24" fill="none" stroke="#8ab" stroke-width="2">'
|
||||
+ '<circle cx="12" cy="12" r="9" stroke-opacity=".25"/><path d="M21 12a9 9 0 0 0-9-9"/></svg>';
|
||||
const params = new URLSearchParams(location.search);
|
||||
// Style recalculation cost depends on how much document there is to walk, so
|
||||
// a variant that is free on a small page is not proven free in a real one.
|
||||
const filler = Math.max(0, Number(params.get('filler') || 0));
|
||||
const variant = params.get('variant') || 'none';
|
||||
const count = Math.max(1, Number(params.get('count') || 2));
|
||||
const grid = document.getElementById('grid');
|
||||
grid.className = 'grid v-' + variant;
|
||||
const usesWrapper = variant.endsWith('-wrapper');
|
||||
const usesButton = variant === 'ctx-button' || variant === 'ctx-sibling-translatez';
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
const spinner = SPINNER.replace('<svg', '<svg class="spin"');
|
||||
if (usesWrapper) cell.innerHTML = '<span class="wrap">' + SPINNER + '</span>';
|
||||
else if (usesButton) cell.innerHTML = '<button>' + spinner + '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="#456"/></svg></button>';
|
||||
else cell.innerHTML = SPINNER;
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
if (filler > 0) {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;';
|
||||
let markup = '';
|
||||
for (let index = 0; index < filler; index += 1) {
|
||||
markup += '<div class="filler-row"><span>row ' + index + '</span><em>x</em></div>';
|
||||
}
|
||||
container.innerHTML = markup;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Shared Chrome DevTools Protocol helpers for OpenChamber performance tooling.
|
||||
*
|
||||
* `scripts/profile-browser.mjs` and `scripts/profile-idle.mjs` both drive Chrome
|
||||
* over CDP. Launching, target discovery, and the minimal protocol client live
|
||||
* here so both entry points stay thin and behave identically.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { createServer } from "node:net"
|
||||
import { existsSync } from "node:fs"
|
||||
import { platform } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import process from "node:process"
|
||||
|
||||
export const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds))
|
||||
|
||||
const chromeCandidates = () => {
|
||||
if (platform() === "darwin") {
|
||||
return [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
]
|
||||
}
|
||||
if (platform() === "win32") {
|
||||
return [
|
||||
join(process.env.PROGRAMFILES ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
join(process.env["PROGRAMFILES(X86)"] ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
join(process.env.LOCALAPPDATA ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
]
|
||||
}
|
||||
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]
|
||||
}
|
||||
|
||||
export const resolveChrome = (explicit) => {
|
||||
if (explicit) {
|
||||
const candidate = resolve(explicit)
|
||||
if (!existsSync(candidate)) throw new Error(`Chrome executable not found: ${candidate}`)
|
||||
return candidate
|
||||
}
|
||||
const candidate = chromeCandidates().find((path) => path && existsSync(path))
|
||||
if (!candidate) throw new Error("Chrome/Chromium was not found. Pass its path with --chrome.")
|
||||
return candidate
|
||||
}
|
||||
|
||||
export const reservePort = () => new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close()
|
||||
reject(new Error("Could not reserve a Chrome debugging port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolvePort(port))
|
||||
})
|
||||
})
|
||||
|
||||
const waitForJson = async (url, timeoutMs = 15_000) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastError
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) return await response.json()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
await wait(100)
|
||||
}
|
||||
throw new Error(`Chrome debugging endpoint did not start: ${lastError?.message ?? url}`)
|
||||
}
|
||||
|
||||
export const createPageTarget = async (port) => {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await waitForJson(`${baseUrl}/json/version`)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" })
|
||||
if (response.ok) {
|
||||
const target = await response.json()
|
||||
if (target?.type === "page" && target.webSocketDebuggerUrl) return target
|
||||
}
|
||||
} catch {
|
||||
// Some Chromium variants do not expose /json/new; use their startup page.
|
||||
}
|
||||
|
||||
const targets = await waitForJson(`${baseUrl}/json`)
|
||||
const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl)
|
||||
if (!target) throw new Error("Chrome did not expose or create a page target")
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* Chrome throttles timers and stops producing frames for windows it considers
|
||||
* backgrounded or occluded. A profiling run must never silently measure a
|
||||
* throttled renderer, so occlusion and background throttling are disabled for
|
||||
* every launch: without these the idle report shows zero layouts per second
|
||||
* regardless of how much work the page actually schedules.
|
||||
*/
|
||||
const ANTI_THROTTLING_ARGS = [
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
|
||||
]
|
||||
|
||||
export const launchChrome = ({ chrome, profileDir, port, headless, extraArgs = [] }) => {
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
...ANTI_THROTTLING_ARGS,
|
||||
...extraArgs,
|
||||
"about:blank",
|
||||
]
|
||||
if (headless) args.unshift("--headless=new", "--disable-gpu")
|
||||
return spawn(chrome, args, { stdio: "ignore" })
|
||||
}
|
||||
|
||||
export class CdpClient {
|
||||
constructor(url) {
|
||||
this.socket = new WebSocket(url)
|
||||
this.nextId = 1
|
||||
this.pending = new Map()
|
||||
this.listeners = new Map()
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await new Promise((resolveConnect, reject) => {
|
||||
this.socket.addEventListener("open", resolveConnect, { once: true })
|
||||
this.socket.addEventListener("error", reject, { once: true })
|
||||
})
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data))
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(message.id)
|
||||
if (message.error) pending.reject(new Error(message.error.message))
|
||||
else pending.resolve(message.result ?? {})
|
||||
return
|
||||
}
|
||||
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = this.nextId++
|
||||
return new Promise((resolveSend, reject) => {
|
||||
this.pending.set(id, { resolve: resolveSend, reject: reject })
|
||||
this.socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? new Set()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(method, listeners)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
once(method, timeoutMs = 15_000) {
|
||||
return new Promise((resolveEvent, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe()
|
||||
reject(new Error(`Timed out waiting for ${method}`))
|
||||
}, timeoutMs)
|
||||
const unsubscribe = this.on(method, (params) => {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolveEvent(params)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
export const evaluateValue = async (client, expression) => {
|
||||
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })
|
||||
return result.result?.value ?? null
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Aggregation helpers for `Profiler.stop()` CPU profiles.
|
||||
*
|
||||
* The sampling profiler answers "which functions burned the main thread while
|
||||
* nobody touched the app", which is the question the idle report exists to
|
||||
* answer. Self time is derived from the sample stream rather than from
|
||||
* `hitCount`, because sample deltas carry the actual elapsed time.
|
||||
*/
|
||||
|
||||
const frameLabel = (callFrame) => {
|
||||
const name = callFrame.functionName || "(anonymous)"
|
||||
const url = callFrame.url || "(native)"
|
||||
const shortUrl = url.replace(/^https?:\/\/[^/]+/, "")
|
||||
return `${name} @ ${shortUrl}:${callFrame.lineNumber + 1}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{nodes: Array, samples: Array<number>, timeDeltas: Array<number>}} profile
|
||||
* @param {number} topCount
|
||||
*/
|
||||
export const summarizeCpuProfile = (profile, topCount = 25) => {
|
||||
const nodes = new Map()
|
||||
for (const node of profile?.nodes ?? []) nodes.set(node.id, node)
|
||||
|
||||
const samples = profile?.samples ?? []
|
||||
const timeDeltas = profile?.timeDeltas ?? []
|
||||
|
||||
const selfMicros = new Map()
|
||||
let totalMicros = 0
|
||||
let idleMicros = 0
|
||||
let gcMicros = 0
|
||||
let programMicros = 0
|
||||
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
// `timeDeltas[i]` is the interval preceding sample `i`.
|
||||
const delta = Math.max(0, Number(timeDeltas[index] ?? 0))
|
||||
totalMicros += delta
|
||||
const node = nodes.get(samples[index])
|
||||
if (!node) continue
|
||||
const name = node.callFrame?.functionName
|
||||
if (name === "(idle)") {
|
||||
idleMicros += delta
|
||||
continue
|
||||
}
|
||||
if (name === "(garbage collector)") gcMicros += delta
|
||||
if (name === "(program)") programMicros += delta
|
||||
const label = frameLabel(node.callFrame ?? {})
|
||||
selfMicros.set(label, (selfMicros.get(label) ?? 0) + delta)
|
||||
}
|
||||
|
||||
const busyMicros = totalMicros - idleMicros
|
||||
const toMs = (micros) => Number((micros / 1000).toFixed(2))
|
||||
|
||||
return {
|
||||
sampleCount: samples.length,
|
||||
totalMs: toMs(totalMicros),
|
||||
idleMs: toMs(idleMicros),
|
||||
busyMs: toMs(busyMicros),
|
||||
busyPercent: totalMicros > 0 ? Number(((busyMicros / totalMicros) * 100).toFixed(2)) : 0,
|
||||
garbageCollectorMs: toMs(gcMicros),
|
||||
programMs: toMs(programMicros),
|
||||
topSelfTime: [...selfMicros.entries()]
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, topCount)
|
||||
.map(([label, micros]) => ({
|
||||
function: label,
|
||||
selfMs: toMs(micros),
|
||||
percentOfBusy: busyMicros > 0 ? Number(((micros / busyMicros) * 100).toFixed(2)) : 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Page-side idle instrumentation.
|
||||
*
|
||||
* `buildIdleProbeSource()` returns a self-contained script installed with
|
||||
* `Page.addScriptToEvaluateOnNewDocument`, so it wraps the scheduling APIs
|
||||
* before any application module runs. The probe attributes wall time to the
|
||||
* call site that scheduled the work, which is what identifies a background
|
||||
* loop that keeps running while the user is idle.
|
||||
*
|
||||
* Design constraints:
|
||||
* - The probe must not change observable behaviour: every wrapper forwards
|
||||
* arguments and return values unchanged, and preserves handle identity so
|
||||
* `clearInterval`/`cancelAnimationFrame` keep working.
|
||||
* - Stack capture happens at schedule time, not at fire time, and stops after
|
||||
* a bounded number of captures so instrumentation cannot become the
|
||||
* bottleneck it is measuring.
|
||||
*/
|
||||
|
||||
export const IDLE_PROBE_GLOBAL = "__openchamberIdleProbe"
|
||||
|
||||
const probeFactory = function installOpenchamberIdleProbe(globalName, stackCaptureBudget) {
|
||||
if (globalThis[globalName]) return
|
||||
|
||||
const now = () => performance.now()
|
||||
const sites = new Map()
|
||||
let stackCaptures = 0
|
||||
let recording = false
|
||||
|
||||
const siteFromStack = (kind) => {
|
||||
if (stackCaptures >= stackCaptureBudget) return `${kind} <stack budget exhausted>`
|
||||
stackCaptures += 1
|
||||
const stack = new Error().stack ?? ""
|
||||
const lines = stack.split("\n")
|
||||
for (const line of lines) {
|
||||
// Skip the Error line and every frame belonging to the probe itself.
|
||||
if (!line.includes("http")) continue
|
||||
if (line.includes("installOpenchamberIdleProbe")) continue
|
||||
const match = line.match(/\(?((?:https?:)\/\/[^\s)]+)\)?$/)
|
||||
const location = match ? match[1] : line.trim()
|
||||
const name = line.trim().replace(/^at\s+/, "").split(" (")[0]
|
||||
return `${kind} ${name} @ ${location}`
|
||||
}
|
||||
return `${kind} <unknown>`
|
||||
}
|
||||
|
||||
const record = (site, elapsedMs) => {
|
||||
if (!recording) return
|
||||
let entry = sites.get(site)
|
||||
if (!entry) {
|
||||
entry = { site, calls: 0, totalMs: 0, maxMs: 0 }
|
||||
sites.set(site, entry)
|
||||
}
|
||||
entry.calls += 1
|
||||
entry.totalMs += elapsedMs
|
||||
if (elapsedMs > entry.maxMs) entry.maxMs = elapsedMs
|
||||
}
|
||||
|
||||
const counters = {
|
||||
setTimeoutScheduled: 0,
|
||||
setIntervalScheduled: 0,
|
||||
rafScheduled: 0,
|
||||
idleCallbackScheduled: 0,
|
||||
listenersAdded: 0,
|
||||
listenersRemoved: 0,
|
||||
mutationRecords: 0,
|
||||
resizeEntries: 0,
|
||||
intersectionEntries: 0,
|
||||
fetches: 0,
|
||||
postMessages: 0,
|
||||
}
|
||||
const listenerTypes = new Map()
|
||||
const bump = (map, key, amount) => map.set(key, (map.get(key) ?? 0) + amount)
|
||||
|
||||
const wrapCallback = (callback, site) => {
|
||||
if (typeof callback !== "function") return callback
|
||||
return function instrumentedIdleProbeCallback(...args) {
|
||||
const started = now()
|
||||
try {
|
||||
return callback.apply(this, args)
|
||||
} finally {
|
||||
record(site, now() - started)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nativeSetTimeout = globalThis.setTimeout
|
||||
const nativeSetInterval = globalThis.setInterval
|
||||
const nativeRaf = globalThis.requestAnimationFrame
|
||||
const nativeIdle = globalThis.requestIdleCallback
|
||||
|
||||
globalThis.setTimeout = function setTimeout(handler, timeout, ...rest) {
|
||||
counters.setTimeoutScheduled += 1
|
||||
if (typeof handler !== "function") return nativeSetTimeout.call(this, handler, timeout, ...rest)
|
||||
const site = siteFromStack(`setTimeout(${Number(timeout) || 0})`)
|
||||
return nativeSetTimeout.call(this, wrapCallback(handler, site), timeout, ...rest)
|
||||
}
|
||||
|
||||
globalThis.setInterval = function setInterval(handler, timeout, ...rest) {
|
||||
counters.setIntervalScheduled += 1
|
||||
if (typeof handler !== "function") return nativeSetInterval.call(this, handler, timeout, ...rest)
|
||||
const site = siteFromStack(`setInterval(${Number(timeout) || 0})`)
|
||||
return nativeSetInterval.call(this, wrapCallback(handler, site), timeout, ...rest)
|
||||
}
|
||||
|
||||
if (typeof nativeRaf === "function") {
|
||||
globalThis.requestAnimationFrame = function requestAnimationFrame(callback) {
|
||||
counters.rafScheduled += 1
|
||||
if (typeof callback !== "function") return nativeRaf.call(this, callback)
|
||||
const site = siteFromStack("requestAnimationFrame")
|
||||
return nativeRaf.call(this, wrapCallback(callback, site))
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nativeIdle === "function") {
|
||||
globalThis.requestIdleCallback = function requestIdleCallback(callback, options) {
|
||||
counters.idleCallbackScheduled += 1
|
||||
if (typeof callback !== "function") return nativeIdle.call(this, callback, options)
|
||||
const site = siteFromStack("requestIdleCallback")
|
||||
return nativeIdle.call(this, wrapCallback(callback, site), options)
|
||||
}
|
||||
}
|
||||
|
||||
// Listener accounting explains the growing "JS event listeners" curve.
|
||||
const nativeAdd = EventTarget.prototype.addEventListener
|
||||
const nativeRemove = EventTarget.prototype.removeEventListener
|
||||
EventTarget.prototype.addEventListener = function addEventListener(type, listener, options) {
|
||||
counters.listenersAdded += 1
|
||||
bump(listenerTypes, String(type), 1)
|
||||
return nativeAdd.call(this, type, listener, options)
|
||||
}
|
||||
EventTarget.prototype.removeEventListener = function removeEventListener(type, listener, options) {
|
||||
counters.listenersRemoved += 1
|
||||
bump(listenerTypes, String(type), -1)
|
||||
return nativeRemove.call(this, type, listener, options)
|
||||
}
|
||||
|
||||
const wrapObserver = (Original, kind, countEntries) => {
|
||||
if (typeof Original !== "function") return Original
|
||||
const Wrapped = function ObserverWrapper(callback, ...rest) {
|
||||
const site = siteFromStack(kind)
|
||||
const instrumented = typeof callback === "function"
|
||||
? function instrumentedObserverCallback(entries, observer) {
|
||||
countEntries(entries)
|
||||
const started = now()
|
||||
try {
|
||||
return callback.call(this, entries, observer)
|
||||
} finally {
|
||||
record(site, now() - started)
|
||||
}
|
||||
}
|
||||
: callback
|
||||
return new Original(instrumented, ...rest)
|
||||
}
|
||||
Wrapped.prototype = Original.prototype
|
||||
return Wrapped
|
||||
}
|
||||
|
||||
globalThis.MutationObserver = wrapObserver(globalThis.MutationObserver, "MutationObserver", (entries) => {
|
||||
counters.mutationRecords += entries?.length ?? 0
|
||||
})
|
||||
globalThis.ResizeObserver = wrapObserver(globalThis.ResizeObserver, "ResizeObserver", (entries) => {
|
||||
counters.resizeEntries += entries?.length ?? 0
|
||||
})
|
||||
globalThis.IntersectionObserver = wrapObserver(globalThis.IntersectionObserver, "IntersectionObserver", (entries) => {
|
||||
counters.intersectionEntries += entries?.length ?? 0
|
||||
})
|
||||
|
||||
const nativeFetch = globalThis.fetch
|
||||
if (typeof nativeFetch === "function") {
|
||||
globalThis.fetch = function fetch(...args) {
|
||||
counters.fetches += 1
|
||||
return nativeFetch.apply(this, args)
|
||||
}
|
||||
}
|
||||
|
||||
const nativePostMessage = globalThis.postMessage
|
||||
if (typeof nativePostMessage === "function") {
|
||||
globalThis.postMessage = function postMessage(...args) {
|
||||
counters.postMessages += 1
|
||||
return nativePostMessage.apply(this, args)
|
||||
}
|
||||
}
|
||||
|
||||
globalThis[globalName] = {
|
||||
start() {
|
||||
recording = true
|
||||
sites.clear()
|
||||
for (const key of Object.keys(counters)) counters[key] = 0
|
||||
},
|
||||
stop() {
|
||||
recording = false
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
stackCaptures,
|
||||
stackBudgetExhausted: stackCaptures >= stackCaptureBudget,
|
||||
counters: { ...counters },
|
||||
listenerTypes: [...listenerTypes.entries()]
|
||||
.map(([type, net]) => ({ type, net }))
|
||||
.filter((entry) => entry.net !== 0)
|
||||
.sort((left, right) => right.net - left.net)
|
||||
.slice(0, 25),
|
||||
sites: [...sites.values()]
|
||||
.sort((left, right) => right.totalMs - left.totalMs)
|
||||
.slice(0, 40)
|
||||
.map((entry) => ({
|
||||
site: entry.site,
|
||||
calls: entry.calls,
|
||||
totalMs: Number(entry.totalMs.toFixed(2)),
|
||||
maxMs: Number(entry.maxMs.toFixed(2)),
|
||||
})),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const buildIdleProbeSource = (stackCaptureBudget = 200_000) =>
|
||||
`(${probeFactory.toString()})(${JSON.stringify(IDLE_PROBE_GLOBAL)}, ${stackCaptureBudget});`
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Metric helpers shared by the idle and streaming profilers.
|
||||
*
|
||||
* Both commands read the same `Performance.getMetrics` counters and need the
|
||||
* same derivations, so the maths lives here and each entry point only decides
|
||||
* which numbers to report.
|
||||
*/
|
||||
|
||||
export const round = (value, digits = 2) => Number(Number(value ?? 0).toFixed(digits))
|
||||
|
||||
export const metricMap = (metrics = []) => Object.fromEntries(metrics.map(({ name, value }) => [name, value]))
|
||||
|
||||
/**
|
||||
* Least-squares slope of a sampled series, in units per second. A slope
|
||||
* separates a genuine upward trend from the sawtooth that garbage collection
|
||||
* produces, which start/end deltas alone cannot distinguish.
|
||||
*/
|
||||
export const growthPerSecond = (samples, key) => {
|
||||
if (samples.length < 2) return 0
|
||||
const meanTime = samples.reduce((total, sample) => total + sample.elapsedSeconds, 0) / samples.length
|
||||
const meanValue = samples.reduce((total, sample) => total + (sample[key] ?? 0), 0) / samples.length
|
||||
let covariance = 0
|
||||
let variance = 0
|
||||
for (const sample of samples) {
|
||||
const timeDelta = sample.elapsedSeconds - meanTime
|
||||
covariance += timeDelta * ((sample[key] ?? 0) - meanValue)
|
||||
variance += timeDelta * timeDelta
|
||||
}
|
||||
return variance === 0 ? 0 : Number((covariance / variance).toFixed(3))
|
||||
}
|
||||
|
||||
/** Percentile of an unsorted numeric series, using nearest-rank. */
|
||||
export const percentile = (values, fraction) => {
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1))
|
||||
return round(sorted[rank])
|
||||
}
|
||||
|
||||
// `RunTask` and `RunMicrotasks` are containers: their duration already
|
||||
// includes the work below them, so counting them would double-count.
|
||||
const CONTAINER_TRACE_EVENTS = new Set(["RunTask", "RunMicrotasks", "ProfileChunk", "Profile"])
|
||||
|
||||
/**
|
||||
* Breaks recorded time down by trace event.
|
||||
*
|
||||
* A CPU sampling profile attributes native work to `(program)`, which hides
|
||||
* whether time went to HTML parsing, style recalculation, layout, or paint.
|
||||
* The timeline trace names that work explicitly, so this is what turns "76% of
|
||||
* busy time is native" into an actionable list.
|
||||
*/
|
||||
export const summarizeTraceEvents = (traceEvents, topCount = 15) => {
|
||||
const totals = new Map()
|
||||
for (const event of traceEvents) {
|
||||
if (event.ph !== "X" || !(Number(event.dur) > 0)) continue
|
||||
if (CONTAINER_TRACE_EVENTS.has(event.name)) continue
|
||||
const entry = totals.get(event.name) ?? { name: event.name, count: 0, totalMs: 0, maxMs: 0 }
|
||||
const durationMs = Number(event.dur) / 1000
|
||||
entry.count += 1
|
||||
entry.totalMs += durationMs
|
||||
if (durationMs > entry.maxMs) entry.maxMs = durationMs
|
||||
totals.set(event.name, entry)
|
||||
}
|
||||
return [...totals.values()]
|
||||
.sort((left, right) => right.totalMs - left.totalMs)
|
||||
.slice(0, topCount)
|
||||
.map((entry) => ({ ...entry, totalMs: round(entry.totalMs), maxMs: round(entry.maxMs) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Long tasks block input and animation, so a streaming capture is judged by
|
||||
* its task-duration distribution rather than by an average frame rate.
|
||||
*/
|
||||
export const summarizeLongTasks = (traceEvents, thresholdMs = 50) => {
|
||||
const durations = traceEvents
|
||||
.filter((event) => event.name === "RunTask" && Number(event.dur) > 0)
|
||||
.map((event) => Number(event.dur) / 1000)
|
||||
const long = durations.filter((duration) => duration >= thresholdMs)
|
||||
return {
|
||||
taskCount: durations.length,
|
||||
longTaskCount: long.length,
|
||||
longTaskTotalMs: round(long.reduce((total, duration) => total + duration, 0)),
|
||||
// Spreading a large array into Math.max overflows the call stack; a trace
|
||||
// can easily carry hundreds of thousands of tasks.
|
||||
longestTaskMs: round(durations.reduce((max, duration) => Math.max(max, duration), 0)),
|
||||
taskP95Ms: percentile(durations, 0.95),
|
||||
taskP99Ms: percentile(durations, 0.99),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Scenario setup shared by the idle and streaming profilers.
|
||||
*
|
||||
* Idle and streaming cost both depend on how much of the sidebar is mounted,
|
||||
* so both commands need the same way to reach a heavily populated sidebar.
|
||||
* Setup always runs before the measured window.
|
||||
*/
|
||||
|
||||
import { evaluateValue, wait } from "./cdp.mjs"
|
||||
|
||||
/**
|
||||
* Expands every project. The sidebar persists the ids of collapsed projects,
|
||||
* so an empty list expands everything. Requires a reload to take effect.
|
||||
*/
|
||||
export const expandProjects = async (client) => {
|
||||
await evaluateValue(client, `localStorage.setItem("oc.sessions.projectCollapse", "[]")`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks every "Show more sessions" control until none remain.
|
||||
*
|
||||
* Session list pagination is component state, so unlike project collapse it
|
||||
* cannot be seeded through storage. The controls only exist once the sidebar
|
||||
* has populated, so call this after the page has settled, never straight after
|
||||
* the load event.
|
||||
*
|
||||
* Matching is a case-insensitive substring test, which assumes the English UI
|
||||
* locale; a non-English locale expands nothing and reports zero.
|
||||
*/
|
||||
export const expandSessionLists = async (client, { passes = 40, settleMs = 400 } = {}) => {
|
||||
let totalClicked = 0
|
||||
for (let pass = 0; pass < passes; pass += 1) {
|
||||
const clicked = await evaluateValue(client, `(() => {
|
||||
const controls = [...document.querySelectorAll("button")]
|
||||
.filter((button) => (button.textContent ?? "").toLowerCase().includes("show more"))
|
||||
for (const control of controls) control.click()
|
||||
return controls.length
|
||||
})()`)
|
||||
if (!clicked) break
|
||||
totalClicked += clicked
|
||||
await wait(settleMs)
|
||||
}
|
||||
return totalClicked
|
||||
}
|
||||
@@ -120,9 +120,9 @@ const DEFAULT_OUT_DIR = path.join(REPO_ROOT, 'packages', 'ui', 'src', 'lib', 'th
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
fonts: {
|
||||
sans: '"IBM Plex Mono", monospace',
|
||||
mono: '"IBM Plex Mono", monospace',
|
||||
heading: '"IBM Plex Mono", monospace',
|
||||
sans: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
mono: 'ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace',
|
||||
heading: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
},
|
||||
radius: {
|
||||
none: '0',
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measures what a CSS animation costs, in isolation.
|
||||
*
|
||||
* A continuously running animation is one of the few things an idle interface
|
||||
* keeps paying for, and the price depends entirely on whether the compositor
|
||||
* can drive the animated property. Compositor-driven properties cost a small
|
||||
* constant; everything else recalculates style on every frame, roughly an order
|
||||
* of magnitude more.
|
||||
*
|
||||
* Rather than rebuilding the application and streaming a response to answer
|
||||
* that question, this command serves a fixture page and measures each variant
|
||||
* directly, so a whole comparison takes seconds. Add a variant to
|
||||
* `perf/animation-fixture.html` to measure a property or technique that is not
|
||||
* listed yet.
|
||||
*
|
||||
* The output answers two questions the fixture is designed for:
|
||||
* - which properties are cheap to animate;
|
||||
* - whether cost scales with the number of animated elements (`--count`).
|
||||
*/
|
||||
|
||||
import { createReadStream } from "node:fs"
|
||||
import { createServer } from "node:http"
|
||||
import { homedir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import process from "node:process"
|
||||
|
||||
import { CdpClient, createPageTarget, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
||||
import { metricMap, round } from "./perf/metrics.mjs"
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const fixturePath = join(scriptDirectory, "perf", "animation-fixture.html")
|
||||
|
||||
const DEFAULT_VARIANTS = [
|
||||
"none",
|
||||
"transform-rotate",
|
||||
"transform-rotate-willchange",
|
||||
"transform-rotate-steps",
|
||||
"transform-rotate-wrapper",
|
||||
"rotate-property",
|
||||
"opacity",
|
||||
"translate",
|
||||
"scale",
|
||||
"background-position",
|
||||
"border-color",
|
||||
"box-shadow",
|
||||
"filter",
|
||||
"width",
|
||||
"ctx-button",
|
||||
"ctx-sibling-translatez",
|
||||
"ctx-filter",
|
||||
"ctx-overflow",
|
||||
"ctx-backdrop",
|
||||
"ctx-opacity",
|
||||
"ctx-transformed-parent",
|
||||
"ctx-currentcolor",
|
||||
]
|
||||
|
||||
const HELP = `Usage: bun run profile:animation -- [options]
|
||||
|
||||
Measures the idle cost of CSS animations using an isolated fixture page.
|
||||
|
||||
Options:
|
||||
--variant <name> Measure only this variant (repeatable)
|
||||
--count <n> Animated elements per variant (default: 2)
|
||||
--duration <seconds> Measurement window per variant (default: 10)
|
||||
--settle <seconds> Wait before measuring each variant (default: 3)
|
||||
--filler <n> Static elements added to the page, to measure a variant
|
||||
against a realistically sized document (default: 0)
|
||||
--chrome <path> Chrome/Chromium executable
|
||||
--profile-dir <path> Reusable isolated Chrome profile
|
||||
--headed Show the browser (default: headless)
|
||||
--json Print results as JSON
|
||||
--help Show this help
|
||||
|
||||
Variants live in scripts/perf/animation-fixture.html. Add one there to measure
|
||||
a property or technique that is not covered.`
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const options = {
|
||||
variants: [],
|
||||
count: 2,
|
||||
duration: 10,
|
||||
settle: 3,
|
||||
filler: 0,
|
||||
chrome: null,
|
||||
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
|
||||
headless: true,
|
||||
json: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === "--help") return { ...options, help: true }
|
||||
else if (value === "--headed") options.headless = false
|
||||
else if (value === "--json") options.json = true
|
||||
else if (value === "--variant") options.variants.push(argv[++index])
|
||||
else if (value === "--count") options.count = Number(argv[++index])
|
||||
else if (value === "--duration") options.duration = Number(argv[++index])
|
||||
else if (value === "--settle") options.settle = Number(argv[++index])
|
||||
else if (value === "--filler") options.filler = Number(argv[++index])
|
||||
else if (value === "--chrome") options.chrome = argv[++index]
|
||||
else if (value === "--profile-dir") options.profileDir = argv[++index]
|
||||
else throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number")
|
||||
if (!Number.isFinite(options.count) || options.count < 1) throw new Error("--count must be at least 1")
|
||||
if (options.variants.length === 0) options.variants = DEFAULT_VARIANTS
|
||||
return options
|
||||
}
|
||||
|
||||
/** Serves only the fixture, on an ephemeral loopback port. */
|
||||
const startFixtureServer = () => new Promise((resolvePromise, reject) => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
||||
createReadStream(fixturePath).pipe(response)
|
||||
})
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close()
|
||||
reject(new Error("Could not bind the fixture server"))
|
||||
return
|
||||
}
|
||||
resolvePromise({ server, port: address.port })
|
||||
})
|
||||
})
|
||||
|
||||
const measureVariant = async (client, url, options) => {
|
||||
const loaded = client.once("Page.loadEventFired", 30_000)
|
||||
await client.send("Page.navigate", { url })
|
||||
await loaded
|
||||
await wait(options.settle * 1000)
|
||||
|
||||
const before = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const startedAt = Date.now()
|
||||
await wait(options.duration * 1000)
|
||||
const elapsedSeconds = (Date.now() - startedAt) / 1000
|
||||
const after = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
|
||||
const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0)
|
||||
return {
|
||||
recalcStylePerSecond: round(delta("RecalcStyleCount") / elapsedSeconds),
|
||||
layoutsPerSecond: round(delta("LayoutCount") / elapsedSeconds),
|
||||
mainThreadBusyPercent: round((delta("TaskDuration") / elapsedSeconds) * 100),
|
||||
recalcStyleMsPerSecond: round((delta("RecalcStyleDuration") / elapsedSeconds) * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = resolveChrome(options.chrome)
|
||||
const { server, port: fixturePort } = await startFixtureServer()
|
||||
const debuggingPort = await reservePort()
|
||||
const chromeProcess = launchChrome({
|
||||
chrome,
|
||||
profileDir: resolve(options.profileDir),
|
||||
port: debuggingPort,
|
||||
headless: options.headless,
|
||||
})
|
||||
|
||||
let client
|
||||
const results = []
|
||||
try {
|
||||
const target = await createPageTarget(debuggingPort)
|
||||
client = new CdpClient(target.webSocketDebuggerUrl)
|
||||
await client.connect()
|
||||
await Promise.all([
|
||||
client.send("Page.enable"),
|
||||
client.send("Runtime.enable"),
|
||||
client.send("Performance.enable"),
|
||||
])
|
||||
await client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1200, height: 800, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
|
||||
console.log(`Measuring ${options.variants.length} variants, ${options.count} element(s) each, ${options.duration}s per variant.\n`)
|
||||
for (const variant of options.variants) {
|
||||
const url = `http://127.0.0.1:${fixturePort}/?variant=${encodeURIComponent(variant)}&count=${options.count}&filler=${options.filler}`
|
||||
const measured = await measureVariant(client, url, options)
|
||||
results.push({ variant, ...measured })
|
||||
if (!options.json) {
|
||||
console.log(
|
||||
`${variant.padEnd(30)} recalc/s ${String(measured.recalcStylePerSecond).padStart(8)}`
|
||||
+ ` layout/s ${String(measured.layoutsPerSecond).padStart(6)}`
|
||||
+ ` busy% ${String(measured.mainThreadBusyPercent).padStart(6)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ count: options.count, durationSeconds: options.duration, results }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
const baseline = results.find((entry) => entry.variant === "none")
|
||||
if (baseline) {
|
||||
console.log(
|
||||
`\nA still page costs ${baseline.recalcStylePerSecond} style recalculations per second.`
|
||||
+ " Compositor-driven properties add a small constant; anything far above that recalculates every frame.",
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
client?.close()
|
||||
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
|
||||
server.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Animation profiling failed: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
export function projectSessionLoadPerformance(events, recordingStartedAt) {
|
||||
const sourceEvents = Array.isArray(events) ? events : []
|
||||
if (!Number.isFinite(recordingStartedAt)) {
|
||||
return { bufferAtCapacity: sourceEvents.length >= 1000, events: [] }
|
||||
}
|
||||
const allowedOperations = new Set([
|
||||
"bootstrap.directory",
|
||||
"bootstrap.sessions.all",
|
||||
"bootstrap.sessions.archived",
|
||||
"bootstrap.sessions.roots",
|
||||
"global-sessions.active",
|
||||
"global-sessions.archived",
|
||||
"session-messages.initial",
|
||||
"session-messages.older",
|
||||
"session-messages.page",
|
||||
"session-messages.refresh",
|
||||
"session-messages.visible",
|
||||
"session-prefetch",
|
||||
])
|
||||
const allowedCallers = new Set([
|
||||
"action-demand",
|
||||
"current-directory",
|
||||
"initial",
|
||||
"initial-page",
|
||||
"known-project",
|
||||
"known-worktree",
|
||||
"older",
|
||||
"pagination",
|
||||
"prefetch",
|
||||
"project-expanded",
|
||||
"refresh",
|
||||
"selected-session",
|
||||
"server-connected",
|
||||
"worktree-expanded",
|
||||
])
|
||||
const allowedOutcomes = new Set(["complete", "error", "stale", "deduplicated", "canceled"])
|
||||
const optionalNonNegativeNumber = (value) => Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
const optionalNonNegativeInteger = (value) => Number.isInteger(value) && value >= 0 ? value : undefined
|
||||
return {
|
||||
bufferAtCapacity: sourceEvents.length >= 1000,
|
||||
events: sourceEvents.flatMap((event) => {
|
||||
const {
|
||||
operation,
|
||||
caller,
|
||||
queuedMs,
|
||||
requestLimit,
|
||||
cursorPresent,
|
||||
durationMs,
|
||||
outcome,
|
||||
retryCount,
|
||||
recordCount,
|
||||
at,
|
||||
} = event && typeof event === "object" ? event : {}
|
||||
if (!allowedOperations.has(operation)
|
||||
|| !allowedCallers.has(caller)
|
||||
|| !allowedOutcomes.has(outcome)
|
||||
|| !Number.isFinite(durationMs)
|
||||
|| durationMs < 0
|
||||
|| !Number.isFinite(at)) {
|
||||
return []
|
||||
}
|
||||
const projected = {
|
||||
operation,
|
||||
caller,
|
||||
durationMs,
|
||||
outcome,
|
||||
offsetMs: Math.max(0, at - recordingStartedAt),
|
||||
}
|
||||
const safeQueuedMs = optionalNonNegativeNumber(queuedMs)
|
||||
const safeRequestLimit = optionalNonNegativeInteger(requestLimit)
|
||||
const safeRetryCount = optionalNonNegativeInteger(retryCount)
|
||||
const safeRecordCount = optionalNonNegativeInteger(recordCount)
|
||||
if (safeQueuedMs !== undefined) projected.queuedMs = safeQueuedMs
|
||||
if (safeRequestLimit !== undefined) projected.requestLimit = safeRequestLimit
|
||||
if (typeof cursorPresent === "boolean") projected.cursorPresent = cursorPresent
|
||||
if (safeRetryCount !== undefined) projected.retryCount = safeRetryCount
|
||||
if (safeRecordCount !== undefined) projected.recordCount = safeRecordCount
|
||||
return [projected]
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import { projectSessionLoadPerformance } from "./profile-browser-session-load.mjs"
|
||||
|
||||
test("session-load summary exports only the approved diagnostic fields", () => {
|
||||
const projectInBrowser = Function(
|
||||
"events",
|
||||
"recordingStartedAt",
|
||||
`return (${projectSessionLoadPerformance.toString()})(events, recordingStartedAt)`,
|
||||
)
|
||||
const projected = projectInBrowser([{
|
||||
operation: "session-messages.initial",
|
||||
caller: "initial",
|
||||
queuedMs: 3,
|
||||
requestLimit: 50,
|
||||
cursorPresent: false,
|
||||
durationMs: 17,
|
||||
outcome: "complete",
|
||||
retryCount: 1,
|
||||
recordCount: 50,
|
||||
at: 1_250,
|
||||
runtimeKey: "secret-runtime",
|
||||
directory: "/secret/worktree",
|
||||
sessionID: "secret-session",
|
||||
message: "secret-message",
|
||||
content: "secret-content",
|
||||
authorization: "Bearer secret-token",
|
||||
token: "secret-token",
|
||||
password: "secret-password",
|
||||
cookie: "secret-cookie",
|
||||
credentials: { apiKey: "secret-api-key" },
|
||||
}, {
|
||||
operation: "session-messages.older",
|
||||
caller: "older",
|
||||
queuedMs: "secret-queued",
|
||||
durationMs: 5,
|
||||
outcome: "complete",
|
||||
retryCount: { value: "secret-retry" },
|
||||
recordCount: Number.POSITIVE_INFINITY,
|
||||
at: 1_300,
|
||||
}, {
|
||||
operation: "secret-operation",
|
||||
caller: "secret-caller",
|
||||
durationMs: { secret: "secret-duration" },
|
||||
outcome: "secret-outcome",
|
||||
at: 1_300,
|
||||
}], 1_000)
|
||||
|
||||
assert.deepEqual(projected, {
|
||||
bufferAtCapacity: false,
|
||||
events: [{
|
||||
operation: "session-messages.initial",
|
||||
caller: "initial",
|
||||
queuedMs: 3,
|
||||
requestLimit: 50,
|
||||
cursorPresent: false,
|
||||
durationMs: 17,
|
||||
outcome: "complete",
|
||||
retryCount: 1,
|
||||
recordCount: 50,
|
||||
offsetMs: 250,
|
||||
}, {
|
||||
operation: "session-messages.older",
|
||||
caller: "older",
|
||||
durationMs: 5,
|
||||
outcome: "complete",
|
||||
offsetMs: 300,
|
||||
}],
|
||||
})
|
||||
|
||||
const serialized = JSON.stringify(projected)
|
||||
for (const secret of [
|
||||
"secret-runtime",
|
||||
"/secret/worktree",
|
||||
"secret-session",
|
||||
"secret-message",
|
||||
"secret-content",
|
||||
"secret-token",
|
||||
"secret-password",
|
||||
"secret-cookie",
|
||||
"secret-api-key",
|
||||
"secret-operation",
|
||||
"secret-caller",
|
||||
"secret-duration",
|
||||
"secret-outcome",
|
||||
"secret-queued",
|
||||
"secret-retry",
|
||||
]) {
|
||||
assert.equal(serialized.includes(secret), false)
|
||||
}
|
||||
})
|
||||
|
||||
test("session-load summary reports when the source buffer is at capacity", () => {
|
||||
const events = Array.from({ length: 1000 }, () => ({ at: 1_000 }))
|
||||
assert.equal(projectSessionLoadPerformance(events, 1_000).bufferAtCapacity, true)
|
||||
})
|
||||
|
||||
test("session-load summary rejects an invalid recording timestamp", () => {
|
||||
assert.deepEqual(projectSessionLoadPerformance([{
|
||||
operation: "session-messages.initial",
|
||||
caller: "initial",
|
||||
durationMs: 1,
|
||||
outcome: "complete",
|
||||
at: 1_000,
|
||||
}], Number.NaN), {
|
||||
bufferAtCapacity: false,
|
||||
events: [],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
# Browser performance capture
|
||||
|
||||
Start OpenChamber locally, then run:
|
||||
|
||||
```bash
|
||||
bun run profile:browser
|
||||
```
|
||||
|
||||
The command opens an isolated Chrome profile. On the first run, complete any
|
||||
login or setup in that window, prepare the sessions and screen you want to
|
||||
measure, then return to the terminal and press Enter. Use OpenChamber normally
|
||||
for the next 60 seconds.
|
||||
|
||||
Google Chrome is selected first on macOS, with Chrome Canary and Chromium as
|
||||
fallbacks. Other Chromium-based browsers are used only when explicitly selected
|
||||
with `--chrome /path/to/executable`.
|
||||
|
||||
The generated `artifacts/browser-profile-*/` directory contains:
|
||||
|
||||
- `summary.json`: long-task, memory, network, sync-operation, and UI streaming/render metrics. `failedRequests` includes transport failures and HTTP 4xx/5xx responses, while `httpErrorResponses` isolates HTTP errors;
|
||||
- `trace.json`: import into Chrome DevTools Performance with **Load profile**;
|
||||
- `network.har`: import into Chrome DevTools Network with **Import HAR**.
|
||||
|
||||
`summary.json.longTaskAttribution` correlates long tasks with global-session
|
||||
lifecycle publications, session navigation, and targeted sidebar/message-list
|
||||
renders. One task may appear under multiple marks when phases overlap.
|
||||
|
||||
Chrome trace finalization is allowed up to two minutes for large captures. If
|
||||
Chrome still does not emit its completion event, the command preserves the
|
||||
summary, HAR, and all trace events received so far instead of discarding the
|
||||
entire recording. In that case `summary.json` sets `traceComplete` to `false`,
|
||||
and trace-derived long-task totals should be treated as lower bounds.
|
||||
|
||||
Trace events are streamed to disk instead of being serialized into one large
|
||||
JavaScript string. Summary and HAR files are written first, so they remain
|
||||
available even if the trace file cannot be completed. `traceFileComplete`
|
||||
reports whether `trace.json` finished writing.
|
||||
|
||||
The HAR omits response bodies and redacts cookies, authorization headers, and
|
||||
sensitive URL parameters. The trace applies the same key and URL-parameter
|
||||
redaction, but profiling artifacts can still reveal project paths and endpoint
|
||||
names. Do not publish them without review.
|
||||
|
||||
`summary.json.sessionLoadPerformance.events` contains the bounded session-loading
|
||||
operation timeline without runtime keys, directories, session IDs, message
|
||||
content, or credentials. It includes recording-relative timing, caller, outcome,
|
||||
retry count, and downloaded record count where available.
|
||||
|
||||
The capture bypasses the PWA service worker and reloads without the browser cache before recording, so repeated optimization runs execute the current local build instead of a previously cached bundle. By default, network recording begins after that preparation reload. Pass `--reload` to perform another cache-bypassing reload after recording starts and include startup requests in the HAR and session-load timeline.
|
||||
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
bun run profile:browser -- --duration 120
|
||||
bun run profile:browser -- --url http://localhost:4173
|
||||
bun run profile:browser -- --output /tmp/openchamber-profile
|
||||
bun run profile:browser -- --reload --no-prompt --duration 60
|
||||
```
|
||||
|
||||
Run `bun run profile:browser -- --help` for all options.
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { createServer } from "node:net"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import { createWriteStream, existsSync } from "node:fs"
|
||||
import { homedir, platform } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import { createInterface } from "node:readline/promises"
|
||||
import process from "node:process"
|
||||
|
||||
import { projectSessionLoadPerformance } from "./profile-browser-session-load.mjs"
|
||||
|
||||
const HELP = `Usage: bun run profile:browser -- [options]
|
||||
|
||||
Options:
|
||||
--url <url> OpenChamber URL (default: http://localhost:3000)
|
||||
--duration <seconds> Recording duration after Enter (default: 60)
|
||||
--output <directory> Artifact directory (default: artifacts/browser-profile-<time>)
|
||||
--chrome <path> Chrome/Chromium executable
|
||||
--profile-dir <path> Reusable isolated Chrome profile
|
||||
--headless Run without a visible browser
|
||||
--no-prompt Start after a 5 second preparation delay
|
||||
--reload Reload after recording starts to capture startup
|
||||
--help Show this help
|
||||
|
||||
The command records a Chrome performance trace, a redacted HAR, browser metrics,
|
||||
and OpenChamber's numeric sync counters. It never records response bodies.`
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const options = {
|
||||
url: "http://localhost:3000",
|
||||
duration: 60,
|
||||
output: null,
|
||||
chrome: null,
|
||||
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
|
||||
headless: false,
|
||||
prompt: true,
|
||||
reload: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === "--help") return { ...options, help: true }
|
||||
if (value === "--headless") options.headless = true
|
||||
else if (value === "--no-prompt") options.prompt = false
|
||||
else if (value === "--reload") options.reload = true
|
||||
else if (value === "--url") options.url = argv[++index]
|
||||
else if (value === "--duration") options.duration = Number(argv[++index])
|
||||
else if (value === "--output") options.output = argv[++index]
|
||||
else if (value === "--chrome") options.chrome = argv[++index]
|
||||
else if (value === "--profile-dir") options.profileDir = argv[++index]
|
||||
else throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
if (!Number.isFinite(options.duration) || options.duration <= 0) {
|
||||
throw new Error("--duration must be a positive number")
|
||||
}
|
||||
new URL(options.url)
|
||||
return options
|
||||
}
|
||||
|
||||
const chromeCandidates = () => {
|
||||
if (platform() === "darwin") {
|
||||
return [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
]
|
||||
}
|
||||
if (platform() === "win32") {
|
||||
return [
|
||||
join(process.env.PROGRAMFILES ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
join(process.env["PROGRAMFILES(X86)"] ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
join(process.env.LOCALAPPDATA ?? "", "Google/Chrome/Application/chrome.exe"),
|
||||
]
|
||||
}
|
||||
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]
|
||||
}
|
||||
|
||||
const resolveChrome = (explicit) => {
|
||||
if (explicit) {
|
||||
const candidate = resolve(explicit)
|
||||
if (!existsSync(candidate)) throw new Error(`Chrome executable not found: ${candidate}`)
|
||||
return candidate
|
||||
}
|
||||
const candidate = chromeCandidates().find((path) => path && existsSync(path))
|
||||
if (!candidate) throw new Error("Chrome/Chromium was not found. Pass its path with --chrome.")
|
||||
return candidate
|
||||
}
|
||||
|
||||
const reservePort = () => new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close()
|
||||
reject(new Error("Could not reserve a Chrome debugging port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolvePort(port))
|
||||
})
|
||||
})
|
||||
|
||||
const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds))
|
||||
|
||||
const waitForJson = async (url, timeoutMs = 15_000) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastError
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) return await response.json()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
await wait(100)
|
||||
}
|
||||
throw new Error(`Chrome debugging endpoint did not start: ${lastError?.message ?? url}`)
|
||||
}
|
||||
|
||||
const createPageTarget = async (port) => {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await waitForJson(`${baseUrl}/json/version`)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" })
|
||||
if (response.ok) {
|
||||
const target = await response.json()
|
||||
if (target?.type === "page" && target.webSocketDebuggerUrl) return target
|
||||
}
|
||||
} catch {
|
||||
// Some Chromium variants do not expose /json/new; use their startup page.
|
||||
}
|
||||
|
||||
const targets = await waitForJson(`${baseUrl}/json`)
|
||||
const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl)
|
||||
if (!target) throw new Error("Chrome did not expose or create a page target")
|
||||
return target
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
constructor(url) {
|
||||
this.socket = new WebSocket(url)
|
||||
this.nextId = 1
|
||||
this.pending = new Map()
|
||||
this.listeners = new Map()
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await new Promise((resolveConnect, reject) => {
|
||||
this.socket.addEventListener("open", resolveConnect, { once: true })
|
||||
this.socket.addEventListener("error", reject, { once: true })
|
||||
})
|
||||
this.socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data))
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(message.id)
|
||||
if (message.error) pending.reject(new Error(message.error.message))
|
||||
else pending.resolve(message.result ?? {})
|
||||
return
|
||||
}
|
||||
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = this.nextId++
|
||||
return new Promise((resolveSend, reject) => {
|
||||
this.pending.set(id, { resolve: resolveSend, reject: reject })
|
||||
this.socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? new Set()
|
||||
listeners.add(listener)
|
||||
this.listeners.set(method, listeners)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
once(method, timeoutMs = 15_000) {
|
||||
return new Promise((resolveEvent, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe()
|
||||
reject(new Error(`Timed out waiting for ${method}`))
|
||||
}, timeoutMs)
|
||||
const unsubscribe = this.on(method, (params) => {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolveEvent(params)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
const SENSITIVE_HEADER = /authorization|cookie|token|secret|password|api[-_]?key|x-openchamber/i
|
||||
const SENSITIVE_QUERY = /token|secret|password|auth|key|code|credential/i
|
||||
|
||||
const redactHeaders = (headers = {}) => Object.entries(headers).map(([name, value]) => ({
|
||||
name,
|
||||
value: SENSITIVE_HEADER.test(name) ? "[REDACTED]" : String(value),
|
||||
}))
|
||||
|
||||
const redactUrl = (value) => {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
for (const name of [...url.searchParams.keys()]) {
|
||||
if (SENSITIVE_QUERY.test(name)) url.searchParams.set(name, "[REDACTED]")
|
||||
}
|
||||
return url.toString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const redactTraceJson = (key, value) => {
|
||||
if (SENSITIVE_HEADER.test(key)) return "[REDACTED]"
|
||||
if (typeof value === "string" && /^https?:\/\//i.test(value)) return redactUrl(value)
|
||||
return value
|
||||
}
|
||||
|
||||
const writeTraceFile = (path, traceEvents) => new Promise((resolveWrite, rejectWrite) => {
|
||||
const stream = createWriteStream(path, { encoding: "utf8" })
|
||||
let index = 0
|
||||
|
||||
const writeNext = () => {
|
||||
while (index < traceEvents.length) {
|
||||
const prefix = index === 0 ? "" : ","
|
||||
const serialized = JSON.stringify(traceEvents[index], redactTraceJson)
|
||||
index += 1
|
||||
if (!stream.write(`${prefix}${serialized}`)) {
|
||||
stream.once("drain", writeNext)
|
||||
return
|
||||
}
|
||||
}
|
||||
stream.end("]}")
|
||||
}
|
||||
|
||||
stream.on("error", rejectWrite)
|
||||
stream.on("finish", resolveWrite)
|
||||
stream.write('{"traceEvents":[')
|
||||
writeNext()
|
||||
})
|
||||
|
||||
const createHar = (records, pageUrl, startedAt) => ({
|
||||
log: {
|
||||
version: "1.2",
|
||||
creator: { name: "OpenChamber browser profiler", version: "1" },
|
||||
pages: [{ startedDateTime: startedAt, id: "page_1", title: "OpenChamber profile", pageTimings: {} }],
|
||||
entries: [...records.values()].map((record) => {
|
||||
const start = record.wallTime ? new Date(record.wallTime * 1000).toISOString() : startedAt
|
||||
const duration = record.finishedAt && record.startedAt
|
||||
? Math.max(0, (record.finishedAt - record.startedAt) * 1000)
|
||||
: 0
|
||||
return {
|
||||
pageref: "page_1",
|
||||
startedDateTime: start,
|
||||
time: duration,
|
||||
request: {
|
||||
method: record.request?.method ?? "GET",
|
||||
url: redactUrl(record.request?.url ?? pageUrl),
|
||||
httpVersion: "HTTP/1.1",
|
||||
headers: redactHeaders(record.request?.headers),
|
||||
queryString: [],
|
||||
cookies: [],
|
||||
headersSize: -1,
|
||||
bodySize: record.request?.postData ? Buffer.byteLength(record.request.postData) : 0,
|
||||
},
|
||||
response: {
|
||||
status: record.response?.status ?? 0,
|
||||
statusText: record.response?.statusText ?? (record.failed ? "Failed" : ""),
|
||||
httpVersion: record.response?.protocol ?? "",
|
||||
headers: redactHeaders(record.response?.headers),
|
||||
cookies: [],
|
||||
content: {
|
||||
size: record.encodedDataLength ?? 0,
|
||||
mimeType: record.response?.mimeType ?? "",
|
||||
},
|
||||
redirectURL: "",
|
||||
headersSize: -1,
|
||||
bodySize: record.encodedDataLength ?? -1,
|
||||
},
|
||||
cache: {},
|
||||
timings: { blocked: -1, dns: -1, connect: -1, send: 0, wait: duration, receive: 0, ssl: -1 },
|
||||
_resourceType: record.type ?? null,
|
||||
_failed: record.failed ?? null,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const metricMap = (metrics = []) => Object.fromEntries(metrics.map(({ name, value }) => [name, value]))
|
||||
|
||||
const LONG_TASK_ATTRIBUTION_MARKS = [
|
||||
"openchamber.global_sessions.event_update_flush",
|
||||
"openchamber.navigation.session_select",
|
||||
"openchamber.navigation.session_state_set",
|
||||
"openchamber.react.session_sidebar_render",
|
||||
"openchamber.react.message_list_render",
|
||||
]
|
||||
|
||||
const buildLongTaskAttribution = (traceEvents, longTasks) => {
|
||||
const marks = traceEvents.filter((event) => LONG_TASK_ATTRIBUTION_MARKS.includes(event.name))
|
||||
return Object.fromEntries(LONG_TASK_ATTRIBUTION_MARKS.map((markName) => {
|
||||
const matchingMarks = marks.filter((event) => event.name === markName)
|
||||
const matchingTasks = longTasks.filter((task) => matchingMarks.some((mark) => (
|
||||
mark.pid === task.pid
|
||||
&& mark.tid === task.tid
|
||||
&& Number(mark.ts) >= Number(task.ts)
|
||||
&& Number(mark.ts) <= Number(task.ts) + Number(task.dur)
|
||||
)))
|
||||
const durations = matchingTasks.map((task) => Number(task.dur) / 1000)
|
||||
return [markName, {
|
||||
marks: matchingMarks.length,
|
||||
longTasks: matchingTasks.length,
|
||||
totalLongTaskMs: Number(durations.reduce((total, duration) => total + duration, 0).toFixed(3)),
|
||||
longestTaskMs: Number(durations.reduce((max, duration) => Math.max(max, duration), 0).toFixed(3)),
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
const evaluateValue = async (client, expression) => {
|
||||
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })
|
||||
return result.result?.value ?? null
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = resolveChrome(options.chrome)
|
||||
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")
|
||||
const output = resolve(options.output ?? join("artifacts", `browser-profile-${timestamp}`))
|
||||
const profileDir = resolve(options.profileDir)
|
||||
await mkdir(output, { recursive: true })
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const port = await reservePort()
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"about:blank",
|
||||
]
|
||||
if (options.headless) chromeArgs.unshift("--headless=new", "--disable-gpu")
|
||||
|
||||
const chromeProcess = spawn(chrome, chromeArgs, { stdio: "ignore" })
|
||||
let client
|
||||
try {
|
||||
console.log(`Using browser: ${chrome}`)
|
||||
const target = await createPageTarget(port)
|
||||
client = new CdpClient(target.webSocketDebuggerUrl)
|
||||
await client.connect()
|
||||
await Promise.all([
|
||||
client.send("Page.enable"),
|
||||
client.send("Runtime.enable"),
|
||||
client.send("Network.enable", { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }),
|
||||
client.send("Performance.enable"),
|
||||
])
|
||||
// Profile the current local build rather than a service-worker-cached
|
||||
// bundle from an earlier optimization run.
|
||||
await client.send("Network.setBypassServiceWorker", { bypass: true })
|
||||
|
||||
const loaded = client.once("Page.loadEventFired", 30_000)
|
||||
await client.send("Page.navigate", { url: options.url })
|
||||
await loaded
|
||||
await evaluateValue(client, `
|
||||
localStorage.setItem("openchamber_sync_perf", "1")
|
||||
localStorage.setItem("openchamber_stream_perf", "1")
|
||||
localStorage.setItem("openchamber_session_load_perf", "1")
|
||||
`)
|
||||
const reloaded = client.once("Page.loadEventFired", 30_000)
|
||||
await client.send("Page.reload", { ignoreCache: true })
|
||||
await reloaded
|
||||
|
||||
if (options.prompt && !options.headless && process.stdin.isTTY) {
|
||||
const readline = createInterface({ input: process.stdin, output: process.stdout })
|
||||
console.log(`\nChrome opened ${options.url}. Prepare the sessions and screen you want to measure.`)
|
||||
await readline.question("Press Enter to start recording... ")
|
||||
readline.close()
|
||||
} else {
|
||||
console.log("Waiting 5 seconds before recording...")
|
||||
await wait(5_000)
|
||||
}
|
||||
|
||||
await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`)
|
||||
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
|
||||
await evaluateValue(client, `window.__openchamberStreamPerformance?.reset()`)
|
||||
await evaluateValue(client, `if (window.__openchamberSessionLoadPerformance) window.__openchamberSessionLoadPerformance.events.length = 0`)
|
||||
const records = new Map()
|
||||
const traceEvents = []
|
||||
const startedAt = new Date().toISOString()
|
||||
const unsubscribers = [
|
||||
client.on("Network.requestWillBeSent", (event) => {
|
||||
records.set(event.requestId, {
|
||||
request: event.request,
|
||||
type: event.type,
|
||||
startedAt: event.timestamp,
|
||||
wallTime: event.wallTime,
|
||||
})
|
||||
}),
|
||||
client.on("Network.responseReceived", (event) => {
|
||||
const record = records.get(event.requestId)
|
||||
if (record) record.response = event.response
|
||||
}),
|
||||
client.on("Network.loadingFinished", (event) => {
|
||||
const record = records.get(event.requestId)
|
||||
if (record) {
|
||||
record.finishedAt = event.timestamp
|
||||
record.encodedDataLength = event.encodedDataLength
|
||||
}
|
||||
}),
|
||||
client.on("Network.loadingFailed", (event) => {
|
||||
const record = records.get(event.requestId)
|
||||
if (record) {
|
||||
record.finishedAt = event.timestamp
|
||||
record.failed = event.errorText
|
||||
}
|
||||
}),
|
||||
client.on("Tracing.dataCollected", ({ value }) => traceEvents.push(...(value ?? []))),
|
||||
]
|
||||
|
||||
const beforeMetrics = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const beforeHeap = await client.send("Runtime.getHeapUsage")
|
||||
await client.send("Tracing.start", {
|
||||
transferMode: "ReportEvents",
|
||||
categories: [
|
||||
"devtools.timeline",
|
||||
"v8.execute",
|
||||
"blink.user_timing",
|
||||
"loading",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
].join(","),
|
||||
})
|
||||
|
||||
console.log(`Recording for ${options.duration} seconds. Use OpenChamber normally during this window.`)
|
||||
const recordingStartedAt = Date.now()
|
||||
if (options.reload) {
|
||||
const recordedReload = client.once("Page.loadEventFired", 30_000)
|
||||
await client.send("Page.reload", { ignoreCache: true })
|
||||
await recordedReload
|
||||
}
|
||||
await wait(Math.max(0, options.duration * 1000 - (Date.now() - recordingStartedAt)))
|
||||
const afterMetrics = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const afterHeap = await client.send("Runtime.getHeapUsage")
|
||||
const syncCounters = await evaluateValue(client, `window.__openchamberSyncPerformance?.getSnapshot() ?? null`)
|
||||
const streamPerformance = await evaluateValue(client, `window.__openchamberStreamPerformance?.getSnapshot() ?? null`)
|
||||
const sessionLoadPerformance = await evaluateValue(
|
||||
client,
|
||||
`(${projectSessionLoadPerformance.toString()})(window.__openchamberSessionLoadPerformance?.events ?? [], ${JSON.stringify(recordingStartedAt)})`,
|
||||
)
|
||||
const traceCompleteEvent = client.once("Tracing.tracingComplete", 120_000)
|
||||
let traceComplete = true
|
||||
try {
|
||||
await client.send("Tracing.end")
|
||||
await traceCompleteEvent
|
||||
} catch (error) {
|
||||
traceComplete = false
|
||||
void traceCompleteEvent.catch(() => undefined)
|
||||
console.warn(`Chrome did not confirm trace completion; saving the collected partial trace: ${error.message}`)
|
||||
// Allow already-buffered Tracing.dataCollected events a final turn before writing.
|
||||
await wait(2_000)
|
||||
}
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
|
||||
const longTasks = traceEvents.filter((event) => event.name === "RunTask" && Number(event.dur) >= 50_000)
|
||||
const longTaskAttribution = buildLongTaskAttribution(traceEvents, longTasks)
|
||||
const failedRequests = [...records.values()].filter((record) => (
|
||||
record.failed || Number(record.response?.status) >= 400
|
||||
)).length
|
||||
const httpErrorResponses = [...records.values()].filter((record) => Number(record.response?.status) >= 400).length
|
||||
const summary = {
|
||||
recordedAt: startedAt,
|
||||
url: redactUrl(options.url),
|
||||
durationSeconds: options.duration,
|
||||
requests: records.size,
|
||||
failedRequests,
|
||||
httpErrorResponses,
|
||||
transferredBytes: [...records.values()].reduce((total, record) => total + (record.encodedDataLength ?? 0), 0),
|
||||
longTasksOver50ms: longTasks.length,
|
||||
longestTaskMs: longTasks.reduce((max, event) => Math.max(max, Number(event.dur) / 1000), 0),
|
||||
longTaskAttribution,
|
||||
performanceMetricsBefore: beforeMetrics,
|
||||
performanceMetricsAfter: afterMetrics,
|
||||
heapBefore: beforeHeap,
|
||||
heapAfter: afterHeap,
|
||||
syncCounters,
|
||||
streamPerformance,
|
||||
sessionLoadPerformance,
|
||||
includesRecordedReload: options.reload,
|
||||
traceComplete,
|
||||
traceFileComplete: false,
|
||||
privacy: "Headers and sensitive URL parameters are redacted. Response bodies are not captured.",
|
||||
}
|
||||
|
||||
const summaryPath = join(output, "summary.json")
|
||||
await Promise.all([
|
||||
writeFile(join(output, "network.har"), JSON.stringify(createHar(records, options.url, startedAt), null, 2)),
|
||||
writeFile(summaryPath, JSON.stringify(summary, null, 2)),
|
||||
])
|
||||
try {
|
||||
await writeTraceFile(join(output, "trace.json"), traceEvents)
|
||||
summary.traceFileComplete = true
|
||||
await writeFile(summaryPath, JSON.stringify(summary, null, 2))
|
||||
} catch (error) {
|
||||
console.warn(`Trace file write failed; summary and HAR were preserved: ${error.message}`)
|
||||
}
|
||||
console.log(`\nProfile saved to ${output}`)
|
||||
if (!traceComplete) console.log("Trace completion was not confirmed; summary, HAR, and the collected partial trace were preserved.")
|
||||
if (!summary.traceFileComplete) console.log("Trace file is incomplete; summary and HAR are available.")
|
||||
console.log(`Long tasks over 50 ms: ${summary.longTasksOver50ms}; requests: ${summary.requests}`)
|
||||
} finally {
|
||||
client?.close()
|
||||
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Browser profiling failed: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fully automated idle CPU/memory capture for OpenChamber.
|
||||
*
|
||||
* Unlike `profile:browser`, this command needs no human in the loop: it loads
|
||||
* the app, lets it settle, then records a window during which no input is
|
||||
* delivered. Everything it reports is therefore work the app performs while the
|
||||
* user is doing nothing, which is the regression class users notice as fan
|
||||
* noise, battery drain, and a permanently busy tab.
|
||||
*
|
||||
* Reported dimensions (per second of the idle window):
|
||||
* - main-thread busy time, script time, style recalculation, layout;
|
||||
* - style recalculation and layout counts;
|
||||
* - DOM node, document, frame, and JS event listener growth;
|
||||
* - JS heap trajectory (start/end/max plus linear growth rate);
|
||||
* - CPU sampling profile with self time per function;
|
||||
* - scheduled-work attribution per timer/animation-frame/observer call site.
|
||||
*
|
||||
* Runs are directly comparable: `--baseline <run-directory>` prints a per-metric
|
||||
* delta table and exits non-zero when a budget in `--budget-*` is exceeded, so
|
||||
* the same command works as an investigation tool and as a regression gate.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import process from "node:process"
|
||||
|
||||
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
||||
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
||||
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
||||
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
|
||||
import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs"
|
||||
|
||||
const HELP = `Usage: bun run profile:idle -- [options]
|
||||
|
||||
Records what OpenChamber does while nobody is interacting with it.
|
||||
|
||||
Options:
|
||||
--url <url> OpenChamber URL (default: http://localhost:3000)
|
||||
--session <id> Open this session before recording (deep link)
|
||||
--tab <name> Open this main tab before recording
|
||||
--then-tab <name> After settling, navigate to this tab without a
|
||||
reload, then record. Use it to measure what a
|
||||
surface keeps doing after the user leaves it.
|
||||
--expand-sessions Click every "Show more sessions" control until the
|
||||
sidebar has no collapsed session lists left, so all
|
||||
session rows are mounted. Clicks happen before the
|
||||
recording window, which stays input-free.
|
||||
--expand-projects Expand every project in the sidebar before
|
||||
recording, which mounts a row per worktree and
|
||||
session directory
|
||||
--panel <mode[=target]> Open the context panel on this surface before
|
||||
recording (chat, preview, terminal, git, pr, notes,
|
||||
file, diff, plan, context, browser, walkthrough).
|
||||
Repeatable; the first entry becomes the active tab.
|
||||
--duration <seconds> Idle recording window (default: 30)
|
||||
--settle <seconds> Wait after load before recording (default: 15)
|
||||
--output <directory> Artifact directory (default: artifacts/idle-profile-<time>)
|
||||
--label <text> Human label stored in the summary
|
||||
--chrome <path> Chrome/Chromium executable
|
||||
--profile-dir <path> Reusable isolated Chrome profile
|
||||
--headed Show the browser (default: headless)
|
||||
--sampling-interval <us> CPU sampler interval in microseconds (default: 200)
|
||||
--baseline <directory> Compare against a previous run directory
|
||||
--budget-cpu <percent> Fail when idle main-thread busy time exceeds this
|
||||
--budget-listeners <n> Fail when net listener growth exceeds this
|
||||
--budget-heap <mb> Fail when heap growth exceeds this
|
||||
--json Print the summary as JSON instead of a table
|
||||
--help Show this help
|
||||
|
||||
Exit code is non-zero when any provided budget is exceeded.`
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const options = {
|
||||
url: "http://localhost:3000",
|
||||
session: null,
|
||||
tab: null,
|
||||
thenTab: null,
|
||||
panels: [],
|
||||
expandProjects: false,
|
||||
expandSessions: false,
|
||||
duration: 30,
|
||||
settle: 15,
|
||||
output: null,
|
||||
label: null,
|
||||
chrome: null,
|
||||
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
|
||||
headless: true,
|
||||
samplingInterval: 200,
|
||||
baseline: null,
|
||||
budgetCpu: null,
|
||||
budgetListeners: null,
|
||||
budgetHeap: null,
|
||||
json: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === "--help") return { ...options, help: true }
|
||||
else if (value === "--headed") options.headless = false
|
||||
else if (value === "--json") options.json = true
|
||||
else if (value === "--url") options.url = argv[++index]
|
||||
else if (value === "--session") options.session = argv[++index]
|
||||
else if (value === "--tab") options.tab = argv[++index]
|
||||
else if (value === "--then-tab") options.thenTab = argv[++index]
|
||||
else if (value === "--panel") options.panels.push(argv[++index])
|
||||
else if (value === "--expand-projects") options.expandProjects = true
|
||||
else if (value === "--expand-sessions") options.expandSessions = true
|
||||
else if (value === "--label") options.label = argv[++index]
|
||||
else if (value === "--duration") options.duration = Number(argv[++index])
|
||||
else if (value === "--settle") options.settle = Number(argv[++index])
|
||||
else if (value === "--output") options.output = argv[++index]
|
||||
else if (value === "--chrome") options.chrome = argv[++index]
|
||||
else if (value === "--profile-dir") options.profileDir = argv[++index]
|
||||
else if (value === "--sampling-interval") options.samplingInterval = Number(argv[++index])
|
||||
else if (value === "--baseline") options.baseline = argv[++index]
|
||||
else if (value === "--budget-cpu") options.budgetCpu = Number(argv[++index])
|
||||
else if (value === "--budget-listeners") options.budgetListeners = Number(argv[++index])
|
||||
else if (value === "--budget-heap") options.budgetHeap = Number(argv[++index])
|
||||
else throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number")
|
||||
if (!Number.isFinite(options.settle) || options.settle < 0) throw new Error("--settle must be zero or greater")
|
||||
// Deep-link parameters are folded into the URL so the recorded window starts
|
||||
// from the requested screen without synthesising input events.
|
||||
const target = new URL(options.url)
|
||||
if (options.session) target.searchParams.set("session", options.session)
|
||||
if (options.tab) target.searchParams.set("tab", options.tab)
|
||||
options.url = target.toString()
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `useUIStore`'s context-panel tab identity rules so a seeded tab is
|
||||
* indistinguishable from one the user opened. Only `file` and `preview` key
|
||||
* their identity by target path; every other surface allows one tab per mode.
|
||||
*/
|
||||
const buildPanelTab = (descriptor, touchedAt) => {
|
||||
const { mode, targetPath } = descriptor
|
||||
const dedupeKey = (mode === "file" || mode === "preview") ? (targetPath || mode) : mode
|
||||
return {
|
||||
id: dedupeKey === mode ? mode : `${mode}:${dedupeKey}`,
|
||||
mode,
|
||||
targetPath: targetPath || null,
|
||||
dedupeKey,
|
||||
label: null,
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: "working",
|
||||
touchedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const parsePanelDescriptor = (value) => {
|
||||
const separator = value.indexOf("=")
|
||||
if (separator === -1) return { mode: value.trim(), targetPath: null }
|
||||
return { mode: value.slice(0, separator).trim(), targetPath: value.slice(separator + 1).trim() || null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the context panel by seeding the persisted store the app reads on
|
||||
* boot, then reloading. Driving persisted state rather than synthesising
|
||||
* clicks keeps the scenario deterministic and keeps the recorded window free
|
||||
* of input-driven work that a real idle session would not perform.
|
||||
*/
|
||||
const seedContextPanel = async (client, panels, sessionId) => {
|
||||
const descriptors = panels.map(parsePanelDescriptor)
|
||||
const tabs = descriptors.map((descriptor, index) => buildPanelTab(
|
||||
descriptor.mode === "chat" && !descriptor.targetPath ? { ...descriptor, targetPath: sessionId } : descriptor,
|
||||
Date.now() + index,
|
||||
))
|
||||
|
||||
const stored = await evaluateValue(client, `JSON.stringify({
|
||||
lastDirectory: localStorage.getItem("lastDirectory"),
|
||||
uiStore: localStorage.getItem("ui-store"),
|
||||
})`)
|
||||
const { lastDirectory, uiStore } = JSON.parse(stored ?? "{}")
|
||||
if (!lastDirectory) throw new Error("Could not open the context panel: no lastDirectory in browser storage")
|
||||
if (!uiStore) throw new Error("Could not open the context panel: no ui-store in browser storage")
|
||||
|
||||
// `lastDirectory` is persisted as a JSON string by some writers and as a raw
|
||||
// path by others; accept both rather than guessing.
|
||||
let directory = lastDirectory
|
||||
try {
|
||||
const decoded = JSON.parse(lastDirectory)
|
||||
if (typeof decoded === "string") directory = decoded
|
||||
} catch {
|
||||
// Already a raw path.
|
||||
}
|
||||
const normalized = directory.replace(/\\/g, "/").replace(/\/+$/g, "") || "/"
|
||||
|
||||
const parsed = JSON.parse(uiStore)
|
||||
parsed.state = parsed.state ?? {}
|
||||
parsed.state.contextPanelByDirectory = parsed.state.contextPanelByDirectory ?? {}
|
||||
parsed.state.contextPanelByDirectory[normalized] = {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
tabs,
|
||||
activeTabId: tabs[0]?.id ?? null,
|
||||
widthByMode: {},
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
|
||||
await evaluateValue(client, `localStorage.setItem("ui-store", ${JSON.stringify(JSON.stringify(parsed))})`)
|
||||
console.log(`Context panel seeded for ${normalized}: ${tabs.map((tab) => tab.id).join(", ")}`)
|
||||
}
|
||||
|
||||
const REPORTED_METRICS = [
|
||||
{ key: "mainThreadBusyPercent", label: "Main-thread busy", unit: "%", lowerIsBetter: true },
|
||||
{ key: "scriptPercent", label: "Script", unit: "%", lowerIsBetter: true },
|
||||
{ key: "recalcStylePercent", label: "Style recalc", unit: "%", lowerIsBetter: true },
|
||||
{ key: "layoutPercent", label: "Layout", unit: "%", lowerIsBetter: true },
|
||||
{ key: "recalcStylePerSecond", label: "Style recalcs/sec", unit: "", lowerIsBetter: true },
|
||||
{ key: "layoutsPerSecond", label: "Layouts/sec", unit: "", lowerIsBetter: true },
|
||||
{ key: "tasksPerSecond", label: "Tasks/sec", unit: "", lowerIsBetter: true },
|
||||
{ key: "listenerGrowth", label: "Listener growth", unit: "", lowerIsBetter: true },
|
||||
{ key: "nodeGrowth", label: "DOM node growth", unit: "", lowerIsBetter: true },
|
||||
{ key: "heapGrowthMbPerSecond", label: "Heap growth", unit: "MB/s", lowerIsBetter: true },
|
||||
{ key: "heapMaxMb", label: "Heap max", unit: "MB", lowerIsBetter: true },
|
||||
]
|
||||
|
||||
const buildSummary = ({ options, before, after, samples, cpu, probe, elapsedSeconds }) => {
|
||||
const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0)
|
||||
const percent = (name) => round((delta(name) / elapsedSeconds) * 100)
|
||||
const heapSamples = samples.map((sample) => sample.jsHeapUsedMb)
|
||||
|
||||
return {
|
||||
recordedAt: new Date().toISOString(),
|
||||
label: options.label,
|
||||
url: options.url,
|
||||
durationSeconds: round(elapsedSeconds),
|
||||
settleSeconds: options.settle,
|
||||
metrics: {
|
||||
mainThreadBusyPercent: percent("TaskDuration"),
|
||||
scriptPercent: percent("ScriptDuration"),
|
||||
recalcStylePercent: percent("RecalcStyleDuration"),
|
||||
layoutPercent: percent("LayoutDuration"),
|
||||
tasksPerSecond: round(delta("TaskCount") / elapsedSeconds),
|
||||
recalcStylePerSecond: round(delta("RecalcStyleCount") / elapsedSeconds),
|
||||
layoutsPerSecond: round(delta("LayoutCount") / elapsedSeconds),
|
||||
listenerStart: Number(before.JSEventListeners ?? 0),
|
||||
listenerEnd: Number(after.JSEventListeners ?? 0),
|
||||
listenerGrowth: delta("JSEventListeners"),
|
||||
listenerGrowthPerSecond: growthPerSecond(samples, "jsEventListeners"),
|
||||
nodeStart: Number(before.Nodes ?? 0),
|
||||
nodeEnd: Number(after.Nodes ?? 0),
|
||||
nodeGrowth: delta("Nodes"),
|
||||
documents: Number(after.Documents ?? 0),
|
||||
frames: Number(after.Frames ?? 0),
|
||||
heapStartMb: round(heapSamples.at(0) ?? 0),
|
||||
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
||||
heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
|
||||
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
||||
},
|
||||
cpuProfile: cpu,
|
||||
scheduledWork: probe,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
const formatRow = (label, value, unit) => `${label.padEnd(22)} ${String(value).padStart(12)} ${unit}`
|
||||
|
||||
const printReport = (summary, baseline) => {
|
||||
const { metrics } = summary
|
||||
console.log(`\nIdle profile — ${summary.durationSeconds}s window at ${summary.url}`)
|
||||
if (summary.label) console.log(`Label: ${summary.label}`)
|
||||
console.log("")
|
||||
for (const metric of REPORTED_METRICS) {
|
||||
const current = metrics[metric.key]
|
||||
if (!baseline) {
|
||||
console.log(formatRow(metric.label, current, metric.unit))
|
||||
continue
|
||||
}
|
||||
const previous = baseline.metrics?.[metric.key]
|
||||
const change = Number.isFinite(previous) ? round(current - previous) : null
|
||||
const marker = change === null || change === 0
|
||||
? ""
|
||||
: (change < 0) === metric.lowerIsBetter ? " improved" : " WORSE"
|
||||
const changeText = change === null ? "n/a" : `${change > 0 ? "+" : ""}${change}`
|
||||
console.log(`${formatRow(metric.label, current, metric.unit).padEnd(42)} was ${String(previous ?? "n/a").padStart(10)} ${changeText.padStart(9)}${marker}`)
|
||||
}
|
||||
|
||||
console.log("\nTop self time while idle:")
|
||||
for (const entry of summary.cpuProfile?.topSelfTime?.slice(0, 12) ?? []) {
|
||||
console.log(` ${String(entry.selfMs).padStart(9)} ms ${String(entry.percentOfBusy).padStart(5)}% ${entry.function}`)
|
||||
}
|
||||
|
||||
console.log("\nTop scheduled-work call sites while idle:")
|
||||
for (const entry of summary.scheduledWork?.sites?.slice(0, 12) ?? []) {
|
||||
console.log(` ${String(entry.totalMs).padStart(9)} ms ${String(entry.calls).padStart(6)}x ${entry.site}`)
|
||||
}
|
||||
|
||||
const counters = summary.scheduledWork?.counters
|
||||
if (counters) {
|
||||
console.log(
|
||||
`\nScheduled during window: timeouts ${counters.setTimeoutScheduled}, intervals ${counters.setIntervalScheduled},`
|
||||
+ ` frames ${counters.rafScheduled}, listeners +${counters.listenersAdded}/-${counters.listenersRemoved},`
|
||||
+ ` mutations ${counters.mutationRecords}, resizes ${counters.resizeEntries}, fetches ${counters.fetches}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateBudgets = (summary, options) => {
|
||||
const failures = []
|
||||
const check = (budget, key, label, unit) => {
|
||||
if (!Number.isFinite(budget)) return
|
||||
const value = summary.metrics[key]
|
||||
if (value > budget) failures.push(`${label} ${value}${unit} exceeds budget ${budget}${unit}`)
|
||||
}
|
||||
check(options.budgetCpu, "mainThreadBusyPercent", "Idle main-thread busy", "%")
|
||||
check(options.budgetListeners, "listenerGrowth", "Listener growth", "")
|
||||
check(options.budgetHeap, "heapGrowthMbPerSecond", "Heap growth", "MB/s")
|
||||
return failures
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = resolveChrome(options.chrome)
|
||||
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")
|
||||
const output = resolve(options.output ?? join("artifacts", `idle-profile-${timestamp}`))
|
||||
const profileDir = resolve(options.profileDir)
|
||||
await mkdir(output, { recursive: true })
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
|
||||
const baseline = options.baseline
|
||||
? JSON.parse(await readFile(join(resolve(options.baseline), "idle-summary.json"), "utf8"))
|
||||
: null
|
||||
|
||||
const port = await reservePort()
|
||||
const chromeProcess = launchChrome({ chrome, profileDir, port, headless: options.headless })
|
||||
let client
|
||||
try {
|
||||
const target = await createPageTarget(port)
|
||||
client = new CdpClient(target.webSocketDebuggerUrl)
|
||||
await client.connect()
|
||||
await Promise.all([
|
||||
client.send("Page.enable"),
|
||||
client.send("Runtime.enable"),
|
||||
client.send("Performance.enable"),
|
||||
client.send("Profiler.enable"),
|
||||
client.send("Network.enable", { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }),
|
||||
])
|
||||
// Measure the current local build, never a service-worker-cached bundle
|
||||
// from an earlier optimization run.
|
||||
await client.send("Network.setBypassServiceWorker", { bypass: true })
|
||||
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: buildIdleProbeSource() })
|
||||
// A fixed viewport keeps runs comparable and guarantees a compositor in
|
||||
// headless mode, so frame-driven work is measured rather than skipped.
|
||||
await client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1600,
|
||||
height: 1000,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
const loaded = client.once("Page.loadEventFired", 60_000)
|
||||
await client.send("Page.navigate", { url: options.url })
|
||||
await loaded
|
||||
|
||||
if (options.expandProjects) {
|
||||
await expandProjects(client)
|
||||
console.log("Expanded every project in the sidebar.")
|
||||
}
|
||||
|
||||
if (options.panels.length > 0 || options.expandProjects) {
|
||||
if (options.panels.length > 0) await seedContextPanel(client, options.panels, options.session)
|
||||
const reloaded = client.once("Page.loadEventFired", 60_000)
|
||||
await client.send("Page.reload", { ignoreCache: false })
|
||||
await reloaded
|
||||
}
|
||||
|
||||
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
|
||||
await wait(options.settle * 1000)
|
||||
|
||||
if (options.expandSessions) {
|
||||
const expanded = await expandSessionLists(client)
|
||||
console.log(`Expanded ${expanded} collapsed session lists; settling ${options.settle}s again.`)
|
||||
await wait(options.settle * 1000)
|
||||
}
|
||||
|
||||
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.start()`)
|
||||
await client.send("Profiler.setSamplingInterval", { interval: options.samplingInterval })
|
||||
await client.send("Profiler.start")
|
||||
const before = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const startedAt = Date.now()
|
||||
|
||||
console.log(`Recording ${options.duration}s of idle time. No input is delivered to the page.`)
|
||||
const samples = []
|
||||
while (Date.now() - startedAt < options.duration * 1000) {
|
||||
await wait(1_000)
|
||||
const current = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
samples.push({
|
||||
elapsedSeconds: round((Date.now() - startedAt) / 1000),
|
||||
jsHeapUsedMb: round(Number(current.JSHeapUsedSize ?? 0) / (1024 * 1024)),
|
||||
jsEventListeners: Number(current.JSEventListeners ?? 0),
|
||||
nodes: Number(current.Nodes ?? 0),
|
||||
taskDuration: round(Number(current.TaskDuration ?? 0), 3),
|
||||
})
|
||||
}
|
||||
|
||||
// A renderer that is throttled or occluded reports near-zero rendering work
|
||||
// no matter what the page does. Measuring frame liveness turns that failure
|
||||
// mode into an explicit warning instead of a falsely clean report.
|
||||
const frameLiveness = await evaluateValue(client, `new Promise((resolve) => {
|
||||
let frames = 0
|
||||
const startedAt = performance.now()
|
||||
const tick = () => {
|
||||
frames += 1
|
||||
if (performance.now() - startedAt < 1000) requestAnimationFrame(tick)
|
||||
else resolve({ framesPerSecond: frames, visibilityState: document.visibilityState })
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
setTimeout(() => resolve({ framesPerSecond: frames, visibilityState: document.visibilityState }), 2000)
|
||||
})`)
|
||||
|
||||
const elapsedSeconds = (Date.now() - startedAt) / 1000
|
||||
const after = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const { profile } = await client.send("Profiler.stop")
|
||||
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.stop()`)
|
||||
const probe = await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.snapshot() ?? null`)
|
||||
|
||||
const summary = buildSummary({
|
||||
options,
|
||||
before,
|
||||
after,
|
||||
samples,
|
||||
cpu: summarizeCpuProfile(profile),
|
||||
probe,
|
||||
elapsedSeconds,
|
||||
})
|
||||
summary.frameLiveness = frameLiveness
|
||||
if (Number(frameLiveness?.framesPerSecond ?? 0) < 10) {
|
||||
console.warn(
|
||||
`\nWARNING: the renderer produced ${frameLiveness?.framesPerSecond ?? 0} frames per second`
|
||||
+ ` (visibility: ${frameLiveness?.visibilityState ?? "unknown"}). Rendering metrics from this run understate real work.`,
|
||||
)
|
||||
}
|
||||
|
||||
await writeFile(join(output, "idle-summary.json"), JSON.stringify(summary, null, 2))
|
||||
await writeFile(join(output, "cpu-profile.cpuprofile"), JSON.stringify(profile))
|
||||
|
||||
if (options.json) console.log(JSON.stringify(summary, null, 2))
|
||||
else printReport(summary, baseline)
|
||||
console.log(`\nSaved to ${output}`)
|
||||
|
||||
const failures = evaluateBudgets(summary, options)
|
||||
if (failures.length > 0) {
|
||||
console.error(`\nBudget failures:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
client?.close()
|
||||
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Idle profiling failed: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,630 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fully automated streaming capture for OpenChamber.
|
||||
*
|
||||
* Where `profile:idle` measures what the app does when nothing happens, this
|
||||
* command measures the opposite: what it costs to receive and render a live
|
||||
* assistant response. It creates a session, opens it in a real browser, sends a
|
||||
* prompt through the supported `openchamber session` CLI, and records until the
|
||||
* session reports itself idle again.
|
||||
*
|
||||
* The streaming path is judged by responsiveness rather than by totals: a
|
||||
* response that renders in one 4-second block and one that renders in eighty
|
||||
* 50 ms blocks move the same bytes, but only the second stays interactive. The
|
||||
* report therefore leads with the long-task distribution, frame production, and
|
||||
* per-token render cost, not with elapsed wall time.
|
||||
*
|
||||
* No input is synthesised. The only stimulus is the prompt, dispatched over the
|
||||
* CLI, so everything recorded is the application reacting to its own event
|
||||
* stream.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import process from "node:process"
|
||||
|
||||
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
||||
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
||||
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
||||
import { growthPerSecond, metricMap, round, summarizeLongTasks, summarizeTraceEvents } from "./perf/metrics.mjs"
|
||||
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const cliPath = join(repoRoot, "packages/web/bin/cli.js")
|
||||
|
||||
const DEFAULT_PROMPT = "Write a technical explanation of how a bytecode virtual machine executes"
|
||||
+ " a function call, about 800 words. Include three fenced code blocks in different languages"
|
||||
+ " and a markdown table comparing stack and register machines. Do not use any tools."
|
||||
|
||||
const HELP = `Usage: bun run profile:session -- [options]
|
||||
|
||||
Records what OpenChamber costs while an assistant response streams in.
|
||||
|
||||
Options:
|
||||
--url <url> OpenChamber URL (default: http://localhost:3000)
|
||||
--port <port> OpenChamber CLI port (default: from --url)
|
||||
--dir <path> Session directory (default: repository root)
|
||||
--session <id> Reuse this session instead of creating one
|
||||
--expand-projects Expand every project in the sidebar before recording
|
||||
--expand-sessions Click every "Show more sessions" control before
|
||||
recording, so all session rows are mounted
|
||||
--view-session <id> Display this session while the prompt streams into
|
||||
another one. Measures what an idle session costs
|
||||
while a different session is active in the
|
||||
background.
|
||||
--prompt <text> Prompt to send (default: a long markdown+code answer)
|
||||
--model <provider/model> Model override (default: configured selection)
|
||||
--agent <id> Agent override (default: configured selection)
|
||||
--settle <seconds> Wait after load before recording (default: 12)
|
||||
--timeout <seconds> Give up waiting for idle (default: 600)
|
||||
--tail <seconds> Keep recording after idle (default: 5)
|
||||
--output <directory> Artifact directory
|
||||
--label <text> Human label stored in the summary
|
||||
--chrome <path> Chrome/Chromium executable
|
||||
--profile-dir <path> Reusable isolated Chrome profile
|
||||
--headed Show the browser (default: headless)
|
||||
--sampling-interval <us> CPU sampler interval (default: 200)
|
||||
--baseline <directory> Compare against a previous run directory
|
||||
--budget-long-tasks <n> Fail when long tasks exceed this count
|
||||
--budget-longest <ms> Fail when the longest task exceeds this
|
||||
--keep-session Do not report the session as disposable
|
||||
--json Print the summary as JSON instead of a table
|
||||
--help Show this help
|
||||
|
||||
Exit code is non-zero when any provided budget is exceeded.`
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const options = {
|
||||
url: "http://localhost:3000",
|
||||
port: null,
|
||||
dir: repoRoot,
|
||||
session: null,
|
||||
viewSession: null,
|
||||
expandProjects: false,
|
||||
expandSessions: false,
|
||||
prompt: DEFAULT_PROMPT,
|
||||
model: null,
|
||||
agent: null,
|
||||
settle: 12,
|
||||
timeout: 600,
|
||||
tail: 5,
|
||||
output: null,
|
||||
label: null,
|
||||
chrome: null,
|
||||
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
|
||||
headless: true,
|
||||
samplingInterval: 200,
|
||||
baseline: null,
|
||||
budgetLongTasks: null,
|
||||
budgetLongest: null,
|
||||
keepSession: false,
|
||||
json: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value === "--help") return { ...options, help: true }
|
||||
else if (value === "--headed") options.headless = false
|
||||
else if (value === "--json") options.json = true
|
||||
else if (value === "--keep-session") options.keepSession = true
|
||||
else if (value === "--url") options.url = argv[++index]
|
||||
else if (value === "--port") options.port = argv[++index]
|
||||
else if (value === "--dir") options.dir = argv[++index]
|
||||
else if (value === "--session") options.session = argv[++index]
|
||||
else if (value === "--view-session") options.viewSession = argv[++index]
|
||||
else if (value === "--expand-projects") options.expandProjects = true
|
||||
else if (value === "--expand-sessions") options.expandSessions = true
|
||||
else if (value === "--prompt") options.prompt = argv[++index]
|
||||
else if (value === "--model") options.model = argv[++index]
|
||||
else if (value === "--agent") options.agent = argv[++index]
|
||||
else if (value === "--label") options.label = argv[++index]
|
||||
else if (value === "--settle") options.settle = Number(argv[++index])
|
||||
else if (value === "--timeout") options.timeout = Number(argv[++index])
|
||||
else if (value === "--tail") options.tail = Number(argv[++index])
|
||||
else if (value === "--output") options.output = argv[++index]
|
||||
else if (value === "--chrome") options.chrome = argv[++index]
|
||||
else if (value === "--profile-dir") options.profileDir = argv[++index]
|
||||
else if (value === "--sampling-interval") options.samplingInterval = Number(argv[++index])
|
||||
else if (value === "--baseline") options.baseline = argv[++index]
|
||||
else if (value === "--budget-long-tasks") options.budgetLongTasks = Number(argv[++index])
|
||||
else if (value === "--budget-longest") options.budgetLongest = Number(argv[++index])
|
||||
else throw new Error(`Unknown option: ${value}`)
|
||||
}
|
||||
const parsed = new URL(options.url)
|
||||
options.port = options.port ?? parsed.port ?? "3000"
|
||||
options.dir = resolve(options.dir)
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an `openchamber session` subcommand and returns its parsed JSON.
|
||||
* The CLI is the supported automation entry point, so the harness drives the
|
||||
* same path a scripted user would rather than reaching into internal APIs.
|
||||
*/
|
||||
const runSessionCli = (args, { timeoutMs = 900_000 } = {}) => new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(process.execPath, [cliPath, "session", ...args, "--json"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`session ${args[0]} timed out after ${Math.round(timeoutMs / 1000)}s`))
|
||||
}, timeoutMs)
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk })
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk })
|
||||
child.on("error", (error) => { clearTimeout(timer); reject(error) })
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) {
|
||||
reject(new Error(`session ${args[0]} exited with ${code}: ${stderr.trim() || stdout.trim()}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolvePromise(JSON.parse(stdout))
|
||||
} catch {
|
||||
reject(new Error(`session ${args[0]} returned unparseable output: ${stdout.slice(0, 400)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Reads what the page actually rendered for the session under test.
|
||||
*
|
||||
* A streaming capture is only meaningful if the recorded page was showing the
|
||||
* session that streamed. Opening a session that belongs to a directory the app
|
||||
* is not currently viewing produces a perfectly quiet, perfectly useless
|
||||
* profile, so the run verifies rendering rather than assuming it.
|
||||
*/
|
||||
const countRenderedMessages = async (client) => {
|
||||
const raw = await evaluateValue(client, `JSON.stringify({
|
||||
messages: document.querySelectorAll("[data-message-id]").length,
|
||||
characters: document.body.innerText.length,
|
||||
})`)
|
||||
try {
|
||||
return JSON.parse(raw ?? "{}")
|
||||
} catch {
|
||||
return { messages: 0, characters: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots the animations the page is running right now.
|
||||
*
|
||||
* Compositing work shows up in a trace as `Layerize`/`Commit`/`PrePaint` with
|
||||
* no indication of what caused it. `document.getAnimations()` names the
|
||||
* culprits directly, which turns "the compositor is busy" into a specific list
|
||||
* of elements and keyframes.
|
||||
*/
|
||||
const snapshotAnimations = async (client) => {
|
||||
const raw = await evaluateValue(client, `JSON.stringify((() => {
|
||||
if (typeof document.getAnimations !== "function") return []
|
||||
const describe = (animation) => {
|
||||
const target = animation.effect && animation.effect.target
|
||||
const identity = target
|
||||
? \`\${target.tagName.toLowerCase()}\${target.className && typeof target.className === "string" ? "." + target.className.trim().split(/\\s+/).slice(0, 4).join(".") : ""}\`
|
||||
: "(no target)"
|
||||
const keyframe = animation.animationName
|
||||
|| (animation.effect && animation.effect.getKeyframes && animation.effect.getKeyframes().length ? "transition/keyframes" : "unknown")
|
||||
return \`\${animation.playState} \${keyframe} on \${identity}\`
|
||||
}
|
||||
const counts = new Map()
|
||||
for (const animation of document.getAnimations()) {
|
||||
const key = describe(animation)
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 20)
|
||||
.map(([description, count]) => ({ description, count }))
|
||||
})())`)
|
||||
try {
|
||||
return JSON.parse(raw ?? "[]")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const REPORTED_METRICS = [
|
||||
{ key: "longTaskCount", label: "Long tasks (>50ms)", unit: "", lowerIsBetter: true },
|
||||
{ key: "longestTaskMs", label: "Longest task", unit: "ms", lowerIsBetter: true },
|
||||
{ key: "taskP95Ms", label: "Task p95", unit: "ms", lowerIsBetter: true },
|
||||
{ key: "taskP99Ms", label: "Task p99", unit: "ms", lowerIsBetter: true },
|
||||
{ key: "blockedPercent", label: "Time in long tasks", unit: "%", lowerIsBetter: true },
|
||||
{ key: "mainThreadBusyPercent", label: "Main-thread busy", unit: "%", lowerIsBetter: true },
|
||||
{ key: "recalcStylePerSecond", label: "Style recalcs/sec", unit: "", lowerIsBetter: true },
|
||||
{ key: "layoutsPerSecond", label: "Layouts/sec", unit: "", lowerIsBetter: true },
|
||||
{ key: "framesPerSecond", label: "Animation frames/sec", unit: "", lowerIsBetter: false },
|
||||
{ key: "streamSeconds", label: "Stream duration", unit: "s", lowerIsBetter: true },
|
||||
{ key: "renderedCharacters", label: "Rendered characters", unit: "", lowerIsBetter: false },
|
||||
{ key: "busyMsPerKilochar", label: "Busy per 1k chars", unit: "ms", lowerIsBetter: true },
|
||||
{ key: "recalcStylePerKilochar", label: "Style recalcs per 1k", unit: "", lowerIsBetter: true },
|
||||
{ key: "nodeGrowth", label: "DOM node growth", unit: "", lowerIsBetter: true },
|
||||
{ key: "listenerGrowth", label: "Listener growth", unit: "", lowerIsBetter: true },
|
||||
{ key: "heapGrowthMbPerSecond", label: "Heap growth", unit: "MB/s", lowerIsBetter: true },
|
||||
{ key: "heapMaxMb", label: "Heap max", unit: "MB", lowerIsBetter: true },
|
||||
]
|
||||
|
||||
const formatRow = (label, value, unit) => `${label.padEnd(22)} ${String(value).padStart(12)} ${unit}`
|
||||
|
||||
const printReport = (summary, baseline) => {
|
||||
const { metrics } = summary
|
||||
console.log(`\nStreaming profile — ${summary.metrics.streamSeconds}s response at ${summary.url}`)
|
||||
if (summary.label) console.log(`Label: ${summary.label}`)
|
||||
console.log(`Session: ${summary.sessionId}${summary.model ? ` Model: ${summary.model}` : ""}`)
|
||||
console.log("")
|
||||
for (const metric of REPORTED_METRICS) {
|
||||
const current = metrics[metric.key]
|
||||
if (!baseline) {
|
||||
console.log(formatRow(metric.label, current, metric.unit))
|
||||
continue
|
||||
}
|
||||
const previous = baseline.metrics?.[metric.key]
|
||||
const change = Number.isFinite(previous) ? round(current - previous) : null
|
||||
const marker = change === null || change === 0
|
||||
? ""
|
||||
: (change < 0) === metric.lowerIsBetter ? " improved" : " WORSE"
|
||||
const changeText = change === null ? "n/a" : `${change > 0 ? "+" : ""}${change}`
|
||||
console.log(`${formatRow(metric.label, current, metric.unit).padEnd(42)} was ${String(previous ?? "n/a").padStart(10)} ${changeText.padStart(9)}${marker}`)
|
||||
}
|
||||
|
||||
console.log("\nTop self time while streaming:")
|
||||
for (const entry of summary.cpuProfile?.topSelfTime?.slice(0, 15) ?? []) {
|
||||
console.log(` ${String(entry.selfMs).padStart(9)} ms ${String(entry.percentOfBusy).padStart(5)}% ${entry.function}`)
|
||||
}
|
||||
|
||||
if (summary.runningAnimations?.length) {
|
||||
console.log("\nAnimations running mid-stream:")
|
||||
for (const entry of summary.runningAnimations.slice(0, 12)) {
|
||||
console.log(` ${String(entry.count).padStart(4)}x ${entry.description}`)
|
||||
}
|
||||
} else {
|
||||
console.log("\nAnimations running mid-stream: none")
|
||||
}
|
||||
|
||||
console.log("\nWhere recorded time went (timeline trace):")
|
||||
for (const entry of summary.traceBreakdown?.slice(0, 14) ?? []) {
|
||||
console.log(` ${String(entry.totalMs).padStart(9)} ms ${String(entry.count).padStart(6)}x max ${String(entry.maxMs).padStart(7)} ms ${entry.name}`)
|
||||
}
|
||||
|
||||
const streamEntries = summary.streamPerformance?.entries ?? []
|
||||
if (streamEntries.length > 0) {
|
||||
console.log("\nApplication stream counters (total ms / count):")
|
||||
for (const entry of [...streamEntries].sort((left, right) => right.total - left.total).slice(0, 12)) {
|
||||
console.log(` ${String(round(entry.total)).padStart(9)} ms ${String(entry.count).padStart(6)}x max ${String(round(entry.max)).padStart(7)} ms ${entry.metric}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nTop scheduled-work call sites while streaming:")
|
||||
for (const entry of summary.scheduledWork?.sites?.slice(0, 10) ?? []) {
|
||||
console.log(` ${String(entry.totalMs).padStart(9)} ms ${String(entry.calls).padStart(6)}x ${entry.site}`)
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateBudgets = (summary, options) => {
|
||||
const failures = []
|
||||
if (Number.isFinite(options.budgetLongTasks) && summary.metrics.longTaskCount > options.budgetLongTasks) {
|
||||
failures.push(`Long tasks ${summary.metrics.longTaskCount} exceeds budget ${options.budgetLongTasks}`)
|
||||
}
|
||||
if (Number.isFinite(options.budgetLongest) && summary.metrics.longestTaskMs > options.budgetLongest) {
|
||||
failures.push(`Longest task ${summary.metrics.longestTaskMs}ms exceeds budget ${options.budgetLongest}ms`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
return
|
||||
}
|
||||
|
||||
const chrome = resolveChrome(options.chrome)
|
||||
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")
|
||||
const output = resolve(options.output ?? join("artifacts", `session-profile-${timestamp}`))
|
||||
const profileDir = resolve(options.profileDir)
|
||||
await mkdir(output, { recursive: true })
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
|
||||
const baseline = options.baseline
|
||||
? JSON.parse(await readFile(join(resolve(options.baseline), "session-summary.json"), "utf8"))
|
||||
: null
|
||||
|
||||
const cliBase = ["--dir", options.dir, "--port", String(options.port)]
|
||||
|
||||
let sessionId = options.session
|
||||
if (!sessionId) {
|
||||
const created = await runSessionCli([
|
||||
"create", ...cliBase,
|
||||
"--title", `perf: ${options.label ?? "streaming capture"}`,
|
||||
])
|
||||
sessionId = created.sessionId
|
||||
console.log(`Created session ${sessionId}`)
|
||||
} else {
|
||||
console.log(`Reusing session ${sessionId}`)
|
||||
}
|
||||
|
||||
const target = new URL(options.url)
|
||||
// The displayed session and the streaming session are deliberately separable:
|
||||
// a background session must not make the foreground one expensive.
|
||||
target.searchParams.set("session", options.viewSession ?? sessionId)
|
||||
|
||||
const port = await reservePort()
|
||||
const chromeProcess = launchChrome({ chrome, profileDir, port, headless: options.headless })
|
||||
let client
|
||||
try {
|
||||
const pageTarget = await createPageTarget(port)
|
||||
client = new CdpClient(pageTarget.webSocketDebuggerUrl)
|
||||
await client.connect()
|
||||
await Promise.all([
|
||||
client.send("Page.enable"),
|
||||
client.send("Runtime.enable"),
|
||||
client.send("Performance.enable"),
|
||||
client.send("Profiler.enable"),
|
||||
client.send("Network.enable", { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }),
|
||||
])
|
||||
await client.send("Network.setBypassServiceWorker", { bypass: true })
|
||||
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: buildIdleProbeSource() })
|
||||
await client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false,
|
||||
})
|
||||
|
||||
const traceEvents = []
|
||||
// A spread push overflows the call stack once a chunk carries hundreds of
|
||||
// thousands of events, which a heavily populated sidebar easily produces.
|
||||
const unsubscribeTrace = client.on("Tracing.dataCollected", ({ value }) => {
|
||||
for (const event of value ?? []) traceEvents.push(event)
|
||||
})
|
||||
|
||||
let loaded = client.once("Page.loadEventFired", 60_000)
|
||||
await client.send("Page.navigate", { url: target.toString() })
|
||||
await loaded
|
||||
// The application's own stream counters are opt-in; enabling them before
|
||||
// the recorded reload keeps their timeline aligned with the capture.
|
||||
await evaluateValue(client, `
|
||||
localStorage.setItem("openchamber_sync_perf", "1")
|
||||
localStorage.setItem("openchamber_stream_perf", "1")
|
||||
`)
|
||||
if (options.expandProjects) {
|
||||
await expandProjects(client)
|
||||
console.log("Expanded every project in the sidebar.")
|
||||
}
|
||||
loaded = client.once("Page.loadEventFired", 60_000)
|
||||
await client.send("Page.reload", { ignoreCache: false })
|
||||
await loaded
|
||||
|
||||
console.log(`Opened the session; settling for ${options.settle}s.`)
|
||||
await wait(options.settle * 1000)
|
||||
|
||||
if (options.expandSessions) {
|
||||
const expanded = await expandSessionLists(client)
|
||||
console.log(`Expanded ${expanded} collapsed session lists; settling ${options.settle}s again.`)
|
||||
await wait(options.settle * 1000)
|
||||
}
|
||||
|
||||
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.start()`)
|
||||
await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`)
|
||||
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
|
||||
await evaluateValue(client, `window.__openchamberStreamPerformance?.reset()`)
|
||||
await client.send("Profiler.setSamplingInterval", { interval: options.samplingInterval })
|
||||
await client.send("Profiler.start")
|
||||
await client.send("Tracing.start", {
|
||||
transferMode: "ReportEvents",
|
||||
// `RunTask` is only emitted under the disabled-by-default timeline
|
||||
// category. Without it the capture silently reports zero long tasks.
|
||||
categories: [
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"blink.user_timing",
|
||||
].join(","),
|
||||
})
|
||||
|
||||
const before = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const renderedBefore = await countRenderedMessages(client)
|
||||
const startedAt = Date.now()
|
||||
|
||||
const sendArgs = [
|
||||
"send", ...cliBase,
|
||||
"--session", sessionId,
|
||||
"--prompt", options.prompt,
|
||||
...(options.model ? ["--model", options.model] : []),
|
||||
...(options.agent ? ["--agent", options.agent] : []),
|
||||
]
|
||||
console.log("Dispatching the prompt and recording until the session reports idle.")
|
||||
const dispatch = runSessionCli(sendArgs, { timeoutMs: options.timeout * 1000 })
|
||||
|
||||
const samples = []
|
||||
let dispatchError = null
|
||||
let animationSnapshot = null
|
||||
let becameIdle = false
|
||||
dispatch.catch((error) => { dispatchError = error })
|
||||
|
||||
const deadline = startedAt + options.timeout * 1000
|
||||
let sawBusy = false
|
||||
while (Date.now() < deadline) {
|
||||
await wait(1_000)
|
||||
const current = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
samples.push({
|
||||
elapsedSeconds: round((Date.now() - startedAt) / 1000),
|
||||
jsHeapUsedMb: round(Number(current.JSHeapUsedSize ?? 0) / (1024 * 1024)),
|
||||
jsEventListeners: Number(current.JSEventListeners ?? 0),
|
||||
nodes: Number(current.Nodes ?? 0),
|
||||
taskDuration: round(Number(current.TaskDuration ?? 0), 3),
|
||||
})
|
||||
if (dispatchError) break
|
||||
|
||||
// One mid-stream snapshot is enough to name a continuously running
|
||||
// animation, and avoids polling overhead inside the measured window.
|
||||
if (animationSnapshot === null && Date.now() - startedAt > 15_000) {
|
||||
animationSnapshot = await snapshotAnimations(client)
|
||||
}
|
||||
|
||||
// `session status` is the authoritative activity source; polling it
|
||||
// avoids inferring completion from render or network quiet periods,
|
||||
// which a slow provider would misreport as a finished response.
|
||||
const status = await runSessionCli(["status", ...cliBase, "--session", sessionId], { timeoutMs: 30_000 })
|
||||
.catch(() => null)
|
||||
const type = status?.sessionStatus?.type ?? status?.status
|
||||
if (type && type !== "idle") sawBusy = true
|
||||
if (sawBusy && type === "idle") { becameIdle = true; break }
|
||||
}
|
||||
|
||||
if (dispatchError) throw dispatchError
|
||||
if (!becameIdle) console.warn(`WARNING: the session did not report idle within ${options.timeout}s; the capture is truncated.`)
|
||||
|
||||
const streamEndedAt = Date.now()
|
||||
if (options.tail > 0) await wait(options.tail * 1000)
|
||||
|
||||
const frameLiveness = await evaluateValue(client, `new Promise((resolveFrames) => {
|
||||
let frames = 0
|
||||
const startedAtFrames = performance.now()
|
||||
const tick = () => {
|
||||
frames += 1
|
||||
if (performance.now() - startedAtFrames < 1000) requestAnimationFrame(tick)
|
||||
else resolveFrames({ framesPerSecond: frames, visibilityState: document.visibilityState })
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
setTimeout(() => resolveFrames({ framesPerSecond: frames, visibilityState: document.visibilityState }), 2000)
|
||||
})`)
|
||||
|
||||
const elapsedSeconds = (Date.now() - startedAt) / 1000
|
||||
const renderedAfter = await countRenderedMessages(client)
|
||||
const after = metricMap((await client.send("Performance.getMetrics")).metrics)
|
||||
const { profile } = await client.send("Profiler.stop")
|
||||
|
||||
const tracingComplete = client.once("Tracing.tracingComplete", 120_000)
|
||||
let traceComplete = true
|
||||
try {
|
||||
await client.send("Tracing.end")
|
||||
await tracingComplete
|
||||
} catch (error) {
|
||||
traceComplete = false
|
||||
void tracingComplete.catch(() => undefined)
|
||||
console.warn(`Chrome did not confirm trace completion; using the events collected so far: ${error.message}`)
|
||||
await wait(2_000)
|
||||
}
|
||||
unsubscribeTrace()
|
||||
|
||||
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.stop()`)
|
||||
const probe = await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.snapshot() ?? null`)
|
||||
const streamPerformance = await evaluateValue(client, `window.__openchamberStreamPerformance?.getSnapshot() ?? null`)
|
||||
const syncCounters = await evaluateValue(client, `window.__openchamberSyncPerformance?.getSnapshot() ?? null`)
|
||||
const dispatchResult = await dispatch.catch(() => null)
|
||||
|
||||
// Both signals must agree: new message elements in the DOM and the
|
||||
// application's own message-list render counters firing.
|
||||
const messageListRendered = (streamPerformance?.entries ?? [])
|
||||
.some((entry) => entry.metric.startsWith("ui.message_list") && entry.count > 0)
|
||||
const renderedStream = options.viewSession
|
||||
? true
|
||||
: renderedAfter.messages > renderedBefore.messages && messageListRendered
|
||||
|
||||
const renderedCharacterGrowth = renderedAfter.characters - renderedBefore.characters
|
||||
|
||||
const tasks = summarizeLongTasks(traceEvents)
|
||||
const traceBreakdown = summarizeTraceEvents(traceEvents)
|
||||
const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0)
|
||||
const perSecond = (name) => round(delta(name) / elapsedSeconds)
|
||||
const heapSamples = samples.map((sample) => sample.jsHeapUsedMb)
|
||||
const streamSeconds = round((streamEndedAt - startedAt) / 1000)
|
||||
|
||||
const summary = {
|
||||
recordedAt: new Date(startedAt).toISOString(),
|
||||
label: options.label,
|
||||
url: options.url,
|
||||
sessionId,
|
||||
viewedSessionId: options.viewSession ?? sessionId,
|
||||
directory: options.dir,
|
||||
prompt: options.prompt,
|
||||
model: dispatchResult?.model ? `${dispatchResult.model.providerID}/${dispatchResult.model.modelID}` : options.model,
|
||||
agent: dispatchResult?.agent ?? options.agent,
|
||||
reachedIdle: becameIdle,
|
||||
renderedStream,
|
||||
renderedMessagesBefore: renderedBefore.messages,
|
||||
renderedMessagesAfter: renderedAfter.messages,
|
||||
renderedCharacterGrowth,
|
||||
traceComplete,
|
||||
disposableSession: !options.keepSession && !options.session,
|
||||
metrics: {
|
||||
...tasks,
|
||||
blockedPercent: round((tasks.longTaskTotalMs / (elapsedSeconds * 1000)) * 100),
|
||||
mainThreadBusyPercent: round((delta("TaskDuration") / elapsedSeconds) * 100),
|
||||
recalcStylePerSecond: perSecond("RecalcStyleCount"),
|
||||
layoutsPerSecond: perSecond("LayoutCount"),
|
||||
framesPerSecond: round(Number(probe?.counters?.rafScheduled ?? 0) / elapsedSeconds),
|
||||
// Response length varies between runs even for an identical prompt, so
|
||||
// per-second and total figures are not comparable across captures.
|
||||
// Normalising by rendered output is what makes two runs contrastable.
|
||||
renderedCharacters: renderedCharacterGrowth,
|
||||
busyMsPerKilochar: renderedCharacterGrowth > 0
|
||||
? round((delta("TaskDuration") * 1000) / (renderedCharacterGrowth / 1000))
|
||||
: 0,
|
||||
recalcStylePerKilochar: renderedCharacterGrowth > 0
|
||||
? round(delta("RecalcStyleCount") / (renderedCharacterGrowth / 1000))
|
||||
: 0,
|
||||
streamSeconds,
|
||||
recordedSeconds: round(elapsedSeconds),
|
||||
nodeStart: Number(before.Nodes ?? 0),
|
||||
nodeEnd: Number(after.Nodes ?? 0),
|
||||
nodeGrowth: delta("Nodes"),
|
||||
listenerStart: Number(before.JSEventListeners ?? 0),
|
||||
listenerEnd: Number(after.JSEventListeners ?? 0),
|
||||
listenerGrowth: delta("JSEventListeners"),
|
||||
heapStartMb: round(heapSamples.at(0) ?? 0),
|
||||
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
||||
heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
|
||||
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
||||
},
|
||||
frameLiveness,
|
||||
runningAnimations: animationSnapshot ?? [],
|
||||
cpuProfile: summarizeCpuProfile(profile),
|
||||
traceBreakdown,
|
||||
streamPerformance,
|
||||
syncCounters,
|
||||
scheduledWork: probe,
|
||||
samples,
|
||||
}
|
||||
|
||||
await writeFile(join(output, "session-summary.json"), JSON.stringify(summary, null, 2))
|
||||
await writeFile(join(output, "cpu-profile.cpuprofile"), JSON.stringify(profile))
|
||||
|
||||
if (tasks.taskCount === 0) {
|
||||
console.warn(
|
||||
"\nWARNING: the trace contained no RunTask events, so every long-task number below is a"
|
||||
+ " placeholder zero rather than a measurement. Check the tracing categories before trusting them.",
|
||||
)
|
||||
}
|
||||
|
||||
if (!renderedStream) {
|
||||
console.warn(
|
||||
"\nWARNING: the recorded page never rendered the streaming session"
|
||||
+ ` (message elements ${renderedBefore.messages} -> ${renderedAfter.messages},`
|
||||
+ ` message-list renders ${messageListRendered ? "fired" : "never fired"}).`
|
||||
+ "\nThe session most likely belongs to a directory the browser is not viewing."
|
||||
+ " Pass --dir for the directory the app has open. This capture measures nothing.",
|
||||
)
|
||||
}
|
||||
|
||||
if (options.json) console.log(JSON.stringify(summary, null, 2))
|
||||
else printReport(summary, baseline)
|
||||
console.log(`\nSaved to ${output}`)
|
||||
if (summary.disposableSession) console.log(`Session ${sessionId} was created by this run and can be deleted.`)
|
||||
|
||||
const failures = evaluateBudgets(summary, options)
|
||||
if (failures.length > 0) {
|
||||
console.error(`\nBudget failures:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
client?.close()
|
||||
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Session profiling failed: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Reference in New Issue
Block a user