diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js index 637e356e..6568ee7f 100644 --- a/src/fake-timers-src.js +++ b/src/fake-timers-src.js @@ -205,6 +205,18 @@ function withGlobal(_global) { return isFinite(num); } + var isNearInfiniteLimit = false; + + function checkIsNearInfiniteLimit(clock, i) { + if (clock.loopLimit && i === clock.loopLimit - 1) { + isNearInfiniteLimit = true; + } + } + + function resetIsNearInfiniteLimit() { + isNearInfiniteLimit = false; + } + /** * Parse strings like "01:10:00" (meaning 1 hour, 10 minutes, 0 seconds) into * number of milliseconds. This is used to support human-readable strings passed @@ -288,6 +300,67 @@ function withGlobal(_global) { return timer && timer.callAt >= from && timer.callAt <= to; } + function getInfiniteLoopError(clock, job) { + var infiniteLoopError = new Error( + `Aborting after running ${clock.loopLimit} timers, assuming an infinite loop!` + ); + + // pattern never matched in Node + var computedTargetPattern = /target\.*[<|(|[].*?[>|\]|)]\s*/; + var clockMethodPattern = new RegExp( + String(Object.keys(clock).join("|")) + ); + + if (addTimerReturnsObject) { + // node.js environment + clockMethodPattern = new RegExp( + `\\s+at (Object\\.)?(?:${Object.keys(clock).join("|")})\\s+` + ); + } + + var matchedLineIndex = -1; + job.error.stack.split("\n").some(function (line, i) { + // If we've matched a computed target line (e.g. setTimeout) then we + // don't need to look any further. Return true to stop iterating. + var matchedComputedTarget = line.match(computedTargetPattern); + /* istanbul ignore if */ + if (matchedComputedTarget) { + matchedLineIndex = i; + return true; + } + + // If we've matched a clock method line, then there may still be + // others further down the trace. Return false to keep iterating. + var matchedClockMethod = line.match(clockMethodPattern); + if (matchedClockMethod) { + matchedLineIndex = i; + return false; + } + + // If we haven't matched anything on this line, but we matched + // previously and set the matched line index, then we can stop. + // If we haven't matched previously, then we should keep iterating. + return matchedLineIndex >= 0; + }); + + var stack = `${infiniteLoopError}\n${job.type || "Microtask"} - ${ + job.func.name || "anonymous" + }\n${job.error.stack + .split("\n") + .slice(matchedLineIndex + 1) + .join("\n")}`; + + try { + Object.defineProperty(infiniteLoopError, "stack", { + value: stack, + }); + } catch (e) { + // noop + } + + return infiniteLoopError; + } + /** * @param {Date} target * @param {Date} source @@ -411,12 +484,13 @@ function withGlobal(_global) { for (var i = 0; i < clock.jobs.length; i++) { var job = clock.jobs[i]; job.func.apply(null, job.args); + + checkIsNearInfiniteLimit(clock, i); if (clock.loopLimit && i > clock.loopLimit) { - throw new Error( - `Aborting after running ${clock.loopLimit} timers, assuming an infinite loop!` - ); + throw getInfiniteLoopError(clock, job); } } + resetIsNearInfiniteLimit(); clock.jobs = []; } @@ -441,6 +515,10 @@ function withGlobal(_global) { } } + if (isNearInfiniteLimit) { + timer.error = new Error(); + } + timer.type = timer.immediate ? "Immediate" : "Timeout"; if (timer.hasOwnProperty("delay")) { @@ -997,6 +1075,7 @@ function withGlobal(_global) { return enqueueJob(clock, { func: func, args: Array.prototype.slice.call(arguments, 1), + error: isNearInfiniteLimit ? new Error() : null, }); }; @@ -1304,20 +1383,22 @@ function withGlobal(_global) { runJobs(clock); for (i = 0; i < clock.loopLimit; i++) { if (!clock.timers) { + resetIsNearInfiniteLimit(); return clock.now; } numTimers = Object.keys(clock.timers).length; if (numTimers === 0) { + resetIsNearInfiniteLimit(); return clock.now; } clock.next(); + checkIsNearInfiniteLimit(clock, i); } - throw new Error( - `Aborting after running ${clock.loopLimit} timers, assuming an infinite loop!` - ); + var excessJob = firstTimer(clock); + throw getInfiniteLoopError(clock, excessJob); }; clock.runToFrame = function runToFrame() { @@ -1337,6 +1418,7 @@ function withGlobal(_global) { var numTimers; if (i < clock.loopLimit) { if (!clock.timers) { + resetIsNearInfiniteLimit(); resolve(clock.now); return; } @@ -1344,6 +1426,7 @@ function withGlobal(_global) { numTimers = Object.keys(clock.timers) .length; if (numTimers === 0) { + resetIsNearInfiniteLimit(); resolve(clock.now); return; } @@ -1353,14 +1436,12 @@ function withGlobal(_global) { i++; doRun(); + checkIsNearInfiniteLimit(clock, i); return; } - reject( - new Error( - `Aborting after running ${clock.loopLimit} timers, assuming an infinite loop!` - ) - ); + var excessJob = firstTimer(clock); + reject(getInfiniteLoopError(clock, excessJob)); } catch (e) { reject(e); } diff --git a/test/fake-timers-test.js b/test/fake-timers-test.js index 8465eb5d..710fde91 100644 --- a/test/fake-timers-test.js +++ b/test/fake-timers-test.js @@ -4744,3 +4744,321 @@ describe("#347 - Support util.promisify once installed", function () { }); }); }); + +describe("loop limit stack trace", function () { + var test, + expectedMessage = + "Aborting after running 5 timers, assuming an infinite loop!"; + + beforeEach(function () { + test = this; + this.clock = FakeTimers.install({ loopLimit: 5 }); + }); + + afterEach(function () { + this.clock.uninstall(); + }); + + describe("queueMicrotask", function () { + beforeEach(function () { + function recursiveQueueMicroTask() { + test.clock.queueMicrotask(recursiveQueueMicroTask); + } + recursiveQueueMicroTask(); + }); + + it("provides a stack trace for running microtasks", function () { + var caughtError = false; + + try { + test.clock.runMicrotasks(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Microtask - recursiveQueueMicroTask\\s+(at )*recursiveQueueMicroTask` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("nextTick", function () { + beforeEach(function () { + function recursiveQueueMicroTask() { + test.clock.nextTick(recursiveQueueMicroTask); + } + recursiveQueueMicroTask(); + }); + + it("provides a stack trace for running microtasks", function () { + var caughtError = false; + + try { + test.clock.runMicrotasks(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Microtask - recursiveQueueMicroTask\\s+(at )*recursiveQueueMicroTask` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("setTimeout", function () { + beforeEach(function () { + function recursiveCreateTimer() { + setTimeout(function recursiveCreateTimerTimeout() { + recursiveCreateTimer(); + }, 10); + } + recursiveCreateTimer(); + }); + + it("provides a stack trace for running all async", function () { + var catchSpy = sinon.spy(); + + return test.clock + .runAllAsync() + .catch(catchSpy) + .then(function () { + assert(catchSpy.calledOnce); + var err = catchSpy.firstCall.args[0]; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Timeout - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + }); + }); + + it("provides a stack trace for running all sync", function () { + var caughtError = false; + + try { + test.clock.runAll(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Timeout - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("requestIdleCallback", function () { + beforeEach(function () { + function recursiveCreateTimer() { + test.clock.requestIdleCallback( + function recursiveCreateTimerTimeout() { + recursiveCreateTimer(); + }, + 10 + ); + } + recursiveCreateTimer(); + }); + + it("provides a stack trace for running all async", function () { + var catchSpy = sinon.spy(); + + return test.clock + .runAllAsync() + .catch(catchSpy) + .then(function () { + assert(catchSpy.calledOnce); + var err = catchSpy.firstCall.args[0]; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Timeout - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + }); + }); + + it("provides a stack trace for running all sync", function () { + var caughtError = false; + + try { + test.clock.runAll(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Timeout - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("setInterval", function () { + beforeEach(function () { + function recursiveCreateTimer() { + setInterval(function recursiveCreateTimerTimeout() { + recursiveCreateTimer(); + }, 10); + } + recursiveCreateTimer(); + }); + + it("provides a stack trace for running all async", function () { + var catchSpy = sinon.spy(); + + return test.clock + .runAllAsync() + .catch(catchSpy) + .then(function () { + assert(catchSpy.calledOnce); + var err = catchSpy.firstCall.args[0]; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Interval - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + }); + }); + + it("provides a stack trace for running all sync", function () { + var caughtError = false; + + try { + test.clock.runAll(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Interval - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("setImmediate", function () { + beforeEach(function () { + if (!setImmediatePresent) { + this.skip(); + } + + function recursiveCreateTimer() { + setImmediate(function recursiveCreateTimerTimeout() { + recursiveCreateTimer(); + }); + } + recursiveCreateTimer(); + }); + + it("provides a stack trace for running all async", function () { + var catchSpy = sinon.spy(); + + return test.clock + .runAllAsync() + .catch(catchSpy) + .then(function () { + assert(catchSpy.calledOnce); + var err = catchSpy.firstCall.args[0]; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Immediate - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + }); + }); + + it("provides a stack trace for running all sync", function () { + var caughtError = false; + + try { + test.clock.runAll(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+Immediate - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); + + describe("requestAnimationFrame", function () { + beforeEach(function () { + function recursiveCreateTimer() { + test.clock.requestAnimationFrame( + function recursiveCreateTimerTimeout() { + recursiveCreateTimer(); + } + ); + } + recursiveCreateTimer(); + }); + + it("provides a stack trace for running all async", function () { + var catchSpy = sinon.spy(); + + return test.clock + .runAllAsync() + .catch(catchSpy) + .then(function () { + assert(catchSpy.calledOnce); + var err = catchSpy.firstCall.args[0]; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+AnimationFrame - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + }); + }); + + it("provides a stack trace for running all sync", function () { + var caughtError = false; + + try { + test.clock.runAll(); + } catch (err) { + caughtError = true; + assert.equals(err.message, expectedMessage); + assert.equals( + new RegExp( + `Error: ${expectedMessage}\\s+AnimationFrame - recursiveCreateTimerTimeout\\s+(at )*recursiveCreateTimer` + ).test(err.stack), + true + ); + } + assert.equals(caughtError, true); + }); + }); +});