• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict';
2const {
3  ArrayPrototypePush,
4  ArrayPrototypeSlice,
5  Error,
6  FunctionPrototypeCall,
7  ObjectDefineProperty,
8  ObjectGetOwnPropertyDescriptor,
9  ObjectGetPrototypeOf,
10  Proxy,
11  ReflectApply,
12  ReflectConstruct,
13  ReflectGet,
14  SafeMap,
15} = primordials;
16const {
17  codes: {
18    ERR_INVALID_ARG_TYPE,
19    ERR_INVALID_ARG_VALUE,
20  },
21} = require('internal/errors');
22const { kEmptyObject } = require('internal/util');
23const {
24  validateBoolean,
25  validateFunction,
26  validateInteger,
27  validateObject,
28} = require('internal/validators');
29const { MockTimers } = require('internal/test_runner/mock/mock_timers');
30
31function kDefaultFunction() {}
32
33class MockFunctionContext {
34  #calls;
35  #mocks;
36  #implementation;
37  #restore;
38  #times;
39
40  constructor(implementation, restore, times) {
41    this.#calls = [];
42    this.#mocks = new SafeMap();
43    this.#implementation = implementation;
44    this.#restore = restore;
45    this.#times = times;
46  }
47
48  /**
49   * Gets an array of recorded calls made to the mock function.
50   * @returns {Array} An array of recorded calls.
51   */
52  get calls() {
53    return ArrayPrototypeSlice(this.#calls, 0);
54  }
55
56  /**
57   * Retrieves the number of times the mock function has been called.
58   * @returns {number} The call count.
59   */
60  callCount() {
61    return this.#calls.length;
62  }
63
64  /**
65   * Sets a new implementation for the mock function.
66   * @param {Function} implementation - The new implementation for the mock function.
67   */
68  mockImplementation(implementation) {
69    validateFunction(implementation, 'implementation');
70    this.#implementation = implementation;
71  }
72
73  /**
74   * Replaces the implementation of the function only once.
75   * @param {Function} implementation - The substitute function.
76   * @param {number} [onCall] - The call index to be replaced.
77   */
78  mockImplementationOnce(implementation, onCall) {
79    validateFunction(implementation, 'implementation');
80    const nextCall = this.#calls.length;
81    const call = onCall ?? nextCall;
82    validateInteger(call, 'onCall', nextCall);
83    this.#mocks.set(call, implementation);
84  }
85
86  /**
87   * Restores the original function that was mocked.
88   */
89  restore() {
90    const { descriptor, object, original, methodName } = this.#restore;
91
92    if (typeof methodName === 'string') {
93      // This is an object method spy.
94      ObjectDefineProperty(object, methodName, descriptor);
95    } else {
96      // This is a bare function spy. There isn't much to do here but make
97      // the mock call the original function.
98      this.#implementation = original;
99    }
100  }
101
102  /**
103   * Resets the recorded calls to the mock function
104   */
105  resetCalls() {
106    this.#calls = [];
107  }
108
109  /**
110   * Tracks a call made to the mock function.
111   * @param {object} call - The call details.
112   */
113  trackCall(call) {
114    ArrayPrototypePush(this.#calls, call);
115  }
116
117  /**
118   * Gets the next implementation to use for the mock function.
119   * @returns {Function} The next implementation.
120   */
121  nextImpl() {
122    const nextCall = this.#calls.length;
123    const mock = this.#mocks.get(nextCall);
124    const impl = mock ?? this.#implementation;
125
126    if (nextCall + 1 === this.#times) {
127      this.restore();
128    }
129
130    this.#mocks.delete(nextCall);
131    return impl;
132  }
133}
134
135const { nextImpl, restore, trackCall } = MockFunctionContext.prototype;
136delete MockFunctionContext.prototype.trackCall;
137delete MockFunctionContext.prototype.nextImpl;
138
139class MockTracker {
140  #mocks = [];
141  #timers;
142
143  /**
144   * Returns the mock timers of this MockTracker instance.
145   * @returns {MockTimers} The mock timers instance.
146   */
147  get timers() {
148    this.#timers ??= new MockTimers();
149    return this.#timers;
150  }
151
152  /**
153   * Creates a mock function tracker.
154   * @param {Function} [original] - The original function to be tracked.
155   * @param {Function} [implementation] - An optional replacement function for the original one.
156   * @param {object} [options] - Additional tracking options.
157   * @param {number} [options.times=Infinity] - The maximum number of times the mock function can be called.
158   * @returns {ProxyConstructor} The mock function tracker.
159   */
160  fn(
161    original = function() {},
162    implementation = original,
163    options = kEmptyObject,
164  ) {
165    if (original !== null && typeof original === 'object') {
166      options = original;
167      original = function() {};
168      implementation = original;
169    } else if (implementation !== null && typeof implementation === 'object') {
170      options = implementation;
171      implementation = original;
172    }
173
174    validateFunction(original, 'original');
175    validateFunction(implementation, 'implementation');
176    validateObject(options, 'options');
177    const { times = Infinity } = options;
178    validateTimes(times, 'options.times');
179    const ctx = new MockFunctionContext(implementation, { __proto__: null, original }, times);
180    return this.#setupMock(ctx, original);
181  }
182
183  /**
184   * Creates a method tracker for a specified object or function.
185   * @param {(object | Function)} objectOrFunction - The object or function containing the method to be tracked.
186   * @param {string} methodName - The name of the method to be tracked.
187   * @param {Function} [implementation] - An optional replacement function for the original method.
188   * @param {object} [options] - Additional tracking options.
189   * @param {boolean} [options.getter=false] - Indicates whether this is a getter method.
190   * @param {boolean} [options.setter=false] - Indicates whether this is a setter method.
191   * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
192   * @returns {ProxyConstructor} The mock method tracker.
193   */
194  method(
195    objectOrFunction,
196    methodName,
197    implementation = kDefaultFunction,
198    options = kEmptyObject,
199  ) {
200    validateStringOrSymbol(methodName, 'methodName');
201    if (typeof objectOrFunction !== 'function') {
202      validateObject(objectOrFunction, 'object');
203    }
204
205    if (implementation !== null && typeof implementation === 'object') {
206      options = implementation;
207      implementation = kDefaultFunction;
208    }
209
210    validateFunction(implementation, 'implementation');
211    validateObject(options, 'options');
212
213    const {
214      getter = false,
215      setter = false,
216      times = Infinity,
217    } = options;
218
219    validateBoolean(getter, 'options.getter');
220    validateBoolean(setter, 'options.setter');
221    validateTimes(times, 'options.times');
222
223    if (setter && getter) {
224      throw new ERR_INVALID_ARG_VALUE(
225        'options.setter', setter, "cannot be used with 'options.getter'",
226      );
227    }
228    const descriptor = findMethodOnPrototypeChain(objectOrFunction, methodName);
229
230    let original;
231
232    if (getter) {
233      original = descriptor?.get;
234    } else if (setter) {
235      original = descriptor?.set;
236    } else {
237      original = descriptor?.value;
238    }
239
240    if (typeof original !== 'function') {
241      throw new ERR_INVALID_ARG_VALUE(
242        'methodName', original, 'must be a method',
243      );
244    }
245
246    const restore = { __proto__: null, descriptor, object: objectOrFunction, methodName };
247    const impl = implementation === kDefaultFunction ?
248      original : implementation;
249    const ctx = new MockFunctionContext(impl, restore, times);
250    const mock = this.#setupMock(ctx, original);
251    const mockDescriptor = {
252      __proto__: null,
253      configurable: descriptor.configurable,
254      enumerable: descriptor.enumerable,
255    };
256
257    if (getter) {
258      mockDescriptor.get = mock;
259      mockDescriptor.set = descriptor.set;
260    } else if (setter) {
261      mockDescriptor.get = descriptor.get;
262      mockDescriptor.set = mock;
263    } else {
264      mockDescriptor.writable = descriptor.writable;
265      mockDescriptor.value = mock;
266    }
267
268    ObjectDefineProperty(objectOrFunction, methodName, mockDescriptor);
269
270    return mock;
271  }
272
273  /**
274   * Mocks a getter method of an object.
275   * This is a syntax sugar for the MockTracker.method with options.getter set to true
276   * @param {object} object - The target object.
277   * @param {string} methodName - The name of the getter method to be mocked.
278   * @param {Function} [implementation] - An optional replacement function for the targeted method.
279   * @param {object} [options] - Additional tracking options.
280   * @param {boolean} [options.getter=true] - Indicates whether this is a getter method.
281   * @param {boolean} [options.setter=false] - Indicates whether this is a setter method.
282   * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
283   * @returns {ProxyConstructor} The mock method tracker.
284   */
285  getter(
286    object,
287    methodName,
288    implementation = kDefaultFunction,
289    options = kEmptyObject,
290  ) {
291    if (implementation !== null && typeof implementation === 'object') {
292      options = implementation;
293      implementation = kDefaultFunction;
294    } else {
295      validateObject(options, 'options');
296    }
297
298    const { getter = true } = options;
299
300    if (getter === false) {
301      throw new ERR_INVALID_ARG_VALUE(
302        'options.getter', getter, 'cannot be false',
303      );
304    }
305
306    return this.method(object, methodName, implementation, {
307      __proto__: null,
308      ...options,
309      getter,
310    });
311  }
312
313  /**
314   * Mocks a setter method of an object.
315   * This function is a syntax sugar for MockTracker.method with options.setter set to true.
316   * @param {object} object - The target object.
317   * @param {string} methodName  - The setter method to be mocked.
318   * @param {Function} [implementation] - An optional replacement function for the targeted method.
319   * @param {object} [options] - Additional tracking options.
320   * @param {boolean} [options.getter=false] - Indicates whether this is a getter method.
321   * @param {boolean} [options.setter=true] - Indicates whether this is a setter method.
322   * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
323   * @returns {ProxyConstructor} The mock method tracker.
324   */
325  setter(
326    object,
327    methodName,
328    implementation = kDefaultFunction,
329    options = kEmptyObject,
330  ) {
331    if (implementation !== null && typeof implementation === 'object') {
332      options = implementation;
333      implementation = kDefaultFunction;
334    } else {
335      validateObject(options, 'options');
336    }
337
338    const { setter = true } = options;
339
340    if (setter === false) {
341      throw new ERR_INVALID_ARG_VALUE(
342        'options.setter', setter, 'cannot be false',
343      );
344    }
345
346    return this.method(object, methodName, implementation, {
347      __proto__: null,
348      ...options,
349      setter,
350    });
351  }
352
353  /**
354   * Resets the mock tracker, restoring all mocks and clearing timers.
355   */
356  reset() {
357    this.restoreAll();
358    this.#timers?.reset();
359    this.#mocks = [];
360  }
361
362  /**
363   * Restore all mocks created by this MockTracker instance.
364   */
365  restoreAll() {
366    for (let i = 0; i < this.#mocks.length; i++) {
367      FunctionPrototypeCall(restore, this.#mocks[i]);
368    }
369  }
370
371  #setupMock(ctx, fnToMatch) {
372    const mock = new Proxy(fnToMatch, {
373      __proto__: null,
374      apply(_fn, thisArg, argList) {
375        const fn = FunctionPrototypeCall(nextImpl, ctx);
376        let result;
377        let error;
378
379        try {
380          result = ReflectApply(fn, thisArg, argList);
381        } catch (err) {
382          error = err;
383          throw err;
384        } finally {
385          FunctionPrototypeCall(trackCall, ctx, {
386            __proto__: null,
387            arguments: argList,
388            error,
389            result,
390            // eslint-disable-next-line no-restricted-syntax
391            stack: new Error(),
392            target: undefined,
393            this: thisArg,
394          });
395        }
396
397        return result;
398      },
399      construct(target, argList, newTarget) {
400        const realTarget = FunctionPrototypeCall(nextImpl, ctx);
401        let result;
402        let error;
403
404        try {
405          result = ReflectConstruct(realTarget, argList, newTarget);
406        } catch (err) {
407          error = err;
408          throw err;
409        } finally {
410          FunctionPrototypeCall(trackCall, ctx, {
411            __proto__: null,
412            arguments: argList,
413            error,
414            result,
415            // eslint-disable-next-line no-restricted-syntax
416            stack: new Error(),
417            target,
418            this: result,
419          });
420        }
421
422        return result;
423      },
424      get(target, property, receiver) {
425        if (property === 'mock') {
426          return ctx;
427        }
428
429        return ReflectGet(target, property, receiver);
430      },
431    });
432
433    ArrayPrototypePush(this.#mocks, ctx);
434    return mock;
435  }
436}
437
438function validateStringOrSymbol(value, name) {
439  if (typeof value !== 'string' && typeof value !== 'symbol') {
440    throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value);
441  }
442}
443
444function validateTimes(value, name) {
445  if (value === Infinity) {
446    return;
447  }
448
449  validateInteger(value, name, 1);
450}
451
452function findMethodOnPrototypeChain(instance, methodName) {
453  let host = instance;
454  let descriptor;
455
456  while (host !== null) {
457    descriptor = ObjectGetOwnPropertyDescriptor(host, methodName);
458
459    if (descriptor) {
460      break;
461    }
462
463    host = ObjectGetPrototypeOf(host);
464  }
465
466  return descriptor;
467}
468
469module.exports = { MockTracker };
470