Skip to content

Commit e092462

Browse files
committed
test_runner: isolate assertion prototype restoration
1 parent d1cfa92 commit e092462

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use strict';
2+
3+
const {
4+
ArrayIsArray,
5+
ArrayPrototype,
6+
ObjectDefineProperty,
7+
ObjectGetOwnPropertyDescriptor,
8+
ObjectGetPrototypeOf,
9+
ObjectPrototype,
10+
ObjectPrototypeToString,
11+
ObjectSetPrototypeOf,
12+
} = primordials;
13+
14+
const kAssertionErrorCode = 'ERR_ASSERTION';
15+
const kTestFailureErrorCode = 'ERR_TEST_FAILURE';
16+
const kBaseTypeArray = 'array';
17+
const kBaseTypeObject = 'object';
18+
const kAssertionPrototypeMetadata = 'assertionPrototypeMetadata';
19+
20+
function getName(object) {
21+
const desc = ObjectGetOwnPropertyDescriptor(object, 'name');
22+
return desc?.value;
23+
}
24+
25+
function getAssertionError(error) {
26+
if (error === null || typeof error !== 'object') {
27+
return;
28+
}
29+
30+
if (error.code === kTestFailureErrorCode) {
31+
return error.cause;
32+
}
33+
34+
return error;
35+
}
36+
37+
function getAssertionPrototype(value) {
38+
if (value === null || typeof value !== 'object') {
39+
return;
40+
}
41+
42+
const prototype = ObjectGetPrototypeOf(value);
43+
if (prototype === null) {
44+
return;
45+
}
46+
47+
const constructor = ObjectGetOwnPropertyDescriptor(prototype, 'constructor')?.value;
48+
if (typeof constructor !== 'function') {
49+
return;
50+
}
51+
52+
const constructorName = getName(constructor);
53+
if (typeof constructorName !== 'string' || constructorName.length === 0) {
54+
return;
55+
}
56+
57+
if (ArrayIsArray(value)) {
58+
if (constructorName === 'Array') {
59+
return;
60+
}
61+
62+
return {
63+
__proto__: null,
64+
baseType: kBaseTypeArray,
65+
constructorName,
66+
};
67+
}
68+
69+
if (ObjectPrototypeToString(value) === '[object Object]') {
70+
if (constructorName === 'Object') {
71+
return;
72+
}
73+
74+
return {
75+
__proto__: null,
76+
baseType: kBaseTypeObject,
77+
constructorName,
78+
};
79+
}
80+
}
81+
82+
function createSyntheticConstructor(name) {
83+
function constructor() {}
84+
ObjectDefineProperty(constructor, 'name', {
85+
__proto__: null,
86+
value: name,
87+
configurable: true,
88+
});
89+
return constructor;
90+
}
91+
92+
function collectAssertionPrototypeMetadata(error) {
93+
const assertionError = getAssertionError(error);
94+
if (assertionError === null || typeof assertionError !== 'object' ||
95+
assertionError.code !== kAssertionErrorCode) {
96+
return;
97+
}
98+
99+
const actual = getAssertionPrototype(assertionError.actual);
100+
const expected = getAssertionPrototype(assertionError.expected);
101+
if (!actual && !expected) {
102+
return;
103+
}
104+
105+
return {
106+
__proto__: null,
107+
actual,
108+
expected,
109+
};
110+
}
111+
112+
function applyAssertionPrototypeMetadata(error, metadata) {
113+
if (metadata === undefined || metadata === null || typeof metadata !== 'object') {
114+
return;
115+
}
116+
117+
const assertionError = getAssertionError(error);
118+
if (assertionError === null || typeof assertionError !== 'object' ||
119+
assertionError.code !== kAssertionErrorCode) {
120+
return;
121+
}
122+
123+
for (const key of ['actual', 'expected']) {
124+
const meta = metadata[key];
125+
const value = assertionError[key];
126+
const constructorName = meta?.constructorName;
127+
128+
if (meta === undefined || meta === null || typeof meta !== 'object' ||
129+
value === null || typeof value !== 'object' ||
130+
typeof constructorName !== 'string') {
131+
continue;
132+
}
133+
134+
if (meta.baseType === kBaseTypeArray && !ArrayIsArray(value)) {
135+
continue;
136+
}
137+
138+
if (meta.baseType === kBaseTypeObject &&
139+
ObjectPrototypeToString(value) !== '[object Object]') {
140+
continue;
141+
}
142+
143+
if (meta.baseType !== kBaseTypeArray && meta.baseType !== kBaseTypeObject) {
144+
continue;
145+
}
146+
147+
const currentPrototype = ObjectGetPrototypeOf(value);
148+
const currentConstructor = currentPrototype === null ? undefined :
149+
ObjectGetOwnPropertyDescriptor(currentPrototype, 'constructor')?.value;
150+
if (typeof currentConstructor === 'function' &&
151+
getName(currentConstructor) === constructorName) {
152+
continue;
153+
}
154+
155+
const basePrototype = meta.baseType === kBaseTypeArray ?
156+
ArrayPrototype :
157+
ObjectPrototype;
158+
159+
try {
160+
const constructor = createSyntheticConstructor(constructorName);
161+
const syntheticPrototype = { __proto__: basePrototype };
162+
ObjectDefineProperty(syntheticPrototype, 'constructor', {
163+
__proto__: null,
164+
value: constructor,
165+
writable: true,
166+
enumerable: false,
167+
configurable: true,
168+
});
169+
constructor.prototype = syntheticPrototype;
170+
ObjectSetPrototypeOf(value, syntheticPrototype);
171+
} catch {
172+
// Continue regardless of error.
173+
}
174+
}
175+
}
176+
177+
module.exports = {
178+
applyAssertionPrototypeMetadata,
179+
collectAssertionPrototypeMetadata,
180+
kAssertionPrototypeMetadata,
181+
};

