Skip to content

Commit

Permalink
test_runner: use v8.serialize instead of TAP
Browse files Browse the repository at this point in the history
  • Loading branch information
MoLow committed May 5, 2023
1 parent af9b48a commit d9c12f8
Show file tree
Hide file tree
Showing 16 changed files with 224 additions and 4,601 deletions.
3 changes: 2 additions & 1 deletion doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2319,7 +2319,8 @@ on unsupported platforms will not be fixed.
### `NODE_TEST_CONTEXT=value`

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

### `NODE_TLS_REJECT_UNAUTHORIZED=value`

Expand Down
16 changes: 13 additions & 3 deletions lib/internal/error_serdes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
ObjectGetOwnPropertyNames,
ObjectGetPrototypeOf,
ObjectKeys,
ObjectPrototypeHasOwnProperty,
ObjectPrototypeToString,
RangeError,
ReferenceError,
Expand Down Expand Up @@ -52,7 +53,7 @@ function TryGetAllProperties(object, target = object) {
// Continue regardless of error.
}
}
if ('value' in descriptor && typeof descriptor.value !== 'function') {
if ('value' in descriptor && typeof descriptor.value !== 'function' && typeof descriptor.value !== 'symbol') {
delete descriptor.get;
delete descriptor.set;
all[key] = descriptor;
Expand Down Expand Up @@ -104,6 +105,8 @@ function serializeError(error) {
if (errorConstructorNames.has(name)) {
const serialized = serialize({
constructor: name,
cause: ObjectPrototypeHasOwnProperty(error, 'cause') ? serializeError(error.cause) : null,
hasCause: ObjectPrototypeHasOwnProperty(error, 'cause'),
properties: TryGetAllProperties(error),
});
return Buffer.concat([Buffer.from([kSerializedError]), serialized]);
Expand All @@ -128,13 +131,20 @@ function deserializeError(error) {
if (!deserialize) deserialize = require('v8').deserialize;
switch (error[0]) {
case kSerializedError: {
const { constructor, properties } = deserialize(error.subarray(1));
const { constructor, properties, cause, hasCause } = deserialize(error.subarray(1));
const ctor = errors[constructor];
ObjectDefineProperty(properties, SymbolToStringTag, {
__proto__: null,
value: { value: 'Error', configurable: true },
value: { __proto__: null, value: 'Error', configurable: true },
enumerable: true,
});
if (hasCause) {
ObjectDefineProperty(properties, 'cause', {
__proto__: null,
value: { __proto__: null, value: deserializeError(cause), configurable: true },
enumerable: true,
});
}
return ObjectCreate(ctor.prototype, properties);
}
case kSerializedObject:
Expand Down
36 changes: 36 additions & 0 deletions lib/internal/test_runner/reporter/v8.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

const { DefaultSerializer } = require('v8');
const { Buffer } = require('buffer');
const { serializeError } = require('internal/error_serdes');


module.exports = async function* v8Reporter(source) {
const serializer = new DefaultSerializer();

for await (const item of source) {
const originalError = item.data.details?.error;
if (originalError) {
item.data.details.error = serializeError(originalError);
}
// Add 4 bytes, to later populate with message length
serializer.writeRawBytes(Buffer.allocUnsafe(4));
serializer.writeHeader();
serializer.writeValue(item);

if (originalError) {
item.data.details.error = originalError;
}

const serializedMessage = serializer.releaseBuffer();
const serializedMessageLength = serializedMessage.length - 4;

serializedMessage.set([
serializedMessageLength >> 24 & 0xFF,
serializedMessageLength >> 16 & 0xFF,
serializedMessageLength >> 8 & 0xFF,
serializedMessageLength & 0xFF,
], 0);
yield serializedMessage;
}
};
204 changes: 109 additions & 95 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,25 @@ const {
ArrayPrototypeSort,
ObjectAssign,
PromisePrototypeThen,
SafePromiseAll,
SafePromiseAllReturnVoid,
SafePromiseAllSettledReturnVoid,
PromiseResolve,
SafeMap,
SafeSet,
String,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeStartsWith,
TypedArrayPrototypeSubarray,
} = primordials;

const { spawn } = require('child_process');
const { readdirSync, statSync } = require('fs');
const { finished } = require('internal/streams/end-of-stream');
const { DefaultDeserializer, DefaultSerializer } = require('v8');
// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern.
const { createInterface } = require('readline');
const { deserializeError } = require('internal/error_serdes');
const { Buffer } = require('buffer');
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
const console = require('internal/console/global');
const {
Expand All @@ -40,6 +43,7 @@ const { validateArray, validateBoolean, validateFunction } = require('internal/v
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
const { isRegExp } = require('internal/util/types');
const { kEmptyObject } = require('internal/util');
const { kEmitMessage } = require('internal/test_runner/tests_stream');
const { createTestTree } = require('internal/test_runner/harness');
const {
kAborted,
Expand All @@ -49,9 +53,6 @@ const {
kTestTimeoutFailure,
Test,
} = require('internal/test_runner/test');
const { TapParser } = require('internal/test_runner/tap_parser');
const { YAMLToJs } = require('internal/test_runner/yaml_to_js');
const { TokenKind } = require('internal/test_runner/tap_lexer');

const {
convertStringToRegExp,
Expand Down Expand Up @@ -153,92 +154,62 @@ function getRunArgs({ path, inspectPort, testNamePatterns }) {
return argv;
}

const serializer = new DefaultSerializer();
serializer.writeHeader();
const v8Header = serializer.releaseBuffer();
const v8HeaderAndSize = 4 + v8Header.length;

class FileTest extends Test {
#buffer = [];
#messageBuffer = [];
#messageBufferSize = 0;
#reportedChildren = 0;
failedSubtests = false;
#skipReporting() {
return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed);
}
#checkNestedComment({ comment }) {
#checkNestedComment(comment) {
const firstSpaceIndex = StringPrototypeIndexOf(comment, ' ');
if (firstSpaceIndex === -1) return false;
const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1);
return secondSpaceIndex === -1 &&
ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex));
}
#handleReportItem({ kind, node, comments, nesting = 0 }) {
if (comments) {
ArrayPrototypeForEach(comments, (comment) => this.reporter.diagnostic(nesting, this.name, comment));
}
switch (kind) {
case TokenKind.TAP_VERSION:
// TODO(manekinekko): handle TAP version coming from the parser.
// this.reporter.version(node.version);
break;

case TokenKind.TAP_PLAN:
if (nesting === 0 && this.#skipReporting()) {
break;
}
this.reporter.plan(nesting, this.name, node.end - node.start + 1);
break;

case TokenKind.TAP_SUBTEST_POINT:
this.reporter.start(nesting, this.name, node.name);
break;

case TokenKind.TAP_TEST_POINT: {

const { todo, skip, pass } = node.status;

let directive;

if (skip) {
directive = this.reporter.getSkip(node.reason || true);
} else if (todo) {
directive = this.reporter.getTodo(node.reason || true);
} else {
directive = kEmptyObject;
}

const diagnostics = YAMLToJs(node.diagnostics);
const cancelled = kCanceledTests.has(diagnostics.error?.failureType);
const testNumber = nesting === 0 ? (this.root.harness.counters.topLevel + 1) : node.id;
const method = pass ? 'ok' : 'fail';
this.reporter[method](nesting, this.name, testNumber, node.description, diagnostics, directive);
countCompletedTest({
name: node.description,
finished: true,
skipped: skip,
isTodo: todo,
passed: pass,
cancelled,
nesting,
reportedType: diagnostics.type,
}, this.root.harness);
break;

#handleReportItem(item) {
const isTopLevel = item.data.nesting === 0;
if (isTopLevel) {
if (item.type === 'test:plan' && this.#skipReporting()) {
return;
}
case TokenKind.COMMENT:
if (nesting === 0 && this.#checkNestedComment(node)) {
// Ignore file top level diagnostics
break;
}
this.reporter.diagnostic(nesting, this.name, node.comment);
break;

case TokenKind.UNKNOWN:
this.reporter.diagnostic(nesting, this.name, node.value);
break;
if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) {
return;
}
}
if (item.data.details?.error) {
item.data.details.error = deserializeError(item.data.details.error);
}
if (item.type === 'test:pass' || item.type === 'test:fail') {
item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber;
countCompletedTest({
__proto__: null,
name: item.data.name,
finished: true,
skipped: item.data.skip !== undefined,
isTodo: item.data.todo !== undefined,
passed: item.type === 'test:pass',
cancelled: kCanceledTests.has(item.data.details?.error?.failureType),
nesting: item.data.nesting,
reportedType: item.data.details?.type,
}, this.root.harness);
}
this.reporter[kEmitMessage](item.type, item.data);
}
#accumulateReportItem({ kind, node, comments, nesting = 0 }) {
if (kind !== TokenKind.TAP_TEST_POINT) {
#accumulateReportItem(item) {
if (item.type !== 'test:pass' && item.type !== 'test:fail') {
return;
}
this.#reportedChildren++;
if (nesting === 0 && !node.status.pass) {
if (item.data.nesting === 0 && item.type === 'test:fail') {
this.failedSubtests = true;
}
}
Expand All @@ -248,14 +219,65 @@ class FileTest extends Test {
this.#buffer = [];
}
}
addToReport(ast) {
this.#accumulateReportItem(ast);
addToReport(item) {
this.#accumulateReportItem(item);
if (!this.isClearToSend()) {
ArrayPrototypePush(this.#buffer, ast);
ArrayPrototypePush(this.#buffer, item);
return;
}
this.#drainBuffer();
this.#handleReportItem(ast);
this.#handleReportItem(item);
}
parseMessage(readData) {
if (readData.length === 0) return;

ArrayPrototypePush(this.#messageBuffer, readData);
this.#messageBufferSize += readData.length;

// Index 0 should always be present because we just pushed data into it.
let messageBufferHead = this.#messageBuffer[0];

while (messageBufferHead.length >= 4) {
const isSerializedMessage = messageBufferHead.length >= v8HeaderAndSize &&
v8Header.compare(messageBufferHead, 4, v8HeaderAndSize) === 0;
if (!isSerializedMessage) {
const message = Buffer.concat(this.#messageBuffer, this.#messageBufferSize);
this.#messageBufferSize = 0;
this.#messageBuffer = [];
this.addToReport({
__proto__: null,
type: 'test:diagnostic',
data: { __proto__: null, nesting: 0, file: this.name, message: String(message) },
});
return;
}

// We call `readUInt32BE` manually here, because this is faster than first converting
// it to a buffer and using `readUInt32BE` on that.
const fullMessageSize = (
messageBufferHead[0] << 24 |
messageBufferHead[1] << 16 |
messageBufferHead[2] << 8 |
messageBufferHead[3]
) + 4;

if (this.#messageBufferSize < fullMessageSize) break;

const concatenatedBuffer = this.#messageBuffer.length === 1 ?
this.#messageBuffer[0] : Buffer.concat(this.#messageBuffer, this.#messageBufferSize);

const deserializer = new DefaultDeserializer(
TypedArrayPrototypeSubarray(concatenatedBuffer, 4, fullMessageSize),
);

messageBufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize);
this.#messageBufferSize = messageBufferHead.length;
this.#messageBuffer = this.#messageBufferSize !== 0 ? [messageBufferHead] : [];

deserializer.readHeader();
const item = deserializer.readValue();
this.addToReport(item);
}
}
reportStarted() {}
report() {
Expand All @@ -275,7 +297,7 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
const subtest = root.createSubtest(FileTest, path, async (t) => {
const args = getRunArgs({ path, inspectPort, testNamePatterns });
const stdio = ['pipe', 'pipe', 'pipe'];
const env = { ...process.env, NODE_TEST_CONTEXT: 'child' };
const env = { ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
if (filesWatcher) {
stdio.push('ipc');
env.WATCH_REPORT_DEPENDENCIES = '1';
Expand All @@ -292,6 +314,10 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
err = error;
});

child.stdout.on('data', (data) => {
subtest.parseMessage(data);
});

const rl = createInterface({ input: child.stderr });
rl.on('line', (line) => {
if (isInspectorMessage(line)) {
Expand All @@ -303,26 +329,14 @@ function runTestFile(path, root, inspectPort, filesWatcher, testNamePatterns) {
// surface stderr lines as TAP diagnostics to improve the DX. Inject
// each line into the test output as an unknown token as if it came
// from the TAP parser.
const node = {
kind: TokenKind.UNKNOWN,
node: {
value: line,
},
};

subtest.addToReport(node);
});

const parser = new TapParser();

child.stdout.pipe(parser).on('data', (ast) => {
subtest.addToReport(ast);
subtest.addToReport({
__proto__: null,
type: 'test:diagnostic',
data: { __proto__: null, nesting: 0, file: path, message: line },
});
});

const { 0: { 0: code, 1: signal } } = await SafePromiseAll([
once(child, 'exit', { signal: t.signal }),
finished(parser, { signal: t.signal }),
]);
const { 0: code, 1: signal } = await once(child, 'exit', { signal: t.signal });

runningProcesses.delete(path);
runningSubtests.delete(path);
Expand Down
Loading

0 comments on commit d9c12f8

Please sign in to comment.