perf: add full-app interaction benchmark and UI architecture plan

This commit is contained in:
mesalogo
2026-10-02 13:42:40 +08:00
parent 8280158edc
commit 80579ee421
4 changed files with 884 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
// Full-App interaction benchmark supervisor (PERF-11).
// Starts a loopback fake OpenAI-compatible model on 127.0.0.1:11434 (the
// fresh-profile default model endpoint), launches the current production
// build in an isolated profile through tests/support/app-perf-driver.mjs,
// enforces a total deadline and prints a summary of the driver report.
const { spawn } = require('node:child_process')
const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
const { createServer } = require('node:http')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const root = resolve(__dirname, '..')
const modelPort = 11434
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || 600_000)
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000)
const chunkChars = Number(process.env.GB_PERF_CHUNK_CHARS || 12)
const streamCharsPerSecond = Number(process.env.GB_PERF_STREAM_CPS || 2_000)
const tickIntervalMs = 10
function buildAnswer(targetBytes) {
const blocks = [
'## Section {n}\n\nThis paragraph explains the {n}th step of the benchmark answer with **bold**, `inline code`, and a [link](https://example.com/{n}).\n\n',
'- First item for block {n}\n- Second item with *emphasis*\n - Nested item {n}\n- Third item\n\n',
'```ts\nexport function step{n}(input: number): number {\n const value = input * {n}\n return value + 1\n}\n```\n\n',
'| Column | Value {n} | Notes |\n| --- | --- | --- |\n| alpha | {n} | first |\n| beta | {n}1 | second |\n\n',
// Single-line display math is common in model output. GB_PERF_CORPUS=multiline-math
// puts the delimiters on their own lines instead.
process.env.GB_PERF_CORPUS === 'multiline-math'
? 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\n\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}\n$$\n\n'
: 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}$$\n\n',
'> Quoted note {n}: keep the streaming renderer honest with mixed blocks.\n\n'
]
let text = ''
let n = 1
while (Buffer.byteLength(text) < targetBytes) {
text += blocks[n % blocks.length].replaceAll('{n}', String(n))
n += 1
}
return text
}
function startModelServer(ledger) {
const answer = buildAnswer(streamBytes)
const server = createServer((request, response) => {
const started = Date.now()
const entry = { method: request.method, path: request.url, startedAt: started }
ledger.push(entry)
if (request.method === 'GET' && request.url?.endsWith('/models')) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
return
}
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
response.writeHead(404).end()
return
}
let body = ''
request.on('data', chunk => { body += chunk })
request.on('end', () => {
let parsed = {}
try { parsed = JSON.parse(body) } catch { /* counted below */ }
const messages = Array.isArray(parsed.messages) ? parsed.messages : []
const last = messages.at(-1)
const lastText = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '')
const benchmark = lastText.includes('perf-stream')
entry.kind = benchmark ? 'benchmark-stream' : 'auxiliary'
entry.stream = parsed.stream === true
const content = benchmark ? answer : 'Benchmark title'
if (!entry.stream) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({
id: 'perf', object: 'chat.completion', model: 'qwen3',
choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content } }],
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }
}))
entry.finishedAt = Date.now()
return
}
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
const send = payload => response.write(`data: ${JSON.stringify(payload)}\n\n`)
const delta = text => send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
let offset = 0
let chunks = 0
const finish = () => {
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [], usage: { prompt_tokens: 10, completion_tokens: Math.ceil(content.length / 4), total_tokens: 10 + Math.ceil(content.length / 4) } })
response.write('data: [DONE]\n\n')
response.end()
entry.finishedAt = Date.now()
entry.chunks = chunks
entry.bytes = Buffer.byteLength(content)
}
if (!benchmark) { delta(content); finish(); return }
// Emit at a fixed character rate, split into small SSE events like a
// fast token stream. Pacing is time-based so coarse timers do not
// change throughput.
const streamStart = Date.now()
const tick = () => {
if (response.destroyed) { entry.aborted = true; return }
const due = Math.min(content.length, Math.ceil(((Date.now() - streamStart) / 1000) * streamCharsPerSecond))
while (offset < due) {
delta(content.slice(offset, offset + chunkChars))
offset += chunkChars
chunks += 1
}
if (offset >= content.length) { finish(); return }
setTimeout(tick, tickIntervalMs)
}
tick()
})
})
return new Promise((resolvePromise, reject) => {
server.once('error', reject)
server.listen(modelPort, '127.0.0.1', () => resolvePromise(server))
})
}
function format(value, digits = 1) {
return value === null || value === undefined || Number.isNaN(value) ? '-' : Number(value).toFixed(digits)
}
function printSummary(report) {
console.log(`\nGoodBuddy full-App benchmark (${report.status})`)
console.log(`source ${report.source?.commit ?? '?'}${report.source?.dirty ? ' (dirty)' : ''}, Electron ${report.environment?.electron}, DPR ${report.environment?.devicePixelRatio}, ${report.environment?.cpu}`)
if (report.startup) {
console.log(`startup: window ${format(report.startup.windowCreatedMs, 0)} ms, ready-to-show ${format(report.startup.readyToShowMs, 0)} ms, composer ready ${format(report.startup.composerReadyMs, 0)} ms`)
}
const rows = (report.scenarios ?? []).map(s => ({
scenario: s.id,
ms: format(s.durationMs, 0),
longTasks: `${s.renderer?.longTasks?.count ?? '-'} / ${format(s.renderer?.longTasks?.totalMs, 0)} / ${format(s.renderer?.longTasks?.maxMs, 0)}`,
'frame p50/p95/max': `${format(s.renderer?.frames?.p50)} / ${format(s.renderer?.frames?.p95)} / ${format(s.renderer?.frames?.max, 0)}`,
'jank%': format(s.renderer?.frames?.jankRatio * 100),
'latency p50/p95/max': s.latency ? `${format(s.latency.p50)} / ${format(s.latency.p95)} / ${format(s.latency.max)}` : '-',
'mainLag p99/max': `${format(s.main?.eventLoopDelay?.p99)} / ${format(s.main?.eventLoopDelay?.max)}`,
'cpu% main/renderer/gpu': `${format(s.processes?.cpu?.Browser, 0)} / ${format(s.processes?.cpu?.Tab, 0)} / ${format(s.processes?.cpu?.GPU, 0)}`,
domNodes: s.renderer?.domNodes ?? '-',
heapMB: format(s.renderer?.heapUsedMB)
}))
if (rows.length) console.table(rows)
if (report.error) console.log(`error: ${report.error}`)
}
async function main() {
const required = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']
const missing = required.filter(file => !existsSync(join(root, file)))
if (missing.length) throw new Error(`Missing production build (${missing.join(', ')}); run npm run build:bundle first`)
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-app-perf-'))
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
mkdirSync(outputDirectory, { recursive: true })
const ledger = []
let server
try {
server = await startModelServer(ledger)
} catch (error) {
throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port and retry`, { cause: error })
}
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root }
delete environment.ELECTRON_RUN_AS_NODE
delete environment.ELECTRON_RENDERER_URL
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace')
mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true })
const started = Date.now()
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
else child.kill('SIGKILL')
}, totalTimeoutMs)
const exit = await new Promise(resolvePromise => child.once('exit', (code, signal) => resolvePromise({ code, signal })))
clearTimeout(timer)
await new Promise(resolvePromise => server.close(() => resolvePromise()))
const resultPath = join(outputDirectory, 'result.json')
let report
if (existsSync(resultPath)) report = JSON.parse(readFileSync(resultPath, 'utf8'))
else report = { status: 'failed', error: timedOut ? 'Supervisor timeout before report' : 'Driver exited without a report' }
report.supervisor = { elapsedMs: Date.now() - started, timeoutMs: totalTimeoutMs, timedOut, exitCode: exit.code, signal: exit.signal }
report.corpus = { kind: process.env.GB_PERF_CORPUS || 'single-line-math', bytes: streamBytes, charsPerSecond: streamCharsPerSecond, charsPerEvent: chunkChars }
report.modelRequests = {
boundary: 'loopback fake model only; no external requests',
total: ledger.length,
benchmarkStreams: ledger.filter(entry => entry.kind === 'benchmark-stream').length,
auxiliary: ledger.filter(entry => entry.kind === 'auxiliary').length,
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt }) => ({ method, path, kind, stream, chunks, bytes, aborted, durationMs: finishedAt ? finishedAt - startedAt : null }))
}
if (timedOut || exit.code !== 0) report.status = 'failed'
writeFileSync(resultPath, JSON.stringify(report, null, 2))
printSummary(report)
console.log(`\nreport: ${resultPath}`)
if (!keepProfile) rmSync(join(runDirectory, 'profile'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
@@ -70,6 +70,7 @@
| 2 | 通道、更新和真实 Runtime | `FUN-02`、`FUN-03`、`FUN-05`、`QA-01`、`QA-02` | 对应平台测试配置可用 | 文件办公闭环、更新下载闭环和真实 Runtime E2E 均通过 |
| 3A | 性能基线 | `PERF-01`、`PERF-03`、`PERF-05`、`PERF-07`、`PERF-09` | 固定测试数据和设备 | 形成可重复基线、目标阈值和原始结果 |
| 3B | 已测量性能优化 | `PERF-02`、`PERF-04`、`PERF-06`、`PERF-08`、`PERF-10` | 对应 3A 指标确认存在瓶颈 | 指标改善且功能、取消、恢复语义不回退 |
| 3C | 架构级界面性能改造 | `PERF-11` 至 `PERF-17` | `PERF-11` 完整 App 基线已产生 | 第 7.1 节五条架构规则成立,并由基准和 lint 持续守护 |
| 4 | 增强能力 | `FUN-06` 至 `FUN-09` | 核心闭环稳定;首个电脑控制平台已选定 | 每项形成独立、完整、可真实使用的工作流 |
## 5. 发布与交付
@@ -390,6 +391,159 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
### 7.1 架构级界面性能改造(批次 3C)
2026-10-02 的架构评审确认,界面卡顿的主要来源是架构组织方式,而不是 Electron 或 GPU:
- **Renderer 单组件状态:** `App.tsx` 约 11k 行,`App()` 内有 123 个 `useState`、69 个
`useEffect`。会话、消息、草稿、任务和设置都在其中,输入和每次流式 flush 都会重新执行
整个 App 函数体;多个大面板未 memo,隐藏的 keep-alive 视图也会参与渲染。
- **Main 同步存储:** 全部 SQLite 访问使用 Main 线程的 `DatabaseSync`,包括全表向量扫描、
会话列表与搜索、Story Graph 读取;PDF/Office 解析和附件同步文件 IO 也在 Main。
- **全量回写:** 活动记录在任何变化后 250 ms 全量 `replace`;会话每 500 ms 定时保存;
`*Changed` 通知不带内容,Renderer 再重新查询完整列表。
改造完成后须同时满足以下规则,并由工具守护,避免回退:
1. Main 只负责窗口、生命周期、权限和消息路由,不执行数据库查询、文档解析或批量计算。
2. Renderer 状态按领域拆成外部 store,组件通过 selector 订阅;`App.tsx` 只保留布局壳。
3. 流式文本和终端输出走专用通道,只更新正在显示的行或面板,按帧合并提交。
4. 数据只有一个权威来源,跨进程只传增量;不再全量回写,也不再“通知后重新查全量”。
5. 消息、会话、笔记、文件树等长列表虚拟化,DOM 数量不随数据量增长。
实施方式:先建立 `PERF-11` 基线,再沿 Renderer(`PERF-12`→`PERF-13`→`PERF-14`)和
数据进程(`PERF-15`→`PERF-16`)两条线并行推进,最后由 `PERF-17` 固化守护。每一步完成后
重新运行 `PERF-11` 场景并记录对比;不换技术栈,不一次性重写 `App.tsx`。
### PERF-11 完整 App 交互基准
- **优先级 / 状态:** P1 / 进行中
- **目标:** 在当前生产构建、隔离 profile 和本地假模型下,可重复测量用户能感知的卡顿。
- **运行:** `npm run build:bundle` 后执行 `npm run perf:app`;结果写入
`GB_PERF_OUTPUT` 或系统临时目录,不含用户内容,不发出外部模型请求。
- **场景:** 空闲基线;Composer 连续输入;切换会话;打开长会话;大量会话列表下的流式
回答;流式回答期间同时输入。
- **指标:** Renderer Long Tasks(>50 ms)数量与总时长、按键到下一帧延迟、帧间隔 p95 与
掉帧比例、React commit 无法直接取得时以 DOM mutation 批次近似、Main event-loop 延迟
(`monitorEventLoopDelay`)、Main 与 Renderer 进程 CPU 和内存、Renderer JS heap。
- **验收:** 同一设备多次运行结果可比较;报告记录源码版本、环境、数据规模和原始样本摘要。
#### 实现
- `build/run-app-perf.cjs`:独立 supervisor。在 `127.0.0.1:11434`(新 profile 默认模型地址)
启动 OpenAI 兼容假模型,清除 `ELECTRON_RUN_AS_NODE` 和模型环境变量,设置整轮期限(默认
600 s),汇总报告并删除隔离 profile。端口被占用时直接失败。
- `tests/support/app-perf-driver.mjs`:在导入 `out/main/index.js` 前设置 `appPath`、
`userData`、`sessionData` 和日志目录,然后由生产 Main 创建窗口、注册 IPC 并加载真实
Preload/Renderer。全部交互使用 `sendInputEvent` 原生输入;数据通过生产
`conversations.saveLocal` IPC 写入,再 reload 走正常启动加载。
- Renderer 探针只观察:`longtask`、`long-animation-frame`、Event Timing、rAF 帧间隔、
keydown 到下一帧、MutationObserver 计数、DOM 节点数和精确 JS heap。Main 用
`monitorEventLoopDelay`,进程用 `app.getAppMetrics()`。
- 默认规模:300 个会话 × 20 条消息,另加一个 2,000 条消息的长会话;输入 120 个字符,
按键间隔 60 ms;流式回答约 40 KB 混合 Markdown(标题、列表、代码、表格、行内/块级公式、
引用),每个 SSE 事件 12 个字符,2,000 字符/秒。可用 `GB_PERF_*` 环境变量调整,
见脚本开头。
- `GB_PERF_PROFILE=1` 为流式场景采集 Renderer CPU profile 并汇总自身耗时热点;剖析有开销,
只用于归因,不作为基线数据。
#### 2026-10-02 基线
源码 `8280158`(工作区含其他未提交改动),Windows x64,Intel Core Ultra X7 358H ×16,
32 GB,Electron 43.2.0 / Chromium 150,DPR 1,内容区 1280×800,GPU 合成与光栅化均启用。
连续运行 3 次,假模型请求每次 2 次,外部模型请求 0 次。帧间隔基线为 8.3 ms(120 Hz)。
| 场景 | Long Tasks 次数 / 总时长 | 帧间隔 p95 | 掉帧比例 | 交互延迟 p50 / p95 | Main lag 最大 |
| --- | --- | --- | --- | --- | --- |
| 空闲(新 profile / 有历史) | 0 / 0 | 8.5 ms | 0% | — | 17–21 ms |
| 输入 120 字(新 profile) | 0 / 0 | 8.5 ms | ≤0.2% | 按键→帧 4.8–5.1 / 8.3–8.5 ms | ≤17 ms |
| 输入 120 字(有历史) | 0 / 0 | 8.5 ms | 0% | 按键→帧 6.5–6.7 / 8.4–8.7 ms | ≤18 ms |
| 首次切换会话 ×15 | 1 / 77–81 ms | 8.6 ms | 3.7–4.5% | 点击→显示 49–50 / 102–105 ms | 29–32 ms |
| 再次切换(缓存命中)×10 | 0 / 0 | 8.6 ms | 3.3–4.1% | 点击→显示 52–53 / 56–59 ms | 19–22 ms |
| 打开 2,000 条消息会话 | 1 / 94–102 ms | 8.5 ms | 1.6% | 点击→显示 134–144 ms | ≤21 ms |
| 滚动长会话 3 s | 0 / 0 | 8.5 ms | 0% | — | ≤17 ms |
| **流式 40 KB 回答** | **126–137 / 8.6–9.3 s** | **83 ms** | **45–48%** | — | 30–36 ms |
| **流式期间输入 100 字** | **128–182 / 8.8–11.9 s** | **83 ms** | **50–52%** | 按键→帧 12.5–13.5 / 35–36 ms | 29–144 ms |
其他观察:
- 启动:窗口创建约 0.58 s,`ready-to-show` 约 1.29 s,Composer 可用约 1.42 s;带 300 个
会话 reload 到侧栏出现约 0.19 s。
- 流式回答约 20 s,Long Tasks 占记录时间约 40%;按四等分统计为 `0 / 2.5 / 4.7 / 4.6 s`,
随回答变长而增加,说明每次提交的成本与已生成长度成正比。
- 有历史时侧栏一次渲染全部 302 行,DOM 约 4,400 节点;访问过若干会话后约 1.6 万;两次
流式后约 4 万,JS heap 最高约 210 MB(keep-alive 缓存持续增长)。
- 长会话只渲染最近 80 条,打开耗时主要是一次约 100 ms 的 Long Task。
- KaTeX 的 `data:` 字体被 CSP 拒绝(缺少 `font-src`),每轮 8 条控制台错误,字体回退。
- `app.getAppMetrics()` 的 CPU 百分比在 Windows 上按全部逻辑核归一,只作相对比较。
**归因(剖析运行):** 流式期间 Renderer 自身 CPU 约 74% 在应用 bundle 中,热点是
`hast-util-from-dom` 的 `parseFragment`/`fromDom`(rehype-katex 把 KaTeX HTML 再解析成
hast)、micromark 分词、hast→React 转换和 React reconcile。对照实验确认根因:默认语料的
块级公式写在一行(`$$...$$`),命中 `MarkdownRenderer.tsx` 的 `unsafeSegmentLine`,
`splitMarkdownSegments` 退回整篇单段,**每次 flush 都重新解析整篇回答并重新渲染全部公式**。
仅把 `$$` 改成独占一行(`GB_PERF_CORPUS=multiline-math`)后,同一流式场景 Long Tasks
从约 130 次 / 9 s 降到 1 次 / 0.15 s,帧间隔 p95 从 83 ms 回到 8.5 ms,流式期间按键→帧
p95 从约 36 ms 降到 15.5 ms。
**结论与对后续批次的影响:**
1. 当前最严重、用户最易感知的卡顿是**流式长回答**,且由 Markdown 分段失效后的整篇重解析
主导;输入、切换、滚动和空闲在本机均未出现明显问题。`PERF-12` 应优先处理:让分段在
单行 `$$`、HTML 行、链接定义等情况下也能安全工作或局部回退,并对已完成段缓存渲染结果。
2. 假模型 2,000 字符/秒高于常见真实模型输出速度,但成本随长度线性增长,真实速度下回答
足够长时同样会出现。
3. 会话首次切换 p95 约 100 ms、打开长会话约 140 ms,可在 `PERF-13`/`PERF-14` 中改善,
优先级低于第 1 项。
4. 本机为高性能设备;低端设备上的绝对值会更差,后续应补一台低配 Windows 设备基线。
### PERF-12 流式与输入快速止血
- **优先级 / 状态:** P1 / 待开始
- **范围:** 流式 delta 在 Renderer 按 `requestAnimationFrame` 合并后提交,并以 transition
降低优先级;缓存 Markdown 预处理结果;`RightAssistantSidebar`、`ProjectSwitcher` 和
keep-alive 路由使用 memo 并稳定回调;滚动跟随只在需要时执行。
- **验收:** `PERF-11` 流式与输入场景的 Long Tasks 和输入延迟下降;事件顺序、停止、重试、
工具与 Subagent 块不回退。该项吸收 `PERF-04` 的范围。
### PERF-13 Renderer 领域 Store 迁移
- **优先级 / 状态:** P1 / 待开始
- **顺序:** 流式缓冲 → Composer 草稿 → 会话与消息 → 任务与通知 → 设置及其余界面状态。
- **每个领域的完成步骤:** 建立 store 并把 IPC 订阅移入 store;调用方改为 selector;删除
App 中对应 state/ref 同步代码;运行测试与 `PERF-11`。
- **验收:** 输入和流式更新不再重新渲染 App 根组件;`App.tsx` 最终降到布局壳规模。
### PERF-14 长列表虚拟化
- **优先级 / 状态:** P2 / 待开始
- **实施条件:** `PERF-13` 完成会话与消息迁移,行组件 props 已稳定。
- **范围:** 消息时间线和会话侧栏优先;保留滚动位置、跳转、展开状态和“加载更早消息”语义。
- **验收:** 5,000 条消息和 1,000 个会话场景下 DOM 节点数有界,滚动与切换指标改善。
### PERF-15 存储接口异步化
- **优先级 / 状态:** P1 / 待开始
- **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步,
调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。
- **验收:** 行为与测试不变;`PERF-11` Main event-loop 延迟不变差。
### PERF-16 数据进程
- **优先级 / 状态:** P1 / 待开始
- **实施条件:** `PERF-15` 完成,调���方已全部异步。
- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph、
活动记录及文档解析;Main 只转发;保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。
- **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、
崩溃恢复和迁移语义不回退。
### PERF-17 架构守护
- **优先级 / 状态:** P2 / 待开始
- **范围:** lint 禁止 `src/main` 使用同步文件 API 和在数据进程外引用 `DatabaseSync`;禁止
组件直接订阅 `window.goodbuddy.*.on*`;限制 `App.tsx` 规模;`PERF-11` 关键指标设置回归阈值。
- **验收:** 违反规则的改动在 lint 或基准阶段失败。
## 8. 体验与可访问性
### UX-01 统一 Ask/Execute 文案
+1
View File
@@ -25,6 +25,7 @@
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"perf:app": "node build/run-app-perf.cjs",
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
"build": "npm run typecheck && npm run build:bundle",
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs",
+528
View File
@@ -0,0 +1,528 @@
// 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),
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 }));
observe('long-animation-frame', e => s.loafs.push({ duration: e.duration, blocking: e.blockingDuration }));
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 },
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'
async function withRendererProfile(name, body) {
if (!profiling) return body()
const debuggerApi = win.webContents.debugger
if (!debuggerApi.isAttached()) debuggerApi.attach('1.3')
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)}))`
async function clickConversation(title) {
await focusWindow()
const point = await js(`(() => {
const e = ${rowSelectorByTitle(title)};
if (!e) return null;
e.scrollIntoView({ block: 'center' });
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 } }
}
// -------------------------------------------------------------- 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 }
}
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)}) && document.querySelectorAll('button.conversation-item').length >= ${Math.min(50, config.seedConversations)}`, 60_000, 'seeded sidebar')
report.startup.reloadWithSeedMs = performance.now() - reloadStart
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`, 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`, 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)', 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 } }
})
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', 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 }))
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)
}
}