Migrate ui/notify/fatal_error to new platform (#20752)

Fixes #20695

Extracts the "fatal error" handling logic from the `ui/notify` module and reimplements it in the new platform, using EUI for the fatal error page and continuing to support the `fatalError()` and `addFatalErrorCallback()` methods exported by the `ui/notify` module.

![image](https://user-images.githubusercontent.com/1329312/43032175-d37fbafc-8c65-11e8-8f1f-da71f0dac014.png)
This commit is contained in:
Spencer 2018-07-30 15:06:31 -07:00 committed by GitHub
parent 3b74158b40
commit 1532c5e9aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1293 additions and 223 deletions

View file

@ -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",

View file

@ -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;

View file

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"include": [
"index.d.ts"
],
}

View file

@ -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<LegacyPlatformService>(
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<FatalErrorsService>(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,
});
});
});

View file

@ -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();
}
}

View file

@ -0,0 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rendering render matches snapshot 1`] = `
<EuiPage
restrictWidth={false}
style={
Object {
"minHeight": "100vh",
}
}
>
<EuiPageBody
restrictWidth={false}
>
<EuiPageContent
horizontalPosition="center"
panelPaddingSize="l"
verticalPosition="center"
>
<EuiEmptyPrompt
actions={
Array [
<EuiButton
color="primary"
data-test-subj="clearSession"
fill={true}
iconSide="left"
onClick={[Function]}
type="button"
>
Clear your session
</EuiButton>,
<EuiButtonEmpty
color="primary"
data-test-subj="goBack"
iconSide="left"
onClick={[Function]}
type="button"
>
Go back
</EuiButtonEmpty>,
]
}
body={
<p>
Try refreshing the page. If that doesn't work, go back to the previous page or clear your session data.
</p>
}
iconColor="danger"
iconType="alert"
title={
<h2>
Something went wrong
</h2>
}
/>
<EuiCallOut
color="danger"
iconType="alert"
key="0"
size="m"
title="foo"
>
<EuiCodeBlock
className="eui-textBreakAll"
language="bash"
>
Version: bar
Build: 123
Error: foo
stack...foo.js:1:1
</EuiCodeBlock>
</EuiCallOut>
<EuiCallOut
color="danger"
iconType="alert"
key="1"
size="m"
title="bar"
>
<EuiCodeBlock
className="eui-textBreakAll"
language="bash"
>
Version: bar
Build: 123
Error: bar
stack...bar.js:1:1
</EuiCodeBlock>
</EuiCallOut>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
`;

View file

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#add() deletes all children of rootDomElement and renders <FatalErrorScreen /> into it: fatal error screen component 1`] = `
Array [
Array [
<FatalErrorsScreen
buildNumber="kibanaBuildNumber"
errorInfo$={Rx.Observable}
kibanaVersion="kibanaVersion"
/>,
<div />,
],
]
`;
exports[`#add() deletes all children of rootDomElement and renders <FatalErrorScreen /> into it: fatal error screen container 1`] = `
<div>
<div />
</div>
`;

View file

@ -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(<FatalErrorsScreen {...defaultProps} />);
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(<FatalErrorsScreen {...defaultProps} />)).toMatchSnapshot();
});
it('rerenders when errorInfo$ emits more errors', () => {
const errorInfo$ = new Rx.ReplaySubject<typeof errorInfoFoo>();
const el = shallow(<FatalErrorsScreen {...defaultProps} errorInfo$={errorInfo$} />);
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(<FatalErrorsScreen {...defaultProps} />);
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(<FatalErrorsScreen {...defaultProps} />);
const button = el.find('button').find(testSubjSelector('goBack'));
button.simulate('click');
expect(window.history.back).toHaveBeenCalled();
});
});
});

View file

@ -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<ErrorInfo>;
}
interface State {
errors: ErrorInfo[];
}
export class FatalErrorsScreen extends React.Component<Props, State> {
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 (
<EuiPage style={{ minHeight: '100vh' }}>
<EuiPageBody>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={<h2>Something went wrong</h2>}
body={
<p>
Try refreshing the page. If that doesn't work, go back to the previous page or
clear your session data.
</p>
}
actions={[
<EuiButton
color="primary"
fill
onClick={this.onClickClearSession}
data-test-subj="clearSession"
>
Clear your session
</EuiButton>,
<EuiButtonEmpty onClick={this.onClickGoBack} data-test-subj="goBack">
Go back
</EuiButtonEmpty>,
]}
/>
{this.state.errors.map((error, i) => (
<EuiCallOut key={i} title={error.message} color="danger" iconType="alert">
<EuiCodeBlock language="bash" className="eui-textBreakAll">
{`Version: ${this.props.kibanaVersion}` +
'\n' +
`Build: ${this.props.buildNumber}` +
'\n' +
(error.stack ? error.stack : '')}
</EuiCodeBlock>
</EuiCallOut>
))}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
}
private onClickGoBack = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.history.back();
};
private onClickClearSession = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
localStorage.clear();
sessionStorage.clear();
window.location.hash = '';
window.location.reload();
};
}

