diff --git a/code/addons/interactions/src/Panel.test.ts b/code/addons/interactions/src/Panel.test.ts index 4e2903fd234e..06a1e7278120 100644 --- a/code/addons/interactions/src/Panel.test.ts +++ b/code/addons/interactions/src/Panel.test.ts @@ -7,19 +7,22 @@ describe('Panel', () => { { callId: 'story--id [4] findByText', status: CallStates.DONE, + ancestors: [], }, { callId: 'story--id [5] click', status: CallStates.DONE, + ancestors: [], }, { callId: 'story--id [6] waitFor', status: CallStates.DONE, + ancestors: [], }, { callId: 'story--id [6] waitFor [2] toHaveBeenCalledWith', - parentId: 'story--id [6] waitFor', status: CallStates.DONE, + ancestors: ['story--id [6] waitFor'], }, ]; const calls = new Map( @@ -27,6 +30,7 @@ describe('Panel', () => { { id: 'story--id [0] action', storyId: 'story--id', + ancestors: [], cursor: 0, path: [], method: 'action', @@ -37,6 +41,7 @@ describe('Panel', () => { { id: 'story--id [1] action', storyId: 'story--id', + ancestors: [], cursor: 1, path: [], method: 'action', @@ -47,6 +52,7 @@ describe('Panel', () => { { id: 'story--id [2] action', storyId: 'story--id', + ancestors: [], cursor: 2, path: [], method: 'action', @@ -57,6 +63,7 @@ describe('Panel', () => { { id: 'story--id [3] within', storyId: 'story--id', + ancestors: [], cursor: 3, path: [], method: 'within', @@ -67,6 +74,7 @@ describe('Panel', () => { { id: 'story--id [4] findByText', storyId: 'story--id', + ancestors: [], cursor: 4, path: [{ __callId__: 'story--id [3] within' }], method: 'findByText', @@ -77,6 +85,7 @@ describe('Panel', () => { { id: 'story--id [5] click', storyId: 'story--id', + ancestors: [], cursor: 5, path: ['userEvent'], method: 'click', @@ -86,8 +95,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [0] expect', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 0, path: [], method: 'expect', @@ -97,8 +106,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [1] stringMatching', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 1, path: ['expect'], method: 'stringMatching', @@ -108,8 +117,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [2] toHaveBeenCalledWith', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 2, path: [{ __callId__: 'story--id [6] waitFor [0] expect' }], method: 'toHaveBeenCalledWith', @@ -120,6 +129,7 @@ describe('Panel', () => { { id: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: [], cursor: 6, path: [], method: 'waitFor', @@ -138,6 +148,7 @@ describe('Panel', () => { ...calls.get('story--id [4] findByText'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -145,6 +156,7 @@ describe('Panel', () => { ...calls.get('story--id [5] click'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -152,6 +164,7 @@ describe('Panel', () => { ...calls.get('story--id [6] waitFor'), status: CallStates.DONE, childCallIds: ['story--id [6] waitFor [2] toHaveBeenCalledWith'], + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -159,13 +172,14 @@ describe('Panel', () => { ...calls.get('story--id [6] waitFor [2] toHaveBeenCalledWith'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, ]); }); - it('omits calls for which the parent is collapsed', () => { + it('hides calls for which the parent is collapsed', () => { const withCollapsed = new Set(['story--id [6] waitFor']); expect(getInteractions({ log, calls, collapsed: withCollapsed, setCollapsed })).toEqual([ @@ -173,16 +187,25 @@ describe('Panel', () => { ...calls.get('story--id [4] findByText'), childCallIds: undefined, isCollapsed: false, + isHidden: false, }), expect.objectContaining({ ...calls.get('story--id [5] click'), childCallIds: undefined, isCollapsed: false, + isHidden: false, }), expect.objectContaining({ ...calls.get('story--id [6] waitFor'), childCallIds: ['story--id [6] waitFor [2] toHaveBeenCalledWith'], isCollapsed: true, + isHidden: false, + }), + expect.objectContaining({ + ...calls.get('story--id [6] waitFor [2] toHaveBeenCalledWith'), + childCallIds: undefined, + isCollapsed: false, + isHidden: true, }), ]); }); diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index 960687f98956..2585c506f4d7 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -16,6 +16,7 @@ import { TabIcon, TabStatus } from './components/TabStatus'; interface Interaction extends Call { status: Call['status']; childCallIds: Call['id'][]; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; } @@ -43,16 +44,18 @@ export const getInteractions = ({ const callsById = new Map(); const childCallMap = new Map(); return log - .filter(({ callId, parentId }) => { - if (!parentId) return true; - childCallMap.set(parentId, (childCallMap.get(parentId) || []).concat(callId)); - return !collapsed.has(parentId); + .map(({ callId, ancestors, status }) => { + let isHidden = false; + ancestors.forEach((ancestor) => { + if (collapsed.has(ancestor)) isHidden = true; + childCallMap.set(ancestor, (childCallMap.get(ancestor) || []).concat(callId)); + }); + return { ...calls.get(callId), status, isHidden }; }) - .map(({ callId, status }) => ({ ...calls.get(callId), status } as Call)) .map((call) => { const status = call.status === CallStates.ERROR && - callsById.get(call.parentId)?.status === CallStates.ACTIVE + callsById.get(call.ancestors.slice(-1)[0])?.status === CallStates.ACTIVE ? CallStates.ACTIVE : call.status; callsById.set(call.id, { ...call, status }); @@ -84,7 +87,8 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { const [interactions, setInteractions] = React.useState([]); const [interactionsCount, setInteractionsCount] = React.useState(); - // Calls are tracked in a ref so we don't needlessly rerender. + // Log and calls are tracked in a ref so we don't needlessly rerender. + const log = React.useRef([]); const calls = React.useRef>>(new Map()); const setCall = ({ status, ...call }: Call) => calls.current.set(call.id, call); @@ -110,6 +114,7 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { setInteractions( getInteractions({ log: payload.logItems, calls: calls.current, collapsed, setCollapsed }) ); + log.current = payload.logItems; }, [STORY_RENDER_PHASE_CHANGED]: (event) => { setStoryId(event.storyId); @@ -124,7 +129,6 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { setErrored(true); }, [PLAY_FUNCTION_THREW_EXCEPTION]: (e) => { - console.log('PLAY_FUNCTION_THREW_EXCEPTION'); if (e?.message !== IGNORED_EXCEPTION.message) setCaughtException(e); else setCaughtException(undefined); }, @@ -132,9 +136,15 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { [collapsed] ); + React.useEffect(() => { + setInteractions( + getInteractions({ log: log.current, calls: calls.current, collapsed, setCollapsed }) + ); + }, [collapsed]); + React.useEffect(() => { if (isPlaying || isRerunAnimating) return; - setInteractionsCount(interactions.length); + setInteractionsCount(interactions.filter(({ method }) => method !== 'step').length); }, [interactions, isPlaying, isRerunAnimating]); const controls = React.useMemo( diff --git a/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx b/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx deleted file mode 100644 index 1930000cc9a1..000000000000 --- a/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx +++ /dev/null @@ -1,33 +0,0 @@ -import { Meta, Canvas, Story } from '@storybook/addon-docs'; -import { expect } from '@storybook/jest'; -import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; - -import { AccountForm } from './AccountForm'; - - - -## AccountForm - - - { - const { args, canvasElement } = context - const canvas = within(canvasElement) - - await userEvent.type(canvas.getByTestId('email'), 'username@email.com') - await userEvent.type(canvas.getByTestId('password1'), 'thepassword') - await userEvent.click(canvas.getByRole('button', { name: /create account/i })) - expect(args.onSubmit).not.toHaveBeenCalled() - }}/> - diff --git a/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx b/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx deleted file mode 100644 index 012938d1c79d..000000000000 --- a/code/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { Story as CSF2Story, Meta, ComponentStoryObj } from '@storybook/react'; -import { expect } from '@storybook/jest'; -import { - within, - waitFor, - fireEvent, - userEvent, - waitForElementToBeRemoved, -} from '@storybook/testing-library'; -import React from 'react'; - -import { AccountForm } from './AccountForm'; - -export default { - title: 'Addons/Interactions/AccountForm', - component: AccountForm, - parameters: { - layout: 'centered', - theme: 'light', - options: { selectedPanel: 'storybook/interactions/panel' }, - }, - argTypes: { - onSubmit: { action: true }, - }, -} as Meta; - -type CSF3Story = ComponentStoryObj; - -export const Demo: CSF2Story = (args) => ( - -); - -Demo.play = async ({ args, canvasElement }) => { - await userEvent.click(within(canvasElement).getByRole('button')); - await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); - await expect([{ name: 'John', age: 42 }]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'John' }), - expect.objectContaining({ age: 42 }), - ]) - ); -}; - -export const Exception = Demo.bind({}); -Exception.play = () => Demo.play(undefined as any); // deepscan-disable-line -Exception.parameters = { chromatic: { disableSnapshot: true } }; - -export const FindBy: CSF2Story = (args) => { - const [isLoading, setIsLoading] = React.useState(true); - React.useEffect(() => { - setTimeout(() => setIsLoading(false), 500); - }, []); - return isLoading ?
Loading...
: ; -}; -FindBy.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - await canvas.findByRole('button'); - await expect(true).toBe(true); -}; - -export const WaitFor: CSF2Story = (args) => ( - -); -WaitFor.play = async ({ args, canvasElement }) => { - await userEvent.click(await within(canvasElement).findByText('Click')); - await waitFor(async () => { - await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); - await expect(true).toBe(true); - }); -}; - -export const WaitForElementToBeRemoved: CSF2Story = () => { - const [isLoading, setIsLoading] = React.useState(true); - React.useEffect(() => { - setTimeout(() => setIsLoading(false), 1500); - }, []); - return isLoading ?
Loading...
: ; -}; -WaitForElementToBeRemoved.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitForElementToBeRemoved(await canvas.findByText('Loading...'), { timeout: 2000 }); - const button = await canvas.findByText('Loaded!'); - await expect(button).toBeInTheDocument(); -}; - -export const WithLoaders: CSF2Story = (args, { loaded: { todo } }) => { - return ( - - ); -}; -WithLoaders.loaders = [ - async () => { - // long fake timeout - await new Promise((resolve) => setTimeout(resolve, 2000)); - - return { - todo: { - userId: 1, - id: 1, - title: 'delectus aut autem', - completed: false, - }, - }; - }, -]; -WithLoaders.play = async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - const todoItem = await canvas.findByText('Todo: delectus aut autem'); - await userEvent.click(todoItem); - await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); -}; - -export const Standard: CSF3Story = { - args: { passwordVerification: false }, -}; - -export const StandardEmailFilled: CSF3Story = { - ...Standard, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await fireEvent.change(canvas.getByTestId('email'), { - target: { - value: 'michael@chromatic.com', - }, - }); - }, -}; - -export const StandardEmailFailed: CSF3Story = { - ...Standard, - play: async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); - await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); - - await canvas.findByText('Please enter a correctly formatted email address'); - await expect(args.onSubmit).not.toHaveBeenCalled(); - }, -}; - -export const StandardEmailSuccess: CSF3Story = { - ...Standard, - play: async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); - await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); - await userEvent.click(canvas.getByTestId('submit')); - - await waitFor(async () => { - await expect(args.onSubmit).toHaveBeenCalledTimes(1); - await expect(args.onSubmit).toHaveBeenCalledWith({ - email: 'michael@chromatic.com', - password: 'testpasswordthatwontfail', - }); - }); - }, -}; - -export const StandardPasswordFailed: CSF3Story = { - ...Standard, - play: async (context) => { - const canvas = within(context.canvasElement); - await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdf'); - await userEvent.click(canvas.getByTestId('submit')); - }, -}; - -export const StandardFailHover: CSF3Story = { - ...StandardPasswordFailed, - play: async (context) => { - const canvas = within(context.canvasElement); - await StandardPasswordFailed.play(context); - await waitFor(async () => { - await userEvent.hover(canvas.getByTestId('password-error-info')); - }); - }, -}; - -export const Verification: CSF3Story = { - args: { passwordVerification: true }, - argTypes: { onSubmit: { action: 'clicked' } }, -}; - -export const VerificationPassword: CSF3Story = { - ...Verification, - play: async (context) => { - const canvas = within(context.canvasElement); - await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.click(canvas.getByTestId('submit')); - }, -}; - -export const VerificationPasswordMismatch: CSF3Story = { - ...Verification, - play: async (context) => { - const canvas = within(context.canvasElement); - await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); - await userEvent.click(canvas.getByTestId('submit')); - }, -}; - -export const VerificationSuccess: CSF3Story = { - ...Verification, - play: async (context) => { - const canvas = within(context.canvasElement); - await StandardEmailFilled.play(context); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.click(canvas.getByTestId('submit')); - }, -}; diff --git a/code/addons/interactions/src/components/Interaction.stories.tsx b/code/addons/interactions/src/components/Interaction.stories.tsx index 06aaaba5f5b4..c1b38cafd63e 100644 --- a/code/addons/interactions/src/components/Interaction.stories.tsx +++ b/code/addons/interactions/src/components/Interaction.stories.tsx @@ -45,7 +45,7 @@ export const Done: Story = { export const WithParent: Story = { args: { - call: { ...getCalls(CallStates.DONE).slice(-1)[0], parentId: 'parent-id' }, + call: { ...getCalls(CallStates.DONE).slice(-1)[0], ancestors: ['parent-id'] }, }, }; diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx index 92a60d4fa998..a982ad3d6fc3 100644 --- a/code/addons/interactions/src/components/Interaction.tsx +++ b/code/addons/interactions/src/components/Interaction.tsx @@ -32,7 +32,7 @@ const RowContainer = styled('div', { ? transparentize(0.93, theme.color.negative) : theme.background.warning, }), - paddingLeft: call.parentId ? 20 : 0, + paddingLeft: call.ancestors.length * 20, }), ({ theme, call, pausedAt }) => pausedAt === call.id && { @@ -56,9 +56,9 @@ const RowContainer = styled('div', { } ); -const RowHeader = styled.div<{ disabled: boolean }>(({ theme, disabled }) => ({ +const RowHeader = styled.div<{ isInteractive: boolean }>(({ theme, isInteractive }) => ({ display: 'flex', - '&:hover': disabled ? {} : { background: theme.background.hoverable }, + '&:hover': isInteractive ? {} : { background: theme.background.hoverable }, })); const RowLabel = styled('button', { @@ -130,6 +130,7 @@ export const Interaction = ({ controls, controlStates, childCallIds, + isHidden, isCollapsed, toggleCollapsed, pausedAt, @@ -139,18 +140,23 @@ export const Interaction = ({ controls: Controls; controlStates: ControlStates; childCallIds?: Call['id'][]; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; pausedAt?: Call['id']; }) => { const [isHovered, setIsHovered] = React.useState(false); + const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length; + + if (isHidden) return null; + return ( - + controls.goto(call.id)} - disabled={!controlStates.goto || !call.interceptable || !!call.parentId} + disabled={isInteractive} onMouseEnter={() => controlStates.goto && setIsHovered(true)} onMouseLeave={() => controlStates.goto && setIsHovered(false)} > @@ -163,11 +169,7 @@ export const Interaction = ({ {childCallIds?.length > 0 && ( - } + tooltip={} > diff --git a/code/addons/interactions/src/components/InteractionsPanel.stories.tsx b/code/addons/interactions/src/components/InteractionsPanel.stories.tsx index 606f2cf22bf3..3c618898578e 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.stories.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.stories.tsx @@ -122,7 +122,7 @@ export const Failed: Story = { }, }; -export const WithDebuggingDisabled: Story = { +export const NoDebugger: Story = { args: { controlStates: { ...SubnavStories.args.controlStates, debugger: false } }, }; diff --git a/code/addons/interactions/src/components/InteractionsPanel.tsx b/code/addons/interactions/src/components/InteractionsPanel.tsx index dee82be36ef4..71f8b83d882d 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.tsx @@ -23,6 +23,7 @@ interface InteractionsPanelProps { interactions: (Call & { status?: CallStates; childCallIds: Call['id'][]; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; })[]; @@ -118,6 +119,7 @@ export const InteractionsPanel: React.FC = React.memo( controls={controls} controlStates={controlStates} childCallIds={call.childCallIds} + isHidden={call.isHidden} isCollapsed={call.isCollapsed} toggleCollapsed={call.toggleCollapsed} pausedAt={pausedAt} diff --git a/code/addons/interactions/src/components/MethodCall.stories.tsx b/code/addons/interactions/src/components/MethodCall.stories.tsx index b65001b4e1cb..953a9e6adbb6 100644 --- a/code/addons/interactions/src/components/MethodCall.stories.tsx +++ b/code/addons/interactions/src/components/MethodCall.stories.tsx @@ -57,6 +57,7 @@ export const Args = () => ( /> + @@ -99,6 +100,7 @@ const calls: Call[] = [ { cursor: 0, id: '1', + ancestors: [], path: ['screen'], method: 'getByText', storyId: 'kind--story', @@ -109,6 +111,7 @@ const calls: Call[] = [ { cursor: 1, id: '2', + ancestors: [], path: ['userEvent'], method: 'click', storyId: 'kind--story', @@ -119,6 +122,7 @@ const calls: Call[] = [ { cursor: 2, id: '3', + ancestors: [], path: [], method: 'expect', storyId: 'kind--story', @@ -129,6 +133,7 @@ const calls: Call[] = [ { cursor: 3, id: '4', + ancestors: [], path: [{ __callId__: '3' }, 'not'], method: 'toBe', storyId: 'kind--story', @@ -139,6 +144,7 @@ const calls: Call[] = [ { cursor: 4, id: '5', + ancestors: [], path: ['jest'], method: 'fn', storyId: 'kind--story', @@ -149,6 +155,7 @@ const calls: Call[] = [ { cursor: 5, id: '6', + ancestors: [], path: [], method: 'expect', storyId: 'kind--story', @@ -159,6 +166,7 @@ const calls: Call[] = [ { cursor: 6, id: '7', + ancestors: [], path: ['expect'], method: 'stringMatching', storyId: 'kind--story', @@ -169,6 +177,7 @@ const calls: Call[] = [ { cursor: 7, id: '8', + ancestors: [], path: [{ __callId__: '6' }, 'not'], method: 'toHaveBeenCalledWith', storyId: 'kind--story', @@ -182,6 +191,17 @@ const calls: Call[] = [ interceptable: false, retain: false, }, + { + cursor: 8, + id: '9', + ancestors: [], + path: [], + method: 'step', + storyId: 'kind--story', + args: ['Custom step label', { __function__: { name: '' } }], + interceptable: true, + retain: false, + }, ]; const callsById = calls.reduce((acc, call) => { @@ -189,6 +209,7 @@ const callsById = calls.reduce((acc, call) => { return acc; }, new Map()); +export const Step = () => ; export const Simple = () => ; export const Nested = () => ; export const Chained = () => ; diff --git a/code/addons/interactions/src/components/MethodCall.tsx b/code/addons/interactions/src/components/MethodCall.tsx index e9a98602e783..c5e62c0caf69 100644 --- a/code/addons/interactions/src/components/MethodCall.tsx +++ b/code/addons/interactions/src/components/MethodCall.tsx @@ -383,6 +383,22 @@ export const OtherNode = ({ value }: { value: any }) => { return {stringify(value)}; }; +export const StepNode = ({ label }: { label: string }) => { + const colors = useThemeColors(); + const { typography } = useTheme(); + return ( + + {label} + + ); +}; + export const MethodCall = ({ call, callsById, @@ -393,7 +409,9 @@ export const MethodCall = ({ // Call might be undefined during initial render, can be safely ignored. if (!call) return null; - const colors = useThemeColors(); + if (call.method === 'step' && call.path.length === 0) { + return ; + } const path = call.path.flatMap((elem, index) => { // eslint-disable-next-line no-underscore-dangle @@ -416,6 +434,7 @@ export const MethodCall = ({ : [node]; }); + const colors = useThemeColors(); return ( <> {path} diff --git a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx new file mode 100644 index 000000000000..4c97235008e3 --- /dev/null +++ b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx @@ -0,0 +1,31 @@ +import { Meta, Canvas, Story } from '@storybook/addon-docs'; +import { expect } from '@storybook/jest'; +import { within, waitFor, fireEvent, userEvent, screen } from '@storybook/testing-library'; + +import { AccountForm } from './AccountFormInteractions'; + + + +## AccountForm + + + { + await userEvent.type(screen.getByTestId('email'), 'username@email.com'); + await userEvent.type(screen.getByTestId('password1'), 'thepassword'); + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }} + /> + diff --git a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx new file mode 100644 index 000000000000..34f84115113c --- /dev/null +++ b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx @@ -0,0 +1,162 @@ +/* eslint-disable jest/no-standalone-expect */ +import { Meta, ComponentStoryObj } from '@storybook/react'; +import { expect } from '@storybook/jest'; +import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; + +import { AccountForm } from './AccountFormInteractions'; + +export default { + title: 'Addons/Interactions/Examples/AccountForm', + component: AccountForm, + parameters: { + layout: 'centered', + theme: 'light', + options: { selectedPanel: 'storybook/interactions/panel' }, + }, + argTypes: { + onSubmit: { action: true }, + }, +} as Meta; + +type CSF3Story = ComponentStoryObj; + +export const Standard: CSF3Story = { + args: { passwordVerification: false }, +}; + +export const StandardEmailFilled = { + ...Standard, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email', async () => { + await fireEvent.change(canvas.getByTestId('email'), { + target: { value: 'michael@chromatic.com' }, + }); + }); + }, +}; + +export const StandardEmailFailed = { + ...Standard, + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); + await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + }); + + await canvas.findByText('Please enter a correctly formatted email address'); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const StandardEmailSuccess = { + ...Standard, + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); + await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); + + await waitFor(async () => { + await expect(args.onSubmit).toHaveBeenCalledTimes(1); + await expect(args.onSubmit).toHaveBeenCalledWith({ + email: 'michael@chromatic.com', + password: 'testpasswordthatwontfail', + }); + }); + }, +}; + +export const StandardPasswordFailed = { + ...Standard, + play: async (context) => { + const canvas = within(context.canvasElement); + await StandardEmailFilled.play(context); + + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdf'); + }); + + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); + }, +}; + +export const StandardFailHover = { + ...StandardPasswordFailed, + play: async (context) => { + const canvas = within(context.canvasElement); + await StandardPasswordFailed.play(context); + await waitFor(async () => { + await userEvent.hover(canvas.getByTestId('password-error-info')); + }); + }, +}; + +export const Verification = { + args: { passwordVerification: true }, + argTypes: { onSubmit: { action: 'clicked' } }, +}; + +export const VerificationPassword = { + ...Verification, + play: async (context) => { + const canvas = within(context.canvasElement); + await StandardEmailFilled.play(context); + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); + }, +}; + +export const VerificationPasswordMismatch = { + ...Verification, + play: async (context) => { + const canvas = within(context.canvasElement); + await StandardEmailFilled.play(context); + await context.step('Enter passwords', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); + }, +}; + +export const VerificationSuccess = { + ...Verification, + play: async (context) => { + const canvas = within(context.canvasElement); + await StandardEmailFilled.play(context); + + await context.step('Enter passwords', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); + }); + + await context.step('Submit form', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.click(canvas.getByTestId('submit')); + }); + }, +}; diff --git a/code/addons/interactions/src/components/AccountForm/AccountForm.tsx b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.tsx similarity index 100% rename from code/addons/interactions/src/components/AccountForm/AccountForm.tsx rename to code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.tsx diff --git a/code/addons/interactions/src/examples/Examples.stories.tsx b/code/addons/interactions/src/examples/Examples.stories.tsx new file mode 100644 index 000000000000..d16c086e61ee --- /dev/null +++ b/code/addons/interactions/src/examples/Examples.stories.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jest/no-standalone-expect */ +import { Story, Meta } from '@storybook/react'; +import { expect } from '@storybook/jest'; +import { within, waitFor, userEvent, waitForElementToBeRemoved } from '@storybook/testing-library'; +import React from 'react'; + +export default { + title: 'Addons/Interactions/Examples', + parameters: { + layout: 'centered', + theme: 'light', + options: { selectedPanel: 'storybook/interactions/panel' }, + }, + argTypes: { + onSubmit: { action: true }, + }, +} as Meta; + +export const Assertions: Story = ({ onSubmit }) => ( + +); +Assertions.play = async ({ args, canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole('button')); + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + await expect([{ name: 'John', age: 42 }]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John' }), + expect.objectContaining({ age: 42 }), + ]) + ); +}; + +export const FindBy: Story = () => { + const [isLoading, setIsLoading] = React.useState(true); + React.useEffect(() => { + setTimeout(() => setIsLoading(false), 500); + }, []); + return isLoading ?
Loading...
: ; +}; +FindBy.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button'); + await expect(true).toBe(true); +}; + +export const WaitFor: Story = ({ onSubmit }) => ( + +); +WaitFor.play = async ({ args, canvasElement }) => { + await userEvent.click(await within(canvasElement).findByText('Click')); + await waitFor(async () => { + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + await expect(true).toBe(true); + }); +}; + +export const WaitForElementToBeRemoved: Story = () => { + const [isLoading, setIsLoading] = React.useState(true); + React.useEffect(() => { + setTimeout(() => setIsLoading(false), 1500); + }, []); + return isLoading ?
Loading...
: ; +}; +WaitForElementToBeRemoved.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitForElementToBeRemoved(await canvas.findByText('Loading...'), { timeout: 2000 }); + const button = await canvas.findByText('Loaded!'); + await expect(button).not.toBeNull(); +}; + +export const WithLoaders: Story = ({ onSubmit }, { loaded: { todo } }) => { + return ( + + ); +}; +WithLoaders.loaders = [ + async () => { + // long fake timeout + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return { + todo: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: false, + }, + }; + }, +]; +WithLoaders.play = async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const todoItem = await canvas.findByText('Todo: delectus aut autem'); + await userEvent.click(todoItem); + await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); +}; + +export const WithSteps: Story = ({ onSubmit }) => ( + +); +WithSteps.play = async ({ args, canvasElement, step }) => { + await step('Click button', async () => { + await userEvent.click(within(canvasElement).getByRole('button')); + + await step('Verify submit', async () => { + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + }); + + await step('Verify result', async () => { + await expect([{ name: 'John', age: 42 }]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John' }), + expect.objectContaining({ age: 42 }), + ]) + ); + }); + }); +}; diff --git a/code/addons/interactions/src/mocks/index.ts b/code/addons/interactions/src/mocks/index.ts index d42507545332..14066c7a0149 100644 --- a/code/addons/interactions/src/mocks/index.ts +++ b/code/addons/interactions/src/mocks/index.ts @@ -3,9 +3,22 @@ import { CallStates, Call } from '@storybook/instrumenter'; export const getCalls = (finalStatus: CallStates) => { const calls: Call[] = [ { - id: 'story--id [3] within', + id: 'story--id [3] step', + storyId: 'story--id', + cursor: 1, + ancestors: [], + path: [], + method: 'step', + args: ['Click button', { __function__: { name: '' } }], + interceptable: true, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [3] step [1] within', storyId: 'story--id', cursor: 3, + ancestors: ['story--id [3] step'], path: [], method: 'within', args: [{ __element__: { localName: 'div', id: 'root' } }], @@ -14,10 +27,11 @@ export const getCalls = (finalStatus: CallStates) => { status: CallStates.DONE, }, { - id: 'story--id [4] findByText', + id: 'story--id [3] step [2] findByText', storyId: 'story--id', cursor: 4, - path: [{ __callId__: 'story--id [3] within' }], + ancestors: ['story--id [3] step'], + path: [{ __callId__: 'story--id [3] step [1] within' }], method: 'findByText', args: ['Click'], interceptable: true, @@ -25,9 +39,10 @@ export const getCalls = (finalStatus: CallStates) => { status: CallStates.DONE, }, { - id: 'story--id [5] click', + id: 'story--id [3] step [3] click', storyId: 'story--id', cursor: 5, + ancestors: ['story--id [3] step'], path: ['userEvent'], method: 'click', args: [{ __element__: { localName: 'button', innerText: 'Click' } }], @@ -39,6 +54,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [6] waitFor', storyId: 'story--id', cursor: 6, + ancestors: [], path: [], method: 'waitFor', args: [{ __function__: { name: '' } }], @@ -48,9 +64,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [0] expect', - parentId: 'story--id [6] waitFor', storyId: 'story--id', cursor: 1, + ancestors: ['story--id [6] waitFor'], path: [], method: 'expect', args: [{ __function__: { name: 'handleSubmit' } }], @@ -60,9 +76,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [1] stringMatching', - parentId: 'story--id [6] waitFor', storyId: 'story--id', cursor: 2, + ancestors: ['story--id [6] waitFor'], path: ['expect'], method: 'stringMatching', args: [{ __regexp__: { flags: 'gi', source: '([A-Z])w+' } }], @@ -72,9 +88,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [2] toHaveBeenCalledWith', - parentId: 'story--id [6] waitFor', storyId: 'story--id', cursor: 3, + ancestors: ['story--id [6] waitFor'], path: [{ __callId__: 'story--id [6] waitFor [0] expect' }], method: 'toHaveBeenCalledWith', args: [{ __callId__: 'story--id [6] waitFor [1] stringMatching', retain: false }], @@ -86,6 +102,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [7] expect', storyId: 'story--id', cursor: 7, + ancestors: [], path: [], method: 'expect', args: [{ __function__: { name: 'handleReset' } }], @@ -97,6 +114,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [8] toHaveBeenCalled', storyId: 'story--id', cursor: 8, + ancestors: [], path: [{ __callId__: 'story--id [7] expect' }, 'not'], method: 'toHaveBeenCalled', args: [], @@ -121,9 +139,4 @@ export const getCalls = (finalStatus: CallStates) => { export const getInteractions = (finalStatus: CallStates) => getCalls(finalStatus) .filter((call) => call.interceptable) - .map((call, _, calls) => ({ - ...call, - childCallIds: calls.filter((c) => c.parentId === call.id).map((c) => c.id), - isCollapsed: false, - toggleCollapsed: () => {}, - })); + .map((call) => ({ ...call, childCallIds: [], isCollapsed: false, toggleCollapsed: () => {} })); diff --git a/code/addons/interactions/src/preset/preview.ts b/code/addons/interactions/src/preset/preview.ts index 662b4f4e5af2..41eadcf626f5 100644 --- a/code/addons/interactions/src/preset/preview.ts +++ b/code/addons/interactions/src/preset/preview.ts @@ -1,6 +1,12 @@ import { addons } from '@storybook/addons'; import { FORCE_REMOUNT, STORY_RENDER_PHASE_CHANGED } from '@storybook/core-events'; -import type { AnyFramework, ArgsEnhancer } from '@storybook/csf'; +import type { + AnyFramework, + ArgsEnhancer, + PlayFunction, + PlayFunctionContext, + StepLabel, +} from '@storybook/csf'; import { instrument } from '@storybook/instrumenter'; import { ModuleMocker } from 'jest-mock'; @@ -48,3 +54,8 @@ const addActionsFromArgTypes: ArgsEnhancer = ({ id, initialArgs }) addSpies(id, initialArgs); export const argsEnhancers = [addActionsFromArgTypes]; + +export const { step: runStep } = instrument( + { step: (label: StepLabel, play: PlayFunction, context: PlayFunctionContext) => play(context) }, + { intercept: true } +); diff --git a/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts b/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts index 20ce598c423c..101f2e97735f 100644 --- a/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts +++ b/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts @@ -15,6 +15,7 @@ export interface ClientApi setAddon: ClientApiClass['setAddon']; addArgsEnhancer: ClientApiClass['addArgsEnhancer']; addArgTypesEnhancer: ClientApiClass['addArgTypesEnhancer']; + addStepRunner: ClientApiClass['addStepRunner']; raw: ClientApiClass['raw']; } diff --git a/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts b/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts index 670ecdfd63e6..ad64cdd91d69 100644 --- a/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts +++ b/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts @@ -113,8 +113,15 @@ function configure( if (preview) { // This is essentially the same code as lib/core/src/server/preview/virtualModuleEntry.template - const { parameters, decorators, globals, globalTypes, argsEnhancers, argTypesEnhancers } = - jest.requireActual(preview); + const { + parameters, + decorators, + globals, + globalTypes, + argsEnhancers, + argTypesEnhancers, + runStep, + } = jest.requireActual(preview); if (decorators) { decorators.forEach((decorator: DecoratorFunction) => @@ -124,6 +131,9 @@ function configure( if (parameters || globals || globalTypes) { storybook.addParameters({ ...parameters, globals, globalTypes }); } + if (runStep) { + storybook.addStepRunner(runStep); + } if (argsEnhancers) { argsEnhancers.forEach((enhancer: ArgsEnhancer) => storybook.addArgsEnhancer(enhancer as any) diff --git a/code/examples/cra-ts-essentials/.storybook/main.ts b/code/examples/cra-ts-essentials/.storybook/main.ts index 01c95af66b35..8a4882ff3aa7 100644 --- a/code/examples/cra-ts-essentials/.storybook/main.ts +++ b/code/examples/cra-ts-essentials/.storybook/main.ts @@ -12,6 +12,7 @@ const mainConfig: StorybookConfig = { viewport: false, }, }, + '@storybook/addon-interactions', ], logLevel: 'debug', // add monorepo root as a valid directory to import modules from diff --git a/code/examples/cra-ts-essentials/package.json b/code/examples/cra-ts-essentials/package.json index 97a9d064cc27..3895ad5c3c5b 100644 --- a/code/examples/cra-ts-essentials/package.json +++ b/code/examples/cra-ts-essentials/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@storybook/addon-essentials": "7.0.0-alpha.23", + "@storybook/addon-interactions": "7.0.0-alpha.23", "@storybook/addons": "7.0.0-alpha.23", "@storybook/builder-webpack5": "7.0.0-alpha.23", "@storybook/preset-create-react-app": "^4.1.0", diff --git a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx index 7dc7cabc6a55..d87e1e317baf 100644 --- a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx +++ b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx @@ -79,8 +79,10 @@ export const CSF3InputFieldFilled: CSF3Story = { render: () => { return ; }, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + await step('Step label', async () => { + await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + }); }, }; diff --git a/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js b/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js index 1e3f319f27ce..b667a36248a0 100644 --- a/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js +++ b/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js @@ -5,6 +5,7 @@ import { addLoader, addArgs, addArgTypes, + addStepRunner, addArgsEnhancer, addArgTypesEnhancer, setGlobalRender, @@ -49,6 +50,9 @@ Object.keys(config).forEach((key) => { case 'renderToDOM': { return null; // This key is not handled directly in v6 mode. } + case 'runStep': { + return addStepRunner(value); + } default: { // eslint-disable-next-line prefer-template return console.log(key + ' was not supported :( !'); diff --git a/code/lib/client-api/src/ClientApi.ts b/code/lib/client-api/src/ClientApi.ts index 9f3b0d93a957..58d0090046f9 100644 --- a/code/lib/client-api/src/ClientApi.ts +++ b/code/lib/client-api/src/ClientApi.ts @@ -4,7 +4,7 @@ import deprecate from 'util-deprecate'; import { dedent } from 'ts-dedent'; import global from 'global'; import { logger } from '@storybook/client-logger'; -import { toId, sanitize } from '@storybook/csf'; +import { toId, sanitize, StepRunner } from '@storybook/csf'; import type { Args, ArgTypes, @@ -20,7 +20,12 @@ import type { GlobalTypes, LegacyStoryFn, } from '@storybook/csf'; -import { combineParameters, StoryStore, normalizeInputTypes } from '@storybook/store'; +import { + combineParameters, + composeStepRunners, + StoryStore, + normalizeInputTypes, +} from '@storybook/store'; import type { NormalizedComponentAnnotations, Path, ModuleImportFn } from '@storybook/store'; import type { ClientApiAddons, StoryApi } from '@storybook/addons'; @@ -68,7 +73,7 @@ const checkMethod = (method: string, deprecationWarning: boolean) => { if (global.FEATURES?.storyStoreV7) { throw new Error( dedent`You cannot use \`${method}\` with the new Story Store. - + ${warningAlternatives[method as keyof typeof warningAlternatives]}` ); } @@ -120,6 +125,11 @@ export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => singleton.addArgTypesEnhancer(enhancer); }; +export const addStepRunner = (stepRunner: StepRunner) => { + checkMethod('addStepRunner', false); + singleton.addStepRunner(stepRunner); +}; + export const getGlobalRender = () => { checkMethod('getGlobalRender', false); return singleton.facade.projectAnnotations.render; @@ -215,6 +225,12 @@ export class ClientApi { } }; + addStepRunner = (stepRunner: StepRunner) => { + this.facade.projectAnnotations.runStep = composeStepRunners( + [this.facade.projectAnnotations.runStep, stepRunner].filter(Boolean) + ); + }; + addLoader = (loader: LoaderFunction) => { this.facade.projectAnnotations.loaders.push(loader); }; diff --git a/code/lib/client-api/src/index.ts b/code/lib/client-api/src/index.ts index e1b780cc2d6b..9ac279239c2d 100644 --- a/code/lib/client-api/src/index.ts +++ b/code/lib/client-api/src/index.ts @@ -7,6 +7,7 @@ import { addArgTypes, addArgsEnhancer, addArgTypesEnhancer, + addStepRunner, setGlobalRender, } from './ClientApi'; @@ -24,6 +25,7 @@ export { addArgs, addArgTypes, addParameters, + addStepRunner, setGlobalRender, ClientApi, }; diff --git a/code/lib/instrumenter/src/instrumenter.test.ts b/code/lib/instrumenter/src/instrumenter.test.ts index 5c8025c03dc9..a0a1a0e19d22 100644 --- a/code/lib/instrumenter/src/instrumenter.test.ts +++ b/code/lib/instrumenter/src/instrumenter.test.ts @@ -140,7 +140,7 @@ describe('Instrumenter', () => { method: 'fn', interceptable: false, status: 'done', - parentId: undefined, + ancestors: [], }) ); }); @@ -216,28 +216,28 @@ describe('Instrumenter', () => { }); fn5(); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [0] fn1', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [0] fn1', ancestors: [] }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2 [0] fn3', - parentId: 'kind--story [0] fn1 [0] fn2', + ancestors: ['kind--story [0] fn1', 'kind--story [0] fn1 [0] fn2'], }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [1] fn4', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [1] fn5', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [1] fn5', ancestors: [] }) ); }); @@ -247,16 +247,16 @@ describe('Instrumenter', () => { await fn1(() => fn2()); await fn3(); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [0] fn1', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [0] fn1', ancestors: [] }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [1] fn3', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [1] fn3', ancestors: [] }) ); }); @@ -294,8 +294,8 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [2] fn2', status: 'done' }, - { callId: 'kind--story [3] fn', status: 'done' }, + { callId: 'kind--story [2] fn2', status: 'done', ancestors: [] }, + { callId: 'kind--story [3] fn', status: 'done', ancestors: [] }, ], }) ); @@ -388,8 +388,8 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [0] fn1', status: 'done' }, - { callId: 'kind--story [1] fn2', status: 'done' }, + { callId: 'kind--story [0] fn1', status: 'done', ancestors: [] }, + { callId: 'kind--story [1] fn2', status: 'done', ancestors: [] }, ], }) ); @@ -405,11 +405,11 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [0] fn1', status: 'done' }, + { callId: 'kind--story [0] fn1', status: 'done', ancestors: [] }, { callId: 'kind--story [0] fn1 [0] fn2', status: 'done', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }, ], }) diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts index ea41efe879c8..966e7881528e 100644 --- a/code/lib/instrumenter/src/instrumenter.ts +++ b/code/lib/instrumenter/src/instrumenter.ts @@ -22,13 +22,13 @@ import { } from './types'; export const EVENTS = { - CALL: 'instrumenter/call', - SYNC: 'instrumenter/sync', - START: 'instrumenter/start', - BACK: 'instrumenter/back', - GOTO: 'instrumenter/goto', - NEXT: 'instrumenter/next', - END: 'instrumenter/end', + CALL: 'storybook/instrumenter/call', + SYNC: 'storybook/instrumenter/sync', + START: 'storybook/instrumenter/start', + BACK: 'storybook/instrumenter/back', + GOTO: 'storybook/instrumenter/goto', + NEXT: 'storybook/instrumenter/next', + END: 'storybook/instrumenter/end', }; type PatchedObj = { @@ -78,7 +78,7 @@ const getInitialState = (): State => ({ shadowCalls: [], callRefsByResult: new Map(), chainedCallIds: new Set(), - parentId: undefined, + ancestors: [], playUntil: undefined, resolvers: {}, syncTimeout: undefined, @@ -110,7 +110,7 @@ export class Instrumenter { // Restore state from the parent window in case the iframe was reloaded. this.state = global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {}; - // When called from `start`, isDebugging will be true + // When called from `start`, isDebugging will be true. const resetState = ({ storyId, isPlaying = true, @@ -130,9 +130,7 @@ export class Instrumenter { isPlaying, isDebugging, }); - - // Don't sync while debugging, as it'll cause flicker. - if (!isDebugging) this.sync(storyId); + this.sync(storyId); }; // A forceRemount might be triggered for debugging (on `start`), or elsewhere in Storybook. @@ -171,6 +169,8 @@ export class Instrumenter { const start = ({ storyId, playUntil }: { storyId: string; playUntil?: Call['id'] }) => { if (!this.getState(storyId).isDebugging) { + // Move everything into shadowCalls (a "carbon copy") and mark them as "waiting", so we keep + // a record of the original calls which haven't yet been executed while stepping through. this.setState(storyId, ({ calls }) => ({ calls: [], shadowCalls: calls.map((call) => ({ ...call, status: CallStates.WAITING })), @@ -180,14 +180,13 @@ export class Instrumenter { const log = this.getLog(storyId); this.setState(storyId, ({ shadowCalls }) => { + if (playUntil || !log.length) return { playUntil }; const firstRowIndex = shadowCalls.findIndex((call) => call.id === log[0].callId); return { - playUntil: - playUntil || - shadowCalls - .slice(0, firstRowIndex) - .filter((call) => call.interceptable && !call.parentId) - .slice(-1)[0]?.id, + playUntil: shadowCalls + .slice(0, firstRowIndex) + .filter((call) => call.interceptable && !call.ancestors.length) + .slice(-1)[0]?.id, }; }); @@ -196,7 +195,7 @@ export class Instrumenter { }; const back = ({ storyId }: { storyId: string }) => { - const log = this.getLog(storyId).filter((call) => !call.parentId); + const log = this.getLog(storyId).filter((call) => !call.ancestors.length); const last = log.reduceRight((res, item, index) => { if (res >= 0 || item.status === CallStates.WAITING) return res; return index; @@ -285,7 +284,7 @@ export class Instrumenter { } }); if ((call.interceptable || call.exception) && !seen.has(call.id)) { - acc.unshift({ callId: call.id, status: call.status, parentId: call.parentId }); + acc.unshift({ callId: call.id, status: call.status, ancestors: call.ancestors }); seen.add(call.id); } return acc; @@ -342,13 +341,13 @@ export class Instrumenter { track(method: string, fn: Function, args: any[], options: Options) { const storyId: StoryId = args?.[0]?.__storyId__ || global.window.__STORYBOOK_PREVIEW__?.urlStore?.selection?.storyId; - const { cursor, parentId } = this.getState(storyId); + const { cursor, ancestors } = this.getState(storyId); this.setState(storyId, { cursor: cursor + 1 }); - const id = `${parentId || storyId} [${cursor}] ${method}`; + const id = `${ancestors.slice(-1)[0] || storyId} [${cursor}] ${method}`; const { path = [], intercept = false, retain = false } = options; const interceptable = typeof intercept === 'function' ? intercept(method, path) : intercept; - const call: Call = { id, parentId, storyId, cursor, path, method, args, interceptable, retain }; - const interceptOrInvoke = interceptable && !parentId ? this.intercept : this.invoke; + const call = { id, cursor, storyId, ancestors, path, method, args, interceptable, retain }; + const interceptOrInvoke = interceptable && !ancestors.length ? this.intercept : this.invoke; const result = interceptOrInvoke.call(this, fn, call, options); return this.instrument(result, { ...options, mutate: true, path: [{ __callId__: call.id }] }); } @@ -459,8 +458,10 @@ export class Instrumenter { })); // Exceptions inside callbacks should bubble up to the parent call. - if (call.parentId) { - Object.defineProperty(e, 'callId', { value: call.id }); + if (call.ancestors.length) { + if (!Object.prototype.hasOwnProperty.call(e, 'callId')) { + Object.defineProperty(e, 'callId', { value: call.id }); + } throw e; } @@ -491,18 +492,17 @@ export class Instrumenter { if (typeof arg !== 'function' || Object.keys(arg).length) return arg; return (...args: any) => { - // Set the cursor and parentId for calls that happen inside the callback. - const { cursor, parentId } = this.getState(call.storyId); - this.setState(call.storyId, { cursor: 0, parentId: call.id }); - const restore = () => this.setState(call.storyId, { cursor, parentId }); + // Set the cursor and ancestors for calls that happen inside the callback. + const { cursor, ancestors } = this.getState(call.storyId); + this.setState(call.storyId, { cursor: 0, ancestors: [...ancestors, call.id] }); + const restore = () => this.setState(call.storyId, { cursor, ancestors }); // Invoke the actual callback function. const res = arg(...args); - // Reset cursor and parentId to their original values before we entered the callback. - if (res instanceof Promise) res.then(restore, restore); - else restore(); - + // Reset cursor and ancestors to their original values before we entered the callback. + if (res instanceof Promise) return res.then(restore, restore); + restore(); return res; }; }); @@ -538,10 +538,8 @@ export class Instrumenter { } } - // Sends the call info and log to the manager. - // Uses a 0ms debounce because this might get called many times in one tick. + // Sends the call info to the manager and synchronizes the log. update(call: Call) { - clearTimeout(this.getState(call.storyId).syncTimeout); this.channel.emit(EVENTS.CALL, call); this.setState(call.storyId, ({ calls }) => { // Omit earlier calls for the same ID, which may have been superceded by a later invocation. @@ -554,39 +552,48 @@ export class Instrumenter { calls: Object.values(callsById).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }) ), - syncTimeout: setTimeout(() => this.sync(call.storyId), 0), }; }); + this.sync(call.storyId); } - sync(storyId: StoryId) { - const { isLocked, isPlaying } = this.getState(storyId); - const logItems: LogItem[] = this.getLog(storyId); - const pausedAt = logItems - .filter(({ parentId }) => !parentId) - .find((item) => item.status === CallStates.WAITING)?.callId; + // Builds a log of interceptable calls and control states and sends it to the manager. + // Uses a 0ms debounce because this might get called many times in one tick. + sync(storyId: string) { + const synchronize = () => { + const { isLocked, isPlaying } = this.getState(storyId); + const logItems: LogItem[] = this.getLog(storyId); + const pausedAt = logItems + .filter(({ ancestors }) => !ancestors.length) + .find((item) => item.status === CallStates.WAITING)?.callId; + + const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); + if (debuggerDisabled || isLocked || hasActive || logItems.length === 0) { + const payload: SyncPayload = { controlStates: controlsDisabled, logItems }; + this.channel.emit(EVENTS.SYNC, payload); + return; + } - const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); - if (debuggerDisabled || isLocked || hasActive || logItems.length === 0) { - const payload: SyncPayload = { controlStates: controlsDisabled, logItems }; - this.channel.emit(EVENTS.SYNC, payload); - return; - } + const hasPrevious = logItems.some((item) => + [CallStates.DONE, CallStates.ERROR].includes(item.status) + ); + const controlStates: ControlStates = { + debugger: true, + start: hasPrevious, + back: hasPrevious, + goto: true, + next: isPlaying, + end: isPlaying, + }; - const hasPrevious = logItems.some((item) => - [CallStates.DONE, CallStates.ERROR].includes(item.status) - ); - const controlStates: ControlStates = { - debugger: true, - start: hasPrevious, - back: hasPrevious, - goto: true, - next: isPlaying, - end: isPlaying, + const payload: SyncPayload = { controlStates, logItems, pausedAt }; + this.channel.emit(EVENTS.SYNC, payload); }; - const payload: SyncPayload = { controlStates, logItems, pausedAt }; - this.channel.emit(EVENTS.SYNC, payload); + this.setState(storyId, ({ syncTimeout }) => { + clearTimeout(syncTimeout); + return { syncTimeout: setTimeout(synchronize, 0) }; + }); } } diff --git a/code/lib/instrumenter/src/types.ts b/code/lib/instrumenter/src/types.ts index d7ca5665d109..f358ecb926e0 100644 --- a/code/lib/instrumenter/src/types.ts +++ b/code/lib/instrumenter/src/types.ts @@ -2,9 +2,9 @@ import type { StoryId } from '@storybook/addons'; export interface Call { id: string; - parentId?: Call['id']; - storyId: StoryId; cursor: number; + storyId: StoryId; + ancestors: Call['id'][]; path: Array; method: string; args: any[]; @@ -52,7 +52,7 @@ export interface ControlStates { export interface LogItem { callId: Call['id']; status: Call['status']; - parentId?: Call['id']; + ancestors: Call['id'][]; } export interface SyncPayload { @@ -71,7 +71,7 @@ export interface State { shadowCalls: Call[]; callRefsByResult: Map; chainedCallIds: Set; - parentId?: Call['id']; + ancestors: Call['id'][]; playUntil?: Call['id']; resolvers: Record; syncTimeout: ReturnType; diff --git a/code/lib/preview-web/src/Preview.tsx b/code/lib/preview-web/src/Preview.tsx index 87aaaa65ec98..c17ca1fa131b 100644 --- a/code/lib/preview-web/src/Preview.tsx +++ b/code/lib/preview-web/src/Preview.tsx @@ -32,10 +32,10 @@ import { StandaloneDocsRender } from './render/StandaloneDocsRender'; const { fetch } = global; -type MaybePromise = Promise | T; - const STORY_INDEX_PATH = './index.json'; +export type MaybePromise = Promise | T; + export class Preview { serverChannel?: Channel; @@ -112,7 +112,7 @@ export class Preview { Perhaps it needs to be upgraded for Storybook 6.4? - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field `); } return projectAnnotations; @@ -342,7 +342,7 @@ export class Preview { // In v6 mode, if your preview.js throws, we never get a chance to initialize the preview // or store, and the error is simply logged to the browser console. This is the best we can do throw new Error(dedent`Failed to initialize Storybook. - + Do you have an error in your \`preview.js\`? Check your Storybook's browser console for errors.`); } diff --git a/code/lib/preview-web/src/PreviewWeb.test.ts b/code/lib/preview-web/src/PreviewWeb.test.ts index 940bd56acfbb..56b69d95d630 100644 --- a/code/lib/preview-web/src/PreviewWeb.test.ts +++ b/code/lib/preview-web/src/PreviewWeb.test.ts @@ -524,7 +524,7 @@ describe('PreviewWeb', () => { Perhaps it needs to be upgraded for Storybook 6.4? - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ] + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field] `); }); diff --git a/code/lib/preview-web/src/PreviewWeb.tsx b/code/lib/preview-web/src/PreviewWeb.tsx index 6b6014236f9a..3178ae7611fe 100644 --- a/code/lib/preview-web/src/PreviewWeb.tsx +++ b/code/lib/preview-web/src/PreviewWeb.tsx @@ -30,7 +30,7 @@ import type { WebProjectAnnotations, } from '@storybook/store'; -import { Preview } from './Preview'; +import { MaybePromise, Preview } from './Preview'; import { UrlStore } from './UrlStore'; import { WebView } from './WebView'; @@ -45,7 +45,6 @@ function focusInInput(event: Event) { return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null; } -type MaybePromise = Promise | T; type PossibleRender = | StoryRender | TemplateDocsRender diff --git a/code/lib/store/src/csf/composeConfigs.test.ts b/code/lib/store/src/csf/composeConfigs.test.ts index ce35ddd4c444..97c920c7993c 100644 --- a/code/lib/store/src/csf/composeConfigs.test.ts +++ b/code/lib/store/src/csf/composeConfigs.test.ts @@ -157,4 +157,22 @@ describe('composeConfigs', () => { runStep: expect.any(Function), }); }); + + it('composes step runners', () => { + const fn = jest.fn(); + + const { runStep } = composeConfigs([ + { runStep: (label, play, context) => fn(`${label}1`, play(context)) }, + { runStep: (label, play, context) => fn(`${label}2`, play(context)) }, + { runStep: (label, play, context) => fn(`${label}3`, play(context)) }, + ]); + + // @ts-expect-error We don't care about the context value here + runStep('Label', () => {}, {}); + + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, 'Label3', expect.anything()); + expect(fn).toHaveBeenNthCalledWith(2, 'Label2', expect.anything()); + expect(fn).toHaveBeenNthCalledWith(3, 'Label1', expect.anything()); + }); }); diff --git a/code/lib/store/src/csf/composeConfigs.ts b/code/lib/store/src/csf/composeConfigs.ts index dc2cc97c0cec..f43aed164704 100644 --- a/code/lib/store/src/csf/composeConfigs.ts +++ b/code/lib/store/src/csf/composeConfigs.ts @@ -36,6 +36,7 @@ export function composeConfigs( moduleExportList: ModuleExports[] ): WebProjectAnnotations { const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers'); + const stepRunners = getField(moduleExportList, 'runStep'); return { parameters: combineParameters(...getField(moduleExportList, 'parameters')), @@ -53,6 +54,6 @@ export function composeConfigs( render: getSingletonField(moduleExportList, 'render'), renderToDOM: getSingletonField(moduleExportList, 'renderToDOM'), applyDecorators: getSingletonField(moduleExportList, 'applyDecorators'), - runStep: composeStepRunners(getArrayField(moduleExportList, 'runStep')), + runStep: composeStepRunners(stepRunners), }; } diff --git a/code/lib/store/src/csf/index.ts b/code/lib/store/src/csf/index.ts index 00cdb73c4794..4b3aa8fa8c97 100644 --- a/code/lib/store/src/csf/index.ts +++ b/code/lib/store/src/csf/index.ts @@ -6,4 +6,5 @@ export * from './normalizeComponentAnnotations'; export * from './normalizeProjectAnnotations'; export * from './getValuesFromArgTypes'; export * from './composeConfigs'; +export * from './stepRunners'; export * from './testing-utils'; diff --git a/code/lib/store/src/csf/prepareStory.test.ts b/code/lib/store/src/csf/prepareStory.test.ts index f943f61889a5..81ae1314eab3 100644 --- a/code/lib/store/src/csf/prepareStory.test.ts +++ b/code/lib/store/src/csf/prepareStory.test.ts @@ -641,7 +641,7 @@ describe('playFunction', () => { expect(context.step).toEqual(expect.any(Function)); }); const play = jest.fn(async ({ step }) => { - step('label', stepPlay); + await step('label', stepPlay); }); const runStep = jest.fn((label, p, c) => p(c)); const { playFunction } = prepareStory( diff --git a/code/yarn.lock b/code/yarn.lock index 2178493acd52..dbe50caa9955 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -17311,6 +17311,7 @@ __metadata: resolution: "cra-ts-essentials@workspace:examples/cra-ts-essentials" dependencies: "@storybook/addon-essentials": 7.0.0-alpha.23 + "@storybook/addon-interactions": 7.0.0-alpha.23 "@storybook/addons": 7.0.0-alpha.23 "@storybook/builder-webpack5": 7.0.0-alpha.23 "@storybook/components": 7.0.0-alpha.23 diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 000000000000..d033feea7ad5 --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "bracketSpacing": true, + "trailingComma": "es5", + "singleQuote": true, + "arrowParens": "always" +} diff --git a/docs/essentials/interactions.md b/docs/essentials/interactions.md index 7cfaf8f462ea..a5b47696596a 100644 --- a/docs/essentials/interactions.md +++ b/docs/essentials/interactions.md @@ -67,7 +67,7 @@ Make sure to import the Storybook wrappers for Jest and Testing Library rather t -The above example uses the `canvasElement` to scope your element queries to the current story. It's essential if you want your play functions to eventually be compatible with Storybook Docs, which renders multiple components on the same page. +The above example uses the `canvasElement` to scope your element queries to the current story. It's essential if you want your play functions to eventually be compatible with Storybook Docs, which renders multiple components on the same page. Additionally, the `step` function can be used to create labeled groups of interactions. While you can refer to the [Testing Library documentation](https://testing-library.com/docs/) for details on how to use it, there's an important detail that's different when using the Storybook wrapper: **method invocations must be `await`-ed**. It allows you to step back and forth through your interactions using the debugger. diff --git a/docs/snippets/common/storybook-interactions-play-function.js.mdx b/docs/snippets/common/storybook-interactions-play-function.js.mdx index c4751ebd30d9..dafb7dde0e51 100644 --- a/docs/snippets/common/storybook-interactions-play-function.js.mdx +++ b/docs/snippets/common/storybook-interactions-play-function.js.mdx @@ -20,13 +20,18 @@ export default { }; export const Submitted = { - play: async () => { + play: async ({ args, canvasElement, step }) => { // Starts querying the component from its root element const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); - await userEvent.type(canvas.getByTestId('password'), 'supersecret'); - await userEvent.click(canvas.getByRole('button')); + await step('Enter credentials', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); await waitFor(() => expect(args.onSubmit).toHaveBeenCalled()); }, diff --git a/docs/snippets/common/storybook-interactions-step-function.js.mdx b/docs/snippets/common/storybook-interactions-step-function.js.mdx new file mode 100644 index 000000000000..7ef59f7d482c --- /dev/null +++ b/docs/snippets/common/storybook-interactions-step-function.js.mdx @@ -0,0 +1,14 @@ +```js +Submitted.play = async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); +}; +``` diff --git a/docs/writing-tests/interaction-testing.md b/docs/writing-tests/interaction-testing.md index d4d74ea0ca48..9d13650d8c4a 100644 --- a/docs/writing-tests/interaction-testing.md +++ b/docs/writing-tests/interaction-testing.md @@ -81,7 +81,7 @@ Once the story loads in the UI, it simulates the user's behavior and verifies th /> -## API for user-events +### API for user-events Under the hood, Storybook’s interaction addon mirrors Testing Library’s [`user-events`](https://testing-library.com/docs/user-event/intro/) API. If you’re familiar with [Testing Library](https://testing-library.com/), you should be at home in Storybook. @@ -99,6 +99,24 @@ Below is an abridged API for user-event. For more, check out the [official user- | `type` | Writes text inside inputs, or textareas
`userEvent.type(await within(canvasElement).getByRole('my-input'),'Some text');` | | `unhover` | Unhovers out of element
`userEvent.unhover(await within(canvasElement).getByLabelText(/Example/i));` | +### Group interactions with the `step` function + +For complex flows, it can be worthwhile to group sets of related interactions together using the `step` function. This allows you to provide a custom label that describes a set of interactions: + + + + + + + +This will show your interactions nested in a collapsible group: + +![Interaction testing with labeled steps](./storybook-addon-interactions-steps.png) + ### Interactive debugger If you check your interactions panel, you'll see the step-by-step flow. It also offers a handy set of UI controls to pause, resume, rewind, and step through each interaction. diff --git a/docs/writing-tests/storybook-addon-interactions-steps.png b/docs/writing-tests/storybook-addon-interactions-steps.png new file mode 100644 index 000000000000..0438e58678d4 Binary files /dev/null and b/docs/writing-tests/storybook-addon-interactions-steps.png differ diff --git a/scripts/package.json b/scripts/package.json index ff04825b90e8..5979e6bae522 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -185,6 +185,9 @@ "window-size": "^1.1.1", "zx": "^7.0.3" }, + "devDependencies": { + "@types/lodash": "^4" + }, "optionalDependencies": { "@cypress/skip-test": "^2.6.1", "@cypress/webpack-preprocessor": "^5.9.1", @@ -198,8 +201,5 @@ "engines": { "node": ">=10.13.0", "yarn": ">=1.3.2" - }, - "devDependencies": { - "@types/lodash": "^4" } }