From 80579ee4219b7b916f3a9708bb25aa07d586a736 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 13:42:40 +0800 Subject: [PATCH] perf: add full-app interaction benchmark and UI architecture plan --- build/run-app-perf.cjs | 201 +++++++ ...performance-experience-improvement-plan.md | 154 +++++ package.json | 1 + tests/support/app-perf-driver.mjs | 528 ++++++++++++++++++ 4 files changed, 884 insertions(+) create mode 100644 build/run-app-perf.cjs create mode 100644 tests/support/app-perf-driver.mjs diff --git a/build/run-app-perf.cjs b/build/run-app-perf.cjs new file mode 100644 index 00000000..effbaa6e --- /dev/null +++ b/build/run-app-perf.cjs @@ -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 +}) diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index 8d679e96..6673b77f 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -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 文案 diff --git a/package.json b/package.json index 79333000..d76614f5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/support/app-perf-driver.mjs b/tests/support/app-perf-driver.mjs new file mode 100644 index 00000000..db7c5eef --- /dev/null +++ b/tests/support/app-perf-driver.mjs @@ -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) +} +}