Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assert: add getCalls and reset to callTracker #44191

Merged
merged 1 commit into from
Aug 11, 2022
Merged
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
83 changes: 83 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,47 @@ function func() {}
const callsfunc = tracker.calls(func);
```

### `tracker.getCalls(fn)`

<!-- YAML
added: REPLACEME
-->

* `fn` {Function}.

* Returns: {Array} with all the calls to a tracked function.

* Object {Object}
* `thisArg` {Object}
* `arguments` {Array} the arguments passed to the tracked function

```mjs
import assert from 'node:assert';

const tracker = new assert.CallTracker();

function func() {}
const callsfunc = tracker.calls(func);
callsfunc(1, 2, 3);

assert.deepStrictEqual(tracker.getCalls(callsfunc),
[{ thisArg: this, arguments: [1, 2, 3 ] }]);
```

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

// Creates call tracker.
const tracker = new assert.CallTracker();

function func() {}
const callsfunc = tracker.calls(func);
callsfunc(1, 2, 3);

assert.deepStrictEqual(tracker.getCalls(callsfunc),
[{ thisArg: this, arguments: [1, 2, 3 ] }]);
```

### `tracker.report()`

<!-- YAML
Expand Down Expand Up @@ -395,6 +436,48 @@ tracker.report();
// ]
```

### `tracker.reset([fn])`

<!-- YAML
added: REPLACEME
-->

* `fn` {Function} a tracked function to reset.

reset calls of the call tracker.
if a tracked function is passed as an argument, the calls will be reset for it.
if no arguments are passed, all tracked functions will be reset

```mjs
import assert from 'node:assert';

const tracker = new assert.CallTracker();

function func() {}
const callsfunc = tracker.calls(func);

callsfunc();
// Tracker was called once
tracker.getCalls(callsfunc).length === 1;

tracker.reset(callsfunc);
tracker.getCalls(callsfunc).length === 0;
```

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

function func() {}
const callsfunc = tracker.calls(func);

callsfunc();
// Tracker was called once
tracker.getCalls(callsfunc).length === 1;

tracker.reset(callsfunc);
tracker.getCalls(callsfunc).length === 0;
```

### `tracker.verify()`

<!-- YAML
Expand Down
117 changes: 83 additions & 34 deletions lib/internal/assert/calltracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

const {
ArrayPrototypePush,
ArrayPrototypeSlice,
Error,
FunctionPrototype,
ObjectFreeze,
Proxy,
ReflectApply,
SafeSet,
SafeWeakMap,
} = primordials;

