perf(tooling): reach a fully populated sidebar in both profilers
Idle and streaming cost both depend on how much of the sidebar is mounted, so the scenario setup is now shared. --expand-projects seeds the persisted collapse state; --expand-sessions clicks every "Show more sessions" control, which cannot be seeded because pagination is component state. Both run before the measured window, so it stays input-free. Session expansion must run after the sidebar has populated, not straight after the load event, or the controls do not exist yet. Also replaces a spread push over collected trace events, which overflowed the call stack once a populated sidebar produced chunks of hundreds of thousands of events, and the equivalent spread in the heap-maximum calculation.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
+12
-17
@@ -29,6 +29,7 @@ import process from "node:process"
|
|||||||
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
||||||
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
||||||
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
||||||
|
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
|
||||||
import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs"
|
import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs"
|
||||||
|
|
||||||
const HELP = `Usage: bun run profile:idle -- [options]
|
const HELP = `Usage: bun run profile:idle -- [options]
|
||||||
@@ -42,6 +43,10 @@ Options:
|
|||||||
--then-tab <name> After settling, navigate to this tab without a
|
--then-tab <name> After settling, navigate to this tab without a
|
||||||
reload, then record. Use it to measure what a
|
reload, then record. Use it to measure what a
|
||||||
surface keeps doing after the user leaves it.
|
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
|
--expand-projects Expand every project in the sidebar before
|
||||||
recording, which mounts a row per worktree and
|
recording, which mounts a row per worktree and
|
||||||
session directory
|
session directory
|
||||||
@@ -74,6 +79,7 @@ const parseArgs = (argv) => {
|
|||||||
thenTab: null,
|
thenTab: null,
|
||||||
panels: [],
|
panels: [],
|
||||||
expandProjects: false,
|
expandProjects: false,
|
||||||
|
expandSessions: false,
|
||||||
duration: 30,
|
duration: 30,
|
||||||
settle: 15,
|
settle: 15,
|
||||||
output: null,
|
output: null,
|
||||||
@@ -99,6 +105,7 @@ const parseArgs = (argv) => {
|
|||||||
else if (value === "--then-tab") options.thenTab = argv[++index]
|
else if (value === "--then-tab") options.thenTab = argv[++index]
|
||||||
else if (value === "--panel") options.panels.push(argv[++index])
|
else if (value === "--panel") options.panels.push(argv[++index])
|
||||||
else if (value === "--expand-projects") options.expandProjects = true
|
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 === "--label") options.label = argv[++index]
|
||||||
else if (value === "--duration") options.duration = Number(argv[++index])
|
else if (value === "--duration") options.duration = Number(argv[++index])
|
||||||
else if (value === "--settle") options.settle = Number(argv[++index])
|
else if (value === "--settle") options.settle = Number(argv[++index])
|
||||||
@@ -243,7 +250,7 @@ const buildSummary = ({ options, before, after, samples, cpu, probe, elapsedSeco
|
|||||||
frames: Number(after.Frames ?? 0),
|
frames: Number(after.Frames ?? 0),
|
||||||
heapStartMb: round(heapSamples.at(0) ?? 0),
|
heapStartMb: round(heapSamples.at(0) ?? 0),
|
||||||
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
||||||
heapMaxMb: round(Math.max(0, ...heapSamples)),
|
heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
|
||||||
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
||||||
},
|
},
|
||||||
cpuProfile: cpu,
|
cpuProfile: cpu,
|
||||||
@@ -357,10 +364,7 @@ const main = async () => {
|
|||||||
await loaded
|
await loaded
|
||||||
|
|
||||||
if (options.expandProjects) {
|
if (options.expandProjects) {
|
||||||
// The sidebar persists the ids of collapsed projects, so an empty list
|
await expandProjects(client)
|
||||||
// expands everything. This is the state in issue #1472: one mounted
|
|
||||||
// directory-bound row per worktree and session.
|
|
||||||
await evaluateValue(client, `localStorage.setItem("oc.sessions.projectCollapse", "[]")`)
|
|
||||||
console.log("Expanded every project in the sidebar.")
|
console.log("Expanded every project in the sidebar.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,18 +378,9 @@ const main = async () => {
|
|||||||
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
|
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
|
||||||
await wait(options.settle * 1000)
|
await wait(options.settle * 1000)
|
||||||
|
|
||||||
if (options.thenTab) {
|
if (options.expandSessions) {
|
||||||
// Route changes normally come from clicks; driving history directly
|
const expanded = await expandSessionLists(client)
|
||||||
// reaches the same router path without generating input work inside the
|
console.log(`Expanded ${expanded} collapsed session lists; settling ${options.settle}s again.`)
|
||||||
// recorded window, and leaves already-mounted surfaces mounted.
|
|
||||||
await evaluateValue(client, `(() => {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.searchParams.set("tab", ${JSON.stringify(options.thenTab)})
|
|
||||||
window.history.pushState({}, "", url.toString())
|
|
||||||
window.dispatchEvent(new PopStateEvent("popstate", { state: {} }))
|
|
||||||
return true
|
|
||||||
})()`)
|
|
||||||
console.log(`Navigated to tab "${options.thenTab}" without reloading; settling ${options.settle}s again.`)
|
|
||||||
await wait(options.settle * 1000)
|
await wait(options.settle * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort,
|
|||||||
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
|
||||||
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
|
||||||
import { growthPerSecond, metricMap, round, summarizeLongTasks, summarizeTraceEvents } from "./perf/metrics.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 repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
const cliPath = join(repoRoot, "packages/web/bin/cli.js")
|
const cliPath = join(repoRoot, "packages/web/bin/cli.js")
|
||||||
@@ -47,6 +48,9 @@ Options:
|
|||||||
--port <port> OpenChamber CLI port (default: from --url)
|
--port <port> OpenChamber CLI port (default: from --url)
|
||||||
--dir <path> Session directory (default: repository root)
|
--dir <path> Session directory (default: repository root)
|
||||||
--session <id> Reuse this session instead of creating one
|
--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
|
--view-session <id> Display this session while the prompt streams into
|
||||||
another one. Measures what an idle session costs
|
another one. Measures what an idle session costs
|
||||||
while a different session is active in the
|
while a different session is active in the
|
||||||
@@ -79,6 +83,8 @@ const parseArgs = (argv) => {
|
|||||||
dir: repoRoot,
|
dir: repoRoot,
|
||||||
session: null,
|
session: null,
|
||||||
viewSession: null,
|
viewSession: null,
|
||||||
|
expandProjects: false,
|
||||||
|
expandSessions: false,
|
||||||
prompt: DEFAULT_PROMPT,
|
prompt: DEFAULT_PROMPT,
|
||||||
model: null,
|
model: null,
|
||||||
agent: null,
|
agent: null,
|
||||||
@@ -108,6 +114,8 @@ const parseArgs = (argv) => {
|
|||||||
else if (value === "--dir") options.dir = argv[++index]
|
else if (value === "--dir") options.dir = argv[++index]
|
||||||
else if (value === "--session") options.session = argv[++index]
|
else if (value === "--session") options.session = argv[++index]
|
||||||
else if (value === "--view-session") options.viewSession = 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 === "--prompt") options.prompt = argv[++index]
|
||||||
else if (value === "--model") options.model = argv[++index]
|
else if (value === "--model") options.model = argv[++index]
|
||||||
else if (value === "--agent") options.agent = argv[++index]
|
else if (value === "--agent") options.agent = argv[++index]
|
||||||
@@ -364,7 +372,11 @@ const main = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const traceEvents = []
|
const traceEvents = []
|
||||||
const unsubscribeTrace = client.on("Tracing.dataCollected", ({ value }) => traceEvents.push(...(value ?? [])))
|
// 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)
|
let loaded = client.once("Page.loadEventFired", 60_000)
|
||||||
await client.send("Page.navigate", { url: target.toString() })
|
await client.send("Page.navigate", { url: target.toString() })
|
||||||
@@ -375,6 +387,10 @@ const main = async () => {
|
|||||||
localStorage.setItem("openchamber_sync_perf", "1")
|
localStorage.setItem("openchamber_sync_perf", "1")
|
||||||
localStorage.setItem("openchamber_stream_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)
|
loaded = client.once("Page.loadEventFired", 60_000)
|
||||||
await client.send("Page.reload", { ignoreCache: false })
|
await client.send("Page.reload", { ignoreCache: false })
|
||||||
await loaded
|
await loaded
|
||||||
@@ -382,6 +398,12 @@ const main = async () => {
|
|||||||
console.log(`Opened the session; settling for ${options.settle}s.`)
|
console.log(`Opened the session; settling for ${options.settle}s.`)
|
||||||
await wait(options.settle * 1000)
|
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, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.start()`)
|
||||||
await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`)
|
await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`)
|
||||||
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
|
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
|
||||||
@@ -553,7 +575,7 @@ const main = async () => {
|
|||||||
listenerGrowth: delta("JSEventListeners"),
|
listenerGrowth: delta("JSEventListeners"),
|
||||||
heapStartMb: round(heapSamples.at(0) ?? 0),
|
heapStartMb: round(heapSamples.at(0) ?? 0),
|
||||||
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
heapEndMb: round(heapSamples.at(-1) ?? 0),
|
||||||
heapMaxMb: round(Math.max(0, ...heapSamples)),
|
heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
|
||||||
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
|
||||||
},
|
},
|
||||||
frameLiveness,
|
frameLiveness,
|
||||||
|
|||||||
Reference in New Issue
Block a user