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.
Closes #130.
  • Loading branch information
zackthehuman committed Feb 21, 2021
1 parent 53432e5 commit c38f467
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
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
119 changes: 119 additions & 0 deletions test/fixtures/unit.js
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 @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions test/unit/tap-reporter.js
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 c38f467

Please sign in to comment.