Skip to content

Commit

Permalink
Reporter: handle objects with cycles
Browse files Browse the repository at this point in the history
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 #104
  • Loading branch information
zackthehuman authored Feb 21, 2021
1 parent fff3df7 commit 8307cc4
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 1 deletion.
44 changes: 43 additions & 1 deletion lib/reporters/TapReporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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) {
Expand Down Expand Up @@ -116,7 +119,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 {
Expand Down
119 changes: 119 additions & 0 deletions test/fixtures/unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -195,6 +228,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({
Expand Down Expand Up @@ -223,6 +329,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: undefined,
Expand Down
20 changes: 20 additions & 0 deletions test/unit/tap-reporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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';
Expand Down

0 comments on commit 8307cc4

Please sign in to comment.