diff --git a/.storybook/is-test-runner.js b/.storybook/is-test-runner.js new file mode 100644 index 00000000..58b532bf --- /dev/null +++ b/.storybook/is-test-runner.js @@ -0,0 +1,10 @@ +/** + * Returns whether the story is rendering inside of the Storybook test runner. + */ +export function isTestRunner() { + return !!( + typeof window !== 'undefined' && + window && + window.navigator.userAgent.match(/StorybookTestRunner/) + ); +} diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 00000000..9cfd9258 --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,11 @@ +import { isTestRunner } from './is-test-runner'; + +const withSkippableTests = (StoryFn, { parameters }) => { + if (parameters.test?.skip && isTestRunner()) { + return () => {}; + } + + return StoryFn(); +}; + +export const decorators = [withSkippableTests]; diff --git a/README.md b/README.md index 52902649..5d2b7346 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ Storybook test runner turns all of your stories into executable tests. - [DOM snapshot recipe](#dom-snapshot-recipe) - [Image snapshot recipe](#image-snapshot-recipe) - [Render lifecycle](#render-lifecycle) - - [Global utility functions](#global-utility-functions) + - [Utility functions](#utility-functions) + - [getStoryContext](#getstorycontext) + - [StorybookTestRunner user agent](#storybooktestrunner-user-agent) - [Troubleshooting](#troubleshooting) - [The error output in the CLI is too short](#the-error-output-in-the-cli-is-too-short) - [The test runner seems flaky and keeps timing out](#the-test-runner-seems-flaky-and-keeps-timing-out) @@ -475,7 +477,11 @@ it('button--basic', async () => { }); ``` -### Global utility functions +### Utility functions + +For more specific use cases, the test runner provides utility functions that could be useful to you. + +#### getStoryContext While running tests using the hooks, you might want to get information from a story, such as the parameters passed to it, or its args. The test runner now provides a `getStoryContext` utility function that fetches the story context for the current story: @@ -506,7 +512,7 @@ module.exports = { // Apply story-level a11y rules await configureAxe(page, { rules: storyContext.parameters?.a11y?.config?.rules, - }) + }); // from Storybook 7.0 onwards, the selector should be #storybook-root await checkA11y(page, '#root', { @@ -521,6 +527,29 @@ module.exports = { }; ``` +#### StorybookTestRunner user agent + +The test-runner adds a `StorybookTestRunner` entry to the browser's user agent. You can use it to determine if a story is rendering in the context of the test runner. This might be useful if you want to disable certain features in your stories when running in the test runner, though it's likely an edge case. + +```js +export const MyStory = () => { + const isTestRunner = window.navigator.userAgent.match(/StorybookTestRunner/); + return ( +
+

Is this story running in the test runner?

+

{isTestRunner ? 'Yes' : 'No'}

+
+ ); +}; +``` + +Given that this check is happening in the browser, it is only applicable in the following scenarios: + +- inside of a render/template function of a story +- inside of a play function +- inside of preview.js +- inside any other code that is executed in the browser + ## Troubleshooting #### The error output in the CLI is too short @@ -570,3 +599,4 @@ For more context, [here's some explanation](https://github.com/facebook/jest/iss Future plans involve adding support for the following features: - 📄 Run addon reports +- ⚙️ Spawning Storybook via the test runner in a single command diff --git a/bin/test-storybook.js b/bin/test-storybook.js index c3b28c9e..63a0c93e 100755 --- a/bin/test-storybook.js +++ b/bin/test-storybook.js @@ -9,13 +9,13 @@ const fs = require('fs'); const dedent = require('ts-dedent').default; const path = require('path'); const tempy = require('tempy'); -const semver = require('semver'); const { getCliOptions, getStorybookMetadata } = require('../dist/cjs/util'); const { transformPlaywrightJson } = require('../dist/cjs/playwright/transformPlaywrightJson'); // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'test'; process.env.NODE_ENV = 'test'; +process.env.STORYBOOK_TEST_RUNNER = 'true'; process.env.PUBLIC_URL = ''; // Makes the script crash on unhandled rejections instead of silently diff --git a/package.json b/package.json index 8f89297a..619b8e7c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/ts/index.d.ts", + "bin": { + "test-storybook": "./bin/test-storybook.js" + }, "files": [ "bin", "dist/**/*", @@ -52,9 +55,6 @@ "generate-dynamic-stories": "node scripts/generate-dynamic-stories.js", "prepare": "husky install" }, - "bin": { - "test-storybook": "./bin/test-storybook.js" - }, "devDependencies": { "@auto-it/released": "^10.37.1", "@babel/cli": "^7.12.1", @@ -125,6 +125,7 @@ "jest-watch-typeahead": "^2.0.0", "node-fetch": "^2", "playwright": "^1.14.0", + "read-pkg-up": "^7.0.1", "regenerator-runtime": "^0.13.9", "semver": "^7.3.7", "tempy": "^1.0.1", diff --git a/src/setup-page.ts b/src/setup-page.ts index 5b73b688..520551ee 100644 --- a/src/setup-page.ts +++ b/src/setup-page.ts @@ -1,5 +1,5 @@ import type { Page } from 'playwright'; -import dedent from 'ts-dedent'; +import readPackageUp from 'read-pkg-up'; const sanitizeURL = (url: string) => { let finalURL = url; @@ -25,8 +25,9 @@ const sanitizeURL = (url: string) => { export const setupPage = async (page: Page) => { const targetURL = new URL('iframe.html', process.env.TARGET_URL).toString(); const viewMode = process.env.VIEW_MODE || 'story'; - const isCoverageMode = process.env.STORYBOOK_COLLECT_COVERAGE === 'true'; const renderedEvent = viewMode === 'docs' ? 'docsRendered' : 'storyRendered'; + const { packageJson } = await readPackageUp(); + const { version: testRunnerVersion } = packageJson; const referenceURL = process.env.REFERENCE_URL && sanitizeURL(process.env.REFERENCE_URL); const debugPrintLimit = process.env.DEBUG_PRINT_LIMIT @@ -102,6 +103,17 @@ export const setupPage = async (page: Page) => { return input; } + function addToUserAgent(extra) { + const originalUserAgent = globalThis.navigator.userAgent; + if (!originalUserAgent.includes(extra)) { + Object.defineProperty(globalThis.navigator, 'userAgent', { + get: function () { + return [originalUserAgent, extra].join(' '); + }, + }); + } + }; + class StorybookTestRunnerError extends Error { constructor(storyId, errorMessage, logs) { super(errorMessage); @@ -166,6 +178,8 @@ export const setupPage = async (page: Page) => { ); } + addToUserAgent(\`(StorybookTestRunner@${testRunnerVersion})\`); + // collect logs to show upon test error let logs = []; diff --git a/stories/atoms/Button.stories.js b/stories/atoms/Button.stories.js index f4295cbb..2291dc2a 100644 --- a/stories/atoms/Button.stories.js +++ b/stories/atoms/Button.stories.js @@ -1,5 +1,6 @@ import React from 'react'; import { expect } from '@storybook/jest'; +import { isTestRunner } from '../../.storybook/is-test-runner'; import { within, waitFor, userEvent, waitForElementToBeRemoved } from '@storybook/testing-library'; import { Button } from './Button'; @@ -109,3 +110,25 @@ WithLoaders.play = async ({ args, canvasElement }) => { await userEvent.click(todoItem); await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); }; + +export const UserAgent = () => ( +
+

+ isTestRunner: {isTestRunner().toString()} +

+

+ User agent: {window.navigator.userAgent} +

+
+); +UserAgent.play = async () => { + if (isTestRunner()) { + await expect(window.navigator.userAgent).toContain('StorybookTestRunner'); + } +}; +UserAgent.parameters = { + tests: { + skip: true, + disableSnapshots: true, + }, +};