Skip to main content

Storybook

The happo-plugin-storybook library is a happo.io plugin for Storybook. See this blog post for a lengthier introduction to this plugin.

Installation

npm install --save-dev happo.io happo-plugin-storybook

Configuration

Add the following to your .happo.js configuration file:

// .happo.js
const happoPluginStorybook = require('happo-plugin-storybook');

module.exports = {
// ...
plugins: [
happoPluginStorybook({
// options go here
}),
],
};

Add this to .storybook/preview.js (or .storybook/config.js if you're using Storybook < v5):

// .storybook/preview.js

import 'happo-plugin-storybook/register';

Add a happo script to package.json:

{
"scripts": { "happo": "happo" }
}

Finally, add this to .storybook/main.js:

module.exports = {
addons: ['happo-plugin-storybook/preset'],
};

The final step is optional but it will enable a "Happo" addons panel where you can inspect Happo parameters and invoke lifecycle functions.

Options

These options are available to the happoPluginStorybook function:

  • configDir specify the name of the Storybook configuration directory. The default is '.storybook'.
  • outputDir the name of the directory where compiled files are saved. The default is '.out'.
  • staticDir directory where to load static files from, comma-separated list.
  • usePrebuiltPackage set to true to skip building storybook and instead use an already built package. It's important that the outputDir matches the place where the prebuilt package is located. Default is false.

These options are mostly the same ones used for the build-storybook CLI command. See https://storybook.js.org/configurations/cli-options/#for-build-storybook

Running

To execute the test suite, run

npm run happo run

Tips and Tricks

If you want to have better control over what addons and/or decorators get loaded you can make use of the isHappoRun function exported by happo-plugin-storybook/register:

import { isHappoRun } from 'happo-plugin-storybook/register';

if (!isHappoRun()) {
// load some addons/decorators that happo won't use
} else {
// load some addons/decorators that happo will use
}

Disabling a story

If some of your stories aren't well suited for Happo, you can disable them by setting a happo: false parameter. This can be done in the default export to globally disable all stories in the same file, or individually on certain stories.

export default {
title: 'FooComponent',
parameters: {
happo: false, // this will disable all `FooComponent` stories
},
};

const WithBorder = () => <FooComponent bordered />;

WithBorder.parameters = {
happo: false, // this will disable the `WithBorder` story
};

export { WithBorder };
storiesOf('FooComponent', module)
.add('Default', () => <FooComponent />);
.add('Dynamic', () => <DynamicFooComponent />, { happo: false });

// or

storiesOf('FooComponent', module)
.addParameters({ happo: false })
.add('Dynamic', () => <DynamicFooComponent />);

Dark mode and themes

If you want to take screenshots in more than one theme, you can make Happo automatically render stories in several themes. This is great if you for instance want to make sure that your components look right in both dark mode and light mode.

Start by adding a happo.themes parameter to one or more of your stories:

const Foo = () => <FooExample />;
Foo.parameters = {
happo: {
themes: ['light', 'dark'],
},
};
export { Foo };

Additionally, you also need to provide Happo with a "theme switcher" function. The happo-plugin-storybook/register import will export a setThemeSwitcher function that will allow you to control theme switching. Here's an example that makes use of storybook-dark-mode:

// .storybook/preview.js
import { setThemeSwitcher } from 'happo-plugin-storybook/register';
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';

setThemeSwitcher((theme, channel) => {
return new Promise(resolve => {
const isDarkMode = theme === 'dark';

// Listen for dark mode to change and resolve.
channel.once(DARK_MODE_EVENT_NAME, resolve);
// Change the theme.
channel.emit(DARK_MODE_EVENT_NAME, isDarkMode);
});
});

The theme passed to your theme switcher function is the name of the theme that Happo wants to switch to. If we use the Foo example from above, it will be either the string 'light' or the string 'dark'.

The channel parameter passed to your theme switcher as the second argument is the addons channel. You can use this to subscribe to and send events.

If you want to set happo.themes globally for all stories, the best way is through the parameters export in .storybook/preview.js:

// .storybook/preview.js

export const parameters = {
happo: { themes: ['light', 'dark'] },
};

Limiting targets

If you want to avoid rendering an example in all targets, you can use a targets array defined for an example. The example will then be rendered in the specified targets exclusively.

export default {
title: 'FooComponent',
parameters: {
happo: {
targets: ['chrome-small'],
},
},
};
storiesOf('FooComponent', module)
.add('Default', () => <FooComponent />, { happo: { targets: ['chrome-small'] });

// or

storiesOf('FooComponent', module)
.addParameters({ happo: { targets: ['chrome-small'] })
.add('Default', () => <FooComponent />);

In the example above, the FooComponent > Default story will only be rendered in the target named chrome-small (defined in .happo.js).

Waiting for content

In some cases, examples might not be ready by the time Happo takes the screenshot. Adding a delay might help, but only if the asynchronous event is consistently timed. In these cases the waitForContent parameter might help. Let's assume that PaymentForm in the example below loads some third-party iframe that you have no control over, loading a credit card form. In order to wait for the iframe to finish, we can add a waitForContent parameter with some unique string in the iframe.

const Basic = () => <PaymentForm />;
Basic.parameters = {
happo: {
waitForContent: 'Credit card',
},
};
export { Basic };
storiesOf('PaymentForm', module).add('default', () => <PaymentForm />, {
happo: { waitForContent: 'Credit card' },
});

Waiting for a condition to be truthy

To make Happo wait with the screenshot until a condition has been met, use the waitFor option. Specify a function that returns true (or anything truthy) when the time is right to take the screenshot.

Here's an example that waits for a specific element (.credit-card) to appear:

const Basic = () => <PaymentForm />;
Basic.parameters = {
happo: {
waitFor: () => document.querySelector('.credit-card'),
},
};
export { Basic };
storiesOf('PaymentForm', module).add('default', () => <PaymentForm />, {
happo: { waitFor: () => document.querySelector('.credit-card') },
});

Here's another example that waits for a specific number of elements:

const Basic = () => <PaymentForm />;
Basic.parameters = {
happo: {
waitFor: () => document.querySelectorAll('.validation-output').length === 5,
},
};
export { Basic };
storiesOf('PaymentForm', module).add('default', () => <PaymentForm />, {
happo: {
waitFor: () => document.querySelectorAll('.validation-output').length === 5,
},
});

Setting delay for a story

Use delays only as a last resort. They slow down your test suite and rarely get to the bottom of the issue.

Happo will make its best to wait for your stories to render, but at times you might need a little more control in the form of delays. Use the happo.delay parameter to set an individual delay for a story:

export default {
title: 'FooComponent',
parameters: {
happo: {
delay: 200, // set a 200ms delay for all FooComponent stories
},
},
};

const WithBorder = () => <FooComponent bordered />;

WithBorder.parameters = {
happo: {
delay: 1000, // Set a 1000ms delay for the WithBorder story
},
};

export { WithBorder };
storiesOf('FooComponent', module).add('delayed', () => <FooComponent />, {
happo: { delay: 200 },
});

Overriding the default render timeout

By default, Happo will wait up to 2 seconds for a story to complete. In some cases, you might have to increase this timeout to allow certain things to finish up properly. An example could be if you have a story with a play function using userEvent.type with a delay.

import { userEvent } from '@storybook/testing-library';

export const InteractiveStory = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'some longer text', {
delay: 200,
});
},
};

