Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 77 additions & 8 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,63 @@
});
```

### Temporal

The mock timers API also allows mocking the [`Temporal.Now`][] methods that
read the current time. This is useful for testing code that reads the current
time through the Temporal API instead of the legacy `Date` object.

Enabling the `'Temporal.Now'` api mocks the following methods:

* `Temporal.Now.instant()`
* `Temporal.Now.zonedDateTimeISO()`
* `Temporal.Now.plainDateTimeISO()`
* `Temporal.Now.plainDateISO()`
* `Temporal.Now.plainTimeISO()`

`Temporal.Now.timeZoneId()` is not mocked and keeps returning the actual
system time zone. Only the clock is virtualized, not the time zone.

**Note:** `Date` and `Temporal.Now` share the same internal mock clock. When
both are mocked, `Date.now()` and `Temporal.Now.instant().epochMilliseconds`
always agree, and advancing the clock with `.tick()` or `.setTime()` advances
both.

**Note:** The mock clock has millisecond precision, while `Temporal.Instant`
has nanosecond precision. Mocked values are derived from the clock's
millisecond value, so the sub-millisecond digits of
`Temporal.Now.instant().epochNanoseconds` are always zero.

```mjs
import assert from 'node:assert';
import { test } from 'node:test';

test('mocks Temporal.Now', (context) => {
// Optionally choose what to mock
context.mock.timers.enable({ apis: ['Temporal.Now'], now: 9999 });
assert.strictEqual(Temporal.Now.instant().epochMilliseconds, 9999);

// Advance in time will also advance Temporal.Now
context.mock.timers.tick(1);
assert.strictEqual(Temporal.Now.instant().epochMilliseconds, 10000);
});
```

```cjs
const assert = require('node:assert');
const { test } = require('node:test');

