node/lib/internal/test_runner/reporter/tap.js
Moshe Atlow d1eaded0d1
test_runner: count nested tests
PR-URL: https://github.com/nodejs/node/pull/47094
Fixes: https://github.com/nodejs/node/issues/46762
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
2023-03-21 08:16:38 +00:00

279 lines
8.0 KiB
JavaScript

'use strict';
const {
ArrayPrototypeForEach,
ArrayPrototypeJoin,
ArrayPrototypePush,
NumberPrototypeToFixed,
ObjectEntries,
RegExpPrototypeSymbolReplace,
SafeMap,
StringPrototypeReplaceAll,
StringPrototypeSplit,
StringPrototypeRepeat,
} = primordials;
const { inspectWithNoCustomRetry } = require('internal/errors');
const { isError, kEmptyObject } = require('internal/util');
const { relative } = require('path');
const kDefaultIndent = ' '; // 4 spaces
const kFrameStartRegExp = /^ {4}at /;
const kLineBreakRegExp = /\n|\r\n/;
const kDefaultTAPVersion = 13;
const inspectOptions = { colors: false, breakLength: Infinity };
let testModule; // Lazy loaded due to circular dependency.
function lazyLoadTest() {
testModule ??= require('internal/test_runner/test');
return testModule;
}
async function * tapReporter(source) {
yield `TAP version ${kDefaultTAPVersion}\n`;
for await (const { type, data } of source) {
switch (type) {
case 'test:fail':
yield reportTest(data.nesting, data.testNumber, 'not ok', data.name, data.skip, data.todo);
yield reportDetails(data.nesting, data.details);
break;
case 'test:pass':
yield reportTest(data.nesting, data.testNumber, 'ok', data.name, data.skip, data.todo);
yield reportDetails(data.nesting, data.details);
break;
case 'test:plan':
yield `${indent(data.nesting)}1..${data.count}\n`;
break;
case 'test:start':
yield `${indent(data.nesting)}# Subtest: ${tapEscape(data.name)}\n`;
break;
case 'test:diagnostic':
yield `${indent(data.nesting)}# ${tapEscape(data.message)}\n`;
break;
case 'test:coverage':
yield reportCoverage(data.nesting, data.summary);
break;
}
}
}
function reportTest(nesting, testNumber, status, name, skip, todo) {
let line = `${indent(nesting)}${status} ${testNumber}`;
if (name) {
line += ` ${tapEscape(`- ${name}`)}`;
}
if (skip !== undefined) {
line += ` # SKIP${typeof skip === 'string' && skip.length ? ` ${tapEscape(skip)}` : ''}`;
} else if (todo !== undefined) {
line += ` # TODO${typeof todo === 'string' && todo.length ? ` ${tapEscape(todo)}` : ''}`;
}
line += '\n';
return line;
}
function reportCoverage(nesting, summary) {
const pad = indent(nesting);
let report = `${pad}# start of coverage report\n`;
report += `${pad}# file | line % | branch % | funcs % | uncovered lines\n`;
for (let i = 0; i < summary.files.length; ++i) {
const {
path,
coveredLinePercent,
coveredBranchPercent,
coveredFunctionPercent,
uncoveredLineNumbers,
} = summary.files[i];
const relativePath = relative(summary.workingDirectory, path);
const lines = NumberPrototypeToFixed(coveredLinePercent, 2);
const branches = NumberPrototypeToFixed(coveredBranchPercent, 2);
const functions = NumberPrototypeToFixed(coveredFunctionPercent, 2);
const uncovered = ArrayPrototypeJoin(uncoveredLineNumbers, ', ');
report += `${pad}# ${relativePath} | ${lines} | ${branches} | ` +
`${functions} | ${uncovered}\n`;
}
const { totals } = summary;
report += `${pad}# all files | ` +
`${NumberPrototypeToFixed(totals.coveredLinePercent, 2)} | ` +
`${NumberPrototypeToFixed(totals.coveredBranchPercent, 2)} | ` +
`${NumberPrototypeToFixed(totals.coveredFunctionPercent, 2)} |\n`;
report += `${pad}# end of coverage report\n`;
return report;
}
function reportDetails(nesting, data = kEmptyObject) {
const { error, duration_ms } = data;
const _indent = indent(nesting);
let details = `${_indent} ---\n`;
details += jsToYaml(_indent, 'duration_ms', duration_ms);
details += jsToYaml(_indent, 'type', data.type);
details += jsToYaml(_indent, null, error);
details += `${_indent} ...\n`;
return details;
}
const memo = new SafeMap();
function indent(nesting) {
let value = memo.get(nesting);
if (value === undefined) {
value = StringPrototypeRepeat(kDefaultIndent, nesting);
memo.set(nesting, value);
}
return value;
}
// In certain places, # and \ need to be escaped as \# and \\.
function tapEscape(input) {
let result = StringPrototypeReplaceAll(input, '\b', '\\b');
result = StringPrototypeReplaceAll(result, '\f', '\\f');
result = StringPrototypeReplaceAll(result, '\t', '\\t');
result = StringPrototypeReplaceAll(result, '\n', '\\n');
result = StringPrototypeReplaceAll(result, '\r', '\\r');
result = StringPrototypeReplaceAll(result, '\v', '\\v');
result = StringPrototypeReplaceAll(result, '\\', '\\\\');
result = StringPrototypeReplaceAll(result, '#', '\\#');
return result;
}
function jsToYaml(indent, name, value) {
if (value === null || value === undefined) {
return '';
}
if (typeof value !== 'object') {
const prefix = `${indent} ${name}: `;
if (typeof value !== 'string') {
return `${prefix}${inspectWithNoCustomRetry(value, inspectOptions)}\n`;
}
const lines = StringPrototypeSplit(value, kLineBreakRegExp);
if (lines.length === 1) {
return `${prefix}${inspectWithNoCustomRetry(value, inspectOptions)}\n`;
}
let str = `${prefix}|-\n`;
for (let i = 0; i < lines.length; i++) {
str += `${indent} ${lines[i]}\n`;
}
return str;
}
const entries = ObjectEntries(value);
const isErrorObj = isError(value);
let result = '';
for (let i = 0; i < entries.length; i++) {
const { 0: key, 1: value } = entries[i];
if (isErrorObj && (key === 'cause' || key === 'code')) {
continue;
}
result += jsToYaml(indent, key, value);
}
if (isErrorObj) {
const { kTestCodeFailure, kUnwrapErrors } = lazyLoadTest();
const {
cause,
code,
failureType,
message,
expected,
actual,
operator,
stack,
name,
} = value;
let errMsg = message ?? '<unknown error>';
let errName = name;
let errStack = stack;
let errCode = code;
let errExpected = expected;
let errActual = actual;
let errOperator = operator;
let errIsAssertion = isAssertionLike(value);
// If the ERR_TEST_FAILURE came from an error provided by user code,
// then try to unwrap the original error message and stack.
if (code === 'ERR_TEST_FAILURE' && kUnwrapErrors.has(failureType)) {
errStack = cause?.stack ?? errStack;
errCode = cause?.code ?? errCode;
errName = cause?.name ?? errName;
if (isAssertionLike(cause)) {
errExpected = cause.expected;
errActual = cause.actual;
errOperator = cause.operator ?? errOperator;
errIsAssertion = true;
}
if (failureType === kTestCodeFailure) {
errMsg = cause?.message ?? errMsg;
}
}
result += jsToYaml(indent, 'error', errMsg);
if (errCode) {
result += jsToYaml(indent, 'code', errCode);
}
if (errName && errName !== 'Error') {
result += jsToYaml(indent, 'name', errName);
}
if (errIsAssertion) {
result += jsToYaml(indent, 'expected', errExpected);
result += jsToYaml(indent, 'actual', errActual);
if (errOperator) {
result += jsToYaml(indent, 'operator', errOperator);
}
}
if (typeof errStack === 'string') {
const frames = [];
ArrayPrototypeForEach(
StringPrototypeSplit(errStack, kLineBreakRegExp),
(frame) => {
const processed = RegExpPrototypeSymbolReplace(
kFrameStartRegExp,
frame,
'',
);
if (processed.length > 0 && processed.length !== frame.length) {
ArrayPrototypePush(frames, processed);
}
},
);
if (frames.length > 0) {
const frameDelimiter = `\n${indent} `;
result += `${indent} stack: |-${frameDelimiter}`;
result += `${ArrayPrototypeJoin(frames, frameDelimiter)}\n`;
}
}
}
return result;
}
function isAssertionLike(value) {
return value && typeof value === 'object' && 'expected' in value && 'actual' in value;
}
module.exports = tapReporter;