lib/internal/test_runner/reporter/v8-serializer.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const {
66
const { DefaultSerializer } = require('v8');
77
const { Buffer } = require('buffer');
88
const { serializeError } = require('internal/error_serdes');
9+
const {
10+
collectAssertionPrototypeMetadata,
11+
kAssertionPrototypeMetadata,
12+
} = require('internal/test_runner/assertion_error_prototype');
913

1014

1115
module.exports = async function* v8Reporter(source) {
@@ -15,7 +19,15 @@ module.exports = async function* v8Reporter(source) {
1519

1620
for await (const item of source) {
1721
const originalError = item.data.details?.error;
22+
let assertionPrototypeMetadata;
1823
if (originalError) {
24+
assertionPrototypeMetadata = collectAssertionPrototypeMetadata(originalError);
25+
if (assertionPrototypeMetadata !== undefined) {
26+
// test_runner-only metadata used by the parent process to restore
27+
// AssertionError actual/expected constructor names.
28+
item.data.details[kAssertionPrototypeMetadata] = assertionPrototypeMetadata;
29+
}
30+
1931
// Error is overridden with a serialized version, so that it can be
2032
// deserialized in the parent process.
2133
// Error is restored after serialization.
@@ -29,6 +41,9 @@ module.exports = async function* v8Reporter(source) {
2941

3042
if (originalError) {
3143
item.data.details.error = originalError;
44+
if (assertionPrototypeMetadata !== undefined) {
45+
delete item.data.details[kAssertionPrototypeMetadata];
46+
}
3247
}
3348

3449
const serializedMessage = serializer.releaseBuffer();

lib/internal/test_runner/runner.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const { DefaultDeserializer, DefaultSerializer } = require('v8');
3838
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
3939
const { Interface } = require('internal/readline/interface');
4040
const { deserializeError } = require('internal/error_serdes');
41+
const {
42+
applyAssertionPrototypeMetadata,
43+
kAssertionPrototypeMetadata,
44+
} = require('internal/test_runner/assertion_error_prototype');
4145
const { Buffer } = require('buffer');
4246
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
4347
const console = require('internal/console/global');
@@ -253,6 +257,13 @@ class FileTest extends Test {
253257
}
254258
if (item.data.details?.error) {
255259
item.data.details.error = deserializeError(item.data.details.error);
260+
applyAssertionPrototypeMetadata(
261+
item.data.details.error,
262+
item.data.details[kAssertionPrototypeMetadata],
263+
);
264+
}
265+
if (item.data.details?.[kAssertionPrototypeMetadata] !== undefined) {
266+
delete item.data.details[kAssertionPrototypeMetadata];
256267
}
257268
if (item.type === 'test:pass' || item.type === 'test:fail') {
258269
item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
// Regression test for https://github.com/nodejs/node/issues/50397:
5+
// verify test runner assertion metadata restores actual constructor names.
6+
7+
require('../common');
8+
const assert = require('assert');
9+
const { serializeError, deserializeError } = require('internal/error_serdes');
10+
const {
11+
applyAssertionPrototypeMetadata,
12+
collectAssertionPrototypeMetadata,
13+
} = require('internal/test_runner/assertion_error_prototype');
14+
15+
class ExtendedArray extends Array {}
16+
17+
function createAssertionError() {
18+
try {
19+
assert.deepStrictEqual(new ExtendedArray('hello'), ['hello']);
20+
} catch (error) {
21+
return error;
22+
}
23+
assert.fail('Expected AssertionError');
24+
}
25+
26+
const assertionError = createAssertionError();
27+
const assertionPrototypeMetadata = collectAssertionPrototypeMetadata(assertionError);
28+
assert.ok(assertionPrototypeMetadata);
29+
assert.strictEqual(assertionPrototypeMetadata.actual.constructorName, 'ExtendedArray');
30+
31+
const defaultSerializedError = deserializeError(serializeError(assertionError));
32+
assert.strictEqual(defaultSerializedError.actual.constructor.name, 'Array');
33+
34+
applyAssertionPrototypeMetadata(defaultSerializedError, assertionPrototypeMetadata);
35+
// Must be idempotent when metadata application is triggered more than once.
36+
applyAssertionPrototypeMetadata(defaultSerializedError, assertionPrototypeMetadata);
37+
assert.strictEqual(defaultSerializedError.actual.constructor.name, 'ExtendedArray');

0 commit comments

Comments
 (0)