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