Skip to content

Commit 0e20072

Browse files
MoLowruyadorno
authored andcommitted
assert: add getCalls and reset to callTracker
PR-URL: #44191 Reviewed-By: Erick Wendel <erick.workspace@gmail.com> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
1 parent b1590bb commit 0e20072

File tree

3 files changed

+239
-34
lines changed

3 files changed

+239
-34
lines changed

doc/api/assert.md

+83
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,47 @@ function func() {}
322322
const callsfunc = tracker.calls(func);
323323
```
324324

325+
### `tracker.getCalls(fn)`
326+
327+
<!-- YAML
328+
added: REPLACEME
329+
-->
330+
331+
* `fn` {Function}.
332+
333+
* Returns: {Array} with all the calls to a tracked function.
334+
335+
* Object {Object}
336+
* `thisArg` {Object}
337+
* `arguments` {Array} the arguments passed to the tracked function
338+
339+
```mjs
340+
import assert from 'node:assert';
341+
342+
const tracker = new assert.CallTracker();
343+
344+
function func() {}
345+
const callsfunc = tracker.calls(func);
346+
callsfunc(1, 2, 3);
347+
348+
assert.deepStrictEqual(tracker.getCalls(callsfunc),
349+
[{ thisArg: this, arguments: [1, 2, 3 ] }]);
350+
```
351+
352+
```cjs
353+
const assert = require('node:assert');
354+
355+
// Creates call tracker.
356+
const tracker = new assert.CallTracker();
357+
358+
function func() {}
359+
const callsfunc = tracker.calls(func);
360+
callsfunc(1, 2, 3);
361+
362+
assert.deepStrictEqual(tracker.getCalls(callsfunc),
363+
[{ thisArg: this, arguments: [1, 2, 3 ] }]);
364+
```
365+
325366
### `tracker.report()`
326367

327368
<!-- YAML
@@ -395,6 +436,48 @@ tracker.report();
395436
// ]
396437
```
397438

439+
### `tracker.reset([fn])`
440+
441+
<!-- YAML
442+
added: REPLACEME
443+
-->
444+
445+
* `fn` {Function} a tracked function to reset.
446+
447+
reset calls of the call tracker.
448+
if a tracked function is passed as an argument, the calls will be reset for it.
449+
if no arguments are passed, all tracked functions will be reset
450+
451+
```mjs
452+
import assert from 'node:assert';
453+
454+
const tracker = new assert.CallTracker();
455+
456+
function func() {}
457+
const callsfunc = tracker.calls(func);
458+
459+
callsfunc();
460+
// Tracker was called once
461+
tracker.getCalls(callsfunc).length === 1;
462+
463+
tracker.reset(callsfunc);
464+
tracker.getCalls(callsfunc).length === 0;
465+
```
466+
467+
```cjs
468+
const assert = require('node:assert');
469+
470+
function func() {}
471+
const callsfunc = tracker.calls(func);
472+
473+
callsfunc();
474+
// Tracker was called once
475+
tracker.getCalls(callsfunc).length === 1;
476+
477+
tracker.reset(callsfunc);
478+
tracker.getCalls(callsfunc).length === 0;
479+
```
480+
398481
### `tracker.verify()`
399482

400483
<!-- YAML

lib/internal/assert/calltracker.js

+83-34
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
const {
44
ArrayPrototypePush,
5+
ArrayPrototypeSlice,
56
Error,
67
FunctionPrototype,
8+
ObjectFreeze,
79
Proxy,
810
ReflectApply,
911
SafeSet,
12+
SafeWeakMap,
1013
} = primordials;
1114

