diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0bd885273f12..e4567790f7d3 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -10,6 +10,7 @@ export * from './logger'; export * from './memo'; export * from './misc'; export * from './node'; +export * from './normalize'; export * from './object'; export * from './path'; export * from './promisebuffer'; diff --git a/packages/utils/src/normalize.ts b/packages/utils/src/normalize.ts new file mode 100644 index 000000000000..5dfd51df2630 --- /dev/null +++ b/packages/utils/src/normalize.ts @@ -0,0 +1,229 @@ +import { isPrimitive, isSyntheticEvent } from './is'; +import { memoBuilder, MemoFunc } from './memo'; +import { getWalkSource } from './object'; +import { getFunctionName } from './stacktrace'; + +type UnknownMaybeWithToJson = unknown & { toJSON?: () => string }; + +/** + * Recursively normalizes the given object. + * + * - Creates a copy to prevent original input mutation + * - Skips non-enumerable properties + * - When stringifying, calls `toJSON` if implemented + * - Removes circular references + * - Translates non-serializable values (`undefined`/`NaN`/functions) to serializable format + * - Translates known global objects/classes to a string representations + * - Takes care of `Error` object serialization + * - Optionally limits depth of final output + * - Optionally limits number of properties/elements included in any single object/array + * + * @param input The object to be normalized. + * @param depth The max depth to which to normalize the object. (Anything deeper stringified whole.) + * @param maxProperties The max number of elements or properties to be included in any single array or + * object in the normallized output.. + * @returns A normalized version of the object, or `"**non-serializable**"` if any errors are thrown during normalization. + */ +export function normalize(input: unknown, depth: number = +Infinity, maxProperties: number = +Infinity): any { + try { + // since we're at the outermost level, there is no key + return walk('', input as UnknownMaybeWithToJson, depth, maxProperties); + } catch (_oO) { + return '**non-serializable**'; + } +} + +/** JSDoc */ +export function normalizeToSize( + object: { [key: string]: any }, + // Default Node.js REPL depth + depth: number = 3, + // 100kB, as 200kB is max payload size, so half sounds reasonable + maxSize: number = 100 * 1024, +): T { + const serialized = normalize(object, depth); + + if (jsonSize(serialized) > maxSize) { + return normalizeToSize(object, depth - 1, maxSize); + } + + return serialized as T; +} + +/** + * Walks an object to perform a normalization on it + * + * @param key of object that's walked in current iteration + * @param value object to be walked + * @param depth Optional number indicating how deep should walking be performed + * @param maxProperties Optional maximum number of properties/elements included in any single object/array + * @param memo Optional Memo class handling decycling + */ +export function walk( + key: string, + value: UnknownMaybeWithToJson, + depth: number = +Infinity, + maxProperties: number = +Infinity, + memo: MemoFunc = memoBuilder(), +): unknown { + const [memoize, unmemoize] = memo; + + // If we reach the maximum depth, serialize whatever is left + if (depth === 0) { + return serializeValue(value); + } + + // If value implements `toJSON` method, call it and return early + if (value !== null && value !== undefined && typeof value.toJSON === 'function') { + return value.toJSON(); + } + + // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a + // pass-through. If what comes back is a primitive (either because it's been stringified or because it was primitive + // all along), we're done. + const serializable = makeSerializable(value, key); + if (isPrimitive(serializable)) { + return serializable; + } + + // Create source that we will use for the next iteration. It will either be an objectified error object (`Error` type + // with extracted key:value pairs) or the input itself. + const source = getWalkSource(value); + + // Create an accumulator that will act as a parent for all future itterations of that branch + const acc: { [key: string]: any } = Array.isArray(value) ? [] : {}; + + // If we already walked that branch, bail out, as it's circular reference + if (memoize(value)) { + return '[Circular ~]'; + } + + let propertyCount = 0; + // Walk all keys of the source + for (const innerKey in source) { + // Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration. + if (!Object.prototype.hasOwnProperty.call(source, innerKey)) { + continue; + } + + if (propertyCount >= maxProperties) { + acc[innerKey] = '[MaxProperties ~]'; + break; + } + + propertyCount += 1; + + // Recursively walk through all the child nodes + const innerValue: UnknownMaybeWithToJson = source[innerKey]; + acc[innerKey] = walk(innerKey, innerValue, depth - 1, maxProperties, memo); + } + + // Once walked through all the branches, remove the parent from memo storage + unmemoize(value); + + // Return accumulated values + return acc; +} + +/** + * Transform any non-primitive, BigInt, or Symbol-type value into a string. Acts as a no-op on strings, numbers, + * booleans, null, and undefined. + * + * @param value The value to stringify + * @returns For non-primitive, BigInt, and Symbol-type values, a string denoting the value's type, type and value, or + * type and `description` property, respectively. For non-BigInt, non-Symbol primitives, returns the original value, + * unchanged. + */ +function serializeValue(value: any): any { + // Node.js REPL notation + if (typeof value === 'string') { + return value; + } + + const type = Object.prototype.toString.call(value); + if (type === '[object Object]') { + return '[Object]'; + } + if (type === '[object Array]') { + return '[Array]'; + } + + // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a + // pass-through. + const serializable = makeSerializable(value); + return isPrimitive(serializable) ? serializable : type; +} + +/** + * makeSerializable() + * + * Takes unserializable input and make it serializer-friendly. + * + * Handles globals, functions, `undefined`, `NaN`, and other non-serializable values. + */ +function makeSerializable(value: T, key?: any): T | string { + if (key === 'domain' && value && typeof value === 'object' && (value as unknown as { _events: any })._events) { + return '[Domain]'; + } + + if (key === 'domainEmitter') { + return '[DomainEmitter]'; + } + + if (typeof (global as any) !== 'undefined' && (value as unknown) === global) { + return '[Global]'; + } + + // It's safe to use `window` and `document` here in this manner, as we are asserting using `typeof` first + // which won't throw if they are not present. + + // eslint-disable-next-line no-restricted-globals + if (typeof (window as any) !== 'undefined' && (value as unknown) === window) { + return '[Window]'; + } + + // eslint-disable-next-line no-restricted-globals + if (typeof (document as any) !== 'undefined' && (value as unknown) === document) { + return '[Document]'; + } + + // React's SyntheticEvent thingy + if (isSyntheticEvent(value)) { + return '[SyntheticEvent]'; + } + + if (typeof value === 'number' && value !== value) { + return '[NaN]'; + } + + if (value === void 0) { + return '[undefined]'; + } + + if (typeof value === 'function') { + return `[Function: ${getFunctionName(value)}]`; + } + + // symbols and bigints are considered primitives by TS, but aren't natively JSON-serilaizable + + if (typeof value === 'symbol') { + return `[${String(value)}]`; + } + + if (typeof value === 'bigint') { + return `[BigInt: ${String(value)}]`; + } + + return value; +} + +/** Calculates bytes size of input string */ +function utf8Length(value: string): number { + // eslint-disable-next-line no-bitwise + return ~-encodeURI(value).split(/%..|./).length; +} + +/** Calculates bytes size of input object */ +function jsonSize(value: any): number { + return utf8Length(JSON.stringify(value)); +} diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 4c9d9de2da88..9ab104bfcc89 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -3,9 +3,7 @@ import { ExtendedError, WrappedFunction } from '@sentry/types'; import { htmlTreeAsString } from './browser'; -import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive, isSyntheticEvent } from './is'; -import { memoBuilder, MemoFunc } from './memo'; -import { getFunctionName } from './stacktrace'; +import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive } from './is'; import { truncate } from './string'; /** @@ -99,7 +97,7 @@ export function urlEncode(object: { [key: string]: any }): string { * * @param value Initial source that we have to transform in order for it to be usable by the serializer */ -function getWalkSource(value: any): { +export function getWalkSource(value: any): { [key: string]: any; } { if (isError(value)) { @@ -180,231 +178,6 @@ function getWalkSource(value: any): { }; } -/** Calculates bytes size of input string */ -function utf8Length(value: string): number { - // eslint-disable-next-line no-bitwise - return ~-encodeURI(value).split(/%..|./).length; -} - -/** Calculates bytes size of input object */ -function jsonSize(value: any): number { - return utf8Length(JSON.stringify(value)); -} - -/** JSDoc */ -export function normalizeToSize( - object: { [key: string]: any }, - // Default Node.js REPL depth - depth: number = 3, - // 100kB, as 200kB is max payload size, so half sounds reasonable - maxSize: number = 100 * 1024, -): T { - const serialized = normalize(object, depth); - - if (jsonSize(serialized) > maxSize) { - return normalizeToSize(object, depth - 1, maxSize); - } - - return serialized as T; -} - -/** - * Transform any non-primitive, BigInt, or Symbol-type value into a string. Acts as a no-op on strings, numbers, - * booleans, null, and undefined. - * - * @param value The value to stringify - * @returns For non-primitive, BigInt, and Symbol-type values, a string denoting the value's type, type and value, or - * type and `description` property, respectively. For non-BigInt, non-Symbol primitives, returns the original value, - * unchanged. - */ -function serializeValue(value: any): any { - // Node.js REPL notation - if (typeof value === 'string') { - return value; - } - - const type = Object.prototype.toString.call(value); - if (type === '[object Object]') { - return '[Object]'; - } - if (type === '[object Array]') { - return '[Array]'; - } - - // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a - // pass-through. - const serializable = makeSerializable(value); - return isPrimitive(serializable) ? serializable : type; -} - -/** - * makeSerializable() - * - * Takes unserializable input and make it serializer-friendly. - * - * Handles globals, functions, `undefined`, `NaN`, and other non-serializable values. - */ -function makeSerializable(value: T, key?: any): T | string { - if (key === 'domain' && value && typeof value === 'object' && (value as unknown as { _events: any })._events) { - return '[Domain]'; - } - - if (key === 'domainEmitter') { - return '[DomainEmitter]'; - } - - if (typeof (global as any) !== 'undefined' && (value as unknown) === global) { - return '[Global]'; - } - - // It's safe to use `window` and `document` here in this manner, as we are asserting using `typeof` first - // which won't throw if they are not present. - - // eslint-disable-next-line no-restricted-globals - if (typeof (window as any) !== 'undefined' && (value as unknown) === window) { - return '[Window]'; - } - - // eslint-disable-next-line no-restricted-globals - if (typeof (document as any) !== 'undefined' && (value as unknown) === document) { - return '[Document]'; - } - - // React's SyntheticEvent thingy - if (isSyntheticEvent(value)) { - return '[SyntheticEvent]'; - } - - if (typeof value === 'number' && value !== value) { - return '[NaN]'; - } - - if (value === void 0) { - return '[undefined]'; - } - - if (typeof value === 'function') { - return `[Function: ${getFunctionName(value)}]`; - } - - // symbols and bigints are considered primitives by TS, but aren't natively JSON-serilaizable - - if (typeof value === 'symbol') { - return `[${String(value)}]`; - } - - if (typeof value === 'bigint') { - return `[BigInt: ${String(value)}]`; - } - - return value; -} - -type UnknownMaybeWithToJson = unknown & { toJSON?: () => string }; - -/** - * Walks an object to perform a normalization on it - * - * @param key of object that's walked in current iteration - * @param value object to be walked - * @param depth Optional number indicating how deep should walking be performed - * @param maxProperties Optional maximum number of properties/elements included in any single object/array - * @param memo Optional Memo class handling decycling - */ -export function walk( - key: string, - value: UnknownMaybeWithToJson, - depth: number = +Infinity, - maxProperties: number = +Infinity, - memo: MemoFunc = memoBuilder(), -): unknown { - const [memoize, unmemoize] = memo; - - // If we reach the maximum depth, serialize whatever is left - if (depth === 0) { - return serializeValue(value); - } - - // If value implements `toJSON` method, call it and return early - if (value !== null && value !== undefined && typeof value.toJSON === 'function') { - return value.toJSON(); - } - - // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a - // pass-through. If what comes back is a primitive (either because it's been stringified or because it was primitive - // all along), we're done. - const serializable = makeSerializable(value, key); - if (isPrimitive(serializable)) { - return serializable; - } - - // Create source that we will use for the next iteration. It will either be an objectified error object (`Error` type - // with extracted key:value pairs) or the input itself. - const source = getWalkSource(value); - - // Create an accumulator that will act as a parent for all future itterations of that branch - const acc: { [key: string]: any } = Array.isArray(value) ? [] : {}; - - // If we already walked that branch, bail out, as it's circular reference - if (memoize(value)) { - return '[Circular ~]'; - } - - let propertyCount = 0; - // Walk all keys of the source - for (const innerKey in source) { - // Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration. - if (!Object.prototype.hasOwnProperty.call(source, innerKey)) { - continue; - } - - if (propertyCount >= maxProperties) { - acc[innerKey] = '[MaxProperties ~]'; - break; - } - - propertyCount += 1; - - // Recursively walk through all the child nodes - const innerValue: UnknownMaybeWithToJson = source[innerKey]; - acc[innerKey] = walk(innerKey, innerValue, depth - 1, maxProperties, memo); - } - - // Once walked through all the branches, remove the parent from memo storage - unmemoize(value); - - // Return accumulated values - return acc; -} - -/** - * Recursively normalizes the given object. - * - * - Creates a copy to prevent original input mutation - * - Skips non-enumerable properties - * - When stringifying, calls `toJSON` if implemented - * - Removes circular references - * - Translates non-serializable values (`undefined`/`NaN`/functions) to serializable format - * - Translates known global objects/classes to a string representations - * - Takes care of `Error` object serialization - * - Optionally limits depth of final output - * - Optionally limits number of properties/elements included in any single object/array - * - * @param input The object to be normalized. - * @param depth The max depth to which to normalize the object. (Anything deeper stringified whole.) - * @param maxProperties The max number of elements or properties to be included in any single array or - * object in the normallized output.. - * @returns A normalized version of the object, or `"**non-serializable**"` if any errors are thrown during normalization. - */ -export function normalize(input: unknown, depth: number = +Infinity, maxProperties: number = +Infinity): any { - try { - // since we're at the outermost level, there is no key - return walk('', input as UnknownMaybeWithToJson, depth, maxProperties); - } catch (_oO) { - return '**non-serializable**'; - } -} - /** * Given any captured exception, extract its keys and create a sorted * and truncated list that will be used inside the event message. diff --git a/packages/utils/test/normalize.test.ts b/packages/utils/test/normalize.test.ts new file mode 100644 index 000000000000..5e2a3f41a979 --- /dev/null +++ b/packages/utils/test/normalize.test.ts @@ -0,0 +1,489 @@ +/** + * @jest-environment jsdom + */ + +import * as isModule from '../src/is'; +import { normalize } from '../src/normalize'; +import { testOnlyIfNodeVersionAtLeast } from './testutils'; + +describe('normalize()', () => { + describe('acts as a pass-through for simple-cases', () => { + test('return same value for simple input', () => { + expect(normalize('foo')).toEqual('foo'); + expect(normalize(42)).toEqual(42); + expect(normalize(true)).toEqual(true); + expect(normalize(null)).toEqual(null); + }); + + test('return same object or arrays for referenced inputs', () => { + expect(normalize({ foo: 'bar' })).toEqual({ foo: 'bar' }); + expect(normalize([42])).toEqual([42]); + }); + }); + + describe('getWalkSource()', () => { + test('extracts extra properties from error objects', () => { + const obj = new Error('Wubba Lubba Dub Dub') as any; + obj.reason = new TypeError("I'm pickle Riiick!"); + obj.extra = 'some extra prop'; + + obj.stack = 'x'; + obj.reason.stack = 'x'; + + // IE 10/11 + delete obj.description; + delete obj.reason.description; + + expect(normalize(obj)).toEqual({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + stack: 'x', + reason: { + message: "I'm pickle Riiick!", + name: 'TypeError', + stack: 'x', + }, + extra: 'some extra prop', + }); + }); + + testOnlyIfNodeVersionAtLeast(8)('extracts data from `Event` objects', () => { + const isElement = jest.spyOn(isModule, 'isElement').mockReturnValue(true); + const getAttribute = () => undefined; + + const parkElement = { tagName: 'PARK', getAttribute }; + const treeElement = { tagName: 'TREE', parentNode: parkElement, getAttribute }; + const squirrelElement = { tagName: 'SQUIRREL', parentNode: treeElement, getAttribute }; + + const chaseEvent = new Event('chase'); + Object.defineProperty(chaseEvent, 'target', { value: squirrelElement }); + Object.defineProperty(chaseEvent, 'currentTarget', { value: parkElement }); + Object.defineProperty(chaseEvent, 'wagging', { value: true, enumerable: false }); + + expect(normalize(chaseEvent)).toEqual({ + currentTarget: 'park', + isTrusted: false, + target: 'park > tree > squirrel', + type: 'chase', + // notice that `wagging` isn't included because it's not enumerable and not one of the ones we specifically extract + }); + + isElement.mockRestore(); + }); + }); + + describe('decycles cyclical structures', () => { + test('circular objects', () => { + const obj = { name: 'Alice' } as any; + obj.self = obj; + expect(normalize(obj)).toEqual({ name: 'Alice', self: '[Circular ~]' }); + }); + + test('circular objects with intermediaries', () => { + const obj = { name: 'Alice' } as any; + obj.identity = { self: obj }; + expect(normalize(obj)).toEqual({ name: 'Alice', identity: { self: '[Circular ~]' } }); + }); + + test('deep circular objects', () => { + const obj = { name: 'Alice', child: { name: 'Bob' } } as any; + obj.child.self = obj.child; + expect(normalize(obj)).toEqual({ + name: 'Alice', + child: { name: 'Bob', self: '[Circular ~]' }, + }); + }); + + test('deep circular objects with intermediaries', () => { + const obj = { name: 'Alice', child: { name: 'Bob' } } as any; + obj.child.identity = { self: obj.child }; + expect(normalize(obj)).toEqual({ + name: 'Alice', + child: { name: 'Bob', identity: { self: '[Circular ~]' } }, + }); + }); + + test('circular objects in an array', () => { + const obj = { name: 'Alice' } as any; + obj.self = [obj, obj]; + expect(normalize(obj)).toEqual({ + name: 'Alice', + self: ['[Circular ~]', '[Circular ~]'], + }); + }); + + test('deep circular objects in an array', () => { + const obj = { + name: 'Alice', + children: [{ name: 'Bob' }, { name: 'Eve' }], + } as any; + obj.children[0].self = obj.children[0]; + obj.children[1].self = obj.children[1]; + expect(normalize(obj)).toEqual({ + name: 'Alice', + children: [ + { name: 'Bob', self: '[Circular ~]' }, + { name: 'Eve', self: '[Circular ~]' }, + ], + }); + }); + + test('circular arrays', () => { + // eslint-disable-next-line @typescript-eslint/ban-types + const obj: object[] = []; + obj.push(obj); + obj.push(obj); + expect(normalize(obj)).toEqual(['[Circular ~]', '[Circular ~]']); + }); + + test('circular arrays with intermediaries', () => { + // eslint-disable-next-line @typescript-eslint/ban-types + const obj: object[] = []; + obj.push({ name: 'Alice', self: obj }); + obj.push({ name: 'Bob', self: obj }); + expect(normalize(obj)).toEqual([ + { name: 'Alice', self: '[Circular ~]' }, + { name: 'Bob', self: '[Circular ~]' }, + ]); + }); + + test('repeated objects in objects', () => { + const obj = {} as any; + const alice = { name: 'Alice' }; + obj.alice1 = alice; + obj.alice2 = alice; + expect(normalize(obj)).toEqual({ + alice1: { name: 'Alice' }, + alice2: { name: 'Alice' }, + }); + }); + + test('repeated objects in arrays', () => { + const alice = { name: 'Alice' }; + const obj = [alice, alice]; + expect(normalize(obj)).toEqual([{ name: 'Alice' }, { name: 'Alice' }]); + }); + + test('error objects with circular references', () => { + const obj = new Error('Wubba Lubba Dub Dub') as any; + obj.reason = obj; + + obj.stack = 'x'; + obj.reason.stack = 'x'; + + // IE 10/11 + delete obj.description; + + expect(normalize(obj)).toEqual({ + message: 'Wubba Lubba Dub Dub', + name: 'Error', + stack: 'x', + reason: '[Circular ~]', + }); + }); + }); + + describe('dont mutate and skip non-enumerables', () => { + test('simple object', () => { + const circular = { + foo: 1, + } as any; + circular.bar = circular; + + const normalized = normalize(circular); + expect(normalized).toEqual({ + foo: 1, + bar: '[Circular ~]', + }); + + expect(circular.bar).toBe(circular); + expect(normalized).not.toBe(circular); + }); + + test('complex object', () => { + const circular = { + foo: 1, + } as any; + circular.bar = [ + { + baz: circular, + }, + circular, + ]; + circular.qux = circular.bar[0].baz; + + const normalized = normalize(circular); + expect(normalized).toEqual({ + bar: [ + { + baz: '[Circular ~]', + }, + '[Circular ~]', + ], + foo: 1, + qux: '[Circular ~]', + }); + + expect(circular.bar[0].baz).toBe(circular); + expect(circular.bar[1]).toBe(circular); + expect(circular.qux).toBe(circular.bar[0].baz); + expect(normalized).not.toBe(circular); + }); + + test('object with non-enumerable properties', () => { + const circular = { + foo: 1, + } as any; + circular.bar = circular; + circular.baz = { + one: 1337, + }; + Object.defineProperty(circular, 'qux', { + enumerable: true, + value: circular, + }); + Object.defineProperty(circular, 'quaz', { + enumerable: false, + value: circular, + }); + Object.defineProperty(circular.baz, 'two', { + enumerable: false, + value: circular, + }); + + expect(normalize(circular)).toEqual({ + bar: '[Circular ~]', + baz: { + one: 1337, + }, + foo: 1, + qux: '[Circular ~]', + }); + }); + }); + + describe('calls toJSON if implemented', () => { + test('primitive values', () => { + const a = new Number(1) as any; + a.toJSON = () => 10; + const b = new String('2') as any; + b.toJSON = () => '20'; + expect(normalize(a)).toEqual(10); + expect(normalize(b)).toEqual('20'); + }); + + test('objects, arrays and classes', () => { + const a = Object.create({}); + a.toJSON = () => 1; + function B(): void { + /* no-empty */ + } + B.prototype.toJSON = () => 2; + const c: any = []; + c.toJSON = () => 3; + // @ts-ignore target lacks a construct signature + expect(normalize([{ a }, { b: new B() }, c])).toEqual([{ a: 1 }, { b: 2 }, 3]); + }); + }); + + describe('changes unserializeable/global values/classes to its string representation', () => { + test('primitive values', () => { + expect(normalize(undefined)).toEqual('[undefined]'); + expect(normalize(NaN)).toEqual('[NaN]'); + }); + + test('functions', () => { + expect( + normalize(() => { + /* no-empty */ + }), + ).toEqual('[Function: ]'); + const foo = () => { + /* no-empty */ + }; + expect(normalize(foo)).toEqual('[Function: foo]'); + }); + + test('primitive values in objects/arrays', () => { + expect(normalize(['foo', 42, undefined, NaN])).toEqual(['foo', 42, '[undefined]', '[NaN]']); + expect( + normalize({ + foo: 42, + bar: undefined, + baz: NaN, + }), + ).toEqual({ + foo: 42, + bar: '[undefined]', + baz: '[NaN]', + }); + }); + + test('primitive values in deep objects/arrays', () => { + expect(normalize(['foo', 42, [[undefined]], [NaN]])).toEqual(['foo', 42, [['[undefined]']], ['[NaN]']]); + expect( + normalize({ + foo: 42, + bar: { + baz: { + quz: undefined, + }, + }, + wat: { + no: NaN, + }, + }), + ).toEqual({ + foo: 42, + bar: { + baz: { + quz: '[undefined]', + }, + }, + wat: { + no: '[NaN]', + }, + }); + }); + + test('known Classes like Reacts SyntheticEvents', () => { + const obj = { + foo: { + nativeEvent: 'wat', + preventDefault: 'wat', + stopPropagation: 'wat', + }, + }; + expect(normalize(obj)).toEqual({ + foo: '[SyntheticEvent]', + }); + }); + }); + + describe('can limit object to depth', () => { + test('single level', () => { + const obj = { + foo: [], + }; + + expect(normalize(obj, 1)).toEqual({ + foo: '[Array]', + }); + }); + + test('two levels', () => { + const obj = { + foo: [1, 2, []], + }; + + expect(normalize(obj, 2)).toEqual({ + foo: [1, 2, '[Array]'], + }); + }); + + test('multiple levels with various inputs', () => { + const obj = { + foo: { + bar: { + baz: 1, + qux: [ + { + rick: 'morty', + }, + ], + }, + }, + bar: 1, + baz: [ + { + something: 'else', + fn: () => { + /* no-empty */ + }, + }, + ], + }; + + expect(normalize(obj, 3)).toEqual({ + bar: 1, + baz: [ + { + something: 'else', + fn: '[Function: fn]', + }, + ], + foo: { + bar: { + baz: 1, + qux: '[Array]', + }, + }, + }); + }); + }); + + describe('can limit max properties', () => { + test('object', () => { + const obj = { + nope: 'here', + foo: { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + }, + after: 'more', + }; + + expect(normalize(obj, 10, 5)).toEqual({ + nope: 'here', + foo: { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: '[MaxProperties ~]', + }, + after: 'more', + }); + }); + + test('array', () => { + const obj = { + nope: 'here', + foo: new Array(100).fill('s'), + after: 'more', + }; + + expect(normalize(obj, 10, 5)).toEqual({ + nope: 'here', + foo: ['s', 's', 's', 's', 's', '[MaxProperties ~]'], + after: 'more', + }); + }); + }); + + test('normalizes value on every iteration of decycle and takes care of things like Reacts SyntheticEvents', () => { + const obj = { + foo: { + nativeEvent: 'wat', + preventDefault: 'wat', + stopPropagation: 'wat', + }, + baz: NaN, + qux: function qux(): void { + /* no-empty */ + }, + }; + const result = normalize(obj); + expect(result).toEqual({ + foo: '[SyntheticEvent]', + baz: '[NaN]', + qux: '[Function: qux]', + }); + }); +}); diff --git a/packages/utils/test/object.test.ts b/packages/utils/test/object.test.ts index b0b103683978..e4fc4428ae04 100644 --- a/packages/utils/test/object.test.ts +++ b/packages/utils/test/object.test.ts @@ -2,15 +2,7 @@ * @jest-environment jsdom */ -import * as isModule from '../src/is'; -import { - dropUndefinedKeys, - extractExceptionKeysForMessage, - fill, - normalize, - objectify, - urlEncode, -} from '../src/object'; +import { dropUndefinedKeys, extractExceptionKeysForMessage, fill, objectify, urlEncode } from '../src/object'; import { testOnlyIfNodeVersionAtLeast } from './testutils'; describe('fill()', () => { @@ -117,488 +109,6 @@ describe('urlEncode()', () => { }); }); -describe('normalize()', () => { - describe('acts as a pass-through for simple-cases', () => { - test('return same value for simple input', () => { - expect(normalize('foo')).toEqual('foo'); - expect(normalize(42)).toEqual(42); - expect(normalize(true)).toEqual(true); - expect(normalize(null)).toEqual(null); - }); - - test('return same object or arrays for referenced inputs', () => { - expect(normalize({ foo: 'bar' })).toEqual({ foo: 'bar' }); - expect(normalize([42])).toEqual([42]); - }); - }); - - describe('getWalkSource()', () => { - test('extracts extra properties from error objects', () => { - const obj = new Error('Wubba Lubba Dub Dub') as any; - obj.reason = new TypeError("I'm pickle Riiick!"); - obj.extra = 'some extra prop'; - - obj.stack = 'x'; - obj.reason.stack = 'x'; - - // IE 10/11 - delete obj.description; - delete obj.reason.description; - - expect(normalize(obj)).toEqual({ - message: 'Wubba Lubba Dub Dub', - name: 'Error', - stack: 'x', - reason: { - message: "I'm pickle Riiick!", - name: 'TypeError', - stack: 'x', - }, - extra: 'some extra prop', - }); - }); - - testOnlyIfNodeVersionAtLeast(8)('extracts data from `Event` objects', () => { - const isElement = jest.spyOn(isModule, 'isElement').mockReturnValue(true); - const getAttribute = () => undefined; - - const parkElement = { tagName: 'PARK', getAttribute }; - const treeElement = { tagName: 'TREE', parentNode: parkElement, getAttribute }; - const squirrelElement = { tagName: 'SQUIRREL', parentNode: treeElement, getAttribute }; - - const chaseEvent = new Event('chase'); - Object.defineProperty(chaseEvent, 'target', { value: squirrelElement }); - Object.defineProperty(chaseEvent, 'currentTarget', { value: parkElement }); - Object.defineProperty(chaseEvent, 'wagging', { value: true, enumerable: false }); - - expect(normalize(chaseEvent)).toEqual({ - currentTarget: 'park', - isTrusted: false, - target: 'park > tree > squirrel', - type: 'chase', - // notice that `wagging` isn't included because it's not enumerable and not one of the ones we specifically extract - }); - - isElement.mockRestore(); - }); - }); - - describe('decycles cyclical structures', () => { - test('circular objects', () => { - const obj = { name: 'Alice' } as any; - obj.self = obj; - expect(normalize(obj)).toEqual({ name: 'Alice', self: '[Circular ~]' }); - }); - - test('circular objects with intermediaries', () => { - const obj = { name: 'Alice' } as any; - obj.identity = { self: obj }; - expect(normalize(obj)).toEqual({ name: 'Alice', identity: { self: '[Circular ~]' } }); - }); - - test('deep circular objects', () => { - const obj = { name: 'Alice', child: { name: 'Bob' } } as any; - obj.child.self = obj.child; - expect(normalize(obj)).toEqual({ - name: 'Alice', - child: { name: 'Bob', self: '[Circular ~]' }, - }); - }); - - test('deep circular objects with intermediaries', () => { - const obj = { name: 'Alice', child: { name: 'Bob' } } as any; - obj.child.identity = { self: obj.child }; - expect(normalize(obj)).toEqual({ - name: 'Alice', - child: { name: 'Bob', identity: { self: '[Circular ~]' } }, - }); - }); - - test('circular objects in an array', () => { - const obj = { name: 'Alice' } as any; - obj.self = [obj, obj]; - expect(normalize(obj)).toEqual({ - name: 'Alice', - self: ['[Circular ~]', '[Circular ~]'], - }); - }); - - test('deep circular objects in an array', () => { - const obj = { - name: 'Alice', - children: [{ name: 'Bob' }, { name: 'Eve' }], - } as any; - obj.children[0].self = obj.children[0]; - obj.children[1].self = obj.children[1]; - expect(normalize(obj)).toEqual({ - name: 'Alice', - children: [ - { name: 'Bob', self: '[Circular ~]' }, - { name: 'Eve', self: '[Circular ~]' }, - ], - }); - }); - - test('circular arrays', () => { - // eslint-disable-next-line @typescript-eslint/ban-types - const obj: object[] = []; - obj.push(obj); - obj.push(obj); - expect(normalize(obj)).toEqual(['[Circular ~]', '[Circular ~]']); - }); - - test('circular arrays with intermediaries', () => { - // eslint-disable-next-line @typescript-eslint/ban-types - const obj: object[] = []; - obj.push({ name: 'Alice', self: obj }); - obj.push({ name: 'Bob', self: obj }); - expect(normalize(obj)).toEqual([ - { name: 'Alice', self: '[Circular ~]' }, - { name: 'Bob', self: '[Circular ~]' }, - ]); - }); - - test('repeated objects in objects', () => { - const obj = {} as any; - const alice = { name: 'Alice' }; - obj.alice1 = alice; - obj.alice2 = alice; - expect(normalize(obj)).toEqual({ - alice1: { name: 'Alice' }, - alice2: { name: 'Alice' }, - }); - }); - - test('repeated objects in arrays', () => { - const alice = { name: 'Alice' }; - const obj = [alice, alice]; - expect(normalize(obj)).toEqual([{ name: 'Alice' }, { name: 'Alice' }]); - }); - - test('error objects with circular references', () => { - const obj = new Error('Wubba Lubba Dub Dub') as any; - obj.reason = obj; - - obj.stack = 'x'; - obj.reason.stack = 'x'; - - // IE 10/11 - delete obj.description; - - expect(normalize(obj)).toEqual({ - message: 'Wubba Lubba Dub Dub', - name: 'Error', - stack: 'x', - reason: '[Circular ~]', - }); - }); - }); - - describe('dont mutate and skip non-enumerables', () => { - test('simple object', () => { - const circular = { - foo: 1, - } as any; - circular.bar = circular; - - const normalized = normalize(circular); - expect(normalized).toEqual({ - foo: 1, - bar: '[Circular ~]', - }); - - expect(circular.bar).toBe(circular); - expect(normalized).not.toBe(circular); - }); - - test('complex object', () => { - const circular = { - foo: 1, - } as any; - circular.bar = [ - { - baz: circular, - }, - circular, - ]; - circular.qux = circular.bar[0].baz; - - const normalized = normalize(circular); - expect(normalized).toEqual({ - bar: [ - { - baz: '[Circular ~]', - }, - '[Circular ~]', - ], - foo: 1, - qux: '[Circular ~]', - }); - - expect(circular.bar[0].baz).toBe(circular); - expect(circular.bar[1]).toBe(circular); - expect(circular.qux).toBe(circular.bar[0].baz); - expect(normalized).not.toBe(circular); - }); - - test('object with non-enumerable properties', () => { - const circular = { - foo: 1, - } as any; - circular.bar = circular; - circular.baz = { - one: 1337, - }; - Object.defineProperty(circular, 'qux', { - enumerable: true, - value: circular, - }); - Object.defineProperty(circular, 'quaz', { - enumerable: false, - value: circular, - }); - Object.defineProperty(circular.baz, 'two', { - enumerable: false, - value: circular, - }); - - expect(normalize(circular)).toEqual({ - bar: '[Circular ~]', - baz: { - one: 1337, - }, - foo: 1, - qux: '[Circular ~]', - }); - }); - }); - - describe('calls toJSON if implemented', () => { - test('primitive values', () => { - const a = new Number(1) as any; - a.toJSON = () => 10; - const b = new String('2') as any; - b.toJSON = () => '20'; - expect(normalize(a)).toEqual(10); - expect(normalize(b)).toEqual('20'); - }); - - test('objects, arrays and classes', () => { - const a = Object.create({}); - a.toJSON = () => 1; - function B(): void { - /* no-empty */ - } - B.prototype.toJSON = () => 2; - const c: any = []; - c.toJSON = () => 3; - // @ts-ignore target lacks a construct signature - expect(normalize([{ a }, { b: new B() }, c])).toEqual([{ a: 1 }, { b: 2 }, 3]); - }); - }); - - describe('changes unserializeable/global values/classes to its string representation', () => { - test('primitive values', () => { - expect(normalize(undefined)).toEqual('[undefined]'); - expect(normalize(NaN)).toEqual('[NaN]'); - }); - - test('functions', () => { - expect( - normalize(() => { - /* no-empty */ - }), - ).toEqual('[Function: ]'); - const foo = () => { - /* no-empty */ - }; - expect(normalize(foo)).toEqual('[Function: foo]'); - }); - - test('primitive values in objects/arrays', () => { - expect(normalize(['foo', 42, undefined, NaN])).toEqual(['foo', 42, '[undefined]', '[NaN]']); - expect( - normalize({ - foo: 42, - bar: undefined, - baz: NaN, - }), - ).toEqual({ - foo: 42, - bar: '[undefined]', - baz: '[NaN]', - }); - }); - - test('primitive values in deep objects/arrays', () => { - expect(normalize(['foo', 42, [[undefined]], [NaN]])).toEqual(['foo', 42, [['[undefined]']], ['[NaN]']]); - expect( - normalize({ - foo: 42, - bar: { - baz: { - quz: undefined, - }, - }, - wat: { - no: NaN, - }, - }), - ).toEqual({ - foo: 42, - bar: { - baz: { - quz: '[undefined]', - }, - }, - wat: { - no: '[NaN]', - }, - }); - }); - - test('known Classes like Reacts SyntheticEvents', () => { - const obj = { - foo: { - nativeEvent: 'wat', - preventDefault: 'wat', - stopPropagation: 'wat', - }, - }; - expect(normalize(obj)).toEqual({ - foo: '[SyntheticEvent]', - }); - }); - }); - - describe('can limit object to depth', () => { - test('single level', () => { - const obj = { - foo: [], - }; - - expect(normalize(obj, 1)).toEqual({ - foo: '[Array]', - }); - }); - - test('two levels', () => { - const obj = { - foo: [1, 2, []], - }; - - expect(normalize(obj, 2)).toEqual({ - foo: [1, 2, '[Array]'], - }); - }); - - test('multiple levels with various inputs', () => { - const obj = { - foo: { - bar: { - baz: 1, - qux: [ - { - rick: 'morty', - }, - ], - }, - }, - bar: 1, - baz: [ - { - something: 'else', - fn: () => { - /* no-empty */ - }, - }, - ], - }; - - expect(normalize(obj, 3)).toEqual({ - bar: 1, - baz: [ - { - something: 'else', - fn: '[Function: fn]', - }, - ], - foo: { - bar: { - baz: 1, - qux: '[Array]', - }, - }, - }); - }); - }); - - describe('can limit max properties', () => { - test('object', () => { - const obj = { - nope: 'here', - foo: { - one: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: 6, - seven: 7, - }, - after: 'more', - }; - - expect(normalize(obj, 10, 5)).toEqual({ - nope: 'here', - foo: { - one: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: '[MaxProperties ~]', - }, - after: 'more', - }); - }); - - test('array', () => { - const obj = { - nope: 'here', - foo: new Array(100).fill('s'), - after: 'more', - }; - - expect(normalize(obj, 10, 5)).toEqual({ - nope: 'here', - foo: ['s', 's', 's', 's', 's', '[MaxProperties ~]'], - after: 'more', - }); - }); - }); - - test('normalizes value on every iteration of decycle and takes care of things like Reacts SyntheticEvents', () => { - const obj = { - foo: { - nativeEvent: 'wat', - preventDefault: 'wat', - stopPropagation: 'wat', - }, - baz: NaN, - qux: function qux(): void { - /* no-empty */ - }, - }; - const result = normalize(obj); - expect(result).toEqual({ - foo: '[SyntheticEvent]', - baz: '[NaN]', - qux: '[Function: qux]', - }); - }); -}); - describe('extractExceptionKeysForMessage()', () => { test('no keys', () => { expect(extractExceptionKeysForMessage({}, 10)).toEqual('[object has no keys]');