Skip to content

Commit 8a29161

Browse files
committed
Add integration test for unnecessary re-renders
1 parent ced52d6 commit 8a29161

File tree

6 files changed

+403
-2
lines changed

6 files changed

+403
-2
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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

Comments
 (0)