test('mocks Temporal.Now', (context) => {
// Optionally choose what to mock
context.mock.timers.enable({ apis: ['Temporal.Now'], now: 9999 });
assert.strictEqual(Temporal.Now.instant().epochMilliseconds, 9999);

// Advance in time will also advance Temporal.Now
context.mock.timers.tick(1);
assert.strictEqual(Temporal.Now.instant().epochMilliseconds, 10000);
});
```

## Snapshot testing

<!-- YAML
Expand Down Expand Up @@ -2914,7 +2971,8 @@
control the behavior of timers, such as `setInterval` and `setTimeout`,
without actually waiting for the specified time intervals.

MockTimers is also able to mock the `Date` object.
MockTimers is also able to mock the `Date` object and the [`Temporal.Now`][]
methods.

The [`MockTracker`][] provides a top-level `timers` export
which is a `MockTimers` instance.
Expand All @@ -2926,6 +2984,9 @@
- v20.4.0
- v18.19.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/REPLACEME

Check warning on line 2988 in doc/api/test.md

View workflow job for this annotation

GitHub Actions / lint-pr-url

pr-url doesn't match the URL of the current PR.
description: Added support for mocking `Temporal.Now`.
- version:
- v21.2.0
- v20.11.0
Expand All @@ -2940,19 +3001,25 @@
mocking. The following properties are supported:
* `apis` {Array} An optional array containing the timers to mock.
The currently supported timer values are `'setInterval'`, `'setTimeout'`, `'setImmediate'`,
and `'Date'`. **Default:** `['setInterval', 'setTimeout', 'setImmediate', 'Date']`.
`'Date'`, and `'Temporal.Now'`.
**Default:** `['setInterval', 'setTimeout', 'setImmediate', 'Date', 'Temporal.Now']`.
If no array is provided, all time related APIs (`'setInterval'`, `'clearInterval'`,
`'setTimeout'`, `'clearTimeout'`, `'setImmediate'`, `'clearImmediate'`, and
`'Date'`) will be mocked by default.
`'setTimeout'`, `'clearTimeout'`, `'setImmediate'`, `'clearImmediate'`,
`'Date'`, and `'Temporal.Now'`) will be mocked by default.
* `now` {number | Date} An optional number or Date object representing the
initial time (in milliseconds) to use as the value
for `Date.now()`. **Default:** `0`.
for `Date.now()` and the mocked [`Temporal.Now`][] methods. **Default:** `0`.

**Note:** When you enable mocking for a specific timer, its associated
clear function will also be implicitly mocked.

**Note:** Mocking `Date` will affect the behavior of the mocked timers
as they use the same internal clock.
**Note:** Mocking `Date` or `Temporal.Now` will affect the behavior of the
mocked timers as they use the same internal clock.

**Note:** Mocking `Temporal.Now` only virtualizes the clock.
`Temporal.Now.timeZoneId()` is not mocked and returns the actual system time
zone. Since the internal clock has millisecond precision, the sub-millisecond
digits of `Temporal.Now.instant().epochNanoseconds` are always zero.

Example usage without setting initial time:

Expand Down Expand Up @@ -3002,7 +3069,8 @@
`'setImmediate'`, and `'clearImmediate'`) will be mocked. The `setInterval`,
`clearInterval`, `setTimeout`, `clearTimeout`, `setImmediate`, and
`clearImmediate` functions from `node:timers`, `node:timers/promises`, and
`globalThis` will be mocked. As well as the global `Date` object.
`globalThis` will be mocked. As well as the global `Date` object and the
[`Temporal.Now`][] methods.

### `timers.reset()`

Expand Down Expand Up @@ -4957,6 +5025,7 @@
[`MockTracker`]: #class-mocktracker
[`NODE_V8_COVERAGE`]: cli.md#node_v8_coveragedir
[`SuiteContext`]: #class-suitecontext
[`Temporal.Now`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Now
[`TestContext`]: #class-testcontext
[`TracingChannel`]: diagnostics_channel.md#class-tracingchannel
[`assert.throws`]: assert.md#assertthrowsfn-error-message
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/process/pre_execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ function prepareExecution(options) {

require('internal/dns/utils').initializeDns();

require('internal/util/temporal').initialize();

if (isMainThread) {
assert(internalBinding('worker').isMainThread);
// Worker threads will get the manifest in the message handler.
Expand Down
109 changes: 108 additions & 1 deletion lib/internal/test_runner/mock/mock_timers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const {
ArrayPrototypeFilter,
ArrayPrototypeForEach,
ArrayPrototypeIncludes,
DatePrototypeGetTime,
Expand Down Expand Up @@ -40,6 +41,11 @@ const { AbortController, AbortSignal } = require('internal/abort_controller');

const { TIMEOUT_MAX } = require('internal/timers');

// internal/util/temporal is initialized in the pre-execution phase, so its
// properties must be accessed at the point of use rather than destructured
// at module load time.
const temporalUtils = require('internal/util/temporal');

const PriorityQueue = require('internal/priority_queue');
const nodeTimers = require('timers');
const nodeTimersPromises = require('timers/promises');
Expand All @@ -63,7 +69,8 @@ function abortIt(signal) {
}

/**
* @typedef {('setTimeout'|'setInterval'|'setImmediate'|'Date'|'scheduler.wait'|'AbortSignal.timeout')[]} SupportedApis
* @typedef {('setTimeout'|'setInterval'|'setImmediate'|'Date'|
* 'scheduler.wait'|'AbortSignal.timeout'|'Temporal.Now')[]} SupportedApis
* Supported timers that can be enabled via MockTimers.enable({ apis: [...] })
*/
const SUPPORTED_APIS = [
Expand All @@ -73,6 +80,17 @@ const SUPPORTED_APIS = [
'Date',
'scheduler.wait',
'AbortSignal.timeout',
'Temporal.Now',
];

// Temporal.Now methods that read the current time. timeZoneId() is
// intentionally not mocked: only the clock is virtualized, not the time zone.
const MOCKED_TEMPORAL_NOW_METHODS = [
'instant',
'zonedDateTimeISO',
'plainDateTimeISO',
'plainDateISO',
'plainTimeISO',
];
const TIMERS_DEFAULT_INTERVAL = {
__proto__: null,
Expand Down Expand Up @@ -138,6 +156,7 @@ class MockTimers {
#realPromisifiedSetImmediate;

#nativeDateDescriptor;
#realTemporalNowDescriptors;
#realAbortSignalTimeout;

#timersInContext = [];
Expand Down Expand Up @@ -455,6 +474,51 @@ class MockTimers {
return MockDate;
}

#createTemporalNowMethods() {
const mock = this;
// The default time zone is read through the original timeZoneId captured
// at enable time, so it always matches what the non-mocked timeZoneId()
// returns.
const realTimeZoneId = this.#realTemporalNowDescriptors.timeZoneId.value;

function instant() {
return temporalUtils.TemporalInstantFromEpochMilliseconds(mock.#now);
}

function zonedDateTimeISO(timeZone = realTimeZoneId()) {
return temporalUtils.TemporalInstantPrototypeToZonedDateTimeISO(
instant(), timeZone,
);
}

function plainDateTimeISO(timeZone = realTimeZoneId()) {
return temporalUtils.TemporalZonedDateTimePrototypeToPlainDateTime(
zonedDateTimeISO(timeZone),
);
}

function plainDateISO(timeZone = realTimeZoneId()) {
return temporalUtils.TemporalZonedDateTimePrototypeToPlainDate(
zonedDateTimeISO(timeZone),
);
}

function plainTimeISO(timeZone = realTimeZoneId()) {
return temporalUtils.TemporalZonedDateTimePrototypeToPlainTime(
zonedDateTimeISO(timeZone),
);
}

return {
__proto__: null,
instant,
zonedDateTimeISO,
plainDateTimeISO,
plainDateISO,
plainTimeISO,
};
}

async * #setIntervalPromisified(interval, result, options) {
const emitter = new EventEmitter();

Expand Down Expand Up @@ -637,6 +701,22 @@ class MockTimers {
this.#nativeDateDescriptor = ObjectSetPrototypeOf(ObjectGetOwnPropertyDescriptor(globalThis, 'Date'), null);
globalThis.Date = this.#createDate();
},
'Temporal.Now': () => {
const TemporalNow = globalThis.Temporal.Now;
this.#realTemporalNowDescriptors = ObjectSetPrototypeOf(
ObjectGetOwnPropertyDescriptors(TemporalNow), null);

const mocked = this.#createTemporalNowMethods();
ArrayPrototypeForEach(MOCKED_TEMPORAL_NOW_METHODS, (name) => {
ObjectDefineProperty(TemporalNow, name, {
__proto__: null,
configurable: true,
enumerable: true,
writable: true,
value: mocked[name],
});
});
},
'AbortSignal.timeout': () => {
this.#storeOriginalAbortSignalTimeout();
const mock = this;
Expand Down Expand Up @@ -676,6 +756,15 @@ class MockTimers {
'Date': () => {
ObjectDefineProperty(globalThis, 'Date', this.#nativeDateDescriptor);
},
'Temporal.Now': () => {
const TemporalNow = globalThis.Temporal.Now;
ArrayPrototypeForEach(MOCKED_TEMPORAL_NOW_METHODS, (name) => {
ObjectDefineProperty(
TemporalNow, name,
this.#realTemporalNowDescriptors[name],
);
});
},
'AbortSignal.timeout': () => {
this.#restoreOriginalAbortSignalTimeout();
},
Expand Down Expand Up @@ -755,6 +844,24 @@ class MockTimers {
);
}
});

if (!temporalUtils.hasTemporal || globalThis.Temporal === undefined) {
// When apis defaults to SUPPORTED_APIS, silently exclude the token so
// that a parameterless enable() keeps working on builds without
// Temporal. An explicit request is an error.
if (internalOptions.apis !== SUPPORTED_APIS &&
ArrayPrototypeIncludes(internalOptions.apis, 'Temporal.Now')) {
throw new ERR_INVALID_ARG_VALUE(
'options.apis',
'Temporal.Now',
'Temporal is not available in this environment',
);
}
internalOptions.apis = ArrayPrototypeFilter(
internalOptions.apis, (api) => api !== 'Temporal.Now',
);
}

this.#timersInContext = internalOptions.apis;

// Checks if the second argument is the initial time
Expand Down
Loading
Loading