Skip to content

Commit 05e7f28

Browse files
ErickWendeltargos
authored andcommittedOct 28, 2023
test_runner: add initial draft for fakeTimers
Signed-off-by: Erick Wendel <erick.workspace@gmail.com> PR-URL: #47775 Backport-PR-URL: #49618 Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
1 parent 2b30c8b commit 05e7f28

File tree

6 files changed

+1443
-2
lines changed

6 files changed

+1443
-2
lines changed
 

‎doc/api/test.md

+528
Large diffs are not rendered by default.

‎lib/internal/test_runner/mock.js renamed to ‎lib/internal/test_runner/mock/mock.js

+8
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
validateInteger,
2727
validateObject,
2828
} = require('internal/validators');
29+
const { MockTimers } = require('internal/test_runner/mock/mock_timers');
2930

3031
function kDefaultFunction() {}
3132

@@ -106,6 +107,12 @@ delete MockFunctionContext.prototype.nextImpl;
106107

107108
class MockTracker {
108109
#mocks = [];
110+
#timers;
111+
112+
get timers() {
113+
this.#timers ??= new MockTimers();
114+
return this.#timers;
115+
}
109116

110117
fn(
111118
original = function() {},
@@ -265,6 +272,7 @@ class MockTracker {
265272

266273
reset() {
267274
this.restoreAll();
275+
this.#timers?.reset();
268276
this.#mocks = [];
269277
}
270278

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
'use strict';
2+
3+
const {
4+
emitExperimentalWarning,
5+
} = require('internal/util');
6+
7+
const {
8+
ArrayPrototypeForEach,
9+
ArrayPrototypeIncludes,
10+
DateNow,
11+
FunctionPrototypeApply,
12+
FunctionPrototypeBind,
13+
Promise,
14+
SymbolAsyncIterator,
15+
globalThis,
16+
} = primordials;
17+
const {
18+
validateAbortSignal,
19+
validateArray,
20+
} = require('internal/validators');
21+
22+
const {
23+
AbortError,
24+
codes: {
25+
ERR_INVALID_STATE,
26+
ERR_INVALID_ARG_VALUE,
27+
},
28+
} = require('internal/errors');
29+
30+
const PriorityQueue = require('internal/priority_queue');
31+
const nodeTimers = require('timers');
32+
const nodeTimersPromises = require('timers/promises');
33+
const EventEmitter = require('events');
34+
35+
function compareTimersLists(a, b) {
36+
return (a.runAt - b.runAt) || (a.id - b.id);
37+
}
38+
39+
function setPosition(node, pos) {
40+
node.priorityQueuePosition = pos;
41+
}
42+
43+
function abortIt(signal) {
44+
return new AbortError(undefined, { cause: signal.reason });
45+
}
46+
47+
const SUPPORTED_TIMERS = ['setTimeout', 'setInterval'];
48+
49+
class MockTimers {
50+
#realSetTimeout;
51+
#realClearTimeout;
52+
#realSetInterval;
53+
#realClearInterval;
54+
55+
#realPromisifiedSetTimeout;
56+
#realPromisifiedSetInterval;
57+
58+
#realTimersSetTimeout;
59+
#realTimersClearTimeout;
60+
#realTimersSetInterval;
61+
#realTimersClearInterval;
62+
63+
#timersInContext = [];
64+
#isEnabled = false;
65+
#currentTimer = 1;
66+
#now = DateNow();
67+
68+
#executionQueue = new PriorityQueue(compareTimersLists, setPosition);
69+
70+
#setTimeout = FunctionPrototypeBind(this.#createTimer, this, false);
71+
#clearTimeout = FunctionPrototypeBind(this.#clearTimer, this);
72+
#setInterval = FunctionPrototypeBind(this.#createTimer, this, true);
73+
#clearInterval = FunctionPrototypeBind(this.#clearTimer, this);
74+
75+
constructor() {
76+
emitExperimentalWarning('The MockTimers API');
77+
}
78+
79+
#createTimer(isInterval, callback, delay, ...args) {
80+
const timerId = this.#currentTimer++;
81+
this.#executionQueue.insert({
82+
__proto__: null,
83+
id: timerId,
84+
callback,
85+
runAt: this.#now + delay,
86+
interval: isInterval,
87+
args,
88+
});
89+
90+
return timerId;
91+
}
92+
93+
#clearTimer(position) {
94+
this.#executionQueue.removeAt(position);
95+
}
96+
97+
async * #setIntervalPromisified(interval, startTime, options) {
98+
const context = this;
99+
const emitter = new EventEmitter();
100+
if (options?.signal) {
101+
validateAbortSignal(options.signal, 'options.signal');
102+
103+
if (options.signal?.aborted) {
104+
throw abortIt(options.signal);
105+
}
106+
107+
const onAbort = (reason) => {
108+
emitter.emit('data', { __proto__: null, aborted: true, reason });
109+
};
110+
111+
options.signal?.addEventListener('abort', onAbort, {
112+
__proto__: null,
113+
once: true,
114+
});
115+
}
116+
117+
const eventIt = EventEmitter.on(emitter, 'data');
118+
const callback = () => {
119+
startTime += interval;
120+
emitter.emit('data', startTime);
121+
};
122+
123+
const timerId = this.#createTimer(true, callback, interval, options);
124+
const clearListeners = () => {
125+
emitter.removeAllListeners();
126+
context.#clearTimer(timerId);
127+
};
128+
const iterator = {
129+
__proto__: null,
130+
[SymbolAsyncIterator]() {
131+
return this;
132+
},
133+
async next() {
134+
const result = await eventIt.next();
135+
const value = result.value[0];
136+
if (value?.aborted) {
137+
iterator.return();
138+
throw abortIt(options.signal);
139+
}
140+
141+
return {
142+
__proto__: null,
143+
done: result.done,
144+
value,
145+
};
146+
},
147+
async return() {
148+
clearListeners();
149+
return eventIt.return();
150+
},
151+
};
152+
yield* iterator;
153+
}
154+
155+
#setTimeoutPromisified(ms, result, options) {
156+
return new Promise((resolve, reject) => {
157+
if (options?.signal) {
158+
try {
159+
validateAbortSignal(options.signal, 'options.signal');
160+
} catch (err) {
161+
return reject(err);
162+
}
163+
164+
if (options.signal?.aborted) {
165+
return reject(abortIt(options.signal));
166+
}
167+
}
168+
169+
const onabort = () => {
170+
this.#clearTimeout(id);
171+
return reject(abortIt(options.signal));
172+
};
173+
174+
const id = this.#setTimeout(() => {
175+
return resolve(result || id);
176+
}, ms);
177+
178+
options?.signal?.addEventListener('abort', onabort, {
179+
__proto__: null,
180+
once: true,
181+
});
182+
});
183+
}
184+
185+
#toggleEnableTimers(activate) {
186+
const options = {
187+
toFake: {
188+
setTimeout: () => {
189+
this.#realSetTimeout = globalThis.setTimeout;
190+
this.#realClearTimeout = globalThis.clearTimeout;
191+
this.#realTimersSetTimeout = nodeTimers.setTimeout;
192+
this.#realTimersClearTimeout = nodeTimers.clearTimeout;
193+
this.#realPromisifiedSetTimeout = nodeTimersPromises.setTimeout;
194+
195+
globalThis.setTimeout = this.#setTimeout;
196+
globalThis.clearTimeout = this.#clearTimeout;
197+
198+
nodeTimers.setTimeout = this.#setTimeout;
199+
nodeTimers.clearTimeout = this.#clearTimeout;
200+
201+
nodeTimersPromises.setTimeout = FunctionPrototypeBind(
202+
this.#setTimeoutPromisified,
203+
this,
204+
);
205+
},
206+
setInterval: () => {
207+
this.#realSetInterval = globalThis.setInterval;
208+
this.#realClearInterval = globalThis.clearInterval;
209+
this.#realTimersSetInterval = nodeTimers.setInterval;
210+
this.#realTimersClearInterval = nodeTimers.clearInterval;
211+
this.#realPromisifiedSetInterval = nodeTimersPromises.setInterval;
212+
213+
globalThis.setInterval = this.#setInterval;
214+
globalThis.clearInterval = this.#clearInterval;
215+
216+
nodeTimers.setInterval = this.#setInterval;
217+
nodeTimers.clearInterval = this.#clearInterval;
218+
219+
nodeTimersPromises.setInterval = FunctionPrototypeBind(
220+
this.#setIntervalPromisified,
221+
this,
222+
);
223+
},
224+
},
225+
toReal: {
226+
setTimeout: () => {
227+
globalThis.setTimeout = this.#realSetTimeout;
228+
globalThis.clearTimeout = this.#realClearTimeout;
229+
230+
nodeTimers.setTimeout = this.#realTimersSetTimeout;
231+
nodeTimers.clearTimeout = this.#realTimersClearTimeout;
232+
233+
nodeTimersPromises.setTimeout = this.#realPromisifiedSetTimeout;
234+
},
235+
setInterval: () => {
236+
globalThis.setInterval = this.#realSetInterval;
237+
globalThis.clearInterval = this.#realClearInterval;
238+
239+
nodeTimers.setInterval = this.#realTimersSetInterval;
240+
nodeTimers.clearInterval = this.#realTimersClearInterval;
241+
242+
nodeTimersPromises.setInterval = this.#realPromisifiedSetInterval;
243+
},
244+
},
245+
};
246+
247+
const target = activate ? options.toFake : options.toReal;
248+
ArrayPrototypeForEach(this.#timersInContext, (timer) => target[timer]());
249+
this.#isEnabled = activate;
250+
}
251+
252+
tick(time = 1) {
253+
if (!this.#isEnabled) {
254+
throw new ERR_INVALID_STATE(
255+
'You should enable MockTimers first by calling the .enable function',
256+
);
257+
}
258+
259+
if (time < 0) {
260+
throw new ERR_INVALID_ARG_VALUE(
261+
'time',
262+
'positive integer',
263+
time,
264+
);
265+
}
266+
267+
this.#now += time;
268+
let timer = this.#executionQueue.peek();
269+
while (timer) {
270+
if (timer.runAt > this.#now) break;
271+
FunctionPrototypeApply(timer.callback, undefined, timer.args);
272+
273+
this.#executionQueue.shift();
274+
275+
if (timer.interval) {
276+
timer.runAt += timer.interval;
277+
this.#executionQueue.insert(timer);
278+
return;
279+
}
280+
281+
timer = this.#executionQueue.peek();
282+
}
283+
}
284+
285+
enable(timers = SUPPORTED_TIMERS) {
286+
if (this.#isEnabled) {
287+
throw new ERR_INVALID_STATE(
288+
'MockTimers is already enabled!',
289+
);
290+
}
291+
292+
validateArray(timers, 'timers');
293+
294+
// Check that the timers passed are supported
295+
ArrayPrototypeForEach(timers, (timer) => {
296+
if (!ArrayPrototypeIncludes(SUPPORTED_TIMERS, timer)) {
297+
throw new ERR_INVALID_ARG_VALUE(
298+
'timers',
299+
timer,
300+
`option ${timer} is not supported`,
301+
);
302+
}
303+
});
304+
305+
this.#timersInContext = timers;
306+
this.#now = DateNow();
307+
this.#toggleEnableTimers(true);
308+
}
309+
310+
reset() {
311+
// Ignore if not enabled
312+
if (!this.#isEnabled) return;
313+
314+
this.#toggleEnableTimers(false);
315+
this.#timersInContext = [];
316+
317+
let timer = this.#executionQueue.peek();
318+
while (timer) {
319+
this.#executionQueue.shift();
320+
timer = this.#executionQueue.peek();
321+
}
322+
}
323+
324+
runAll() {
325+
if (!this.#isEnabled) {
326+
throw new ERR_INVALID_STATE(
327+
'You should enable MockTimers first by calling the .enable function',
328+
);
329+
}
330+
331+
this.tick(Infinity);
332+
}
333+
}
334+
335+
module.exports = { MockTimers };

‎lib/internal/test_runner/test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const {
3131
},
3232
AbortError,
3333
} = require('internal/errors');
34-
const { MockTracker } = require('internal/test_runner/mock');
34+
const { MockTracker } = require('internal/test_runner/mock/mock');
3535
const { TestsStream } = require('internal/test_runner/tests_stream');
3636
const {
3737
createDeferredCallback,

‎lib/test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ ObjectDefineProperty(module.exports, 'mock', {
2323
enumerable: true,
2424
get() {
2525
if (lazyMock === undefined) {
26-
const { MockTracker } = require('internal/test_runner/mock');
26+
const { MockTracker } = require('internal/test_runner/mock/mock');
2727

2828
lazyMock = new MockTracker();
2929
}

‎test/parallel/test-runner-mock-timers.js

+570
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.