diff --git a/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js b/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js new file mode 100644 index 00000000000..5961532b462 --- /dev/null +++ b/Libraries/LogBox/Data/__tests__/LogBoxLog-test.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @format + * @flow + */ + +'use strict'; + +import type {StackFrame} from '../../../Core/NativeExceptionsManager'; + +jest.mock('../LogBoxSymbolication', () => { + return {__esModule: true, symbolicate: jest.fn(), deleteStack: jest.fn()}; +}); + +function getLogBoxLog() { + return new (require('../LogBoxLog')).default( + {content: '...', substitutions: []}, + createStack(['A', 'B', 'C']), + 'Message category...', + [{component: 'LogBoxLog', location: 'LogBoxLog.js:1'}], + true, + ); +} + +function getLogBoxSymbolication(): {| + symbolicate: JestMockFn< + $ReadOnlyArray>, + Promise>, + >, +|} { + return (require('../LogBoxSymbolication'): any); +} + +const createStack = methodNames => + methodNames.map(methodName => ({ + column: null, + file: 'file://path/to/file.js', + lineNumber: 1, + methodName, + })); + +describe('LogBoxLog', () => { + beforeEach(() => { + jest.resetModules(); + + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => + createStack(stack.map(frame => `S(${frame.methodName})`)), + ); + }); + + it('creates a LogBoxLog object', () => { + const log = getLogBoxLog(); + + expect(log.message).toEqual({content: '...', substitutions: []}); + expect(log.stack).toEqual(createStack(['A', 'B', 'C'])); + expect(log.category).toEqual('Message category...'); + expect(log.componentStack).toEqual([ + {component: 'LogBoxLog', location: 'LogBoxLog.js:1'}, + ]); + expect(log.ignored).toEqual(true); + }); + + it('increments LogBoxLog count', () => { + const log = getLogBoxLog(); + + expect(log.count).toEqual(1); + + log.incrementCount(); + + expect(log.count).toEqual(2); + }); + + it('starts without a symbolicated stack', () => { + const log = getLogBoxLog(); + + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'NONE', + }); + }); + + it('updates when symbolication is in progress', () => { + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.symbolicate(callback); + + expect(callback.mock.calls.length).toBe(1); + expect(log.symbolicated).toEqual({ + error: null, + stack: null, + status: 'PENDING', + }); + }); + + it('updates when symbolication finishes', () => { + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.symbolicate(callback); + + jest.runAllTicks(); + + expect(callback.mock.calls.length).toBe(2); + expect(log.symbolicated).toEqual({ + error: null, + stack: createStack(['S(A)', 'S(B)', 'S(C)']), + status: 'COMPLETE', + }); + }); + + it('updates when symbolication fails', () => { + const error = new Error('...'); + getLogBoxSymbolication().symbolicate.mockImplementation(async stack => { + throw error; + }); + + const log = getLogBoxLog(); + + const callback = jest.fn(); + log.symbolicate(callback); + + jest.runAllTicks(); + + expect(callback.mock.calls.length).toBe(2); + expect(log.symbolicated).toEqual({ + error, + stack: null, + status: 'FAILED', + }); + }); + + it('does not update aborted requests', () => { + const log = getLogBoxLog(); + + const callback = jest.fn(); + const request = log.symbolicate(callback); + request.abort(); + + jest.runAllTicks(); + + expect(callback.mock.calls.length).toBe(1); + }); +}); diff --git a/Libraries/LogBox/Data/__tests__/LogBoxLogData-test.js b/Libraries/LogBox/Data/__tests__/LogBoxLogData-test.js new file mode 100644 index 00000000000..a00b50d2b3f --- /dev/null +++ b/Libraries/LogBox/Data/__tests__/LogBoxLogData-test.js @@ -0,0 +1,308 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @format + * @flow strict-local + */ + +'use strict'; +jest.mock('../../../Core/Devtools/parseErrorStack', () => { + return {__esModule: true, default: jest.fn(() => [])}; +}); + +const LogBoxLogData = require('../LogBoxLogData'); + +const registry = () => { + const observer = jest.fn(); + LogBoxLogData.observe(observer).unsubscribe(); + return observer.mock.calls[0][0]; +}; + +const filteredRegistry = () => { + const observer = jest.fn(); + LogBoxLogData.observe(observer).unsubscribe(); + return observer.mock.calls[0][0].filter(log => !log.ignored); +}; + +const observe = () => { + const observer = jest.fn(); + return { + observer, + subscription: LogBoxLogData.observe(observer), + }; +}; + +describe('LogBoxLogData', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('adds and dismisses logs', () => { + LogBoxLogData.add({args: ['A']}); + + expect(registry().length).toBe(1); + expect(registry()[0]).toBeDefined(); + + LogBoxLogData.dismiss(registry()[0]); + expect(registry().length).toBe(0); + expect(registry()[0]).toBeUndefined(); + }); + + it('clears all logs', () => { + LogBoxLogData.add({args: ['A']}); + LogBoxLogData.add({args: ['B']}); + LogBoxLogData.add({args: ['C']}); + + expect(registry().length).toBe(3); + + LogBoxLogData.clear(); + expect(registry().length).toBe(0); + }); + + it('keeps logs in chronological order', () => { + LogBoxLogData.add({args: ['A']}); + LogBoxLogData.add({args: ['B']}); + LogBoxLogData.add({args: ['C']}); + + let logs = registry(); + expect(logs.length).toBe(3); + expect(logs[0].category).toEqual('A'); + expect(logs[1].category).toEqual('B'); + expect(logs[2].category).toEqual('C'); + + LogBoxLogData.add({args: ['A']}); + + // Expect `A` to be added to the end of the registry. + logs = registry(); + expect(logs.length).toBe(4); + expect(logs[0].category).toEqual('A'); + expect(logs[1].category).toEqual('B'); + expect(logs[2].category).toEqual('C'); + expect(logs[3].category).toEqual('A'); + }); + + it('increments the count of previous log with matching category', () => { + LogBoxLogData.add({args: ['A']}); + LogBoxLogData.add({args: ['B']}); + + let logs = registry(); + expect(logs.length).toBe(2); + expect(logs[0].category).toEqual('A'); + expect(logs[0].count).toBe(1); + expect(logs[1].category).toEqual('B'); + expect(logs[1].count).toBe(1); + + LogBoxLogData.add({args: ['B']}); + + // Expect `B` to be rolled into the last log. + logs = registry(); + expect(logs.length).toBe(2); + expect(logs[0].category).toEqual('A'); + expect(logs[0].count).toBe(1); + expect(logs[1].category).toEqual('B'); + expect(logs[1].count).toBe(2); + }); + + it('ignores logs matching patterns', () => { + LogBoxLogData.add({args: ['A!']}); + LogBoxLogData.add({args: ['B?']}); + LogBoxLogData.add({args: ['C!']}); + expect(filteredRegistry().length).toBe(3); + + LogBoxLogData.addIgnorePatterns(['!']); + expect(filteredRegistry().length).toBe(1); + + LogBoxLogData.addIgnorePatterns(['?']); + expect(filteredRegistry().length).toBe(0); + }); + + it('ignores logs matching regexs or pattern', () => { + LogBoxLogData.add({args: ['There are 4 dogs']}); + LogBoxLogData.add({args: ['There are 3 cats']}); + LogBoxLogData.add({args: ['There are H cats']}); + expect(filteredRegistry().length).toBe(3); + + LogBoxLogData.addIgnorePatterns(['dogs']); + expect(filteredRegistry().length).toBe(2); + + LogBoxLogData.addIgnorePatterns([/There are \d+ cats/]); + expect(filteredRegistry().length).toBe(1); + + LogBoxLogData.addIgnorePatterns(['cats']); + expect(filteredRegistry().length).toBe(0); + }); + + it('ignores all logs when disabled', () => { + LogBoxLogData.add({args: ['A!']}); + LogBoxLogData.add({args: ['B?']}); + LogBoxLogData.add({args: ['C!']}); + expect(registry().length).toBe(3); + + LogBoxLogData.setDisabled(true); + expect(registry().length).toBe(0); + + LogBoxLogData.setDisabled(false); + expect(registry().length).toBe(3); + }); + + it('groups consecutive logs by format string categories', () => { + LogBoxLogData.add({args: ['%s', 'A']}); + expect(registry().length).toBe(1); + expect(registry()[0].count).toBe(1); + + LogBoxLogData.add({args: ['%s', 'B']}); + expect(registry().length).toBe(1); + expect(registry()[0].count).toBe(2); + + LogBoxLogData.add({args: ['A']}); + expect(registry().length).toBe(2); + expect(registry()[1].count).toBe(1); + + LogBoxLogData.add({args: ['B']}); + expect(registry().length).toBe(3); + expect(registry()[2].count).toBe(1); + }); + + it('groups warnings with consideration for arguments', () => { + LogBoxLogData.add({args: ['A', 'B']}); + expect(registry().length).toBe(1); + expect(registry()[0].count).toBe(1); + + LogBoxLogData.add({args: ['A', 'B']}); + expect(registry().length).toBe(1); + expect(registry()[0].count).toBe(2); + + LogBoxLogData.add({args: ['A', 'C']}); + expect(registry().length).toBe(2); + expect(registry()[1].count).toBe(1); + + LogBoxLogData.add({args: ['%s', 'A', 'A']}); + expect(registry().length).toBe(3); + expect(registry()[2].count).toBe(1); + + LogBoxLogData.add({args: ['%s', 'B', 'A']}); + expect(registry().length).toBe(3); + expect(registry()[2].count).toBe(2); + + LogBoxLogData.add({args: ['%s', 'B', 'B']}); + expect(registry().length).toBe(4); + expect(registry()[3].count).toBe(1); + }); + + it('ignores logs starting with "(ADVICE)"', () => { + LogBoxLogData.add({args: ['(ADVICE) ...']}); + expect(registry().length).toBe(0); + }); + + it('does not ignore logs formatted to start with "(ADVICE)"', () => { + LogBoxLogData.add({args: ['%s ...', '(ADVICE)']}); + expect(registry().length).toBe(1); + }); + + it('immediately updates new observers', () => { + const {observer} = observe(); + + expect(observer.mock.calls.length).toBe(1); + expect(observer.mock.calls[0][0]).toEqual(registry()); + }); + + it('sends batched updates asynchronously', () => { + const {observer} = observe(); + expect(observer.mock.calls.length).toBe(1); + + LogBoxLogData.add({args: ['A']}); + LogBoxLogData.add({args: ['B']}); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + }); + + it('stops sending updates to unsubscribed observers', () => { + const {observer, subscription} = observe(); + subscription.unsubscribe(); + + expect(observer.mock.calls.length).toBe(1); + expect(observer.mock.calls[0][0]).toEqual(registry()); + }); + + it('updates observers when a log is added or dismissed', () => { + const {observer} = observe(); + expect(observer.mock.calls.length).toBe(1); + + LogBoxLogData.add({args: ['A']}); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + + const lastLog = observer.mock.calls[1][0][0]; + LogBoxLogData.dismiss(lastLog); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + + // Does nothing when category does not exist. + LogBoxLogData.dismiss(lastLog); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + }); + + it('updates observers when cleared', () => { + const {observer} = observe(); + expect(observer.mock.calls.length).toBe(1); + + LogBoxLogData.add({args: ['A']}); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + + LogBoxLogData.clear(); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + + // Does nothing when already empty. + LogBoxLogData.clear(); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + }); + + it('updates observers when an ignore pattern is added', () => { + const {observer} = observe(); + expect(observer.mock.calls.length).toBe(1); + + LogBoxLogData.addIgnorePatterns(['?']); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + + LogBoxLogData.addIgnorePatterns(['!']); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + + // Does nothing for an existing ignore pattern. + LogBoxLogData.addIgnorePatterns(['!']); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + }); + + it('updates observers when disabled or enabled', () => { + const {observer} = observe(); + expect(observer.mock.calls.length).toBe(1); + + LogBoxLogData.setDisabled(true); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + + // Does nothing when already disabled. + LogBoxLogData.setDisabled(true); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(2); + + LogBoxLogData.setDisabled(false); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + + // Does nothing when already enabled. + LogBoxLogData.setDisabled(false); + jest.runAllImmediates(); + expect(observer.mock.calls.length).toBe(3); + }); +}); diff --git a/Libraries/LogBox/Data/__tests__/LogBoxLogParser-test.js b/Libraries/LogBox/Data/__tests__/LogBoxLogParser-test.js new file mode 100644 index 00000000000..9b21122bd0c --- /dev/null +++ b/Libraries/LogBox/Data/__tests__/LogBoxLogParser-test.js @@ -0,0 +1,160 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @format + * @flow strict-local + */ + +'use strict'; + +jest.mock('../../../Core/Devtools/parseErrorStack', () => { + return {__esModule: true, default: jest.fn(() => [])}; +}); + +const LogBoxLogParser = require('../LogBoxLogParser').default; + +describe('LogBoxLogParser', () => { + it('parses strings', () => { + expect(LogBoxLogParser({args: ['A']})).toEqual({ + componentStack: [], + stack: [], + category: 'A', + message: { + content: 'A', + substitutions: [], + }, + }); + }); + + it('parses strings with arguments', () => { + expect(LogBoxLogParser({args: ['A', 'B', 'C']})).toEqual({ + componentStack: [], + stack: [], + category: 'A B C', + message: { + content: 'A B C', + substitutions: [], + }, + }); + }); + + it('parses formatted strings', () => { + expect(LogBoxLogParser({args: ['%s', 'A']})).toEqual({ + componentStack: [], + stack: [], + category: '\ufeff%s', + message: { + content: 'A', + substitutions: [ + { + length: 1, + offset: 0, + }, + ], + }, + }); + }); + + it('parses formatted strings with insufficient arguments', () => { + expect(LogBoxLogParser({args: ['%s %s', 'A']})).toEqual({ + componentStack: [], + stack: [], + category: '\ufeff%s %s', + message: { + content: 'A %s', + substitutions: [ + { + length: 1, + offset: 0, + }, + { + length: 2, + offset: 2, + }, + ], + }, + }); + }); + + it('parses formatted strings with excess arguments', () => { + expect(LogBoxLogParser({args: ['%s', 'A', 'B']})).toEqual({ + componentStack: [], + stack: [], + category: '\ufeff%s B', + message: { + content: 'A B', + substitutions: [ + { + length: 1, + offset: 0, + }, + ], + }, + }); + }); + + it('treats "%s" in arguments as literals', () => { + expect(LogBoxLogParser({args: ['%s', '%s', 'A']})).toEqual({ + componentStack: [], + stack: [], + category: '\ufeff%s A', + message: { + content: '%s A', + substitutions: [ + { + length: 2, + offset: 0, + }, + ], + }, + }); + }); + + it('detects a component stack in the second argument', () => { + expect( + LogBoxLogParser({ + args: [ + 'Some kind of message', + '\n in MyComponent (at filename.js:1)\n in MyOtherComponent (at filename2.js:1)', + ], + }), + ).toEqual({ + componentStack: [ + {component: 'MyComponent', location: 'filename.js:1'}, + {component: 'MyOtherComponent', location: 'filename2.js:1'}, + ], + stack: [], + category: 'Some kind of message', + message: { + content: 'Some kind of message', + substitutions: [], + }, + }); + }); + + it('does not detect a component stack in the third argument', () => { + expect( + LogBoxLogParser({ + args: [ + 'Some kind of message', + 'Some other kind of message', + '\n in MyComponent (at filename.js:1)\n in MyOtherComponent (at filename2.js:1)', + ], + }), + ).toEqual({ + componentStack: [], + stack: [], + category: + 'Some kind of message Some other kind of message \n in MyComponent (at filename.js:1)\n in MyOtherComponent (at filename2.js:1)', + message: { + content: + 'Some kind of message Some other kind of message \n in MyComponent (at filename.js:1)\n in MyOtherComponent (at filename2.js:1)', + substitutions: [], + }, + }); + }); +}); diff --git a/Libraries/LogBox/Data/__tests__/LogBoxSymbolication-test.js b/Libraries/LogBox/Data/__tests__/LogBoxSymbolication-test.js new file mode 100644 index 00000000000..36dca4a6818 --- /dev/null +++ b/Libraries/LogBox/Data/__tests__/LogBoxSymbolication-test.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @format + * @flow + */ + +'use strict'; + +import type {StackFrame} from '../../../Core/NativeExceptionsManager'; + +jest.mock('../../../Core/Devtools/symbolicateStackTrace'); + +const LogBoxSymbolication = require('../LogBoxSymbolication'); + +const symbolicateStackTrace: JestMockFn< + $ReadOnlyArray>, + Promise>, +> = (require('../../../Core/Devtools/symbolicateStackTrace'): any); + +const createStack = methodNames => + methodNames.map(methodName => ({ + column: null, + file: 'file://path/to/file.js', + lineNumber: 1, + methodName, + })); + +describe('LogBoxSymbolication', () => { + beforeEach(() => { + jest.resetModules(); + symbolicateStackTrace.mockImplementation(async stack => stack); + }); + + it('symbolicates different stacks', () => { + LogBoxSymbolication.symbolicate(createStack(['A', 'B', 'C'])); + LogBoxSymbolication.symbolicate(createStack(['D', 'E', 'F'])); + + expect(symbolicateStackTrace.mock.calls.length).toBe(2); + }); + + it('batch symbolicates equivalent stacks', () => { + const stack = createStack(['A', 'B', 'C']); + LogBoxSymbolication.symbolicate(stack); + LogBoxSymbolication.symbolicate(stack); + + expect(symbolicateStackTrace.mock.calls.length).toBe(1); + }); +}); diff --git a/Libraries/LogBox/__tests__/LogBox-test.js b/Libraries/LogBox/__tests__/LogBox-test.js new file mode 100644 index 00000000000..bd51e49f0ff --- /dev/null +++ b/Libraries/LogBox/__tests__/LogBox-test.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+react_native + * @format + * @flow + */ + +'use strict'; + +const LogBox = require('../LogBox'); +const LogBoxLogData = require('../Data/LogBoxLogData'); + +declare var console: any; + +describe('LogBox', () => { + const {error, warn} = console; + + beforeEach(() => { + jest.resetModules(); + console.error = jest.fn(); + console.warn = jest.fn(); + }); + + afterEach(() => { + LogBox.uninstall(); + console.error = error; + console.warn = warn; + }); + + it('can set `disableLogBox` after installing', () => { + expect(console.disableLogBox).toBe(undefined); + + LogBox.install(); + + expect(console.disableLogBox).toBe(false); + expect(LogBoxLogData.isDisabled()).toBe(false); + + console.disableLogBox = true; + + expect(console.disableLogBox).toBe(true); + expect(LogBoxLogData.isDisabled()).toBe(true); + }); + + it('can set `disableLogBox` before installing', () => { + expect(console.disableLogBox).toBe(undefined); + + console.disableLogBox = true; + LogBox.install(); + + expect(console.disableLogBox).toBe(true); + expect(LogBoxLogData.isDisabled()).toBe(true); + }); + + it('registers warnings', () => { + jest.mock('../Data/LogBoxLogData'); + + LogBox.install(); + + expect(LogBoxLogData.add).not.toBeCalled(); + console.warn('...'); + expect(LogBoxLogData.add).toBeCalled(); + }); + + it('registers errors beginning with "Warning: " as warnings', () => { + jest.mock('../Data/LogBoxLogData'); + + LogBox.install(); + + console.error('...'); + expect(LogBoxLogData.add).not.toBeCalled(); + + console.error('Warning: ...'); + expect(LogBoxLogData.add).toBeCalled(); + }); +});