Skip to content
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

Add setPreRender/setPostRender hooks & example #38

Merged
merged 10 commits into from
Feb 3, 2022
Merged
Show file tree
Hide file tree
Changes from 8 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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Storybook test runner turns all of your stories into executable tests.
- [Running in CI](#running-in-ci)
- [1. Running against deployed Storybooks on Github Actions deployment](#1-running-against-deployed-storybooks-on-github-actions-deployment)
- [2. Running against locally built Storybooks in CI](#2-running-against-locally-built-storybooks-in-ci)
- [Experimental test hook API](#experimental-test-hook-api)
- [Image snapshot recipe](#image-snapshot-recipe)
- [Troubleshooting](#troubleshooting)
- [The test runner seems flaky and keeps timing out](#the-test-runner-seems-flaky-and-keeps-timing-out)
- [Adding the test runner to other CI environments](#adding-the-test-runner-to-other-ci-environments)
Expand Down Expand Up @@ -228,6 +230,59 @@ jobs:

> **_NOTE:_** Building Storybook locally makes it simple to test Storybooks that could be available remotely, but are under authentication layers. If you also deploy your Storybooks somewhere (e.g. Chromatic, Vercel, etc.), the Storybook URL can still be useful with the test-runner. You can pass it to the `REFERENCE_URL` environment variable when running the test-storybook command, and if a story fails, the test-runner will provide a helpful message with the link to the story in your published Storybook instead.

## Experimental test hook API

The test runner renders a story and executes its [play function](https://storybook.js.org/docs/react/writing-stories/play-function) if one exists. However, there are certain behaviors that are not possible to achieve via the play function, which executes in the browser. For example, if you want the test runner to take visual snapshots for you, this is something that is possible via Playwright/Jest, but must be executed in Node.

To enable use cases like visual or DOM snapshots, the test runner exports test hooks that can be overridden globally. These hooks give you access to the test lifecycle before and after the story is rendered.

The hooks, `preRender` and `postRender`, are functions that take a [Playwright Page](https://playwright.dev/docs/pages) and a context object with the current story `id`, `title`, and `name`. They are globally settable by `@storybook/test-runner`'s `setPreRender` and `setPostRender` APIs.

To visualize the test lifecycle, consider a simplified version of the test code automatically generated for each story in your Storybook:

```js
it('button--basic', async () => {
// filled in with data for the current story
const context = { id: 'button--basic', title: 'Button', name: 'Basic' };

// playwright page https://playwright.dev/docs/pages
await page.goto(STORYBOOK_URL);

// pre-render hook
if (preRender) await preRender(page, context);

// render the story and run its play function (if applicable)
await page.execute('render', context);

// post-render hook
if (postRender) await postRender(page, context);
});
```

> **NOTE:** These test hooks are experimental and may be subject to breaking changes. We encourage you to test as much as possible within the story's play function.
yannbf marked this conversation as resolved.
Show resolved Hide resolved

### Image snapshot recipe

If you want to make the test runner take image snapshots, the following recipe uses test hooks in `jest-setup.js` to do it:

```js
const { toMatchImageSnapshot } = require('jest-image-snapshot');
const { setPostRender } = require('@storybook/test-runner');

expect.extend({ toMatchImageSnapshot });

// use custom directory/id to align CSF and stories.json mode outputs
const customSnapshotsDir = `${process.cwd()}/__snapshots__`;

setPostRender(async (page, context) => {
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: context.id,
});
});
```

## Troubleshooting

#### The test runner seems flaky and keeps timing out
Expand Down
Binary file added __snapshots__/basic-button--demo-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __snapshots__/basic-button--find-by-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __snapshots__/basic-button--primary-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __snapshots__/basic-button--wait-for-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __snapshots__/example-page--logged-in-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __snapshots__/example-page--logged-out-snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions jest-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { toMatchImageSnapshot } = require('jest-image-snapshot');
const { setPostRender } = require('./dist/cjs');

expect.extend({ toMatchImageSnapshot });

// use custom directory/id to align CSF and stories.json mode outputs
const customSnapshotsDir = `${process.cwd()}/__snapshots__`;

setPostRender(async (page, context) => {
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: context.id,
failureThreshold: 0.03,
failureThresholdType: 'percent',
});
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"dedent": "^0.7.0",
"jest": "^27.0.6",
"jest-environment-jsdom": "^27.0.6",
"jest-image-snapshot": "^4.5.1",
"prettier": "^2.3.1",
"prop-types": "^15.7.2",
"react": "^17.0.1",
Expand All @@ -106,6 +107,7 @@
"dependencies": {
"@storybook/csf": "0.0.2--canary.87bc651.0",
"@storybook/csf-tools": "^6.4.14",
"global": "^4.4.0",
"jest-playwright-preset": "^1.7.0",
"node-fetch": "^2",
"playwright": "^1.14.0",
Expand Down
13 changes: 8 additions & 5 deletions src/csf/transformCsf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export interface TestContext {
title: t.Literal;
id: t.Literal;
}
type FilePrefixer = () => t.Statement[];
type TestPrefixer = (context: TestContext) => t.Statement[];
type TemplateResult = t.Statement | t.Statement[];
type FilePrefixer = () => TemplateResult;
type TestPrefixer = (context: TestContext) => TemplateResult;

interface TransformOptions {
clearBody?: boolean;
Expand All @@ -38,8 +39,7 @@ const prefixFunction = (
id: t.stringLiteral(toId(title, name)),
};

// instead, let's just make the prefixer return the function
const result = testPrefixer(context);
const result = makeArray(testPrefixer(context));
const stmt = result[1] as t.ExpressionStatement;
return stmt.expression;
};
Expand Down Expand Up @@ -69,6 +69,9 @@ const makeDescribe = (key: string, tests: t.Statement[]): t.Statement | null =>
);
};

const makeArray = (templateResult: TemplateResult) =>
Array.isArray(templateResult) ? templateResult : [templateResult];

export const transformCsf = (
code: string,
{
Expand Down Expand Up @@ -110,7 +113,7 @@ export const transformCsf = (

// FIXME: insert between imports
if (filePrefixer) {
const { code: prefixCode } = generate(t.program(filePrefixer()), {});
const { code: prefixCode } = generate(t.program(makeArray(filePrefixer())), {});
result = `${prefixCode}\n`;
}
if (!clearBody) result = `${result}${code}\n`;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './playwright/hooks';
18 changes: 18 additions & 0 deletions src/playwright/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import global from 'global';
import type { Page } from 'playwright';

export type TestContext = {
id: string;
title: string;
name: string;
};

export type TestHook = (page: Page, context: TestContext) => Promise<void>;

export const setPreRender = (preRender: TestHook) => {
global.__sbPreRender = preRender;
};

export const setPostRender = (postRender: TestHook) => {
global.__sbPostRender = postRender;
};
75 changes: 66 additions & 9 deletions src/playwright/transformPlaywright.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,17 @@ describe('Playwright', () => {
filename
)
).toMatchInlineSnapshot(`
import global from 'global';

if (!require.main) {
describe("foo/bar", () => {
describe("A", () => {
it("play-test", async () => {
const context = {
id: "foo-bar--a",
title: "foo/bar",
name: "A"
};
page.on('pageerror', err => {
page.evaluate(({
id,
Expand All @@ -59,11 +66,23 @@ describe('Playwright', () => {
err: err.message
});
});
return page.evaluate(({
id
}) => __test(id), {

if (global.__sbPreRender) {
await global.__sbPreRender(page, context);
}

const result = await page.evaluate(({
id,
hasPlayFn
}) => __test(id, hasPlayFn), {
id: "foo-bar--a"
});

if (global.__sbPostRender) {
await global.__sbPostRender(page, context);
}

return result;
});
});
});
Expand All @@ -80,10 +99,17 @@ describe('Playwright', () => {
filename
)
).toMatchInlineSnapshot(`
import global from 'global';

if (!require.main) {
describe("foo/bar", () => {
describe("A", () => {
it("smoke-test", async () => {
const context = {
id: "foo-bar--a",
title: "foo/bar",
name: "A"
};
page.on('pageerror', err => {
page.evaluate(({
id,
Expand All @@ -93,11 +119,23 @@ describe('Playwright', () => {
err: err.message
});
});
return page.evaluate(({
id
}) => __test(id), {

if (global.__sbPreRender) {
await global.__sbPreRender(page, context);
}

const result = await page.evaluate(({
id,
hasPlayFn
}) => __test(id, hasPlayFn), {
id: "foo-bar--a"
});

if (global.__sbPostRender) {
await global.__sbPostRender(page, context);
}

return result;
});
});
});
Expand All @@ -115,10 +153,17 @@ describe('Playwright', () => {
filename
)
).toMatchInlineSnapshot(`
import global from 'global';

if (!require.main) {
describe("Example/Header", () => {
describe("A", () => {
it("smoke-test", async () => {
const context = {
id: "example-header--a",
title: "Example/Header",
name: "A"
};
page.on('pageerror', err => {
page.evaluate(({
id,
Expand All @@ -128,11 +173,23 @@ describe('Playwright', () => {
err: err.message
});
});
return page.evaluate(({
id
}) => __test(id), {

if (global.__sbPreRender) {
await global.__sbPreRender(page, context);
}

const result = await page.evaluate(({
id,
hasPlayFn
}) => __test(id, hasPlayFn), {
id: "example-header--a"
});

if (global.__sbPostRender) {
await global.__sbPostRender(page, context);
}

return result;
});
});
});
Expand Down
21 changes: 19 additions & 2 deletions src/playwright/transformPlaywright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,33 @@ import { autoTitle } from '@storybook/store';

import { transformCsf } from '../csf/transformCsf';

const filePrefixer = template(`
import global from 'global';
`);

export const testPrefixer = template(
`
console.log({ id: %%id%%, title: %%title%%, name: %%name%%, storyExport: %%storyExport%% });
async () => {
const context = { id: %%id%%, title: %%title%%, name: %%name%% };

page.on('pageerror', (err) => {
page.evaluate(({ id, err }) => __throwError(id, err), { id: %%id%%, err: err.message });
});

return page.evaluate(({ id }) => __test(id), {
id: %%id%%
if(global.__sbPreRender) {
await global.__sbPreRender(page, context);
}

const result = await page.evaluate(({ id, hasPlayFn }) => __test(id, hasPlayFn), {
id: %%id%%,
});

if(global.__sbPostRender) {
await global.__sbPostRender(page, context);
}

return result;
}
`,
{
Expand Down Expand Up @@ -51,6 +67,7 @@ const getDefaultTitle = (filename: string) => {
export const transformPlaywright = (src: string, filename: string) => {
const defaultTitle = getDefaultTitle(filename);
const result = transformCsf(src, {
filePrefixer,
// @ts-ignore
testPrefixer,
insertTestIfEmpty: true,
Expand Down
Loading