Functional tests for KibanaErrorBoundary (#170569)

Part of https://github.com/elastic/kibana-team/issues/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 <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tim Sullivan 2023-11-08 11:23:19 -07:00 committed by GitHub
parent c902f90a71
commit 09f4708de4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 262 additions and 11 deletions

1
.github/CODEOWNERS vendored
View file

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

View file

@ -0,0 +1,3 @@
## Error Boundary Example
A very simple example plugin for testing Kibana Error Boundary.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}
@ -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 ? (
@ -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>

View file

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

View file

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

View file

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

View file

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