Skip to content
Open
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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default defineConfig([
assert: 'readonly',
loadAddon: 'readonly',
mustCall: 'readonly',
mustCallAtLeast: 'readonly',
mustNotCall: 'readonly',
gc: 'readonly',
gcUntil: 'readonly',
Expand Down
44 changes: 30 additions & 14 deletions implementors/node/must-call.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
const pendingCalls = [];

/**
* Wraps a function and asserts it is called exactly `exact` times before the
* process exits. If `fn` is omitted, a no-op function is used.
*
* Usage:
* promise.then(mustCall((result) => {
* assert.strictEqual(result, 42);
* }));
*/
const mustCall = (fn, exact = 1) => {
// `expected` is a lower bound when `atLeast` is set, an exact count otherwise.
const track = (fn, expected, atLeast) => {
const entry = {
exact,
expected,
atLeast,
actual: 0,
name: fn?.name || '<anonymous>',
error: new Error(), // capture call-site stack
Expand All @@ -23,6 +16,25 @@ const mustCall = (fn, exact = 1) => {
};
};

/**
* Wraps a function and asserts it is called exactly `exact` times before the
* process exits. If `fn` is omitted, a no-op function is used.
*
* Usage:
* promise.then(mustCall((result) => {
* assert.strictEqual(result, 42);
* }));
*/
const mustCall = (fn, exact = 1) => track(fn, exact, false);

/**
* Like `mustCall`, but asserts only a lower bound: the wrapper must be called
* at least `minimum` times, and any number of further calls is fine. Use it
* when the runtime decides how often a callback fires (e.g. a proxy trap the
* engine may consult more than once).
*/
const mustCallAtLeast = (fn, minimum = 1) => track(fn, minimum, true);

/**
* Returns a function that throws immediately if called.
*/
Expand All @@ -34,13 +46,17 @@ const mustNotCall = (msg) => {

process.on('exit', () => {
for (const entry of pendingCalls) {
if (entry.actual !== entry.exact) {
const satisfied = entry.atLeast ?
entry.actual >= entry.expected :
entry.actual === entry.expected;
if (!satisfied) {
entry.error.message =
`mustCall "${entry.name}" expected ${entry.exact} call(s) ` +
`mustCall${entry.atLeast ? 'AtLeast' : ''} "${entry.name}" expected ` +
`${entry.atLeast ? 'at least ' : ''}${entry.expected} call(s) ` +
`but got ${entry.actual}`;
throw entry.error;
}
}
});

Object.assign(globalThis, { mustCall, mustNotCall });
Object.assign(globalThis, { mustCall, mustCallAtLeast, mustNotCall });
4 changes: 4 additions & 0 deletions tests/harness/must-call-at-least-child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Spawned by must-call.js. Calls a wrapper that demands at least two calls
// only once, so the parent can assert that the shortfall is reported at exit.
const wrapper = mustCallAtLeast(function underCalled() {}, 2);
wrapper();
29 changes: 29 additions & 0 deletions tests/harness/must-call.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,35 @@ if (typeof mustCall !== 'function') {
assert.strictEqual(result, undefined);
}

// mustCallAtLeast is a function
if (typeof mustCallAtLeast !== 'function') {
throw new Error('Expected a global mustCallAtLeast function');
}

// mustCallAtLeast forwards arguments and return value, and tolerates more
// calls than the minimum
{
const wrapper = mustCallAtLeast((a, b) => a + b, 2);
assert.strictEqual(wrapper(2, 3), 5);
assert.strictEqual(wrapper(4, 5), 9);
assert.strictEqual(wrapper(6, 7), 13);
}

// mustCallAtLeast defaults its minimum to one call
{
const wrapper = mustCallAtLeast();
const result = wrapper('ignored');
assert.strictEqual(result, undefined);
}

// Falling short of the minimum fails. The count is only checked at process
// exit, so observing the failure needs a child process.
if (runtimeFeatures.spawn) {
const result = await spawnTest('must-call-at-least-child.mjs');
assert.notStrictEqual(result.status, 0, 'an under-called mustCallAtLeast should fail the child');
assert.match(result.stderr, /underCalled.*at least 2 call\(s\) but got 1/);
}

// mustNotCall is a function
if (typeof mustNotCall !== 'function') {
throw new Error('Expected a global mustNotCall function');
Expand Down
Loading