From c38f467af9a1c89b81f3d66d5379196ce96c6891 Mon Sep 17 00:00:00 2001 From: Zachary Mulgrew Date: Sat, 20 Feb 2021 19:29:10 -0800 Subject: [PATCH] Reporter: Handle objects with cycles Updating TapReporter with the ability to handle objects with circular references. This is needed for proper stringification of actual and expected values that contain cycles. Fixes https://github.com/js-reporters/js-reporters/issues/104. Closes https://github.com/js-reporters/js-reporters/pull/130. --- lib/reporters/TapReporter.js | 44 ++++++++++++- test/fixtures/unit.js | 119 +++++++++++++++++++++++++++++++++++ test/unit/tap-reporter.js | 20 ++++++ 3 files changed, 182 insertions(+), 1 deletion(-) 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';