Merge upstream/main into reproduce/issue-1720

Resolve conflicts after 914 upstream commits:
- CHANGELOG.md: keep brew opencode fix entry in Unreleased
- .gitignore: keep superpowers docs exclusion, take upstream additions

Drop /usr/local/ TOOLCHAIN_SEGMENTS addition — /usr/local/bin is part
of the default macOS PATH, so treating it as user-configured would skip
the login-shell fallback that this fix relies on. Upstream tests
(pass 1602) confirm minimal system PATH must not look user-configured.
This commit is contained in:
Mayuresh Kadu
2026-08-20 18:04:40 +01:00
2141 changed files with 246808 additions and 85959 deletions
+540
View File
@@ -0,0 +1,540 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
claimedFilePaths,
printClaims,
readActiveClaims,
releaseRun,
resolveRunsDir,
runDirPath,
} from "./lib/batch-claims.mjs";
const PIPELINE = "as";
// Resolved before command dispatch so every command shares one claims location.
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
const DEFAULT_MAX_ACTIVE = 20;
const DEFAULT_CLAIM_TTL_DAYS = 3;
// Rules ordered by how mechanical and behavior-safe their fixes are. Higher
// scores are preferred when selecting the next batch.
const PRIORITY_RULES = new Map([
["no-object-parameters", 100],
["no-shape-in-symbol-names", 95],
["no-unknown-type-aliases", 90],
["no-unknown-returns", 85],
["no-unknown-parameters", 80],
["no-unsafe-dictionary-type", 75],
["no-conditional-empty-object-spread", 70],
["no-known-value-widening", 65],
["no-chained-type-assertions", 60],
["no-widen-then-assert", 55],
["no-reflect-get", 50],
["no-reflect-apply", 50],
["no-module-mocking", 30],
["require-safety-comment-for-type-assertion", 20],
["no-runtime-typeof", 10],
]);
// Excluded by default because they account for most of the existing backlog and
// their fixes are the least mechanical. Opt in with --include-noisy.
const NOISY_RULES = new Set(["no-runtime-typeof", "require-safety-comment-for-type-assertion"]);
function usage(exitCode = 0) {
const out = exitCode === 0 ? console.log : console.error;
out(`Usage:
bun run deslop -- next-batch [--min-issues 60] [--max-issues 120] [--max-files 4]
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}] [--include-noisy]
bun run deslop -- check-batch --run <run-id>
bun run deslop -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
Every command accepts --claims-dir <path> to isolate a working copy.
bun run deslop -- release --run <run-id>
bun run deslop -- file <path> [--include-noisy]
bun run deslop -- top [--limit 10] [--include-noisy]
Files selected by an active batch are excluded from later batches, so concurrent
batches never touch the same file, including batches created by the React Doctor
pipeline. Claims are shared across clones by default. A batch stays active until
it is released.
Examples:
bun run deslop -- next-batch --min-issues 60 --max-issues 120
bun run deslop -- file packages/ui/src/lib/settings/metadata.ts
bun run deslop -- check-batch --run 2026-08-16T10-12-44Z
bun run deslop -- release --run 2026-08-16T10-12-44Z`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
i += 1;
}
return args;
}
function asPositiveInt(value, fallback, name) {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid --${name}: expected a positive integer.`);
}
return parsed;
}
function runOxlint() {
// Oxlint exits non-zero whenever it reports findings, so the report has to be
// read from stdout of the failed invocation rather than treated as an error.
let output;
try {
output = execFileSync("bunx", ["oxlint", "--format", "json"], {
cwd: process.cwd(),
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
// The utf8 encoding above makes stdout a string whenever the run produced
// a report; an empty stdout means the run itself failed.
if (!error.stdout) throw error;
output = error.stdout;
}
const report = JSON.parse(output);
return { diagnostics: normalizeDiagnostics(report.diagnostics ?? []) };
}
function ruleOf(code) {
const match = /^anti-slop\((.+)\)$/.exec(code ?? "");
return match ? match[1] : (code ?? "unknown");
}
function normalizeDiagnostics(rawDiagnostics) {
return rawDiagnostics.map((diagnostic) => {
const span = diagnostic.labels?.[0]?.span;
return {
filePath: diagnostic.filename,
rule: ruleOf(diagnostic.code),
severity: diagnostic.severity ?? "error",
message: diagnostic.message,
line: span?.line,
column: span?.column,
};
});
}
function selectableDiagnostics(report, includeNoisy) {
if (includeNoisy) return report.diagnostics;
return report.diagnostics.filter((diagnostic) => !NOISY_RULES.has(diagnostic.rule));
}
function groupByFile(diagnostics) {
const byFile = new Map();
for (const diagnostic of diagnostics) {
const list = byFile.get(diagnostic.filePath) ?? [];
list.push(diagnostic);
byFile.set(diagnostic.filePath, list);
}
return byFile;
}
function rulePriority(rule) {
return PRIORITY_RULES.get(rule) ?? 50;
}
function filePriority(diagnostics) {
const score = diagnostics.reduce((sum, diagnostic) => sum + rulePriority(diagnostic.rule), 0);
const mechanicalCount = diagnostics.filter((diagnostic) => rulePriority(diagnostic.rule) >= 75).length;
return score + mechanicalCount * 20;
}
function sortedFileEntries(diagnostics) {
return [...groupByFile(diagnostics).entries()].sort((a, b) => {
const scoreDiff = filePriority(b[1]) - filePriority(a[1]);
if (scoreDiff !== 0) return scoreDiff;
const countDiff = b[1].length - a[1].length;
if (countDiff !== 0) return countDiff;
return a[0].localeCompare(b[0]);
});
}
function summarizeRules(diagnostics) {
const counts = new Map();
for (const diagnostic of diagnostics) {
counts.set(diagnostic.rule, (counts.get(diagnostic.rule) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}
function createRunId() {
return new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "Z");
}
function titleCase(value) {
return value
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
.trim();
}
function fileNameWithoutExtension(filePath) {
const fileName = filePath.split("/").at(-1) ?? filePath;
return fileName.replace(/\.[^.]+$/, "");
}
function slugify(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function createBatchMetadata(runId, selectedFiles) {
const [datePart, timePart = ""] = runId.replace(/Z$/, "").split("T");
const timestamp = `${datePart.replace(/-/g, "")}-${timePart.replace(/-/g, "")}`;
const stems = selectedFiles.map((file) => fileNameWithoutExtension(file.filePath));
const readableArea = stems.length === 1
? stems[0]
: `${stems.slice(0, 2).join(" and ")}${stems.length > 2 ? ` plus ${stems.length - 2}` : ""}`;
const areaSlug = slugify(stems.slice(0, 3).join("-")) || "batch";
const batchName = `as-${timestamp}-${areaSlug}`;
return {
batchName,
branchName: `anti-slop/${batchName}`,
prTitle: `Reduce anti-slop findings in ${titleCase(readableArea)}`,
};
}
function selectBatch(entries, minIssues, maxIssues, maxFiles) {
if (entries.length === 0) {
return { selected: [], oversized: false, belowTarget: false, reason: "No findings available for selection." };
}
const firstFitting = entries.find(([, diagnostics]) => diagnostics.length >= minIssues && diagnostics.length <= maxIssues);
if (firstFitting) {
return {
selected: [firstFitting],
oversized: false,
belowTarget: false,
reason: "A prioritized file already fits the target window.",
};
}
const oversized = entries.find(([, diagnostics]) => diagnostics.length > maxIssues);
if (oversized) {
return {
selected: [oversized],
oversized: true,
belowTarget: false,
reason: "A prioritized file exceeds the target window and was selected as a single complete-file batch.",
};
}
const selected = [];
let total = 0;
for (const entry of entries) {
if (selected.length >= maxFiles) break;
const count = entry[1].length;
if (total + count > maxIssues) {
if (total >= minIssues) break;
continue;
}
selected.push(entry);
total += count;
if (total >= minIssues) break;
}
if (selected.length > 0) {
return {
selected,
oversized: false,
belowTarget: total < minIssues,
reason: total >= minIssues
? "Added complete files until the batch reached the target window."
: "No combination reached the minimum without exceeding the maximum; selected the best smaller complete-file batch.",
};
}
return {
selected: [entries[0]],
oversized: false,
belowTarget: entries[0][1].length < minIssues,
reason: "Selected the best available complete file below the target window.",
};
}
function writeRun(runId, payload) {
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
return dir;
}
function readRun(runId) {
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
const baselinePath = join(dir, "baseline.json");
const batchPath = join(dir, "batch.json");
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
throw new Error(`Unknown run: ${runId}`);
}
return {
baseline: JSON.parse(readFileSync(baselinePath, "utf8")),
batch: JSON.parse(readFileSync(batchPath, "utf8")),
};
}
function printReportHeader(report) {
const total = report.diagnostics.length;
const affected = groupByFile(report.diagnostics).size;
const noisy = report.diagnostics.filter((diagnostic) => NOISY_RULES.has(diagnostic.rule)).length;
console.log(`Total findings: ${total} across ${affected} files`);
console.log(`Excluded-by-default findings: ${noisy} (${[...NOISY_RULES].join(", ")})`);
}
function commandNextBatch(args) {
const minIssues = asPositiveInt(args["min-issues"], 60, "min-issues");
const maxIssues = asPositiveInt(args["max-issues"], 120, "max-issues");
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
const includeNoisy = args["include-noisy"] === true;
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
if (claims.length >= maxActive) {
console.log("Anti-Slop Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const report = runOxlint();
const claimedPaths = claimedFilePaths(claims);
const candidates = selectableDiagnostics(report, includeNoisy)
.filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
const entries = sortedFileEntries(candidates);
if (entries.length === 0) {
console.log("Anti-Slop Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log("Reason: no unclaimed findings remain for the selected rules.");
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
const runId = createRunId();
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
filePath,
diagnosticCount: fileDiagnostics.length,
rules: summarizeRules(fileDiagnostics),
}));
const metadata = createBatchMetadata(runId, selectedFiles);
const batch = {
runId,
...metadata,
minIssues,
maxIssues,
maxFiles,
maxActive,
includeNoisy,
selectedFiles,
oversized: selection.oversized,
belowTarget: selection.belowTarget,
reason: selection.reason,
};
const runDir = writeRun(runId, { report, batch });
console.log("Anti-Slop Next Batch");
console.log("");
console.log(`Run ID: ${runId}`);
console.log(`Batch name: ${batch.batchName}`);
console.log(`Branch name: ${batch.branchName}`);
console.log(`PR title: ${batch.prTitle}`);
console.log(`Baseline: ${join(runDir, "baseline.json")}`);
console.log(`Batch metadata: ${join(runDir, "batch.json")}`);
console.log("");
printReportHeader(report);
console.log("");
console.log(`Batch window: ${minIssues}-${maxIssues} findings`);
console.log(`Noisy rules included: ${includeNoisy ? "yes" : "no"}`);
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
console.log(`Selection mode: complete files only`);
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} findings`);
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
console.log(`Below target: ${selection.belowTarget ? "yes" : "no"}`);
console.log(`Selection reason: ${selection.reason}`);
console.log("");
console.log("Selected files:");
selection.selected.forEach(([filePath, fileDiagnostics], index) => {
console.log(`${index + 1}. ${filePath}`);
console.log(` Findings: ${fileDiagnostics.length}`);
console.log(" Rules:");
for (const [rule, count] of summarizeRules(fileDiagnostics)) {
console.log(` ${String(count).padStart(3)} ${rule}`);
}
console.log(" Findings detail:");
for (const diagnostic of fileDiagnostics) {
console.log(` line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
}
console.log("");
});
}
function commandActive(args) {
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
}
function commandRelease(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
console.log(`Released batch ${runId}`);
console.log(`Removed ${dir}`);
}
function commandTop(args) {
const limit = asPositiveInt(args.limit, 10, "limit");
const includeNoisy = args["include-noisy"] === true;
const report = runOxlint();
const entries = sortedFileEntries(selectableDiagnostics(report, includeNoisy)).slice(0, limit);
console.log(`Top ${limit} files by prioritized anti-slop findings`);
console.log("");
for (const [filePath, diagnostics] of entries) {
console.log(`${String(diagnostics.length).padStart(4)} ${filePath}`);
console.log(` ${summarizeRules(diagnostics).map(([rule, count]) => `${rule} ${count}`).join(", ")}`);
}
}
function commandFile(args) {
const filePath = args._[1];
if (!filePath) throw new Error("Missing file path. Usage: bun run deslop -- file <path>");
const includeNoisy = args["include-noisy"] === true;
const report = runOxlint();
const diagnostics = groupByFile(selectableDiagnostics(report, includeNoisy)).get(filePath) ?? [];
console.log(filePath);
console.log(`${diagnostics.length} findings`);
console.log("");
if (diagnostics.length === 0) return;
console.log("Rules:");
for (const [rule, count] of summarizeRules(diagnostics)) {
console.log(`${String(count).padStart(4)} ${rule}`);
}
console.log("");
console.log("Findings:");
for (const diagnostic of diagnostics) {
console.log(`line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
console.log(` ${diagnostic.message}`);
}
}
function commandCheckBatch(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const { baseline, batch } = readRun(runId);
const current = runOxlint();
const includeNoisy = batch.includeNoisy === true;
const beforeDiagnostics = selectableDiagnostics(baseline, includeNoisy);
const afterDiagnostics = selectableDiagnostics(current, includeNoisy);
const beforeByFile = groupByFile(beforeDiagnostics);
const afterByFile = groupByFile(afterDiagnostics);
const selected = batch.selectedFiles ?? [];
let beforeTotal = 0;
let afterTotal = 0;
console.log("Anti-Slop Batch Check");
console.log("");
console.log(`Run ID: ${runId}`);
if (batch.batchName) console.log(`Batch name: ${batch.batchName}`);
if (batch.branchName) console.log(`Branch name: ${batch.branchName}`);
if (batch.prTitle) console.log(`PR title: ${batch.prTitle}`);
console.log("");
console.log("Selected files:");
for (const file of selected) {
const before = beforeByFile.get(file.filePath)?.length ?? 0;
const after = afterByFile.get(file.filePath)?.length ?? 0;
beforeTotal += before;
afterTotal += after;
console.log(file.filePath);
console.log(` Before: ${before}`);
console.log(` After: ${after}`);
console.log(` Delta: ${after - before}`);
}
const selectedPaths = new Set(selected.map((file) => file.filePath));
const beforeOutside = beforeDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
const afterOutside = afterDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
console.log("");
console.log("Batch result:");
console.log(`Fixed findings in selected files: ${Math.max(0, beforeTotal - afterTotal)}`);
console.log(`Remaining findings in selected files: ${afterTotal}`);
console.log(`Findings outside selected files delta: ${afterOutside - beforeOutside}`);
console.log("");
console.log("Current repository summary:");
printReportHeader(current);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const command = args._[0];
if (!command || command === "help" || args.help) usage(0);
switch (command) {
case "next-batch":
commandNextBatch(args);
break;
case "top":
commandTop(args);
break;
case "file":
commandFile(args);
break;
case "check-batch":
commandCheckBatch(args);
break;
case "active":
commandActive(args);
break;
case "release":
commandRelease(args);
break;
default:
throw new Error(`Unknown command: ${command}`);
}
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
+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',
},
+39 -4
View File
@@ -17,6 +17,28 @@ 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"/>`,
],
// Claude spark — official Anthropic mark (Simple Icons path), monochrome.
[
"claude-code",
`<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
],
// Cursor two-cursor mark — official (Simple Icons path), monochrome.
[
"cursor",
`<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
],
// Command Code — corner squares + center square (official logo geometry).
[
"command-code",
`<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
],
])
const source = readFileSync(remixPath, "utf-8")
// --- Step 1: extract variable → path mapping ---
@@ -142,7 +164,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 +380,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();
+107
View File
@@ -0,0 +1,107 @@
import { readdirSync, readFileSync, rmSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// Batch handoff directories double as file claims. A run directory exists from
// the moment its batch is generated until its follow-up task releases it, so
// concurrent maintenance batches can be kept file-disjoint.
//
// Maintenance pipelines are expected to run from dedicated clones of the same
// repository, so claims live outside the working copy by default. Every clone
// and every pipeline therefore sees the same claims without any per-scheduler
// configuration. Override with --claims-dir or OPENCHAMBER_BATCH_CLAIMS_DIR
// only when a working copy must be isolated, for example while experimenting.
const DAY_MS = 24 * 60 * 60 * 1000;
const SHARED_CLAIMS_ENV = "OPENCHAMBER_BATCH_CLAIMS_DIR";
const DEFAULT_CLAIMS_DIR = join(homedir(), ".openchamber", "maintenance-claims");
function expandHome(path) {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
export function resolveRunsDir(claimsDirArgument) {
const override = claimsDirArgument ?? process.env[SHARED_CLAIMS_ENV];
if (override !== undefined && override !== true) {
return { runsDir: join(expandHome(override), "runs"), shared: false };
}
return { runsDir: join(DEFAULT_CLAIMS_DIR, "runs"), shared: true };
}
export function runDirName(pipeline, runId) {
return `${pipeline}-${runId}`;
}
export function runDirPath(runsDir, pipeline, runId) {
return join(runsDir, runDirName(pipeline, runId));
}
function parseDirName(dirName) {
const match = /^([a-z]+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z?)$/.exec(dirName);
if (!match) return undefined;
const [, pipeline, stamp] = match;
const [date, time] = stamp.replace(/Z$/, "").split("T");
const createdAt = Date.parse(`${date}T${time.replace(/-/g, ":")}Z`);
return { pipeline, runId: stamp, createdAt: Number.isNaN(createdAt) ? undefined : createdAt };
}
export function readActiveClaims(runsDir, claimTtlDays) {
if (!existsSync(runsDir)) return [];
const now = Date.now();
const claims = [];
for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const parsed = parseDirName(entry.name);
if (!parsed) continue;
const batchPath = join(runsDir, entry.name, "batch.json");
if (!existsSync(batchPath)) continue;
let batch;
try {
batch = JSON.parse(readFileSync(batchPath, "utf8"));
} catch {
continue;
}
const expired = parsed.createdAt !== undefined && now - parsed.createdAt > claimTtlDays * DAY_MS;
if (expired) continue;
claims.push({
pipeline: parsed.pipeline,
runId: batch.runId ?? parsed.runId,
branchName: batch.branchName,
createdAt: parsed.createdAt,
filePaths: (batch.selectedFiles ?? []).map((file) => file.filePath),
});
}
return claims.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
export function claimedFilePaths(claims) {
return new Set(claims.flatMap((claim) => claim.filePaths));
}
export function releaseRun(runsDir, pipeline, runId) {
const dir = runDirPath(runsDir, pipeline, runId);
if (!existsSync(dir)) throw new Error(`Unknown run: ${runId}`);
rmSync(dir, { recursive: true, force: true });
return dir;
}
export function printClaims(claims, ownPipeline) {
if (claims.length === 0) {
console.log("Active batches: none");
return;
}
console.log(`Active batches: ${claims.length}`);
for (const claim of claims) {
const owner = claim.pipeline === ownPipeline ? "this pipeline" : `pipeline ${claim.pipeline}`;
console.log(` ${claim.runId} ${claim.branchName ?? "(no branch)"} [${owner}]`);
for (const filePath of claim.filePaths) console.log(` ${filePath}`);
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"ios": {
"deviceName": "iPhone Example",
"useXcodeBeta": false,
"xcodeAppName": "Xcode"
},
"features": {
"releaseTools": false
},
"remoteDeployments": [
{
"id": "example-api",
"label": "example API-only",
"host": "example-host",
"port": 3002,
"dir": "testing-dev",
"apiOnly": true
},
{
"id": "example-ui",
"label": "example with UI",
"host": "example-host",
"port": 3002,
"dir": "testing-dev",
"apiOnly": false
}
]
}
+684
View File
@@ -0,0 +1,684 @@
#!/usr/bin/env node
/**
* OpenChamber local development helper.
*
* This script owns the interactive `bun run oc-dev` menu and the equivalent
* non-interactive commands for common local workflows: web deploys, mobile
* builds/device deploys, Electron, VS Code, and maintainer release tasks.
*
* Personal or machine-specific options are intentionally kept out of git.
* The only supported user config is:
*
* ~/.config/openchamber/oc-dev.json
*
* See `scripts/oc-dev.config.example.json` for the shape. The config can set
* local device/app preferences such as `ios.deviceName`, `ios.useXcodeBeta`,
* and `ios.xcodeAppName`, and can define `remoteDeployments`. Remote deploy
* menu entries are shown only when configured. Maintainer-only actions such as
* release creation are hidden unless `features.releaseTools` is true.
*
* Menus are platform-aware: macOS-only iOS/Xcode actions are hidden off macOS.
* Direct unsupported commands fail with a clear error instead of relying on
* prompts for safety.
*/
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cancel, intro, isCancel, log, outro, select, text } from '@clack/prompts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const configPath = path.join(os.homedir(), '.config', 'openchamber', 'oc-dev.json');
const GLOBAL_PORT = '2606';
const TESTING_PORT = '1202';
const TESTING_DIR = 'testing-dev';
const REMOTE_RUNTIME_ENV = 'PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; if [ -z "${OPENCODE_BINARY:-}" ]; then OPENCODE_CANDIDATE=$(command -v opencode 2>/dev/null || true); if [ -n "$OPENCODE_CANDIDATE" ]; then export OPENCODE_BINARY="$OPENCODE_CANDIDATE"; fi; fi';
const isTty = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
const isMac = process.platform === 'darwin';
function printHelp() {
console.log(`Usage:
bun run oc-dev [action] [options]
node scripts/oc-dev.mjs [action] [options]
Actions:
build-deploy-web Build web package and deploy
remote-deploy-web Deploy to configured remote target
start-web-dev Start web development loop
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
create-release Validate and bump release version
Options:
-a, --action <action>
--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-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-connect, android-deploy-usb, android-run, android-logcat,
ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug
`);
}
function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}`);
index += 1;
return value;
};
switch (arg) {
case '-h':
case '--help':
options.help = true;
break;
case '-a':
case '--action':
options.action = readValue();
break;
case '--deployment-mode':
options.deploymentMode = readValue();
break;
case '--remote-id':
options.remoteId = readValue();
break;
case '--target':
options.target = readValue();
break;
case '--web-mode':
options.webMode = readValue();
break;
case '--mobile-mode':
options.mobileMode = readValue();
break;
case '--mobile-task':
options.mobileTask = readValue();
break;
case '--adb-address':
options.adbAddress = readValue();
break;
case '--vsix-cleanup':
options.vsixCleanup = readValue();
break;
case '--version':
options.version = readValue();
break;
default:
if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
if (options.action) throw new Error(`Unexpected argument: ${arg}`);
options.action = arg;
break;
}
}
return options;
}
function loadConfig() {
if (!existsSync(configPath)) return { remoteDeployments: [] };
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
return {
...parsed,
remoteDeployments: Array.isArray(parsed.remoteDeployments) ? parsed.remoteDeployments : [],
};
} catch (error) {
throw new Error(`Failed to read ${configPath}: ${error.message}`);
}
}
function quote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || repoRoot,
env: { ...process.env, ...(options.env || {}) },
stdio: options.capture ? 'pipe' : 'inherit',
encoding: 'utf8',
shell: options.shell || false,
});
if (result.status !== 0 && !options.allowFail) {
throw new Error(`${options.label || [command, ...args].join(' ')} failed`);
}
return result.stdout?.trim() || '';
}
function step(label, fn) {
log.step(label);
const result = fn();
log.success(`${label} completed`);
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 = {
'deploy-web': 'build-deploy-web',
'build/deploy-web': 'build-deploy-web',
'web-dev': 'start-web-dev',
'mobile-dev': 'start-mobile-dev',
'ios-sim-dev': 'start-mobile-dev',
mobile: 'mobile-tools',
'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',
release: 'create-release',
};
return aliases[normalized] || normalized;
}
function ensurePromptable() {
if (!isTty) throw new Error('Missing required option and no TTY is available for prompting.');
}
async function chooseValue(current, choices, message) {
if (current) return current;
ensurePromptable();
const value = await select({ message, options: choices });
if (isCancel(value)) {
cancel('Operation cancelled.');
process.exit(130);
}
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 || []) {
if (address.family === 'IPv4' && !address.internal) return address.address;
}
}
return '';
}
function removeFilesByPrefixSuffix(directory, prefix, suffix) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory)) {
if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) continue;
unlinkSync(path.join(directory, entry));
}
}
function latestFileByExtensions(directory, extensions) {
if (!existsSync(directory)) return '';
return readdirSync(directory)
.filter((entry) => extensions.some((extension) => entry.endsWith(extension)))
.map((entry) => {
const filePath = path.join(directory, entry);
return { filePath, mtimeMs: statSync(filePath).mtimeMs };
})
.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || '';
}
function resetDirectory(directory) {
mkdirSync(directory, { recursive: true });
for (const entry of ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'bun.lockb']) {
rmSync(path.join(directory, entry), { force: true });
}
rmSync(path.join(directory, 'node_modules'), { recursive: true, force: true });
}
function installedWebCli(directory) {
const cliPath = path.join(directory, 'node_modules', '@openchamber', 'web', 'bin', 'cli.js');
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;
run('node', [cliPath, 'stop', '--port', port], { cwd: directory, allowFail: true, label: `stop instance on ${port}` });
}
function startInstalledInstance(directory, port) {
const cliPath = installedWebCli(directory);
if (!cliPath) throw new Error(`OpenChamber CLI was not installed in ${directory}`);
run('node', [cliPath, '--port', port], {
cwd: directory,
env: {
OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '',
OPENCHAMBER_HOST: '0.0.0.0',
},
label: `start instance on ${port}`,
});
}
function packageWeb() {
step('Building web bundle', () => run('bun', ['run', '--cwd', 'packages/web', 'build']));
const packOutput = step('Creating web package archive', () => run('npm', ['pack', '--pack-destination', repoRoot], { cwd: path.join(repoRoot, 'packages/web'), capture: true }));
const packageName = packOutput.split('\n').find((line) => line.trim().endsWith('.tgz'))?.trim();
if (!packageName) throw new Error('Archive creation failed: npm pack did not print a .tgz file.');
return path.join(repoRoot, packageName);
}
async function selectRemoteDeployment(config, options) {
if (options.remoteId) {
const remote = config.remoteDeployments.find((entry) => entry.id === options.remoteId);
if (!remote) throw new Error(`No remote deployment with id "${options.remoteId}" in ${configPath}`);
return remote;
}
if (options.target) {
const normalizedTarget = options.target.toLowerCase();
const apiOnly = ['test', 'testing', 'test-api', 'api', 'api-only'].includes(normalizedTarget);
const withUi = ['test-ui', 'ui', 'with-ui'].includes(normalizedTarget);
if (!apiOnly && !withUi) throw new Error('Invalid --target. Use test-api or test-ui.');
const remote = config.remoteDeployments.find((entry) => Boolean(entry.apiOnly) === apiOnly || (!entry.apiOnly && withUi));
if (remote) return remote;
}
if (config.remoteDeployments.length === 0) {
throw new Error(`No remoteDeployments configured in ${configPath}`);
}
return chooseValue(
'',
config.remoteDeployments.map((remote) => ({ value: remote.id, label: remote.label || remote.id, hint: `${remote.host}:${remote.port}` })),
'Select remote deployment',
).then((id) => config.remoteDeployments.find((entry) => entry.id === id));
}
async function deployWeb(options, config) {
const deploymentMode = (await chooseValue(options.deploymentMode, [
{ value: 'global', label: 'Global' },
{ value: 'testing', label: 'Testing' },
], 'Select installation mode')).toLowerCase();
if (!['global', 'testing'].includes(deploymentMode)) {
throw new Error('Invalid deployment mode. Use global or testing. Use remote-deploy-web for configured remote deployments.');
}
const packageFile = packageWeb();
if (deploymentMode === 'testing') {
const testingDir = path.join(os.homedir(), TESTING_DIR);
step(`Stopping testing instance on ${TESTING_PORT}`, () => stopInstalledInstance(testingDir, TESTING_PORT));
step('Preparing testing install directory', () => {
resetDirectory(testingDir);
run('bun', ['init', '-y'], { cwd: testingDir });
});
step('Installing testing package', () => run('bun', ['add', packageFile], { cwd: testingDir }));
step(`Starting testing instance on ${TESTING_PORT}`, () => startInstalledInstance(testingDir, TESTING_PORT));
return;
}
step(`Stopping global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['stop', '--port', GLOBAL_PORT], { allowFail: true, label: `stop global instance on ${GLOBAL_PORT}` }));
step('Removing old global package', () => {
run('bun', ['remove', '-g', '@openchamber/web'], { allowFail: true, label: 'remove @openchamber/web' });
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}`, () => {
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) {
const remote = await selectRemoteDeployment(config, options);
const packageFile = packageWeb();
const host = remote.host;
const dir = remote.dir;
const port = String(remote.port);
const apiOnly = remote.apiOnly ? 'true' : 'false';
const packageBase = path.basename(packageFile);
if (!host || !dir || !port) throw new Error(`Remote deployment ${remote.id} must define host, dir, and port.`);
step('Preparing remote directories', () => run('ssh', [host, `mkdir -p ~/${dir}/releases`]));
step(`Stopping remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; ${REMOTE_RUNTIME_ENV}; cd ~/${dir} 2>/dev/null || exit 0; PORT=${quote(port)}; TMPDIR=$(node -p "require('os').tmpdir()" 2>/dev/null || echo /tmp); PIDFILE="$TMPDIR/openchamber-${port}.pid"; INSTANCEFILE="$TMPDIR/openchamber-${port}.json"; if [ -f ./node_modules/@openchamber/web/bin/cli.js ]; then bun ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || node ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || true; fi; if command -v lsof >/dev/null 2>&1; then lsof -ti :"$PORT" | xargs -r kill >/dev/null 2>&1 || true; sleep 0.5; lsof -ti :"$PORT" | xargs -r kill -9 >/dev/null 2>&1 || true; fi; rm -f "$PIDFILE" "$INSTANCEFILE"`], { label: 'stop remote instance' }));
step('Copying package to remote', () => {
run('ssh', [host, `mkdir -p ~/${dir}/releases && rm -f ~/${dir}/releases/*.tgz`]);
run('scp', ['-q', packageFile, `${host}:~/${dir}/releases/${packageBase}`]);
});
step('Resetting remote install state', () => run('ssh', [host, `cd ~/${dir} && rm -f package.json package-lock.json pnpm-lock.yaml bun.lockb && rm -rf node_modules`]));
step('Preparing remote package manifest', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm init -y >/dev/null 2>&1`]));
step('Installing remote package', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm install ./releases/${packageBase}`]));
step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; OPENCHAMBER_HOST=0.0.0.0 node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`]));
log.success(`Remote deployment ready: ${host}:${port}`);
}
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-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') {
run('bun', ['run', 'dev:web:full']);
} else {
run('bun', ['run', 'dev:web:hmr']);
}
}
async function startMobileDev(options) {
const mobileModeChoices = [
{ value: 'ios-sim-local', label: 'iOS Simulator local' },
{ value: 'ios-sim-lan', label: 'iOS Simulator LAN' },
{ value: 'android-local', label: 'Android emulator local' },
{ value: 'android-lan', label: 'Android device LAN' },
].filter((choice) => isMac || !choice.value.startsWith('ios-'));
const mode = await chooseValue(options.mobileMode, mobileModeChoices, 'Select mobile dev mode');
if (mode.startsWith('ios-') && !isMac) {
throw new Error('iOS mobile dev actions require macOS and Xcode.');
}
const hmrPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180';
let hmrBindHost = '127.0.0.1';
let liveReloadHost = '127.0.0.1';
let platform = 'ios';
let extraArgs = [];
if (mode === 'ios-sim-lan' || mode === 'android-lan') {
hmrBindHost = '0.0.0.0';
liveReloadHost = detectLanIp();
if (!liveReloadHost) throw new Error('Could not detect LAN IP.');
}
if (mode.startsWith('android')) platform = 'android';
if (mode === 'android-local') extraArgs = ['--forwardPorts', `${hmrPort}:${hmrPort}`];
log.step(`Starting mobile UI dev server on ${hmrBindHost}:${hmrPort}`);
const devServer = spawn('bun', ['x', 'vite', '--config', 'local-dev-mobile-vite.config.mjs', '--host', hmrBindHost, '--port', hmrPort, '--strictPort'], {
cwd: repoRoot,
stdio: 'inherit',
env: { ...process.env, OPENCHAMBER_DISABLE_PWA_DEV: '1' },
});
const stopDevServer = () => {
if (!devServer.killed) devServer.kill('SIGTERM');
};
process.once('SIGINT', () => {
stopDevServer();
process.exit(130);
});
process.once('SIGTERM', () => {
stopDevServer();
process.exit(143);
});
await new Promise((resolve) => setTimeout(resolve, 6000));
run('node', ['scripts/with-mobile-env.mjs', `bunx cap run ${platform} --live-reload --host ${liveReloadHost} --port ${hmrPort} ${extraArgs.join(' ')}`], { cwd: path.join(repoRoot, 'packages/mobile') });
log.info('Mobile UI dev server is still running. Press Ctrl+C to stop.');
await new Promise((resolve) => devServer.on('exit', resolve));
}
async function mobileTools(options, config) {
const mobileTaskChoices = [
{ 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' },
{ value: 'ios-sim-build', label: 'iOS Simulator: build' },
{ value: 'ios-sim-run', label: 'iOS Simulator: install + launch' },
{ value: 'ios-sim-serve', label: 'iOS Simulator: browser preview' },
{ value: 'ios-sim-kill', label: 'iOS Simulator: stop browser preview' },
{ value: 'ios-device-sync-debug', label: 'iOS Device: sync + open debugger workspace' },
].filter((choice) => isMac || !choice.value.startsWith('ios-'));
const task = await chooseValue(options.mobileTask, mobileTaskChoices, 'Select mobile action');
if (task.startsWith('ios-') && !isMac) {
throw new Error('iOS mobile actions require macOS and Xcode.');
}
const mobileCwd = path.join(repoRoot, 'packages/mobile');
const mobileRun = (label, script) => step(label, () => run('bun', ['run', script], { cwd: mobileCwd }));
switch (task) {
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');
case 'android-run': return mobileRun('Installing and launching Android app on USB device', 'android:run');
case 'android-logcat': return mobileRun('Streaming Android app logs', 'android:logcat');
case 'ios-sim-build': return mobileRun('Building iOS Simulator app', 'build:ios:simulator');
case 'ios-sim-run': return mobileRun('Installing and launching iOS Simulator app', 'sim:run');
case 'ios-sim-serve': return mobileRun('Starting iOS Simulator browser preview', 'sim:serve');
case 'ios-sim-kill': return mobileRun('Stopping iOS Simulator browser preview', 'sim:kill');
case 'ios-device-sync-debug': {
mobileRun('Syncing iOS native project', 'sync');
const deviceName = process.env.IOS_DEVICE_NAME || config.ios?.deviceName || 'iPhone Bohdan';
const xcodeAppName = process.env.XCODE_APP_NAME || config.ios?.xcodeAppName || (config.ios?.useXcodeBeta ? 'Xcode-beta' : 'Xcode');
log.info(`Target physical device: ${deviceName}`);
log.warn('CLI can sync/build/install parts of iOS, but attaching Apple\'s debugger to a physical iPhone is still Xcode\'s job. Select the device in Xcode and press Run.');
if (process.platform !== 'darwin') throw new Error('Opening Xcode requires macOS.');
return step(`Opening iOS workspace in ${xcodeAppName}`, () => run('open', ['-a', xcodeAppName, path.join(mobileCwd, 'ios/App/App.xcworkspace')]));
}
default:
throw new Error(`Unknown mobile task: ${task}`);
}
}
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;
const artifact = latestFileByExtensions(distDir, ['.dmg', '-mac.zip']);
if (artifact) run('open', [artifact]);
}
function startVsCodeExtension() {
const vscodeDir = path.join(repoRoot, 'packages/vscode');
removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix');
step('Building VS Code extension', () => run('bun', ['run', 'vscode:build']));
run('code', ['--extensionDevelopmentPath', vscodeDir]);
}
async function installVsCodeExtensionLocal(options) {
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']));
step('Removing found VSIX package(s) before install flow', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'));
step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir }));
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) {
if (!options.config?.features?.releaseTools) {
throw new Error(`Release tools are disabled. Set features.releaseTools=true in ${configPath} to enable this maintainer task.`);
}
let version = options.version;
if (!version) {
ensurePromptable();
version = await text({ message: 'Enter release version', placeholder: '1.4.7' });
if (isCancel(version)) {
cancel('Operation cancelled.');
process.exit(130);
}
}
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]));
printReleaseNextSteps(version);
}
async function chooseAction(config) {
const options = [
{ value: 'build-deploy-web', label: 'Build/Deploy web' },
{ value: 'start-web-dev', label: 'Start web dev' },
{ 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' },
];
if (config.features?.releaseTools) {
options.push({ value: 'create-release', label: 'Create Release' });
}
if (config.remoteDeployments.length > 0) {
options.splice(1, 0, { value: 'remote-deploy-web', label: 'Deploy configured remote web' });
}
const action = await chooseValue('', options, 'Select OpenChamber dev action');
return action;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const config = loadConfig();
const interactive = !options.action;
if (interactive) intro('OpenChamber dev');
let action = normalizeAction(options.action || await chooseAction(config));
switch (action) {
case 'build-deploy-web':
await deployWeb(options, config);
break;
case 'remote-deploy-web':
await deployRemoteWeb(options, config);
break;
case 'start-web-dev':
await startWebDev(options);
break;
case 'start-mobile-dev':
await startMobileDev(options);
break;
case 'mobile-tools':
await mobileTools(options, config);
break;
case 'start-electron-app':
startElectronApp();
break;
case 'prepare-opencode-cli':
prepareOpenCodeCli();
break;
case 'build-electron-app':
buildElectronApp();
break;
case 'start-vscode-extension':
startVsCodeExtension();
break;
case 'install-vscode-extension-local':
await installVsCodeExtensionLocal(options);
break;
case 'create-release':
options.config = config;
await createRelease(options);
break;
default:
throw new Error(`Unknown action: ${action}`);
}
if (interactive) outro('Done');
}
main().catch((error) => {
log.error(error.message);
process.exit(1);
});
+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
})
+83 -7
View File
@@ -4,8 +4,24 @@ import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
claimedFilePaths,
printClaims,
readActiveClaims,
releaseRun,
resolveRunsDir,
runDirPath,
} from "./lib/batch-claims.mjs";
const PROJECT_NAME = "openchamber-monorepo";
const RUNS_DIR = join(process.cwd(), ".tmp", "react-doctor", "runs");
// Pinned so unattended batch runs cannot change diagnostics or output shape
// without an explicit update here.
const REACT_DOCTOR_VERSION = "0.9.12";
const PIPELINE = "rd";
// Resolved before command dispatch so every command shares one claims location.
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
const DEFAULT_MAX_ACTIVE = 20;
const DEFAULT_CLAIM_TTL_DAYS = 3;
const PRIORITY_RULES = new Map([
["effect-needs-cleanup", 100],
@@ -88,14 +104,25 @@ function usage(exitCode = 0) {
const out = exitCode === 0 ? console.log : console.error;
out(`Usage:
bun run doctor -- next-batch [--min-issues 75] [--max-issues 120] [--max-files 4]
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
bun run doctor -- check-batch --run <run-id>
bun run doctor -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
Every command accepts --claims-dir <path> to isolate a working copy.
bun run doctor -- release --run <run-id>
bun run doctor -- file <path>
bun run doctor -- top [--limit 10]
Files selected by an active batch are excluded from later batches, so concurrent
batches never touch the same file, including batches created by the anti-slop
pipeline. Claims are shared across clones by default. A batch stays active until
it is released.
Examples:
bun run doctor -- next-batch --min-issues 75 --max-issues 120
bun run doctor -- file packages/ui/src/components/chat/ChatInput.tsx
bun run doctor -- check-batch --run 2026-05-14T12-31-44`);
bun run doctor -- check-batch --run 2026-05-14T12-31-44Z
bun run doctor -- release --run 2026-05-14T12-31-44Z`);
process.exit(exitCode);
}
@@ -132,7 +159,7 @@ function runReactDoctor() {
const output = execFileSync(
"npx",
[
"react-doctor@latest",
`react-doctor@${REACT_DOCTOR_VERSION}`,
"--project",
PROJECT_NAME,
"--json",
@@ -296,7 +323,7 @@ function selectBatch(entries, minIssues, maxIssues, maxFiles) {
}
function writeRun(runId, payload) {
const dir = join(RUNS_DIR, runId);
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
@@ -304,7 +331,7 @@ function writeRun(runId, payload) {
}
function readRun(runId) {
const dir = join(RUNS_DIR, runId);
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
const baselinePath = join(dir, "baseline.json");
const batchPath = join(dir, "batch.json");
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
@@ -336,10 +363,36 @@ function commandNextBatch(args) {
const maxIssues = asPositiveInt(args["max-issues"], 120, "max-issues");
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
if (claims.length >= maxActive) {
console.log("React Doctor Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const report = runReactDoctor();
const diagnostics = allDiagnostics(report);
const claimedPaths = claimedFilePaths(claims);
const diagnostics = allDiagnostics(report).filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
const entries = sortedFileEntries(diagnostics);
if (entries.length === 0) {
console.log("React Doctor Next Batch");
console.log("");
console.log("NO BATCH AVAILABLE");
console.log("Reason: no unclaimed diagnostics remain.");
console.log("Stop here. Do not create a branch or a pull request.");
console.log("");
printClaims(claims, PIPELINE);
return;
}
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
const runId = createRunId();
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
@@ -348,7 +401,7 @@ function commandNextBatch(args) {
rules: summarizeRules(fileDiagnostics),
}));
const metadata = createBatchMetadata(runId, selectedFiles);
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, maxActive, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
const runDir = writeRun(runId, { report, batch });
console.log("React Doctor Next Batch");
@@ -363,6 +416,9 @@ function commandNextBatch(args) {
printReportHeader(report);
console.log("");
console.log(`Batch window: ${minIssues}-${maxIssues} diagnostics`);
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
console.log(`Selection mode: complete files only`);
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} diagnostics`);
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
@@ -387,6 +443,20 @@ function commandNextBatch(args) {
});
}
function commandActive(args) {
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
}
function commandRelease(args) {
const runId = args.run;
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
console.log(`Released batch ${runId}`);
console.log(`Removed ${dir}`);
}
function commandTop(args) {
const limit = asPositiveInt(args.limit, 10, "limit");
const report = runReactDoctor();
@@ -479,6 +549,12 @@ async function main() {
case "check-batch":
commandCheckBatch(args);
break;
case "active":
commandActive(args);
break;
case "release":
commandRelease(args);
break;
default:
throw new Error(`Unknown command: ${command}`);
}
+8 -13
View File
@@ -300,11 +300,7 @@ async function main() {
of potentially seconds. (Standard brew paths are also
covered by hardcoded fallbacks.)
3. ✅ TOOLCHAIN_SEGMENTS (path-utils.js):
Added '/usr/local/' to TOOLCHAIN_SEGMENTS so a PATH
containing /usr/local/bin is recognized as user-configured.
4. ✅ VS CODE FALLBACK ORDER (opencode.ts):
3. ✅ VS CODE FALLBACK ORDER (opencode.ts):
Brew fallback order now matches server: /opt/homebrew
(Apple Silicon) before /usr/local (Intel).
@@ -341,14 +337,13 @@ async function main() {
console.log(`
──────────────────────────────────────────────────────────
Fixes applied:
1. ✅ All 4 shell probe spawnSync calls now have a 5s timeout
(SHELL_PROBE_TIMEOUT_MS, added at env-runtime.js module level)
2. ✅ Fast-path via /bin/sh -c 'command -v opencode' added
before login shell probing in all 3 resolvers
3. ✅ /usr/local/ added to TOOLCHAIN_SEGMENTS in path-utils.js
4. ✅ Brew fallback order fixed in VS Code extension
(/opt/homebrew before /usr/local)
Fixes applied:
1. ✅ All 4 shell probe spawnSync calls now have a 5s timeout
(SHELL_PROBE_TIMEOUT_MS, added at env-runtime.js module level)
2. ✅ Fast-path via /bin/sh -c 'command -v opencode' added
before login shell probing in all 3 resolvers
3. ✅ Brew fallback order fixed in VS Code extension
(/opt/homebrew before /usr/local)
`);
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// Runs every test file under the given roots, each in its own process.
//
// Two properties of this repository make that the working arrangement rather
// than a preference:
//
// - The shared UI and extension suites keep module-level singletons (runtime
// endpoint, relay tunnel, stores, registries). Executed in one process they
// leak state into each other and fail by load order, which is why the relay
// guidance already says to run those files one at a time.
// - The same directories mix `bun:test` and `node:test` files, so no single
// runner command covers them. The framework is read from the file's imports
// instead of being listed here, so adding a test never requires editing a
// list that then rots.
//
// Usage: node scripts/run-isolated-tests.mjs <root> [...roots]
import { spawn } from 'node:child_process';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/;
const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']);
const MAX_PARALLEL = 4;
const collect = (root, found = []) => {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (entry.name.startsWith('.') && entry.name !== '.') continue;
const full = path.join(root, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) collect(full, found);
continue;
}
if (TEST_FILE.test(entry.name)) found.push(full);
}
return found;
};
/** `null` when the file names no known runner, so it is reported instead of skipped silently. */
const resolveCommand = (file) => {
const source = readFileSync(file, 'utf8');
const isTypeScript = /\.tsx?$/.test(file);
// TypeScript goes to Bun even when the file imports `node:test`, which Bun
// implements. Node's ESM loader cannot resolve the extensionless local
// specifiers these files use (`./sseProxy`), so it never ran them at all.
if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) {
return { label: 'bun', command: 'bun', args: ['test', file] };
}
if (/from\s+['"]node:test['"]/.test(source)) {
return { label: 'node', command: 'node', args: ['--test', file] };
}
return null;
};
const run = ({ command, args }) => new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
child.stdout.on('data', (chunk) => { output += chunk; });
child.stderr.on('data', (chunk) => { output += chunk; });
child.on('error', (error) => resolve({ code: 1, output: `${output}${error.message}` }));
child.on('close', (code) => resolve({ code: code ?? 1, output }));
});
const roots = process.argv.slice(2);
if (roots.length === 0) {
console.error('run-isolated-tests: expected at least one root directory');
process.exit(1);
}
const files = [];
for (const root of roots) {
const resolved = path.resolve(root);
if (!statSync(resolved).isDirectory()) {
console.error(`run-isolated-tests: not a directory: ${root}`);
process.exit(1);
}
files.push(...collect(resolved));
}
files.sort();
const failures = [];
const unknown = [];
let passed = 0;
let next = 0;
const worker = async () => {
while (next < files.length) {
const file = files[next++];
const relative = path.relative(process.cwd(), file);
const resolved = resolveCommand(file);
if (!resolved) {
unknown.push(relative);
continue;
}
const { code, output } = await run(resolved);
if (code === 0) {
passed += 1;
} else {
failures.push({ relative, label: resolved.label, output });
console.error(`FAIL (${resolved.label}) ${relative}`);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL, files.length) }, worker));
for (const failure of failures) {
console.error(`\n===== ${failure.relative} (${failure.label}) =====\n${failure.output}`);
}
for (const file of unknown) {
console.error(`UNKNOWN RUNNER ${file}: imports neither bun:test nor node:test`);
}
console.log(`\n${passed}/${files.length} test files passed${failures.length ? `, ${failures.length} failed` : ''}${unknown.length ? `, ${unknown.length} with no known runner` : ''}`);
process.exit(failures.length > 0 || unknown.length > 0 ? 1 : 0);