|
| 1 | +import { spawn } from 'child_process' |
| 2 | +import path from 'path' |
| 3 | + |
| 4 | +import { describe, test, expect, beforeAll } from 'bun:test' |
| 5 | + |
| 6 | +import { |
| 7 | + isTmuxAvailable, |
| 8 | + isSDKBuilt, |
| 9 | + sleep, |
| 10 | + ensureCliTestEnv, |
| 11 | + getDefaultCliEnv, |
| 12 | + parseRerenderLogs, |
| 13 | + analyzeRerenders, |
| 14 | + clearCliDebugLog, |
| 15 | +} from './test-utils' |
| 16 | + |
| 17 | +const CLI_PATH = path.join(__dirname, '../index.tsx') |
| 18 | +const DEBUG_LOG_PATH = path.join(__dirname, '../../../debug/cli.jsonl') |
| 19 | +const TIMEOUT_MS = 45000 |
| 20 | +const tmuxAvailable = isTmuxAvailable() |
| 21 | +const sdkBuilt = isSDKBuilt() |
| 22 | + |
| 23 | +ensureCliTestEnv() |
| 24 | + |
| 25 | +/** |
| 26 | + * Re-render performance thresholds. |
| 27 | + * These values are based on observed behavior after optimization. |
| 28 | + * If these thresholds are exceeded, it likely indicates a performance regression. |
| 29 | + */ |
| 30 | +const RERENDER_THRESHOLDS = { |
| 31 | + /** Maximum total re-renders across all messages for a simple prompt */ |
| 32 | + maxTotalRerenders: 20, |
| 33 | + |
| 34 | + /** Maximum re-renders for any single message */ |
| 35 | + maxRerenderPerMessage: 12, |
| 36 | + |
| 37 | + /** |
| 38 | + * Props that should NEVER appear in changedProps after memoization fixes. |
| 39 | + * If these appear, it means callbacks are not properly memoized. |
| 40 | + */ |
| 41 | + forbiddenChangedProps: [ |
| 42 | + 'onOpenFeedback', |
| 43 | + 'onToggleCollapsed', |
| 44 | + 'onBuildFast', |
| 45 | + 'onBuildMax', |
| 46 | + 'onCloseFeedback', |
| 47 | + ], |
| 48 | + |
| 49 | + /** |
| 50 | + * Maximum times streamingAgents should appear in changedProps. |
| 51 | + * After Set stabilization, this should be very low. |
| 52 | + */ |
| 53 | + maxStreamingAgentChanges: 5, |
| 54 | +} |
| 55 | + |
| 56 | +// Utility to run tmux commands |
| 57 | +function tmux(args: string[]): Promise<string> { |
| 58 | + return new Promise((resolve, reject) => { |
| 59 | + const proc = spawn('tmux', args, { stdio: 'pipe' }) |
| 60 | + let stdout = '' |
| 61 | + let stderr = '' |
| 62 | + |
| 63 | + proc.stdout?.on('data', (data) => { |
| 64 | + stdout += data.toString() |
| 65 | + }) |
| 66 | + |
| 67 | + proc.stderr?.on('data', (data) => { |
| 68 | + stderr += data.toString() |
| 69 | + }) |
| 70 | + |
| 71 | + proc.on('close', (code) => { |
| 72 | + if (code === 0) { |
| 73 | + resolve(stdout) |
| 74 | + } else { |
| 75 | + reject(new Error(`tmux command failed: ${stderr}`)) |
| 76 | + } |
| 77 | + }) |
| 78 | + }) |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Send input to the CLI using bracketed paste mode. |
| 83 | + * Standard send-keys doesn't work with OpenTUI - see tmux.knowledge.md |
| 84 | + */ |
| 85 | +async function sendCliInput( |
| 86 | + sessionName: string, |
| 87 | + text: string, |
| 88 | +): Promise<void> { |
| 89 | + await tmux([ |
| 90 | + 'send-keys', |
| 91 | + '-t', |
| 92 | + sessionName, |
| 93 | + '-l', |
| 94 | + `\x1b[200~${text}\x1b[201~`, |
| 95 | + ]) |
| 96 | +} |
| 97 | + |
| 98 | +describe.skipIf(!tmuxAvailable || !sdkBuilt)( |
| 99 | + 'Re-render Performance Tests', |
| 100 | + () => { |
| 101 | + beforeAll(async () => { |
| 102 | + if (!tmuxAvailable) { |
| 103 | + console.log('\n⚠️ Skipping re-render perf tests - tmux not installed') |
| 104 | + console.log( |
| 105 | + '📦 Install with: brew install tmux (macOS) or sudo apt-get install tmux (Linux)\n', |
| 106 | + ) |
| 107 | + } |
| 108 | + if (!sdkBuilt) { |
| 109 | + console.log('\n⚠️ Skipping re-render perf tests - SDK not built') |
| 110 | + console.log('🔨 Build SDK: cd sdk && bun run build\n') |
| 111 | + } |
| 112 | + if (tmuxAvailable && sdkBuilt) { |
| 113 | + const envVars = getDefaultCliEnv() |
| 114 | + const entries = Object.entries(envVars) |
| 115 | + // Propagate environment into tmux server |
| 116 | + await Promise.all( |
| 117 | + entries.map(([key, value]) => |
| 118 | + tmux(['set-environment', '-g', key, value]).catch(() => {}), |
| 119 | + ), |
| 120 | + ) |
| 121 | + // Enable performance testing |
| 122 | + await tmux(['set-environment', '-g', 'CODEBUFF_PERF_TEST', 'true']) |
| 123 | + } |
| 124 | + }) |
| 125 | + |
| 126 | + test( |
| 127 | + 'MessageBlock re-renders stay within acceptable limits', |
| 128 | + async () => { |
| 129 | + const sessionName = 'codebuff-perf-test-' + Date.now() |
| 130 | + |
| 131 | + // Clear the debug log before test |
| 132 | + clearCliDebugLog(DEBUG_LOG_PATH) |
| 133 | + |
| 134 | + try { |
| 135 | + // Start CLI with perf testing enabled |
| 136 | + await tmux([ |
| 137 | + 'new-session', |
| 138 | + '-d', |
| 139 | + '-s', |
| 140 | + sessionName, |
| 141 | + '-x', |
| 142 | + '120', |
| 143 | + '-y', |
| 144 | + '30', |
| 145 | + `CODEBUFF_PERF_TEST=true bun run ${CLI_PATH}`, |
| 146 | + ]) |
| 147 | + |
| 148 | + // Wait for CLI to initialize |
| 149 | + await sleep(5000) |
| 150 | + |
| 151 | + // Send a simple prompt that will trigger streaming response |
| 152 | + await sendCliInput(sessionName, 'what is 2+2') |
| 153 | + await tmux(['send-keys', '-t', sessionName, 'Enter']) |
| 154 | + |
| 155 | + // Wait for response to complete (longer wait for API response) |
| 156 | + await sleep(15000) |
| 157 | + |
| 158 | + // Parse and analyze the re-render logs |
| 159 | + const entries = parseRerenderLogs(DEBUG_LOG_PATH) |
| 160 | + const analysis = analyzeRerenders(entries) |
| 161 | + |
| 162 | + // Log analysis for debugging |
| 163 | + console.log('\n📊 Re-render Analysis:') |
| 164 | + console.log(` Total re-renders: ${analysis.totalRerenders}`) |
| 165 | + console.log( |
| 166 | + ` Max per message: ${analysis.maxRerenderPerMessage}`, |
| 167 | + ) |
| 168 | + console.log(` Messages tracked: ${analysis.rerendersByMessage.size}`) |
| 169 | + if (analysis.propChangeFrequency.size > 0) { |
| 170 | + console.log(' Prop change frequency:') |
| 171 | + for (const [prop, count] of analysis.propChangeFrequency) { |
| 172 | + console.log(` - ${prop}: ${count}`) |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // Assert total re-renders within threshold |
| 177 | + expect(analysis.totalRerenders).toBeLessThanOrEqual( |
| 178 | + RERENDER_THRESHOLDS.maxTotalRerenders, |
| 179 | + ) |
| 180 | + |
| 181 | + // Assert max re-renders per message within threshold |
| 182 | + expect(analysis.maxRerenderPerMessage).toBeLessThanOrEqual( |
| 183 | + RERENDER_THRESHOLDS.maxRerenderPerMessage, |
| 184 | + ) |
| 185 | + |
| 186 | + // Assert forbidden props don't appear (memoization check) |
| 187 | + for (const forbiddenProp of RERENDER_THRESHOLDS.forbiddenChangedProps) { |
| 188 | + const count = analysis.propChangeFrequency.get(forbiddenProp) || 0 |
| 189 | + if (count > 0) { |
| 190 | + console.log( |
| 191 | + `\n❌ Forbidden prop '${forbiddenProp}' changed ${count} times - callback not memoized!`, |
| 192 | + ) |
| 193 | + } |
| 194 | + expect(count).toBe(0) |
| 195 | + } |
| 196 | + |
| 197 | + // Assert streamingAgents changes within threshold |
| 198 | + const streamingAgentChanges = |
| 199 | + analysis.propChangeFrequency.get('streamingAgents') || 0 |
| 200 | + expect(streamingAgentChanges).toBeLessThanOrEqual( |
| 201 | + RERENDER_THRESHOLDS.maxStreamingAgentChanges, |
| 202 | + ) |
| 203 | + |
| 204 | + console.log('\n✅ Re-render performance within acceptable limits') |
| 205 | + } finally { |
| 206 | + // Cleanup tmux session |
| 207 | + try { |
| 208 | + await tmux(['kill-session', '-t', sessionName]) |
| 209 | + } catch { |
| 210 | + // Session may have already exited |
| 211 | + } |
| 212 | + } |
| 213 | + }, |
| 214 | + TIMEOUT_MS, |
| 215 | + ) |
| 216 | + |
| 217 | + test( |
| 218 | + 'Forbidden callback props are properly memoized', |
| 219 | + async () => { |
| 220 | + const sessionName = 'codebuff-memo-test-' + Date.now() |
| 221 | + |
| 222 | + clearCliDebugLog(DEBUG_LOG_PATH) |
| 223 | + |
| 224 | + try { |
| 225 | + await tmux([ |
| 226 | + 'new-session', |
| 227 | + '-d', |
| 228 | + '-s', |
| 229 | + sessionName, |
| 230 | + '-x', |
| 231 | + '120', |
| 232 | + '-y', |
| 233 | + '30', |
| 234 | + `CODEBUFF_PERF_TEST=true bun run ${CLI_PATH}`, |
| 235 | + ]) |
| 236 | + |
| 237 | + await sleep(5000) |
| 238 | + |
| 239 | + // Send multiple rapid prompts to stress test memoization |
| 240 | + await sendCliInput(sessionName, 'hi') |
| 241 | + await tmux(['send-keys', '-t', sessionName, 'Enter']) |
| 242 | + await sleep(8000) |
| 243 | + |
| 244 | + const entries = parseRerenderLogs(DEBUG_LOG_PATH) |
| 245 | + const analysis = analyzeRerenders(entries) |
| 246 | + |
| 247 | + // Check that none of the callback props appear in changed props |
| 248 | + const forbiddenPropsFound: string[] = [] |
| 249 | + for (const prop of RERENDER_THRESHOLDS.forbiddenChangedProps) { |
| 250 | + const count = analysis.propChangeFrequency.get(prop) || 0 |
| 251 | + if (count > 0) { |
| 252 | + forbiddenPropsFound.push(`${prop} (${count}x)`) |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + if (forbiddenPropsFound.length > 0) { |
| 257 | + console.log( |
| 258 | + `\n❌ Unmemoized callbacks detected: ${forbiddenPropsFound.join(', ')}`, |
| 259 | + ) |
| 260 | + } |
| 261 | + |
| 262 | + expect(forbiddenPropsFound).toHaveLength(0) |
| 263 | + } finally { |
| 264 | + try { |
| 265 | + await tmux(['kill-session', '-t', sessionName]) |
| 266 | + } catch {} |
| 267 | + } |
| 268 | + }, |
| 269 | + TIMEOUT_MS, |
| 270 | + ) |
| 271 | + }, |
| 272 | +) |
| 273 | + |
| 274 | +// Show helpful message when tests are skipped |
| 275 | +if (!tmuxAvailable) { |
| 276 | + describe('Re-render Performance - tmux Required', () => { |
| 277 | + test.skip('Install tmux for performance tests', () => {}) |
| 278 | + }) |
| 279 | +} |
| 280 | + |
| 281 | +if (!sdkBuilt) { |
| 282 | + describe('Re-render Performance - SDK Required', () => { |
| 283 | + test.skip('Build SDK: cd sdk && bun run build', () => {}) |
| 284 | + }) |
| 285 | +} |
0 commit comments