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 BethGriggs committed Sep 15, 2020
1 parent a1da012 commit d73b834
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.

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 @@ -1400,6 +1531,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 @@ -1411,6 +1543,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 @@ -1972,6 +1972,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 @@ -2552,6 +2558,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 @@ -49,6 +49,7 @@ const { EOL } = require('internal/constants');
const { NativeModule } = require('internal/bootstrap/loaders');

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

let isDeepEqual;
let isDeepStrictEqual;
Expand Down Expand Up @@ -900,6 +901,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 @@ -1397,6 +1397,8 @@ E('ERR_TRANSFORM_ALREADY_TRANSFORMING',
E('ERR_TRANSFORM_WITH_LENGTH_0',
'Calling transform done when writableState.length != 0', Error);
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 @@ -94,6 +94,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 d73b834

Please sign in to comment.