Repository: nodejs/node
Pull request: test_runner: add assert.register() API

This commit adds a top level `assert.register()` API to the test runner. This function allows users to define their own custom assertion functions on the TestContext.

Fixes: https://github.com/nodejs/node/issues/52033

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/test.md
patch: @@ -1750,6 +1750,29 @@ describe('tests', async () => {
 });
 ```
 
+## `assert`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+An object whose methods are used to configure available assertions on the
+`TestContext` objects in the current process. The methods from `node:assert`
+and snapshot testing functions are available by default.
+
+It is possible to apply the same configuration to all files by placing common
+configuration code in a module
+preloaded with `--require` or `--import`.
+
+### `assert.register(name, fn)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+Defines a new assertion function with the provided name and function. If an
+assertion already exists with the same name, it is overwritten.
+
 ## `snapshot`
 
 <!-- YAML

filename: lib/internal/test_runner/assert.js
patch: @@ -0,0 +1,50 @@
+'use strict';
+const {
+  SafeMap,
+} = primordials;
+const {
+  validateFunction,
+  validateString,
+} = require('internal/validators');
+const assert = require('assert');
+const methodsToCopy = [
+  'deepEqual',
+  'deepStrictEqual',
+  'doesNotMatch',
+  'doesNotReject',
+  'doesNotThrow',
+  'equal',
+  'fail',
+  'ifError',
+  'match',
+  'notDeepEqual',
+  'notDeepStrictEqual',
+  'notEqual',
+  'notStrictEqual',
+  'partialDeepStrictEqual',
+  'rejects',
+  'strictEqual',
+  'throws',
+];
+let assertMap;
+
+function getAssertionMap() {
+  if (assertMap === undefined) {
+    assertMap = new SafeMap();
+
+    for (let i = 0; i < methodsToCopy.length; i++) {
+      assertMap.set(methodsToCopy[i], assert[methodsToCopy[i]]);
+    }
+  }
+
+  return assertMap;
+}
+
+function register(name, fn) {
+  validateString(name, 'name');
+  validateFunction(fn, 'fn');
+  const map = getAssertionMap();
+  map.set(name, fn);
+}
+
+module.exports = { getAssertionMap, register };

filename: lib/internal/test_runner/test.js
patch: @@ -100,34 +100,15 @@ function lazyFindSourceMap(file) {
 
 function lazyAssertObject(harness) {
   if (assertObj === undefined) {
-    assertObj = new SafeMap();
-    const assert = require('assert');
-    const { SnapshotManager } = require('internal/test_runner/snapshot');
-    const methodsToCopy = [
-      'deepEqual',
-      'deepStrictEqual',
-      'doesNotMatch',
-      'doesNotReject',
-      'doesNotThrow',
-      'equal',
-      'fail',
-      'ifError',
-      'match',
-      'notDeepEqual',
-      'notDeepStrictEqual',
-      'notEqual',
-      'notStrictEqual',
-      'partialDeepStrictEqual',
-      'rejects',
-      'strictEqual',
-      'throws',
-    ];
-    for (let i = 0; i < methodsToCopy.length; i++) {
-      assertObj.set(methodsToCopy[i], assert[methodsToCopy[i]]);
-    }
+    const { getAssertionMap } = require('internal/test_runner/assert');
+
+    assertObj = getAssertionMap();
+    if (!assertObj.has('snapshot')) {
+      const { SnapshotManager } = require('internal/test_runner/snapshot');
 
-    harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
-    assertObj.set('snapshot', harness.snapshotManager.createAssert());
+      harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
+      assertObj.set('snapshot', harness.snapshotManager.createAssert());
+    }
   }
   return assertObj;
 }
@@ -264,15 +245,18 @@ class TestContext {
         };
       });
 
-      // This is a hack. It allows the innerOk function to collect the stacktrace from the correct starting point.
-      function ok(...args) {
-        if (plan !== null) {
-          plan.actual++;
+      if (!map.has('ok')) {
+        // This is a hack. It allows the innerOk function to collect the
+        // stacktrace from the correct starting point.
+        function ok(...args) {
+          if (plan !== null) {
+            plan.actual++;
+          }
+          innerOk(ok, args.length, ...args);
         }
-        innerOk(ok, args.length, ...args);
-      }
 
-      assert.ok = ok;
+        assert.ok = ok;
+      }
     }
     return this.#assert;
   }

filename: lib/test.js
patch: @@ -61,3 +61,15 @@ ObjectDefineProperty(module.exports, 'snapshot', {
     return lazySnapshot;
   },
 });
+
+ObjectDefineProperty(module.exports, 'assert', {
+  __proto__: null,
+  configurable: true,
+  enumerable: true,
+  get() {
+    const { register } = require('internal/test_runner/assert');
+    const assert = { __proto__: null, register };
+    ObjectDefineProperty(module.exports, 'assert', assert);
+    return assert;
+  },
+});

filename: test/parallel/test-runner-custom-assertions.js
patch: @@ -0,0 +1,63 @@
+'use strict';
+require('../common');
+const assert = require('node:assert');
+const { test, assert: testAssertions } = require('node:test');
+
+testAssertions.register('isOdd', (n) => {
+  assert.strictEqual(n % 2, 1);
+});
+
+testAssertions.register('ok', () => {
+  return 'ok';
+});
+
+testAssertions.register('snapshot', () => {
+  return 'snapshot';
+});
+
+testAssertions.register('deepStrictEqual', () => {
+  return 'deepStrictEqual';
+});
+
+testAssertions.register('context', function() {
+  return this;
+});
+
+test('throws if name is not a string', () => {
+  assert.throws(() => {
+    testAssertions.register(5);
+  }, {
+    code: 'ERR_INVALID_ARG_TYPE',
+    message: 'The "name" argument must be of type string. Received type number (5)'
+  });
+});
+
+test('throws if fn is not a function', () => {
+  assert.throws(() => {
+    testAssertions.register('foo', 5);
+  }, {
+    code: 'ERR_INVALID_ARG_TYPE',
+    message: 'The "fn" argument must be of type function. Received type number (5)'
+  });
+});
+
+test('invokes a custom assertion as part of the test plan', (t) => {
+  t.plan(2);
+  t.assert.isOdd(5);
+  assert.throws(() => {
+    t.assert.isOdd(4);
+  }, {
+    code: 'ERR_ASSERTION',
+    message: /Expected values to be strictly equal/
+  });
+});
+
+test('can override existing assertions', (t) => {
+  assert.strictEqual(t.assert.ok(), 'ok');
+  assert.strictEqual(t.assert.snapshot(), 'snapshot');
+  assert.strictEqual(t.assert.deepStrictEqual(), 'deepStrictEqual');
+});
+
+test('"this" is set to the TestContext', (t) => {
+  assert.strictEqual(t.assert.context(), t);
+});