diff --git a/.circleci/config.yml b/.circleci/config.yml
index b34288f775cb..1cd268cbbe1b 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -47,6 +47,19 @@ executors:
environment:
NODE_OPTIONS: --max_old_space_size=6144
resource_class: <>
+ sb_playwright_component_testing:
+ parameters:
+ class:
+ description: The Resource class
+ type: enum
+ enum: ["small", "medium", "medium+", "large", "xlarge"]
+ default: "small"
+ working_directory: /tmp/storybook
+ docker:
+ - image: mcr.microsoft.com/playwright:v1.42.1-jammy
+ environment:
+ NODE_OPTIONS: --max_old_space_size=6144
+ resource_class: <>
orbs:
git-shallow-clone: guitarrapc/git-shallow-clone@2.5.0
@@ -565,7 +578,39 @@ jobs:
STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >>
STORYBOOK_DISABLE_TELEMETRY: true
- report-workflow-on-failure
-
+ test-portable-stories:
+ parameters:
+ directory:
+ type: string
+ executor:
+ name: sb_playwright_component_testing
+ class: medium
+ steps:
+ - git-shallow-clone/checkout_advanced:
+ clone_options: "--depth 1 --verbose"
+ - attach_workspace:
+ at: .
+ - run:
+ name: Install dependencies
+ command: yarn install
+ working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
+ - run:
+ name: Run Jest tests
+ command: yarn jest
+ working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
+ - run:
+ name: Run Vitest tests
+ command: yarn vitest
+ working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
+ - run:
+ name: Run Playwright CT tests
+ command: yarn playwright
+ working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
+ - run:
+ name: Run Cypress CT tests
+ command: yarn cypress
+ working_directory: test-storybooks/portable-stories-kitchen-sink/<< parameters.directory >>
+ - report-workflow-on-failure
workflows:
docs:
when:
@@ -624,6 +669,12 @@ workflows:
parallelism: 5
requires:
- build-sandboxes
+ - test-portable-stories:
+ requires:
+ - build
+ matrix:
+ parameters:
+ directory: ["react", "vue3", "nextjs", "svelte"]
# TODO: reenable once we find out the source of flakyness
# - test-runner-dev:
# requires:
@@ -676,6 +727,12 @@ workflows:
parallelism: 14
requires:
- build-sandboxes
+ - test-portable-stories:
+ requires:
+ - build
+ matrix:
+ parameters:
+ directory: ["react", "vue3", "nextjs", "svelte"]
- bench:
parallelism: 5
requires:
@@ -733,7 +790,12 @@ workflows:
parallelism: 30
requires:
- build-sandboxes
-
+ - test-portable-stories:
+ requires:
+ - build
+ matrix:
+ parameters:
+ directory: ["react", "vue3", "nextjs", "svelte"]
- test-empty-init:
requires:
- build
diff --git a/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch b/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch
new file mode 100644
index 000000000000..212dfcc7d0ea
--- /dev/null
+++ b/code/.yarn/patches/@testing-library-svelte-npm-4.1.0-34b7037bc0.patch
@@ -0,0 +1,97 @@
+diff --git a/package.json b/package.json
+index 195dac9ee7d42fdb76bb22dc37580fa0bffd4680..980ad42f41a06023f9f7e370fd382c9217c24be5 100644
+--- a/package.json
++++ b/package.json
+@@ -55,7 +55,7 @@
+ "contributors:generate": "all-contributors generate"
+ },
+ "peerDependencies": {
+- "svelte": "^3 || ^4"
++ "svelte": "^3 || ^4 || ^5"
+ },
+ "dependencies": {
+ "@testing-library/dom": "^9.3.1"
+diff --git a/src/pure.js b/src/pure.js
+index 6d4943412448c9f310f007ca7dab9d04cef90d0d..d62f4aebeb1b23ccc3c3d82aadd67075c6507c0e 100644
+--- a/src/pure.js
++++ b/src/pure.js
+@@ -3,7 +3,7 @@ import {
+ getQueriesForElement,
+ prettyDOM
+ } from '@testing-library/dom'
+-import { tick } from 'svelte'
++import { tick, mount, unmount } from 'svelte'
+
+ const containerCache = new Set()
+ const componentCache = new Set()
+@@ -54,40 +54,34 @@ const render = (
+ return { props: options }
+ }
+
+- let component = new ComponentConstructor({
++ let component = mount(ComponentConstructor, {
+ target,
+- ...checkProps(options)
++ ...checkProps(options),
++ ondestroy: () => componentCache.delete(component)
+ })
+
+ containerCache.add({ container, target, component })
+ componentCache.add(component)
+
+- component.$$.on_destroy.push(() => {
+- componentCache.delete(component)
+- })
+-
+ return {
+ container,
+ component,
+ debug: (el = container) => console.log(prettyDOM(el)),
+ rerender: (options) => {
+- if (componentCache.has(component)) component.$destroy()
++ if (componentCache.has(component)) unmount(component)
+
+ // eslint-disable-next-line no-new
+ component = new ComponentConstructor({
+ target,
+- ...checkProps(options)
++ ...checkProps(options),
++ ondestroy: () => componentCache.delete(component)
+ })
+
+ containerCache.add({ container, target, component })
+ componentCache.add(component)
+-
+- component.$$.on_destroy.push(() => {
+- componentCache.delete(component)
+- })
+ },
+ unmount: () => {
+- if (componentCache.has(component)) component.$destroy()
++ if (componentCache.has(component)) unmount(component)
+ },
+ ...getQueriesForElement(container, queries)
+ }
+@@ -96,7 +90,7 @@ const render = (
+ const cleanupAtContainer = (cached) => {
+ const { target, component } = cached
+
+- if (componentCache.has(component)) component.$destroy()
++ if (componentCache.has(component)) unmount(component)
+
+ if (target.parentNode === document.body) {
+ document.body.removeChild(target)
+@@ -109,9 +103,10 @@ const cleanup = () => {
+ Array.from(containerCache.keys()).forEach(cleanupAtContainer)
+ }
+
+-const act = async (fn) => {
+- if (fn) {
+- await fn()
++const act = (fn) => {
++ const value = fn && fn()
++ if (value !== undefined && typeof value.then === 'function') {
++ return value.then(() => tick())
+ }
+ return tick()
+ }
diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json
index 2dc509c9a535..e1148efa3066 100644
--- a/code/addons/a11y/package.json
+++ b/code/addons/a11y/package.json
@@ -32,8 +32,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/actions/package.json b/code/addons/actions/package.json
index 3daab0e6523b..2e3772547dfd 100644
--- a/code/addons/actions/package.json
+++ b/code/addons/actions/package.json
@@ -33,8 +33,12 @@
"require": "./dist/decorator.js",
"import": "./dist/decorator.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register.js": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/backgrounds/package.json b/code/addons/backgrounds/package.json
index e1628ec59e9c..ab4c5ad3051c 100644
--- a/code/addons/backgrounds/package.json
+++ b/code/addons/backgrounds/package.json
@@ -32,8 +32,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/essentials/package.json b/code/addons/essentials/package.json
index bd5e7c1d3042..f2122af1de0d 100644
--- a/code/addons/essentials/package.json
+++ b/code/addons/essentials/package.json
@@ -28,22 +28,50 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
- "./actions/preview": "./dist/actions/preview.js",
+ "./actions/preview": {
+ "types": "./dist/actions/preview.d.ts",
+ "import": "./dist/actions/preview.mjs",
+ "require": "./dist/actions/preview.js"
+ },
"./actions/manager": "./dist/actions/manager.js",
- "./backgrounds/preview": "./dist/backgrounds/preview.js",
+ "./backgrounds/preview": {
+ "types": "./dist/backgrounds/preview.d.ts",
+ "import": "./dist/backgrounds/preview.mjs",
+ "require": "./dist/backgrounds/preview.js"
+ },
"./backgrounds/manager": "./dist/backgrounds/manager.js",
"./controls/manager": "./dist/controls/manager.js",
- "./docs/preview": "./dist/docs/preview.js",
+ "./docs/preview": {
+ "types": "./dist/docs/preview.d.ts",
+ "import": "./dist/docs/preview.mjs",
+ "require": "./dist/docs/preview.js"
+ },
"./docs/preset": "./dist/docs/preset.js",
"./docs/mdx-react-shim": "./dist/docs/mdx-react-shim.js",
- "./highlight/preview": "./dist/highlight/preview.js",
- "./measure/preview": "./dist/measure/preview.js",
+ "./highlight/preview": {
+ "types": "./dist/highlight/preview.d.ts",
+ "import": "./dist/highlight/preview.mjs",
+ "require": "./dist/highlight/preview.js"
+ },
+ "./measure/preview": {
+ "types": "./dist/measure/preview.d.ts",
+ "import": "./dist/measure/preview.mjs",
+ "require": "./dist/measure/preview.js"
+ },
"./measure/manager": "./dist/measure/manager.js",
- "./outline/preview": "./dist/outline/preview.js",
+ "./outline/preview": {
+ "types": "./dist/outline/preview.d.ts",
+ "import": "./dist/outline/preview.mjs",
+ "require": "./dist/outline/preview.js"
+ },
"./outline/manager": "./dist/outline/manager.js",
"./toolbars/manager": "./dist/toolbars/manager.js",
"./viewport/manager": "./dist/viewport/manager.js",
- "./viewport/preview": "./dist/viewport/preview.js",
+ "./viewport/preview": {
+ "types": "./dist/viewport/preview.d.ts",
+ "import": "./dist/viewport/preview.mjs",
+ "require": "./dist/viewport/preview.js"
+ },
"./package.json": "./package.json"
},
"main": "dist/index.js",
diff --git a/code/addons/essentials/src/measure/preview.ts b/code/addons/essentials/src/measure/preview.ts
index 647ef4345a6d..c34063ac4ca4 100644
--- a/code/addons/essentials/src/measure/preview.ts
+++ b/code/addons/essentials/src/measure/preview.ts
@@ -1,2 +1 @@
-// @ts-expect-error (no types needed for this)
export * from '@storybook/addon-measure/preview';
diff --git a/code/addons/highlight/package.json b/code/addons/highlight/package.json
index 4a5d8848e411..6e0383f755a3 100644
--- a/code/addons/highlight/package.json
+++ b/code/addons/highlight/package.json
@@ -30,7 +30,11 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
- "./preview": "./dist/preview.js",
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./package.json": "./package.json"
},
"main": "dist/index.js",
diff --git a/code/addons/highlight/src/preview.ts b/code/addons/highlight/src/preview.ts
index 1948f7b39d97..794417ef0d9d 100644
--- a/code/addons/highlight/src/preview.ts
+++ b/code/addons/highlight/src/preview.ts
@@ -8,18 +8,12 @@ const { document } = global;
type OutlineStyle = 'dotted' | 'dashed' | 'solid' | 'double';
-export const highlightStyle = (color = '#FF4785', style: OutlineStyle = 'dashed') => `
+const highlightStyle = (color = '#FF4785', style: OutlineStyle = 'dashed') => `
outline: 2px ${style} ${color};
outline-offset: 2px;
box-shadow: 0 0 0 6px rgba(255,255,255,0.6);
`;
-export const highlightObject = (color: string) => ({
- outline: `2px dashed ${color}`,
- outlineOffset: 2,
- boxShadow: '0 0 0 6px rgba(255,255,255,0.6)',
-});
-
interface HighlightInfo {
/** html selector of the element */
elements: string[];
diff --git a/code/addons/interactions/package.json b/code/addons/interactions/package.json
index f39a3065e103..9b6c22d96f90 100644
--- a/code/addons/interactions/package.json
+++ b/code/addons/interactions/package.json
@@ -28,8 +28,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./preset": "./dist/preset.js",
"./register.js": "./dist/manager.js",
"./package.json": "./package.json"
diff --git a/code/addons/links/package.json b/code/addons/links/package.json
index 4effdb357a7c..d5269011dc6b 100644
--- a/code/addons/links/package.json
+++ b/code/addons/links/package.json
@@ -33,8 +33,12 @@
"require": "./dist/react/index.js",
"import": "./dist/react/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/measure/package.json b/code/addons/measure/package.json
index 71616e30727c..2e5c905ba64b 100644
--- a/code/addons/measure/package.json
+++ b/code/addons/measure/package.json
@@ -31,8 +31,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/outline/package.json b/code/addons/outline/package.json
index 96f83f0b4b15..07fb23746bce 100644
--- a/code/addons/outline/package.json
+++ b/code/addons/outline/package.json
@@ -34,8 +34,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./register": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json
index 2724c1b9450b..a55582e17c84 100644
--- a/code/addons/themes/package.json
+++ b/code/addons/themes/package.json
@@ -33,8 +33,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
- "./preview": "./dist/preview.js",
"./package.json": "./package.json",
"./postinstall": "./postinstall.js"
},
diff --git a/code/addons/viewport/package.json b/code/addons/viewport/package.json
index eecdb09fb296..45bf6471994f 100644
--- a/code/addons/viewport/package.json
+++ b/code/addons/viewport/package.json
@@ -29,7 +29,11 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
- "./preview": "./dist/preview.js",
+ "./preview": {
+ "types": "./dist/preview.d.ts",
+ "require": "./dist/preview.js",
+ "import": "./dist/preview.mjs"
+ },
"./manager": "./dist/manager.js",
"./package.json": "./package.json"
},
diff --git a/code/frameworks/nextjs/src/index.ts b/code/frameworks/nextjs/src/index.ts
index fcb073fefcd6..a904f93ec89d 100644
--- a/code/frameworks/nextjs/src/index.ts
+++ b/code/frameworks/nextjs/src/index.ts
@@ -1 +1,2 @@
export * from './types';
+export * from './portable-stories';
diff --git a/code/frameworks/nextjs/src/portable-stories.ts b/code/frameworks/nextjs/src/portable-stories.ts
new file mode 100644
index 000000000000..01948d524c2c
--- /dev/null
+++ b/code/frameworks/nextjs/src/portable-stories.ts
@@ -0,0 +1,126 @@
+import {
+ composeStory as originalComposeStory,
+ composeStories as originalComposeStories,
+ setProjectAnnotations as originalSetProjectAnnotations,
+ composeConfigs,
+} from '@storybook/preview-api';
+import type {
+ Args,
+ ProjectAnnotations,
+ StoryAnnotationsOrFn,
+ Store_CSFExports,
+ StoriesWithPartialProps,
+} from '@storybook/types';
+
+// ! ATTENTION: This needs to be a relative import so it gets prebundled. This is to avoid ESM issues in Nextjs + Jest setups
+import { INTERNAL_DEFAULT_PROJECT_ANNOTATIONS as reactAnnotations } from '../../../renderers/react/src/portable-stories';
+import * as nextJsAnnotations from './preview';
+
+import type { ReactRenderer, Meta } from '@storybook/react';
+
+/** Function that sets the globalConfig of your storybook. The global config is the preview module of your .storybook folder.
+ *
+ * It should be run a single time, so that your global config (e.g. decorators) is applied to your stories when using `composeStories` or `composeStory`.
+ *
+ * Example:
+ *```jsx
+ * // setup.js (for jest)
+ * import { setProjectAnnotations } from '@storybook/nextjs';
+ * import projectAnnotations from './.storybook/preview';
+ *
+ * setProjectAnnotations(projectAnnotations);
+ *```
+ *
+ * @param projectAnnotations - e.g. (import projectAnnotations from '../.storybook/preview')
+ */
+export function setProjectAnnotations(
+ projectAnnotations: ProjectAnnotations | ProjectAnnotations[]
+) {
+ originalSetProjectAnnotations(projectAnnotations);
+}
+
+// This will not be necessary once we have auto preset loading
+const defaultProjectAnnotations: ProjectAnnotations = composeConfigs([
+ reactAnnotations,
+ nextJsAnnotations,
+]);
+
+/**
+ * Function that will receive a story along with meta (e.g. a default export from a .stories file)
+ * and optionally projectAnnotations e.g. (import * from '../.storybook/preview)
+ * and will return a composed component that has all args/parameters/decorators/etc combined and applied to it.
+ *
+ *
+ * It's very useful for reusing a story in scenarios outside of Storybook like unit testing.
+ *
+ * Example:
+ *```jsx
+ * import { render } from '@testing-library/react';
+ * import { composeStory } from '@storybook/nextjs';
+ * import Meta, { Primary as PrimaryStory } from './Button.stories';
+ *
+ * const Primary = composeStory(PrimaryStory, Meta);
+ *
+ * test('renders primary button with Hello World', () => {
+ * const { getByText } = render(Hello world);
+ * expect(getByText(/Hello world/i)).not.toBeNull();
+ * });
+ *```
+ *
+ * @param story
+ * @param componentAnnotations - e.g. (import Meta from './Button.stories')
+ * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files.
+ * @param [exportsName] - in case your story does not contain a name and you want it to have a name.
+ */
+export function composeStory(
+ story: StoryAnnotationsOrFn,
+ componentAnnotations: Meta,
+ projectAnnotations?: ProjectAnnotations,
+ exportsName?: string
+) {
+ return originalComposeStory(
+ story as StoryAnnotationsOrFn,
+ componentAnnotations,
+ projectAnnotations,
+ defaultProjectAnnotations,
+ exportsName
+ );
+}
+
+/**
+ * Function that will receive a stories import (e.g. `import * as stories from './Button.stories'`)
+ * and optionally projectAnnotations (e.g. `import * from '../.storybook/preview`)
+ * and will return an object containing all the stories passed, but now as a composed component that has all args/parameters/decorators/etc combined and applied to it.
+ *
+ *
+ * It's very useful for reusing stories in scenarios outside of Storybook like unit testing.
+ *
+ * Example:
+ *```jsx
+ * import { render } from '@testing-library/react';
+ * import { composeStories } from '@storybook/nextjs';
+ * import * as stories from './Button.stories';
+ *
+ * const { Primary, Secondary } = composeStories(stories);
+ *
+ * test('renders primary button with Hello World', () => {
+ * const { getByText } = render(Hello world);
+ * expect(getByText(/Hello world/i)).not.toBeNull();
+ * });
+ *```
+ *
+ * @param csfExports - e.g. (import * as stories from './Button.stories')
+ * @param [projectAnnotations] - e.g. (import * as projectAnnotations from '../.storybook/preview') this can be applied automatically if you use `setProjectAnnotations` in your setup files.
+ */
+export function composeStories>(
+ csfExports: TModule,
+ projectAnnotations?: ProjectAnnotations
+) {
+ // @ts-expect-error (Converted from ts-ignore)
+ const composedStories = originalComposeStories(csfExports, projectAnnotations, composeStory);
+
+ return composedStories as unknown as Omit<
+ StoriesWithPartialProps,
+ keyof Store_CSFExports
+ >;
+}
diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts
index 3eeb6ea86ed8..57c812109e89 100644
--- a/code/lib/instrumenter/src/instrumenter.ts
+++ b/code/lib/instrumenter/src/instrumenter.ts
@@ -102,7 +102,7 @@ export class Instrumenter {
// Restore state from the parent window in case the iframe was reloaded.
// @ts-expect-error (TS doesn't know about this global variable)
- this.state = global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};
+ this.state = global.window?.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};
// When called from `start`, isDebugging will be true.
const resetState = ({
@@ -242,8 +242,10 @@ export class Instrumenter {
const patch = typeof update === 'function' ? update(state) : update;
this.state = { ...this.state, [storyId]: { ...state, ...patch } };
// Track state on the parent window so we can reload the iframe without losing state.
- // @ts-expect-error (TS doesn't know about this global variable)
- global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
+ if (global.window?.parent) {
+ // @ts-expect-error fix this later in d.ts file
+ global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
+ }
}
cleanup() {
@@ -259,8 +261,10 @@ export class Instrumenter {
);
const payload: SyncPayload = { controlStates: controlsDisabled, logItems: [] };
this.channel.emit(EVENTS.SYNC, payload);
- // @ts-expect-error (TS doesn't know about this global variable)
- global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
+ if (global.window?.parent) {
+ // @ts-expect-error fix this later in d.ts file
+ global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state;
+ }
}
getLog(storyId: string): LogItem[] {
@@ -426,7 +430,7 @@ export class Instrumenter {
const { flags, source } = value;
return { __regexp__: { flags, source } };
}
- if (value instanceof global.window.HTMLElement) {
+ if (value instanceof global.window?.HTMLElement) {
const { prefix, localName, id, classList, innerText } = value;
const classNames = Array.from(classList);
return { __element__: { prefix, localName, id, classNames, innerText } };
@@ -640,23 +644,23 @@ export function instrument>(
let forceInstrument = false;
let skipInstrument = false;
- if (global.window.location?.search?.includes('instrument=true')) {
+ if (global.window?.location?.search?.includes('instrument=true')) {
forceInstrument = true;
- } else if (global.window.location?.search?.includes('instrument=false')) {
+ } else if (global.window?.location?.search?.includes('instrument=false')) {
skipInstrument = true;
}
// Don't do any instrumentation if not loaded in an iframe unless it's forced - instrumentation can also be skipped.
- if ((global.window.parent === global.window && !forceInstrument) || skipInstrument) {
+ if ((global.window?.parent === global.window && !forceInstrument) || skipInstrument) {
return obj;
}
// Only create an instance if we don't have one (singleton) yet.
- if (!global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) {
+ if (global.window && !global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) {
global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__ = new Instrumenter();
}
- const instrumenter: Instrumenter = global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__;
+ const instrumenter: Instrumenter = global.window?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__;
return instrumenter.instrument(obj, options);
} catch (e) {
// Access to the parent window might fail due to CORS restrictions.
diff --git a/code/lib/preview-api/src/index.ts b/code/lib/preview-api/src/index.ts
index e47cdaa0a0dd..779cfeba1557 100644
--- a/code/lib/preview-api/src/index.ts
+++ b/code/lib/preview-api/src/index.ts
@@ -62,6 +62,8 @@ export {
sortStoriesV7,
} from './store';
+export { createPlaywrightTest } from './modules/store/csf/portable-stories';
+
export type { PropDescriptor } from './store';
/**
diff --git a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts
index 47465eacc8e5..57e8fcda9a2b 100644
--- a/code/lib/preview-api/src/modules/store/csf/portable-stories.ts
+++ b/code/lib/preview-api/src/modules/store/csf/portable-stories.ts
@@ -1,5 +1,7 @@
+/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/naming-convention */
import { isExportStory } from '@storybook/csf';
+import dedent from 'ts-dedent';
import type {
Renderer,
Args,
@@ -161,3 +163,68 @@ export function composeStories(
return composedStories;
}
+
+type WrappedStoryRef = { __pw_type: 'jsx' | 'importRef' };
+type UnwrappedJSXStoryRef = {
+ __pw_type: 'jsx';
+ type: ComposedStoryFn;
+};
+type UnwrappedImportStoryRef = ComposedStoryFn;
+
+declare global {
+ function __pwUnwrapObject(
+ storyRef: WrappedStoryRef
+ ): Promise;
+}
+
+export function createPlaywrightTest(
+ baseTest: TFixture
+): TFixture {
+ return baseTest.extend({
+ mount: async ({ mount, page }: any, use: any) => {
+ await use(async (storyRef: WrappedStoryRef, ...restArgs: any) => {
+ // Playwright CT deals with JSX import references differently than normal imports
+ // and we can currently only handle JSX import references
+ if (
+ !('__pw_type' in storyRef) ||
+ ('__pw_type' in storyRef && storyRef.__pw_type !== 'jsx')
+ ) {
+ // eslint-disable-next-line local-rules/no-uncategorized-errors
+ throw new Error(dedent`
+ Portable stories in Playwright CT only work when referencing JSX elements.
+ Please use JSX format for your components such as:
+
+ instead of:
+ await mount(MyComponent, { props: { foo: 'bar' } })
+
+ do:
+ await mount()
+
+ More info: https://storybook.js.org/docs/api/portable-stories-playwright
+ `);
+ }
+
+ await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => {
+ const unwrappedStoryRef = await globalThis.__pwUnwrapObject?.(wrappedStoryRef);
+ const story =
+ '__pw_type' in unwrappedStoryRef ? unwrappedStoryRef.type : unwrappedStoryRef;
+ return story?.load?.();
+ }, storyRef);
+
+ // mount the story
+ const mountResult = await mount(storyRef, ...restArgs);
+
+ // play the story in the browser
+ await page.evaluate(async (wrappedStoryRef: WrappedStoryRef) => {
+ const unwrappedStoryRef = await globalThis.__pwUnwrapObject?.(wrappedStoryRef);
+ const story =
+ '__pw_type' in unwrappedStoryRef ? unwrappedStoryRef.type : unwrappedStoryRef;
+ const canvasElement = document.querySelector('#root');
+ return story?.play?.({ canvasElement });
+ }, storyRef);
+
+ return mountResult;
+ });
+ },
+ });
+}
diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index a73eb90118ce..9a46313c53ff 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -26,6 +26,12 @@
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
+ "./experimental-playwright": {
+ "types": "./dist/playwright.d.ts",
+ "node": "./dist/playwright.js",
+ "require": "./dist/playwright.js",
+ "import": "./dist/playwright.mjs"
+ },
"./preset": "./preset.js",
"./dist/entry-preview.mjs": "./dist/entry-preview.mjs",
"./dist/entry-preview-docs.mjs": "./dist/entry-preview-docs.mjs",
@@ -101,7 +107,8 @@
"./src/preset.ts",
"./src/entry-preview.ts",
"./src/entry-preview-docs.ts",
- "./src/entry-preview-rsc.tsx"
+ "./src/entry-preview-rsc.tsx",
+ "./src/playwright.ts"
],
"platform": "browser"
},
diff --git a/code/renderers/react/src/__test__/Button.stories.tsx b/code/renderers/react/src/__test__/Button.stories.tsx
index 277f92ddde1f..fc78c1f27d63 100644
--- a/code/renderers/react/src/__test__/Button.stories.tsx
+++ b/code/renderers/react/src/__test__/Button.stories.tsx
@@ -4,6 +4,8 @@ import type { StoryFn as CSF2Story, StoryObj as CSF3Story, Meta } from '..';
import type { ButtonProps } from './Button';
import { Button } from './Button';
+import type { HandlerFunction } from '@storybook/addon-actions';
+import { action } from '@storybook/addon-actions';
const meta = {
title: 'Example/Button',
@@ -124,3 +126,35 @@ export const LoaderStory: CSF3Story<{ mockFn: (val: string) => string }> = {
expect(mockFn).toHaveBeenCalledWith('render');
},
};
+
+export const WithActionArg: CSF3Story<{ someActionArg: HandlerFunction }> = {
+ args: {
+ someActionArg: action('some-action-arg'),
+ },
+ render: (args) => {
+ args.someActionArg('in render');
+ return (
+
+
+
+
+
+
+