Skip to content

Commit

Permalink
assert: port common.mustCall() to assert
Browse files Browse the repository at this point in the history
Fixes: #31392

PR-URL: #31982
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Zeyu Yang <himself65@outlook.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Denys Otrishko <shishugi@gmail.com>
  • Loading branch information
ConorDavenport authored and jasnell committed Apr 23, 2020
1 parent 01e158c commit 50d28d4
Show file tree
Hide file tree
Showing 10 changed files with 388 additions and 3 deletions.
135 changes: 135 additions & 0 deletions doc/api/assert.md
Expand Up @@ -147,6 +147,137 @@ try {
}
```

## Class: `assert.CallTracker`

### `new assert.CallTracker()`
<!-- YAML
added: REPLACEME
-->

Creates a new [`CallTracker`][] object which can be used to track if functions
were called a specific number of times. The `tracker.verify()` must be called
for the verification to take place. The usual pattern would be to call it in a
[`process.on('exit')`][] handler.

```js
const assert = require('assert');

const tracker = new assert.CallTracker();

function func() {}

// callsfunc() must be called exactly 1 time before tracker.verify().
const callsfunc = tracker.calls(func, 1);

callsfunc();

// Calls tracker.verify() and verifies if all tracker.calls() functions have
// been called exact times.
process.on('exit', () => {
tracker.verify();
});
```

### `tracker.calls([fn][, exact])`
<!-- YAML
added: REPLACEME
-->

* `fn` {Function} **Default** A no-op function.
* `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.

```js
const assert = require('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.calls(func);
```

### `tracker.report()`
<!-- YAML
added: REPLACEME
-->

* Returns: {Array} of objects containing information about the wrapper functions
returned by [`tracker.calls()`][].
* Object {Object}
* `message` {string}
* `actual` {number} The actual number of times the function was called.
* `expected` {number} The number of times the function was expected to be
called.
* `operator` {string} The name of the function that is wrapped.
* `stack` {Object} A stack trace of the function.

This comment has been minimized.

Copy link
@Semigradsky

Semigradsky Sep 11, 2020

Contributor

@ConorDavenport @jasnell Shouldn't there be Error instead of Object?


The arrays contains information about the expected and actual number of calls of
the functions that have not been called the expected number of times.

```js
const assert = require('assert');

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

function func() {}

function foo() {}

// Returns a function that wraps func() that must be called exact times
// before tracker.verify().
const callsfunc = tracker.calls(func, 2);

// Returns an array containing information on callsfunc()
tracker.report();
// [
// {
// message: 'Expected the func function to be executed 2 time(s) but was
// executed 0 time(s).',
// actual: 0,
// expected: 2,
// operator: 'func',
// stack: stack trace
// }
// ]
```

### `tracker.verify()`
<!-- YAML
added: REPLACEME
-->

Iterates through the list of functions passed to
[`tracker.calls()`][] and will throw an error for functions that
have not been called the expected number of times.

```js
const assert = require('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.calls(func, 2);

callsfunc();

// Will throw an error since callsfunc() was only called once.
tracker.verify();
```