const {
codes: {
ERR_UNAVAILABLE_DURING_EXIT,
ERR_INVALID_ARG_VALUE,
},
} = require('internal/errors');
const AssertionError = require('internal/assert/assertion_error');
Expand All @@ -21,66 +25,111 @@ const {

const noop = FunctionPrototype;

class CallTrackerContext {
#expected;
#calls;
#name;
#stackTrace;
constructor({ expected, stackTrace, name }) {
this.#calls = [];
this.#expected = expected;
this.#stackTrace = stackTrace;
this.#name = name;
}

track(thisArg, args) {
const argsClone = ObjectFreeze(ArrayPrototypeSlice(args));
ArrayPrototypePush(this.#calls, ObjectFreeze({ thisArg, arguments: argsClone }));
}

get delta() {
return this.#calls.length - this.#expected;
}

reset() {
this.#calls = [];
}
getCalls() {
return ObjectFreeze(ArrayPrototypeSlice(this.#calls));
}

report() {
if (this.delta !== 0) {
const message = `Expected the ${this.#name} function to be ` +
`executed ${this.#expected} time(s) but was ` +
`executed ${this.#calls.length} time(s).`;
return {
message,
actual: this.#calls.length,
expected: this.#expected,
operator: this.#name,
stack: this.#stackTrace
};
}
}
}

class CallTracker {

#callChecks = new SafeSet();
#trackedFunctions = new SafeWeakMap();

#getTrackedFunction(tracked) {
if (!this.#trackedFunctions.has(tracked)) {
throw new ERR_INVALID_ARG_VALUE('tracked', tracked, 'is not a tracked function');
}
return this.#trackedFunctions.get(tracked);
}

reset(tracked) {
if (tracked === undefined) {
this.#callChecks.forEach((check) => check.reset());
return;
}

calls(fn, exact = 1) {
this.#getTrackedFunction(tracked).reset();
}

getCalls(tracked) {
return this.#getTrackedFunction(tracked).getCalls();
}

calls(fn, expected = 1) {
if (process._exiting)
throw new ERR_UNAVAILABLE_DURING_EXIT();
if (typeof fn === 'number') {
exact = fn;
expected = fn;
fn = noop;
} else if (fn === undefined) {
fn = noop;
}

validateUint32(exact, 'exact', true);
validateUint32(expected, 'expected', true);

const context = {
exact,
actual: 0,
const context = new CallTrackerContext({
expected,
// eslint-disable-next-line no-restricted-syntax
stackTrace: new Error(),
name: fn.name || 'calls'
};
const callChecks = this.#callChecks;
callChecks.add(context);

return new Proxy(fn, {
});
const tracked = new Proxy(fn, {
__proto__: null,
apply(fn, thisArg, argList) {
context.actual++;
if (context.actual === context.exact) {
// Once function has reached its call count remove it from
// callChecks set to prevent memory leaks.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this "memory leak" is now needed by design. it can be avoided by consumers using reset.
anyway - this module is mainly designed to be used for testing

callChecks.delete(context);
}
// If function has been called more than expected times, add back into
// callchecks.
if (context.actual === context.exact + 1) {
callChecks.add(context);
}
context.track(thisArg, argList);
return ReflectApply(fn, thisArg, argList);
},
});
this.#callChecks.add(context);
this.#trackedFunctions.set(tracked, context);
return tracked;
}

report() {
const errors = [];
for (const context of this.#callChecks) {
// If functions have not been called exact times
if (context.actual !== context.exact) {
const message = `Expected the ${context.name} function to be ` +
`executed ${context.exact} time(s) but was ` +
`executed ${context.actual} time(s).`;
ArrayPrototypePush(errors, {
message,
actual: context.actual,
expected: context.exact,
operator: context.name,
stack: context.stackTrace
});
const message = context.report();
if (message !== undefined) {
ArrayPrototypePush(errors, message);
}
}
return errors;
Expand Down
73 changes: 73 additions & 0 deletions test/parallel/test-assert-calltracker-getCalls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';
require('../common');
const assert = require('assert');
const { describe, it } = require('node:test');


describe('assert.CallTracker.getCalls()', { concurrency: true }, () => {
const tracker = new assert.CallTracker();

it('should return empty list when no calls', () => {
const fn = tracker.calls();
assert.deepStrictEqual(tracker.getCalls(fn), []);
});

it('should return calls', () => {
const fn = tracker.calls(() => {});
const arg1 = {};
const arg2 = {};
fn(arg1, arg2);
fn.call(arg2, arg2);
assert.deepStrictEqual(tracker.getCalls(fn), [
{ arguments: [arg1, arg2], thisArg: undefined },
{ arguments: [arg2], thisArg: arg2 }]);
});

it('should throw when getting calls of a non-tracked function', () => {
[() => {}, 1, true, null, undefined, {}, []].forEach((fn) => {
assert.throws(() => tracker.getCalls(fn), { code: 'ERR_INVALID_ARG_VALUE' });
});
});

it('should return a frozen object', () => {
const fn = tracker.calls();
fn();
const calls = tracker.getCalls(fn);
assert.throws(() => calls.push(1), /object is not extensible/);
assert.throws(() => Object.assign(calls[0], { foo: 'bar' }), /object is not extensible/);
assert.throws(() => calls[0].arguments.push(1), /object is not extensible/);
});
});

describe('assert.CallTracker.reset()', () => {
const tracker = new assert.CallTracker();

it('should reset calls', () => {
const fn = tracker.calls();
fn();
fn();
fn();
assert.strictEqual(tracker.getCalls(fn).length, 3);
tracker.reset(fn);
assert.deepStrictEqual(tracker.getCalls(fn), []);
});

it('should reset all calls', () => {
const fn1 = tracker.calls();
const fn2 = tracker.calls();
fn1();
fn2();
assert.strictEqual(tracker.getCalls(fn1).length, 1);
assert.strictEqual(tracker.getCalls(fn2).length, 1);
tracker.reset();
assert.deepStrictEqual(tracker.getCalls(fn1), []);
assert.deepStrictEqual(tracker.getCalls(fn2), []);
});


it('should throw when resetting a non-tracked function', () => {
[() => {}, 1, true, null, {}, []].forEach((fn) => {
assert.throws(() => tracker.reset(fn), { code: 'ERR_INVALID_ARG_VALUE' });
});
});
});