From 75ccae13be63fdc102ce373629910f0b364ec1c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 22:55:16 +0200 Subject: [PATCH] perf(app): add explorer toggle profiling harness --- docs/development.md | 13 + packages/app/package.json | 1 + .../app/scripts/profile-explorer-toggle.mjs | 538 ++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 packages/app/scripts/profile-explorer-toggle.mjs diff --git a/docs/development.md b/docs/development.md index 01b51cf77..ad584aa61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -242,6 +242,19 @@ focused invalidation capture; React Native's generated stacks make that mode hig Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis. +For the desktop explorer toggle, run the app against the root checkout's daemon and use: + +```bash +npm run profile:explorer-toggle --workspace=@getpaseo/app +``` + +The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane, +records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports +input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set +`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or +`PASEO_PROFILE_CPU_PATH=/tmp/explorer-toggle.cpuprofile` for separate Chromium captures. Override +the app URL, daemon port, workspace, or server with the corresponding `PASEO_PROFILE_*` variables. + For sustained composer typing, run the paired composer-versus-textarea benchmark against a seeded daemon: diff --git a/packages/app/package.json b/packages/app/package.json index 0d667a301..5b547fad9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,7 @@ "build": "npm run build:web", "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", "profile:composer-typing": "node ./scripts/profile-composer-typing.mjs", + "profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs", "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", diff --git a/packages/app/scripts/profile-explorer-toggle.mjs b/packages/app/scripts/profile-explorer-toggle.mjs new file mode 100644 index 000000000..ec86e8cc7 --- /dev/null +++ b/packages/app/scripts/profile-explorer-toggle.mjs @@ -0,0 +1,538 @@ +import { connect } from "node:net"; +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const appUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081"; +const daemonPort = Number(process.env.PASEO_PROFILE_DAEMON_PORT ?? 6768); +const workspaceCwd = process.env.PASEO_PROFILE_WORKSPACE_CWD ?? repoRoot; +const workspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID ?? resolvePaseoWorkspaceId(); +const serverId = + process.env.PASEO_PROFILE_SERVER_ID ?? + (await readFile(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")).trim(); +const warmPresses = numberFromEnv("PASEO_PROFILE_WARM_PRESSES", 8); +const measuredPresses = numberFromEnv("PASEO_PROFILE_MEASURED_PRESSES", 20); +const burstPresses = numberFromEnv("PASEO_PROFILE_BURST_PRESSES", 20); +const burstCadenceMs = numberFromEnv("PASEO_PROFILE_BURST_CADENCE_MS", 50); +const idleBaselineMs = numberFromEnv("PASEO_PROFILE_IDLE_MS", 2_000); +const tracePath = process.env.PASEO_PROFILE_TRACE_PATH?.trim() || null; +const cpuProfilePath = process.env.PASEO_PROFILE_CPU_PATH?.trim() || null; +const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1"; + +function numberFromEnv(name, fallback) { + const value = Number(process.env[name] ?? fallback); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${name} must be a non-negative number`); + } + return value; +} + +function resolvePaseoWorkspaceId() { + const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, FORCE_COLOR: "0" }, + }); + const jsonStart = output.indexOf("[\n"); + if (jsonStart < 0) throw new Error("Could not parse `paseo workspace ls --json`"); + const workspaces = JSON.parse(output.slice(jsonStart)); + const candidates = workspaces.filter((workspace) => workspace.cwd === workspaceCwd); + const workspace = candidates.find((candidate) => candidate.name === "Paseo") ?? candidates[0]; + if (!workspace?.workspaceId) { + throw new Error(`No active workspace found for ${workspaceCwd}`); + } + return workspace.workspaceId; +} + +function round(value) { + return Math.round(value * 100) / 100; +} + +function percentile(values, fraction) { + if (values.length === 0) return null; + const sorted = [...values].sort((left, right) => left - right); + return round(sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]); +} + +function summarizeDurations(values) { + return { + count: values.length, + minMs: values.length > 0 ? round(Math.min(...values)) : null, + medianMs: percentile(values, 0.5), + p95Ms: percentile(values, 0.95), + maxMs: values.length > 0 ? round(Math.max(...values)) : null, + }; +} + +async function assertPortReachable(port) { + await new Promise((resolveConnection, rejectConnection) => { + const socket = connect({ host: "127.0.0.1", port }); + const timeout = setTimeout(() => { + socket.destroy(); + rejectConnection(new Error(`Timed out connecting to daemon on 127.0.0.1:${port}`)); + }, 2_000); + socket.once("connect", () => { + clearTimeout(timeout); + socket.destroy(); + resolveConnection(); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + rejectConnection(error); + }); + }); +} + +async function installMeasurementProbe(page) { + await page.evaluate(() => { + const readExplorerState = () => { + const tab = document.querySelector('[data-testid="workspace-tab-working_diff"]'); + const group = tab?.closest('[data-testid^="split-group-child"]'); + const testId = group?.getAttribute("data-testid"); + if (testId === "split-group-child-hidden") return "hidden"; + if (testId === "split-group-child") return "visible"; + return "absent"; + }; + const state = { + events: [], + domMutations: 0, + addedNodes: 0, + removedNodes: 0, + explorerMounts: 0, + explorerUnmounts: 0, + explorerMounted: readExplorerState() !== "absent", + }; + const settleLatestEvent = () => { + const event = state.events.findLast((candidate) => candidate.paintTime === null); + if (!event) return; + const after = readExplorerState(); + if (after === event.before) return; + event.after = after; + event.mutationTime ??= performance.now(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + event.paintTime ??= performance.now(); + }); + }); + }; + const observer = new MutationObserver((records) => { + state.domMutations += records.length; + for (const record of records) { + state.addedNodes += record.addedNodes.length; + state.removedNodes += record.removedNodes.length; + } + const explorerMounted = readExplorerState() !== "absent"; + if (!state.explorerMounted && explorerMounted) state.explorerMounts += 1; + if (state.explorerMounted && !explorerMounted) state.explorerUnmounts += 1; + state.explorerMounted = explorerMounted; + settleLatestEvent(); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["data-testid", "style"], + childList: true, + subtree: true, + }); + const onKeyDown = (event) => { + if (!event.metaKey || event.code !== "KeyE") return; + const sequence = state.events.length; + performance.mark(`paseo:explorer-toggle:keydown:${sequence}`); + console.timeStamp(`paseo:explorer-toggle:keydown:${sequence}`); + state.events.push({ + sequence, + inputTime: event.timeStamp, + before: globalThis.__PASEO_EXPLORER_TOGGLE_PENDING_BEFORE__ ?? readExplorerState(), + after: null, + mutationTime: null, + paintTime: null, + }); + globalThis.__PASEO_EXPLORER_TOGGLE_PENDING_BEFORE__ = undefined; + }; + addEventListener("keydown", onKeyDown, true); + globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__ = state; + globalThis.__PASEO_EXPLORER_TOGGLE_STATE__ = readExplorerState; + globalThis.__PASEO_EXPLORER_TOGGLE_RESET__ = () => { + state.events.length = 0; + state.domMutations = 0; + state.addedNodes = 0; + state.removedNodes = 0; + state.explorerMounts = 0; + state.explorerUnmounts = 0; + state.explorerMounted = readExplorerState() !== "absent"; + globalThis.__PASEO_RESET_RENDER_PROFILE__?.(); + }; + }); +} + +async function explorerState(page) { + return page.evaluate(() => globalThis.__PASEO_EXPLORER_TOGGLE_STATE__?.() ?? "absent"); +} + +async function waitForProfilerIdle(page, quietMs = 500) { + let previousCount = -1; + let unchangedSince = Date.now(); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0); + if (count !== previousCount) { + previousCount = count; + unchangedSince = Date.now(); + } else if (Date.now() - unchangedSince >= quietMs) { + return; + } + await page.waitForTimeout(50); + } + throw new Error("React profiler did not become idle"); +} + +async function pressAndWaitForPaint(page) { + const eventIndex = await page.evaluate( + () => globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__?.events.length ?? 0, + ); + const before = await explorerState(page); + await page.evaluate((state) => { + globalThis.__PASEO_EXPLORER_TOGGLE_PENDING_BEFORE__ = state; + }, before); + await page.keyboard.press("Meta+e"); + try { + await page.waitForFunction( + (index) => { + const event = globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__?.events[index]; + return event !== undefined && typeof event.paintTime === "number"; + }, + eventIndex, + { timeout: 15_000 }, + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + state: globalThis.__PASEO_EXPLORER_TOGGLE_STATE__?.(), + measurements: globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__, + })); + throw new Error(`Cmd+E did not paint: ${JSON.stringify(diagnostic)}`, { cause: error }); + } +} + +async function pressBurst(page, count, cadenceMs) { + const initialState = await explorerState(page); + let expectedBefore = initialState; + await page.keyboard.down("Meta"); + try { + for (let index = 0; index < count; index += 1) { + await page.evaluate((state) => { + globalThis.__PASEO_EXPLORER_TOGGLE_PENDING_BEFORE__ = state; + }, expectedBefore); + await page.keyboard.press("e"); + expectedBefore = expectedBefore === "visible" ? "hidden" : "visible"; + if (index + 1 < count) await page.waitForTimeout(cadenceMs); + } + } finally { + await page.keyboard.up("Meta"); + } + await page.waitForFunction( + (expectedCount) => { + const events = globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__?.events ?? []; + return ( + events.length === expectedCount && + events.every((event) => typeof event.paintTime === "number") + ); + }, + count, + { timeout: 15_000 }, + ); + const finalState = await explorerState(page); + let expectedFinalState = initialState; + if (count % 2 !== 0) { + expectedFinalState = initialState === "visible" ? "hidden" : "visible"; + } + if (finalState !== expectedFinalState) { + throw new Error( + `Cmd+E burst lost an input: expected ${expectedFinalState}, observed ${finalState}`, + ); + } +} + +function groupCommits(samples) { + const commits = new Map(); + for (const sample of samples) { + const commit = commits.get(sample.commitTime) ?? { + commitTime: sample.commitTime, + rootActualDuration: null, + components: [], + }; + if (sample.id === "WorkspaceDeck") commit.rootActualDuration = sample.actualDuration; + commit.components.push({ + id: sample.id, + phase: sample.phase, + actualDuration: round(sample.actualDuration), + baseDuration: round(sample.baseDuration), + }); + commits.set(sample.commitTime, commit); + } + return [...commits.values()] + .sort((left, right) => left.commitTime - right.commitTime) + .map((commit) => { + commit.commitTime = round(commit.commitTime); + commit.rootActualDuration = + commit.rootActualDuration === null ? null : round(commit.rootActualDuration); + commit.components.sort((left, right) => right.actualDuration - left.actualDuration); + return commit; + }); +} + +function summarizeComponents(samples) { + const components = new Map(); + for (const sample of samples) { + const current = components.get(sample.id) ?? { + renders: 0, + totalActualDuration: 0, + durations: [], + }; + current.renders += 1; + current.totalActualDuration += sample.actualDuration; + current.durations.push(sample.actualDuration); + components.set(sample.id, current); + } + return [...components.entries()] + .map(([id, value]) => ({ + id, + renders: value.renders, + totalActualDurationMs: round(value.totalActualDuration), + medianActualDurationMs: percentile(value.durations, 0.5), + p95ActualDurationMs: percentile(value.durations, 0.95), + })) + .sort((left, right) => right.totalActualDurationMs - left.totalActualDurationMs); +} + +async function readScenario(page, name) { + const result = await page.evaluate(() => ({ + measurements: globalThis.__PASEO_EXPLORER_TOGGLE_PROFILE__, + samples: globalThis.__PASEO_RENDER_PROFILE__ ?? [], + reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}, + })); + const events = result.measurements.events; + const inputToMutation = events.map((event) => event.mutationTime - event.inputTime); + const inputToPaint = events.map((event) => event.paintTime - event.inputTime); + const commits = groupCommits(result.samples); + return { + name, + inputCount: events.length, + transitions: events.map((event) => `${event.before}->${event.after}`), + inputToMutation: summarizeDurations(inputToMutation), + inputToPaint: summarizeDurations(inputToPaint), + react: { + commitCount: commits.length, + rootCommitDuration: summarizeDurations( + commits.flatMap((commit) => + commit.rootActualDuration === null ? [] : [commit.rootActualDuration], + ), + ), + components: summarizeComponents(result.samples), + reasons: result.reasons, + commits: dumpCommits ? commits : undefined, + }, + dom: { + mutationRecords: result.measurements.domMutations, + addedNodes: result.measurements.addedNodes, + removedNodes: result.measurements.removedNodes, + explorerMounts: result.measurements.explorerMounts, + explorerUnmounts: result.measurements.explorerUnmounts, + }, + }; +} + +async function resetMeasurements(page) { + await page.evaluate(() => globalThis.__PASEO_EXPLORER_TOGGLE_RESET__?.()); +} + +async function runSettledScenario(page, name, count) { + await resetMeasurements(page); + for (let index = 0; index < count; index += 1) await pressAndWaitForPaint(page); + return readScenario(page, name); +} + +async function runIdleScenario(page) { + await resetMeasurements(page); + await page.waitForTimeout(idleBaselineMs); + return readScenario(page, "idle-baseline"); +} + +async function runBurstScenario(page, name, count) { + await resetMeasurements(page); + await pressBurst(page, count, burstCadenceMs); + return readScenario(page, name); +} + +async function captureCpuProfile(page, run) { + const session = await page.context().newCDPSession(page); + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 100 }); + await session.send("Profiler.start"); + try { + return await run(); + } finally { + const { profile } = await session.send("Profiler.stop"); + await session.send("Profiler.disable"); + await writeFile(cpuProfilePath, JSON.stringify(profile)); + } +} + +async function collectMemory(session, page) { + await session.send("HeapProfiler.collectGarbage"); + const [heap, performanceMetrics, explorerTabs] = await Promise.all([ + session.send("Runtime.getHeapUsage"), + session.send("Performance.getMetrics"), + page.locator('[data-testid="workspace-tab-working_diff"]').count(), + ]); + const metrics = Object.fromEntries( + performanceMetrics.metrics + .filter((metric) => ["JSHeapUsedSize", "JSHeapTotalSize", "Nodes"].includes(metric.name)) + .map((metric) => [metric.name, round(metric.value)]), + ); + return { + usedSize: heap.usedSize, + totalSize: heap.totalSize, + embedderHeapUsedSize: heap.embedderHeapUsedSize, + backingStorageSize: heap.backingStorageSize, + domNodes: metrics.Nodes ?? null, + explorerTabs, + metrics, + }; +} + +async function readCdpStream(session, handle) { + const chunks = []; + while (true) { + const result = await session.send("IO.read", { handle }); + chunks.push( + result.base64Encoded ? Buffer.from(result.data, "base64") : Buffer.from(result.data), + ); + if (result.eof) break; + } + await session.send("IO.close", { handle }); + return Buffer.concat(chunks); +} + +async function captureDevToolsTrace(page, run) { + const session = await page.context().newCDPSession(page); + const tracingComplete = new Promise((resolveTrace) => { + session.once("Tracing.tracingComplete", resolveTrace); + }); + await session.send("Tracing.start", { + categories: [ + "-*", + "toplevel", + "devtools.timeline", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "disabled-by-default-v8.cpu_profiler", + "disabled-by-default-v8.cpu_profiler.hires", + ].join(","), + options: "sampling-frequency=10000", + transferMode: "ReturnAsStream", + }); + let result; + try { + result = await run(); + } finally { + await session.send("Tracing.end"); + const completed = await tracingComplete; + await writeFile(tracePath, await readCdpStream(session, completed.stream)); + } + return result; +} + +async function main() { + await assertPortReachable(daemonPort); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + try { + await page.addInitScript(() => { + const preserveRenderProfile = (original) => + function preserveProfileParam(state, unused, url) { + if (url === undefined || url === null) { + return Reflect.apply(original, this, [state, unused, url]); + } + const nextUrl = new URL(String(url), location.href); + nextUrl.searchParams.set("renderProfile", "1"); + return Reflect.apply(original, this, [ + state, + unused, + `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`, + ]); + }; + history.pushState = preserveRenderProfile(history.pushState); + history.replaceState = preserveRenderProfile(history.replaceState); + }); + await page.goto(`${appUrl}/?renderProfile=1`, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + const workspaceRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await workspaceRow.waitFor({ timeout: 60_000 }); + await workspaceRow.click(); + await page.getByTestId("workspace-tabs-row").first().waitFor({ timeout: 60_000 }); + await waitForProfilerIdle(page); + await installMeasurementProbe(page); + + if ((await explorerState(page)) === "absent") { + await pressAndWaitForPaint(page); + await waitForProfilerIdle(page); + } + for (let index = 0; index < warmPresses; index += 1) await pressAndWaitForPaint(page); + + const memorySession = await page.context().newCDPSession(page); + await memorySession.send("Performance.enable"); + const memoryBefore = await collectMemory(memorySession, page); + const idle = await runIdleScenario(page); + const settled = await runSettledScenario(page, "retained-settled", measuredPresses); + const burst = await runBurstScenario(page, "retained-burst", burstPresses); + const memoryAfter = await collectMemory(memorySession, page); + await memorySession.send("Performance.disable"); + + let cpu = null; + if (cpuProfilePath) { + cpu = await captureCpuProfile(page, () => + runSettledScenario(page, "retained-settled-cpu", measuredPresses), + ); + } + let trace = null; + if (tracePath) { + trace = await captureDevToolsTrace(page, () => + runSettledScenario(page, "retained-settled-trace", measuredPresses), + ); + } + + console.log( + JSON.stringify( + { + appUrl, + daemon: `127.0.0.1:${daemonPort}`, + serverId, + workspaceId, + workspaceCwd, + warmPresses, + measuredPresses, + burstPresses, + burstCadenceMs, + idleBaselineMs, + cpuProfilePath, + tracePath, + memory: { before: memoryBefore, after: memoryAfter }, + scenarios: [idle, settled, burst], + profileScenarios: { cpu, trace }, + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + } +} + +await main();