diff --git a/lib/internal/test_runner/assertion_error_prototype.js b/lib/internal/test_runner/assertion_error_prototype.js new file mode 100644 index 00000000000000..6bdbda7c381932 --- /dev/null +++ b/lib/internal/test_runner/assertion_error_prototype.js @@ -0,0 +1,188 @@ +'use strict'; + +// test_runner-only helpers used to preserve AssertionError actual/expected +// constructor names across process isolation boundaries. + +const { + ArrayIsArray, + ArrayPrototype, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectGetPrototypeOf, + ObjectPrototype, + ObjectPrototypeToString, + ObjectSetPrototypeOf, +} = primordials; + +const kAssertionErrorCode = 'ERR_ASSERTION'; +const kTestFailureErrorCode = 'ERR_TEST_FAILURE'; +const kBaseTypeArray = 'array'; +const kBaseTypeObject = 'object'; +// Internal key used on test_runner item details during transport. +const kAssertionPrototypeMetadata = 'assertionPrototypeMetadata'; + +function getName(object) { + const desc = ObjectGetOwnPropertyDescriptor(object, 'name'); + return desc?.value; +} + +function getAssertionError(error) { + if (error === null || typeof error !== 'object') { + return; + } + + if (error.code === kTestFailureErrorCode) { + return error.cause; + } + + return error; +} + +function getAssertionPrototype(value) { + if (value === null || typeof value !== 'object') { + return; + } + + const prototype = ObjectGetPrototypeOf(value); + if (prototype === null) { + return; + } + + const constructor = ObjectGetOwnPropertyDescriptor(prototype, 'constructor')?.value; + if (typeof constructor !== 'function') { + return; + } + + const constructorName = getName(constructor); + if (typeof constructorName !== 'string' || constructorName.length === 0) { + return; + } + + // Keep the scope narrow for this regression fix: only Array/Object values + // are currently restored for AssertionError actual/expected. + if (ArrayIsArray(value)) { + if (constructorName === 'Array') { + return; + } + + return { + __proto__: null, + baseType: kBaseTypeArray, + constructorName, + }; + } + + if (ObjectPrototypeToString(value) === '[object Object]') { + if (constructorName === 'Object') { + return; + } + + return { + __proto__: null, + baseType: kBaseTypeObject, + constructorName, + }; + } +} + +function createSyntheticConstructor(name) { + function constructor() {} + ObjectDefineProperty(constructor, 'name', { + __proto__: null, + value: name, + configurable: true, + }); + return constructor; +} + +function collectAssertionPrototypeMetadata(error) { + const assertionError = getAssertionError(error); + if (assertionError === null || typeof assertionError !== 'object' || + assertionError.code !== kAssertionErrorCode) { + return; + } + + const actual = getAssertionPrototype(assertionError.actual); + const expected = getAssertionPrototype(assertionError.expected); + if (!actual && !expected) { + return; + } + + return { + __proto__: null, + actual, + expected, + }; +} + +function applyAssertionPrototypeMetadata(error, metadata) { + if (metadata === undefined || metadata === null || typeof metadata !== 'object') { + return; + } + + const assertionError = getAssertionError(error); + if (assertionError === null || typeof assertionError !== 'object' || + assertionError.code !== kAssertionErrorCode) { + return; + } + + for (const key of ['actual', 'expected']) { + const meta = metadata[key]; + const value = assertionError[key]; + const constructorName = meta?.constructorName; + + if (meta === undefined || meta === null || typeof meta !== 'object' || + value === null || typeof value !== 'object' || + typeof constructorName !== 'string') { + continue; + } + + if (meta.baseType === kBaseTypeArray && !ArrayIsArray(value)) { + continue; + } + + if (meta.baseType === kBaseTypeObject && + ObjectPrototypeToString(value) !== '[object Object]') { + continue; + } + + if (meta.baseType !== kBaseTypeArray && meta.baseType !== kBaseTypeObject) { + continue; + } + + const currentPrototype = ObjectGetPrototypeOf(value); + const currentConstructor = currentPrototype === null ? undefined : + ObjectGetOwnPropertyDescriptor(currentPrototype, 'constructor')?.value; + if (typeof currentConstructor === 'function' && + getName(currentConstructor) === constructorName) { + continue; + } + + const basePrototype = meta.baseType === kBaseTypeArray ? + ArrayPrototype : + ObjectPrototype; + + try { + const constructor = createSyntheticConstructor(constructorName); + const syntheticPrototype = { __proto__: basePrototype }; + ObjectDefineProperty(syntheticPrototype, 'constructor', { + __proto__: null, + value: constructor, + writable: true, + enumerable: false, + configurable: true, + }); + constructor.prototype = syntheticPrototype; + ObjectSetPrototypeOf(value, syntheticPrototype); + } catch { + // Best-effort only. If prototype restoration fails, keep the + // deserialized value as-is and continue reporting. + } + } +} + +module.exports = { + applyAssertionPrototypeMetadata, + collectAssertionPrototypeMetadata, + kAssertionPrototypeMetadata, +}; diff --git a/lib/internal/test_runner/reporter/v8-serializer.js b/lib/internal/test_runner/reporter/v8-serializer.js index c75bfcdac478cf..fbc9aac3add569 100644 --- a/lib/internal/test_runner/reporter/v8-serializer.js +++ b/lib/internal/test_runner/reporter/v8-serializer.js @@ -6,6 +6,10 @@ const { const { DefaultSerializer } = require('v8'); const { Buffer } = require('buffer'); const { serializeError } = require('internal/error_serdes'); +const { + collectAssertionPrototypeMetadata, + kAssertionPrototypeMetadata, +} = require('internal/test_runner/assertion_error_prototype'); module.exports = async function* v8Reporter(source) { @@ -15,7 +19,15 @@ module.exports = async function* v8Reporter(source) { for await (const item of source) { const originalError = item.data.details?.error; + let assertionPrototypeMetadata; if (originalError) { + assertionPrototypeMetadata = collectAssertionPrototypeMetadata(originalError); + if (assertionPrototypeMetadata !== undefined) { + // test_runner-only metadata used by the parent process to restore + // AssertionError actual/expected constructor names. + item.data.details[kAssertionPrototypeMetadata] = assertionPrototypeMetadata; + } + // Error is overridden with a serialized version, so that it can be // deserialized in the parent process. // Error is restored after serialization. @@ -29,6 +41,9 @@ module.exports = async function* v8Reporter(source) { if (originalError) { item.data.details.error = originalError; + if (assertionPrototypeMetadata !== undefined) { + delete item.data.details[kAssertionPrototypeMetadata]; + } } const serializedMessage = serializer.releaseBuffer(); diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index df2c85bdaed8de..d0590dc5d755d8 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -38,6 +38,10 @@ const { DefaultDeserializer, DefaultSerializer } = require('v8'); const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options'); const { Interface } = require('internal/readline/interface'); const { deserializeError } = require('internal/error_serdes'); +const { + applyAssertionPrototypeMetadata, + kAssertionPrototypeMetadata, +} = require('internal/test_runner/assertion_error_prototype'); const { Buffer } = require('buffer'); const { FilesWatcher } = require('internal/watch_mode/files_watcher'); const console = require('internal/console/global'); @@ -253,6 +257,15 @@ class FileTest extends Test { } if (item.data.details?.error) { item.data.details.error = deserializeError(item.data.details.error); + applyAssertionPrototypeMetadata( + item.data.details.error, + item.data.details[kAssertionPrototypeMetadata], + ); + } + // Metadata is test_runner-internal and must not leak to downstream + // reporters regardless of whether restoration ran. + if (item.data.details?.[kAssertionPrototypeMetadata] !== undefined) { + delete item.data.details[kAssertionPrototypeMetadata]; } if (item.type === 'test:pass' || item.type === 'test:fail') { item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber; diff --git a/test/fixtures/test-runner/issue-50397/prototype-mismatch.js b/test/fixtures/test-runner/issue-50397/prototype-mismatch.js new file mode 100644 index 00000000000000..dbfa13f39aede6 --- /dev/null +++ b/test/fixtures/test-runner/issue-50397/prototype-mismatch.js @@ -0,0 +1,10 @@ +'use strict'; + +const assert = require('node:assert'); +const { test } = require('node:test'); + +class ExtendedArray extends Array {} + +test('assertion error preserves prototype name', () => { + assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']); +}); diff --git a/test/parallel/test-runner-issue-50397.js b/test/parallel/test-runner-issue-50397.js new file mode 100644 index 00000000000000..95536d5c92a253 --- /dev/null +++ b/test/parallel/test-runner-issue-50397.js @@ -0,0 +1,29 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/50397: +// ensure --test preserves AssertionError actual type across isolation modes. + +require('../common'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const assert = require('node:assert'); +const fixtures = require('../common/fixtures'); + +for (const isolation of ['none', 'process']) { + const args = [ + '--test', + '--test-reporter=spec', + `--test-isolation=${isolation}`, + fixtures.path('test-runner/issue-50397/prototype-mismatch.js'), + ]; + spawnSyncAndAssert(process.execPath, args, { + status: 1, + signal: null, + stderr: '', + stdout(output) { + // Spec reporter output varies between inspect forms; accept both while + // still requiring the restored constructor name. + assert.match(output, /actual:\s+(?:\[ExtendedArray\]|ExtendedArray\(1\)\s+\[\s*'hello'\s*\])/); + assert.doesNotMatch(output, /actual:\s+\[Array\]/); + }, + }); +} diff --git a/test/sequential/test-runner-assertion-error-prototype.js b/test/sequential/test-runner-assertion-error-prototype.js new file mode 100644 index 00000000000000..819b12e90d77d7 --- /dev/null +++ b/test/sequential/test-runner-assertion-error-prototype.js @@ -0,0 +1,37 @@ +// Flags: --expose-internals +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/50397: +// verify test runner assertion metadata restores actual constructor names. + +require('../common'); +const assert = require('assert'); +const { serializeError, deserializeError } = require('internal/error_serdes'); +const { + applyAssertionPrototypeMetadata, + collectAssertionPrototypeMetadata, +} = require('internal/test_runner/assertion_error_prototype'); + +class ExtendedArray extends Array {} + +function createAssertionError() { + try { + assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']); + } catch (error) { + return error; + } + assert.fail('Expected AssertionError'); +} + +const assertionError = createAssertionError(); +const assertionPrototypeMetadata = collectAssertionPrototypeMetadata(assertionError); +assert.ok(assertionPrototypeMetadata); +assert.strictEqual(assertionPrototypeMetadata.actual.constructorName, 'ExtendedArray'); + +const defaultSerializedError = deserializeError(serializeError(assertionError)); +assert.strictEqual(defaultSerializedError.actual.constructor.name, 'Array'); + +applyAssertionPrototypeMetadata(defaultSerializedError, assertionPrototypeMetadata); +// Must be idempotent when metadata application is triggered more than once. +applyAssertionPrototypeMetadata(defaultSerializedError, assertionPrototypeMetadata); +assert.strictEqual(defaultSerializedError.actual.constructor.name, 'ExtendedArray');