Skip to content

Commit 017bde1

Browse files
refactor: improve code quality and documentation in loop-runner and capture
Improvements to loop-runner.js: - Extract isValidJestRunnerPath() helper to reduce code duplication - Add comprehensive JSDoc comments for Jest version detection - Improve error messages with more context about detected versions - Add better documentation for runTests() method - Add validation for TestRunner class availability in Jest 30 Improvements to capture.js: - Extract _recordAsyncTiming() helper to reduce duplication - Add comprehensive JSDoc for _capturePerfAsync() with all parameters - Improve error handling in async looping (record timing before throwing) - Enhance shouldStopStability() documentation with algorithm details - Improve code organization with clearer comments These changes improve maintainability and debugging without changing behavior.
1 parent 4157534 commit 017bde1

2 files changed

Lines changed: 116 additions & 43 deletions

File tree

packages/codeflash/runtime/capture.js

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -267,26 +267,40 @@ const results = [];
267267
let db = null;
268268

269269
/**
270-
* Check if performance has stabilized (for internal looping).
271-
* Matches Python's pytest_plugin.should_stop() logic.
270+
* Check if performance has stabilized, allowing early stopping of benchmarks.
271+
* Matches Python's pytest_plugin.should_stop() logic for consistency.
272+
*
273+
* Performance is considered stable when BOTH conditions are met:
274+
* 1. CENTER: All recent measurements are within ±10% of the median
275+
* 2. SPREAD: The range (max-min) is within 10% of the minimum
276+
*
277+
* @param {Array<number>} runtimes - Array of runtime measurements in microseconds
278+
* @param {number} window - Number of recent measurements to check
279+
* @param {number} minWindowSize - Minimum samples required before checking
280+
* @returns {boolean} True if performance has stabilized
272281
*/
273282
function shouldStopStability(runtimes, window, minWindowSize) {
274283
if (runtimes.length < window || runtimes.length < minWindowSize) {
275284
return false;
276285
}
286+
277287
const recent = runtimes.slice(-window);
278288
const recentSorted = [...recent].sort((a, b) => a - b);
279289
const mid = Math.floor(window / 2);
280290
const median = window % 2 ? recentSorted[mid] : (recentSorted[mid - 1] + recentSorted[mid]) / 2;
281291

292+
// Check CENTER: all recent points must be close to median
282293
for (const r of recent) {
283294
if (Math.abs(r - median) / median > STABILITY_CENTER_TOLERANCE) {
284295
return false;
285296
}
286297
}
298+
299+
// Check SPREAD: range must be small relative to minimum
287300
const rMin = recentSorted[0];
288301
const rMax = recentSorted[recentSorted.length - 1];
289302
if (rMin === 0) return false;
303+
290304
return (rMax - rMin) / rMin <= STABILITY_SPREAD_TOLERANCE;
291305
}
292306

@@ -775,11 +789,40 @@ function capturePerf(funcName, lineId, fn, ...args) {
775789
return lastReturnValue;
776790
}
777791

