Skip to content

Commit 3e27d63

Browse files
fix: add Jest 30 support and fix time limit in loop-runner
- Add Jest 30 compatibility by detecting version and using TestRunner class - Resolve jest-runner from project's node_modules instead of codeflash's bundle - Fix time limit enforcement by using local time tracking instead of shared state (Jest runs tests in worker processes, so state isn't shared with runner) - Integrate stability-based early stopping into capturePerf - Use plain object instead of Set for stableInvocations to survive Jest module resets Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7bc7032 commit 3e27d63

File tree

2 files changed

+163
-27
lines changed

2 files changed

+163
-27
lines changed

packages/codeflash/runtime/capture.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ if (!process[PERF_STATE_KEY]) {
7171
shouldStop: false, // Flag to stop all further looping
7272
currentBatch: 0, // Current batch number (incremented by runner)
7373
invocationLoopCounts: {}, // Track loops per invocation: {invocationKey: loopCount}
74+
invocationRuntimes: {}, // Track runtimes per invocation for stability: {invocationKey: [runtimes]}
75+
stableInvocations: {}, // Invocations that have reached stability: {invocationKey: true}
7476
};
7577
}
7678
const sharedPerfState = process[PERF_STATE_KEY];
@@ -657,12 +659,26 @@ function capturePerf(funcName, lineId, fn, ...args) {
657659
? (hasExternalLoopRunner ? PERF_BATCH_SIZE : PERF_LOOP_COUNT)
658660
: 1;
659661

662+
// Initialize runtime tracking for this invocation if needed
663+
if (!sharedPerfState.invocationRuntimes[invocationKey]) {
664+
sharedPerfState.invocationRuntimes[invocationKey] = [];
665+
}
666+
const runtimes = sharedPerfState.invocationRuntimes[invocationKey];
667+
668+
// Calculate stability window size based on collected runtimes
669+
const getStabilityWindow = () => Math.max(PERF_MIN_LOOPS, Math.ceil(runtimes.length * STABILITY_WINDOW_SIZE));
670+
660671
for (let batchIndex = 0; batchIndex < batchSize; batchIndex++) {
661672
// Check shared time limit BEFORE each iteration
662673
if (shouldLoop && checkSharedTimeLimit()) {
663674
break;
664675
}
665676

677+
// Check if this invocation has already reached stability
678+
if (PERF_STABILITY_CHECK && sharedPerfState.stableInvocations[invocationKey]) {
679+
break;
680+
}
681+
666682
// Get the global loop index for this invocation (increments across batches)
667683
const loopIndex = getInvocationLoopIndex(invocationKey);
668684

@@ -695,6 +711,8 @@ function capturePerf(funcName, lineId, fn, ...args) {
695711
const asyncDurationNs = getDurationNs(startTime, asyncEndTime);
696712
console.log(`!######${testStdoutTag}:${asyncDurationNs}######!`);
697713
sharedPerfState.totalLoopsCompleted++;
714+
// Track runtime for stability (convert to microseconds for stability check)
715+
runtimes.push(asyncDurationNs / 1000);
698716
return resolved;
699717
},
700718
(err) => {
@@ -719,6 +737,20 @@ function capturePerf(funcName, lineId, fn, ...args) {
719737
// Update shared loop counter
720738
sharedPerfState.totalLoopsCompleted++;
721739

740+
// Track runtime for stability check (convert to microseconds)
741+
if (durationNs > 0) {
742+
runtimes.push(durationNs / 1000);
743+
}
744+
745+
// Check stability after accumulating enough samples
746+
if (PERF_STABILITY_CHECK && runtimes.length >= PERF_MIN_LOOPS) {
747+
const window = getStabilityWindow();
748+
if (shouldStopStability(runtimes, window, PERF_MIN_LOOPS)) {
749+
sharedPerfState.stableInvocations[invocationKey] = true;
750+
break;
751+
}
752+
}
753+
722754
// If we had an error, stop looping
723755
if (lastError) {
724756
break;
@@ -790,6 +822,8 @@ function resetPerfState() {
790822
sharedPerfState.startTime = null;
791823
sharedPerfState.totalLoopsCompleted = 0;
792824
sharedPerfState.shouldStop = false;
825+
sharedPerfState.invocationRuntimes = {};
826+
sharedPerfState.stableInvocations = {};
793827
}
794828

795829
/**

packages/codeflash/runtime/loop-runner.js

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,60 @@
2424
* NOTE: This runner requires jest-runner to be installed in your project.
2525
* It is a Jest-specific feature and does not work with Vitest.
2626
* For Vitest projects, capturePerf() does all loops internally in a single call.
27+
*
28+
* Compatibility: Works with Jest 29.x and Jest 30.x
2729
*/
2830

2931
'use strict';
3032

3133
const { createRequire } = require('module');
3234
const path = require('path');
3335

34-
// Try to load jest-runner - it's a peer dependency that must be installed by the user
36+
// Try to load jest-runner from the PROJECT's node_modules, not from codeflash package
37+
// This ensures we use the same version of jest-runner that the project uses
38+
let TestRunner;
3539
let runTest;
3640
let jestRunnerAvailable = false;
41+
let jestVersion = 0;
3742

3843
try {
39-
const jestRunnerPath = require.resolve('jest-runner');
44+
// Resolve jest-runner from the current working directory (project root)
45+
// This is important because the codeflash package may bundle a different version
46+
const projectRoot = process.cwd();
47+
const projectRequire = createRequire(path.join(projectRoot, 'node_modules', 'package.json'));
48+
49+
let jestRunnerPath;
50+
try {
51+
// First try to resolve from project's node_modules
52+
jestRunnerPath = projectRequire.resolve('jest-runner');
53+
} catch (e) {
54+
// Fall back to default resolution (codeflash's bundled version)
55+
jestRunnerPath = require.resolve('jest-runner');
56+
}
57+
4058
const internalRequire = createRequire(jestRunnerPath);
41-
runTest = internalRequire('./runTest').default;
42-
jestRunnerAvailable = true;
59+
60+
// Try to get the TestRunner class (Jest 30+)
61+
const jestRunner = internalRequire(jestRunnerPath);
62+
TestRunner = jestRunner.default || jestRunner.TestRunner;
63+
64+
if (TestRunner && TestRunner.prototype && typeof TestRunner.prototype.runTests === 'function') {
65+
// Jest 30+ - use TestRunner class
66+
jestVersion = 30;
67+
jestRunnerAvailable = true;
68+
} else {
69+
// Try Jest 29 style import
70+
try {
71+
runTest = internalRequire('./runTest').default;
72+
if (typeof runTest === 'function') {
73+
jestVersion = 29;
74+
jestRunnerAvailable = true;
75+
}
76+
} catch (e29) {
77+
// Neither Jest 29 nor 30 style import worked
78+
jestRunnerAvailable = false;
79+
}
80+
}
4381
} catch (e) {
4482
// jest-runner not installed - this is expected for Vitest projects
4583
// The runner will throw a helpful error if someone tries to use it without jest-runner
@@ -106,6 +144,9 @@ function deepCopy(obj, seen = new WeakMap()) {
106144

107145
/**
108146
* Codeflash Loop Runner with Batched Looping
147+
*
148+
* For Jest 30+, extends the TestRunner class directly.
149+
* For Jest 29, uses the runTest function import.
109150
*/
110151
class CodeflashLoopRunner {
111152
constructor(globalConfig, context) {
@@ -120,6 +161,11 @@ class CodeflashLoopRunner {
120161
this._globalConfig = globalConfig;
121162
this._context = context || {};
122163
this._eventEmitter = new SimpleEventEmitter();
164+
165+
// For Jest 30+, create an instance of the base TestRunner for delegation
166+
if (jestVersion >= 30 && TestRunner) {
167+
this._baseRunner = new TestRunner(globalConfig, context);
168+
}
123169
}
124170

125171
get supportsEventEmitters() {
@@ -143,29 +189,20 @@ class CodeflashLoopRunner {
143189
let hasFailure = false;
144190
let allConsoleOutput = '';
145191

146-
// Import shared state functions from capture module
147-
// We need to do this dynamically since the module may be reloaded
148-
let checkSharedTimeLimit;
149-
let incrementBatch;
150-
try {
151-
const capture = require('codeflash');
152-
checkSharedTimeLimit = capture.checkSharedTimeLimit;
153-
incrementBatch = capture.incrementBatch;
154-
} catch (e) {
155-
// Fallback if codeflash module not available
156-
checkSharedTimeLimit = () => {
157-
const elapsed = Date.now() - startTime;
158-
return elapsed >= TARGET_DURATION_MS && batchCount >= MIN_BATCHES;
159-
};
160-
incrementBatch = () => {};
161-
}
192+
// Time limit check - must use local time tracking because Jest runs tests
193+
// in worker processes, so shared state from capture.js isn't accessible here
194+
const checkTimeLimit = () => {
195+
const elapsed = Date.now() - startTime;
196+
return elapsed >= TARGET_DURATION_MS && batchCount >= MIN_BATCHES;
197+
};
162198

163199
// Batched looping: run all test files multiple times
164200
while (batchCount < MAX_BATCHES) {
165201
batchCount++;
166202

167203
// Check time limit BEFORE each batch
168-
if (batchCount > MIN_BATCHES && checkSharedTimeLimit()) {
204+
if (batchCount > MIN_BATCHES && checkTimeLimit()) {
205+
console.log(`[codeflash] Time limit reached after ${batchCount - 1} batches (${Date.now() - startTime}ms elapsed)`);
169206
break;
170207
}
171208

@@ -174,13 +211,11 @@ class CodeflashLoopRunner {
174211
break;
175212
}
176213

177-
// Increment batch counter in shared state and set env var
178-
// The env var persists across Jest module resets, ensuring continuous loop indices
179-
incrementBatch();
214+
// Set env var for batch number - persists across Jest module resets
180215
process.env.CODEFLASH_PERF_CURRENT_BATCH = String(batchCount);
181216

182217
// Run all test files in this batch
183-
const batchResult = await this._runAllTestsOnce(tests, watcher);
218+
const batchResult = await this._runAllTestsOnce(tests, watcher, options);
184219
allConsoleOutput += batchResult.consoleOutput;
185220

186221
if (batchResult.hasFailure) {
@@ -189,7 +224,8 @@ class CodeflashLoopRunner {
189224
}
190225

191226
// Check time limit AFTER each batch
192-
if (checkSharedTimeLimit()) {
227+
if (checkTimeLimit()) {
228+
console.log(`[codeflash] Time limit reached after ${batchCount} batches (${Date.now() - startTime}ms elapsed)`);
193229
break;
194230
}
195231
}
@@ -207,8 +243,74 @@ class CodeflashLoopRunner {
207243

208244
/**
209245
* Run all test files once (one batch).
246+
* Uses different approaches for Jest 29 vs Jest 30.
247+
*/
248+
async _runAllTestsOnce(tests, watcher, options) {
249+
if (jestVersion >= 30) {
250+
return this._runAllTestsOnceJest30(tests, watcher, options);
251+
} else {
252+
return this._runAllTestsOnceJest29(tests, watcher);
253+
}
254+
}
255+
256+
/**
257+
* Jest 30+ implementation - delegates to base TestRunner and collects results.
258+
*/
259+
async _runAllTestsOnceJest30(tests, watcher, options) {
260+
let hasFailure = false;
261+
let allConsoleOutput = '';
262+
263+
// For Jest 30, we need to collect results through event listeners
264+
const resultsCollector = [];
265+
266+
// Subscribe to events from the base runner
267+
const unsubscribeSuccess = this._baseRunner.on('test-file-success', (testData) => {
268+
const [test, result] = testData;
269+
resultsCollector.push({ test, result, success: true });
270+
271+
if (result && result.console && Array.isArray(result.console)) {
272+
allConsoleOutput += result.console.map(e => e.message || '').join('\n') + '\n';
273+
}
274+
275+
if (result && result.numFailingTests > 0) {
276+
hasFailure = true;
277+
}
278+
279+
// Forward to our event emitter
280+
this._eventEmitter.emit('test-file-success', testData);
281+
});
282+
283+
const unsubscribeFailure = this._baseRunner.on('test-file-failure', (testData) => {
284+
const [test, error] = testData;
285+
resultsCollector.push({ test, error, success: false });
286+
hasFailure = true;
287+
288+
// Forward to our event emitter
289+
this._eventEmitter.emit('test-file-failure', testData);
290+
});
291+
292+
const unsubscribeStart = this._baseRunner.on('test-file-start', (testData) => {
293+
// Forward to our event emitter
294+
this._eventEmitter.emit('test-file-start', testData);
295+
});
296+
297+
try {
298+
// Run tests using the base runner (always serial for benchmarking)
299+
await this._baseRunner.runTests(tests, watcher, { ...options, serial: true });
300+
} finally {
301+
// Cleanup subscriptions
302+
if (typeof unsubscribeSuccess === 'function') unsubscribeSuccess();
303+
if (typeof unsubscribeFailure === 'function') unsubscribeFailure();
304+
if (typeof unsubscribeStart === 'function') unsubscribeStart();
305+
}
306+
307+
return { consoleOutput: allConsoleOutput, hasFailure };
308+
}
309+
310+
/**
311+
* Jest 29 implementation - uses direct runTest import.
210312
*/
211-
async _runAllTestsOnce(tests, watcher) {
313+
async _runAllTestsOnceJest29(tests, watcher) {
212314
let hasFailure = false;
213315
let allConsoleOutput = '';
214316

0 commit comments

Comments
 (0)