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/parallel/test-runner-v8-deserializer.mjs b/test/parallel/test-runner-v8-deserializer.mjs index 0f6fea1e64b58d..63ad911427d53a 100644 --- a/test/parallel/test-runner-v8-deserializer.mjs +++ b/test/parallel/test-runner-v8-deserializer.mjs @@ -8,6 +8,8 @@ import { DefaultSerializer } from 'node:v8'; import serializer from 'internal/test_runner/reporter/v8-serializer'; import runner from 'internal/test_runner/runner'; +class ExtendedArray extends Array {} + async function toArray(chunks) { const arr = []; for await (const i of chunks) arr.push(i); @@ -77,6 +79,26 @@ describe('v8 deserializer', common.mustCall(() => { ]); }); + it('should restore assertion metadata without leaking internal transport fields', async () => { + let assertionError; + try { + assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']); + } catch (error) { + assertionError = error; + } + assert(assertionError); + + const [chunk] = await toArray(serializer([{ + type: 'test:diagnostic', + data: { nesting: 0, details: { error: assertionError }, message: 'diagnostic' }, + }])); + + const reported = await collectReported([chunk]); + const detailError = reported[0].data.details.error; + assert.strictEqual(detailError.actual.constructor.name, 'ExtendedArray'); + assert.strictEqual(Object.hasOwn(reported[0].data.details, 'assertionPrototypeMetadata'), false); + }); + const headerPosition = headerLength * 2 + 4; for (let i = 0; i < headerPosition + 5; i++) { const message = `should deserialize a serialized message split into two chunks {...${i},${i + 1}...}`; 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..f9480d5128280b --- /dev/null +++ b/test/sequential/test-runner-assertion-error-prototype.js @@ -0,0 +1,92 @@ +// 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 {} +class ExtendedObject { + constructor(value) { + this.value = value; + } +} + +function createAssertionError(actual, expected) { + try { + assert.deepStrictEqual(actual, expected); + } catch (error) { + return error; + } + assert.fail('Expected AssertionError'); +} + +const arrayAssertionError = createAssertionError(new ExtendedArray('hello'), ['hello']); +const arrayPrototypeMetadata = collectAssertionPrototypeMetadata(arrayAssertionError); +assert.ok(arrayPrototypeMetadata); +assert.strictEqual(arrayPrototypeMetadata.actual.constructorName, 'ExtendedArray'); + +const defaultSerializedArrayError = deserializeError(serializeError(arrayAssertionError)); +assert.strictEqual(defaultSerializedArrayError.actual.constructor.name, 'Array'); + +applyAssertionPrototypeMetadata(defaultSerializedArrayError, arrayPrototypeMetadata); +// Must be idempotent when metadata application is triggered more than once. +applyAssertionPrototypeMetadata(defaultSerializedArrayError, arrayPrototypeMetadata); +assert.strictEqual(defaultSerializedArrayError.actual.constructor.name, 'ExtendedArray'); + +const objectAssertionError = createAssertionError(new ExtendedObject(42), { value: 42 }); +const objectPrototypeMetadata = collectAssertionPrototypeMetadata(objectAssertionError); +assert.ok(objectPrototypeMetadata); +assert.strictEqual(objectPrototypeMetadata.actual.constructorName, 'ExtendedObject'); + +const defaultSerializedObjectError = deserializeError(serializeError(objectAssertionError)); +assert.strictEqual(defaultSerializedObjectError.actual.constructor.name, 'Object'); + +applyAssertionPrototypeMetadata(defaultSerializedObjectError, objectPrototypeMetadata); +assert.strictEqual(defaultSerializedObjectError.actual.constructor.name, 'ExtendedObject'); + +const expectedArrayAssertionError = createAssertionError(['hello'], new ExtendedArray('hello')); +const expectedArrayPrototypeMetadata = collectAssertionPrototypeMetadata(expectedArrayAssertionError); +assert.ok(expectedArrayPrototypeMetadata); +assert.strictEqual(expectedArrayPrototypeMetadata.actual, undefined); +assert.strictEqual(expectedArrayPrototypeMetadata.expected.constructorName, 'ExtendedArray'); + +const defaultSerializedExpectedArrayError = deserializeError(serializeError(expectedArrayAssertionError)); +assert.strictEqual(defaultSerializedExpectedArrayError.actual.constructor.name, 'Array'); +assert.strictEqual(defaultSerializedExpectedArrayError.expected.constructor.name, 'Array'); + +applyAssertionPrototypeMetadata(defaultSerializedExpectedArrayError, expectedArrayPrototypeMetadata); +assert.strictEqual(defaultSerializedExpectedArrayError.actual.constructor.name, 'Array'); +assert.strictEqual(defaultSerializedExpectedArrayError.expected.constructor.name, 'ExtendedArray'); + +// AssertionError wrapped in ERR_TEST_FAILURE should still be supported. +const wrappedMetadata = collectAssertionPrototypeMetadata({ + code: 'ERR_TEST_FAILURE', + cause: arrayAssertionError, +}); +assert.deepStrictEqual(wrappedMetadata, arrayPrototypeMetadata); + +// Unsupported metadata shapes must be ignored safely. +// These calls are expected to be ignored (no throw, no mutation). +applyAssertionPrototypeMetadata(arrayAssertionError, null); +applyAssertionPrototypeMetadata(arrayAssertionError, { + actual: { baseType: 'map', constructorName: 'ExtendedMap' }, +}); + +// Base type mismatch should not rewrite the value prototype. +const mismatchError = deserializeError(serializeError(objectAssertionError)); +applyAssertionPrototypeMetadata(mismatchError, { + actual: { + __proto__: null, + baseType: 'array', + constructorName: 'ExtendedArray', + }, +}); +assert.strictEqual(mismatchError.actual.constructor.name, 'Object');