Repository: denoland/deno
Pull request: fix(ext/node): add assert.CallTracker


Changed files:
filename: ext/node/lib.rs
patch: @@ -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",

filename: ext/node/polyfills/assert.ts
patch: @@ -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,

filename: ext/node/polyfills/internal/assert/calltracker.js
patch: @@ -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 };

filename: ext/node/polyfills/internal/errors.ts
patch: @@ -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;