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
2 changes: 1 addition & 1 deletion PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Tests covering the engine-specific part of Node-API, defined in `js_native_api.h
| `test_instance_data` | Not ported | Medium |
| `test_new_target` | Ported ✅ | Easy |
| `test_number` | Ported ✅ | Easy |
| `test_object` | Not ported | Hard |
| `test_object` | Partial | Hard |
| `test_promise` | Ported ✅ | Easy |
| `test_properties` | Ported ✅ | Easy |
| `test_reference` | Ported ✅ | Medium |
Expand Down
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
6 changes: 6 additions & 0 deletions implementors/node/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ globalThis.runtimeFeatures = {
major > 25 ||
(major === 25 && minor >= 4) ||
(major === 24 && (minor > 13 || (minor === 13 && patch >= 1))),

// Object APIs report a throwing proxy handler as napi_pending_exception only
// since Node.js v22.0.0 (nodejs/node@52fcf14258b). It was not backported to
// v20.x, where the exception is left pending but the call reports another
// status, so the throw escapes the addon instead.
proxyHandlerExceptions: major >= 22,
};
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
2 changes: 2 additions & 0 deletions tests/js-native-api/test_object/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
add_node_api_cts_addon(test_object test_object.c test_null.c)
add_node_api_cts_addon(test_exceptions test_exceptions.c)
Loading
Loading