Files
goodbuddy/tests/support/app-perf-driver.mjs
T
mesalogo 663aa85e1a perf: window the sidebar, cache Markdown trees and window chat scrolling once per frame
The chat pane decides re-windowing once per frame from scrollTop and cached row heights instead of reading layout per scroll event. Rendered Markdown hast trees are cached, so remounted rows skip parsing. Sidebar lists of 60+ conversations mount only rows near the viewport. The perf driver scrolls the windowed sidebar to reach a row.
2026-10-03 05:53:10 +08:00

637 lines
34 KiB
JavaScript

// Full-App interaction benchmark driver (PERF-11). Launched by
// build/run-app-perf.cjs. Runs inside the Electron main process: isolates the
// profile, imports the current production Main, then drives the real window
// with native input events and records renderer, Main and process metrics.
import { app } from 'electron'
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { cpus, totalmem } from 'node:os'
import { join } from 'node:path'
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import console from 'node:console'
import process from 'node:process'
import { clearTimeout, setTimeout } from 'node:timers'
import { setTimeout as sleep } from 'node:timers/promises'
import { pathToFileURL } from 'node:url'
const directory = process.env.GB_PERF_DIRECTORY
const artifacts = process.env.GB_PERF_ARTIFACTS
const root = process.env.GB_PERF_ROOT
if (!directory || !artifacts || !root) throw new Error('Run through build/run-app-perf.cjs')
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
const config = {
seedConversations: Number(process.env.GB_PERF_SEED_CONVERSATIONS || 300),
seedMessagesPerConversation: Number(process.env.GB_PERF_SEED_MESSAGES || 20),
longConversationMessages: Number(process.env.GB_PERF_LONG_MESSAGES || 2_000),
typedCharacters: Number(process.env.GB_PERF_TYPED_CHARACTERS || 120),
keyIntervalMs: Number(process.env.GB_PERF_KEY_INTERVAL_MS || 60),
switches: Number(process.env.GB_PERF_SWITCHES || 15),
memoryVisits: Number(process.env.GB_PERF_MEMORY_VISITS || 24),
// Chromium CPU throttling of the renderer only (Main is not slowed down).
cpuThrottle: Number(process.env.GB_PERF_CPU_THROTTLE || 1),
contentWidth: 1280,
contentHeight: 800
}
// Isolation must happen before the production Main module loads: it takes
// the single-instance lock and opens storage from userData at import time.
app.setAppPath(root)
app.setName(packageJson.name)
app.setVersion(packageJson.version)
app.setPath('userData', join(directory, 'profile'))
app.setPath('sessionData', join(directory, 'session'))
app.setAppLogsPath(join(directory, 'logs'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.commandLine.appendSwitch('enable-precise-memory-info')
mkdirSync(artifacts, { recursive: true })
const driverStart = performance.now()
const report = {
schemaVersion: 1,
kind: 'goodbuddy-full-app-benchmark',
status: 'running',
source: sourceInfo(),
environment: null,
config,
startup: {},
scenarios: [],
rendererErrors: [],
pending: 'startup'
}
const errors = report.rendererErrors
const mainDelay = monitorEventLoopDelay({ resolution: 10 })
mainDelay.enable()
let win
function sourceInfo() {
try {
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().split('\n').filter(Boolean).length
return { commit, dirty: dirty > 0, dirtyFiles: dirty, version: packageJson.version }
} catch {
return { commit: null, dirty: null, version: packageJson.version }
}
}
function writeReport() {
writeFileSync(join(artifacts, 'result.json'), JSON.stringify(report, null, 2))
}
const deadline = setTimeout(() => {
report.status = 'failed'
report.error = `Driver timeout during ${report.pending}`
writeReport()
app.exit(1)
}, Number(process.env.GB_PERF_DRIVER_TIMEOUT_MS || 540_000))
app.on('browser-window-created', (_event, created) => {
if (win) return
win = created
report.startup.windowCreatedMs = performance.now() - driverStart
created.once('ready-to-show', () => { report.startup.readyToShowMs = performance.now() - driverStart })
created.webContents.on('console-message', event => {
if (event.level !== 'error') return
const text = String(event.message)
// CSP has no font-src, so KaTeX data: fonts are refused. Count, don't list.
if (text.startsWith("Loading the font 'data:font/")) { report.cspBlockedDataFonts = (report.cspBlockedDataFonts ?? 0) + 1; return }
errors.push(text.slice(0, 500))
})
created.webContents.on('render-process-gone', (_e, details) => errors.push(`render-process-gone ${JSON.stringify(details)}`))
})
await import(pathToFileURL(join(root, 'out', 'main', 'index.js')).href)
// ---------------------------------------------------------------- helpers
const js = code => win.webContents.executeJavaScript(code, true)
async function waitFor(code, timeoutMs = 30_000, label = code) {
const started = performance.now()
while (performance.now() - started < timeoutMs) {
try { if (await js(code)) return performance.now() - started } catch { /* page may be reloading */ }
await sleep(20)
}
throw new Error(`Timeout waiting for ${label}`)
}
const settle = () => js('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))')
async function focusWindow() {
win.show()
win.focus()
win.webContents.focus()
await waitFor('document.hasFocus()', 5_000, 'window focus')
}
async function clickSelector(selector, { scroll = true } = {}) {
await focusWindow()
const point = await js(`(() => {
const e = document.querySelector(${JSON.stringify(selector)});
if (!e) return null;
${scroll ? "e.scrollIntoView({ block: 'center' });" : ''}
const r = e.getBoundingClientRect();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
})()`)
if (!point) throw new Error(`Missing element ${selector}`)
if (!point.hit) throw new Error(`Element occluded ${selector}`)
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
}
async function typeText(text) {
for (const character of text) {
const keyCode = character === ' ' ? 'Space' : character
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'char', keyCode: character })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await sleep(config.keyIntervalMs)
}
}
function percentile(values, p) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
}
function summarize(values) {
if (!values.length) return null
return { count: values.length, p50: percentile(values, 50), p95: percentile(values, 95), max: Math.max(...values) }
}
// Installed into the renderer after every load. It only observes; it does not
// change application state.
const probeSource = `(() => {
if (window.__gbPerf) return true;
const s = { recording: false, startedAt: 0, frames: [], lastFrame: 0, longTasks: [], loafs: [], events: [], latencies: [], mutations: 0, pointerDown: 0 };
const frame = t => { if (s.recording && s.lastFrame) s.frames.push(t - s.lastFrame); s.lastFrame = t; requestAnimationFrame(frame); };
requestAnimationFrame(frame);
const observe = (type, sink, options = {}) => { try { new PerformanceObserver(list => { if (s.recording) for (const e of list.getEntries()) sink(e); }).observe({ type, ...options }); } catch {} };
observe('longtask', e => s.longTasks.push({ at: e.startTime - s.startedAt, duration: e.duration }));
// Split each long frame into script time and rendering (style + layout + paint) time.
observe('long-animation-frame', e => s.loafs.push({ duration: e.duration, blocking: e.blockingDuration,
scriptMs: e.scripts.reduce((a, x) => a + x.duration, 0),
renderMs: e.renderStart ? e.startTime + e.duration - e.renderStart : 0,
styleLayoutMs: e.styleAndLayoutStart ? e.startTime + e.duration - e.styleAndLayoutStart : 0,
forcedLayoutMs: e.scripts.reduce((a, x) => a + (x.forcedStyleAndLayoutDuration || 0), 0) }));
observe('event', e => { if (e.name === 'keydown' || e.name === 'keypress' || e.name === 'input' || e.name === 'pointerdown' || e.name === 'click' || e.name === 'mousedown') s.events.push({ name: e.name, duration: e.duration }); }, { durationThreshold: 16 });
document.addEventListener('keydown', e => { if (!s.recording) return; const t = e.timeStamp; requestAnimationFrame(() => s.latencies.push(performance.now() - t)); }, true);
document.addEventListener('mousedown', e => { s.pointerDown = e.timeStamp; }, true);
new MutationObserver(() => { if (s.recording) s.mutations += 1; }).observe(document.documentElement, { subtree: true, childList: true, characterData: true });
window.__gbPerf = {
start() { Object.assign(s, { recording: true, startedAt: performance.now(), frames: [], lastFrame: 0, longTasks: [], loafs: [], events: [], latencies: [], mutations: 0 }); return true; },
stop() {
s.recording = false;
return { elapsed: performance.now() - s.startedAt, frames: s.frames, longTasks: s.longTasks, loafs: s.loafs, events: s.events, latencies: s.latencies, mutations: s.mutations,
domNodes: document.getElementsByTagName('*').length, heapUsedMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null };
},
// Resolves with the time from the last native mousedown until the given
// conversation is the active pane with rendered messages, checked at frame
// boundaries. (CSP forbids eval, so the condition is fixed here.)
paneShownSinceMouseDown(conversationId) {
return new Promise((resolve, reject) => {
const started = performance.now();
const check = () => {
const pane = document.querySelector('.chat-history-pane[data-active=true]');
if (pane && pane.dataset.conversationId === conversationId && pane.querySelector('article.message')) resolve(performance.now() - s.pointerDown);
else if (performance.now() - started > 30000) reject(new Error('timeout pane ' + conversationId));
else requestAnimationFrame(check);
};
check();
});
}
};
return true;
})()`
function processSnapshot() {
const byType = {}
for (const metric of app.getAppMetrics()) {
const key = metric.type
const entry = byType[key] ?? (byType[key] = { cpu: 0, workingSetMB: 0, count: 0 })
entry.cpu += metric.cpu.percentCPUUsage
entry.workingSetMB += metric.memory.workingSetSize / 1024
entry.count += 1
}
return byType
}
let frameBaseline = null
async function measure(id, description, body) {
report.pending = id
writeReport()
await js(probeSource)
processSnapshot() // resets per-process CPU accounting
mainDelay.reset()
await js('window.__gbPerf.start()')
const started = performance.now()
const extra = (await body()) ?? {}
const durationMs = performance.now() - started
const raw = await js('window.__gbPerf.stop()')
const processes = processSnapshot()
const frames = raw.frames
const baseline = frameBaseline ?? percentile(frames, 50) ?? 16.7
const longTaskDurations = raw.longTasks.map(task => task.duration)
const longTaskTotal = longTaskDurations.reduce((a, b) => a + b, 0)
// Long-task time per quarter of the recorded window shows whether cost grows over the scenario.
const quarters = [0, 0, 0, 0]
for (const task of raw.longTasks) quarters[Math.min(3, Math.max(0, Math.floor((task.at / raw.elapsed) * 4)))] += task.duration
const scenario = {
id,
description,
durationMs,
renderer: {
longTasks: { count: raw.longTasks.length, totalMs: longTaskTotal, maxMs: longTaskDurations.length ? Math.max(...longTaskDurations) : 0, shareOfDuration: longTaskTotal / raw.elapsed, msByQuarter: quarters.map(Math.round) },
longAnimationFrames: { count: raw.loafs.length, blockingMs: raw.loafs.reduce((a, b) => a + b.blocking, 0), maxMs: raw.loafs.length ? Math.max(...raw.loafs.map(l => l.duration)) : 0,
scriptMs: Math.round(raw.loafs.reduce((a, b) => a + b.scriptMs, 0)),
forcedLayoutMs: Math.round(raw.loafs.reduce((a, b) => a + b.forcedLayoutMs, 0)),
styleLayoutMs: Math.round(raw.loafs.reduce((a, b) => a + b.styleLayoutMs, 0)),
renderMs: Math.round(raw.loafs.reduce((a, b) => a + b.renderMs, 0)) },
frames: { count: frames.length, ...summarize(frames), baselineMs: baseline, jankRatio: frames.length ? frames.filter(f => f > baseline * 1.5).length / frames.length : null },
slowEvents: { countOver16ms: raw.events.length, maxMs: raw.events.length ? Math.max(...raw.events.map(e => e.duration)) : 0, byName: raw.events.reduce((acc, e) => ({ ...acc, [e.name]: (acc[e.name] ?? 0) + 1 }), {}) },
domMutationBatches: raw.mutations,
domNodes: raw.domNodes,
heapUsedMB: raw.heapUsedMB
},
latency: raw.latencies.length ? summarize(raw.latencies) : extra.latency ?? null,
main: {
eventLoopDelay: {
p50: mainDelay.percentile(50) / 1e6,
p99: mainDelay.percentile(99) / 1e6,
max: mainDelay.max / 1e6,
mean: mainDelay.mean / 1e6
}
},
processes: {
cpu: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, value.cpu])),
workingSetMB: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, Math.round(value.workingSetMB)]))
},
...extra.fields
}
delete scenario.latencyExtra
report.scenarios.push(scenario)
writeReport()
console.log(`[perf] ${id}: ${Math.round(durationMs)} ms, long tasks ${raw.longTasks.length} (${Math.round(longTaskTotal)} ms), frame p95 ${scenario.renderer.frames.p95?.toFixed(1)} ms, main lag max ${scenario.main.eventLoopDelay.max.toFixed(1)} ms`)
return scenario
}
// Optional renderer CPU profile (GB_PERF_PROFILE=1). Profiling adds overhead,
// so profiled runs are for attribution only, not for baseline numbers.
const profiling = process.env.GB_PERF_PROFILE === '1'
function cdp() {
const debuggerApi = win.webContents.debugger
if (!debuggerApi.isAttached()) debuggerApi.attach('1.3')
return debuggerApi
}
// Throttling is per page session, so it is reapplied after every reload.
async function applyCpuThrottle() {
if (config.cpuThrottle > 1) await cdp().sendCommand('Emulation.setCPUThrottlingRate', { rate: config.cpuThrottle })
}
// Heap after a full GC, so retained memory is not hidden by GC timing.
async function retainedHeapMB() {
await cdp().sendCommand('HeapProfiler.collectGarbage')
const { usedSize } = await cdp().sendCommand('Runtime.getHeapUsage')
return usedSize / 1048576
}
async function withRendererProfile(name, body) {
if (!profiling) return body()
const debuggerApi = cdp()
await debuggerApi.sendCommand('Profiler.enable')
await debuggerApi.sendCommand('Profiler.setSamplingInterval', { interval: 200 })
await debuggerApi.sendCommand('Profiler.start')
try {
return await body()
} finally {
const { profile } = await debuggerApi.sendCommand('Profiler.stop')
writeFileSync(join(artifacts, `${name}.cpuprofile`), JSON.stringify(profile))
report.profiles = [...(report.profiles ?? []), { scenario: name, hotspots: profileHotspots(profile) }]
}
}
// Self time per function and per script, without source text.
function profileHotspots(profile) {
const byId = new Map(profile.nodes.map(node => [node.id, node]))
const self = new Map()
const deltas = profile.timeDeltas
for (let index = 0; index < profile.samples.length; index += 1) {
const id = profile.samples[index]
self.set(id, (self.get(id) ?? 0) + (deltas[index] ?? 0))
}
const functions = new Map()
const scripts = new Map()
let total = 0
for (const [id, micros] of self) {
const frame = byId.get(id).callFrame
total += micros
const file = frame.url ? frame.url.split('/').pop() : `(${frame.functionName || 'native'})`
const key = `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}`
functions.set(key, (functions.get(key) ?? 0) + micros)
scripts.set(file, (scripts.get(file) ?? 0) + micros)
}
const top = (map, count) => [...map].sort((a, b) => b[1] - a[1]).slice(0, count).map(([key, micros]) => ({ key, ms: Math.round(micros / 1000), share: Math.round((micros / total) * 1000) / 10 }))
return { totalMs: Math.round(total / 1000), functions: top(functions, 30), scripts: top(scripts, 10) }
}
async function screenshot(name) {
writeFileSync(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG())
}
const composer = '.composer__input textarea'
const sendButton = '.composer__submit-actions button.send-button:not(.send-button--stop)'
const stopButton = 'button.send-button--stop'
async function dismissStartupDialogs({ expect = true } = {}) {
// The fresh-profile release notes dialog opens after an async IPC read.
if (expect) await waitFor("!!document.querySelector('section.release-notes-dialog')", 6_000, 'release notes').catch(() => {})
for (let attempt = 0; attempt < 3; attempt += 1) {
const open = await js("!!document.querySelector('section.release-notes-dialog')")
if (!open) return
await clickSelector('.release-notes-dialog__header button.icon-button', { scroll: false })
await waitFor("!document.querySelector('section.release-notes-dialog')", 5_000, 'release notes closed')
}
}
function message(role, index, createdAt) {
const content = role === 'user'
? `Question ${index}: how should the benchmark handle item ${index}?`
: `## Answer ${index}\n\nThe benchmark answer for item ${index} contains **formatting**, \`inline code\`, and a list:\n\n- point one\n- point two\n\n\`\`\`ts\nconst value${index} = ${index}\n\`\`\`\n\nInline math $x_${index} = ${index}^2$.`
return { id: randomUUID(), role, content, createdAt, state: 'complete' }
}
function conversation(projectId, title, messageCount, updatedAt) {
const messages = []
for (let index = 0; index < messageCount; index += 1) {
messages.push(message(index % 2 === 0 ? 'user' : 'assistant', index, updatedAt - (messageCount - index) * 1000))
}
return { header: { id: randomUUID(), projectId, title, updatedAt, knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, messages }
}
async function seed() {
const projectId = await js(`window.goodbuddy.projects.list(false).then(list => list.find(p => p.kind === 'user' && p.executionSpace.kind === 'local')?.id)`)
if (!projectId) throw new Error('No local project to seed')
const now = Date.now()
const items = []
for (let index = 0; index < config.seedConversations; index += 1) {
items.push(conversation(projectId, `Perf conversation ${String(index + 1).padStart(4, '0')}`, config.seedMessagesPerConversation, now - (index + 1) * 60_000))
}
const long = conversation(projectId, 'Perf long conversation', config.longConversationMessages, now - 30_000)
const started = performance.now()
for (let offset = 0; offset < items.length; offset += 100) {
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify(items.slice(offset, offset + 100))}).then(() => true)`)
}
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify([long])}).then(() => true)`)
return { seedMs: performance.now() - started, titles: items.map(item => item.header.title), longTitle: long.header.title, longId: long.header.id, ids: items.map(item => item.header.id) }
}
const rowSelectorByTitle = title => `[...document.querySelectorAll('button.conversation-item')].find(b => b.querySelector('.conversation-item__title')?.textContent.includes(${JSON.stringify(title)}))`
// The sidebar mounts only the rows near its viewport (PERF-14). Scroll it a
// screen at a time from the top, letting it re-window after each step, until
// the row is mounted; then centre it and wait for the re-window again.
async function revealConversation(title) {
const list = "document.querySelector('.conversation-list')"
const present = `!!(${rowSelectorByTitle(title)})`
await js(`(() => { const l = ${list}; if (l) l.scrollTop = 0; return true; })()`)
await settle()
for (let step = 0; step < 200; step += 1) {
await settle()
if (await js(present)) break
const moved = await js(`(() => { const l = ${list}; if (!l) return false; const before = l.scrollTop; l.scrollTop += Math.max(200, Math.round(l.clientHeight * 0.8)); return l.scrollTop !== before; })()`)
if (!moved) break
}
await js(`(() => { ${rowSelectorByTitle(title)}?.scrollIntoView({ block: 'center' }); return true; })()`)
}
async function clickConversation(title) {
await focusWindow()
await revealConversation(title)
let point
for (let attempt = 0; attempt < 10 && !point?.hit; attempt += 1) {
await settle()
point = await js(`(() => {
const e = ${rowSelectorByTitle(title)};
if (!e) return null;
const r = e.getBoundingClientRect();
const x = Math.round(r.x + Math.min(r.width / 2, 80)), y = Math.round(r.y + r.height / 2);
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
})()`)
}
if (!point?.hit) throw new Error(`Conversation row not clickable: ${title}`)
await settle()
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
}
async function streamOnce({ typeDuring = 0 } = {}) {
await clickSelector('button.new-chat', { scroll: false })
await waitFor(`!!document.querySelector(${JSON.stringify(composer)})`, 10_000, 'composer after new chat')
await clickSelector(composer, { scroll: false })
await typeText('perf-stream benchmark request')
await waitFor(`!!document.querySelector(${JSON.stringify(sendButton)}) && !document.querySelector(${JSON.stringify(sendButton)}).disabled`, 15_000, 'send enabled')
await clickSelector(sendButton, { scroll: false })
const sendAt = performance.now()
await waitFor(`!!document.querySelector(${JSON.stringify(stopButton)})`, 15_000, 'stream started')
const startedMs = performance.now() - sendAt
// Renderer samples cover only the streaming period, not prompt typing.
await js('window.__gbPerf.start()')
let typedDuringStream = 0
if (typeDuring > 0) {
await clickSelector(composer, { scroll: false })
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(typeDuring / 26)).slice(0, typeDuring))
typedDuringStream = typeDuring
}
const stillStreaming = await js(`!!document.querySelector(${JSON.stringify(stopButton)})`)
await waitFor(`!document.querySelector(${JSON.stringify(stopButton)})`, 180_000, 'stream finished')
const assistantChars = await js(`(() => { const list = document.querySelectorAll('.chat-history-pane[data-active=true] article.message--assistant'); return list.length ? list[list.length - 1].textContent.length : 0; })()`)
return { fields: { streamStartMs: startedMs, streamTotalMs: performance.now() - sendAt, assistantRenderedChars: assistantChars, typedDuringStream, typingEndedBeforeStreamFinished: stillStreaming } }
}
// PERF-09: visit many distinct conversations and check whether DOM size and
// retained heap stay bounded by the keep-alive cache. Sampling forces a GC,
// so it is recorded outside measure() and is not mixed with frame metrics.
async function memoryGrowth(seeded) {
report.pending = 'memory-growth'
writeReport()
const sample = async visits => ({
visits,
mountedPanes: await js("document.querySelectorAll('.chat-history-pane').length"),
domNodes: await js("document.getElementsByTagName('*').length"),
retainedHeapMB: Math.round(await retainedHeapMB() * 10) / 10
})
const samples = [await sample(0)]
const openMs = []
for (let index = 0; index < config.memoryVisits; index += 1) {
const target = index * 3 + 2
await clickConversation(seeded.titles[target])
openMs.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
await sleep(200)
if ((index + 1) % 4 === 0) samples.push(await sample(index + 1))
}
const first = samples[0]
const last = samples[samples.length - 1]
const half = samples[Math.floor(samples.length / 2)]
report.memoryGrowth = {
description: `Visit ${config.memoryVisits} distinct seeded conversations; DOM and post-GC heap every 4 visits`,
samples,
openLatency: summarize(openMs),
// Growth in the second half shows whether the cache bound actually holds.
secondHalfGrowth: { domNodes: last.domNodes - half.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - half.retainedHeapMB) * 10) / 10 },
totalGrowth: { domNodes: last.domNodes - first.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - first.retainedHeapMB) * 10) / 10 }
}
writeReport()
console.log(`[perf] memory-growth: panes ${first.mountedPanes}->${last.mountedPanes}, DOM ${first.domNodes}->${last.domNodes}, heap ${first.retainedHeapMB}->${last.retainedHeapMB} MB (second half +${report.memoryGrowth.secondHalfGrowth.retainedHeapMB} MB)`)
}
// -------------------------------------------------------------- scenarios
// Electron emits `ready` only after this ESM entry finishes evaluating, so
// the scenario run must not be awaited at module top level.
void app.whenReady().then(run)
async function run() {
try {
for (let i = 0; i < 600 && !win; i += 1) await sleep(100)
if (!win) throw new Error('Main window was not created')
win.setContentSize(config.contentWidth, config.contentHeight)
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && !!document.querySelector('button.new-chat')`, 60_000, 'composer ready')
report.startup.composerReadyMs = performance.now() - driverStart
await dismissStartupDialogs()
await js('document.fonts.ready.then(() => true)')
const gpu = app.getGPUFeatureStatus()
const [innerWidth, innerHeight, devicePixelRatio] = await js('[innerWidth, innerHeight, devicePixelRatio]')
report.environment = {
platform: process.platform, arch: process.arch, electron: process.versions.electron, chrome: process.versions.chrome,
cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824),
innerWidth, innerHeight, devicePixelRatio,
gpu: { gpu_compositing: gpu.gpu_compositing, rasterization: gpu.rasterization, webgl: gpu.webgl, webgl2: gpu.webgl2, video_decode: gpu.video_decode },
rendererCpuThrottle: config.cpuThrottle
}
await applyCpuThrottle()
await sleep(1_500)
const idle = await measure('idle', 'Fresh profile, no interaction for 3 s', async () => { await sleep(3_000) })
frameBaseline = idle.renderer.frames.p50 ?? 16.7
await measure('typing-small', `Type ${config.typedCharacters} characters into the composer (fresh profile)`, async () => {
await clickSelector(composer, { scroll: false })
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(config.typedCharacters / 26)).slice(0, config.typedCharacters))
await settle()
})
// Clear the draft so later sends start from an empty composer.
await js(`(() => { const t = document.querySelector(${JSON.stringify(composer)}); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`)
report.pending = 'seed'
const seeded = await seed()
report.seed = { conversations: config.seedConversations, messagesPerConversation: config.seedMessagesPerConversation, longConversationMessages: config.longConversationMessages, seedMs: seeded.seedMs }
// Reload so the App loads the seeded history through its normal startup path.
const reloadStart = performance.now()
win.webContents.reload()
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && (() => { const rows = document.querySelectorAll('button.conversation-item'); const entry = document.querySelector('.conversation-list [aria-setsize]'); const total = entry ? Number(entry.getAttribute('aria-setsize')) : rows.length; return total >= ${config.seedConversations} && rows.length >= ${Math.min(10, config.seedConversations)}; })()`, 60_000, 'seeded sidebar')
report.startup.reloadWithSeedMs = performance.now() - reloadStart
await applyCpuThrottle()
await dismissStartupDialogs({ expect: false })
await sleep(1_500)
report.seed.sidebarRows = await js("document.querySelectorAll('button.conversation-item').length")
await screenshot('seeded')
await measure('idle-seeded', `Seeded history (${config.seedConversations} conversations), no interaction for 3 s`, async () => { await sleep(3_000) })
await measure('typing-seeded', `Type ${config.typedCharacters} characters with seeded history`, () => withRendererProfile('typing-seeded', async () => {
await clickSelector(composer, { scroll: false })
await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(config.typedCharacters / 26)).slice(0, config.typedCharacters))
await settle()
}))
await js(`(() => { const t = document.querySelector(${JSON.stringify(composer)}); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`)
await measure('switch-conversations', `Click ${config.switches} different seeded conversations (${config.seedMessagesPerConversation} messages each), first visit`, () => withRendererProfile('switch-conversations', async () => {
const samples = []
for (let index = 0; index < config.switches; index += 1) {
const target = index * 3 + 1
await clickConversation(seeded.titles[target])
samples.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
await sleep(250)
}
return { latency: summarize(samples), fields: { switchSamplesMs: samples } }
}))
await measure('switch-conversations-warm', 'Revisit the same conversations (keep-alive cache warm)', () => withRendererProfile('switch-conversations-warm', async () => {
const samples = []
for (let index = 0; index < Math.min(config.switches, 10); index += 1) {
const target = index * 3 + 1
await clickConversation(seeded.titles[target])
samples.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
await sleep(250)
}
return { latency: summarize(samples), fields: { switchSamplesMs: samples } }
}))
// The warm scenario above revisits more conversations than the pane cache
// holds, so in LRU order most revisits miss. This one alternates between
// three conversations that are certainly still mounted.
await measure('switch-conversations-hot', 'Alternate between 3 recently opened conversations (panes mounted)', () => withRendererProfile('switch-conversations-hot', async () => {
const targets = [1, 4, 7]
for (const target of targets) {
await clickConversation(seeded.titles[target])
await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`)
await sleep(250)
}
const samples = []
for (let index = 0; index < 12; index += 1) {
const target = targets[index % targets.length]
await clickConversation(seeded.titles[target])
samples.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`))
await sleep(250)
}
return { latency: summarize(samples), fields: { switchSamplesMs: samples } }
}))
await measure('open-long-conversation', `Open a conversation with ${config.longConversationMessages} messages`, async () => {
await clickConversation(seeded.longTitle)
const openMs = await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.longId)})`)
await sleep(1_000)
const renderedMessages = await js("document.querySelectorAll('.chat-history-pane[data-active=true] article.message').length")
return { latency: { count: 1, p50: openMs, p95: openMs, max: openMs }, fields: { openMs, renderedMessages } }
})
await measure('scroll-long-conversation', 'Wheel-scroll up through the long conversation for 3 s', () => withRendererProfile('scroll-long-conversation', async () => {
const point = await js(`(() => { const e = document.querySelector('.chat-history-pane[data-active=true] #chat-message-list') || document.querySelector('.chat-history-pane[data-active=true]'); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`)
const scroller = `(() => { const p = document.querySelector('.chat-history-pane[data-active=true]'); const c = p && [p, ...p.querySelectorAll('*')].find(e => e.scrollHeight > e.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)); return c ? c.scrollTop : null; })()`;
const before = await js(scroller)
const end = performance.now() + 3_000
while (performance.now() < end) {
win.webContents.sendInputEvent({ type: 'mouseWheel', x: point.x, y: point.y, deltaX: 0, deltaY: 120 })
await sleep(16)
}
await sleep(300)
const after = await js(scroller)
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } }
}))
await screenshot('long-conversation')
await measure('stream-seeded', 'New conversation, stream a long Markdown answer from the loopback model (seeded history)', () => withRendererProfile('stream-seeded', () => streamOnce()))
await screenshot('streamed')
await measure('stream-and-type', 'Stream a long answer while typing into the composer; latency and frames cover the typing period', () => streamOnce({ typeDuring: 100 }))
if (config.memoryVisits > 0) await memoryGrowth(seeded)
report.status = errors.length ? 'passed-with-renderer-errors' : 'passed'
report.pending = null
report.startup.driverTotalMs = performance.now() - driverStart
writeReport()
clearTimeout(deadline)
app.exit(0)
} catch (error) {
report.status = 'failed'
report.error = error instanceof Error ? error.stack : String(error)
try { await screenshot('failure') } catch { /* window may be gone */ }
writeReport()
clearTimeout(deadline)
app.exit(1)
}
}