chore: add architecture lint ratchets and perf regression thresholds
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

ESLint allowlists (only to shrink) for DatabaseSync, synchronous fs in src/main and direct window.goodbuddy subscriptions in components, plus a max-lines ratchet for App.tsx; docs demos and vendored scripts are no longer linted. perf:check (or GB_PERF_CHECK=1 perf:app) fails a non-throttled benchmark that exceeds build/perf-thresholds.json. Records the results in the PERF plan.
This commit is contained in:
mesalogo
2026-10-02 23:11:11 +08:00
parent d859cbfdeb
commit 53b9ecbcae
7 changed files with 485 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// PERF-17 regression gate for the PERF-11 full-App benchmark report.
// Usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]
// Exit codes: 0 all limits met, 1 a limit, scenario or status failed,
// 2 the report cannot be judged (throttled run, unreadable input).
const { existsSync, readFileSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
const defaultThresholdsPath = join(__dirname, 'perf-thresholds.json')
function metricValue(scenario, path) {
let value = scenario
for (const key of path.split('.')) {
if (value === null || value === undefined) return undefined
value = value[key]
}
return value
}
// Pure: judges a parsed report against parsed thresholds.
// Returns { ok, refused, reason?, rows: [{ scenario, metric, value, limit, pass, note? }] }.
function evaluate(report, thresholds) {
const rows = []
const throttle = Number(report?.environment?.rendererCpuThrottle ?? 1)
const maxThrottle = Number(thresholds.maxRendererCpuThrottle ?? 1)
if (!(throttle <= maxThrottle)) {
return { ok: false, refused: true, reason: `renderer CPU throttle ${throttle}x exceeds ${maxThrottle}x; thresholds apply to non-throttled runs only`, rows }
}
const requiredStatus = thresholds.requiredStatus ?? 'passed'
rows.push({ scenario: '(report)', metric: 'status', value: report?.status ?? null, limit: `= ${requiredStatus}`, pass: report?.status === requiredStatus })
const scenarios = Array.isArray(report?.scenarios) ? report.scenarios : []
for (const [id, limits] of Object.entries(thresholds.scenarios ?? {})) {
const scenario = scenarios.find(entry => entry?.id === id)
if (!scenario) {
const optional = limits.optional === true
rows.push({ scenario: id, metric: '(present)', value: 'missing', limit: optional ? 'optional' : 'required', pass: optional })
continue
}
for (const [metric, bound] of Object.entries(limits)) {
if (metric === 'optional') continue
const raw = metricValue(scenario, metric)
const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
let pass = value !== null
const limitParts = []
if (typeof bound.max === 'number') { limitParts.push(`<= ${bound.max}`); pass = pass && value <= bound.max }
if (typeof bound.min === 'number') { limitParts.push(`>= ${bound.min}`); pass = pass && value >= bound.min }
rows.push({ scenario: id, metric, value, limit: limitParts.join(', '), pass, ...(value === null ? { note: 'metric missing' } : {}) })
}
}
return { ok: rows.every(row => row.pass), refused: false, rows }
}
function resolveReportPath(input) {
const path = resolve(input)
return existsSync(path) && statSync(path).isDirectory() ? join(path, 'result.json') : path
}
function formatValue(value) {
if (typeof value !== 'number') return String(value)
return Number.isInteger(value) ? String(value) : value.toFixed(value < 1 ? 3 : 1)
}
function run(argv) {
const [input, thresholdsArg] = argv
if (!input) {
console.error('usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]')
return 2
}
const reportPath = resolveReportPath(input)
const thresholdsPath = resolve(thresholdsArg || defaultThresholdsPath)
let report
let thresholds
try {
report = JSON.parse(readFileSync(reportPath, 'utf8'))
thresholds = JSON.parse(readFileSync(thresholdsPath, 'utf8'))
} catch (error) {
console.error(`cannot read report or thresholds: ${error instanceof Error ? error.message : error}`)
return 2
}
const result = evaluate(report, thresholds)
console.log(`\nperf thresholds: ${reportPath}`)
if (result.refused) {
console.error(`refused: ${result.reason}`)
return 2
}
console.table(result.rows.map(row => ({
scenario: row.scenario,
metric: row.metric,
value: formatValue(row.value),
limit: row.limit,
pass: row.pass ? 'ok' : `FAIL${row.note ? ` (${row.note})` : ''}`
})))
const failed = result.rows.filter(row => !row.pass).length
console.log(failed ? `${failed} of ${result.rows.length} checks failed` : `all ${result.rows.length} checks passed`)
return result.ok ? 0 : 1
}
module.exports = { evaluate, run, defaultThresholdsPath }
if (require.main === module) process.exitCode = run(process.argv.slice(2))
+89
View File
@@ -0,0 +1,89 @@
{
"note": "PERF-17 regression ceilings for the PERF-11 full-App benchmark (npm run perf:app). They apply ONLY to non-throttled runs (environment.rendererCpuThrottle = 1, the default) with the default GB_PERF_* scale, on the reference machine of the 2026-10-02 baseline (Intel Core Ultra X7 358H x16, 32 GB, DPR 1, 120 Hz). Limits are about 2-3x the 2026-10-02 values so that run-to-run noise does not fail; they catch regressions of the size seen before PERF-12 (e.g. ~130 long tasks / 9 s while streaming). Tighten them as PERF-12..PERF-14 land; do not loosen them to make a regression pass. Every listed scenario must be present unless marked optional, and report.status must equal requiredStatus.",
"requiredStatus": "passed",
"maxRendererCpuThrottle": 1,
"scenarios": {
"idle": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"typing-small": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"latency.p95": { "max": 25 },
"latency.max": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"idle-seeded": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"typing-seeded": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"latency.p95": { "max": 25 },
"latency.max": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations": {
"renderer.longTasks.count": { "max": 5 },
"renderer.longTasks.totalMs": { "max": 500 },
"renderer.frames.jankRatio": { "max": 0.15 },
"latency.p95": { "max": 350 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations-warm": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.jankRatio": { "max": 0.15 },
"latency.p95": { "max": 200 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations-hot": {
"optional": true,
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.jankRatio": { "max": 0.1 },
"latency.p95": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"open-long-conversation": {
"renderer.longTasks.count": { "max": 4 },
"renderer.longTasks.totalMs": { "max": 400 },
"latency.p95": { "max": 400 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"scroll-long-conversation": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"stream-seeded": {
"renderer.longTasks.count": { "max": 10 },
"renderer.longTasks.totalMs": { "max": 1500 },
"renderer.frames.p95": { "max": 34 },
"renderer.frames.jankRatio": { "max": 0.15 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"stream-and-type": {
"renderer.longTasks.count": { "max": 10 },
"renderer.longTasks.totalMs": { "max": 1500 },
"renderer.frames.p95": { "max": 34 },
"renderer.frames.jankRatio": { "max": 0.2 },
"latency.p95": { "max": 50 },
"main.eventLoopDelay.p99": { "max": 100 }
}
}
}
+3
View File
@@ -208,6 +208,9 @@ async function main() {
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
// PERF-17: GB_PERF_CHECK=1 gates the run on build/perf-thresholds.json
// (exit 1 on a regression, 2 when the report cannot be judged, e.g. throttled).
if (process.env.GB_PERF_CHECK === '1') process.exitCode = require('./check-perf-thresholds.cjs').run([resultPath])
}
main().catch(error => {
@@ -701,6 +701,29 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
- **React 规则:** 缓存会话保持滚动位置(`bf6c4a9`)在渲染期间写 ref,即当前 lint 的 3 条
`react-hooks/refs` 错误;启用 React Compiler 前需改为由状态派生顺序。
#### 2026-10-02 并行修复:补全分帧、远程批量写库、架构守护
- **补全分帧(`ChatHistoryPane.tsx`):** 新面板补全改为每帧 +20 条(20→40→60→80),每步后
贴底或按高度差保持阅读位置;“加载更早”、批次变化或笔记跳转到达时立即渲染完整批次。
- **渲染期 ref(`App.tsx`、`pane-order.ts`):** 会话面板顺序改为 state,用纯函数
`reconcilePaneOrder` 在渲染期按需调整,顺序不变时返回原数组;消除 3 条 `react-hooks/refs`。
- **远程批量写库(`remote-event-batcher.ts`、`assistant-database.ts`、`ipc.ts`):** 远程语义事件
按 checkpoint 一个事务提交(Agent 在 checkpoint 之后才可能 ACK),可恢复会话每批只读写一次
消息;非远程事件、`question`、`done`/`error`、显式 flush、256 条或 50 ms 计时器也会先提交。
只转发新写入的事件,顺序不变;提交失败整批回滚且之后拒绝继续提交。交互、后台/定时/通道
和重启恢复三条路径都已改用。当前 checkpoint 粒度是每条 transcript 记录而非每页,按页批量
需要改 `acp-remote-runtime.ts`。
- **架构守护(提前实施 `PERF-17` 的一部分):** `eslint.config.js` 新增只减不增的白名单规则:
`DatabaseSync`(19 个文件)、`src/main` 同步 fs(8 个文件)、组件直接订阅
`window.goodbuddy.*.on*`(6 个文件)、`App.tsx` `max-lines` 11700;文档演示和 vendor 脚本
不再参与 lint,`npx eslint .` 现为 0 错误。`build/perf-thresholds.json` +
`npm run perf:check`(或 `GB_PERF_CHECK=1 npm run perf:app`)检查不降速报告的阈值。
- **`npm run perf:app` A/B(改动前为 `9d8477b`):** 不降速 2 轮两版均通过 60 项阈值;打开长会话
从 1 个 Long Task / 56–61 ms、最大帧 50–58 ms 降到 0 个、最大帧 25–33 ms,其余持平。4× 降速
3 轮:打开长会话最大帧从 475–567 ms 降到 225–283 ms(帧 p95 因多出几帧小提交升到
175–258 ms);切换会话持平;流式场景两版方差都很大,未见一致变化。远程批量写库不在该基准
覆盖范围内,由单元测试和 `ipc.test.ts` 验证事务次数、顺序与回放去重。
### PERF-14 长列表虚拟化
- **优先级 / 状态:** P2 / 待开始
+134
View File
@@ -3,6 +3,86 @@ import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
// ---------------------------------------------------------------------------
// Architecture ratchets (docs/roadmap/product-performance-experience-improvement-plan.md,
// batch 3C, PERF-17). Existing code still violates these rules, so each rule
// carries an allowlist of the files that violated it when the rule was added.
// The allowlists must ONLY SHRINK: remove a file once it is fixed, never add
// one. New code that needs the forbidden API belongs in the place the plan
// assigns it (data process, async fs, domain store, layout shell).
// ---------------------------------------------------------------------------
// PERF-16: SQLite belongs to a dedicated data process (utilityProcess); Main
// only routes messages. Files that imported DatabaseSync from 'node:sqlite'
// (including `import type`) on 2026-10-02. Shrink only.
const databaseSyncAllowlist = [
'src/agent-daemon/agent-model-gateway.ts',
'src/agent-daemon/event-journal.ts',
'src/agent-daemon/installation-cleanup.ts',
'src/agent-daemon/runtime-owner-registry.ts',
'src/agent-daemon/semantic-prompt-store.ts',
'src/main/agent/runtime-session-binding-store.ts',
'src/main/assistant/assistant-database.ts',
'src/main/assistant/assistant-storage-upgrade.ts',
'src/main/assistant/story-graph-reader.ts',
'src/main/assistant/subagent-progress-storage.ts',
'src/main/assistant/supervision-experiences.ts',
'src/main/assistant/supervision-review-store.ts',
'src/main/assistant/supervision-stories.ts',
'src/main/assistant/supervision-suggestions.ts',
'src/main/assistant/supervision-timeline.ts',
'src/main/conversation-attachment-storage.ts',
'src/main/knowledge/external/external-knowledge-store.ts',
'src/main/knowledge/knowledge-database.ts',
'src/shared/node/private-sqlite-database.ts'
]
// PERF-15/PERF-16 rule 1: Main must not block its event loop on file IO.
// Synchronous fs functions; also caught as `fs.xxxSync` member access.
const syncFsFunctions = [
'accessSync', 'appendFileSync', 'chmodSync', 'chownSync', 'closeSync', 'copyFileSync', 'cpSync',
'existsSync', 'fchmodSync', 'fchownSync', 'fdatasyncSync', 'fstatSync', 'fsyncSync', 'ftruncateSync',
'futimesSync', 'globSync', 'lchmodSync', 'lchownSync', 'linkSync', 'lstatSync', 'lutimesSync',
'mkdirSync', 'mkdtempSync', 'opendirSync', 'openSync', 'readdirSync', 'readFileSync', 'readlinkSync',
'readSync', 'readvSync', 'realpathSync', 'renameSync', 'rmdirSync', 'rmSync', 'statfsSync', 'statSync',
'symlinkSync', 'truncateSync', 'unlinkSync', 'utimesSync', 'writeFileSync', 'writeSync', 'writevSync'
]
const syncFsMessage = 'Synchronous fs APIs block the Main event loop (PERF-17). Use node:fs/promises, or move the work to the data process (PERF-16).'
const databaseSyncRestriction = [{
name: 'node:sqlite',
importNames: ['DatabaseSync'],
message: 'DatabaseSync may only be used by the data process (PERF-16); other code must go through its async interface (PERF-15). The allowlist in eslint.config.js must only shrink.'
}]
const syncFsRestriction = ['node:fs', 'fs', 'original-fs'].map(name => ({ name, importNames: syncFsFunctions, message: syncFsMessage }))
// Files under src/main (non-test) that used synchronous fs APIs on 2026-10-02. Shrink only.
const mainSyncFsAllowlist = [
'src/main/agent/paged-output-store.ts',
'src/main/assistant/assistant-database.ts',
'src/main/assistant/assistant-storage-upgrade.ts',
'src/main/conversation-attachment-storage.ts',
'src/main/magic-notes/magic-note-storage.ts',
'src/main/portable-user-data.ts',
'src/main/remote-agent/control-plane-package-installer.ts',
'src/main/windows-notification-identity.ts'
]
// PERF-13 rule 2: IPC subscriptions live in domain stores (*.ts), components
// subscribe to stores through selectors. React component files (*.tsx) that
// subscribed directly to window.goodbuddy.*.on* on 2026-10-02. Shrink only.
const rendererIpcSubscriptionAllowlist = [
'src/renderer/src/App.tsx',
'src/renderer/src/DocumentResultPreview.tsx',
'src/renderer/src/MagicNotesPanel.tsx',
'src/renderer/src/MagicNotesWorkspace.tsx',
'src/renderer/src/RightAssistantSidebar.tsx',
'src/renderer/src/StaticHtmlPreview.tsx'
]
const rendererIpcSubscriptionMessage = 'Components must not subscribe to window.goodbuddy.*.on* directly (PERF-17). Move the subscription into a domain store and read it with a selector (PERF-13).'
// PERF-13 goal: App.tsx shrinks to a layout shell. 11,635 lines on
// 2026-10-02, ceiling rounded up to the next 100. Only ever lower this number.
const appTsxMaxLines = 11700
export default tseslint.config(
{
ignores: [
@@ -12,6 +92,8 @@ export default tseslint.config(
'coverage/**',
'dist/**',
'docs/**/*-demo.*',
'docs/**/*-demo-*.js',
'docs/**/vendor/**',
'node_modules/**',
'out/**',
'shareserver/**'
@@ -84,5 +166,57 @@ export default tseslint.config(
...globals.vitest
}
}
},
// --- Architecture ratchets (PERF-17), see allowlists at the top. ---------
// Rules a (DatabaseSync) and b (sync fs) share `no-restricted-imports`, and a
// later flat-config block replaces an earlier block's options for the same
// rule. The blocks below therefore partition the files so that each file
// gets exactly the restrictions it is subject to.
// DatabaseSync outside the data process (all of src, both value and type imports).
{
files: ['src/**/*.{ts,tsx}'],
ignores: ['**/*.test.{ts,tsx}', ...databaseSyncAllowlist],
rules: { 'no-restricted-imports': ['error', { paths: databaseSyncRestriction }] }
},
// Synchronous fs in Main: files not allowlisted for either rule get both restrictions.
{
files: ['src/main/**/*.ts'],
ignores: ['**/*.test.ts', ...mainSyncFsAllowlist, ...databaseSyncAllowlist],
rules: { 'no-restricted-imports': ['error', { paths: [...databaseSyncRestriction, ...syncFsRestriction] }] }
},
// Main files allowlisted for DatabaseSync but not for sync fs.
{
files: databaseSyncAllowlist.filter(file => file.startsWith('src/main/')),
ignores: mainSyncFsAllowlist,
rules: { 'no-restricted-imports': ['error', { paths: syncFsRestriction }] }
},
// `fs.readFileSync(...)` style access through a namespace/default import.
{
files: ['src/main/**/*.ts'],
ignores: ['**/*.test.ts', ...mainSyncFsAllowlist],
rules: {
'no-restricted-syntax': ['error', {
selector: `MemberExpression[object.name=/^(fs|nodeFs|fsSync|originalFs)$/][property.name=/^(${syncFsFunctions.join('|')})$/]`,
message: syncFsMessage
}]
}
},
// Direct IPC subscriptions in renderer components.
{
files: ['src/renderer/src/**/*.tsx'],
ignores: ['**/*.test.tsx', ...rendererIpcSubscriptionAllowlist],
rules: {
'no-restricted-syntax': ['error', {
selector: "CallExpression[callee.property.name=/^on[A-Z]/] > MemberExpression.callee MemberExpression[object.name='window'][property.name='goodbuddy']",
message: rendererIpcSubscriptionMessage
}]
}
},
// App.tsx size ceiling.
{
files: ['src/renderer/src/App.tsx'],
rules: {
'max-lines': ['error', { max: appTsxMaxLines, skipBlankLines: false, skipComments: false }]
}
}
)
+1
View File
@@ -26,6 +26,7 @@
"test": "vitest run",
"test:watch": "vitest",
"perf:app": "node build/run-app-perf.cjs",
"perf:check": "node build/check-perf-thresholds.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",
+136
View File
@@ -0,0 +1,136 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
interface ThresholdRow {
scenario: string
metric: string
value: number | string | null
limit: string
pass: boolean
note?: string
}
interface EvaluateResult {
ok: boolean
refused: boolean
reason?: string
rows: ThresholdRow[]
}
interface PerfThresholds {
requiredStatus: string
scenarios: Record<string, Record<string, { max?: number } | boolean>>
}
interface PerfCheckerModule {
evaluate(report: unknown, thresholds: PerfThresholds): EvaluateResult
defaultThresholdsPath: string
}
const require = createRequire(import.meta.url)
const checker = require('../build/check-perf-thresholds.cjs') as PerfCheckerModule
const thresholds = JSON.parse(readFileSync(checker.defaultThresholdsPath, 'utf8')) as PerfThresholds
// A report shaped like tests/support/app-perf-driver.mjs output, with values
// close to the 2026-10-02 non-throttled baseline.
function scenario(id: string, overrides: { longTasks?: number, longTaskMs?: number, frameP95?: number, latencyP95?: number | null } = {}) {
const latencyP95 = overrides.latencyP95 === undefined ? 10 : overrides.latencyP95
return {
id,
durationMs: 3000,
renderer: {
longTasks: { count: overrides.longTasks ?? 0, totalMs: overrides.longTaskMs ?? 0, maxMs: overrides.longTaskMs ?? 0 },
frames: { p50: 8.3, p95: overrides.frameP95 ?? 8.5, max: 17, jankRatio: 0.01 }
},
latency: latencyP95 === null ? null : { p50: latencyP95 / 2, p95: latencyP95, max: latencyP95 + 2, count: 10 },
main: { eventLoopDelay: { p99: 20, max: 30 } }
}
}
function passingReport() {
return {
status: 'passed',
environment: { rendererCpuThrottle: 1 },
scenarios: [
scenario('idle', { latencyP95: null }),
scenario('typing-small', { latencyP95: 8.5 }),
scenario('idle-seeded', { latencyP95: null }),
scenario('typing-seeded', { latencyP95: 8.7 }),
scenario('switch-conversations', { longTasks: 1, longTaskMs: 120, latencyP95: 160 }),
scenario('switch-conversations-warm', { latencyP95: 68 }),
scenario('switch-conversations-hot', { latencyP95: 32 }),
scenario('open-long-conversation', { longTasks: 1, longTaskMs: 104, latencyP95: 140 }),
scenario('scroll-long-conversation', { latencyP95: null }),
scenario('stream-seeded', { longTasks: 1, longTaskMs: 250, latencyP95: null }),
scenario('stream-and-type', { longTasks: 1, longTaskMs: 230, frameP95: 16.6, latencyP95: 17 })
]
}
}
describe('perf threshold checker', () => {
it('accepts a report at baseline levels', () => {
const result = checker.evaluate(passingReport(), thresholds)
expect(result.refused).toBe(false)
expect(result.rows.filter(row => !row.pass)).toEqual([])
expect(result.ok).toBe(true)
})
it('every configured scenario has a check for each metric it lists', () => {
const result = checker.evaluate(passingReport(), thresholds)
for (const [id, limits] of Object.entries(thresholds.scenarios)) {
const metrics = Object.keys(limits).filter(key => key !== 'optional')
expect(result.rows.filter(row => row.scenario === id).map(row => row.metric).sort()).toEqual(metrics.sort())
}
})
it('fails a streaming regression of the pre-PERF-12 size and a slow switch', () => {
const report = passingReport()
report.scenarios[9] = scenario('stream-seeded', { longTasks: 130, longTaskMs: 9000, frameP95: 83, latencyP95: null })
report.scenarios[4] = scenario('switch-conversations', { longTasks: 1, longTaskMs: 120, latencyP95: 900 })
const result = checker.evaluate(report, thresholds)
expect(result.ok).toBe(false)
const failures = result.rows.filter(row => !row.pass).map(row => `${row.scenario} ${row.metric}`)
expect(failures).toEqual(expect.arrayContaining([
'stream-seeded renderer.longTasks.count',
'stream-seeded renderer.longTasks.totalMs',
'stream-seeded renderer.frames.p95',
'switch-conversations latency.p95'
]))
})
it('fails when the report status is not passed', () => {
const report = { ...passingReport(), status: 'passed-with-renderer-errors' }
const result = checker.evaluate(report, thresholds)
expect(result.ok).toBe(false)
expect(result.rows.find(row => row.metric === 'status')?.pass).toBe(false)
})
it('fails when a required scenario or metric is missing, but not an optional scenario', () => {
const report = passingReport()
report.scenarios = report.scenarios.filter(entry => entry.id !== 'stream-and-type' && entry.id !== 'switch-conversations-hot')
report.scenarios[1] = { ...scenario('typing-small'), latency: null }
const result = checker.evaluate(report, thresholds)
expect(result.ok).toBe(false)
const failures = result.rows.filter(row => !row.pass)
expect(failures).toEqual(expect.arrayContaining([
expect.objectContaining({ scenario: 'stream-and-type', metric: '(present)', value: 'missing' }),
expect.objectContaining({ scenario: 'typing-small', metric: 'latency.p95', value: null, note: 'metric missing' })
]))
expect(failures.some(row => row.scenario === 'switch-conversations-hot')).toBe(false)
})
it('refuses to judge a throttled run', () => {
const report = { ...passingReport(), environment: { rendererCpuThrottle: 4 } }
const result = checker.evaluate(report, thresholds)
expect(result.refused).toBe(true)
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/throttle 4x/)
})
it('ships thresholds that require a clean, non-throttled report', () => {
expect(thresholds.requiredStatus).toBe('passed')
expect(join(checker.defaultThresholdsPath)).toMatch(/perf-thresholds\.json$/)
})
})