Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
377 changes: 377 additions & 0 deletions pages/app-layout/with-error-boundaries.page.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/__a11y__/a11y-app-layout-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const EXCLUDED_PAGES = [
// Test page for an app layout nested inside another through an iframe.
// Not a use case that's encouraged.
'app-layout/multi-layout-global-drawer-child-layout',
'app-layout/with-error-boundaries',
Copy link
Member

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?

Copy link
Member Author

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

Copy link
Member

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

Copy link
Member Author

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

];

describe('A11y checks for app layout toolbar', () => {
Expand Down
1 change: 1 addition & 0 deletions src/__a11y__/run-a11y-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function runA11yTests(theme: Theme, mode: Mode, skip: string[] =
'theming/tokens',
// this page intentionally has issues to test the helper
'undefined-texts',
'app-layout/with-error-boundaries',
];
const testFunction =
skipPages.includes(inputUrl) ||
Expand Down
89 changes: 89 additions & 0 deletions src/app-layout/__integ__/app-layout-error-boundaries.test.ts
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

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 4/4)

RETRY 1: Visual refresh toolbar only › without parent error boundary › should handle error boundary in local drawer trigger

expect(received).toBeGreaterThan(expected) Expected: > 0 Received: 0 at PageObject.expectConsoleErrors (src/app-layout/__integ__/app-layout-error-boundaries.test.ts:40:37) at src/app-layout/__integ__/app-layout-error-boundaries.test.ts:83:11 at src/app-layout/__integ__/app-layout-error-boundaries.test.ts:60:7 at Object.<anonymous> (node_modules/@cloudscape-design/browser-test-tools/use-browser.js:36:13)
}

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();
})
);
});
});
});
284 changes: 284 additions & 0 deletions src/app-layout/__tests__/widget-areas-error-boundaries.test.tsx
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])(
Copy link
Member

Choose a reason for hiding this comment

The 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 wrappedWithErrorBoundary = true scenario, where you assert that the root error boundary was reached when expected and was not reached when not expected

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you would like to test a simple try/catch logic, you do not need to test with with different types of handlers.

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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who is going to reset this spy function between tests?

Copy link
Member Author

Choose a reason for hiding this comment

The 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');
}
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More tests and assertions needed

  1. All tests needs to ensure that the content slot is intact after errors happen
    • Maybe also check other combinations like "error in navigation does not break tools", "error in breadcrumbs does not break navigation", etc
  2. We need to have a test for <AppLayout content={<ThrowsError />}>. In this case the app layout crashes completely, only the wrapper error boundary is triggered

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added both

);
});
});
Loading
Loading