Skip to content

Commit

Permalink
Functional tests for KibanaErrorBoundary (elastic#170569)
Browse files Browse the repository at this point in the history
Part of elastic/kibana-team#646

This PR adds an example plugin in `examples/error_boundary` that shows
usage of KibanaErrorBoundary.

The example plugin is used in a functional test to ensure errors are
caught in the appropriate way, and error messages include a working
Refresh button.

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
tsullivan and kibanamachine authored Nov 8, 2023
1 parent c902f90 commit 09f4708
Show file tree
Hide file tree
Showing 13 changed files with 262 additions and 11 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ src/plugins/embeddable @elastic/kibana-presentation
x-pack/examples/embedded_lens_example @elastic/kibana-visualizations
x-pack/plugins/encrypted_saved_objects @elastic/kibana-security
x-pack/plugins/enterprise_search @elastic/enterprise-search-frontend
examples/error_boundary @elastic/appex-sharedux
packages/kbn-es @elastic/kibana-operations
packages/kbn-es-archiver @elastic/kibana-operations @elastic/appex-qa
packages/kbn-es-errors @elastic/kibana-core
Expand Down
3 changes: 3 additions & 0 deletions examples/error_boundary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Error Boundary Example

A very simple example plugin for testing Kibana Error Boundary.
14 changes: 14 additions & 0 deletions examples/error_boundary/kibana.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"type": "plugin",
"id": "@kbn/error-boundary-example-plugin",
"owner": "@elastic/appex-sharedux",
"description": "A plugin which exemplifes the KibanaErrorBoundary",
"plugin": {
"id": "error_boundary_example",
"server": false,
"browser": true,
"requiredPlugins": [
"developerExamples"
]
}
}
12 changes: 12 additions & 0 deletions examples/error_boundary/public/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ErrorBoundaryExamplePlugin } from './plugin';

export function plugin() {
return new ErrorBoundaryExamplePlugin();
}
111 changes: 111 additions & 0 deletions examples/error_boundary/public/plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButton } from '@elastic/eui';

import React, { useState } from 'react';
import ReactDOM from 'react-dom';

import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';

interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
}

const useErrors = () => {
return useState(false);
};

export const FatalComponent = () => {
const [hasError, setHasError] = useErrors();

if (hasError) {
const fatalError = new Error('Example of unknown error type');
throw fatalError;
}

return (
<EuiButton
onClick={() => {
setHasError(true);
}}
data-test-subj="fatalErrorBtn"
>
Click for fatal error
</EuiButton>
);
};

export const RecoverableComponent = () => {
const [hasError, setHasError] = useErrors();

if (hasError) {
// FIXME: use network interception to disable responses
// for chunk requests and attempt to lazy-load a component
// https://github.com/elastic/kibana/issues/170777
const upgradeError = new Error('ChunkLoadError');
upgradeError.name = 'ChunkLoadError';
throw upgradeError;
}

return (
<EuiButton
onClick={() => {
setHasError(true);
}}
data-test-subj="recoverableErrorBtn"
>
Click for recoverable error
</EuiButton>
);
};

