Skip to content

Commit d9c12f8

Browse files
committed
test_runner: use v8.serialize instead of TAP
1 parent af9b48a commit d9c12f8

16 files changed

+224
-4601
lines changed

doc/api/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2319,7 +2319,8 @@ on unsupported platforms will not be fixed.
23192319
### `NODE_TEST_CONTEXT=value`
23202320

23212321
If `value` equals `'child'`, test reporter options will be overridden and test
2322-
output will be sent to stdout in the TAP format.
2322+
output will be sent to stdout in the TAP format. If any other value is provided,
2323+
Node.js makes no guarantees about the reporter format used or its stability.
23232324

23242325
### `NODE_TLS_REJECT_UNAUTHORIZED=value`
23252326

lib/internal/error_serdes.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
ObjectGetOwnPropertyNames,
1414
ObjectGetPrototypeOf,
1515
ObjectKeys,
16+
ObjectPrototypeHasOwnProperty,
1617
ObjectPrototypeToString,
1718
RangeError,
1819
ReferenceError,
@@ -52,7 +53,7 @@ function TryGetAllProperties(object, target = object) {
5253
// Continue regardless of error.
5354
}
5455
}
55-
if ('value' in descriptor && typeof descriptor.value !== 'function') {
56+
if ('value' in descriptor && typeof descriptor.value !== 'function' && typeof descriptor.value !== 'symbol') {
5657
delete descriptor.get;
5758
delete descriptor.set;
5859
all[key] = descriptor;
@@ -104,6 +105,8 @@ function serializeError(error) {
104105
if (errorConstructorNames.has(name)) {
105106
const serialized = serialize({
106107
constructor: name,
108+
cause: ObjectPrototypeHasOwnProperty(error, 'cause') ? serializeError(error.cause) : null,
109+
hasCause: ObjectPrototypeHasOwnProperty(error, 'cause'),
107110
properties: TryGetAllProperties(error),
108111
});
109112
return Buffer.concat([Buffer.from([kSerializedError]), serialized]);
@@ -128,13 +131,20 @@ function deserializeError(error) {
128131
if (!deserialize) deserialize = require('v8').deserialize;
129132
switch (error[0]) {
130133
case kSerializedError: {
131-
const { constructor, properties } = deserialize(error.subarray(1));
134+
const { constructor, properties, cause, hasCause } = deserialize(error.subarray(1));
132135
const ctor = errors[constructor];
133136
ObjectDefineProperty(properties, SymbolToStringTag, {
134137
__proto__: null,
135-
value: { value: 'Error', configurable: true },
138+
value: { __proto__: null, value: 'Error', configurable: true },
136139
enumerable: true,
137140
});
141+
if (hasCause) {
142+
ObjectDefineProperty(properties, 'cause', {
143+
__proto__: null,
144+
value: { __proto__: null, value: deserializeError(cause), configurable: true },
145+
enumerable: true,
146+
});
147+
}
138148
return ObjectCreate(ctor.prototype, properties);
139149
}
140150
case kSerializedObject:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
const { DefaultSerializer } = require('v8');
4+
const { Buffer } = require('buffer');
5+
const { serializeError } = require('internal/error_serdes');
6+
7+
8+
module.exports = async function* v8Reporter(source) {
9+
const serializer = new DefaultSerializer();
10+
11+
for await (const item of source) {
12+
const originalError = item.data.details?.error;
13+
if (originalError) {
14+
item.data.details.error = serializeError(originalError);
15+
}
16+
// Add 4 bytes, to later populate with message length
17+
serializer.writeRawBytes(Buffer.allocUnsafe(4));
18+
serializer.writeHeader();
19+
serializer.writeValue(item);
20+
21+
if (originalError) {
22+
item.data.details.error = originalError;
23+
}
24+
25+
const serializedMessage = serializer.releaseBuffer();
26+
const serializedMessageLength = serializedMessage.length - 4;
27+
28+
serializedMessage.set([
29+
serializedMessageLength >> 24 & 0xFF,
30+
serializedMessageLength >> 16 & 0xFF,
31+
serializedMessageLength >> 8 & 0xFF,
32+
serializedMessageLength & 0xFF,
33+
], 0);
34+
yield serializedMessage;
35+
}
36+
};

lib/internal/test_runner/runner.js

Lines changed: 109 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,25 @@ const {
1212
ArrayPrototypeSort,
1313
ObjectAssign,
1414
PromisePrototypeThen,
15-
SafePromiseAll,
1615
SafePromiseAllReturnVoid,
1716
SafePromiseAllSettledReturnVoid,
1817
PromiseResolve,
1918
SafeMap,
2019
SafeSet,
20+
String,
2121
StringPrototypeIndexOf,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
TypedArrayPrototypeSubarray,
2425
} = primordials;
2526

2627
const { spawn } = require('child_process');
2728
const { readdirSync, statSync } = require('fs');
28-
const { finished } = require('internal/streams/end-of-stream');
29+
const { DefaultDeserializer, DefaultSerializer } = require('v8');
2930
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
3031
const { createInterface } = require('readline');
32+
const { deserializeError } = require('internal/error_serdes');
33+
const { Buffer } = require('buffer');
3134
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
3235
const console = require('internal/console/global');
3336
const {
@@ -40,6 +43,7 @@ const { validateArray, validateBoolean, validateFunction } = require('internal/v
4043
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
4144
const { isRegExp } = require('internal/util/types');
4245
const { kEmptyObject } = require('internal/util');
46+
const { kEmitMessage } = require('internal/test_runner/tests_stream');
4347
const { createTestTree } = require('internal/test_runner/harness');
4448
const {
4549
kAborted,
@@ -49,9 +53,6 @@ const {
4953
kTestTimeoutFailure,
5054
Test,
5155
} = require('internal/test_runner/test');
52-
const { TapParser } = require('internal/test_runner/tap_parser');
53-
const { YAMLToJs } = require('internal/test_runner/yaml_to_js');
54-
const { TokenKind } = require('internal/test_runner/tap_lexer');
5556

5657
const {
5758
convertStringToRegExp,
@@ -153,92 +154,62 @@ function getRunArgs({ path, inspectPort, testNamePatterns }) {
153154
return argv;
154155
}
155156

157+
const serializer = new DefaultSerializer();
158+
serializer.writeHeader();
159+
const v8Header = serializer.releaseBuffer();
160+
const v8HeaderAndSize = 4 + v8Header.length;
161+
156162
class FileTest extends Test {
157163
#buffer = [];
164+
#messageBuffer = [];
165+
#messageBufferSize = 0;
158166
#reportedChildren = 0;
159167
failedSubtests = false;
160168
#skipReporting() {
161169
return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed);
162170
}
163-
#checkNestedComment({ comment }) {
171+
#checkNestedComment(comment) {
164172
const firstSpaceIndex = StringPrototypeIndexOf(comment, ' ');
165173
if (firstSpaceIndex === -1) return false;
166174
const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1);
167175
return secondSpaceIndex === -1 &&
168176
ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex));
169177
}
170-
#handleReportItem({ kind, node, comments, nesting = 0 }) {
171-
if (comments) {
172-
ArrayPrototypeForEach(comments, (comment) => this.reporter.diagnostic(nesting, this.name, comment));
173-
}
174-
switch (kind) {
175-
case TokenKind.TAP_VERSION:
176-
// TODO(manekinekko): handle TAP version coming from the parser.
177-
// this.reporter.version(node.version);
178-
break;
179-
180-
case TokenKind.TAP_PLAN:
181-
if (nesting === 0 && this.#skipReporting()) {
182-
break;
183-
}
184-
this.reporter.plan(nesting, this.name, node.end - node.start + 1);
185-
break;
186-
187-
case TokenKind.TAP_SUBTEST_POINT:
188-
this.reporter.start(nesting, this.name, node.name);
189-
break;
190-
191-
case TokenKind.TAP_TEST_POINT: {
192-
193-
const { todo, skip, pass } = node.status;
194-
195-
let directive;
196-
197-
if (skip) {
198-
directive = this.reporter.getSkip(node.reason || true);
199-
} else if (todo) {
200-
directive = this.reporter.getTodo(node.reason || true);
201-
} else {
202-
directive = kEmptyObject;
203-
}
204-
205-
const diagnostics = YAMLToJs(node.diagnostics);
206-
const cancelled = kCanceledTests.has(diagnostics.error?.failureType);
207-
const testNumber = nesting === 0 ? (this.root.harness.counters.topLevel + 1) : node.id;
208-
const method = pass ? 'ok' : 'fail';
209-
this.reporter[method](nesting, this.name, testNumber, node.description, diagnostics, directive);
210-
countCompletedTest({
211-
name: node.description,
212-
finished: true,
213-
skipped: skip,
214-
isTodo: todo,
215-
passed: pass,
216-
cancelled,
217-
nesting,
218-
reportedType: diagnostics.type,
219-
}, this.root.harness);
220-
break;
221-
178+
#handleReportItem(item) {
179+
const isTopLevel = item.data.nesting === 0;
180+
if (isTopLevel) {
181+
if (item.type === 'test:plan' && this.#skipReporting()) {
182+
return;
222183
}
223-
case TokenKind.COMMENT:
224-
if (nesting === 0 && this.#checkNestedComment(node)) {
225-
// Ignore file top level diagnostics
226-
break;
227-
}
228-
this.reporter.diagnostic(nesting, this.name, node.comment);
229-
break;
230-
231-
case TokenKind.UNKNOWN:
232-
this.reporter.diagnostic(nesting, this.name, node.value);
233-
break;
184+
if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) {
185+
return;
186+
}
187+
}
188+
if (item.data.details?.error) {
189+
item.data.details.error = deserializeError(item.data.details.error);
234190
}
191+
if (item.type === 'test:pass' || item.type === 'test:fail') {
192+
item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
193+
countCompletedTest({
194+
__proto__: null,
195+
name: item.data.name,
196+
finished: true,
197+
skipped: item.data.skip !== undefined,
198+
isTodo: item.data.todo !== undefined,
199+
passed: item.type === 'test:pass',
200+
cancelled: kCanceledTests.has(item.data.details?.error?.failureType),
201+
nesting: item.data.nesting,
202+
reportedType: item.data.details?.type,
203+
}, this.root.harness);
204+
}
205+
this.reporter[kEmitMessage](item.type, item.data);
235206
}
236-
#accumulateReportItem({ kind, node, comments, nesting = 0 }) {
237-
if (kind !== TokenKind.TAP_TEST_POINT) {
207+
#accumulateReportItem(item) {
208+
if (item.type !== 'test:pass' && item.type !== 'test:fail') {
238209
return;
239210
}
240211
this.#reportedChildren++;
241-
if (nesting === 0 && !node.status.pass) {
212+
if (item.data.nesting === 0 && item.type === 'test:fail') {
242213
this.failedSubtests = true;
243214
}
244215
}
@@ -248,14 +219,65 @@ class FileTest extends Test {
248219
this.#buffer = [];
249220
}
250221
}
251-
addToReport(ast) {
252-
this.#accumulateReportItem(ast);
222+
addToReport(item) {
223+
this.#accumulateReportItem(item);
253224
if (!this.isClearToSend()) {
254-
ArrayPrototypePush(this.#buffer, ast);
225+
ArrayPrototypePush(this.#buffer, item);
255226
return;
256227
}
257228
this.#drainBuffer();
258-
this.#handleReportItem(ast);
229+
this.#handleReportItem(item);
230+
}
231+
parseMessage(readData) {
232+
if (readData.length === 0) return;
233+
234+
ArrayPrototypePush(this.#messageBuffer, readData);
235+
this.#messageBufferSize += readData.length;
236+
237+
// Index 0 should always be present because we just pushed data into it.
238+
let messageBufferHead = this.#messageBuffer[0];
239+
240+
while (messageBufferHead.length >= 4) {
241+
const isSerializedMessage = messageBufferHead.length >= v8HeaderAndSize &&
242+
v8Header.compare(messageBufferHead, 4, v8HeaderAndSize) === 0;
243+
if (!isSerializedMessage) {
244+
const message = Buffer.concat(this.#messageBuffer, this.#messageBufferSize);
245+
this.#messageBufferSize = 0;
246+
this.#messageBuffer = [];
247+
this.addToReport({
248+
__proto__: null,
249+
type: 'test:diagnostic',
250+
data: { __proto__: null, nesting: 0, file: this.name, message: String(message) },
251+
});
252+
return;
253+
}
254+
255+
// We call `readUInt32BE` manually here, because this is faster than first converting
256+
// it to a buffer and using `readUInt32BE` on that.
257+
const fullMessageSize = (
258+
messageBufferHead[0] << 24 |
259+
messageBufferHead[1] << 16 |
260+
messageBufferHead[2] << 8 |
261+
messageBufferHead[3]
262+
) + 4;
263+
264+
if (this.#messageBufferSize < fullMessageSize) break;
265+
266+
const concatenatedBuffer = this.#messageBuffer.length === 1 ?
267+
this.#messageBuffer[0] : Buffer.concat(this.#messageBuffer, this.#messageBufferSize);
268+
269+
const deserializer = new DefaultDeserializer(
270+
TypedArrayPrototypeSubarray(concatenatedBuffer, 4, fullMessageSize),
271+
);
272+
273+
messageBufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
274+
this.#messageBufferSize = messageBufferHead.length;
275+
this.#messageBuffer = this.#messageBufferSize !== 0 ? [messageBufferHead] : [];
276+
277+
deserializer.readHeader();
278+
const item = deserializer.readValue();
279+
this.addToReport(item);
280+
}
259281
}
260282
reportStarted() {}
261283
report() {
@@ -275,7 +297,7 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
275297
const subtest = root.createSubtest(FileTest, path, async (t) => {
276298
const args = getRunArgs({ path, inspectPort, testNamePatterns });
277299
const stdio = ['pipe', 'pipe', 'pipe'];
278-
const env = { ...process.env, NODE_TEST_CONTEXT: 'child' };
300+
const env = { ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
279301
if (filesWatcher) {
280302
stdio.push('ipc');
281303
env.WATCH_REPORT_DEPENDENCIES = '1';
@@ -292,6 +314,10 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
292314
err = error;
293315
});
294316

317+
child.stdout.on('data', (data) => {
318+
subtest.parseMessage(data);
319+
});
320+
295321
const rl = createInterface({ input: child.stderr });
296322
rl.on('line', (line) => {
297323
if (isInspectorMessage(line)) {
@@ -303,26 +329,14 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
303329
// surface stderr lines as TAP diagnostics to improve the DX. Inject
304330
// each line into the test output as an unknown token as if it came
305331
// from the TAP parser.
306-
const node = {
307-
kind: TokenKind.UNKNOWN,
308-
node: {
309-
value: line,
310-
},
311-
};
312-
313-
subtest.addToReport(node);
314-
});
315-
316-
const parser = new TapParser();
317-
318-
child.stdout.pipe(parser).on('data', (ast) => {
319-
subtest.addToReport(ast);
332+
subtest.addToReport({
333+
__proto__: null,
334+
type: 'test:diagnostic',
335+
data: { __proto__: null, nesting: 0, file: path, message: line },
336+
});
320337
});
321338

322-
const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
323-
once(child, 'exit', { signal: t.signal }),
324-
finished(parser, { signal: t.signal }),
325-
]);
339+
const { 0: code, 1: signal } = await once(child, 'exit', { signal: t.signal });
326340

327341
runningProcesses.delete(path);
328342
runningSubtests.delete(path);

0 commit comments

Comments
 (0)