This story would take over 3 seconds to finish. To make happo wait that long, you can use setRenderTimeoutMs to increase the timeout.

// .storybook/preview.js
import { setRenderTimeoutMs } from 'happo-plugin-storybook/register';

setRenderTimeoutMs(5000);

Setting a longer timeout won't affect rendering times for fast/regular stories. It is only in effect if you use the play function to do interactions, or if you use waitFor or waitForContent.

The beforeScreenshot hook

If you need to interact with the DOM before a screenshot is taken you can use the beforeScreenshot option. This parameter, expected to be a function, is called right before Happo takes the screenshot. You can use this to e.g. click a button, enter text in an input field, remove certain elements, etc.

Here's an example where a button is clicked to open a modal:

const BasicModal = () => <ModalExample />;
BasicModal.parameters = {
happo: {
beforeScreenshot: () => {
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: false,
});
document.querySelector('button.open-modal').dispatchEvent(clickEvent);
},
},
};
export { BasicModal };

You can use async here as well:

const BasicModal = () => <ModalExample />;
BasicModal.parameters = {
happo: {
beforeScreenshot: async () => {
await doSomethingAsync();
},
},
};
export { BasicModal };

The afterScreenshot hook

Similar to beforeScreenshot, this hook can be used to clean up things from the DOM after a story has been fully processed.

