Skip to content
File

Blob: src/node/internal/mock.js

javascript494 lines
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4//
5// Copyright Joyent, Inc. and other Node contributors.
6//
7// Permission is hereby granted, free of charge, to any person obtaining a
8// copy of this software and associated documentation files (the
9// "Software"), to deal in the Software without restriction, including
10// without limitation the rights to use, copy, modify, merge, publish,
11// distribute, sublicense, and/or sell copies of the Software, and to permit
12// persons to whom the Software is furnished to do so, subject to the
13// following conditions:
14//
15// The above copyright notice and this permission notice shall be included
16// in all copies or substantial portions of the Software.
17//
18// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
19// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
21// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
22// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT ORs
23// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
24// USE OR OTHER DEALINGS IN THE SOFTWARE.
25 
26import {
27 ERR_INVALID_ARG_TYPE,
28 ERR_INVALID_ARG_VALUE,
29} from 'node-internal:internal_errors';
30 
31import {
32 validateBoolean,
33 validateFunction,
34 validateInteger,
35 validateObject,
36} from 'node-internal:validators';
37 
38const kEmptyObject = Object.create(null);
39function kDefaultFunction() {}
40 
41// TODO(soon): MockTimers are currently still fairly experimental in Node.js.
42// The intention is to implement them but for now, I'm skipping it.
43//const { MockTimers } = require('internal/test_runner/mock/mock_timers');
44 
45export class MockFunctionContext {
46 #calls;
47 #mocks;
48 #implementation;
49 #restore;
50 #times;
51 
52 constructor(implementation, restore, times) {
53 this.#calls = [];
54 this.#mocks = new Map();
55 this.#implementation = implementation;
56 this.#restore = restore;
57 this.#times = times;
58 }
59 
60 /**
61 * Gets an array of recorded calls made to the mock function.
62 * @returns {Array} An array of recorded calls.
63 */
64 get calls() {
65 return this.#calls.slice(0);
66 }
67 
68 /**
69 * Retrieves the number of times the mock function has been called.
70 * @returns {number} The call count.
71 */
72 callCount() {
73 return this.#calls.length;
74 }
75 
76 /**
77 * Sets a new implementation for the mock function.
78 * @param {Function} implementation - The new implementation for the mock function.
79 */
80 mockImplementation(implementation) {
81 validateFunction(implementation, 'implementation');
82 this.#implementation = implementation;
83 }
84 
85 /**
86 * Replaces the implementation of the function only once.
87 * @param {Function} implementation - The substitute function.
88 * @param {number} [onCall] - The call index to be replaced.
89 */
90 mockImplementationOnce(implementation, onCall) {
91 validateFunction(implementation, 'implementation');
92 const nextCall = this.#calls.length;
93 const call = onCall ?? nextCall;
94 validateInteger(call, 'onCall', nextCall);
95 this.#mocks.set(call, implementation);
96 }
97 
98 /**
99 * Restores the original function that was mocked.
100 */
101 restore() {
102 const { descriptor, object, original, methodName } = this.#restore;
103 
104 if (typeof methodName === 'string') {
105 // This is an object method spy.
106 Object.defineProperty(object, methodName, descriptor);
107 } else {
108 // This is a bare function spy. There isn't much to do here but make
109 // the mock call the original function.
110 this.#implementation = original;
111 }
112 }
113 
114 /**
115 * Resets the recorded calls to the mock function
116 */
117 resetCalls() {
118 this.#calls = [];
119 }
120 
121 /**
122 * Tracks a call made to the mock function.
123 * @param {object} call - The call details.
124 */
125 trackCall(call) {
126 this.#calls.push(call);
127 }
128 
129 /**
130 * Gets the next implementation to use for the mock function.
131 * @returns {Function} The next implementation.
132 */
133 nextImpl() {
134 const nextCall = this.#calls.length;
135 const mock = this.#mocks.get(nextCall);
136 const impl = mock ?? this.#implementation;
137 
138 if (nextCall + 1 === this.#times) {
139 this.restore();
140 }
141 
142 this.#mocks.delete(nextCall);
143 return impl;
144 }
145}
146 
147const { nextImpl, restore, trackCall } = MockFunctionContext.prototype;
148delete MockFunctionContext.prototype.trackCall;
149delete MockFunctionContext.prototype.nextImpl;
150 
151export class MockTracker {
152 #mocks = [];
153 
154 // TODO(soon): MockTimers are currently still fairly experimental in Node.js.
155 // The intention is to implement them but for now, I'm skipping it.
156 // #timers;
157 
158 // /**
159 // * Returns the mock timers of this MockTracker instance.
160 // * @returns {MockTimers} The mock timers instance.
161 // */
162 // get timers() {
163 // this.#timers ??= new MockTimers();
164 // return this.#timers;
165 // }
166 
167 /**
168 * Creates a mock function tracker.
169 * @param {Function} [original] - The original function to be tracked.
170 * @param {Function} [implementation] - An optional replacement function for the original one.
171 * @param {object} [options] - Additional tracking options.
172 * @param {number} [options.times=Infinity] - The maximum number of times the mock function can be called.
173 * @returns {ProxyConstructor} The mock function tracker.
174 */
175 fn(
176 original = function () {},
177 implementation = original,
178 options = kEmptyObject
179 ) {
180 if (original !== null && typeof original === 'object') {
181 options = original;
182 original = function () {};
183 implementation = original;
184 } else if (implementation !== null && typeof implementation === 'object') {
185 options = implementation;
186 implementation = original;
187 }
188 
189 validateFunction(original, 'original');
190 validateFunction(implementation, 'implementation');
191 validateObject(options, 'options');
192 const { times = Infinity } = options;
193 validateTimes(times, 'options.times');
194 const ctx = new MockFunctionContext(
195 implementation,
196 { __proto__: null, original },
197 times
198 );
199 return this.#setupMock(ctx, original);
200 }
201 
202 /**
203 * Creates a method tracker for a specified object or function.
204 * @param {(object | Function)} objectOrFunction - The object or function containing the method to be tracked.
205 * @param {string} methodName - The name of the method to be tracked.
206 * @param {Function} [implementation] - An optional replacement function for the original method.
207 * @param {object} [options] - Additional tracking options.
208 * @param {boolean} [options.getter=false] - Indicates whether this is a getter method.
209 * @param {boolean} [options.setter=false] - Indicates whether this is a setter method.
210 * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
211 * @returns {ProxyConstructor} The mock method tracker.
212 */
213 method(
214 objectOrFunction,
215 methodName,
216 implementation = kDefaultFunction,
217 options = kEmptyObject
218 ) {
219 validateStringOrSymbol(methodName, 'methodName');
220 if (typeof objectOrFunction !== 'function') {
221 validateObject(objectOrFunction, 'object');
222 }
223 
224 if (implementation !== null && typeof implementation === 'object') {
225 options = implementation;
226 implementation = kDefaultFunction;
227 }
228 
229 validateFunction(implementation, 'implementation');
230 validateObject(options, 'options');
231 
232 const { getter = false, setter = false, times = Infinity } = options;
233 
234 validateBoolean(getter, 'options.getter');
235 validateBoolean(setter, 'options.setter');
236 validateTimes(times, 'options.times');
237 
238 if (setter && getter) {
239 throw new ERR_INVALID_ARG_VALUE(
240 'options.setter',
241 setter,
242 "cannot be used with 'options.getter'"
243 );
244 }
245 const descriptor = findMethodOnPrototypeChain(objectOrFunction, methodName);
246 
247 let original;
248 
249 if (getter) {
250 original = descriptor?.get;
251 } else if (setter) {
252 original = descriptor?.set;
253 } else {
254 original = descriptor?.value;
255 }
256 
257 if (typeof original !== 'function') {
258 throw new ERR_INVALID_ARG_VALUE(
259 'methodName',
260 original,
261 'must be a method'
262 );
263 }
264 
265 const restore = {
266 __proto__: null,
267 descriptor,
268 object: objectOrFunction,
269 methodName,
270 };
271 const impl =
272 implementation === kDefaultFunction ? original : implementation;
273 const ctx = new MockFunctionContext(impl, restore, times);
274 const mock = this.#setupMock(ctx, original);
275 const mockDescriptor = {
276 __proto__: null,
277 configurable: descriptor.configurable,
278 enumerable: descriptor.enumerable,
279 };
280 
281 if (getter) {
282 mockDescriptor.get = mock;
283 mockDescriptor.set = descriptor.set;
284 } else if (setter) {
285 mockDescriptor.get = descriptor.get;
286 mockDescriptor.set = mock;
287 } else {
288 mockDescriptor.writable = descriptor.writable;
289 mockDescriptor.value = mock;
290 }
291 
292 Object.defineProperty(objectOrFunction, methodName, mockDescriptor);
293 
294 return mock;
295 }
296 
297 /**
298 * Mocks a getter method of an object.
299 * This is a syntax sugar for the MockTracker.method with options.getter set to true
300 * @param {object} object - The target object.
301 * @param {string} methodName - The name of the getter method to be mocked.
302 * @param {Function} [implementation] - An optional replacement function for the targeted method.
303 * @param {object} [options] - Additional tracking options.
304 * @param {boolean} [options.getter=true] - Indicates whether this is a getter method.
305 * @param {boolean} [options.setter=false] - Indicates whether this is a setter method.
306 * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
307 * @returns {ProxyConstructor} The mock method tracker.
308 */
309 getter(
310 object,
311 methodName,
312 implementation = kDefaultFunction,
313 options = kEmptyObject
314 ) {
315 if (implementation !== null && typeof implementation === 'object') {
316 options = implementation;
317 implementation = kDefaultFunction;
318 } else {
319 validateObject(options, 'options');
320 }
321 
322 const { getter = true } = options;
323 
324 if (getter === false) {
325 throw new ERR_INVALID_ARG_VALUE(
326 'options.getter',
327 getter,
328 'cannot be false'
329 );
330 }
331 
332 return this.method(object, methodName, implementation, {
333 __proto__: null,
334 ...options,
335 getter,
336 });
337 }
338 
339 /**
340 * Mocks a setter method of an object.
341 * This function is a syntax sugar for MockTracker.method with options.setter set to true.
342 * @param {object} object - The target object.
343 * @param {string} methodName - The setter method to be mocked.
344 * @param {Function} [implementation] - An optional replacement function for the targeted method.
345 * @param {object} [options] - Additional tracking options.
346 * @param {boolean} [options.getter=false] - Indicates whether this is a getter method.
347 * @param {boolean} [options.setter=true] - Indicates whether this is a setter method.
348 * @param {number} [options.times=Infinity] - The maximum number of times the mock method can be called.
349 * @returns {ProxyConstructor} The mock method tracker.
350 */
351 setter(
352 object,
353 methodName,
354 implementation = kDefaultFunction,
355 options = kEmptyObject
356 ) {
357 if (implementation !== null && typeof implementation === 'object') {
358 options = implementation;
359 implementation = kDefaultFunction;
360 } else {
361 validateObject(options, 'options');
362 }
363 
364 const { setter = true } = options;
365 
366 if (setter === false) {
367 throw new ERR_INVALID_ARG_VALUE(
368 'options.setter',
369 setter,
370 'cannot be false'
371 );
372 }
373 
374 return this.method(object, methodName, implementation, {
375 __proto__: null,
376 ...options,
377 setter,
378 });
379 }
380 
381 /**
382 * Resets the mock tracker, restoring all mocks and clearing timers.
383 */
384 reset() {
385 this.restoreAll();
386 // this.#timers?.reset();
387 this.#mocks = [];
388 }
389 
390 /**
391 * Restore all mocks created by this MockTracker instance.
392 */
393 restoreAll() {
394 for (let i = 0; i < this.#mocks.length; i++) {
395 restore.call(this.#mocks[i]);
396 }
397 }
398 
399 #setupMock(ctx, fnToMatch) {
400 const mock = new Proxy(fnToMatch, {
401 __proto__: null,
402 apply(_fn, thisArg, argList) {
403 const fn = nextImpl.call(ctx);
404 let result;
405 let error;
406 
407 try {
408 result = Reflect.apply(fn, thisArg, argList);
409 } catch (err) {
410 error = err;
411 throw err;
412 } finally {
413 trackCall.call(ctx, {
414 __proto__: null,
415 arguments: argList,
416 error,
417 result,
418 stack: new Error(),
419 target: undefined,
420 this: thisArg,
421 });
422 }
423 
424 return result;
425 },
426 construct(target, argList, newTarget) {
427 const realTarget = nextImpl.call(ctx);
428 let result;
429 let error;
430 
431 try {
432 result = Reflect.construct(realTarget, argList, newTarget);
433 } catch (err) {
434 error = err;
435 throw err;
436 } finally {
437 trackCall.call(ctx, {
438 __proto__: null,
439 arguments: argList,
440 error,
441 result,
442 stack: new Error(),
443 target,
444 this: result,
445 });
446 }
447 
448 return result;
449 },
450 get(target, property, receiver) {
451 if (property === 'mock') {
452 return ctx;
453 }
454 
455 return Reflect.get(target, property, receiver);
456 },
457 });
458 
459 this.#mocks.push(ctx);
460 return mock;
461 }
462}
463 
464function validateStringOrSymbol(value, name) {
465 if (typeof value !== 'string' && typeof value !== 'symbol') {
466 throw new ERR_INVALID_ARG_TYPE(name, ['string', 'symbol'], value);
467 }
468}
469 
470function validateTimes(value, name) {
471 if (value === Infinity) {
472 return;
473 }
474 
475 validateInteger(value, name, 1);
476}
477 
478function findMethodOnPrototypeChain(instance, methodName) {
479 let host = instance;
480 let descriptor;
481 
482 while (host !== null) {
483 descriptor = Object.getOwnPropertyDescriptor(host, methodName);
484 
485 if (descriptor) {
486 break;
487 }
488 
489 host = Object.getPrototypeOf(host);
490 }
491 
492 return descriptor;
493}