diff --git a/package.json b/package.json index 38ee5d4b1ef87..c1db37ad03b3d 100644 --- a/package.json +++ b/package.json @@ -238,6 +238,7 @@ "@types/bluebird": "^3.1.1", "@types/chance": "^1.0.0", "@types/classnames": "^2.2.3", + "@types/enzyme": "^3.1.12", "@types/eslint": "^4.16.2", "@types/execa": "^0.9.0", "@types/fetch-mock": "^5.12.2", diff --git a/packages/kbn-test-subj-selector/index.d.ts b/packages/kbn-test-subj-selector/index.d.ts new file mode 100644 index 0000000000000..91eda37f1e844 --- /dev/null +++ b/packages/kbn-test-subj-selector/index.d.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export default function(selector: string): string; diff --git a/packages/kbn-test-subj-selector/tsconfig.json b/packages/kbn-test-subj-selector/tsconfig.json new file mode 100644 index 0000000000000..3604f1004cf6c --- /dev/null +++ b/packages/kbn-test-subj-selector/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "index.d.ts" + ], +} diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 76d615b3a45e0..90c503ab5126b 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -17,12 +17,14 @@ * under the License. */ +import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformService } from './legacy_platform'; const MockLegacyPlatformService = jest.fn( function _MockLegacyPlatformService(this: any) { this.start = jest.fn(); + this.stop = jest.fn(); } ); jest.mock('./legacy_platform', () => ({ @@ -39,7 +41,19 @@ jest.mock('./injected_metadata', () => ({ InjectedMetadataService: MockInjectedMetadataService, })); +const mockFatalErrorsStartContract = {}; +const MockFatalErrorsService = jest.fn(function _MockFatalErrorsService( + this: any +) { + this.start = jest.fn().mockReturnValue(mockFatalErrorsStartContract); + this.add = jest.fn(); +}); +jest.mock('./fatal_errors', () => ({ + FatalErrorsService: MockFatalErrorsService, +})); + import { CoreSystem } from './core_system'; +jest.spyOn(CoreSystem.prototype, 'stop'); const defaultCoreSystemParams = { rootDomElement: null!, @@ -97,6 +111,44 @@ describe('constructor', () => { useLegacyTestHarness, }); }); + + it('passes injectedMetadata, rootDomElement, and a stopCoreSystem function to FatalErrorsService', () => { + const rootDomElement = { rootDomElement: true } as any; + const injectedMetadata = { injectedMetadata: true } as any; + + const coreSystem = new CoreSystem({ + ...defaultCoreSystemParams, + rootDomElement, + injectedMetadata, + }); + + expect(MockFatalErrorsService).toHaveBeenCalledTimes(1); + expect(MockFatalErrorsService).toHaveBeenLastCalledWith({ + rootDomElement, + injectedMetadata: expect.any(MockInjectedMetadataService), + stopCoreSystem: expect.any(Function), + }); + + const [{ stopCoreSystem }] = MockFatalErrorsService.mock.calls[0]; + + expect(coreSystem.stop).not.toHaveBeenCalled(); + stopCoreSystem(); + expect(coreSystem.stop).toHaveBeenCalled(); + }); +}); + +describe('#stop', () => { + it('call legacyPlatform.stop()', () => { + const coreSystem = new CoreSystem({ + ...defaultCoreSystemParams, + }); + + const legacyPlatformService = MockLegacyPlatformService.mock.instances[0]; + + expect(legacyPlatformService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(legacyPlatformService.stop).toHaveBeenCalled(); + }); }); describe('#start()', () => { @@ -115,12 +167,20 @@ describe('#start()', () => { expect(mockInstance.start).toHaveBeenCalledWith(); }); - it('calls lifecycleSystem#start()', () => { + it('calls fatalErrors#start()', () => { + startCore(); + const [mockInstance] = MockFatalErrorsService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith(); + }); + + it('calls legacyPlatform#start()', () => { startCore(); const [mockInstance] = MockLegacyPlatformService.mock.instances; expect(mockInstance.start).toHaveBeenCalledTimes(1); expect(mockInstance.start).toHaveBeenCalledWith({ injectedMetadata: mockInjectedMetadataStartContract, + fatalErrors: mockFatalErrorsStartContract, }); }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index cc659d74e01af..98bed9567e445 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -17,6 +17,7 @@ * under the License. */ +import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; @@ -34,6 +35,7 @@ interface Params { * platform the CoreSystem will get many more Services. */ export class CoreSystem { + private fatalErrors: FatalErrorsService; private injectedMetadata: InjectedMetadataService; private legacyPlatform: LegacyPlatformService; @@ -44,6 +46,14 @@ export class CoreSystem { injectedMetadata, }); + this.fatalErrors = new FatalErrorsService({ + rootDomElement, + injectedMetadata: this.injectedMetadata, + stopCoreSystem: () => { + this.stop(); + }, + }); + this.legacyPlatform = new LegacyPlatformService({ rootDomElement, requireLegacyFiles, @@ -52,8 +62,16 @@ export class CoreSystem { } public start() { - this.legacyPlatform.start({ - injectedMetadata: this.injectedMetadata.start(), - }); + try { + const injectedMetadata = this.injectedMetadata.start(); + const fatalErrors = this.fatalErrors.start(); + this.legacyPlatform.start({ injectedMetadata, fatalErrors }); + } catch (error) { + this.fatalErrors.add(error); + } + } + + public stop() { + this.legacyPlatform.stop(); } } diff --git a/src/core/public/fatal_errors/__snapshots__/fatal_errors_screen.test.tsx.snap b/src/core/public/fatal_errors/__snapshots__/fatal_errors_screen.test.tsx.snap new file mode 100644 index 0000000000000..1e280f1dedf07 --- /dev/null +++ b/src/core/public/fatal_errors/__snapshots__/fatal_errors_screen.test.tsx.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rendering render matches snapshot 1`] = ` + + + + + Clear your session + , + + Go back + , + ] + } + body={ +

+ Try refreshing the page. If that doesn't work, go back to the previous page or clear your session data. +

+ } + iconColor="danger" + iconType="alert" + title={ +

+ Something went wrong +

+ } + /> + + + Version: bar +Build: 123 +Error: foo + stack...foo.js:1:1 + + + + + Version: bar +Build: 123 +Error: bar + stack...bar.js:1:1 + + +
+
+
+`; diff --git a/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap b/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap new file mode 100644 index 0000000000000..841ba0f148aed --- /dev/null +++ b/src/core/public/fatal_errors/__snapshots__/fatal_errors_service.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#add() deletes all children of rootDomElement and renders into it: fatal error screen component 1`] = ` +Array [ + Array [ + , +
, + ], +] +`; + +exports[`#add() deletes all children of rootDomElement and renders into it: fatal error screen container 1`] = ` +
+
+
+`; diff --git a/src/core/public/fatal_errors/fatal_errors_screen.test.tsx b/src/core/public/fatal_errors/fatal_errors_screen.test.tsx new file mode 100644 index 0000000000000..184c0b544e265 --- /dev/null +++ b/src/core/public/fatal_errors/fatal_errors_screen.test.tsx @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// @ts-ignore EuiCallOut not available until we upgrade to EUI 3.1.0 +import { EuiCallOut } from '@elastic/eui'; +import testSubjSelector from '@kbn/test-subj-selector'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import * as Rx from 'rxjs'; + +import { FatalErrorsScreen } from './fatal_errors_screen'; + +const errorInfoFoo = { + message: 'foo', + stack: 'Error: foo\n stack...foo.js:1:1', +}; +const errorInfoBar = { + message: 'bar', + stack: 'Error: bar\n stack...bar.js:1:1', +}; + +const defaultProps = { + buildNumber: 123, + kibanaVersion: 'bar', + errorInfo$: Rx.of(errorInfoFoo, errorInfoBar), +}; + +const noop = () => { + // noop +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('reloading', () => { + it('refreshes the page if a `hashchange` event is emitted', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + const locationReloadSpy = jest.spyOn(window.location, 'reload').mockImplementation(noop); + + shallow(); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('hashchange', expect.any(Function), undefined); + + expect(locationReloadSpy).not.toHaveBeenCalled(); + const [, handler] = addEventListenerSpy.mock.calls[0]; + handler(); + expect(locationReloadSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('rendering', () => { + it('render matches snapshot', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('rerenders when errorInfo$ emits more errors', () => { + const errorInfo$ = new Rx.ReplaySubject(); + + const el = shallow(); + + expect(el.find(EuiCallOut)).toHaveLength(0); + + errorInfo$.next(errorInfoFoo); + el.update(); // allow setState() to cause a render + + expect(el.find(EuiCallOut)).toHaveLength(1); + + errorInfo$.next(errorInfoBar); + el.update(); // allow setState() to cause a render + + expect(el.find(EuiCallOut)).toHaveLength(2); + }); +}); + +describe('buttons', () => { + beforeAll(() => { + Object.assign(window, { + localStorage: { + clear: jest.fn(), + }, + sessionStorage: { + clear: jest.fn(), + }, + }); + }); + + afterAll(() => { + delete (window as any).localStorage; + delete (window as any).sessionStorage; + }); + + describe('"Clear your session"', () => { + it('clears localStorage, sessionStorage, the location.hash, and reloads the page', () => { + window.location.hash = '/foo/bar'; + jest.spyOn(window.location, 'reload').mockImplementation(noop); + + const el = mount(); + const button = el.find('button').find(testSubjSelector('clearSession')); + button.simulate('click'); + + expect(window.localStorage.clear).toHaveBeenCalled(); + expect(window.sessionStorage.clear).toHaveBeenCalled(); + expect(window.location.reload).toHaveBeenCalled(); + expect(window.location.hash).toBe(''); + }); + }); + + describe('"Go back"', () => { + it('calls window.history.back()', () => { + jest.spyOn(window.history, 'back').mockImplementation(noop); + + const el = mount(); + const button = el.find('button').find(testSubjSelector('goBack')); + button.simulate('click'); + + expect(window.history.back).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/public/fatal_errors/fatal_errors_screen.tsx b/src/core/public/fatal_errors/fatal_errors_screen.tsx new file mode 100644 index 0000000000000..a217fc7ee1575 --- /dev/null +++ b/src/core/public/fatal_errors/fatal_errors_screen.tsx @@ -0,0 +1,147 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + EuiButton, + EuiButtonEmpty, + // @ts-ignore EuiCallOut not available until we upgrade to EUI 3.1.0 + EuiCallOut, + // @ts-ignore EuiCodeBlock not available until we upgrade to EUI 3.1.0 + EuiCodeBlock, + // @ts-ignore EuiEmptyPrompt not available until we upgrade to EUI 3.1.0 + EuiEmptyPrompt, + EuiPage, + EuiPageBody, + EuiPageContent, +} from '@elastic/eui'; +import React from 'react'; +import * as Rx from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { ErrorInfo } from './get_error_info'; + +interface Props { + kibanaVersion: string; + buildNumber: number; + errorInfo$: Rx.Observable; +} + +interface State { + errors: ErrorInfo[]; +} + +export class FatalErrorsScreen extends React.Component { + public state: State = { + errors: [], + }; + + private subscription?: Rx.Subscription; + + public componentDidMount() { + this.subscription = Rx.merge( + // reload the page if hash-based navigation is attempted + Rx.fromEvent(window, 'hashchange').pipe( + tap(() => { + window.location.reload(); + }) + ), + + // consume error notifications and set them to the component state + this.props.errorInfo$.pipe( + tap(error => { + this.setState(state => ({ + ...state, + errors: [...state.errors, error], + })); + }) + ) + ).subscribe({ + error(error) { + // tslint:disable-next-line no-console + console.error('Uncaught error in fatal error screen internals', error); + }, + }); + } + + public componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = undefined; + } + } + + public render() { + return ( + + + + Something went wrong} + body={ +

+ Try refreshing the page. If that doesn't work, go back to the previous page or + clear your session data. +

+ } + actions={[ + + Clear your session + , + + Go back + , + ]} + /> + {this.state.errors.map((error, i) => ( + + + {`Version: ${this.props.kibanaVersion}` + + '\n' + + `Build: ${this.props.buildNumber}` + + '\n' + + (error.stack ? error.stack : '')} + + + ))} +
+
+
+ ); + } + + private onClickGoBack = (e: React.MouseEvent) => { + e.preventDefault(); + window.history.back(); + }; + + private onClickClearSession = (e: React.MouseEvent) => { + e.preventDefault(); + localStorage.clear(); + sessionStorage.clear(); + window.location.hash = ''; + window.location.reload(); + }; +} diff --git a/src/core/public/fatal_errors/fatal_errors_service.test.ts b/src/core/public/fatal_errors/fatal_errors_service.test.ts new file mode 100644 index 0000000000000..15859bc9cbc2d --- /dev/null +++ b/src/core/public/fatal_errors/fatal_errors_service.test.ts @@ -0,0 +1,128 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as Rx from 'rxjs'; + +expect.addSnapshotSerializer({ + test: val => val instanceof Rx.Observable, + print: () => `Rx.Observable`, +}); + +const mockRender = jest.fn(); +jest.mock('react-dom', () => { + return { + render: mockRender, + }; +}); + +import { FatalErrorsService } from './fatal_errors_service'; + +function setup() { + const rootDomElement = document.createElement('div'); + + const injectedMetadata = { + getKibanaBuildNumber: jest.fn().mockReturnValue('kibanaBuildNumber'), + getKibanaVersion: jest.fn().mockReturnValue('kibanaVersion'), + }; + + const stopCoreSystem = jest.fn(); + + return { + rootDomElement, + injectedMetadata, + stopCoreSystem, + fatalErrors: new FatalErrorsService({ + injectedMetadata: injectedMetadata as any, + rootDomElement, + stopCoreSystem, + }), + }; +} + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('#add()', () => { + it('calls stopCoreSystem() param', () => { + const { stopCoreSystem, fatalErrors } = setup(); + + expect(stopCoreSystem).not.toHaveBeenCalled(); + expect(() => { + fatalErrors.add(new Error('foo')); + }).toThrowError(); + expect(stopCoreSystem).toHaveBeenCalled(); + expect(stopCoreSystem).toHaveBeenCalledWith(); + }); + + it('deletes all children of rootDomElement and renders into it', () => { + const { fatalErrors, rootDomElement } = setup(); + + rootDomElement.innerHTML = ` +

Loading...

+
+ `; + + expect(mockRender).not.toHaveBeenCalled(); + expect(rootDomElement.children).toHaveLength(2); + expect(() => { + fatalErrors.add(new Error('foo')); + }).toThrowError(); + expect(rootDomElement).toMatchSnapshot('fatal error screen container'); + expect(mockRender.mock.calls).toMatchSnapshot('fatal error screen component'); + }); +}); + +describe('start.add()', () => { + it('exposes a function that passes its two arguments to fatalErrors.add()', () => { + const { fatalErrors } = setup(); + + jest.spyOn(fatalErrors, 'add').mockImplementation(() => { + /* noop */ + }); + + expect(fatalErrors.add).not.toHaveBeenCalled(); + const { add } = fatalErrors.start(); + add('foo', 'bar'); + expect(fatalErrors.add).toHaveBeenCalledTimes(1); + expect(fatalErrors.add).toHaveBeenCalledWith('foo', 'bar'); + }); +}); + +describe('start.get$()', () => { + it('provides info about the errors passed to fatalErrors.add()', () => { + const { fatalErrors } = setup(); + + const startContract = fatalErrors.start(); + + const onError = jest.fn(); + startContract.get$().subscribe(onError); + + expect(onError).not.toHaveBeenCalled(); + expect(() => { + fatalErrors.add(new Error('bar')); + }).toThrowError(); + + expect(onError).toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith({ + message: 'bar', + stack: expect.stringMatching(/Error: bar[\w\W]+fatal_errors_service\.test\.ts/), + }); + }); +}); diff --git a/src/core/public/fatal_errors/fatal_errors_service.tsx b/src/core/public/fatal_errors/fatal_errors_service.tsx new file mode 100644 index 0000000000000..3a7e821a368a6 --- /dev/null +++ b/src/core/public/fatal_errors/fatal_errors_service.tsx @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import * as Rx from 'rxjs'; +import { first, tap } from 'rxjs/operators'; + +import { InjectedMetadataService } from '../injected_metadata'; +import { FatalErrorsScreen } from './fatal_errors_screen'; +import { ErrorInfo, getErrorInfo } from './get_error_info'; + +export interface FatalErrorsParams { + rootDomElement: HTMLElement; + injectedMetadata: InjectedMetadataService; + stopCoreSystem: () => void; +} + +export class FatalErrorsService { + private readonly errorInfo$ = new Rx.ReplaySubject(); + + constructor(private params: FatalErrorsParams) { + this.errorInfo$.pipe(first(), tap(() => this.onFirstError())).subscribe({ + error: error => { + // tslint:disable-next-line no-console + console.error('Uncaught error in fatal error screen internals', error); + }, + }); + } + + public add = (error: Error | string, source?: string) => { + const errorInfo = getErrorInfo(error, source); + + this.errorInfo$.next(errorInfo); + + if (error instanceof Error) { + // make stack traces clickable by putting whole error in the console + // tslint:disable-next-line no-console + console.error(error); + } + + throw error; + }; + + public start() { + return { + add: this.add, + get$: () => { + return this.errorInfo$.asObservable(); + }, + }; + } + + private onFirstError() { + // stop the core systems so that things like the legacy platform are stopped + // and angular/react components are unmounted; + this.params.stopCoreSystem(); + + // delete all content in the rootDomElement + this.params.rootDomElement.textContent = ''; + + // create and mount a container for the + const container = document.createElement('div'); + this.params.rootDomElement.appendChild(container); + + render( + , + container + ); + } +} + +export type FatalErrorsStartContract = ReturnType; diff --git a/src/core/public/fatal_errors/get_error_info.test.ts b/src/core/public/fatal_errors/get_error_info.test.ts new file mode 100644 index 0000000000000..478b75ee5d2f7 --- /dev/null +++ b/src/core/public/fatal_errors/get_error_info.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getErrorInfo } from './get_error_info'; + +class StubEsError extends Error { + constructor(public resp: T) { + super('This is an elasticsearch error'); + Error.captureStackTrace(this, StubEsError); + } +} + +it('should prepend the `source` to the message', () => { + expect(getErrorInfo('error message', 'unit_test')).toEqual({ + message: 'unit_test: error message', + }); +}); + +it('should handle a simple string', () => { + expect(getErrorInfo('error message')).toEqual({ + message: 'error message', + }); +}); + +it('reads the message and stack from an Error object', () => { + const err = new Error('error message'); + expect(getErrorInfo(err)).toEqual({ + message: 'error message', + stack: expect.stringContaining(__filename), + }); +}); + +it('reads the root cause reason from elasticsearch errors', () => { + const err = new StubEsError({ + error: { + root_cause: [ + { + reason: 'I am the detailed message', + }, + ], + }, + }); + + expect(getErrorInfo(err, 'foo')).toEqual({ + message: 'foo: I am the detailed message', + stack: expect.stringContaining(__filename), + }); +}); + +it('should combine the root cause reasons if elasticsearch error has more than one', () => { + const err = new StubEsError({ + error: { + root_cause: [ + { + reason: 'I am the detailed message 1', + }, + { + reason: 'I am the detailed message 2', + }, + ], + }, + }); + + expect(getErrorInfo(err)).toEqual({ + message: 'I am the detailed message 1\nI am the detailed message 2', + stack: expect.stringContaining(__filename), + }); +}); + +it('should prepend the stack with the error message if it is not already there', () => { + const error = new Error('Foo'); + error.stack = 'bar.js:1:1\nbaz.js:2:1\n'; + + expect(getErrorInfo(error)).toEqual({ + message: 'Foo', + stack: 'Error: Foo\nbar.js:1:1\nbaz.js:2:1\n', + }); +}); + +it('should just return the stack if it already includes the message', () => { + const error = new Error('Foo'); + error.stack = 'Foo\n bar.js:1:1\n baz.js:2:1\n'; + + expect(getErrorInfo(error)).toEqual({ + message: 'Foo', + stack: 'Foo\n bar.js:1:1\n baz.js:2:1\n', + }); +}); diff --git a/src/core/public/fatal_errors/get_error_info.ts b/src/core/public/fatal_errors/get_error_info.ts new file mode 100644 index 0000000000000..fcaf7144ce6c0 --- /dev/null +++ b/src/core/public/fatal_errors/get_error_info.ts @@ -0,0 +1,78 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { inspect } from 'util'; + +/** + * Produce a string version of an error, + */ +function formatMessage(error: any) { + if (typeof error === 'string') { + return error; + } + + if (!error) { + // stringify undefined/null/0/whatever this falsy value is + return inspect(error); + } + + // handle es error response with `root_cause`s + if (error.resp && error.resp.error && error.resp.error.root_cause) { + return error.resp.error.root_cause.map((cause: { reason: string }) => cause.reason).join('\n'); + } + + // handle http response errors with error messages + if (error.body && typeof error.body.message === 'string') { + return error.body.message; + } + + // handle standard error objects with messages + if (error instanceof Error && error.message) { + return error.message; + } + + // everything else can just be serialized using util.inspect() + return inspect(error); +} + +/** + * Format the stack trace from a message so that it starts with the message, which + * some browsers do automatically and some don't + */ +function formatStack(err: Error) { + if (err.stack && !err.stack.includes(err.message)) { + return 'Error: ' + err.message + '\n' + err.stack; + } + + return err.stack; +} + +/** + * Produce a simple ErrorInfo object from some error and optional source, used for + * displaying error information on the fatal error screen + */ +export function getErrorInfo(error: any, source?: string) { + const prefix = source ? source + ': ' : ''; + return { + message: prefix + formatMessage(error), + stack: formatStack(error), + }; +} + +export type ErrorInfo = ReturnType; diff --git a/src/core/public/fatal_errors/index.ts b/src/core/public/fatal_errors/index.ts new file mode 100644 index 0000000000000..08360da97d735 --- /dev/null +++ b/src/core/public/fatal_errors/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { FatalErrorsStartContract, FatalErrorsService } from './fatal_errors_service'; diff --git a/src/core/public/injected_metadata/injected_metadata_service.test.ts b/src/core/public/injected_metadata/injected_metadata_service.test.ts index 59b3cc65db24e..bace5dd371194 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -19,23 +19,27 @@ import { InjectedMetadataService } from './injected_metadata_service'; -describe('#start()', () => { - it('deeply freezes its injectedMetadata param', () => { - const params = { - injectedMetadata: { foo: true } as any, - }; - - const injectedMetadata = new InjectedMetadataService(params); +describe('#getKibanaVersion', () => { + it('returns version from injectedMetadata', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + version: 'foo', + }, + } as any); - expect(() => { - params.injectedMetadata.foo = false; - }).not.toThrowError(); + expect(injectedMetadata.getKibanaVersion()).toBe('foo'); + }); +}); - injectedMetadata.start(); +describe('#getKibanaBuildNumber', () => { + it('returns buildNumber from injectedMetadata', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + buildNumber: 'foo', + }, + } as any); - expect(() => { - params.injectedMetadata.foo = true; - }).toThrowError(`read only property 'foo'`); + expect(injectedMetadata.getKibanaBuildNumber()).toBe('foo'); }); }); @@ -44,10 +48,29 @@ describe('start.getLegacyMetadata()', () => { const injectedMetadata = new InjectedMetadataService({ injectedMetadata: { legacyMetadata: 'foo', - } as any, - }); + }, + } as any); const contract = injectedMetadata.start(); expect(contract.getLegacyMetadata()).toBe('foo'); }); + + it('exposes frozen version of legacyMetadata', () => { + const injectedMetadata = new InjectedMetadataService({ + injectedMetadata: { + legacyMetadata: { + foo: true, + }, + }, + } as any); + + const legacyMetadata = injectedMetadata.start().getLegacyMetadata(); + expect(legacyMetadata).toEqual({ + foo: true, + }); + expect(() => { + // @ts-ignore TS knows this shouldn't be possible + legacyMetadata.foo = false; + }).toThrowError(); + }); }); diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 2921d97fca66d..e756d99b1f854 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -21,6 +21,8 @@ import { deepFreeze } from './deep_freeze'; export interface InjectedMetadataParams { injectedMetadata: { + version: string; + buildNumber: number; legacyMetadata: { [key: string]: any; }; @@ -34,17 +36,25 @@ export interface InjectedMetadataParams { * and is read from the DOM in most cases. */ export class InjectedMetadataService { + private state = deepFreeze(this.params.injectedMetadata); + constructor(private readonly params: InjectedMetadataParams) {} public start() { - const state = deepFreeze(this.params.injectedMetadata); - return { - getLegacyMetadata() { - return state.legacyMetadata; + getLegacyMetadata: () => { + return this.state.legacyMetadata; }, }; } + + public getKibanaVersion() { + return this.state.version; + } + + public getKibanaBuildNumber() { + return this.state.buildNumber; + } } export type InjectedMetadataStartContract = ReturnType; diff --git a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap new file mode 100644 index 0000000000000..59165d049c786 --- /dev/null +++ b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#stop() destroys the angular scope and empties the rootDomElement if angular is bootstraped to rootDomElement 1`] = ` +
+`; + +exports[`#stop() does nothing if angular was not bootstrapped to rootDomElement 1`] = ` +
+ + +

+ foo +

+ + +

+ bar +

+ + +
+`; diff --git a/src/core/public/legacy_platform/legacy_platform_service.test.ts b/src/core/public/legacy_platform/legacy_platform_service.test.ts index 3927d7f56149d..071fe1d2ef417 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.test.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.test.ts @@ -17,6 +17,8 @@ * under the License. */ +import angular from 'angular'; + const mockLoadOrder: string[] = []; const mockUiMetadataInit = jest.fn(); @@ -43,8 +45,18 @@ jest.mock('ui/test_harness', () => { }; }); +const mockFatalErrorInit = jest.fn(); +jest.mock('ui/notify/fatal_error', () => { + mockLoadOrder.push('ui/notify/fatal_error'); + return { + __newPlatformInit__: mockFatalErrorInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; +const fatalErrorsStartContract = {} as any; + const injectedMetadataStartContract = { getLegacyMetadata: jest.fn(), }; @@ -74,6 +86,7 @@ describe('#start()', () => { }); legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, }); @@ -81,6 +94,20 @@ describe('#start()', () => { expect(mockUiMetadataInit).toHaveBeenCalledWith(legacyMetadata); }); + it('passes fatalErrors service to ui/notify/fatal_errors', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, + injectedMetadata: injectedMetadataStartContract, + }); + + expect(mockFatalErrorInit).toHaveBeenCalledTimes(1); + expect(mockFatalErrorInit).toHaveBeenCalledWith(fatalErrorsStartContract); + }); + describe('useLegacyTestHarness = false', () => { it('passes the rootDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ @@ -88,6 +115,7 @@ describe('#start()', () => { }); legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, }); @@ -104,6 +132,7 @@ describe('#start()', () => { }); legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, }); @@ -124,10 +153,16 @@ describe('#start()', () => { expect(mockLoadOrder).toEqual([]); legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, }); - expect(mockLoadOrder).toEqual(['ui/metadata', 'ui/chrome', 'legacy files']); + expect(mockLoadOrder).toEqual([ + 'ui/metadata', + 'ui/notify/fatal_error', + 'ui/chrome', + 'legacy files', + ]); }); }); @@ -141,11 +176,64 @@ describe('#start()', () => { expect(mockLoadOrder).toEqual([]); legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, }); - expect(mockLoadOrder).toEqual(['ui/metadata', 'ui/test_harness', 'legacy files']); + expect(mockLoadOrder).toEqual([ + 'ui/metadata', + 'ui/notify/fatal_error', + 'ui/test_harness', + 'legacy files', + ]); }); }); }); }); + +describe('#stop()', () => { + it('does nothing if angular was not bootstrapped to rootDomElement', () => { + const rootDomElement = document.createElement('div'); + rootDomElement.innerHTML = ` +

foo

+

bar

+ `; + + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + rootDomElement, + }); + + legacyPlatform.stop(); + expect(rootDomElement).toMatchSnapshot(); + }); + + it('destroys the angular scope and empties the rootDomElement if angular is bootstraped to rootDomElement', () => { + const rootDomElement = document.createElement('div'); + const scopeDestroySpy = jest.fn(); + + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + rootDomElement, + }); + + // simulate bootstraping with a module "foo" + angular.module('foo', []).directive('bar', () => ({ + restrict: 'E', + link($scope) { + $scope.$on('$destroy', scopeDestroySpy); + }, + })); + + rootDomElement.innerHTML = ` + + `; + + angular.bootstrap(rootDomElement, ['foo']); + + legacyPlatform.stop(); + + expect(rootDomElement).toMatchSnapshot(); + expect(scopeDestroySpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index ed7976bfa2a35..4996d6d95dff6 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -17,10 +17,13 @@ * under the License. */ +import angular from 'angular'; +import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; interface Deps { injectedMetadata: InjectedMetadataStartContract; + fatalErrors: FatalErrorsStartContract; } export interface LegacyPlatformParams { @@ -39,10 +42,11 @@ export interface LegacyPlatformParams { export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} - public start({ injectedMetadata }: Deps) { + public start({ injectedMetadata, fatalErrors }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts require('ui/metadata').__newPlatformInit__(injectedMetadata.getLegacyMetadata()); + require('ui/notify/fatal_error').__newPlatformInit__(fatalErrors); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first @@ -54,6 +58,23 @@ export class LegacyPlatformService { bootstrapModule.bootstrap(this.params.rootDomElement); } + public stop() { + const angularRoot = angular.element(this.params.rootDomElement); + const injector$ = angularRoot.injector(); + + // if we haven't gotten to the point of bootstraping + // angular, injector$ won't be defined + if (!injector$) { + return; + } + + // destroy the root angular scope + injector$.get('$rootScope').$destroy(); + + // clear the inner html of the root angular element + this.params.rootDomElement.textContent = ''; + } + private loadBootstrapModule(): { bootstrap: (rootDomElement: HTMLElement) => void; } { diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js index 4fb8065bf4ed5..914cb67a88993 100644 --- a/src/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/core_plugins/kibana/public/discover/controllers/discover.js @@ -34,7 +34,7 @@ import { timefilter } from 'ui/timefilter'; import 'ui/share'; import 'ui/query_bar'; import { hasSearchStategyForIndexPattern, isDefaultTypeIndexPattern } from 'ui/courier'; -import { toastNotifications, getPainlessError } from 'ui/notify'; +import { toastNotifications } from 'ui/notify'; import { VisProvider } from 'ui/vis'; import { BasicResponseHandlerProvider } from 'ui/vis/response_handlers/basic'; import { DocTitleProvider } from 'ui/doc_title'; @@ -53,6 +53,7 @@ import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; import { recentlyAccessed } from 'ui/persisted_log'; import { getDocLink } from 'ui/documentation_links'; import '../components/fetch_error'; +import { getPainlessError } from './get_painless_error'; const app = uiModules.get('apps/discover', [ 'kibana/notify', diff --git a/src/core_plugins/kibana/public/discover/controllers/get_painless_error.ts b/src/core_plugins/kibana/public/discover/controllers/get_painless_error.ts new file mode 100644 index 0000000000000..95a2e5ba42554 --- /dev/null +++ b/src/core_plugins/kibana/public/discover/controllers/get_painless_error.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; + +export function getPainlessError(error: Error) { + const rootCause: Array<{ lang: string; script: string }> | undefined = get( + error, + 'resp.error.root_cause' + ); + + if (!rootCause) { + return; + } + + const [{ lang, script }] = rootCause; + + if (lang !== 'painless') { + return; + } + + return { + lang, + script, + message: `Error with Painless scripted field '${script}'`, + error: error.message, + }; +} diff --git a/src/core_plugins/tests_bundle/tests_entry_template.js b/src/core_plugins/tests_bundle/tests_entry_template.js index 850eb66f88abb..03a943c8f625d 100644 --- a/src/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/core_plugins/tests_bundle/tests_entry_template.js @@ -74,6 +74,8 @@ const legacyMetadata = { new CoreSystem({ injectedMetadata: { + version: legacyMetadata.version, + buildNumber: legacyMetadata.buildNum, legacyMetadata }, rootDomElement: document.body, diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 29557240bac6f..99b22b14c2d71 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -21,6 +21,7 @@ export default { rootDir: '../../..', roots: [ '/src/ui', + '/src/core', '/src/core_plugins', '/src/server', '/src/cli', @@ -30,7 +31,6 @@ export default { '/src/utils', '/src/setup_node_env', '/packages', - '/src/core', ], collectCoverageFrom: [ 'packages/kbn-ui-framework/src/components/**/*.js', diff --git a/src/ui/public/notify/fatal_error.js b/src/ui/public/notify/fatal_error.js deleted file mode 100644 index 62221953c3104..0000000000000 --- a/src/ui/public/notify/fatal_error.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import $ from 'jquery'; -import { metadata } from '../metadata'; -import { formatMsg, formatStack } from './lib'; -import fatalSplashScreen from './partials/fatal_splash_screen.html'; -import { callEach } from '../utils/function'; - -const { - version, - buildNum, -} = metadata; - -// used to identify the first call to fatal, set to false there -let firstFatal = true; - -const fatalToastTemplate = (function lazyTemplate(tmpl) { - let compiled; - return function (vars) { - return (compiled || (compiled = _.template(tmpl)))(vars); - }; -}(require('./partials/fatal.html'))); - -// to be notified when the first fatal error occurs, push a function into this array. -const fatalCallbacks = []; - -export const addFatalErrorCallback = callback => { - fatalCallbacks.push(callback); -}; - -function formatInfo() { - const info = []; - - if (!_.isUndefined(version)) { - info.push(`Version: ${version}`); - } - - if (!_.isUndefined(buildNum)) { - info.push(`Build: ${buildNum}`); - } - - return info.join('\n'); -} - -// We're exporting this because state_management/state.js calls fatalError, which makes it -// impossible to test unless we stub this stuff out. -export const fatalErrorInternals = { - show: (err, location) => { - if (firstFatal) { - callEach(fatalCallbacks); - firstFatal = false; - window.addEventListener('hashchange', function () { - window.location.reload(); - }); - } - - const html = fatalToastTemplate({ - info: formatInfo(), - msg: formatMsg(err, location), - stack: formatStack(err) - }); - - let $container = $('#fatal-splash-screen'); - - if (!$container.length) { - $(document.body) - // in case the app has not completed boot - .removeAttr('ng-cloak') - .html(fatalSplashScreen); - - $container = $('#fatal-splash-screen'); - } - - $container.append(html); - }, -}; - -/** - * Kill the page, display an error, then throw the error. - * Used as a last-resort error back in many promise chains - * so it rethrows the error that's displayed on the page. - * - * @param {Error} err - The error that occured - */ -export function fatalError(err, location) { - fatalErrorInternals.show(err, location); - console.error(err.stack); // eslint-disable-line no-console - - throw err; -} diff --git a/src/ui/public/notify/fatal_error.ts b/src/ui/public/notify/fatal_error.ts new file mode 100644 index 0000000000000..3956a7a41aa2c --- /dev/null +++ b/src/ui/public/notify/fatal_error.ts @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FatalErrorsStartContract } from '../../../core/public/fatal_errors'; +import { + AngularHttpError, + formatAngularHttpError, + isAngularHttpError, +} from './lib/format_angular_http_error'; + +let newPlatformFatalErrors: FatalErrorsStartContract; + +export function __newPlatformInit__(instance: FatalErrorsStartContract) { + if (newPlatformFatalErrors) { + throw new Error('ui/notify/fatal_error already initialized with new platform apis'); + } + + newPlatformFatalErrors = instance; +} + +export function addFatalErrorCallback(callback: () => void) { + newPlatformFatalErrors.get$().subscribe(() => { + callback(); + }); +} + +export function fatalError(error: AngularHttpError | Error | string, location?: string) { + // add support for angular http errors to newPlatformFatalErrors + if (isAngularHttpError(error)) { + error = formatAngularHttpError(error); + } + + newPlatformFatalErrors.add(error, location); +} diff --git a/src/ui/public/notify/index.js b/src/ui/public/notify/index.js index f5991abc9e3e1..31ef28161a862 100644 --- a/src/ui/public/notify/index.js +++ b/src/ui/public/notify/index.js @@ -19,7 +19,6 @@ export { notify } from './notify'; export { Notifier } from './notifier'; -export { getPainlessError } from './lib'; -export { fatalError, fatalErrorInternals, addFatalErrorCallback } from './fatal_error'; +export { fatalError, addFatalErrorCallback } from './fatal_error'; export { GlobalToastList, toastNotifications } from './toasts'; export { GlobalBannerList, banners } from './banners'; diff --git a/src/ui/public/notify/lib/format_angular_http_error.ts b/src/ui/public/notify/lib/format_angular_http_error.ts new file mode 100644 index 0000000000000..ec6efded95ca8 --- /dev/null +++ b/src/ui/public/notify/lib/format_angular_http_error.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IHttpResponse } from 'angular'; + +export type AngularHttpError = IHttpResponse<{ message: string }>; + +export function isAngularHttpError(error: any): error is AngularHttpError { + return ( + error && + typeof error.status === 'number' && + typeof error.statusText === 'string' && + error.data && + typeof error.data.message === 'string' + ); +} + +export function formatAngularHttpError(error: AngularHttpError) { + // is an Angular $http "error object" + if (error.status === -1) { + // status = -1 indicates that the request was failed to reach the server + return ( + 'An HTTP request has failed to connect. ' + + 'Please check if the Kibana server is running and that your browser has a working connection, ' + + 'or contact your system administrator.' + ); + } + + return `Error ${error.status} ${error.statusText}: ${error.data.message}`; +} diff --git a/src/ui/public/notify/lib/format_es_msg.js b/src/ui/public/notify/lib/format_es_msg.js index f5a4fa0e33952..1a7cf817c9d83 100644 --- a/src/ui/public/notify/lib/format_es_msg.js +++ b/src/ui/public/notify/lib/format_es_msg.js @@ -36,24 +36,3 @@ export const formatESMsg = (err) => { const result = _.pluck(rootCause, 'reason').join('\n'); return result; }; - -export const getPainlessError = (err) => { - const rootCause = getRootCause(err); - - if (!rootCause) { - return; - } - - const { lang, script } = rootCause[0]; - - if (lang !== 'painless') { - return; - } - - return { - lang, - script, - message: `Error with Painless scripted field '${script}'`, - error: err.message, - }; -}; diff --git a/src/ui/public/notify/lib/index.js b/src/ui/public/notify/lib/index.js index 5d0c6628f1a00..426cfbb88ed8d 100644 --- a/src/ui/public/notify/lib/index.js +++ b/src/ui/public/notify/lib/index.js @@ -17,6 +17,7 @@ * under the License. */ -export { formatESMsg, getPainlessError } from './format_es_msg'; +export { formatESMsg } from './format_es_msg'; export { formatMsg } from './format_msg'; export { formatStack } from './format_stack'; +export { isAngularHttpError, formatAngularHttpError } from './format_angular_http_error'; diff --git a/src/ui/public/notify/partials/fatal.html b/src/ui/public/notify/partials/fatal.html deleted file mode 100644 index f65c52e4ae601..0000000000000 --- a/src/ui/public/notify/partials/fatal.html +++ /dev/null @@ -1,21 +0,0 @@ - -

-
-
-

- Fatal Error -

-
-
<%- msg %>
- <% if (info) { %> - - <% } %> - <% if (stack) { %> - - <% } %> -
\ No newline at end of file diff --git a/src/ui/public/notify/partials/fatal_splash_screen.html b/src/ui/public/notify/partials/fatal_splash_screen.html deleted file mode 100644 index 4aee2075cb67a..0000000000000 --- a/src/ui/public/notify/partials/fatal_splash_screen.html +++ /dev/null @@ -1,35 +0,0 @@ -
-
-

- Oops! -

- -

- Looks like something went wrong. Refreshing may do the trick. -

- -
- - - -
-
-
-
-
diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index 0a6416b6039d0..12058da104b79 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -22,7 +22,8 @@ import expect from 'expect.js'; import ngMock from 'ng_mock'; import { encode as encodeRison } from 'rison-node'; import '../../private'; -import { fatalErrorInternals, toastNotifications } from '../../notify'; +import { toastNotifications } from '../../notify'; +import * as FatalErrorNS from '../../notify/fatal_error'; import { StateProvider } from '../state'; import { unhashQueryString, @@ -36,6 +37,9 @@ import StubBrowserStorage from 'test_utils/stub_browser_storage'; import { EventsProvider } from '../../events'; describe('State Management', () => { + const sandbox = sinon.createSandbox(); + afterEach(() => sandbox.restore()); + describe('Enabled', () => { let $rootScope; let $location; @@ -289,13 +293,16 @@ describe('State Management', () => { expect(toastNotifications.list[0].title).to.match(/use the share functionality/i); }); - it('throws error linking to github when setting item fails', () => { + it('triggers fatal error linking to github when setting item fails', () => { const { state, hashedItemStore } = setup({ storeInHash: true }); - sinon.stub(fatalErrorInternals, 'show'); + const fatalErrorStub = sandbox.stub(FatalErrorNS, 'fatalError'); sinon.stub(hashedItemStore, 'setItem').returns(false); - expect(() => { - state.toQueryParam(); - }).to.throwError(/github\.com/); + state.toQueryParam(); + sinon.assert.calledOnce(fatalErrorStub); + sinon.assert.calledWith(fatalErrorStub, sinon.match(error => ( + error instanceof Error && + error.message.includes('github.com')) + )); }); it('translateHashToRison should gracefully fallback if parameter can not be parsed', () => { diff --git a/src/ui/ui_render/ui_render_mixin.js b/src/ui/ui_render/ui_render_mixin.js index 8385f129c12c4..d9c24452cf8ee 100644 --- a/src/ui/ui_render/ui_render_mixin.js +++ b/src/ui/ui_render/ui_render_mixin.js @@ -151,6 +151,8 @@ export function uiRenderMixin(kbnServer, server, config) { i18n: (id, options) => i18n.translate(id, options), injectedMetadata: { + version: kbnServer.version, + buildNumber: config.get('pkg.buildNum'), legacyMetadata: await getLegacyKibanaPayload({ app, request, diff --git a/yarn.lock b/yarn.lock index d90582efd7429..b081f6ccb24a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -289,6 +289,10 @@ version "1.0.1" resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.0.1.tgz#c10703020369602c40dd9428cc6e1437027116df" +"@types/cheerio@*": + version "0.22.8" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.8.tgz#5702f74f78b73e13f1eb1bd435c2c9de61a250d4" + "@types/classnames@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" @@ -301,6 +305,13 @@ version "2.0.1" resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" +"@types/enzyme@^3.1.12": + version "3.1.12" + resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.1.12.tgz#293bb07c1ef5932d37add3879e72e0f5bc614f3c" + dependencies: + "@types/cheerio" "*" + "@types/react" "*" + "@types/eslint@^4.16.2": version "4.16.2" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-4.16.2.tgz#30f4f026019eb78a6ef12f276b75cd16ea2afb27"