export class ErrorBoundaryExamplePlugin implements Plugin<void, void, SetupDeps> {
public setup(core: CoreSetup, deps: SetupDeps) {
// Register an application into the side navigation menu
core.application.register({
id: 'errorBoundaryExample',
title: 'Error Boundary Example',
async mount({ element }: AppMountParameters) {
ReactDOM.render(
<KibanaErrorBoundaryProvider analytics={core.analytics}>
<KibanaErrorBoundary>
<KibanaPageTemplate>
<KibanaPageTemplate.Header
pageTitle="KibanaErrorBoundary example"
data-test-subj="errorBoundaryExampleHeader"
/>
<KibanaPageTemplate.Section grow={false}>
<FatalComponent />
</KibanaPageTemplate.Section>
<KibanaPageTemplate.Section>
<RecoverableComponent />
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
</KibanaErrorBoundary>
</KibanaErrorBoundaryProvider>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
},
});

// This section is only needed to get this example plugin to show up in our Developer Examples.
deps.developerExamples.register({
appId: 'errorBoundaryExample',
title: 'Error Boundary Example Application',
description: `Build a plugin that registers an application that simply says "Error Boundary Example"`,
});
}
public start(_core: CoreStart) {
return {};
}
public stop() {}
}
22 changes: 22 additions & 0 deletions examples/error_boundary/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"common/**/*.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../typings/**/*"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/developer-examples-plugin",
"@kbn/shared-ux-error-boundary",
"@kbn/shared-ux-page-kibana-template"
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@
"@kbn/embedded-lens-example-plugin": "link:x-pack/examples/embedded_lens_example",
"@kbn/encrypted-saved-objects-plugin": "link:x-pack/plugins/encrypted_saved_objects",
"@kbn/enterprise-search-plugin": "link:x-pack/plugins/enterprise_search",
"@kbn/error-boundary-example-plugin": "link:examples/error_boundary",
"@kbn/es-errors": "link:packages/kbn-es-errors",
"@kbn/es-query": "link:packages/kbn-es-query",
"@kbn/es-types": "link:packages/kbn-es-types",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('<KibanaErrorBoundary>', () => {
expect(await findByText(strings.recoverable.callout.title())).toBeVisible();
expect(await findByText(strings.recoverable.callout.pageReloadButton())).toBeVisible();

(await findByTestId('recoverablePromptReloadBtn')).click();
(await findByTestId('errorBoundaryRecoverablePromptReloadBtn')).click();

expect(reloadSpy).toHaveBeenCalledTimes(1);
});
Expand All @@ -69,7 +69,7 @@ describe('<KibanaErrorBoundary>', () => {
expect(await findByText(strings.fatal.callout.showDetailsButton())).toBeVisible();
expect(await findByText(strings.fatal.callout.pageReloadButton())).toBeVisible();

(await findByTestId('fatalPromptReloadBtn')).click();
(await findByTestId('errorBoundaryFatalPromptReloadBtn')).click();

expect(reloadSpy).toHaveBeenCalledTimes(1);
});
Expand Down
30 changes: 21 additions & 9 deletions packages/shared-ux/error_boundary/src/ui/message_components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const CodePanel: React.FC<ErrorCalloutProps & { onClose: () => void }> = (props)
</EuiPanel>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiCodeBlock>
<EuiCodeBlock data-test-subj="errorBoundaryFatalDetailsErrorString">
<p>{(error.stack ?? error.toString()) + '\n\n'}</p>
<p>
{errorName}
Expand Down Expand Up @@ -93,25 +93,29 @@ export const FatalPrompt: React.FC<ErrorCalloutProps> = (props) => {

return (
<EuiEmptyPrompt
title={<h2>{strings.fatal.callout.title()}</h2>}
title={<h2 data-test-subj="errorBoundaryFatalHeader">{strings.fatal.callout.title()}</h2>}
color="danger"
iconType="error"
body={
<>
<p>{strings.fatal.callout.body()}</p>
<p data-test-subj="errorBoundaryFatalPromptBody">{strings.fatal.callout.body()}</p>
<p>
<EuiButton
color="danger"
iconType="refresh"
fill={true}
onClick={onClickRefresh}
data-test-subj="fatalPromptReloadBtn"
data-test-subj="errorBoundaryFatalPromptReloadBtn"
>
{strings.fatal.callout.pageReloadButton()}
</EuiButton>
</p>
<p>
<EuiLink color="danger" onClick={() => setIsFlyoutVisible(true)}>
<EuiLink
color="danger"
onClick={() => setIsFlyoutVisible(true)}
data-test-subj="errorBoundaryFatalShowDetailsBtn"
>
{strings.fatal.callout.showDetailsButton()}
</EuiLink>
{isFlyoutVisible ? (
Expand All @@ -128,17 +132,25 @@ export const RecoverablePrompt = (props: ErrorCalloutProps) => {
const { onClickRefresh } = props;
return (
<EuiEmptyPrompt
iconType="warning"
title={<h2>{strings.recoverable.callout.title()}</h2>}
body={<p>{strings.recoverable.callout.body()}</p>}
title={
<h2 data-test-subj="errorBoundaryRecoverableHeader">
{strings.recoverable.callout.title()}
</h2>
}
color="warning"
iconType="warning"
body={
<p data-test-subj="errorBoundaryRecoverablePromptBody">
{strings.recoverable.callout.body()}
</p>
}
actions={
<EuiButton
color="warning"
iconType="refresh"
fill={true}
onClick={onClickRefresh}
data-test-subj="recoverablePromptReloadBtn"
data-test-subj="errorBoundaryRecoverablePromptReloadBtn"
>
{strings.recoverable.callout.pageReloadButton()}
</EuiButton>
Expand Down
1 change: 1 addition & 0 deletions test/examples/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./content_management'),
require.resolve('./unified_field_list_examples'),
require.resolve('./discover_customization_examples'),
require.resolve('./error_boundary'),
],
services: {
...functionalConfig.get('services'),
Expand Down
68 changes: 68 additions & 0 deletions test/examples/error_boundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import expect from '@kbn/expect';
import { FtrProviderContext } from '../../functional/ftr_provider_context';

// eslint-disable-next-line import/no-default-export
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
const log = getService('log');

describe('Error Boundary Examples', () => {
before(async () => {
await PageObjects.common.navigateToApp('errorBoundaryExample');
await testSubjects.existOrFail('errorBoundaryExampleHeader');
});

it('fatal error', async () => {
log.debug('clicking button for fatal error');
await testSubjects.click('fatalErrorBtn');
const errorHeader = await testSubjects.getVisibleText('errorBoundaryFatalHeader');
expect(errorHeader).to.not.be(undefined);

log.debug('checking that the error has taken over the page');
await testSubjects.missingOrFail('errorBoundaryExampleHeader');

await testSubjects.click('errorBoundaryFatalShowDetailsBtn');
const errorString = await testSubjects.getVisibleText('errorBoundaryFatalDetailsErrorString');
expect(errorString).to.match(/Error: Example of unknown error type/);

log.debug('closing error flyout');
await testSubjects.click('euiFlyoutCloseButton');

log.debug('clicking page refresh');
await testSubjects.click('errorBoundaryFatalPromptReloadBtn');

await retry.try(async () => {
log.debug('checking for page refresh');
await testSubjects.existOrFail('errorBoundaryExampleHeader');
});
});

it('recoverable error', async () => {
log.debug('clicking button for recoverable error');
await testSubjects.click('recoverableErrorBtn');
const errorHeader = await testSubjects.getVisibleText('errorBoundaryRecoverableHeader');
expect(errorHeader).to.not.be(undefined);

log.debug('checking that the error has taken over the page');
await testSubjects.missingOrFail('errorBoundaryExampleHeader');

log.debug('clicking page refresh');
await testSubjects.click('errorBoundaryRecoverablePromptReloadBtn');

await retry.try(async () => {
log.debug('checking for page refresh');
await testSubjects.existOrFail('errorBoundaryExampleHeader');
});
});
});
}
2 changes: 2 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,8 @@
"@kbn/encrypted-saved-objects-plugin/*": ["x-pack/plugins/encrypted_saved_objects/*"],
"@kbn/enterprise-search-plugin": ["x-pack/plugins/enterprise_search"],
"@kbn/enterprise-search-plugin/*": ["x-pack/plugins/enterprise_search/*"],
"@kbn/error-boundary-example-plugin": ["examples/error_boundary"],
"@kbn/error-boundary-example-plugin/*": ["examples/error_boundary/*"],
"@kbn/es": ["packages/kbn-es"],
"@kbn/es/*": ["packages/kbn-es/*"],
"@kbn/es-archiver": ["packages/kbn-es-archiver"],
Expand Down
4 changes: 4 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4304,6 +4304,10 @@
version "0.0.0"
uid ""

"@kbn/error-boundary-example-plugin@link:examples/error_boundary":
version "0.0.0"
uid ""

"@kbn/es-archiver@link:packages/kbn-es-archiver":
version "0.0.0"
uid ""
Expand Down

0 comments on commit 09f4708

Please sign in to comment.