792+
/**
793+
* Helper to record async timing and update state.
794+
* @private
795+
*/
796+
function _recordAsyncTiming(startTime, testStdoutTag, durationNs, runtimes) {
797+
console.log(`!######${testStdoutTag}:${durationNs}######!`);
798+
sharedPerfState.totalLoopsCompleted++;
799+
if (durationNs > 0) {
800+
runtimes.push(durationNs / 1000);
801+
}
802+
}
803+
778804
/**
779805
* Async helper for capturePerf to handle async function looping.
780806
* This function awaits promises and continues the benchmark loop properly.
781807
*
782808
* @private
809+
* @param {string} funcName - Name of the function being benchmarked
810+
* @param {string} lineId - Line identifier for this capture point
811+
* @param {Function} fn - The async function to benchmark
812+
* @param {Array} args - Arguments to pass to fn
813+
* @param {Promise} firstPromise - The first promise that was already started
814+
* @param {number} firstStartTime - Start time of the first execution
815+
* @param {string} firstTestStdoutTag - Timing marker tag for the first execution
816+
* @param {string} safeModulePath - Sanitized module path
817+
* @param {string|null} testClassName - Test class name (if any)
818+
* @param {string} safeTestFunctionName - Sanitized test function name
819+
* @param {string} invocationKey - Unique key for this invocation
820+
* @param {Array<number>} runtimes - Array to collect runtimes for stability checking
821+
* @param {number} batchSize - Number of iterations per batch
822+
* @param {number} startBatchIndex - Index where async looping started
823+
* @param {boolean} shouldLoop - Whether to continue looping
824+
* @param {Function} getStabilityWindow - Function to get stability window size
825+
* @returns {Promise} The last return value from fn
783826
*/
784827
async function _capturePerfAsync(
785828
funcName, lineId, fn, args,
@@ -796,61 +839,52 @@ async function _capturePerfAsync(
796839
lastReturnValue = await firstPromise;
797840
const asyncEndTime = getTimeNs();
798841
const asyncDurationNs = getDurationNs(firstStartTime, asyncEndTime);
799-
console.log(`!######${firstTestStdoutTag}:${asyncDurationNs}######!`);
800-
sharedPerfState.totalLoopsCompleted++;
801-
if (asyncDurationNs > 0) {
802-
runtimes.push(asyncDurationNs / 1000);
803-
}
842+
_recordAsyncTiming(firstStartTime, firstTestStdoutTag, asyncDurationNs, runtimes);
804843
} catch (err) {
805844
const asyncEndTime = getTimeNs();
806845
const asyncDurationNs = getDurationNs(firstStartTime, asyncEndTime);
807-
console.log(`!######${firstTestStdoutTag}:${asyncDurationNs}######!`);
808-
sharedPerfState.totalLoopsCompleted++;
809-
throw err;
846+
_recordAsyncTiming(firstStartTime, firstTestStdoutTag, asyncDurationNs, runtimes);
847+
lastError = err;
848+
// Don't throw yet - we want to record the timing first
849+
}
850+
851+
// If first iteration failed, stop and throw
852+
if (lastError) {
853+
throw lastError;
810854
}
811855

812856
// Continue looping for remaining iterations
813857
for (let batchIndex = startBatchIndex + 1; batchIndex < batchSize; batchIndex++) {
814-
// Check shared time limit
858+
// Check exit conditions before starting next iteration
815859
if (shouldLoop && checkSharedTimeLimit()) {
816860
break;
817861
}
818862

819-
// Check if this invocation has already reached stability
820863
if (getPerfStabilityCheck() && sharedPerfState.stableInvocations[invocationKey]) {
821864
break;
822865
}
823866

824-
// Get the global loop index for this invocation
825867
const loopIndex = getInvocationLoopIndex(invocationKey);
826-
827-
// Check if we've exceeded max loops
828868
if (loopIndex > getPerfLoopCount()) {
829869
break;
830870
}
831871

832-
// Get invocation index for the timing marker
872+
// Generate timing marker identifiers
833873
const testId = `${safeModulePath}:${testClassName}:${safeTestFunctionName}:${lineId}:${loopIndex}`;
834874
const invocationIndex = getInvocationIndex(testId);
835875
const invocationId = `${lineId}_${invocationIndex}`;
836-
837-
// Format stdout tag
838876
const testStdoutTag = `${safeModulePath}:${testClassName ? testClassName + '.' : ''}${safeTestFunctionName}:${funcName}:${loopIndex}:${invocationId}`;
839877

878+
// Execute and time the function
840879
try {
841880
const startTime = getTimeNs();
842881
lastReturnValue = await fn(...args);
843882
const endTime = getTimeNs();
844883
const durationNs = getDurationNs(startTime, endTime);
845884

846-
console.log(`!######${testStdoutTag}:${durationNs}######!`);
847-
sharedPerfState.totalLoopsCompleted++;
848-
849-
if (durationNs > 0) {
850-
runtimes.push(durationNs / 1000);
851-
}
885+
_recordAsyncTiming(startTime, testStdoutTag, durationNs, runtimes);
852886

853-
// Check stability
887+
// Check if we've reached performance stability
854888
if (getPerfStabilityCheck() && runtimes.length >= getPerfMinLoops()) {
855889
const window = getStabilityWindow();
856890
if (shouldStopStability(runtimes, window, getPerfMinLoops())) {

packages/codeflash/runtime/loop-runner.js

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,26 @@ const { createRequire } = require('module');
3434
const path = require('path');
3535
const fs = require('fs');
3636

37+
/**
38+
* Validates that a jest-runner path is valid by checking for package.json.
39+
* @param {string} jestRunnerPath - Path to check
40+
* @returns {boolean} True if valid jest-runner package
41+
*/
42+
function isValidJestRunnerPath(jestRunnerPath) {
43+
if (!fs.existsSync(jestRunnerPath)) {
44+
return false;
45+
}
46+
const packageJsonPath = path.join(jestRunnerPath, 'package.json');
47+
return fs.existsSync(packageJsonPath);
48+
}
49+
3750
/**
3851
* Resolve jest-runner with monorepo support.
3952
* Uses CODEFLASH_MONOREPO_ROOT environment variable if available,
4053
* otherwise walks up the directory tree looking for node_modules/jest-runner.
54+
*
55+
* @returns {string} Path to jest-runner package
56+
* @throws {Error} If jest-runner cannot be found
4157
*/
4258
function resolveJestRunner() {
4359
// Try standard resolution first (works in simple projects)
@@ -51,11 +67,8 @@ function resolveJestRunner() {
5167
const monorepoRoot = process.env.CODEFLASH_MONOREPO_ROOT;
5268
if (monorepoRoot) {
5369
const jestRunnerPath = path.join(monorepoRoot, 'node_modules', 'jest-runner');
54-
if (fs.existsSync(jestRunnerPath)) {
55-
const packageJsonPath = path.join(jestRunnerPath, 'package.json');
56-
if (fs.existsSync(packageJsonPath)) {
57-
return jestRunnerPath;
58-
}
70+
if (isValidJestRunnerPath(jestRunnerPath)) {
71+
return jestRunnerPath;
5972
}
6073
}
6174

@@ -71,11 +84,8 @@ function resolveJestRunner() {
7184

7285
// Try node_modules/jest-runner at this level
7386
const jestRunnerPath = path.join(currentDir, 'node_modules', 'jest-runner');
74-
if (fs.existsSync(jestRunnerPath)) {
75-
const packageJsonPath = path.join(jestRunnerPath, 'package.json');
76-
if (fs.existsSync(packageJsonPath)) {
77-
return jestRunnerPath;
78-
}
87+
if (isValidJestRunnerPath(jestRunnerPath)) {
88+
return jestRunnerPath;
7989
}
8090

8191
// Check if this is a workspace root (has monorepo markers)
@@ -91,11 +101,18 @@ function resolveJestRunner() {
91101
currentDir = path.dirname(currentDir);
92102
}
93103

94-
throw new Error('jest-runner not found');
104+
throw new Error(
105+
'jest-runner not found. Please install jest-runner in your project: npm install --save-dev jest-runner'
106+
);
95107
}
96108

97-
// Try to load jest-runner from the PROJECT's node_modules, not from codeflash package
98-
// This ensures we use the same version of jest-runner that the project uses
109+
/**
110+
* Jest runner components - loaded dynamically from project's node_modules.
111+
* This ensures we use the same version that the project uses.
112+
*
113+
* Jest 30+ uses TestRunner class with event-based architecture.
114+
* Jest 29 uses runTest function for direct test execution.
115+
*/
99116
let TestRunner;
100117
let runTest;
101118
let jestRunnerAvailable = false;
@@ -110,19 +127,24 @@ try {
110127
TestRunner = jestRunner.default || jestRunner.TestRunner;
111128

112129
if (TestRunner && TestRunner.prototype && typeof TestRunner.prototype.runTests === 'function') {
113-
// Jest 30+ - use TestRunner class
130+
// Jest 30+ - use TestRunner class with event emitter pattern
114131
jestVersion = 30;
115132
jestRunnerAvailable = true;
116133
} else {
117134
// Try Jest 29 style import
118135
try {
119136
runTest = internalRequire('./runTest').default;
120137
if (typeof runTest === 'function') {
138+
// Jest 29 - use direct runTest function
121139
jestVersion = 29;
122140
jestRunnerAvailable = true;
123141
}
124142
} catch (e29) {
125143
// Neither Jest 29 nor 30 style import worked
144+
const errorMsg = `Found jest-runner at ${jestRunnerPath} but could not load it. ` +
145+
`This may indicate an unsupported Jest version. ` +
146+
`Supported versions: Jest 29.x and Jest 30.x`;
147+
console.error(errorMsg);
126148
jestRunnerAvailable = false;
127149
}
128150
}
@@ -203,15 +225,22 @@ class CodeflashLoopRunner {
203225
'codeflash/loop-runner requires jest-runner to be installed.\n' +
204226
'Please install it: npm install --save-dev jest-runner\n\n' +
205227
'If you are using Vitest, the loop-runner is not needed - ' +
206-
'Vitest projects use external looping handled by the Python runner.'
228+
'Vitest projects use internal looping handled by capturePerf().'
207229
);
208230
}
231+
209232
this._globalConfig = globalConfig;
210233
this._context = context || {};
211234
this._eventEmitter = new SimpleEventEmitter();
212235

213236
// For Jest 30+, create an instance of the base TestRunner for delegation
214-
if (jestVersion >= 30 && TestRunner) {
237+
if (jestVersion >= 30) {
238+
if (!TestRunner) {
239+
throw new Error(
240+
`Jest ${jestVersion} detected but TestRunner class not available. ` +
241+
`This indicates an internal error in loop-runner initialization.`
242+
);
243+
}
215244
this._baseRunner = new TestRunner(globalConfig, context);
216245
}
217246
}
@@ -229,7 +258,17 @@ class CodeflashLoopRunner {
229258
}
230259

231260
/**
232-
* Run tests with batched looping for fair distribution.
261+
* Run tests with batched looping for fair distribution across all test invocations.
262+
*
263+
* This implements the batched looping strategy:
264+
* Batch 1: Test1(N loops) → Test2(N loops) → Test3(N loops)
265+
* Batch 2: Test1(N loops) → Test2(N loops) → Test3(N loops)
266+
* ...until time budget exhausted or max batches reached
267+
*
268+
* @param {Array} tests - Jest test objects to run
269+
* @param {Object} watcher - Jest watcher for interrupt handling
270+
* @param {Object} options - Jest runner options
271+
* @returns {Promise<void>}
233272
*/
234273
async runTests(tests, watcher, options) {
235274
const startTime = Date.now();
@@ -238,7 +277,7 @@ class CodeflashLoopRunner {
238277
let allConsoleOutput = '';
239278

240279
// Time limit check - must use local time tracking because Jest runs tests
241-
// in worker processes, so shared state from capture.js isn't accessible here
280+
// in isolated worker processes where shared state from capture.js isn't accessible
242281
const checkTimeLimit = () => {
243282
const elapsed = Date.now() - startTime;
244283
return elapsed >= TARGET_DURATION_MS && batchCount >= MIN_BATCHES;

0 commit comments

Comments
 (0)