View file

@ -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 <FatalErrorScreen /> into it', () => {
const { fatalErrors, rootDomElement } = setup();
rootDomElement.innerHTML = `
<h1>Loading...</h1>
<div class="someSpinner"></div>
`;
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/),
});
});
});

View file

@ -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<ErrorInfo>();
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 <FatalErrorScreen>
const container = document.createElement('div');
this.params.rootDomElement.appendChild(container);
render(
<FatalErrorsScreen
buildNumber={this.params.injectedMetadata.getKibanaBuildNumber()}
kibanaVersion={this.params.injectedMetadata.getKibanaVersion()}
errorInfo$={this.errorInfo$}
/>,
container
);
}
}
export type FatalErrorsStartContract = ReturnType<FatalErrorsService['start']>;

View file

@ -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<T> 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',
});
});

View file

@ -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<typeof getErrorInfo>;

View file

@ -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';

View file

@ -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,
};
describe('#getKibanaVersion', () => {
it('returns version from injectedMetadata', () => {
const injectedMetadata = new InjectedMetadataService({
injectedMetadata: {
version: 'foo',
},
} as any);
const injectedMetadata = new InjectedMetadataService(params);
expect(injectedMetadata.getKibanaVersion()).toBe('foo');
});
});
expect(() => {
params.injectedMetadata.foo = false;
}).not.toThrowError();
describe('#getKibanaBuildNumber', () => {
it('returns buildNumber from injectedMetadata', () => {
const injectedMetadata = new InjectedMetadataService({
injectedMetadata: {
buildNumber: 'foo',
},
} as any);
injectedMetadata.start();
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();
});
});

View file

@ -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<InjectedMetadataService['start']>;

View file

@ -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`] = `
<div
class="ng-scope"
/>
`;
exports[`#stop() does nothing if angular was not bootstrapped to rootDomElement 1`] = `
<div>
<h1>
foo
</h1>
<h2>
bar
</h2>
</div>
`;

View file

@ -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 = `
<h1>foo</h1>
<h2>bar</h2>
`;
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 = `
<bar></bar>
`;
angular.bootstrap(rootDomElement, ['foo']);
legacyPlatform.stop();
expect(rootDomElement).toMatchSnapshot();
expect(scopeDestroySpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -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;
} {

View file

@ -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',

View file

@ -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,
};
}

View file

@ -74,6 +74,8 @@ const legacyMetadata = {
new CoreSystem({
injectedMetadata: {
version: legacyMetadata.version,
buildNumber: legacyMetadata.buildNum,
legacyMetadata
},
rootDomElement: document.body,

View file

@ -21,6 +21,7 @@ export default {
rootDir: '../../..',
roots: [
'<rootDir>/src/ui',
'<rootDir>/src/core',
'<rootDir>/src/core_plugins',
'<rootDir>/src/server',
'<rootDir>/src/cli',
@ -30,7 +31,6 @@ export default {
'<rootDir>/src/utils',
'<rootDir>/src/setup_node_env',
'<rootDir>/packages',
'<rootDir>/src/core',
],
collectCoverageFrom: [
'packages/kbn-ui-framework/src/components/**/*.js',

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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';

View file

@ -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}`;
}

View file

@ -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,
};
};

View file

@ -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';

View file

@ -1,21 +0,0 @@
<!--
!!!!
Since fatal error could prevent angular from starting
this template is just a simple lodash template
!!!!
-->
<h1><i class="fa fa-warning-triangle"></i></h1>
<div class="panel panel-danger">
<div class="panel-heading">
<h1 class="panel-title">
<i class="fa fa-warning"></i> Fatal Error
</h1>
</div>
<div class="panel-body fatal-body"><%- msg %></div>
<% if (info) { %>
<div class="panel-footer"><pre><%- info %></pre></div>
<% } %>
<% if (stack) { %>
<div class="panel-footer"><pre><%- stack %></pre></div>
<% } %>
</div>

View file

@ -1,35 +0,0 @@
<div
id="fatal-splash-screen-header"
class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem"
>
<div style="text-align: center">
<h1 class="kuiTitle kuiVerticalRhythm">
Oops!
</h1>
<p class="kuiText kuiVerticalRhythm">
Looks like something went wrong. Refreshing may do the trick.
</p>
<div
class="kuiButtonGroup kuiVerticalRhythm"
style="text-align: center; display: inline-block;"
>
<button
class="kuiButton kuiButton--primary"
onclick="window.history.back();"
>
Go back
</button>
<button
class="kuiButton kuiButton--hollow"
onclick="localStorage.clear(); sessionStorage.clear(); window.location.hash = ''; window.location.reload();"
>
Clear your session
</button>
</div>
</div>
<div id="fatal-splash-screen">
</div>
</div>

View file

@ -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', () => {

View file

@ -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,

View file

@ -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"