1215
const {
1316
codes: {
1417
ERR_UNAVAILABLE_DURING_EXIT,
18+
ERR_INVALID_ARG_VALUE,
1519
},
1620
} = require('internal/errors');
1721
const AssertionError = require('internal/assert/assertion_error');
@@ -21,66 +25,111 @@ const {
2125

2226
const noop = FunctionPrototype;
2327

28+
class CallTrackerContext {
29+
#expected;
30+
#calls;
31+
#name;
32+
#stackTrace;
33+
constructor({ expected, stackTrace, name }) {
34+
this.#calls = [];
35+
this.#expected = expected;
36+
this.#stackTrace = stackTrace;
37+
this.#name = name;
38+
}
39+
40+
track(thisArg, args) {
41+
const argsClone = ObjectFreeze(ArrayPrototypeSlice(args));
42+
ArrayPrototypePush(this.#calls, ObjectFreeze({ thisArg, arguments: argsClone }));
43+
}
44+
45+
get delta() {
46+
return this.#calls.length - this.#expected;
47+
}
48+
49+
reset() {
50+
this.#calls = [];
51+
}
52+
getCalls() {
53+
return ObjectFreeze(ArrayPrototypeSlice(this.#calls));
54+
}
55+
56+
report() {
57+
if (this.delta !== 0) {
58+
const message = `Expected the ${this.#name} function to be ` +
59+
`executed ${this.#expected} time(s) but was ` +
60+
`executed ${this.#calls.length} time(s).`;
61+
return {
62+
message,
63+
actual: this.#calls.length,
64+
expected: this.#expected,
65+
operator: this.#name,
66+
stack: this.#stackTrace
67+
};
68+
}
69+
}
70+
}
71+
2472
class CallTracker {
2573

2674
#callChecks = new SafeSet();
75+
#trackedFunctions = new SafeWeakMap();
76+
77+
#getTrackedFunction(tracked) {
78+
if (!this.#trackedFunctions.has(tracked)) {
79+
throw new ERR_INVALID_ARG_VALUE('tracked', tracked, 'is not a tracked function');
80+
}
81+
return this.#trackedFunctions.get(tracked);
82+
}
83+
84+
reset(tracked) {
85+
if (tracked === undefined) {
86+
this.#callChecks.forEach((check) => check.reset());
87+
return;
88+
}
2789

28-
calls(fn, exact = 1) {
90+
this.#getTrackedFunction(tracked).reset();
91+
}
92+
93+
getCalls(tracked) {
94+
return this.#getTrackedFunction(tracked).getCalls();
95+
}
96+
97+
calls(fn, expected = 1) {
2998
if (process._exiting)
3099
throw new ERR_UNAVAILABLE_DURING_EXIT();
31100
if (typeof fn === 'number') {
32-
exact = fn;
101+
expected = fn;
33102
fn = noop;
34103
} else if (fn === undefined) {
35104
fn = noop;
36105
}
37106

38-
validateUint32(exact, 'exact', true);
107+
validateUint32(expected, 'expected', true);
39108

40-
const context = {
41-
exact,
42-
actual: 0,
109+
const context = new CallTrackerContext({
110+
expected,
43111
// eslint-disable-next-line no-restricted-syntax
44112
stackTrace: new Error(),
45113
name: fn.name || 'calls'
46-
};
47-
const callChecks = this.#callChecks;
48-
callChecks.add(context);
49-
50-
return new Proxy(fn, {
114+
});
115+
const tracked = new Proxy(fn, {
51116
__proto__: null,
52117
apply(fn, thisArg, argList) {
53-
context.actual++;
54-
if (context.actual === context.exact) {
55-
// Once function has reached its call count remove it from
56-
// callChecks set to prevent memory leaks.
57-
callChecks.delete(context);
58-
}
59-
// If function has been called more than expected times, add back into
60-
// callchecks.
61-
if (context.actual === context.exact + 1) {
62-
callChecks.add(context);
63-
}
118+
context.track(thisArg, argList);
64119
return ReflectApply(fn, thisArg, argList);
65120
},
66121
});
122+
this.#callChecks.add(context);
123+
this.#trackedFunctions.set(tracked, context);
124+
return tracked;
67125
}
68126

69127
report() {
70128
const errors = [];
71129
for (const context of this.#callChecks) {
72-
// If functions have not been called exact times
73-
if (context.actual !== context.exact) {
74-
const message = `Expected the ${context.name} function to be ` +
75-
`executed ${context.exact} time(s) but was ` +
76-
`executed ${context.actual} time(s).`;
77-
ArrayPrototypePush(errors, {
78-
message,
79-
actual: context.actual,
80-
expected: context.exact,
81-
operator: context.name,
82-
stack: context.stackTrace
83-
});
130+
const message = context.report();
131+
if (message !== undefined) {
132+
ArrayPrototypePush(errors, message);
84133
}
85134
}
86135
return errors;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use strict';
2+
require('../common');
3+
const assert = require('assert');
4+
const { describe, it } = require('node:test');
5+
6+
7+
describe('assert.CallTracker.getCalls()', { concurrency: true }, () => {
8+
const tracker = new assert.CallTracker();
9+
10+
it('should return empty list when no calls', () => {
11+
const fn = tracker.calls();
12+
assert.deepStrictEqual(tracker.getCalls(fn), []);
13+
});
14+
15+
it('should return calls', () => {
16+
const fn = tracker.calls(() => {});
17+
const arg1 = {};
18+
const arg2 = {};
19+
fn(arg1, arg2);
20+
fn.call(arg2, arg2);
21+
assert.deepStrictEqual(tracker.getCalls(fn), [
22+
{ arguments: [arg1, arg2], thisArg: undefined },
23+
{ arguments: [arg2], thisArg: arg2 }]);
24+
});
25+
26+
it('should throw when getting calls of a non-tracked function', () => {
27+
[() => {}, 1, true, null, undefined, {}, []].forEach((fn) => {
28+
assert.throws(() => tracker.getCalls(fn), { code: 'ERR_INVALID_ARG_VALUE' });
29+
});
30+
});
31+
32+
it('should return a frozen object', () => {
33+
const fn = tracker.calls();
34+
fn();
35+
const calls = tracker.getCalls(fn);
36+
assert.throws(() => calls.push(1), /object is not extensible/);
37+
assert.throws(() => Object.assign(calls[0], { foo: 'bar' }), /object is not extensible/);
38+
assert.throws(() => calls[0].arguments.push(1), /object is not extensible/);
39+
});
40+
});
41+
42+
describe('assert.CallTracker.reset()', () => {
43+
const tracker = new assert.CallTracker();
44+
45+
it('should reset calls', () => {
46+
const fn = tracker.calls();
47+
fn();
48+
fn();
49+
fn();
50+
assert.strictEqual(tracker.getCalls(fn).length, 3);
51+
tracker.reset(fn);
52+
assert.deepStrictEqual(tracker.getCalls(fn), []);
53+
});
54+
55+
it('should reset all calls', () => {
56+
const fn1 = tracker.calls();
57+
const fn2 = tracker.calls();
58+
fn1();
59+
fn2();
60+
assert.strictEqual(tracker.getCalls(fn1).length, 1);
61+
assert.strictEqual(tracker.getCalls(fn2).length, 1);
62+
tracker.reset();
63+
assert.deepStrictEqual(tracker.getCalls(fn1), []);
64+
assert.deepStrictEqual(tracker.getCalls(fn2), []);
65+
});
66+
67+
68+
it('should throw when resetting a non-tracked function', () => {
69+
[() => {}, 1, true, null, {}, []].forEach((fn) => {
70+
assert.throws(() => tracker.reset(fn), { code: 'ERR_INVALID_ARG_VALUE' });
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)