Skip to content

Commit 7ec72e3

Browse files
committed
Reporter: Support multi-line strings in TAP failures data
This is a different approach to fixing #109. The issue was previously fixed in js-reporters 1.2.2 by #110 but that made multi-line strings difficult to read and left numerous escape hatches in place. That fix has since been reverted to keep 1.x behaving the same has before. The new approach will be part of 2.0. Fixes #109.
1 parent 3bb584e commit 7ec72e3

File tree

3 files changed

+163
-25
lines changed

3 files changed

+163
-25
lines changed

lib/reporters/TapReporter.js

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,121 @@ const chalk = require('chalk');
22

33
const hasOwn = Object.hasOwnProperty;
44

5+
/**
6+
* Format a given value into YAML.
7+
*
8+
* YAML is a superset of JSON that supports all the same data
9+
* types and syntax, and more. As such, it is always possible
10+
* to fallback to JSON.stringfify, but we generally avoid
11+
* that to make output easier to read for humans.
12+
*
13+
* Supported data types:
14+
*
15+
* - null
16+
* - boolean
17+
* - number
18+
* - string
19+
* - array
20+
* - object
21+
*
22+
* Anything else (including NaN, Infinity, and undefined)
23+
* must be described in strings, for display purposes.
24+
*
25+
* Note that quotes are optional in YAML strings if the
26+
* strings are "simple", and as such we generally prefer
27+
* that for improved readability. We output strings in
28+
* one of three ways:
29+
*
30+
* - bare unquoted text, for simple one-line strings.
31+
* - JSON (quoted text), for complex one-line strings.
32+
* - YAML Block, for complex multi-line strings.
33+
*/
34+
function prettyYamlValue (value, indent = 4) {
35+
if (value === undefined) {
36+
// Not supported in JSON/YAML, turn into string
37+
// and let the below output it as bare string.
38+
value = String(value);
39+
}
40+
41+
if (typeof value === 'number' && !Number.isFinite(value)) {
42+
// Turn NaN and Infinity into simple strings.
43+
// Paranoia: Don't return directly just in case there's
44+
// a way to add special characters here.
45+
value = String(value);
46+
}
47+
48+
if (typeof value === 'number') {
49+
// Simple numbers
50+
return JSON.stringify(value);
51+
}
52+
53+
if (typeof value === 'string') {
54+
// If any of these match, then we can't output it
55+
// as bare unquoted text, because that would either
56+
// cause data loss or invalid YAML syntax.
57+
//
58+
// - Quotes, escapes, line breaks, or JSON-like stuff.
59+
const rSpecialJson = /['"\\/[{}\]\r\n]/;
60+
// - Characters that are special at the start of a YAML value
61+
const rSpecialYaml = /[-?:,[\]{}#&*!|=>'"%@`]/;
62+
// - Leading or trailing whitespace.
63+
const rUntrimmed = /(^\s|\s$)/;
64+
// - Ambiguous as YAML number, e.g. '2', '-1.2', '.2', or '2_000'
65+
const rNumerical = /^[\d._-]+$/;
66+
// - Ambiguous as YAML bool.
67+
// Use case-insensitive match, although technically only
68+
// fully-lower, fully-upper, or uppercase-first would be ambiguous.
69+
// e.g. true/True/TRUE, but not tRUe.
70+
const rBool = /^(true|false|y|n|yes|no|on|off)$/i;
71+
72+
// Is this a complex string?
73+
if (
74+
value === '' ||
75+
rSpecialJson.test(value) ||
76+
rSpecialYaml.test(value[0]) ||
77+
rUntrimmed.test(value) ||
78+
rNumerical.test(value) ||
79+
rBool.test(value)
80+
) {
81+
if (!/\n/.test(value)) {
82+
// Complex one-line string, use JSON (quoted string)
83+
return JSON.stringify(value);
84+
}
85+
86+
// See also <https://yaml-multiline.info/>
87+
const prefix = ' '.repeat(indent);
88+
89+
const trailingLinebreakMatch = value.match(/\n+$/);
90+
const trailingLinebreaks = trailingLinebreakMatch ? trailingLinebreakMatch[0].length : 0;
91+
92+
if (trailingLinebreaks === 1) {
93+
// Use the most straight-forward "Block" string in YAML
94+
// without any "Chomping" indicators.
95+
const lines = value
96+
// Ignore the last new line, since we'll get that one for free
97+
// with the straight-forward Block syntax.
98+
.replace(/\n$/, '')
99+
.split('\n')
100+
.map(line => prefix + line);
101+
return '|\n' + lines.join('\n');
102+
} else {
103+
// This has either no trailing new lines, or more than 1.
104+
// Use |+ so that YAML parsers will preserve it exactly.
105+
const lines = value
106+
.split('\n')
107+
.map(line => prefix + line);
108+
return '|+\n' + lines.join('\n');
109+
}
110+
} else {
111+
// Simple string, use bare unquoted text
112+
return value;
113+
}
114+
}
115+
116+
// Handle null, boolean, array, and object
117+
return JSON.stringify(value, null, 2);
118+
}
119+
5120
module.exports = class TapReporter {
6121
constructor (runner) {
7122
this.testCount = 0;
@@ -44,24 +159,25 @@ module.exports = class TapReporter {
44159
}
45160

46161
logError (error, severity) {
47-
console.log(' ---');
48-
console.log(` message: "${(error.message || 'failed').replace(/"/g, '\\"')}"`);
49-
console.log(` severity: ${severity || 'failed'}`);
162+
let out = ' ---';
163+
out += `\n message: ${prettyYamlValue(error.message || 'failed')}`;
164+
out += `\n severity: ${prettyYamlValue(severity || 'failed')}`;
50165

51166
if (hasOwn.call(error, 'actual')) {
52-
const actualStr = error.actual !== undefined ? ('"' + JSON.stringify(error.actual, null, 2).replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"') : 'undefined';
53-
console.log(` actual : ${actualStr}`);
167+
out += `\n actual : ${prettyYamlValue(error.actual)}`;
54168
}
55169

56170
if (hasOwn.call(error, 'expected')) {
57-
const expectedStr = error.expected !== undefined ? ('"' + JSON.stringify(error.expected, null, 2).replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"') : 'undefined';
58-
console.log(` expected: ${expectedStr}`);
171+
out += `\n expected: ${prettyYamlValue(error.expected)}`;
59172
}
60173

61174
if (error.stack) {
62-
console.log(` stack: "${error.stack.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
175+
// Since stacks aren't user generated, take a bit of liberty by
176+
// adding a trailing new line to allow a straight-forward YAML Blocks.
177+
out += `\n stack: ${prettyYamlValue(error.stack + '\n')}`;
63178
}
64179

65-
console.log(' ...');
180+
out += '\n ...';
181+
console.log(out);
66182
}
67183
};

test/unit/data.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,41 @@
11
const { TestEnd, TestStart, SuiteStart, SuiteEnd } = require('../../');
22

3+
function mockStack (error) {
4+
error.stack = ` at Object.<anonymous> (/dev/null/test/unit/data.js:6:5)
5+
at require (internal/modules/cjs/helpers.js:22:18)
6+
at /dev/null/node_modules/mocha/lib/mocha.js:220:27
7+
at startup (internal/bootstrap/node.js:283:19)
8+
at bootstrapNodeJSCore (internal/bootstrap/node.js:743:3)`;
9+
return error;
10+
}
11+
312
module.exports = {
413
passingTest: new TestEnd('pass', undefined, [], 'passed', 0, []),
514
failingTest: new TestEnd('fail', undefined, [], 'failed', 0, [
6-
new Error('first error'), new Error('second error')
15+
mockStack(new Error('first error')), mockStack(new Error('second error'))
716
]),
17+
failingTapData: [
18+
` ---
19+
message: first error
20+
severity: failed
21+
stack: |
22+
at Object.<anonymous> (/dev/null/test/unit/data.js:6:5)
23+
at require (internal/modules/cjs/helpers.js:22:18)
24+
at /dev/null/node_modules/mocha/lib/mocha.js:220:27
25+
at startup (internal/bootstrap/node.js:283:19)
26+
at bootstrapNodeJSCore (internal/bootstrap/node.js:743:3)
27+
...`,
28+
` ---
29+
message: second error
30+
severity: failed
31+
stack: |
32+
at Object.<anonymous> (/dev/null/test/unit/data.js:6:5)
33+
at require (internal/modules/cjs/helpers.js:22:18)
34+
at /dev/null/node_modules/mocha/lib/mocha.js:220:27
35+
at startup (internal/bootstrap/node.js:283:19)
36+
at bootstrapNodeJSCore (internal/bootstrap/node.js:743:3)
37+
...`
38+
],
839
actualUndefinedTest: new TestEnd('fail', undefined, [], 'failed', 0, [{
940
passed: false,
1041
actual: undefined,

test/unit/tap-reporter.js

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,10 @@ describe('Tap reporter', function () {
6565

6666
it('should output all errors for a failing test', sinon.test(function () {
6767
const spy = this.stub(console, 'log');
68-
const expected = [];
69-
70-
data.failingTest.errors.forEach(function (error) {
71-
expected.push(' ---');
72-
expected.push(' message: "' + error.message.replace(/"/g, '\\"') + '"');
73-
expected.push(' severity: failed');
74-
expected.push(' stack: "' + error.stack.replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"');
75-
expected.push(' ...');
76-
});
7768

7869
emitter.emit('testEnd', data.failingTest);
79-
for (let i = 0; i < expected.length; i++) {
80-
expect(spy).to.have.been.calledWith(expected[i]);
70+
for (let i = 0; i < data.failingTapData.length; i++) {
71+
expect(spy).to.have.been.calledWith(data.failingTapData[i]);
8172
}
8273
}));
8374

@@ -86,31 +77,31 @@ describe('Tap reporter', function () {
8677

8778
emitter.emit('testEnd', data.actualUndefinedTest);
8879

89-
expect(spy).to.have.been.calledWith(' actual : undefined');
80+
expect(spy).to.have.been.calledWithMatch(/^ {2}actual {2}: undefined$/m);
9081
}));
9182

9283
it('should output actual value for failed assertions even it was falsy', sinon.test(function () {
9384
const spy = this.stub(console, 'log');
9485

9586
emitter.emit('testEnd', data.actualFalsyTest);
9687

97-
expect(spy).to.have.been.calledWith(' actual : "0"');
88+
expect(spy).to.have.been.calledWithMatch(/^ {2}actual {2}: 0$/m);
9889
}));
9990

10091
it('should output expected value for failed assertions even it was undefined', sinon.test(function () {
10192
const spy = this.stub(console, 'log');
10293

10394
emitter.emit('testEnd', data.expectedUndefinedTest);
10495

105-
expect(spy).to.have.been.calledWith(' expected: undefined');
96+
expect(spy).to.have.been.calledWithMatch(/^ {2}expected: undefined$/m);
10697
}));
10798

10899
it('should output expected value for failed assertions even it was falsy', sinon.test(function () {
109100
const spy = this.stub(console, 'log');
110101

111102
emitter.emit('testEnd', data.expectedFalsyTest);
112103

113-
expect(spy).to.have.been.calledWith(' expected: "0"');
104+
expect(spy).to.have.been.calledWithMatch(/^ {2}expected: 0$/m);
114105
}));
115106

116107
it('should output the total number of tests', sinon.test(function () {

0 commit comments

Comments
 (0)