Skip to content
Permalink
Browse files
fix(core): improved ErrorSerialiser so that it works with cyclic data…
… structures

When an assertion relying on a cyclic data strucutre, such as Ensure.that(QUestion<ElementFinder>,
isPresent()) fails, the AssertionError thrown contains the ElementFinder object. Since that object
has circular references, trying to serialise it to string using StreamReporter would cause a
TypeError. This fix addresses that issue.
  • Loading branch information
jan-molak committed Oct 8, 2020
1 parent 39a175d commit 9309302d0ca7ec4bc27e414813a18c301cf3ef02
Show file tree
Hide file tree
Showing 10 changed files with 461 additions and 31 deletions.

Large diffs are not rendered by default.

@@ -1,5 +1,7 @@
import 'mocha';

import { AssertionError } from '../../src/errors';
import { ErrorSerialiser } from '../../src/io';
import { ErrorSerialiser, parse } from '../../src/io';
import { expect } from '../expect';

describe ('ErrorSerialiser', () => {
@@ -8,11 +10,11 @@ describe ('ErrorSerialiser', () => {
it('serialises an Error object to JSON', () => {
const e = new Error(`Something happened`);

expect(ErrorSerialiser.serialise(e)).to.deep.equal({
expect(ErrorSerialiser.serialise(e)).to.equal(JSON.stringify({
name: 'Error',
message: 'Something happened',
stack: e.stack,
});
message: 'Something happened',
}));
});

/** @test {ErrorSerialiser} */
@@ -23,11 +25,11 @@ describe ('ErrorSerialiser', () => {
' at Generator.next (<anonymous>)',
].join('\n');

const error = ErrorSerialiser.deserialise({
const error = ErrorSerialiser.deserialise(JSON.stringify({
name: 'Error',
message: 'Something happened',
stack,
});
}));

expect(error).to.be.instanceOf(Error);
expect(error.name).to.equal(`Error`);
@@ -41,8 +43,8 @@ describe ('ErrorSerialiser', () => {
error = new AssertionError(`Expected false to equal true`, true, false),
serialised = ErrorSerialiser.serialise(error);

expect(serialised.name).to.equal('AssertionError');
expect(serialised.message).to.equal('Expected false to equal true');
expect(parse(serialised).name).to.equal('AssertionError');
expect(parse(serialised).message).to.equal('Expected false to equal true');
});

/** @test {ErrorSerialiser} */
@@ -53,11 +55,11 @@ describe ('ErrorSerialiser', () => {
' at Generator.next (<anonymous>)',
].join('\n');

const error = ErrorSerialiser.deserialise({
const error = ErrorSerialiser.deserialise(JSON.stringify({
name: 'AssertionError',
message: 'Expected false to equal true',
stack,
});
}));

expect(error).to.be.instanceOf(AssertionError);
expect(error.name).to.equal(`AssertionError`);
@@ -0,0 +1,208 @@
import 'mocha';

import { given } from 'mocha-testdata';
import { parse, stringify } from '../../../src/io';
import { expect } from '../../expect';

describe('cycle', () => {

describe('when used with primitives', () => {

describe('stringify', () => {

given([
{ description: 'number', value: 1.5 },
{ description: 'string', value: 'hi' },
{ description: 'boolean', value: true },
{ description: 'null', value: null },
{ description: 'undefined', value: void 0 },
]).
it('behaves just like JSON.stringify', (value: any) => {
expect(stringify(value)).to.equal(JSON.stringify(value));
});
});

describe('parse', () => {

given([
{ description: 'number', value: '1.5', expected: 1.5 },
{ description: 'string', value: '"hi"', expected: 'hi' },
{ description: 'boolean', value: 'true', expected: true },
{ description: 'null', value: 'null', expected: null },
]).
it('behaves just like JSON.parse', ({ value, expected }) => {
expect(parse(value)).to.equal(JSON.parse(value));
expect(parse(value)).to.equal(expected);
});
});
});

describe('when used with acyclic objects', () => {

describe('stringify', () => {

given([
{ description: 'empty object', value: { } },
{ description: 'simple object', value: { name: 'Jan' } },
{ description: 'nested object', value: { l1: { l2: 'value'} } },
{ description: 'empty array', value: [] },
{ description: 'simple array', value: [ '1', 2, 3.0 ] },
{ description: 'nested array', value: [ [ '1' ], [['2']], '3' ] },
{ description: 'mixed', value: [{ values: [1, { name: 'Jan' }]}] },
]).
it('behaves just like JSON.stringify', (value: any) => {
expect(stringify(value)).to.equal(JSON.stringify(value));
});
});

describe('parse', () => {

given([
{ description: 'empty object', value: '{}', expected: { } },
{ description: 'simple object', value: '{"name":"Jan"}', expected: { name: 'Jan'} },
{ description: 'nested object', value: '{"l1":{"l2":"value"}}', expected: { l1: { l2: 'value'} } },
{ description: 'empty array', value: '[]', expected: [] },
{ description: 'simple array', value: '["1",2,3]', expected: [ '1', 2, 3.0 ] },
{ description: 'nested array', value: '[["1"],[["2"]],"3"]', expected: [ [ '1' ], [['2']], '3' ] },
{ description: 'mixed', value: '[{"values":[1,{"name":"Jan"}]}]', expected: [{ values: [1, { name: 'Jan' }]}] },
]).
it('behaves just like JSON.parse', ({ value, expected }) => {
expect(parse(value)).to.deep.equal(JSON.parse(value));
expect(parse(value)).to.deep.equal(expected);
});
});
});

describe('when used with cyclic objects', () => {

describe('JSON.stringify', () => {

it('should fail because of a circular reference in a round robin list', () => {
expect(() => JSON.stringify(roundRobin(2)))
.to.throw(TypeError, 'Converting circular structure to JSON');
});

it('should fail because of a circular reference in simple nested object', () => {
expect(() => JSON.stringify(simpleNestedObject()))
.to.throw(TypeError, 'Converting circular structure to JSON');
});

it('should fail because of a circular reference in complex nested object', () => {
expect(() => JSON.stringify(complexNestedObject()))
.to.throw(TypeError, 'Converting circular structure to JSON');
});

it('should fail because of a cycles in object with parallel references', () => {
expect(() => JSON.stringify(objectWithParallelReferences()))
.to.throw(TypeError, 'Converting circular structure to JSON');
});
});

describe('stringify', () => {
it('should serialise a round robin list data structure', () => {
expect(stringify(roundRobin(1)))
.to.equal('[{"prev":{"$ref":"$[0]"}}]');
});

it('should serialise a simple nested object', () => {
expect(stringify(simpleNestedObject()))
.to.equal('{"property":"value","self":{"$ref":"$"}}');
});

it('should serialise a complex nested object', () => {
expect(stringify(complexNestedObject()))
.to.equal('{"property":"value","another":{"property":"another value","self":{"$ref":"$[\\"another\\"]"}},"self":{"$ref":"$"}}');
});

it('should serialise an object with parallel references', () => {
expect(stringify(objectWithParallelReferences()))
.to.equal('{"property1":{"value":42,"sibling":{"$ref":"$[\\"property1\\"]"}},"property2":{"$ref":"$[\\"property1\\"]"}}');
});
});

describe('parse', () => {

it('should deserialise a round robin list data structure', () => {
expect(parse(stringify(roundRobin(1))))
.to.deep.equal(roundRobin(1));
});

it('should deserialise a simple nested object', () => {
expect(parse(stringify(simpleNestedObject())))
.to.deep.equal(simpleNestedObject());
});

it('should deserialise a complex nested object', () => {
expect(parse(stringify(complexNestedObject())))
.to.deep.equal(complexNestedObject());
});

it('should deserialise an object with parallel references', () => {
expect(parse(stringify(objectWithParallelReferences())))
.to.deep.equal(objectWithParallelReferences());
});
});

function roundRobin(segments: number) {
if (segments <= 0) {
return [];
}

const list = [];
let prev = null;

for (let i = 0; i < segments; ++i) {
prev = list[i] = { prev }
}

list[0].prev = list[segments - 1];

return list;
}

function simpleNestedObject() {

const sample = {
property: 'value',
self: undefined,
};
sample.self = sample;

return sample;
}

function complexNestedObject() {

const sample = {
property: 'value',
another: {
property: 'another value',
self: undefined,
},
self: undefined,
};
sample.another.self = sample.another;
sample.self = sample;

return sample;
}

function objectWithParallelReferences() {

const property = {
value: 42,
sibling: undefined,
}

const sample = {
property1: property,
property2: property,
};

sample.property1.sibling = sample.property2;
sample.property2.sibling = sample.property1;

return sample;
}
});
});
@@ -1,12 +1,12 @@
import { JSONObject } from 'tiny-types';
import { ErrorSerialiser, SerialisedError } from '../io';
import { ErrorSerialiser } from '../io';
import { CorrelationId, Timestamp } from '../model';
import { DomainEvent } from './DomainEvent';

export class AsyncOperationFailed extends DomainEvent {
static fromJSON(o: JSONObject) {
return new AsyncOperationFailed(
ErrorSerialiser.deserialise(o.error as SerialisedError),
ErrorSerialiser.deserialise(o.error as string),
CorrelationId.fromJSON(o.correlationId as string),
Timestamp.fromJSON(o.timestamp as string),
);
@@ -19,4 +19,12 @@ export class AsyncOperationFailed extends DomainEvent {
) {
super(timestamp);
}

toJSON() {
return {
correlationId: this.correlationId.toJSON(),
error: ErrorSerialiser.serialise(this.error),
timestamp: this.timestamp.toJSON(),
};
}
}
@@ -1,5 +1,6 @@
import { JSONObject } from 'tiny-types';
import * as serenitySpecificErrors from '../errors';
import { parse, stringify } from './json';

/**
*
@@ -36,15 +37,18 @@ export class ErrorSerialiser {
URIError,
];

static serialise(error: Error): SerialisedError {
// todo: serialise the cause map well
return Object.getOwnPropertyNames(error).reduce((serialised, key) => {
serialised[key] = error[key];
static serialise(error: Error): string {
const serialisedError = Object.getOwnPropertyNames(error).reduce((serialised, key) => {
serialised[key] = error[key]
return serialised;
}, { name: error.constructor.name || error.name }) as SerialisedError;

return stringify(serialisedError);
}

static deserialise<E extends Error>(serialisedError: SerialisedError): E {
static deserialise<E extends Error>(stringifiedError: string): E {
const serialisedError = parse(stringifiedError) as SerialisedError;

// todo: de-serialise the cause map well
const constructor = ErrorSerialiser.recognisedErrors.find(errorType => errorType.name === serialisedError.name) || Error;
const deserialised = Object.create(constructor.prototype);
@@ -66,6 +70,6 @@ export class ErrorSerialiser {

const [, name, message ] = lines[0].match(pattern);

return ErrorSerialiser.deserialise({ name, message, stack });
return ErrorSerialiser.deserialise(stringify({ name, message, stack }));
}
}
@@ -1,4 +1,5 @@
export * from './collections';
export * from './json';
export * from './AssertionReportDiffer';
export * from './commaSeparated';
export * from './ErrorSerialiser';

0 comments on commit 9309302

Please sign in to comment.