Here's an example where a lingering DOM element is removed.

const Foo = () => <FooExample />;
Foo.parameters = {
happo: {
afterScreenshot: () => {
document.querySelector('.some-selector').remove();
},
},
};
export { Foo };

Same as for beforeScreenshot, you can use async as well.

Using forceHappoScreenshot

If you are using the play function and the Interactions addon you can force Happo to take screenshots of different steps along the way. Here's an example of a Dropdown story that we open and close in two different steps:

import { forceHappoScreenshot } from 'happo-plugin-storybook/register';

export const Dropdown = {
play: async ({ args, canvasElement, step }) => {
const canvas = within(canvasElement);

await step('open', async () => {
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Edit item')).toBeInTheDocument();
await forceHappoScreenshot('open');
});

await step('closed', async () => {
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Edit item')).not.toBeInTheDocument();
await forceHappoScreenshot('closed');
});
},
};

The forceHappoScreenshot function takes a string argument which will be used to identify the story in the Happo report. In the above example, you will see these snapshots:

  • Dropdown > Default-open
  • Dropdown > Default-closed
  • Dropdown > Default

Apart from taking all the "forced" screenshot, Happo also takes one screenshot of the "finished" state of the play function. This means that you could potentially omit the last step in the play execution, since it will be part of the Happo report anyway.

Under the hood, forceHappoScreenshot throws an error that gets picked up by Happo. This means that the play function will be invoked several times, restarting execution from the beginning (until Happo finds a step that it hasn't seen before). When the play function is executed outside of Happo (e.g. when you're using the Storybook UI), the forceHappoScreenshot call will simply be ignored.

Caveats

When you're using this plugin, some of the regular Happo commands and configuration options aren't available. These include:

Debugging

If you want to debug your test suite similar to how Happo workers process jobs, you can follow these steps:

  1. In a browser, go to the storybook URL. E.g. http://localhost:3000
  2. The URL will change to something like http://localhost:3000/?selectedKind=foo&selectedStory=default
  3. Change the URL to point to /iframe.html, e.g. http://localhost:3000/iframe.html
  4. Open the JavaScript console
  5. Paste this JavaScript snippet and hit enter: happo.nextExample().then((item) => console.log(item))
  6. Run that code again repeatedly to step through each example (use the arrow up key to reuse the last command)

To quickly run through all examples, follow steps 1-4, then paste this script instead:

var renderIter = function () {
window.happo.nextExample().then(function (a) {
if (!a) {
return;
}
console.log(a);
renderIter();
});
};
renderIter();

Troubleshooting

  • Getting a Failed on worker error? Make sure you are making a call to import 'happo-plugin-storybook/register' in your .storybook/preview.js file.
  • Getting spurious diffs from fonts not loading? Happo workers will wait for fonts to load before taking the screenshot, but it assumes that fonts it has already seen are already available. Make sure the @font-face declaration is declared globally and not part of the stories themselves.