diff --git a/lib/reporters/TapReporter.js b/lib/reporters/TapReporter.js index 49ab2c3..33f74bb 100644 --- a/lib/reporters/TapReporter.js +++ b/lib/reporters/TapReporter.js @@ -29,6 +29,9 @@ const hasOwn = Object.hasOwnProperty; * - bare unquoted text, for simple one-line strings. * - JSON (quoted text), for complex one-line strings. * - YAML Block, for complex multi-line strings. + * + * Objects with cyclical references will be stringifed as + * "[Circular]" as they cannot otherwise be represented. */ function prettyYamlValue (value, indent = 4) { if (value === undefined) { @@ -115,7 +118,46 @@ function prettyYamlValue (value, indent = 4) { } // Handle null, boolean, array, and object - return JSON.stringify(value, null, 2); + return JSON.stringify(decycledShallowClone(value), null, 2); +} + +/** + * Creates a shallow clone of an object where cycles have + * been replaced with "[Circular]". + */ +function decycledShallowClone (object, ancestors = []) { + if (ancestors.indexOf(object) !== -1) { + return '[Circular]'; + } + + let clone; + + const type = Object.prototype.toString + .call(object) + .replace(/^\[.+\s(.+?)]$/, '$1') + .toLowerCase(); + + switch (type) { + case 'array': + ancestors.push(object); + clone = object.map(function (element) { + return decycledShallowClone(element, ancestors); + }); + ancestors.pop(); + break; + case 'object': + ancestors.push(object); + clone = {}; + Object.keys(object).forEach(function (key) { + clone[key] = decycledShallowClone(object[key], ancestors); + }); + ancestors.pop(); + break; + default: + clone = object; + } + + return clone; } module.exports = class TapReporter { diff --git a/test/fixtures/unit.js b/test/fixtures/unit.js index efc37d3..1cf4bff 100644 --- a/test/fixtures/unit.js +++ b/test/fixtures/unit.js @@ -11,6 +11,39 @@ function copyErrors (testEnd) { return testEnd; } +/** + * Creates an object that has a cyclical reference. + */ +function createCyclical () { + const cyclical = { a: 'example' }; + cyclical.cycle = cyclical; + return cyclical; +} + +/** + * Creates an object that has a cyclical reference in a subobject. + */ +function createSubobjectCyclical () { + const cyclical = { a: 'example', sub: {} }; + cyclical.sub.cycle = cyclical; + return cyclical; +} + +/** + * Creates an object that references another object more + * than once in an acyclical way. + */ +function createDuplicateAcyclical () { + const duplicate = { + example: 'value' + }; + return { + a: duplicate, + b: duplicate, + c: 'unique' + }; +} + module.exports = { passingTestStart: { name: 'pass', @@ -194,6 +227,79 @@ module.exports = { message: failed severity: failed actual : [] + expected: expected + ...`, + actualCyclical: copyErrors({ + name: 'Failing', + suiteName: undefined, + fullName: ['Failing'], + status: 'failed', + runtime: 0, + errors: [{ + passed: false, + actual: createCyclical(), + expected: 'expected' + }], + assertions: null + }), + actualCyclicalTap: ` --- + message: failed + severity: failed + actual : { + "a": "example", + "cycle": "[Circular]" +} + expected: expected + ...`, + actualSubobjectCyclical: copyErrors({ + name: 'Failing', + suiteName: undefined, + fullName: ['Failing'], + status: 'failed', + runtime: 0, + errors: [{ + passed: false, + actual: createSubobjectCyclical(), + expected: 'expected' + }], + assertions: null + }), + actualSubobjectCyclicalTap: ` --- + message: failed + severity: failed + actual : { + "a": "example", + "sub": { + "cycle": "[Circular]" + } +} + expected: expected + ...`, + actualDuplicateAcyclic: copyErrors({ + name: 'Failing', + suiteName: undefined, + fullName: ['Failing'], + status: 'failed', + runtime: 0, + errors: [{ + passed: false, + actual: createDuplicateAcyclical(), + expected: 'expected' + }], + assertions: null + }), + actualDuplicateAcyclicTap: ` --- + message: failed + severity: failed + actual : { + "a": { + "example": "value" + }, + "b": { + "example": "value" + }, + "c": "unique" +} expected: expected ...`, expectedUndefinedTest: copyErrors({ @@ -222,6 +328,19 @@ module.exports = { }], assertions: null }), + expectedCircularTest: copyErrors({ + name: 'fail', + suiteName: undefined, + fullName: [], + status: 'failed', + runtime: 0, + errors: [{ + passed: false, + actual: 'actual', + expected: createCyclical() + }], + assertions: null + }), skippedTest: { name: 'skip', suiteName: null, diff --git a/test/unit/tap-reporter.js b/test/unit/tap-reporter.js index f8e01d2..10d5d36 100644 --- a/test/unit/tap-reporter.js +++ b/test/unit/tap-reporter.js @@ -115,6 +115,21 @@ QUnit.module('TapReporter', hooks => { assert.equal(spy.args[1][0], data.actualArrayTap); }); + test('output actual assertion value of a cyclical structure', assert => { + emitter.emit('testEnd', data.actualCyclical); + assert.equal(spy.args[1][0], data.actualCyclicalTap); + }); + + test('output actual assertion value of a subobject cyclical structure', assert => { + emitter.emit('testEnd', data.actualSubobjectCyclical); + assert.equal(spy.args[1][0], data.actualSubobjectCyclicalTap); + }); + + test('output actual assertion value of an acyclical structure', assert => { + emitter.emit('testEnd', data.actualDuplicateAcyclic); + assert.equal(spy.args[1][0], data.actualDuplicateAcyclicTap); + }); + test('output expected assertion of undefined', assert => { emitter.emit('testEnd', data.expectedUndefinedTest); assert.true(spy.calledWithMatch(/^ {2}expected: undefined$/m)); @@ -125,6 +140,11 @@ QUnit.module('TapReporter', hooks => { assert.true(spy.calledWithMatch(/^ {2}expected: 0$/m)); }); + test('output expected assertion of a circular structure', assert => { + emitter.emit('testEnd', data.expectedCircularTest); + assert.true(spy.calledWithMatch(/^ {2}expected: \{\n {2}"a": "example",\n {2}"cycle": "\[Circular\]"\n\}$/m)); + }); + test('output the total number of tests', assert => { const summary = '1..6'; const passCount = '# pass 3';