Repository: sinonjs/fake-timers
Pull request #474 (2023-07-18 00:28:47)

feat: mock intl api
#### Purpose (TL;DR) - Mock the global Intl API

Solve the breaking of timer-dependant methods on the Intl global object by including a mocked version

#### Background (Problem in detail)

Even after calling install(), the Intl.DateTimeFormat() class will currently use the real time of the system, not the time specified by the fake clock.

Affected methods: DateTimeFormat.format(), DateTimeFormat.formatToParts()

My repo demonstrating the problem, using Jest (which uses sinonjs/fake-timers): https://github.com/BenIsenstein/jest_fix_intl_fake_timers

The fix works by following the patterns used to mock the global Date class and mocking the Intl object. A producer function is written to return an Intl object largely identical to the original, except for the DateTimeFormat() class. DateTimeFormat has several methods that are time-sensitive, and depend on the system time when no argument is passed. These are intercepted and made to default to the clock time.

```javascript
//eslint-disable-next-line jsdoc/require-jsdoc
function createIntl() {
    const ClockIntl = { ...NativeIntl };

    ClockIntl.DateTimeFormat = function (...args) {
        const realFormatter = new NativeIntl.DateTimeFormat(...args);
        const formatter = {};

        ["formatRange", "formatRangeToParts", "resolvedOptions"].forEach(
            (method) => {
                formatter[method] =
                    realFormatter[method].bind(realFormatter);
            }
        );

        ["format", "formatToParts"].forEach((method) => {
            formatter[method] = function (date) {
                return realFormatter[method](date || ClockIntl.clock.now);
            };
        });

        return formatter;
    };

    ClockIntl.DateTimeFormat.prototype = Object.create(
        NativeIntl.DateTimeFormat.prototype
    );
    ClockIntl.DateTimeFormat.prototype.constructor =
        ClockIntl.DateTimeFormat;
    ClockIntl.DateTimeFormat.supportedLocalesOf =
        NativeIntl.DateTimeFormat.supportedLocalesOf;

    return ClockIntl;
}
```

This fake Intl object is provided to the Global object when install() is called.

Line 1176, inside createClock()
```javascript
if (intlPresent) {
    clock.Intl = createIntl();
    clock.Intl.clock = clock;
}
```

and the original Intl object is restored during uninstall().

Line 979 inside hijackMethod()
An else-if clause added to handle hijacking Intl. Because Intl isn't a straightforward global function, rather a global object used like a module, it can't be mocked using the default else clause at the end of hijackMethod(). It has its own clause to set the mocked Intl object globally. 
```javascript
else if (method === "Intl") {
    target[method] = clock[method];
```

I'm fairly confident on implementation, however less confident on the tests. All advice is appreciated!

