From 590e05040461ed51a4e62df776fbb39b5b439961 Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Sun, 2 Aug 2026 20:51:06 +0500 Subject: [PATCH] fix: don't turn an omitted assert message into undefined implementors/node/assert.js wraps each assertion in an arrow function with named parameters and forwards them positionally, so a call that leaves the message out passes an explicit undefined instead. Node.js 26 reads the message as a variadic tuple (nodejs/node#58849, first released in v26.0.0), where [undefined] is a message of the wrong type rather than an absent one. assert.strictEqual(1, 2) on v26 fails with TypeError [ERR_INVALID_ARG_TYPE]: The "message" argument must be one of type string or function. Received undefined instead of an AssertionError carrying the actual and expected values. strictEqual, notStrictEqual and deepStrictEqual take that path; ok, match and throws still report normally. Verified broken on v26.0.0 and v26.5.1, fine on v25.9.0 and earlier. It only shows up once an assertion fails, which is the moment a conformance run is worth reading, so a runtime with a real Node-API gap gets a message about the CTS harness rather than about its own behavior. Forwarding with rest arguments leaves an omitted argument omitted. The harness test now checks that every method fails the same way with and without a message, and the CI matrix gains 26.x, the only entry that exercises it. Signed-off-by: hexbinoct --- .github/workflows/test.yml | 1 + implementors/node/assert.js | 21 +++++++++-------- tests/harness/assert.js | 46 +++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f783c2..e78f0f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: - 22.x - 24.x - 25.x + - 26.x runs-on: ${{ matrix.runner }} steps: - name: Harden Runner diff --git a/implementors/node/assert.js b/implementors/node/assert.js index 8320281..3a6be80 100644 --- a/implementors/node/assert.js +++ b/implementors/node/assert.js @@ -7,16 +7,17 @@ import { match, } from 'node:assert/strict'; -const assert = Object.assign((value, message) => ok(value, message), { - ok: (value, message) => ok(value, message), - strictEqual: (actual, expected, message) => - strictEqual(actual, expected, message), - notStrictEqual: (actual, expected, message) => - notStrictEqual(actual, expected, message), - deepStrictEqual: (actual, expected, message) => - deepStrictEqual(actual, expected, message), - throws: (fn, error, message) => throws(fn, error, message), - match: (string, regex, message) => match(string, regex, message), +// Forward with rest arguments rather than named parameters: an omitted trailing +// message has to stay omitted. Node.js 26 reads the message as a variadic tuple, +// so an explicitly passed `undefined` is a message of the wrong type there and +// the assertion fails with ERR_INVALID_ARG_TYPE instead of the value comparison. +const assert = Object.assign((...args) => ok(...args), { + ok: (...args) => ok(...args), + strictEqual: (...args) => strictEqual(...args), + notStrictEqual: (...args) => notStrictEqual(...args), + deepStrictEqual: (...args) => deepStrictEqual(...args), + throws: (...args) => throws(...args), + match: (...args) => match(...args), }); Object.assign(globalThis, { assert }); diff --git a/tests/harness/assert.js b/tests/harness/assert.js index 65bf43c..a1848ae 100644 --- a/tests/harness/assert.js +++ b/tests/harness/assert.js @@ -105,3 +105,49 @@ if (!threw) throw new Error('assert.match must throw when input is not a string' threw = false; try { assert.match('hello', 'hello'); } catch { threw = true; } if (!threw) throw new Error('assert.match must throw when pattern is not a RegExp'); + +// The message is optional on every method. A failure without one must still +// report the comparison, and a failure with one must report that message. +function failureOf(label, fn) { + try { + fn(); + } catch (error) { + return error; + } + throw new Error(`${label} was expected to throw`); +} + +const optionalMessage = [ + ['assert', () => assert(false), (m) => assert(false, m)], + ['assert.ok', () => assert.ok(false), (m) => assert.ok(false, m)], + ['assert.strictEqual', () => assert.strictEqual(1, 2), (m) => assert.strictEqual(1, 2, m)], + ['assert.notStrictEqual', () => assert.notStrictEqual(1, 1), (m) => assert.notStrictEqual(1, 1, m)], + [ + 'assert.deepStrictEqual', + () => assert.deepStrictEqual({ a: 1 }, { a: 2 }), + (m) => assert.deepStrictEqual({ a: 1 }, { a: 2 }, m), + ], + ['assert.match', () => assert.match('hello', /world/), (m) => assert.match('hello', /world/, m)], + ['assert.throws', () => assert.throws(() => {}, /oops/), (m) => assert.throws(() => {}, /oops/, m)], +]; + +const customMessage = 'a message the caller chose'; + +for (const [label, withoutMessage, withMessage] of optionalMessage) { + // Only a substring check: the strict assert methods append their own value + // comparison to a caller supplied message. + const described = failureOf(label, () => withMessage(customMessage)); + if (!described.message.includes(customMessage)) { + throw new Error( + `${label} must fail with the message it was given but failed with "${described.message}"`, + ); + } + + const bare = failureOf(label, withoutMessage); + if (bare.name !== described.name) { + throw new Error( + `${label} must fail the same way with and without a message, but without one it failed ` + + `with "${bare.name}: ${bare.message}" instead of ${described.name}`, + ); + } +}