diff --git a/ext/node/lib.rs b/ext/node/lib.rs index 5e27667fe3..12bf2b706e 100644 --- a/ext/node/lib.rs +++ b/ext/node/lib.rs @@ -579,6 +579,7 @@ deno_core::extension!(deno_node, "internal_binding/udp_wrap.ts", "internal_binding/util.ts", "internal_binding/uv.ts", + "internal/assert/calltracker.js", "internal/assert.mjs", "internal/async_hooks.ts", "internal/blocklist.mjs", diff --git a/ext/node/polyfills/assert.ts b/ext/node/polyfills/assert.ts index 48b4627044..85a1dec6d8 100644 --- a/ext/node/polyfills/assert.ts +++ b/ext/node/polyfills/assert.ts @@ -18,6 +18,8 @@ import { } from "ext:deno_node/internal/errors.ts"; import { isDeepEqual } from "ext:deno_node/internal/util/comparisons.ts"; import { primordials } from "ext:core/mod.js"; +import { CallTracker } from "ext:deno_node/internal/assert/calltracker.js"; +import { deprecate } from "node:util"; const { ObjectPrototypeIsPrototypeOf } = primordials; @@ -882,8 +884,15 @@ function isValidThenable(maybeThennable: any): boolean { return isThenable && typeof maybeThennable !== "function"; } +const CallTracker_ = deprecate( + CallTracker, + "assert.CallTracker is deprecated.", + "DEP0173", +); + Object.assign(strict, { AssertionError, + CallTracker: CallTracker_, deepEqual: deepStrictEqual, deepStrictEqual, doesNotMatch, @@ -906,6 +915,7 @@ Object.assign(strict, { export default Object.assign(assert, { AssertionError, + CallTracker: CallTracker_, deepEqual, deepStrictEqual, doesNotMatch, diff --git a/ext/node/polyfills/internal/assert/calltracker.js b/ext/node/polyfills/internal/assert/calltracker.js new file mode 100644 index 0000000000..e0bf12c37b --- /dev/null +++ b/ext/node/polyfills/internal/assert/calltracker.js @@ -0,0 +1,162 @@ +// Copyright 2018-2025 the Deno authors. MIT license. +// Copyright Node.js contributors. All rights reserved. MIT License. + +// deno-lint-ignore-file prefer-primordials + +import { primordials } from "ext:core/mod.js"; +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + Error, + FunctionPrototype, + ObjectFreeze, + Proxy, + ReflectApply, + SafeSet, + SafeWeakMap, +} = primordials; + +import { codes } from "ext:deno_node/internal/errors.ts"; +const { + ERR_INVALID_ARG_VALUE, + ERR_UNAVAILABLE_DURING_EXIT, +} = codes; +import { AssertionError } from "ext:deno_node/assertion_error.ts"; +import { validateUint32 } from "ext:deno_node/internal/validators.mjs"; + +const noop = FunctionPrototype; + +class CallTrackerContext { + #expected; + #calls; + #name; + #stackTrace; + constructor({ expected, stackTrace, name }) { + this.#calls = []; + this.#expected = expected; + this.#stackTrace = stackTrace; + this.#name = name; + } + + track(thisArg, args) { + const argsClone = ObjectFreeze(ArrayPrototypeSlice(args)); + ArrayPrototypePush( + this.#calls, + ObjectFreeze({ thisArg, arguments: argsClone }), + ); + } + + get delta() { + return this.#calls.length - this.#expected; + } + + reset() { + this.#calls = []; + } + getCalls() { + return ObjectFreeze(ArrayPrototypeSlice(this.#calls)); + } + + report() { + if (this.delta !== 0) { + const message = `Expected the ${this.#name} function to be ` + + `executed ${this.#expected} time(s) but was ` + + `executed ${this.#calls.length} time(s).`; + return { + message, + actual: this.#calls.length, + expected: this.#expected, + operator: this.#name, + stack: this.#stackTrace, + }; + } + } +} + +class CallTracker { + #callChecks = new SafeSet(); + #trackedFunctions = new SafeWeakMap(); + + #getTrackedFunction(tracked) { + if (!this.#trackedFunctions.has(tracked)) { + throw new ERR_INVALID_ARG_VALUE( + "tracked", + tracked, + "is not a tracked function", + ); + } + return this.#trackedFunctions.get(tracked); + } + + reset(tracked) { + if (tracked === undefined) { + this.#callChecks.forEach((check) => check.reset()); + return; + } + + this.#getTrackedFunction(tracked).reset(); + } + + getCalls(tracked) { + return this.#getTrackedFunction(tracked).getCalls(); + } + + calls(fn, expected = 1) { + // deno-lint-ignore no-process-global + if (process._exiting) { + throw new ERR_UNAVAILABLE_DURING_EXIT(); + } + if (typeof fn === "number") { + expected = fn; + fn = noop; + } else if (fn === undefined) { + fn = noop; + } + + validateUint32(expected, "expected", true); + + const context = new CallTrackerContext({ + expected, + // eslint-disable-next-line no-restricted-syntax + stackTrace: new Error(), + name: fn.name || "calls", + }); + const tracked = new Proxy(fn, { + __proto__: null, + apply(fn, thisArg, argList) { + context.track(thisArg, argList); + return ReflectApply(fn, thisArg, argList); + }, + }); + this.#callChecks.add(context); + this.#trackedFunctions.set(tracked, context); + return tracked; + } + + report() { + const errors = []; + for (const context of this.#callChecks) { + const message = context.report(); + if (message !== undefined) { + ArrayPrototypePush(errors, message); + } + } + return errors; + } + + verify() { + const errors = this.report(); + if (errors.length === 0) { + return; + } + const message = errors.length === 1 + ? errors[0].message + : "Functions were not called the expected number of times"; + throw new AssertionError({ + message, + details: errors, + }); + } +} + +export { CallTracker }; diff --git a/ext/node/polyfills/internal/errors.ts b/ext/node/polyfills/internal/errors.ts index 75c7e1f153..5cdd281fd5 100644 --- a/ext/node/polyfills/internal/errors.ts +++ b/ext/node/polyfills/internal/errors.ts @@ -2682,6 +2682,7 @@ codes.ERR_MULTIPLE_CALLBACK = ERR_MULTIPLE_CALLBACK; codes.ERR_STREAM_WRITE_AFTER_END = ERR_STREAM_WRITE_AFTER_END; codes.ERR_INVALID_ARG_TYPE = ERR_INVALID_ARG_TYPE; codes.ERR_INVALID_ARG_VALUE = ERR_INVALID_ARG_VALUE; +codes.ERR_UNAVAILABLE_DURING_EXIT = ERR_UNAVAILABLE_DURING_EXIT; codes.ERR_OUT_OF_RANGE = ERR_OUT_OF_RANGE; codes.ERR_SOCKET_BAD_PORT = ERR_SOCKET_BAD_PORT; codes.ERR_SOCKET_CONNECTION_TIMEOUT = ERR_SOCKET_CONNECTION_TIMEOUT;