Skip to content

Commit ee350b5

Browse files
authored
feat: add mustCallAtLeast to the harness (#71)
1 parent 805c58a commit ee350b5

4 files changed

Lines changed: 64 additions & 14 deletions

File tree

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export default defineConfig([
9898
assert: 'readonly',
9999
loadAddon: 'readonly',
100100
mustCall: 'readonly',
101+
mustCallAtLeast: 'readonly',
101102
mustNotCall: 'readonly',
102103
gc: 'readonly',
103104
gcUntil: 'readonly',

implementors/node/must-call.js

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
const pendingCalls = [];
22

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

19+
/**
20+
* Wraps a function and asserts it is called exactly `exact` times before the
21+
* process exits. If `fn` is omitted, a no-op function is used.
22+
*
23+
* Usage:
24+
* promise.then(mustCall((result) => {
25+
* assert.strictEqual(result, 42);
26+
* }));
27+
*/
28+
const mustCall = (fn, exact = 1) => track(fn, exact, false);
29+
30+
/**
31+
* Like `mustCall`, but asserts only a lower bound: the wrapper must be called
32+
* at least `minimum` times, and any number of further calls is fine. Use it
33+
* when the runtime decides how often a callback fires (e.g. a proxy trap the
34+
* engine may consult more than once).
35+
*/
36+
const mustCallAtLeast = (fn, minimum = 1) => track(fn, minimum, true);
37+
2638
/**
2739
* Returns a function that throws immediately if called.
2840
*/
@@ -34,13 +46,17 @@ const mustNotCall = (msg) => {
3446

3547
process.on('exit', () => {
3648
for (const entry of pendingCalls) {
37-
if (entry.actual !== entry.exact) {
49+
const satisfied = entry.atLeast ?
50+
entry.actual >= entry.expected :
51+
entry.actual === entry.expected;
52+
if (!satisfied) {
3853
entry.error.message =
39-
`mustCall "${entry.name}" expected ${entry.exact} call(s) ` +
54+
`mustCall${entry.atLeast ? 'AtLeast' : ''} "${entry.name}" expected ` +
55+
`${entry.atLeast ? 'at least ' : ''}${entry.expected} call(s) ` +
4056
`but got ${entry.actual}`;
4157
throw entry.error;
4258
}
4359
}
4460
});
4561

46-
Object.assign(globalThis, { mustCall, mustNotCall });
62+
Object.assign(globalThis, { mustCall, mustCallAtLeast, mustNotCall });
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Spawned by must-call.js. Calls a wrapper that demands at least two calls
2+
// only once, so the parent can assert that the shortfall is reported at exit.
3+
const wrapper = mustCallAtLeast(function underCalled() {}, 2);
4+
wrapper();

tests/harness/must-call.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ if (typeof mustCall !== 'function') {
2626
assert.strictEqual(result, undefined);
2727
}
2828

29+
// mustCallAtLeast is a function
30+
if (typeof mustCallAtLeast !== 'function') {
31+
throw new Error('Expected a global mustCallAtLeast function');
32+
}
33+
34+
// mustCallAtLeast forwards arguments and return value, and tolerates more
35+
// calls than the minimum
36+
{
37+
const wrapper = mustCallAtLeast((a, b) => a + b, 2);
38+
assert.strictEqual(wrapper(2, 3), 5);
39+
assert.strictEqual(wrapper(4, 5), 9);
40+
assert.strictEqual(wrapper(6, 7), 13);
41+
}
42+
43+
// mustCallAtLeast defaults its minimum to one call
44+
{
45+
const wrapper = mustCallAtLeast();
46+
const result = wrapper('ignored');
47+
assert.strictEqual(result, undefined);
48+
}
49+
50+
// Falling short of the minimum fails. The count is only checked at process
51+
// exit, so observing the failure needs a child process.
52+
if (runtimeFeatures.spawn) {
53+
const result = await spawnTest('must-call-at-least-child.mjs');
54+
assert.notStrictEqual(result.status, 0, 'an under-called mustCallAtLeast should fail the child');
55+
assert.match(result.stderr, /underCalled.*at least 2 call\(s\) but got 1/);
56+
}
57+
2958
// mustNotCall is a function
3059
if (typeof mustNotCall !== 'function') {
3160
throw new Error('Expected a global mustNotCall function');

0 commit comments

Comments
 (0)