Ben


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sinonjs/fake-timers/pull/474?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) Report
Patch coverage: **`100.00`**% and project coverage change: **`+0.15`** :tada:
> Comparison is base [(`2fc8324`)](https://app.codecov.io/gh/sinonjs/fake-timers/commit/2fc83240262c8f0e8690e75d9d18dca1fd61b952?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) 95.66% compared to head [(`842b235`)](https://app.codecov.io/gh/sinonjs/fake-timers/pull/474?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) 95.82%.

> :exclamation: Current head 842b235 differs from pull request most recent head 860e59b. Consider uploading reports for the commit 860e59b to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #474      +/-   ##
==========================================
+ Coverage   95.66%   95.82%   +0.15%     
==========================================
  Files           2        2              
  Lines         669      694      +25     
==========================================
+ Hits          640      665      +25     
  Misses         29       29              
```

| Flag | Coverage Δ | |
|---|---|---|
| unit | `95.82% <100.00%> (+0.15%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/sinonjs/fake-timers/pull/474?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) | Coverage Δ | |
|---|---|---|
| [src/fake-timers-src.js](https://app.codecov.io/gh/sinonjs/fake-timers/pull/474?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs#diff-c3JjL2Zha2UtdGltZXJzLXNyYy5qcw==) | `95.82% <100.00%> (+0.16%)` | :arrow_up: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/sinonjs/fake-timers/pull/474?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs).

fatso83: Love when wanted features come with a PR <3 I am off on holiday with just a small mobile screen unsuitable for reviewing code, but hopefully someone else could have a looksie?
benjamingr: I'm on medical leave I'll try to take a look next week and maybe ask Erick from Node to look
ErickWendel: As I don't have much context I think it LGTM 
BenIsenstein: Hi all, it looks like the issue in the test suite is being caused by a difference between the mochify and node runtimes. `Intl.DateTimeFormat.resolvedOptions()` returns a different value between the two. Any suggestions on how to resolve this?
fatso83: It's not mochify (which is running Chrome headless) vs Node, but specifically Node 14 breaking. You see the tests passing in Node 16 and 18. 

If you look at [the engine comparisons page for Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#:~:text=Toggle%20history-,options.fractionalSecondDigits,-parameter) you can see that `fractionalSecondDigits` was only added in Node 14.6. Guessing we run a slightly older version. 

In any case, we only support Node LTS versions, and Node 14 LTS has reached its EOL, so we can just remove that :)
fatso83: I have now removed Node 14 from `main`. Does not block this and you do not _need_ to rebase to verify, but know you now 👍 

Do you deem it ready for final review and merge?
BenIsenstein: Rebased just to keep things clean (Y) yes, it's ready for final review!
BenIsenstein: Thanks for the review! Changes:

- removed changelog entry
- removed IE tautology
- removed setting constructor (setting the prototype takes care of that already, good catch)
- added a test for the `supportedLocalesOf` method

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 940100e..5cac950 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,3 @@
-
 11.0.0 / 2023-06-12
 ==================
   * Re-release 10.2.0 as a new major version as mocking Node "timers" module broke some setups
diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js
index f5129e5..5aebe51 100644
--- a/src/fake-timers-src.js
+++ b/src/fake-timers-src.js
@@ -186,6 +186,7 @@ function withGlobal(_global) {
         typeof _global.cancelIdleCallback === "function";
     const setImmediatePresent =
         _global.setImmediate && typeof _global.setImmediate === "function";
+    const intlPresent = _global.Intl && typeof _global.Intl === "object";
 
     // Make properties writable in IE, as per
     // https://www.adequatelygood.com/Replacing-setTimeout-Globally.html
@@ -204,11 +205,13 @@ function withGlobal(_global) {
         _global.setImmediate = _global.setImmediate;
         _global.clearImmediate = _global.clearImmediate;
     }
+
     /* eslint-enable no-self-assign */
 
     _global.clearTimeout(timeoutResult);
 
     const NativeDate = _global.Date;
+    const NativeIntl = _global.Intl;
     let uniqueTimerId = idCounterStart;
 
     /**
@@ -500,6 +503,40 @@ function withGlobal(_global) {
         return mirrorDateProperties(ClockDate, NativeDate);
     }
 
+    //eslint-disable-next-line jsdoc/require-jsdoc
+    function createIntl() {
+        const ClockIntl = { ...NativeIntl };
+
+        ClockIntl.DateTimeFormat = function (...args) {
+            const realFormatter = new NativeIntl.DateTimeFormat(...args);
+            const formatter = {};
+
+            ["formatRange", "formatRangeToParts", "resolvedOptions"].forEach(
+                (method) => {
+                    formatter[method] =
+                        realFormatter[method].bind(realFormatter);
+                }
+            );
+
+            ["format", "formatToParts"].forEach((method) => {
+                formatter[method] = function (date) {
+                    return realFormatter[method](date || ClockIntl.clock.now);
+                };
+            });
+
+            return formatter;
+        };
+
+        ClockIntl.DateTimeFormat.prototype = Object.create(
+            NativeIntl.DateTimeFormat.prototype
+        );
+
+        ClockIntl.DateTimeFormat.supportedLocalesOf =
+            NativeIntl.DateTimeFormat.supportedLocalesOf;
+
+        return ClockIntl;
+    }
+
     //eslint-disable-next-line jsdoc/require-jsdoc
     function enqueueJob(clock, job) {
         // enqueues a microtick-deferred task - ecma262/#sec-enqueuejob
@@ -934,6 +971,8 @@ function withGlobal(_global) {
         if (method === "Date") {
             const date = mirrorDateProperties(clock[method], target[method]);
             target[method] = date;
+        } else if (method === "Intl") {
+            target[method] = clock[method];
         } else if (method === "performance") {
             const originalPerfDescriptor = Object.getOwnPropertyDescriptor(
                 target,
@@ -988,6 +1027,7 @@ function withGlobal(_global) {
      * @property {setInterval} setInterval
      * @property {clearInterval} clearInterval
      * @property {Date} Date
+     * @property {Intl} Intl
      * @property {SetImmediate=} setImmediate
      * @property {function(NodeImmediate): void=} clearImmediate
      * @property {function(number[]):number[]=} hrtime
@@ -1046,6 +1086,10 @@ function withGlobal(_global) {
         timers.cancelIdleCallback = _global.cancelIdleCallback;
     }
 
+    if (intlPresent) {
+        timers.Intl = _global.Intl;
+    }
+
     const originalSetTimeout = _global.setImmediate || _global.setTimeout;
 
     /**
@@ -1124,6 +1168,11 @@ function withGlobal(_global) {
             };
         }
 
+        if (intlPresent) {
+            clock.Intl = createIntl();
+            clock.Intl.clock = clock;
+        }
+
         clock.requestIdleCallback = function requestIdleCallback(
             func,
             timeout


Test patch:
diff --git a/test/fake-timers-test.js b/test/fake-timers-test.js
index 5ecce05..c0ae9af 100644
--- a/test/fake-timers-test.js
+++ b/test/fake-timers-test.js
@@ -5179,3 +5179,125 @@ describe("Node Timer: ref(), unref(),hasRef()", function () {
         clock.uninstall();
     });
 });
+
+describe("Intl API", function () {
+    /**
+     * Tester function to check if the globally hijacked Intl object is plugging into the faked Clock
+     *
+     * @param {string} ianaTimeZone - IANA time zone name
+     * @param {number} timestamp - UNIX timestamp
+     * @returns {boolean}
+     */
+    function isFirstOfMonth(ianaTimeZone, timestamp) {
+        return (
+            new Intl.DateTimeFormat(undefined, { timeZone: ianaTimeZone })
+                .formatToParts(timestamp)
+                .find((part) => part.type === "day").value === "1"
+        );
+    }
+
+    let clock;
+
+    before(function () {
+        clock = FakeTimers.install();
+    });
+
+    after(function () {
+        clock.uninstall();
+    });
+
+    it("Executes formatRange like normal", function () {
+        const start = new Date(Date.UTC(2020, 0, 1, 0, 0));
+        const end = new Date(Date.UTC(2020, 0, 1, 0, 1));
+        const options = {
+            timeZone: "UTC",
+            hour12: false,
+            hour: "numeric",
+            minute: "numeric",
+        };
+        assert.equals(
+            new Intl.DateTimeFormat("en-GB", options).formatRange(start, end),
+            "00:00–00:01"
+        );
+    });
+
+    it("Executes formatRangeToParts like normal", function () {
+        const start = new Date(Date.UTC(2020, 0, 1, 0, 0));
+        const end = new Date(Date.UTC(2020, 0, 1, 0, 1));
+        const options = {
+            timeZone: "UTC",
+            hour12: false,
+            hour: "numeric",
+            minute: "numeric",
+        };
+        assert.equals(
+            new Intl.DateTimeFormat("en-GB", options).formatRangeToParts(
+                start,
+                end
+            ),
+            [
+                { type: "hour", value: "00", source: "startRange" },
+                { type: "literal", value: ":", source: "startRange" },
+                { type: "minute", value: "00", source: "startRange" },
+                { type: "literal", value: "–", source: "shared" },
+                { type: "hour", value: "00", source: "endRange" },
+                { type: "literal", value: ":", source: "endRange" },
+                { type: "minute", value: "01", source: "endRange" },
+            ]
+        );
+    });
+
+    it("Executes resolvedOptions like normal", function () {
+        const options = {
+            timeZone: "UTC",
+            hour12: false,
+            hour: "2-digit",
+            minute: "2-digit",
+        };
+        assert.equals(
+            new Intl.DateTimeFormat("en-GB", options).resolvedOptions(),
+            {
+                locale: "en-GB",
+                calendar: "gregory",
+                numberingSystem: "latn",
+                timeZone: "UTC",
+                hour12: false,
+                hourCycle: "h23",
+                hour: "2-digit",
+                minute: "2-digit",
+            }
+        );
+    });
+
+    it("formatToParts via isFirstOfMonth -> Returns true when passed a timestamp argument that is first of the month", function () {
+        // June 1 04:00 UTC - Toronto is June 1 00:00
+        assert.isTrue(
+            isFirstOfMonth("America/Toronto", Date.UTC(2022, 5, 1, 4))
+        );
+    });
+
+    it("formatToParts via isFirstOfMonth -> Returns false when passed a timestamp argument that is not first of the month", function () {
+        // June 1 00:00 UTC - Toronto is May 31 20:00
+        assert.isFalse(isFirstOfMonth("America/Toronto", Date.UTC(2022, 5, 1)));
+    });
+
+    it("formatToParts via isFirstOfMonth -> Returns true when passed no timestamp and system time is first of the month", function () {
+        // June 1 04:00 UTC - Toronto is June 1 00:00
+        clock.now = Date.UTC(2022, 5, 1, 4);
+        assert.isTrue(isFirstOfMonth("America/Toronto"));
+    });
+
+    it("formatToParts via isFirstOfMonth -> Returns false when passed no timestamp and system time is not first of the month", function () {
+        // June 1 00:00 UTC - Toronto is May 31 20:00
+        clock.now = Date.UTC(2022, 5, 1);
+        assert.isFalse(isFirstOfMonth("America/Toronto"));
+    });
+
+    it("Executes supportedLocalesOf like normal", function () {
+        assert.equals(
+            Intl.DateTimeFormat.supportedLocalesOf(),
+            //eslint-disable-next-line no-underscore-dangle
+            clock._Intl.DateTimeFormat.supportedLocalesOf()
+        );
+    });
+});