sinonjs / fake-timers

Fake setTimeout and friends (collectively known as "timers"). Useful in your JavaScript tests. Extracted from Sinon.JS
BSD 3-Clause "New" or "Revised" License
793 stars 103 forks source link

[Feature] Jump forward in time to simulate throttled timers #452

Closed CreativeTechGuy closed 1 year ago

CreativeTechGuy commented 1 year ago

We understand you have a problem and are in a hurry, but please provide us with some info to make it much more likely for your issue to be understood, worked on and resolved quickly.

What did you expect to happen?

I expected there would be an API to enable skipping forward in time in a similar way that browsers will throttle a setInterval when the page is backgrounded. That is to say, if I have the following code:

setInterval(() => {
    console.log("Here");
}, 100);

This would print 10 times a second. So running clock.tick(2000) would print this 20 times. I expected that there would be a clock.jump(2000) which would print this only once, effectively advancing the clock without any intermediary tasks in the event loop being processed. Another way to look at it is that all pending tasks were throttled to whatever value is passed to the clock.jump method.

What actually happens

There currently is no way to test code which is written to handle this browser behavior of throttling timers.

How to reproduce

N/A

fatso83 commented 1 year ago

Having a hard time envisioning when this would be useful ? Seems very exotic.

CreativeTechGuy commented 1 year ago

Yeah happy to give a concrete example. This is simplified for discussion purposes, but hopefully you can see how this will apply in a lot of different ways to more complex applications.

Let's say I have a game that progresses days in-game every X milliseconds. So my code looks something like this:

const msPerInGameDay = 100;
let lastDayTime = Date.now();
let gameDay = 0;
setInterval(() => {
    if (Date.now() - lastDayTime >= msPerInGameDay) {
        gameDay++;
        lastDayTime += lastDayTime;
    }
}, 25);

This code works by checking if the elapsed time is greater or equal to the amount of time per day, and then incrementing day and moving the marker forward. This is necessary since JavaScript runtimes don't guarantee timeouts to be exact. A 25ms timeout could be fired at 50ms or more. This is especially prevalent on slower systems with fast timeouts or when there's a long-running task (eg: computation on the main thread) that cannot be interrupted when it is time for the interval to fire.

Anyway, this code has a problem. If the browser "backgrounds" the page (depending on the device this might be throttling, or even pausing it entirely) and then returns to the page, the timer will not have executed at the expected rate due to being backgrounded. So given this code, days will progress super fast to catch up. That is jarring to the user to suddenly see the time rapidly ticking up far faster than it normally does.

To handle this, the code in reality is actually the following:

const msPerInGameDay = 100;
let lastDayTime = Date.now();
let gameDay = 0;
setInterval(() => {
    if (Date.now() - lastDayTime >= msPerInGameDay) {
        gameDay++;
        lastDayTime += lastDayTime;
        if (Date.now() - lastDayTime >= msPerInGameDay * 5) { // 5 indicating the max number of days that will be caught up
            // Handle this in different ways depending on the situation. Might be instantly fast-forwarding time or just skipping to present like this.
            lastDayTime = Date.now();
        }
    }
}, 25);

Okay so now this code handles being backgrounded and will avoid the issue of timers being throttled. In this case, it'll skip all of the time that the user was backgrounded as if it didn't happen.

This all works in a browser and does what is expected. But I now need to write automated tests for this very important functionality. How can I ensure my code does what it is supposed to do in a test? When faking the timers, there is no way to simulate this "jumping forward in time" or "throttling timers" so there is no way to ever hit that inner condition. That is what my feature request is for. A way to simulate this behavior to be able to hit that condition and test this functionality.

Hopefully that all makes sense and seems far less exotic! :)

CreativeTechGuy commented 1 year ago

In trying to unblock myself, I found a really hacky way to get the behavior I'm looking for. It is possible to do in userland but is relying on undocumented things. I'm sharing this as an example of what can be implemented in the core library in a really easy, isolated way.

// This uses jest, but same idea applies
function jumpTime(ms: number): void {
    for (const timer of Object.values(Date.clock.timers)) {
        if (Date.now() + ms > timer.callAt) {
            timer.callAt = Date.now() + ms;
        }
    }
    jest.advanceTimersByTime(ms); // Or clock.tick(ms)
}

What this does is go through all of the mocked timers, check if any would have been called during the duration that is being jumped, and if so, set the time at which they'll be next called at to be right at the very end. This way when the clock is ticked, they'll only be processed once and then time will resume after the skip occurred.

I'm happy to make a PR if needed!

fatso83 commented 1 year ago

The idea is fine, but it's essentially a Chromium feature, not a standard. Given its prevalence, though, it seems relevant to include.

Doing this as a non-breaking change is the easiest fit. Not sure if it should be clock.jump or clock.tick(time, { throttle } )

CreativeTechGuy commented 1 year ago

