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 new callTracker.callsWith function #43133

Closed
Closed
Show file tree
Hide file tree
Changes from 2 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
67 changes: 67 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,73 @@ function func() {}
const callsfunc = tracker.calls(func);
```

### `tracker.callsWith([fn],withArgs[, exact])`
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
### `tracker.callsWith([fn],withArgs[, exact])`
### `tracker.callsWith([fn], withArgs[, exact])`

it is very strange to have an optional argument before a required one. can that be reevaluated?


<!-- YAML
added:
- REPLACEME
-->

* `fn` {Function} **Default:** A no-op function.
* `withArgs` {Array} **Default:** An array with the arguments list.
* `exact` {number} **Default:** `1`.
* Returns: {Function} that wraps `fn`.

The wrapper function is expected to be called exactly `exact` times. If the
function has not been called exactly `exact` times when
[`tracker.verify()`][] is called, then [`tracker.verify()`][] will throw an
error.
tniessen marked this conversation as resolved.
Show resolved Hide resolved

```mjs
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
```mjs
```js

i don't think mjs is a syntax highlighting mode, this is just JS

import assert from 'node:assert';

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

function func() {}

// Returns a function that wraps func() that must be called exact times
// Before tracker.verify().
const callsfunc = tracker.callsWith(func, ['test', 1, 2]);
callsfunc('test', 1, 2);

// Or without any no-op function and calling twice
const myfn = tracker.callsWith(['test', 1, 2], 2);
myfn('test', 1, 2);
myfn('test', 1, 2);
tracker.verify();

// Or
const fn = tracker.callsWith(['test', 1, 2]);
fn('test', 1, 2);
tracker.verify();
```

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

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

function func() {}

// Returns a function that wraps func() that must be called exact times
// Before tracker.verify().
const callsfunc = tracker.callsWith(func, ['test', 1, 2]);
callsfunc('test', 1, 2);

// Or without any no-op function and calling twice
const myfn = tracker.callsWith(['test', 1, 2], 2);
myfn('test', 1, 2);
myfn('test', 1, 2);
tracker.verify();

// Or
const fn = tracker.callsWith(['test', 1, 2]);
fn('test', 1, 2);
tracker.verify();
```

### `tracker.report()`

