diff --git a/build/check-perf-thresholds.cjs b/build/check-perf-thresholds.cjs new file mode 100644 index 00000000..52f509f5 --- /dev/null +++ b/build/check-perf-thresholds.cjs @@ -0,0 +1,99 @@ +// PERF-17 regression gate for the PERF-11 full-App benchmark report. +// Usage: node build/check-perf-thresholds.cjs [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 [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)) diff --git a/build/perf-thresholds.json b/build/perf-thresholds.json new file mode 100644 index 00000000..04027517 --- /dev/null +++ b/build/perf-thresholds.json @@ -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 } + } + } +} diff --git a/build/run-app-perf.cjs b/build/run-app-perf.cjs index 5a884e66..65c59749 100644 --- a/build/run-app-perf.cjs +++ b/build/run-app-perf.cjs @@ -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 => { diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index a16ed90b..74129168 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -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 / 待开始 diff --git a/eslint.config.js b/eslint.config.js index 5dd03b78..501e59ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 }] + } } ) diff --git a/package.json b/package.json index 08c590dd..df6221ee 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: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", diff --git a/tests/perf-thresholds.test.ts b/tests/perf-thresholds.test.ts new file mode 100644 index 00000000..67925484 --- /dev/null +++ b/tests/perf-thresholds.test.ts @@ -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> +} + +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$/) + }) +})