I've been doing a lot of research on this behavior. While I'm unable to find an official source for this, from my personal experience (along with many others online) Safari, and especially iOS Safari, is very prone to throttling timers for backgrounded tabs. I don't believe it is Chromium specific. As far as I'm aware, every browser does something similar in some cases, especially when the system is under severe memory pressure (eg: a few hundred/thousand tabs open at once and it'll throttle some tabs).

To look at it from another angle, if you have a setInterval where the work you are doing inside the interval takes, let's say 1000ms, then the interval won't be able to run faster than once every 1000ms because there's only one thread. So given a slow enough computer and a large enough amount of main-thread work, every browser will experience this even when the tab is in focus.

Anyway haha. I'm too deep into this stuff.

As far as naming, I think that clock.jump makes the most sense given the semantics of clock.tick. And I think we should avoid referring to it as throttling since there's a lot of ways this might occur. However it happens, in the end time has "skipped". So using the jump/skip terminology seems more appropriate and easier to understand.

fatso83 commented 1 year ago

The idea is fine, but it's essentially a Chromium feature, not a standard. Given its prevalence, though, it seems relevant to include.

Doing this as a non-breaking change is the easiest fit. Not sure if it should be clock.jump or something like clock.tick(time, { throttle } )

CreativeTechGuy commented 1 year ago

@fatso83, where are we at here? Any open questions or concerns before I jump into a PR?

amorris-canva commented 1 year ago

Stumbled across this because we have a different use case that would benefit from this:

For our performance metrics, we often want to measure the time when all blocking work triggered by an action has been completed, i.e., painting/rendering + microtasks + any immediate tasks. To do that we do something like:

onButtonClick() {
  timeline.start = performance.now();

  // Do all the work
  // ...

  setTimeout(() => {
    timeline.end = performance.now();
    timeline.log();
  });
}

In our testing with Jest, we want to check that this timeline.end is actually recording the right time. However, using timeline.advanceTimersByTime(100) will result in timeline.end == timeline.start because the timeout implicitly has a delay of 0. But in this case, I'd actually like to test that if the timeout was blocked for a while (while lots of work was happening for rendering), then timeline.end is called with the appropriate time. I think this concept of jumping forward in time would probably be right (though in our case, it's less jumping, more like simulating the main thread being blocked).

benjamingr commented 1 year ago

Tangent: you probably want to use event timing API to find long frames and long renders (though that would be harder to test since it'd be actual long frames)

amorris-canva commented 1 year ago

Yes - we also use that in aggregate. The challenge is that it's harder to associate the timing data with a specific interaction - AFAIU you'd have to try to correlate timestamps which would get messy.

benjamingr commented 1 year ago

The challenge is that it's harder to associate the timing data with a specific interaction - AFAIU you'd have to try to correlate timestamps which would get messy.

(Still mostly OT) We use the JS-Self profiling API (+source maps) at Microsoft to correlate the interactions with the event timing api and have plans to open source the code that does this, though open sourcing stuff at Microsoft can take years at times.

fatso83 commented 1 year ago

@fatso83, where are we at here? Any open questions or concerns before I jump into a PR?

@CreativeTechGuy Sorry, this comment escaped me for months and I cannot remember seeing it before now ... I have absolutely no questions, but I am absolutely not opposed to having something like clock.jump. I am not sure how I got to be the one to decide (a core problem with open source maintenance), anyway, so go ahead 😄

amorris-canva commented 1 year ago

@CreativeTechGuy do you also plan to add this support to Jest?

CreativeTechGuy commented 1 year ago

@amorris-canva That is ultimately the goal. I'm not sure the process. I've seen Jest usually adopt those features whenever they update to the latest version of fake-timers. If you have time to do that PR that'd be awesome!

fatso83 commented 1 year ago

@CreativeTechGuy That's not necessarily the case. For instance, you will see countless hits on StackOverflow on how to mix stubbing of timers with promises, which was addressed several years ago in @sinonjs/fake-timers (or lolex back then), with tickAsync() and friends, but that was only recently exposed on the API in Jest 29.5 (two months ago).

@amorris-canva To state the obvious from the previous paragraph, if you want this in Jest, the quickest path is usually to just make a PR yourself and submit it. When doing this last year (jestjs/jest#12407) it took a couple of months of ping-pong, but eventually you'll get there 😄 That effort needed a bit more hand-holding from Simen, though, so seeing that this is probably just exposing a single method, fetching the latest version and updating the docs, I think you should be good in no time.

jared-canva commented 11 months ago

hi all. I have picked up and started a patch to merge this into jest. The thing blocking taking it across the line is that that the version of fake-timers in npm does not currently include this new method exposed in sinon 15. Are we able to push a new version to npm?

fatso83 commented 11 months ago

@jared-canva Version 11 was pushed in June, some weeks after this landed. Are you sure they haven't been exposed? Maybe just the docs need updating. That usually is the one people fail to address.

fatso83 commented 11 months ago

@jared-canva Just checked. The docs are up to date in both fake-timers and Sinon:

The versions on NPM also lists 11.0.0, even though 10.3.0 is listed as the last one. I'll fix the metadata to amend that, as it was released after 11, if my memory serves me right.

jared-canva commented 11 months ago

Heh. It is on npm now, when I did the patch the latest version on https://www.npmjs.com/package/@sinonjs/fake-timers was still 10 something, maybe was being hit by an aggressive cache. I had updated my work to just point to github. Perfect thanks.

fatso83 commented 11 months ago

No, it was still there. It was just that the dist-tag of NPM pointed to 10.3.0 as latest, as I released that after 11.0.0 (as 10.2.0 turned out to be breaking). You can see the publish dates on the version page.

image

Good that it was sorted out.

jared-canva commented 11 months ago

Ah! that would be why npm.com displayed 10.x as latest then got it.

CreativeTechGuy commented 8 months ago

@jared-canva Were you ever able to get it added to Jest? I see that Jest has a pending major version v30 which already updates fake-timers to v11. Seems like it'd be the perfect time to get this .jump() method exposed! Thanks. 😃