<!-- YAML
Expand Down
86 changes: 78 additions & 8 deletions lib/internal/assert/calltracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const {
ArrayPrototypePush,
ArrayIsArray,
Error,
FunctionPrototype,
Proxy,
Expand All @@ -13,67 +14,136 @@ const {
codes: {
ERR_UNAVAILABLE_DURING_EXIT,
},
genericNodeError,
} = require('internal/errors');

const AssertionError = require('internal/assert/assertion_error');
const {
validateUint32,
} = require('internal/validators');
const {
isDeepStrictEqual,
} = require('internal/util/comparisons');

const noop = FunctionPrototype;

class CallTracker {

#callChecks = new SafeSet();

calls(fn, exact = 1) {
#calls(fn, exact, withArgs = []) {
if (process._exiting)
throw new ERR_UNAVAILABLE_DURING_EXIT();

// When calls([arg1, arg2], ?1)
if (ArrayIsArray(fn)) {
exact = typeof withArgs === 'number' ? withArgs : exact;
withArgs = fn;
fn = noop;
}

// When calls(1)
if (typeof fn === 'number') {
exact = fn;
fn = noop;
} else if (fn === undefined) {
}

// When calls()
if (fn === undefined) {
fn = noop;
}
// Else calls(fn, 1, [])

validateUint32(exact, 'exact', true);

const context = {
exact,
actual: 0,
expectedArgs: withArgs,
currentFnArgs: [],
// 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, {
__proto__: null,
apply(fn, thisArg, argList) {
context.actual++;
if (context.actual === context.exact) {

// Only spy args if requested
if (context.expectedArgs.length)
Copy link
Member

Choose a reason for hiding this comment

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

is this checking if it's truthy, or if it's > 1? a user doesn't have to pass in a number, does it?

context.currentFnArgs = argList;

// TODO:(erick): functions with different instances are not deepStrictEqual
const containsExpectArgs = isDeepStrictEqual(context.currentFnArgs, context.expectedArgs);

if (context.actual === context.exact && containsExpectArgs) {
// Once function has reached its call count remove it from
// callChecks set to prevent memory leaks.
callChecks.delete(context);
}
// If function has been called more than expected times, add back into
// callchecks.
if (context.actual === context.exact + 1) {
if (context.actual === context.exact + 1 && containsExpectArgs) {
callChecks.add(context);
}
return ReflectApply(fn, thisArg, argList);
},
});
}

callsWith(fn, withArgs, exact = 1) {
const expectedArgsWerePassed = ArrayIsArray(fn) ||
ArrayIsArray(exact) ||
ArrayIsArray(withArgs);

if (!expectedArgsWerePassed) {
const message = 'the [ withArgs ] param is required';

throw new AssertionError({
message,
details: ArrayPrototypePush([], {
message,
operator: 'callsWith',
stack: genericNodeError()
})
Comment on lines +108 to +112
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
details: ArrayPrototypePush([], {
message,
operator: 'callsWith',
stack: genericNodeError()
})
details: [{
message,
operator: 'callsWith',
stack: genericNodeError()
}]

});
}

return this.#calls(fn, exact, withArgs);
}

calls(fn, exact = 1) {
return this.#calls(fn, exact);
}

report() {
const errors = [];
for (const context of this.#callChecks) {
// If functions have not been called exact times
if (context.actual !== context.exact) {

const needsToCheckArgs = !!context.expectedArgs;

const invalidArgs = needsToCheckArgs ?
(context.currentFnArgs !== context.expectedArgs) :
false;

const msg = needsToCheckArgs ? [
`with args (${context.expectedArgs}) `,
` with args (${context.currentFnArgs})`,
] : ['', ''];

// If functions have not been called exact times and with correct arguments
if (
context.actual !== context.exact ||
invalidArgs
) {
const message = `Expected the ${context.name} function to be ` +
`executed ${context.exact} time(s) but was ` +
`executed ${context.actual} time(s).`;
`executed ${context.exact} time(s) ${msg[0]}but was ` +
`executed ${context.actual} time(s)${msg[1]}.`;
ArrayPrototypePush(errors, {
message,
actual: context.actual,
Expand Down
126 changes: 126 additions & 0 deletions test/parallel/test-assert-calltracker-callsWith.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'use strict';
require('../common');
const assert = require('assert');

function bar() {}

// It should call once the bar function with 1 as first argument
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, [1], 1);
callsNoop(1);
tracker.verify();
}

// It should call once the bar function with 'a' and 'b' as arguments
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, ['a', 'b'], 1);
callsNoop('a', 'b');
tracker.verify();
}

// It should call twice the bar function with 'a' and 'b' as arguments
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(bar, ['a', 'b'], 2);
callsNoop('a', 'b');
callsNoop('a', 'b');
tracker.verify();
}

// When calling the watching function with incorrect params it should show an error message
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(['a', 'b'], 1);
callsNoop('c', 'd');
const expectedMessage = 'Expected the calls function to be executed 1 time(s)' +
' with args (a,b) but was executed 1 time(s) with args (c,d).';
const [{ message }] = tracker.report();
assert.deepStrictEqual(message, expectedMessage);
}

// It should show an error with calling the function wrongly once and rightly once
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith(['a', 'b'], 1);
callsNoop('c', 'd');
callsNoop('a', 'b');
const expectedMessage = 'Expected the calls function to be executed 1 time(s)' +
' with args (a,b) but was executed 2 time(s) with args (a,b).';
const [{ message }] = tracker.report();
assert.deepStrictEqual(message, expectedMessage);
}

// It should verify once the given args
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([bar]);
callsNoop(bar);
tracker.verify();
}

// It should call 100 times with the same args
{
const tracker = new assert.CallTracker();
const callCount = 100;
const callsNoop = tracker.callsWith([ { abc: 1 }, [1], bar], callCount);
for (let i = 0; i < callCount; i++)
callsNoop({ abc: 1 }, [1], bar);

tracker.verify();
}

// Known behavior: it should validate two different function instances as not equal
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([function() {}]);
callsNoop(function() {});

const [{ message }] = tracker.report();
const expectedMsg = 'Expected the calls function to be executed 1 time(s)' +
' with args (function() {}) but was executed 1 time(s) with args (function() {}).';
assert.deepStrictEqual(message, expectedMsg);
}

// It should not use the same signature of tracker.call
{
const tracker = new assert.CallTracker();
assert.throws(
() => tracker.callsWith(bar),
{
code: 'ERR_ASSERTION',
}
);
}

// It should not use the same signature of tracker.call
{
const tracker = new assert.CallTracker();
assert.throws(
() => tracker.callsWith(1),
{
code: 'ERR_ASSERTION',
}
);
}

// It should make sure that the objects' instances have different values
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([ new Map([[ 'a', '1' ]]) ]);
callsNoop(new Map([[ 'a', '2']]));

const [{ message }] = tracker.report();
const expectedMsg = 'Expected the calls function to be executed 1 time(s)' +
' with args ([object Map]) but was executed 1 time(s) with args ([object Map]).';
assert.deepStrictEqual(message, expectedMsg);
}

// It should validate two complex objects
{
const tracker = new assert.CallTracker();
const callsNoop = tracker.callsWith([ new Map([[ 'a', '1' ]]), new Set([ 'a', 2]) ]);
callsNoop(new Map([[ 'a', '1']]), new Set([ 'a', 2]));
tracker.verify();
}