Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions lib/internal/test_runner/assertion_error_prototype.js
Original file line number Diff line number Diff line change
@@ -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,
};
15 changes: 15 additions & 0 deletions lib/internal/test_runner/reporter/v8-serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand All @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions test/fixtures/test-runner/issue-50397/prototype-mismatch.js
Original file line number Diff line number Diff line change
@@ -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']);
});
29 changes: 29 additions & 0 deletions test/parallel/test-runner-issue-50397.js
Original file line number Diff line number Diff line change
@@ -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\]/);
},
});
}
37 changes: 37 additions & 0 deletions test/sequential/test-runner-assertion-error-prototype.js
Original file line number Diff line number Diff line change
@@ -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');
Loading