Merge branch 'main' into reproduce/issue-1720

Signed-off-by: Mayuresh K <23300+mskadu@users.noreply.github.com>
This commit is contained in:
Mayuresh K
2026-08-05 10:27:32 +01:00
committed by GitHub
1402 changed files with 157643 additions and 52975 deletions
+4 -8
View File
@@ -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',
},
+24 -4
View File
@@ -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.
+275
View File
@@ -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
View File
@@ -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;
+170
View File
@@ -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`. |
+133
View File
@@ -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>
+191
View File
@@ -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
}
+71
View File
@@ -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,
})),
}
}
+218
View File
@@ -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});`
+89
View File
@@ -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),
}
}
+44
View File
@@ -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
}
+3 -3
View File
@@ -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',
+219
View File
@@ -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
})
+81
View File
@@ -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: [],
})
})
+60
View File
@@ -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.
+534
View File
@@ -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
})
+466
View File
@@ -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
})
+630
View File
@@ -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
})