Skip to content

Commit

Permalink
Merge pull request #245 from storybookjs/feat/provide-prepare-api-and…
Browse files Browse the repository at this point in the history
…-browser-context

Extend hooks api with `prepare` and `getHttpHeaders` properties
  • Loading branch information
yannbf committed Mar 6, 2023
2 parents 67cc43c + 714d3ca commit f2191b7
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 38 deletions.
4 changes: 0 additions & 4 deletions .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ let stories = [
'../stories/pages/**/*.stories.@(js|jsx|ts|tsx)',
];

if (process.env.STRESS_TEST) {
stories.push('../stories/stress-test/*.stories.@(js|jsx|ts|tsx)');
}

if (process.env.TEST_FAILURES) {
stories = ['../stories/expected-failures/*.stories.@(js|jsx|ts|tsx)'];
}
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Storybook test runner turns all of your stories into executable tests.
- [2 - Run tests with --coverage flag](#2---run-tests-with---coverage-flag)
- [3 - Merging code coverage with coverage from other tools](#3---merging-code-coverage-with-coverage-from-other-tools)
- [Experimental test hook API](#experimental-test-hook-api)
- [prepare](#prepare)
- [getHttpHeaders](#gethttpheaders)
- [DOM snapshot recipe](#dom-snapshot-recipe)
- [Image snapshot recipe](#image-snapshot-recipe)
- [Render lifecycle](#render-lifecycle)
Expand Down Expand Up @@ -412,10 +414,43 @@ To enable use cases like visual or DOM snapshots, the test runner exports test h

There are three hooks: `setup`, `preRender`, and `postRender`. `setup` executes once before all the tests run. `preRender` and `postRender` execute within a test before and after a story is rendered.

The render functions are async functions that receive 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.
The render functions are async functions that receive a [Playwright Page](https://playwright.dev/docs/pages) and a context object with the current story's `id`, `title`, and `name`. They are globally settable by `@storybook/test-runner`'s `setPreRender` and `setPostRender` APIs.

All three functions can be set up in the configuration file `.storybook/test-runner.js` which can optionally export any of these functions.

Apart from these hooks, there are additional properties you can set in `.storybook/test-runner.js`:

#### prepare

The test-runner has a default `prepare` function which gets the browser in the right environment before testing the stories. You can override this behavior, in case you might want to hack the behavior of the browser. For example, you might want to set a cookie, or add query parameters to the visiting URL, or do some authentication before reaching the Storybook URL. You can do that by overriding the `prepare` function.

The `prepare` function receives an object containing:

- `browserContext`: a [Playwright Browser Context](https://playwright.dev/docs/api/class-browsercontext) instance
- `page`: a [Playwright Page](https://playwright.dev/docs/api/class-page) instance.
- `testRunnerConfig`: the test runner configuration object, coming from the `.storybook/test-runner.js`.

For reference, please use the [default `prepare`](https://github.com/storybookjs/test-runner/blob/next/src/setup-page.ts#L12) function as a starting point.

> **Note**
> If you override the default prepare behavior, even though this is powerful, you will be responsible for properly preparing the browser. Future changes to the default prepare function will not get included in your project, so you will have to keep an eye out for changes in upcoming releases.

#### getHttpHeaders

The test-runner makes a few `fetch` calls to check the status of a Storybook instance, and to get the index of the Storybook's stories. Additionally, it visits a page using Playwright. In all of these scenarios, it's possible, depending on where your Storybook is hosted, that you might need to set some HTTP headers. For example, if your Storybook is hosted behind a basic authentication, you might need to set the `Authorization` header. You can do so by passing a `getHttpHeaders` function to your test-runner config. That function receives the `url` of the fetch calls and page visits, and should return an object with the headers to be set.

```js
// .storybook/test-runner.js
module.exports = {
getHttpHeaders: async (url) => {
const token = url.includes('prod') ? 'XYZ' : 'ABC';
return {
Authorization: `Bearer ${token}`,
};
},
};
```

> **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.
Expand Down
22 changes: 18 additions & 4 deletions bin/test-storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const path = require('path');
const tempy = require('tempy');
const { getCliOptions } = require('../dist/cjs/util/getCliOptions');
const { getStorybookMetadata } = require('../dist/cjs/util/getStorybookMetadata');
const { getTestRunnerConfig } = require('../dist/cjs/util/getTestRunnerConfig');
const { transformPlaywrightJson } = require('../dist/cjs/playwright/transformPlaywrightJson');

const glob_og = require('glob');
Expand All @@ -27,6 +28,8 @@ process.env.NODE_ENV = 'test';
process.env.STORYBOOK_TEST_RUNNER = 'true';
process.env.PUBLIC_URL = '';

let getHttpHeaders = (_url) => Promise.resolve({});

// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
Expand Down Expand Up @@ -142,7 +145,8 @@ async function executeJestPlaywright(args) {

async function checkStorybook(url) {
try {
const res = await fetch(url, { method: 'HEAD' });
const headers = await getHttpHeaders(url);
const res = await fetch(url, { method: 'HEAD', headers });
if (res.status !== 200) throw new Error(`Unxpected status: ${res.status}`);
} catch (e) {
console.error(
Expand All @@ -161,8 +165,13 @@ async function checkStorybook(url) {
async function getIndexJson(url) {
const indexJsonUrl = new URL('index.json', url).toString();
const storiesJsonUrl = new URL('stories.json', url).toString();
const headers = await getHttpHeaders(url);
const fetchOptions = { headers };

const [indexRes, storiesRes] = await Promise.all([fetch(indexJsonUrl), fetch(storiesJsonUrl)]);
const [indexRes, storiesRes] = await Promise.all([
fetch(indexJsonUrl, fetchOptions),
fetch(storiesJsonUrl, fetchOptions),
]);

if (indexRes.ok) {
try {
Expand Down Expand Up @@ -236,6 +245,13 @@ const main = async () => {
process.exit(0);
}

process.env.STORYBOOK_CONFIG_DIR = runnerOptions.configDir;

const testRunnerConfig = getTestRunnerConfig(runnerOptions.configDir) || {};
if (testRunnerConfig.getHttpHeaders) {
getHttpHeaders = testRunnerConfig.getHttpHeaders;
}

// set this flag to skip reporting coverage in watch mode
isWatchMode = jestOptions.watch || jestOptions.watchAll;

Expand Down Expand Up @@ -278,8 +294,6 @@ const main = async () => {
process.env.TEST_MATCH = '**/*.test.js';
}

process.env.STORYBOOK_CONFIG_DIR = runnerOptions.configDir;

const { storiesPaths, lazyCompilation } = getStorybookMetadata();
process.env.STORYBOOK_STORIES_PATTERN = storiesPaths;

Expand Down
2 changes: 1 addition & 1 deletion playwright/custom-environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const PlaywrightEnvironment = require('jest-playwright-preset/lib/PlaywrightEnvi
class CustomEnvironment extends PlaywrightEnvironment {
async setup() {
await super.setup();
await setupPage(this.global.page);
await setupPage(this.global.page, this.global.context);
}

async teardown() {
Expand Down
2 changes: 1 addition & 1 deletion playwright/jest-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ if (testRunnerConfig) {
}

// If the transformed tests need a dependency, it has to be globally available
// in order to work both in defaul (file transformation) and stories/index.json mode.
// in order to work both in default (file transformation) and stories/index.json mode.
globalThis.__sbSetupPage = setupPage;
globalThis.__sbCollectCoverage = process.env.STORYBOOK_COLLECT_COVERAGE === 'true';
20 changes: 19 additions & 1 deletion src/playwright/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from 'playwright';
import type { BrowserContext, Page } from 'playwright';
import type { StoryContext } from '@storybook/csf';

export type TestContext = {
Expand All @@ -7,12 +7,30 @@ export type TestContext = {
name: string;
};

export type PrepareContext = {
page: Page;
browserContext: BrowserContext;
testRunnerConfig: TestRunnerConfig;
};

export type TestHook = (page: Page, context: TestContext) => Promise<void>;
export type HttpHeaderSetter = (url: string) => Promise<Record<string, any>>;
export type PrepareSetter = (context: PrepareContext) => Promise<void>;

export interface TestRunnerConfig {
setup?: () => void;
preRender?: TestHook;
postRender?: TestHook;
/**
* Adds http headers to the test-runner's requests. This is useful if you need to set headers such as `Authorization` for your Storybook instance.
*/
getHttpHeaders?: HttpHeaderSetter;
/**
* Overrides the default prepare behavior of the test-runner. Good for customizing the environment before testing, such as authentication etc.
*
* If you override the default prepare behavior, even though this is powerful, you will be responsible for properly preparing the browser. Future changes to the default prepare function will not get included in your project, so you will have to keep an eye out for changes in upcoming releases.
*/
prepare?: PrepareSetter;
}

export const setPreRender = (preRender: TestHook) => {
Expand Down
6 changes: 3 additions & 3 deletions src/playwright/transformPlaywright.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('Playwright', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -165,7 +165,7 @@ describe('Playwright', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/foo/bar"}/\${"A"}". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -236,7 +236,7 @@ describe('Playwright', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: "\${"Example/Header"}/\${"A"}". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down
2 changes: 1 addition & 1 deletion src/playwright/transformPlaywright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const testPrefixer = template(
if(err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: "\${%%title%%}/\${%%name%%}". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down
16 changes: 8 additions & 8 deletions src/playwright/transformPlaywrightJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Header\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -129,7 +129,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Header\\"}/\${\\"Logged Out\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -185,7 +185,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Page\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -266,7 +266,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Page\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -375,7 +375,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Header\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -429,7 +429,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Header\\"}/\${\\"Logged Out\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -485,7 +485,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Page\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down Expand Up @@ -579,7 +579,7 @@ describe('Playwright Json', () => {
if (err.toString().includes('Execution context was destroyed')) {
console.log(\`An error occurred in the following story, most likely because of a navigation: \\"\${\\"Example/Page\\"}/\${\\"Logged In\\"}\\". Retrying...\`);
await jestPlaywright.resetPage();
await globalThis.__sbSetupPage(globalThis.page);
await globalThis.__sbSetupPage(globalThis.page, globalThis.context);
await testFn();
} else {
throw err;
Expand Down
50 changes: 37 additions & 13 deletions src/setup-page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import type { Page } from 'playwright';
import type { Page, BrowserContext } from 'playwright';
import readPackageUp from 'read-pkg-up';
import { PrepareContext } from './playwright/hooks';
import { getTestRunnerConfig } from './util';

/**
* This is a default prepare function which can be overridden by the user.
*
* In order to test stories, we need to prepare the environment first.
* This is done by accessing the /iframe.html page of the Storybook instance. The test-runner then injects a script which prepares the environment for testing, then visits the stories.
*/
const defaultPrepare = async ({ page, browserContext, testRunnerConfig }: PrepareContext) => {
const targetURL = process.env.TARGET_URL;
const iframeURL = new URL('iframe.html', targetURL).toString();

if (testRunnerConfig?.getHttpHeaders) {
const headers = await testRunnerConfig.getHttpHeaders(iframeURL);
await browserContext.setExtraHTTPHeaders(headers);
}

await page.goto(iframeURL, { waitUntil: 'load' }).catch((err) => {
if (err.message?.includes('ERR_CONNECTION_REFUSED')) {
const errorMessage = `Could not access the Storybook instance at ${targetURL}. Are you sure it's running?\n\n${err.message}`;
throw new Error(errorMessage);
}

throw err;
});
};

const sanitizeURL = (url: string) => {
let finalURL = url;
Expand All @@ -22,7 +49,7 @@ const sanitizeURL = (url: string) => {
return finalURL;
};

export const setupPage = async (page: Page) => {
export const setupPage = async (page: Page, browserContext: BrowserContext) => {
const targetURL = process.env.TARGET_URL;

const viewMode = process.env.VIEW_MODE || 'story';
Expand All @@ -36,20 +63,17 @@ export const setupPage = async (page: Page) => {
: 1000;

if ('TARGET_URL' in process.env && !process.env.TARGET_URL) {
console.log(
`Received TARGET_URL but with a falsy value: ${process.env.TARGET_URL}, will fallback to ${targetURL} instead.`
console.warn(
`Received TARGET_URL but with a falsy value: ${process.env.TARGET_URL}. Please fix it.`
);
}

const iframeURL = new URL('iframe.html', process.env.TARGET_URL).toString();
await page.goto(iframeURL, { waitUntil: 'load' }).catch((err) => {
if (err.message?.includes('ERR_CONNECTION_REFUSED')) {
const errorMessage = `Could not access the Storybook instance at ${targetURL}. Are you sure it's running?\n\n${err.message}`;
throw new Error(errorMessage);
}

throw err;
});
const testRunnerConfig = getTestRunnerConfig();
if (testRunnerConfig?.prepare) {
await testRunnerConfig.prepare({ page, browserContext, testRunnerConfig });
} else {
await defaultPrepare({ page, browserContext, testRunnerConfig });
}

// if we ever want to log something from the browser to node
await page.exposeBinding('logToPage', (_, message) => console.log(message));
Expand Down
4 changes: 3 additions & 1 deletion src/util/getTestRunnerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { TestRunnerConfig } from '../playwright/hooks';
let testRunnerConfig: TestRunnerConfig;
let loaded = false;

export const getTestRunnerConfig = (configDir: string): TestRunnerConfig | undefined => {
export const getTestRunnerConfig = (
configDir: string = process.env.STORYBOOK_CONFIG_DIR
): TestRunnerConfig | undefined => {
// testRunnerConfig can be undefined
if (loaded) {
return testRunnerConfig;
Expand Down

0 comments on commit f2191b7

Please sign in to comment.