From 1e10935d2235bc81d2167c76de16e4171fb1b5f2 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 15:51:33 +0800 Subject: [PATCH] perf: add CPU-throttled, memory-growth and knowledge retrieval benchmarks perf:app gains GB_PERF_CPU_THROTTLE (CDP renderer throttling) and a keep-alive memory scenario with post-GC heap samples; perf:knowledge times synchronous FTS, vector and hybrid search at 5k/20k/50k chunks. Results and conclusions recorded in the performance plan (PERF-05, PERF-09, PERF-12). --- build/run-app-perf.cjs | 5 + build/run-knowledge-perf.cjs | 96 +++++++++++++++++++ ...performance-experience-improvement-plan.md | 81 +++++++++++++++- package.json | 1 + tests/support/app-perf-driver.mjs | 68 ++++++++++++- 5 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 build/run-knowledge-perf.cjs diff --git a/build/run-app-perf.cjs b/build/run-app-perf.cjs index effbaa6e..9381c598 100644 --- a/build/run-app-perf.cjs +++ b/build/run-app-perf.cjs @@ -140,6 +140,11 @@ function printSummary(report) { heapMB: format(s.renderer?.heapUsedMB) })) if (rows.length) console.table(rows) + if (report.memoryGrowth) { + console.log(`memory growth (${report.memoryGrowth.description}):`) + console.table(report.memoryGrowth.samples) + } + if (report.environment?.rendererCpuThrottle > 1) console.log(`renderer CPU throttled ${report.environment.rendererCpuThrottle}x`) if (report.error) console.log(`error: ${report.error}`) } diff --git a/build/run-knowledge-perf.cjs b/build/run-knowledge-perf.cjs new file mode 100644 index 00000000..42b66758 --- /dev/null +++ b/build/run-knowledge-perf.cjs @@ -0,0 +1,96 @@ +// Knowledge retrieval benchmark (PERF-05). Bundles the production +// KnowledgeDatabase with esbuild, seeds synthetic libraries of increasing size +// in a temporary SQLite file and times the synchronous search calls that the +// Electron Main process runs inline during retrieval. Because these calls are +// synchronous, their duration is the time Main is blocked per query. +const { buildSync } = require('esbuild') +const { createHash } = require('node:crypto') +const { mkdtempSync, rmSync, writeFileSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join, resolve } = require('node:path') +const { performance } = require('node:perf_hooks') + +const root = resolve(__dirname, '..') +const sizes = (process.env.GB_KPERF_SIZES || '5000,20000,50000').split(',').map(Number) +const dimensions = Number(process.env.GB_KPERF_DIMENSIONS || 384) +const chunksPerDocument = 100 +const queries = Number(process.env.GB_KPERF_QUERIES || 15) + +const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-knowledge-perf-')) +const bundle = join(directory, 'knowledge-database.cjs') +buildSync({ + entryPoints: [join(root, 'src/main/knowledge/knowledge-database.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + outfile: bundle, + logLevel: 'error' +}) +const { KnowledgeDatabase } = require(bundle) + +// Deterministic pseudo-random generator so runs are comparable. +let seed = 42 +const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296) +const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ') +const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ') +const vector = () => Array.from({ length: dimensions }, () => random() * 2 - 1) +const checksum = value => createHash('sha256').update(value).digest('hex') + +function percentile(values, p) { + 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 time(body) { + const samples = [] + body() // warm statement cache and page cache + for (let index = 0; index < queries; index += 1) { + const started = performance.now() + body() + samples.push(performance.now() - started) + } + return { p50: round(percentile(samples, 50)), p95: round(percentile(samples, 95)), max: round(Math.max(...samples)) } +} +const round = value => Math.round(value * 10) / 10 + +const results = [] +try { + for (const size of sizes) { + const path = join(directory, `knowledge-${size}.sqlite`) + const database = new KnowledgeDatabase(path) + database.initialize() + const library = database.createKnowledgeBase({ name: `Perf ${size}`, storageMode: 'reference' }) + const sqlite = database.database + const seedStarted = performance.now() + for (let documentIndex = 0; documentIndex < size / chunksPerDocument; documentIndex += 1) { + const source = database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: `C:\\perf\\doc-${documentIndex}.md`, displayName: `doc-${documentIndex}.md`, status: 'ready' }) + const chunks = Array.from({ length: chunksPerDocument }, (_, ordinal) => ({ id: `d${documentIndex}-c${ordinal}`, ordinal, content: sentence(80), location: `line ${ordinal + 1}` })) + const document = database.upsertDocument({ knowledgeBaseId: library.id, sourceId: source.id, externalId: `doc-${documentIndex}`, title: `doc-${documentIndex}`, sourceLocation: source.location }, chunks) + const stored = sqlite.prepare("SELECT id, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent'").all(document.id) + database.replaceDocumentEmbeddings(document.id, 'perf', 'perf-model', stored.map(row => ({ chunkId: row.id, contentChecksum: checksum(row.index_content), vector: vector() }))) + } + const seedMs = performance.now() - seedStarted + database.close() + + // Reopen so timings start from a fresh connection, like an app restart. + const reopened = new KnowledgeDatabase(path) + reopened.initialize() + const query = vector() + const entry = { + chunks: size, + dimensions, + seedSeconds: round(seedMs / 1000), + ftsMs: time(() => reopened.search({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40 })), + vectorMs: time(() => reopened.vectorSearch({ knowledgeBaseId: library.id, provider: 'perf', model: 'perf-model', vector: query, limit: 40 })), + hybridMs: time(() => reopened.hybridSearchWithDiagnostics({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40, provider: 'perf', model: 'perf-model', vector: query, graphEnabled: false, candidateMultiplier: 1 })) + } + reopened.close() + results.push(entry) + console.log(`[kperf] ${size} chunks: fts p95 ${entry.ftsMs.p95} ms, vector p95 ${entry.vectorMs.p95} ms, hybrid p95 ${entry.hybridMs.p95} ms (seed ${entry.seedSeconds} s)`) + } + const output = process.env.GB_KPERF_OUTPUT + if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', node: process.version, results }, null, 2)) + console.table(results.map(r => ({ chunks: r.chunks, 'fts p50/p95': `${r.ftsMs.p50} / ${r.ftsMs.p95}`, 'vector p50/p95': `${r.vectorMs.p50} / ${r.vectorMs.p95}`, 'hybrid p50/p95': `${r.hybridMs.p50} / ${r.hybridMs.p95}` }))) +} finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) +} diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index adf81f1e..8c008b40 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -332,11 +332,30 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured. ### PERF-05 向量检索规模基准 -- **优先级 / 状态:** P1 / 待开始 +- **优先级 / 状态:** P1 / 耗时基线已完成,取消响应与峰值内存待测 - **场景:** 1k、10k、50k chunks 的全文、向量和混合检索。 - **指标:** 总耗时、`vectorScannedCount`、Main event-loop lag、取消响应和峰值内存。 - **验收:** 固定语料和查询集产生可比较结果,并保留检索质量指标。 +#### 2026-10-02 耗时基线 + +`npm run perf:knowledge`(`build/run-knowledge-perf.cjs`)用 esbuild 打包生产 +`KnowledgeDatabase`,在临时 SQLite 中写入确定性随机语料(每文档 100 chunks,每 chunk 80 词, +384 维向量,与内置嵌入模型一致),重开连接后对每个规模各测 15 次 `search`、`vectorSearch` +和 `hybridSearchWithDiagnostics`(图谱关闭)。这些调用是同步的,在 App 中由 Main 线程直接 +执行,所以单次耗时就是 Main 被阻塞的时长。同一台设备(Node 24.18),p95: + +| chunks | 全文 | 向量 | 混合 | +| --- | --- | --- | --- | +| 5,000 | 14 ms | 54 ms | 93 ms | +| 20,000 | 59 ms | 173 ms | 219 ms | +| 50,000 | 130 ms | 396 ms | 520 ms | + +向量扫描与 chunk 数线性相关,FTS 也随规模增长。按 50 ms 预算,约 5k chunks 起混合检索就会 +使 Main 可感知地卡顿;5 万 chunks 时每次检索约冻结 Main 0.5 s,期间全部 IPC(包括流式输出、 +终端和界面操作)排队。结论:`PERF-06` 的实施条件已满足;检索应先移出 Main(`PERF-06`, +随后并入 `PERF-16`)。取消响应和峰值内存尚未测量。 + ### PERF-06 向量计算移出 Main - **优先级 / 状态:** P1 / 待开始 @@ -366,11 +385,29 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured. ### PERF-09 Keep-alive 压力测试 -- **优先级 / 状态:** P2 / 待开始 +- **优先级 / 状态:** P2 / 文本会话部分已完成,图片与工具记录待补 - **场景:** 连续打开 12 个会话,每个会话包含 80 条富 Markdown、图片和工具记录。 - **指标:** DOM 节点、Renderer heap、GC pause 和流式 commit。 - **验收:** 明确隐藏会话缓存是否构成实际内存或渲染问题。 +#### 2026-10-02 文本会话测量 + +`npm run perf:app` 在流式场景之后连续打开 24 个未访问过的种子会话(每个 20 条富 Markdown), +每 4 次记录挂载的面板数、DOM 节点数和强制 GC 后的 JS heap(CDP +`HeapProfiler.collectGarbage` + `Runtime.getHeapUsage`)。 + +| 已访问 | 挂载面板 | DOM 节点 | GC 后 heap | +| --- | --- | --- | --- | +| 0(两次流式之后) | 12 | 39,657 | 54.6 MB | +| 4 / 8 | 12 | 39,656 | 54.6–55.1 MB | +| 12 | 12 | 15,884 | 27.4 MB | +| 16 / 20 / 24 | 12 | 15,884 | 27.3–27.9 MB | + +缓存上限 12 个面板有效:两条长流式回答被挤出缓存后,DOM 和 heap 回落并保持不变,后半段 +增长为 0。此前观察到的“4 万节点、210 MB”是两条 40 KB 流式回答仍在缓存中时的瞬时值 +(未 GC),不是持续泄漏。结论:文本会话场景下 keep-alive 不构成内存问题,`PERF-10` 不需要 +调整缓存数量或有效期;含图片和工具记录的会话尚未测。 + ### PERF-10 Keep-alive 轻量优化 - **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成 @@ -444,8 +481,10 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured. 按键间隔 60 ms;流式回答约 40 KB 混合 Markdown(标题、列表、代码、表格、行内/块级公式、 引用),每个 SSE 事件 12 个字符,2,000 字符/秒。可用 `GB_PERF_*` 环境变量调整, 见脚本开头。 -- `GB_PERF_PROFILE=1` 为流式场景采集 Renderer CPU profile 并汇总自身耗时热点;剖析有开销, - 只用于归因,不作为基线数据。 +- `GB_PERF_PROFILE=1` 为有历史输入和流式场景采集 Renderer CPU profile 并汇总自身耗时 + 热点;剖析有开销,只用于归因,不作为基线数据。 +- `GB_PERF_CPU_THROTTLE=<倍数>` 用 CDP 降低 Renderer CPU 速度模拟低端设备; + `GB_PERF_MEMORY_VISITS`(默认 24,0 跳过)控制 `PERF-09` 内存场景。 #### 2026-10-02 基线 @@ -534,6 +573,38 @@ p95 从约 36 ms 降到 15.5 ms。 剩余开销属于 App 级重渲染,留给 `PERF-12` 后续项和 `PERF-13`。 - **剩余:** 流式 delta 按帧合并、大面板 memo、滚动跟随优化仍待实施。 +#### 2026-10-02 低端设备模拟(Renderer CPU 4× 降速) + +`GB_PERF_CPU_THROTTLE=4 npm run perf:app` 通过 CDP `Emulation.setCPUThrottlingRate` 把 +Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用),源码 `5dc6545` +(含 `PERF-12` 第一步),同一设备。不降速一栏为同一轮构建的对照。 + +| 场景 | 不降速 | 4× 降速 | +| --- | --- | --- | +| 输入 120 字(新 profile)按键→帧 p95 | 8.7 ms | 42 ms,掉帧 21% | +| **输入 120 字(有 300 个会话)** | 0 Long Task,p95 9 ms | **37 次 / 3.2 s,p95 355 ms,最大 483 ms** | +| **首次切换会话 ×15** 点击→显示 p50 / p95 | 52 / 117 ms | **434 / 922 ms** | +| 再次切换(缓存命中)p50 / p95 | 55 / 66 ms | 482 / 534 ms | +| 打开 2,000 条消息会话 | 140 ms | 1.2 s | +| 滚动长会话 | 帧 p95 8.5 ms | 帧 p95 17 ms,正常 | +| **流式 40 KB** | 1 次 / 0.17 s,帧 p95 17 ms | **156 次 / 16.7 s,帧 p95 183 ms,掉帧 100%** | +| **流式期间输入** 按键→帧 p50 / p95 | 11.5 / 17 ms | **575 / 1,513 ms** | + +高性能机上不可见的问题,在降速后全部暴露: + +- 新 profile 输入只到 42 ms,有 300 个会话后升到 355 ms,差异来自**每次按键重新渲染整个 + App 和完整侧栏**。降速剖析:`resizeComposerTextarea`(每次输入同步读 `scrollHeight` 强制 + 布局)约 8%;i18next `formatLanguageCode`/`translate` 约 12%(`Intl.getCanonicalLocales` + 每次调用 `t()` 都执行,侧栏 302 行各自多次调用);其余是 App 与侧栏行的 React 渲染。 +- 流式期间约 18% 是 `ChatHistoryPane` 每次 flush 的滚动跟随 layout effect(读布局后 + `scrollTo`),i18next 约 8%,Markdown 解析与 React reconcile 其余。 +- 缓存命中切换也约 0.5 s,说明慢的不是挂载新面板,而是切换触发的 App 级整体重渲染。 + +**结论:** 低端设备上输入、切换和流式都会明显卡顿,主因是 App 级重渲染和侧栏全量渲染, +证实 `PERF-13`(领域 store)和 `PERF-14`(侧栏虚拟化)有必要。`PERF-12` 剩余项应先做三处 +低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、 +滚动跟随按帧合并且只在贴底时执行。 + ### PERF-13 Renderer 领域 Store 迁移 - **优先级 / 状态:** P1 / 待开始 @@ -559,7 +630,7 @@ p95 从约 36 ms 降到 15.5 ms。 ### PERF-16 数据进程 - **优先级 / 状态:** P1 / 待开始 -- **实施条件:** `PERF-15` 完成,调���方已全部异步。 +- **实施条件:** `PERF-15` 完成,调用方已全部异步。 - **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph、 活动记录及文档解析;Main 只转发;保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。 - **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、 diff --git a/package.json b/package.json index c12d70cc..5f75897e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:watch": "vitest", "perf:app": "node build/run-app-perf.cjs", + "perf:knowledge": "node build/run-knowledge-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 index db7c5eef..9aeb69a7 100644 --- a/tests/support/app-perf-driver.mjs +++ b/tests/support/app-perf-driver.mjs @@ -27,6 +27,9 @@ const config = { typedCharacters: Number(process.env.GB_PERF_TYPED_CHARACTERS || 120), keyIntervalMs: Number(process.env.GB_PERF_KEY_INTERVAL_MS || 60), switches: Number(process.env.GB_PERF_SWITCHES || 15), + memoryVisits: Number(process.env.GB_PERF_MEMORY_VISITS || 24), + // Chromium CPU throttling of the renderer only (Main is not slowed down). + cpuThrottle: Number(process.env.GB_PERF_CPU_THROTTLE || 1), contentWidth: 1280, contentHeight: 800 } @@ -267,10 +270,24 @@ async function measure(id, description, body) { // 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() +function cdp() { const debuggerApi = win.webContents.debugger if (!debuggerApi.isAttached()) debuggerApi.attach('1.3') + return debuggerApi +} +// Throttling is per page session, so it is reapplied after every reload. +async function applyCpuThrottle() { + if (config.cpuThrottle > 1) await cdp().sendCommand('Emulation.setCPUThrottlingRate', { rate: config.cpuThrottle }) +} +// Heap after a full GC, so retained memory is not hidden by GC timing. +async function retainedHeapMB() { + await cdp().sendCommand('HeapProfiler.collectGarbage') + const { usedSize } = await cdp().sendCommand('Runtime.getHeapUsage') + return usedSize / 1048576 +} +async function withRendererProfile(name, body) { + if (!profiling) return body() + const debuggerApi = cdp() await debuggerApi.sendCommand('Profiler.enable') await debuggerApi.sendCommand('Profiler.setSamplingInterval', { interval: 200 }) await debuggerApi.sendCommand('Profiler.start') @@ -401,6 +418,42 @@ async function streamOnce({ typeDuring = 0 } = {}) { return { fields: { streamStartMs: startedMs, streamTotalMs: performance.now() - sendAt, assistantRenderedChars: assistantChars, typedDuringStream, typingEndedBeforeStreamFinished: stillStreaming } } } +// PERF-09: visit many distinct conversations and check whether DOM size and +// retained heap stay bounded by the keep-alive cache. Sampling forces a GC, +// so it is recorded outside measure() and is not mixed with frame metrics. +async function memoryGrowth(seeded) { + report.pending = 'memory-growth' + writeReport() + const sample = async visits => ({ + visits, + mountedPanes: await js("document.querySelectorAll('.chat-history-pane').length"), + domNodes: await js("document.getElementsByTagName('*').length"), + retainedHeapMB: Math.round(await retainedHeapMB() * 10) / 10 + }) + const samples = [await sample(0)] + const openMs = [] + for (let index = 0; index < config.memoryVisits; index += 1) { + const target = index * 3 + 2 + await clickConversation(seeded.titles[target]) + openMs.push(await js(`window.__gbPerf.paneShownSinceMouseDown(${JSON.stringify(seeded.ids[target])})`)) + await sleep(200) + if ((index + 1) % 4 === 0) samples.push(await sample(index + 1)) + } + const first = samples[0] + const last = samples[samples.length - 1] + const half = samples[Math.floor(samples.length / 2)] + report.memoryGrowth = { + description: `Visit ${config.memoryVisits} distinct seeded conversations; DOM and post-GC heap every 4 visits`, + samples, + openLatency: summarize(openMs), + // Growth in the second half shows whether the cache bound actually holds. + secondHalfGrowth: { domNodes: last.domNodes - half.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - half.retainedHeapMB) * 10) / 10 }, + totalGrowth: { domNodes: last.domNodes - first.domNodes, retainedHeapMB: Math.round((last.retainedHeapMB - first.retainedHeapMB) * 10) / 10 } + } + writeReport() + console.log(`[perf] memory-growth: panes ${first.mountedPanes}->${last.mountedPanes}, DOM ${first.domNodes}->${last.domNodes}, heap ${first.retainedHeapMB}->${last.retainedHeapMB} MB (second half +${report.memoryGrowth.secondHalfGrowth.retainedHeapMB} MB)`) +} + // -------------------------------------------------------------- scenarios // Electron emits `ready` only after this ESM entry finishes evaluating, so @@ -422,8 +475,10 @@ try { 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 } + gpu: { gpu_compositing: gpu.gpu_compositing, rasterization: gpu.rasterization, webgl: gpu.webgl, webgl2: gpu.webgl2, video_decode: gpu.video_decode }, + rendererCpuThrottle: config.cpuThrottle } + await applyCpuThrottle() await sleep(1_500) const idle = await measure('idle', 'Fresh profile, no interaction for 3 s', async () => { await sleep(3_000) }) @@ -446,6 +501,7 @@ try { 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 applyCpuThrottle() await dismissStartupDialogs({ expect: false }) await sleep(1_500) report.seed.sidebarRows = await js("document.querySelectorAll('button.conversation-item').length") @@ -453,11 +509,11 @@ try { 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 measure('typing-seeded', `Type ${config.typedCharacters} characters with seeded history`, () => withRendererProfile('typing-seeded', async () => { await clickSelector(composer, { scroll: false }) await typeText('abcdefghijklmnopqrstuvwxyz'.repeat(Math.ceil(config.typedCharacters / 26)).slice(0, config.typedCharacters)) await settle() - }) + })) await js(`(() => { const t = document.querySelector(${JSON.stringify(composer)}); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, ''); t.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`) await measure('switch-conversations', `Click ${config.switches} different seeded conversations (${config.seedMessagesPerConversation} messages each), first visit`, async () => { @@ -511,6 +567,8 @@ try { await measure('stream-and-type', 'Stream a long answer while typing into the composer; latency and frames cover the typing period', () => streamOnce({ typeDuring: 100 })) + if (config.memoryVisits > 0) await memoryGrowth(seeded) + report.status = errors.length ? 'passed-with-renderer-errors' : 'passed' report.pending = null report.startup.driverTotalMs = performance.now() - driverStart