## `assert(value[, message])`
<!-- YAML
added: v0.5.9
Expand Down Expand Up @@ -1423,6 +1554,7 @@ argument.
[`TypeError`]: errors.html#errors_class_typeerror
[`WeakMap`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
[`WeakSet`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet
[`CallTracker`]: #assert_class_assert_calltracker
[`assert.deepEqual()`]: #assert_assert_deepequal_actual_expected_message
[`assert.deepStrictEqual()`]: #assert_assert_deepstrictequal_actual_expected_message
[`assert.doesNotThrow()`]: #assert_assert_doesnotthrow_fn_error_message
Expand All @@ -1434,6 +1566,9 @@ argument.
[`assert.ok()`]: #assert_assert_ok_value_message
[`assert.strictEqual()`]: #assert_assert_strictequal_actual_expected_message
[`assert.throws()`]: #assert_assert_throws_fn_error_message
[`process.on('exit')`]: process.html#process_event_exit
[`tracker.calls()`]: #assert_class_assert_CallTracker#tracker_calls
[`tracker.verify()`]: #assert_class_assert_CallTracker#tracker_verify
[strict assertion mode]: #assert_strict_assertion_mode
[Abstract Equality Comparison]: https://tc39.github.io/ecma262/#sec-abstract-equality-comparison
[Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript
Expand Down
7 changes: 7 additions & 0 deletions doc/api/errors.md
Expand Up @@ -1967,6 +1967,12 @@ A `Transform` stream finished with data still in the write buffer.

The initialization of a TTY failed due to a system error.

<a id="ERR_UNAVAILABLE_DURING_EXIT"></a>
### `ERR_UNAVAILABLE_DURING_EXIT`

Function was called within a [`process.on('exit')`][] handler that shouldn't be
called within [`process.on('exit')`][] handler.

<a id="ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET"></a>
### `ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET`

Expand Down Expand Up @@ -2543,6 +2549,7 @@ such as `process.stdout.on('data')`.
[`net`]: net.html
[`new URL(input)`]: url.html#url_constructor_new_url_input_base
[`new URLSearchParams(iterable)`]: url.html#url_constructor_new_urlsearchparams_iterable
[`process.on('exit')`]: process.html#Event:-`'exit'`
[`process.send()`]: process.html#process_process_send_message_sendhandle_options_callback
[`process.setUncaughtExceptionCaptureCallback()`]: process.html#process_process_setuncaughtexceptioncapturecallback_fn
[`readable._read()`]: stream.html#stream_readable_read_size_1
Expand Down
3 changes: 3 additions & 0 deletions lib/assert.js
Expand Up @@ -51,6 +51,7 @@ const { NativeModule } = require('internal/bootstrap/loaders');
const { isError } = require('internal/util');

const errorCache = new Map();
const CallTracker = require('internal/assert/calltracker');

let isDeepEqual;
let isDeepStrictEqual;
Expand Down Expand Up @@ -928,6 +929,8 @@ assert.doesNotMatch = function doesNotMatch(string, regexp, message) {
internalMatch(string, regexp, message, doesNotMatch);
};

assert.CallTracker = CallTracker;

// Expose a strict only variant of assert
function strict(...args) {
innerOk(strict, args.length, ...args);
Expand Down
20 changes: 17 additions & 3 deletions lib/internal/assert/assertion_error.js
Expand Up @@ -312,6 +312,7 @@ class AssertionError extends Error {
message,
operator,
stackStartFn,
details,
// Compatibility with older versions.
stackStartFunction
} = options;
Expand Down Expand Up @@ -426,9 +427,22 @@ class AssertionError extends Error {
configurable: true
});
this.code = 'ERR_ASSERTION';
this.actual = actual;
this.expected = expected;
this.operator = operator;
if (details) {
this.actual = undefined;
this.expected = undefined;
this.operator = undefined;
for (let i = 0; i < details.length; i++) {
this['message ' + i] = details[i].message;
this['actual ' + i] = details[i].actual;
this['expected ' + i] = details[i].expected;
this['operator ' + i] = details[i].operator;
this['stack trace ' + i] = details[i].stack;
}
} else {
this.actual = actual;
this.expected = expected;
this.operator = operator;
}
// eslint-disable-next-line no-restricted-syntax
Error.captureStackTrace(this, stackStartFn || stackStartFunction);
// Create error message including the error code in the name.
Expand Down
93 changes: 93 additions & 0 deletions lib/internal/assert/calltracker.js
@@ -0,0 +1,93 @@
'use strict';

const {
Error,
SafeSet,
} = primordials;

const {
codes: {
ERR_UNAVAILABLE_DURING_EXIT,
},
} = require('internal/errors');
const AssertionError = require('internal/assert/assertion_error');
const {
validateUint32,
} = require('internal/validators');

const noop = () => {};

class CallTracker {

#callChecks = new SafeSet()

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

validateUint32(exact, 'exact', true);

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

return function() {
context.actual++;
if (context.actual === context.exact) {
// 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) {
callChecks.add(context);
}
return fn.apply(this, arguments);
};
}

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).`;
errors.push({
message,
actual: context.actual,
expected: context.exact,
operator: context.name,
stack: context.stackTrace
});
}
}
return errors;
}

verify() {
const errors = this.report();
if (errors.length > 0) {
throw new AssertionError({
message: 'Function(s) were not called the expected number of times',
details: errors,
});
}
}
}

module.exports = CallTracker;
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Expand Up @@ -1377,6 +1377,8 @@ E('ERR_TRACE_EVENTS_UNAVAILABLE', 'Trace events are unavailable', Error);

// This should probably be a `RangeError`.
E('ERR_TTY_INIT_FAILED', 'TTY initialization failed', SystemError);
E('ERR_UNAVAILABLE_DURING_EXIT', 'Cannot call function in process exit ' +
'handler', Error);
E('ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET',
'`process.setupUncaughtExceptionCapture()` was called while a capture ' +
'callback was already active',
Expand Down
1 change: 1 addition & 0 deletions node.gyp
Expand Up @@ -96,6 +96,7 @@
'lib/zlib.js',
'lib/internal/assert.js',
'lib/internal/assert/assertion_error.js',
'lib/internal/assert/calltracker.js',
'lib/internal/async_hooks.js',
'lib/internal/buffer.js',
'lib/internal/cli_table.js',
Expand Down

0 comments on commit 50d28d4

Please sign in to comment.