-
Notifications
You must be signed in to change notification settings - Fork 218
feat: AppLayout error boundaries v2 #4296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; | ||
| import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; | ||
|
|
||
| import createWrapper from '../../../lib/components/test-utils/selectors'; | ||
| import { getUrlParams } from './utils'; | ||
|
|
||
| const wrapper = createWrapper().findAppLayout(); | ||
|
|
||
| const ERROR_BOUNDARY_TEST_SELECTORS = { | ||
| 'left drawer trigger': '[data-testid="break-left-drawer-trigger"]', | ||
| breadcrumbs: '[data-testid="break-breadcrumbs"]', | ||
| 'local drawer trigger': '[data-testid="break-local-drawer-trigger"]', | ||
| 'global drawer trigger': '[data-testid="break-global-drawer-trigger"]', | ||
| 'left drawer content': '[data-testid="break-left-drawer-content"]', | ||
| 'left drawer header': '[data-testid="break-left-drawer-header"]', | ||
| 'navigation panel': '[data-testid="break-nav-panel"]', | ||
| 'local drawer content': '[data-testid="break-local-drawer-content"]', | ||
| 'global drawer content': '[data-testid="break-global-drawer-content"]', | ||
| 'bottom drawer content': '[data-testid="break-bottom-drawer-content"]', | ||
| } as const; | ||
|
|
||
| describe('Visual refresh toolbar only', () => { | ||
| class PageObject extends BasePageObject { | ||
| async getConsoleErrorLogs() { | ||
| const total = (await this.browser.getLogs('browser')) as Array<{ | ||
| level: string; | ||
| message: string; | ||
| source: string; | ||
| }>; | ||
|
|
||
| return total.filter(entry => entry.level === 'SEVERE' && entry.source !== 'network').map(entry => entry.message); | ||
| } | ||
|
|
||
| async expectConsoleErrors() { | ||
| const consoleErrors = await this.getConsoleErrorLogs(); | ||
| const errorPattern = /The above error occurred in the .+ component:/; | ||
| const matchingErrors = consoleErrors.filter(error => errorPattern.test(error)); | ||
| expect(matchingErrors.length).toBeGreaterThan(0); | ||
|
Check warning on line 40 in src/app-layout/__integ__/app-layout-error-boundaries.test.ts
|
||
| } | ||
|
|
||
| async expectContentAreaStaysFunctional() { | ||
| await expect(this.getText(wrapper.findContentRegion().toSelector())).resolves.toContain( | ||
| 'Error boundaries in app layout slots' | ||
| ); | ||
| } | ||
| } | ||
| function setupTest({ hasParentErrorBoundary = 'true' }, testFn: (page: PageObject) => Promise<void>) { | ||
| return useBrowser(async browser => { | ||
| const page = new PageObject(browser); | ||
|
|
||
| await browser.url( | ||
| `#/light/app-layout/with-error-boundaries?${getUrlParams('refresh-toolbar', { | ||
| appLayoutToolbar: 'true', | ||
| hasParentErrorBoundary, | ||
| })}` | ||
| ); | ||
| await page.waitForVisible(wrapper.findContentRegion().toSelector(), true); | ||
| await testFn(page); | ||
| }); | ||
| } | ||
|
|
||
| describe('with parent error boundary', () => { | ||
| Object.entries(ERROR_BOUNDARY_TEST_SELECTORS).forEach(([areaName, selector]) => { | ||
| test( | ||
| `should handle error boundary in ${areaName}`, | ||
| setupTest({ hasParentErrorBoundary: 'true' }, async page => { | ||
| await page.click(selector); | ||
| await page.expectConsoleErrors(); | ||
| await page.expectContentAreaStaysFunctional(); | ||
| }) | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('without parent error boundary', () => { | ||
| Object.entries(ERROR_BOUNDARY_TEST_SELECTORS).forEach(([areaName, selector]) => { | ||
| test( | ||
| `should handle error boundary in ${areaName}`, | ||
| setupTest({ hasParentErrorBoundary: 'false' }, async page => { | ||
| await page.click(selector); | ||
| await page.expectConsoleErrors(); | ||
| await page.expectContentAreaStaysFunctional(); | ||
| }) | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,284 @@ | ||
| // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import React from 'react'; | ||
| import { render } from '@testing-library/react'; | ||
|
|
||
| import AppLayout from '../../../lib/components/app-layout'; | ||
| import ErrorBoundary from '../../../lib/components/error-boundary'; | ||
| import awsuiPlugins from '../../../lib/components/internal/plugins'; | ||
| import { DrawerConfig } from '../../../lib/components/internal/plugins/controllers/drawers'; | ||
| import * as awsuiWidgetPlugins from '../../../lib/components/internal/plugins/widget'; | ||
| import createWrapper, { ErrorBoundaryWrapper } from '../../../lib/components/test-utils/dom'; | ||
| import { describeEachAppLayout, getGlobalDrawersTestUtils } from './utils'; | ||
|
|
||
| const drawerDefaults: DrawerConfig = { | ||
| id: 'test', | ||
| ariaLabels: {}, | ||
| trigger: { iconSvg: 'icon placeholder' }, | ||
| mountContent: container => (container.textContent = 'runtime drawer content'), | ||
| unmountContent: () => {}, | ||
| }; | ||
|
|
||
| function renderComponent(jsx: React.ReactElement) { | ||
| const { container, rerender, getByTestId, ...rest } = render(jsx); | ||
| const wrapper = createWrapper(container).findAppLayout()!; | ||
| const errorBoundaryWrapper = createWrapper(container).findErrorBoundary()!; | ||
| const globalDrawersWrapper = getGlobalDrawersTestUtils(wrapper); | ||
| return { | ||
| wrapper, | ||
| errorBoundaryWrapper, | ||
| globalDrawersWrapper, | ||
| rerender, | ||
| getByTestId, | ||
| container, | ||
| ...rest, | ||
| }; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| jest.resetAllMocks(); | ||
| }); | ||
|
|
||
| describe('AppLayout error boundaries: errors in different areas does not crash the entire app layout', () => { | ||
| const ThrowError = ({ message = 'Test error' }: { message?: string }) => { | ||
| throw new Error(message); | ||
| }; | ||
|
|
||
| const expectInvisibleErrorBoundary = (errorBoundaryWrapper: ErrorBoundaryWrapper) => { | ||
| expect(errorBoundaryWrapper.getElement()).toBeInTheDocument(); | ||
| expect(errorBoundaryWrapper.findHeader()).toBeFalsy(); | ||
| expect(errorBoundaryWrapper.findDescription()).toBeFalsy(); | ||
| expect(errorBoundaryWrapper.findAction()).toBeFalsy(); | ||
| }; | ||
|
|
||
| describeEachAppLayout({ themes: ['refresh-toolbar'] }, () => { | ||
| describe.each([true, false])( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This with and without error boundary repetition is not needed. You only need
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test verifies that errors don't bubble up to the top level and won't crash the entire page, even without a parent error boundary
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you would like to test a simple Same here. You need to have one catch-all wrapper to run assertions. Feel free to keep your code if you really want, but just so you know, this setup is redundant. |
||
| 'entire AppLayout wrapped with error boundary component : %p', | ||
| (wrappedWithErrorBoundary: boolean) => { | ||
| const onError = jest.fn(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Who is going to reset this spy function between tests?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
| const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); | ||
| const AppLayoutWrapper = wrappedWithErrorBoundary ? ErrorBoundary : 'div'; | ||
| const appLayoutWrapperProps = wrappedWithErrorBoundary ? { onError } : {}; | ||
| const content = <div>content</div>; | ||
|
|
||
| const expectErrorCallbacksToBeCalled = () => { | ||
| if (wrappedWithErrorBoundary) { | ||
| expect(onError).toHaveBeenCalled(); | ||
| } | ||
| expect(consoleSpy).toHaveBeenCalled(); | ||
| }; | ||
|
|
||
| test('left drawer content', () => { | ||
| awsuiWidgetPlugins.registerLeftDrawer({ | ||
| defaultActive: true, | ||
| ...drawerDefaults, | ||
| id: '1', | ||
| trigger: undefined, | ||
| mountContent: () => { | ||
| throw new Error('Mount error in drawer content'); | ||
| }, | ||
| }); | ||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('left drawer header', () => { | ||
| awsuiWidgetPlugins.registerLeftDrawer({ | ||
| ...drawerDefaults, | ||
| id: '2', | ||
| defaultActive: true, | ||
| trigger: undefined, | ||
| mountHeader: () => { | ||
| throw new Error('Mount error in drawer content'); | ||
| }, | ||
| }); | ||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('left drawer trigger', () => { | ||
| awsuiWidgetPlugins.registerLeftDrawer({ | ||
| ...drawerDefaults, | ||
| id: '3', | ||
| trigger: { | ||
| iconSvg: Symbol() as any, | ||
| }, | ||
| }); | ||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('breadcrumbs', () => { | ||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} breadcrumbs={<ThrowError message="Breadcrumbs error" />} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('local drawer trigger', () => { | ||
| awsuiPlugins.appLayout.registerDrawer({ | ||
| ...drawerDefaults, | ||
| type: 'local', | ||
| trigger: { | ||
| iconSvg: Symbol() as any, | ||
| }, | ||
| }); | ||
|
|
||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('global drawer trigger', () => { | ||
| awsuiPlugins.appLayout.registerDrawer({ | ||
| ...drawerDefaults, | ||
| type: 'global', | ||
| trigger: { | ||
| iconSvg: Symbol() as any, | ||
| }, | ||
| }); | ||
|
|
||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('nav panel', () => { | ||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout | ||
| content={content} | ||
| navigation={<ThrowError message="Breadcrumbs error" />} | ||
| navigationOpen={true} | ||
| /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('local drawer content', () => { | ||
| awsuiPlugins.appLayout.registerDrawer({ | ||
| ...drawerDefaults, | ||
| type: 'local', | ||
| mountContent: () => { | ||
| throw new Error('Mount error in drawer content'); | ||
| }, | ||
| }); | ||
|
|
||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('global drawer content', () => { | ||
| awsuiPlugins.appLayout.registerDrawer({ | ||
| ...drawerDefaults, | ||
| type: 'global', | ||
| mountContent: () => { | ||
| throw new Error('Mount error in drawer content'); | ||
| }, | ||
| }); | ||
|
|
||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('bottom drawer content', () => { | ||
| awsuiWidgetPlugins.registerBottomDrawer({ | ||
| defaultActive: true, | ||
| ...drawerDefaults, | ||
| id: '4', | ||
| mountContent: () => { | ||
| throw new Error('Mount error in drawer content'); | ||
| }, | ||
| }); | ||
|
|
||
| const { errorBoundaryWrapper, wrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={content} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); | ||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| }); | ||
|
|
||
| test('content area error are not caught by app layout error boundaries', () => { | ||
| if (wrappedWithErrorBoundary) { | ||
| const { errorBoundaryWrapper } = renderComponent( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={<ThrowError />} /> | ||
| </AppLayoutWrapper> | ||
| ); | ||
|
|
||
| expectInvisibleErrorBoundary(errorBoundaryWrapper); | ||
| expectErrorCallbacksToBeCalled(); | ||
| } else { | ||
| expect(() => | ||
| render( | ||
| <AppLayoutWrapper {...(appLayoutWrapperProps as any)}> | ||
| <AppLayout content={<ThrowError />} /> | ||
| </AppLayoutWrapper> | ||
| ) | ||
| ).toThrow('Test error'); | ||
| } | ||
| }); | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More tests and assertions needed
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added both |
||
| ); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not see any nested app layouts, why is it skipped?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed. I initially tested it with nested app layouts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, this is a good idea for a test. That one crashing app layout does not break another
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, brought back nested app layouts