diff --git a/eslint.config.js b/eslint.config.js index ef33613..02f283a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,7 @@ export default defineConfig([ assert: 'readonly', loadAddon: 'readonly', mustCall: 'readonly', + mustCallAtLeast: 'readonly', mustNotCall: 'readonly', gc: 'readonly', gcUntil: 'readonly', diff --git a/implementors/node/must-call.js b/implementors/node/must-call.js index 2792ad3..09b6d78 100644 --- a/implementors/node/must-call.js +++ b/implementors/node/must-call.js @@ -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 || '', error: new Error(), // capture call-site stack @@ -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. */ @@ -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 }); diff --git a/tests/harness/must-call-at-least-child.mjs b/tests/harness/must-call-at-least-child.mjs new file mode 100644 index 0000000..672a656 --- /dev/null +++ b/tests/harness/must-call-at-least-child.mjs @@ -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(); diff --git a/tests/harness/must-call.js b/tests/harness/must-call.js index 1445bed..6f7925c 100644 --- a/tests/harness/must-call.js +++ b/tests/harness/must-call.js @@ -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');