diff --git a/examples/official-storybook/config.js b/examples/official-storybook/config.js index 7ba496088485..ef9842a7e621 100644 --- a/examples/official-storybook/config.js +++ b/examples/official-storybook/config.js @@ -18,7 +18,14 @@ setOptions({ theme: themes.dark, }); -addDecorator(story => {story()}); +addDecorator( + (story, { kind }) => + kind === 'Core|Errors' ? ( + story() + ) : ( + {story()} + ) +); configureViewport({ viewports: { diff --git a/examples/official-storybook/stories/core.stories.js b/examples/official-storybook/stories/core.stories.js index 856042ee22be..35088648b742 100644 --- a/examples/official-storybook/stories/core.stories.js +++ b/examples/official-storybook/stories/core.stories.js @@ -3,6 +3,7 @@ import { storiesOf, addParameters } from '@storybook/react'; import addons from '@storybook/addons'; import Events from '@storybook/core-events'; import { Button } from '@storybook/components'; +import { navigator } from 'global'; const globalParameter = 'globalParameter'; const chapterParameter = 'chapterParameter'; @@ -31,3 +32,19 @@ const increment = () => { storiesOf('Core|Events', module).add('Force re-render', () => ( )); + +// Skip these stories in storyshots, they will throw -- NOTE: would rather do this +// via a params API, see https://github.com/storybooks/storybook/pull/3967#issuecomment-411616023 +if ( + navigator && + navigator.userAgent && + !(navigator.userAgent.indexOf('jsdom') > -1) && + !(navigator.userAgent.indexOf('Chromatic') > -1) +) { + storiesOf('Core|Errors', module) + .add('story throws exception', () => { + throw new Error('error'); + }) + // Story does not return something react can render + .add('story errors', () => null); +} diff --git a/lib/core-events/index.js b/lib/core-events/index.js index c1cbe2ea8819..08e6a0535035 100644 --- a/lib/core-events/index.js +++ b/lib/core-events/index.js @@ -10,4 +10,6 @@ module.exports = { FORCE_RE_RENDER: 'forceReRender', REGISTER_SUBSCRIPTION: 'registerSubscription', STORY_RENDERED: 'storyRendered', + STORY_ERRORED: 'storyErrored', + STORY_THREW_EXCEPTION: 'storyThrewException', }; diff --git a/lib/core/src/client/preview/start.js b/lib/core/src/client/preview/start.js index 613692b90e5d..29cbccad0cc3 100644 --- a/lib/core/src/client/preview/start.js +++ b/lib/core/src/client/preview/start.js @@ -43,14 +43,19 @@ function showErrorDisplay({ message, stack }) { document.body.classList.add(classes.ERROR); } +// showError is used by the various app layers to inform the user they have done something +// wrong -- for instance returned the wrong thing from a story function showError({ title, description }) { + addons.getChannel().emit(Events.STORY_ERRORED, { title, description }); showErrorDisplay({ message: title, stack: description, }); } +// showException is used if we fail to render the story and it is uncaught by the app layer function showException(exception) { + addons.getChannel().emit(Events.STORY_THREW_EXCEPTION, exception); showErrorDisplay(exception); // Log the stack to the console. So, user could check the source code. diff --git a/lib/core/src/client/preview/start.test.js b/lib/core/src/client/preview/start.test.js new file mode 100644 index 000000000000..df11855a26b4 --- /dev/null +++ b/lib/core/src/client/preview/start.test.js @@ -0,0 +1,101 @@ +import addons from '@storybook/addons'; +import Events from '@storybook/core-events'; +import { document } from 'global'; + +import start from './start'; + +jest.mock('@storybook/client-logger'); +jest.mock('@storybook/addons'); +jest.mock('global', () => ({ + navigator: { userAgent: 'browser' }, + window: { + addEventListener: jest.fn(), + location: { search: '' }, + history: { replaceState: jest.fn() }, + }, + document: { + addEventListener: jest.fn(), + getElementById: jest.fn().mockReturnValue({}), + body: { classList: { add: jest.fn(), remove: jest.fn() } }, + documentElement: {}, + }, +})); + +function mockEmit() { + const emit = jest.fn(); + addons.getChannel.mockReturnValue({ emit }); + + return emit; +} + +it('renders nopreview when you have no stories', () => { + const emit = mockEmit(); + + const render = jest.fn(); + + start(render); + + expect(render).not.toHaveBeenCalled(); + expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-nopreview'); + expect(emit).toHaveBeenCalledWith(Events.STORY_RENDERED); +}); + +it('calls render when you add a story', () => { + const emit = mockEmit(); + + const render = jest.fn(); + + const { clientApi, configApi } = start(render); + + emit.mockReset(); + configApi.configure(() => { + clientApi.storiesOf('kind', {}).add('story', () => {}); + }, {}); + + expect(render).toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith(Events.STORY_RENDERED); +}); + +it('emits an exception and shows error when your story throws', () => { + const emit = mockEmit(); + + const render = jest.fn().mockImplementation(() => { + throw new Error('Some exception'); + }); + + const { clientApi, configApi } = start(render); + + emit.mockReset(); + document.body.classList.add.mockReset(); + configApi.configure(() => { + clientApi.storiesOf('kind', {}).add('story', () => {}); + }, {}); + + expect(render).toHaveBeenCalled(); + expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); + expect(emit).toHaveBeenCalledWith(Events.STORY_THREW_EXCEPTION, expect.any(Error)); +}); + +it('emits an error and shows error when your framework calls showError', () => { + const emit = mockEmit(); + + const error = { + title: 'Some error', + description: 'description', + }; + const render = jest.fn().mockImplementation(({ showError }) => { + showError(error); + }); + + const { clientApi, configApi } = start(render); + + emit.mockReset(); + document.body.classList.add.mockReset(); + configApi.configure(() => { + clientApi.storiesOf('kind', {}).add('story', () => {}); + }, {}); + + expect(render).toHaveBeenCalled(); + expect(document.body.classList.add).toHaveBeenCalledWith('sb-show-errordisplay'); + expect(emit).toHaveBeenCalledWith(Events.STORY_ERRORED, error); +});