diff --git a/addons/interactions/src/Panel.stories.tsx b/addons/interactions/src/Panel.stories.tsx index 8e22606d4ecf..cc6f37afdd09 100644 --- a/addons/interactions/src/Panel.stories.tsx +++ b/addons/interactions/src/Panel.stories.tsx @@ -4,7 +4,7 @@ import { ComponentStoryObj, ComponentMeta } from '@storybook/react'; import { CallStates } from '@storybook/instrumenter'; import { styled } from '@storybook/theming'; -import { getCall } from './mocks'; +import { getCalls, getInteractions } from './mocks'; import { AddonPanelPure } from './Panel'; import SubnavStories from './components/Subnav/Subnav.stories'; @@ -20,6 +20,8 @@ const StyledWrapper = styled.div(({ theme }) => ({ overflow: 'auto', })); +const interactions = getInteractions(CallStates.DONE); + export default { title: 'Addons/Interactions/Panel', component: AddonPanelPure, @@ -34,10 +36,10 @@ export default { layout: 'fullscreen', }, args: { - calls: new Map(), + calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), controls: SubnavStories.args.controls, controlStates: SubnavStories.args.controlStates, - interactions: [getCall(CallStates.DONE)], + interactions, fileName: 'addon-interactions.stories.tsx', hasException: false, isPlaying: false, @@ -52,14 +54,14 @@ type Story = ComponentStoryObj; export const Passing: Story = { args: { - interactions: [getCall(CallStates.DONE)], + interactions: getInteractions(CallStates.DONE), }, }; export const Paused: Story = { args: { isPlaying: true, - interactions: [getCall(CallStates.WAITING)], + interactions: getInteractions(CallStates.WAITING), controlStates: { debugger: true, start: false, @@ -68,20 +70,21 @@ export const Paused: Story = { next: true, end: true, }, + pausedAt: interactions[interactions.length - 1].id, }, }; export const Playing: Story = { args: { isPlaying: true, - interactions: [getCall(CallStates.ACTIVE)], + interactions: getInteractions(CallStates.ACTIVE), }, }; export const Failed: Story = { args: { hasException: true, - interactions: [getCall(CallStates.ERROR)], + interactions: getInteractions(CallStates.ERROR), }, }; diff --git a/addons/interactions/src/Panel.tsx b/addons/interactions/src/Panel.tsx index 4693f8577991..fcd6918cfcf5 100644 --- a/addons/interactions/src/Panel.tsx +++ b/addons/interactions/src/Panel.tsx @@ -28,10 +28,16 @@ interface InteractionsPanelProps { active: boolean; controls: Controls; controlStates: ControlStates; - interactions: (Call & { status?: CallStates })[]; + interactions: (Call & { + status?: CallStates; + childCallIds: Call['id'][]; + isCollapsed: boolean; + toggleCollapsed: () => void; + })[]; fileName?: string; hasException?: boolean; isPlaying?: boolean; + pausedAt?: Call['id']; calls: Map; endRef?: React.Ref; onScrollToEnd?: () => void; @@ -66,6 +72,7 @@ export const AddonPanelPure: React.FC = React.memo( fileName, hasException, isPlaying, + pausedAt, onScrollToEnd, endRef, isRerunAnimating, @@ -87,15 +94,21 @@ export const AddonPanelPure: React.FC = React.memo( setIsRerunAnimating={setIsRerunAnimating} /> )} - {interactions.map((call) => ( - - ))} +
+ {interactions.map((call) => ( + + ))} +
{!isPlaying && interactions.length === 0 && ( @@ -116,16 +129,36 @@ export const AddonPanelPure: React.FC = React.memo( export const Panel: React.FC = (props) => { const [storyId, setStoryId] = React.useState(); const [controlStates, setControlStates] = React.useState(INITIAL_CONTROL_STATES); + const [pausedAt, setPausedAt] = React.useState(); const [isPlaying, setPlaying] = React.useState(false); const [isRerunAnimating, setIsRerunAnimating] = React.useState(false); const [scrollTarget, setScrollTarget] = React.useState(); + const [collapsed, setCollapsed] = React.useState>(new Set()); // Calls are tracked in a ref so we don't needlessly rerender. const calls = React.useRef>>(new Map()); const setCall = ({ status, ...call }: Call) => calls.current.set(call.id, call); const [log, setLog] = React.useState([]); - const interactions = log.map(({ callId, status }) => ({ ...calls.current.get(callId), status })); + const childCallMap = new Map(); + const interactions = log + .filter((call) => { + if (!call.parentId) return true; + childCallMap.set(call.parentId, (childCallMap.get(call.parentId) || []).concat(call.callId)); + return !collapsed.has(call.parentId); + }) + .map(({ callId, status }) => ({ + ...calls.current.get(callId), + status, + childCallIds: childCallMap.get(callId), + isCollapsed: collapsed.has(callId), + toggleCollapsed: () => + setCollapsed((ids) => { + if (ids.has(callId)) ids.delete(callId); + else ids.add(callId); + return new Set(ids); + }), + })); const endRef = React.useRef(); React.useEffect(() => { @@ -146,10 +179,12 @@ export const Panel: React.FC = (props) => { [EVENTS.SYNC]: (payload) => { setControlStates(payload.controlStates); setLog(payload.logItems); + setPausedAt(payload.pausedAt); }, [STORY_RENDER_PHASE_CHANGED]: (event) => { setStoryId(event.storyId); setPlaying(event.newPhase === 'playing'); + setPausedAt(undefined); }, }, [] @@ -191,6 +226,7 @@ export const Panel: React.FC = (props) => { fileName={fileName} hasException={hasException} isPlaying={isPlaying} + pausedAt={pausedAt} endRef={endRef} onScrollToEnd={scrollTarget && scrollToTarget} isRerunAnimating={isRerunAnimating} diff --git a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx index 9619a228b507..d3df3f635f2f 100644 --- a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx +++ b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx @@ -131,7 +131,7 @@ export const StandardEmailFailed: CSF3Story = { await userEvent.click(canvas.getByRole('button', { name: /create account/i })); await canvas.findByText('Please enter a correctly formatted email address'); - expect(args.onSubmit).not.toHaveBeenCalled(); + await expect(args.onSubmit).not.toHaveBeenCalled(); }, }; diff --git a/addons/interactions/src/components/Interaction/Interaction.stories.tsx b/addons/interactions/src/components/Interaction/Interaction.stories.tsx index 3fc5ab83b5ef..2089cef4a2a0 100644 --- a/addons/interactions/src/components/Interaction/Interaction.stories.tsx +++ b/addons/interactions/src/components/Interaction/Interaction.stories.tsx @@ -2,7 +2,7 @@ import { ComponentStoryObj, ComponentMeta } from '@storybook/react'; import { expect } from '@storybook/jest'; import { CallStates } from '@storybook/instrumenter'; import { userEvent, within } from '@storybook/testing-library'; -import { getCall } from '../../mocks'; +import { getCalls } from '../../mocks'; import { Interaction } from './Interaction'; import SubnavStories from '../Subnav/Subnav.stories'; @@ -13,7 +13,7 @@ export default { title: 'Addons/Interactions/Interaction', component: Interaction, args: { - callsById: new Map(), + callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), controls: SubnavStories.args.controls, controlStates: SubnavStories.args.controlStates, }, @@ -21,25 +21,31 @@ export default { export const Active: Story = { args: { - call: getCall(CallStates.ACTIVE), + call: getCalls(CallStates.ACTIVE).slice(-1)[0], }, }; export const Waiting: Story = { args: { - call: getCall(CallStates.WAITING), + call: getCalls(CallStates.WAITING).slice(-1)[0], }, }; export const Failed: Story = { args: { - call: getCall(CallStates.ERROR), + call: getCalls(CallStates.ERROR).slice(-1)[0], }, }; export const Done: Story = { args: { - call: getCall(CallStates.DONE), + call: getCalls(CallStates.DONE).slice(-1)[0], + }, +}; + +export const WithParent: Story = { + args: { + call: { ...getCalls(CallStates.DONE).slice(-1)[0], parentId: 'parent-id' }, }, }; diff --git a/addons/interactions/src/components/Interaction/Interaction.tsx b/addons/interactions/src/components/Interaction/Interaction.tsx index d9d6bf27ddb1..30922f8dc908 100644 --- a/addons/interactions/src/components/Interaction/Interaction.tsx +++ b/addons/interactions/src/components/Interaction/Interaction.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { IconButton, Icons, TooltipNote, WithTooltip } from '@storybook/components'; import { Call, CallStates, ControlStates } from '@storybook/instrumenter'; import { styled, typography } from '@storybook/theming'; import { transparentize } from 'polished'; @@ -15,23 +16,55 @@ const MethodCallWrapper = styled.div(() => ({ inlineSize: 'calc( 100% - 40px )', })); -const RowContainer = styled('div', { shouldForwardProp: (prop) => !['call'].includes(prop) })<{ - call: Call; -}>(({ theme, call }) => ({ - display: 'flex', - flexDirection: 'column', - borderBottom: `1px solid ${theme.appBorderColor}`, - fontFamily: typography.fonts.base, - fontSize: 13, - ...(call.status === CallStates.ERROR && { - backgroundColor: - theme.base === 'dark' ? transparentize(0.93, theme.color.negative) : theme.background.warning, +const RowContainer = styled('div', { + shouldForwardProp: (prop) => !['call', 'pausedAt'].includes(prop), +})<{ call: Call; pausedAt: Call['id'] }>( + ({ theme, call }) => ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + borderBottom: `1px solid ${theme.appBorderColor}`, + fontFamily: typography.fonts.base, + fontSize: 13, + ...(call.status === CallStates.ERROR && { + backgroundColor: + theme.base === 'dark' + ? transparentize(0.93, theme.color.negative) + : theme.background.warning, + }), + paddingLeft: call.parentId ? 20 : 0, }), + ({ theme, call, pausedAt }) => + pausedAt === call.id && { + '&::before': { + content: '""', + position: 'absolute', + top: -5, + zIndex: 1, + borderTop: '4.5px solid transparent', + borderLeft: `7px solid ${theme.color.warning}`, + borderBottom: '4.5px solid transparent', + }, + '&::after': { + content: '""', + position: 'absolute', + top: -1, + zIndex: 1, + width: '100%', + borderTop: `1.5px solid ${theme.color.warning}`, + }, + } +); + +const RowHeader = styled.div<{ disabled: boolean }>(({ theme, disabled }) => ({ + display: 'flex', + '&:hover': disabled ? {} : { background: theme.background.hoverable }, })); const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].includes(prop) })< React.ButtonHTMLAttributes & { call: Call } >(({ theme, disabled, call }) => ({ + flex: 1, display: 'grid', background: 'none', border: 0, @@ -42,7 +75,6 @@ const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].inclu padding: '8px 15px', textAlign: 'start', cursor: disabled || call.status === CallStates.ERROR ? 'default' : 'pointer', - '&:hover': disabled ? {} : { background: theme.background.hoverable }, '&:focus-visible': { outline: 0, boxShadow: `inset 3px 0 0 0 ${ @@ -55,45 +87,101 @@ const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].inclu }, })); -const RowMessage = styled('pre')({ - margin: 0, - padding: '8px 10px 8px 30px', +const RowActions = styled.div(({ theme }) => ({ + padding: 6, +})); + +export const StyledIconButton = styled(IconButton as any)(({ theme }) => ({ + color: theme.color.mediumdark, + margin: '0 3px', +})); + +const Note = styled(TooltipNote)(({ theme }) => ({ + fontFamily: theme.typography.fonts.base, +})); + +const RowMessage = styled('div')(({ theme }) => ({ + padding: '8px 10px 8px 36px', fontSize: typography.size.s1, -}); + pre: { + margin: 0, + padding: 0, + }, + p: { + color: theme.color.dark, + }, +})); + +const Exception = ({ exception }: { exception: Call['exception'] }) => { + if (exception.message.startsWith('expect(')) { + return ; + } + const paragraphs = exception.message.split('\n\n'); + const more = paragraphs.length > 1; + return ( + +
{paragraphs[0]}
+ {more &&

See the full stack trace in the browser console.

} +
+ ); +}; export const Interaction = ({ call, callsById, controls, controlStates, + childCallIds, + isCollapsed, + toggleCollapsed, + pausedAt, }: { call: Call; callsById: Map; controls: Controls; controlStates: ControlStates; + childCallIds?: Call['id'][]; + isCollapsed: boolean; + toggleCollapsed: () => void; + pausedAt?: Call['id']; }) => { const [isHovered, setIsHovered] = React.useState(false); return ( - - controls.goto(call.id)} - disabled={!controlStates.goto} - onMouseEnter={() => controlStates.goto && setIsHovered(true)} - onMouseLeave={() => controlStates.goto && setIsHovered(false)} - > - - - - - - {call.status === CallStates.ERROR && - call.exception && - (call.exception.message.startsWith('expect(') ? ( - - ) : ( - {call.exception.message} - ))} + + + controls.goto(call.id)} + disabled={!controlStates.goto || !call.interceptable || !!call.parentId} + onMouseEnter={() => controlStates.goto && setIsHovered(true)} + onMouseLeave={() => controlStates.goto && setIsHovered(false)} + > + + + + + + + {childCallIds?.length > 0 && ( + + } + > + + + + + )} + + + + {call.status === CallStates.ERROR && call.exception?.callId === call.id && ( + + )} ); }; diff --git a/addons/interactions/src/components/MatcherResult.tsx b/addons/interactions/src/components/MatcherResult.tsx index cf52a920ae42..4e3814702e3f 100644 --- a/addons/interactions/src/components/MatcherResult.tsx +++ b/addons/interactions/src/components/MatcherResult.tsx @@ -50,7 +50,7 @@ export const MatcherResult = ({ message }: { message: string }) => {
diff --git a/addons/interactions/src/components/MethodCall.stories.tsx b/addons/interactions/src/components/MethodCall.stories.tsx
index feda43c8ff5c..b65001b4e1cb 100644
--- a/addons/interactions/src/components/MethodCall.stories.tsx
+++ b/addons/interactions/src/components/MethodCall.stories.tsx
@@ -27,7 +27,6 @@ export default {
   },
 };
 
-class FooBar {}
 export const Args = () => (
   
@@ -56,37 +55,49 @@ export const Args = () => ( }} showObjectInspector /> - - + + - - - - - - - - - - {/* eslint-disable-next-line symbol-description */} - - + + + + + + + + + + +
); const calls: Call[] = [ { + cursor: 0, id: '1', path: ['screen'], method: 'getByText', @@ -96,6 +107,7 @@ const calls: Call[] = [ retain: false, }, { + cursor: 1, id: '2', path: ['userEvent'], method: 'click', @@ -105,6 +117,7 @@ const calls: Call[] = [ retain: false, }, { + cursor: 2, id: '3', path: [], method: 'expect', @@ -114,6 +127,7 @@ const calls: Call[] = [ retain: false, }, { + cursor: 3, id: '4', path: [{ __callId__: '3' }, 'not'], method: 'toBe', @@ -123,15 +137,17 @@ const calls: Call[] = [ retain: false, }, { + cursor: 4, id: '5', path: ['jest'], method: 'fn', storyId: 'kind--story', - args: [function actionHandler() {}], + args: [{ __function__: { name: 'actionHandler' } }], interceptable: false, retain: false, }, { + cursor: 5, id: '6', path: [], method: 'expect', @@ -141,20 +157,28 @@ const calls: Call[] = [ retain: false, }, { + cursor: 6, id: '7', path: ['expect'], method: 'stringMatching', storyId: 'kind--story', - args: [/hello/i], + args: [{ __regexp__: { flags: 'i', source: 'hello' } }], interceptable: false, retain: false, }, { + cursor: 7, id: '8', path: [{ __callId__: '6' }, 'not'], method: 'toHaveBeenCalledWith', storyId: 'kind--story', - args: [{ __callId__: '7' }, new Error("Cannot read property 'foo' of undefined")], + args: [ + { __callId__: '7' }, + [ + { __error__: { name: 'Error', message: "Cannot read property 'foo' of undefined" } }, + { __symbol__: { description: 'Hello world' } }, + ], + ], interceptable: false, retain: false, }, diff --git a/addons/interactions/src/components/MethodCall.tsx b/addons/interactions/src/components/MethodCall.tsx index 10a3bf2c0f47..fa792dce56aa 100644 --- a/addons/interactions/src/components/MethodCall.tsx +++ b/addons/interactions/src/components/MethodCall.tsx @@ -111,32 +111,34 @@ export const Node = ({ return ; case value === undefined: return ; + case Array.isArray(value): + return ; case typeof value === 'string': - return ; + return ; case typeof value === 'number': - return ; + return ; case typeof value === 'boolean': - return ; - case typeof value === 'function': - return ; - case value instanceof Array: - return ; - case value instanceof Date: - return ; - case value instanceof Error: - return ; - case value instanceof RegExp: - return ; + return ; + + /* eslint-disable no-underscore-dangle */ + case Object.prototype.hasOwnProperty.call(value, '__date__'): + return ; + case Object.prototype.hasOwnProperty.call(value, '__error__'): + return ; + case Object.prototype.hasOwnProperty.call(value, '__regexp__'): + return ; + case Object.prototype.hasOwnProperty.call(value, '__function__'): + return ; + case Object.prototype.hasOwnProperty.call(value, '__symbol__'): + return ; case Object.prototype.hasOwnProperty.call(value, '__element__'): - // eslint-disable-next-line no-underscore-dangle - return ; + return ; + case Object.prototype.hasOwnProperty.call(value, '__class__'): + return ; case Object.prototype.hasOwnProperty.call(value, '__callId__'): - // eslint-disable-next-line no-underscore-dangle return ; - case typeof value === 'object' && - value.constructor?.name && - value.constructor?.name !== 'Object': - return ; + /* eslint-enable no-underscore-dangle */ + case Object.prototype.toString.call(value) === '[object Object]': return ; default: @@ -263,18 +265,27 @@ export const ObjectNode = ({ ); }; -export const ClassNode = ({ value }: { value: Record }) => { +export const ClassNode = ({ name }: { name: string }) => { const colors = useThemeColors(); - return {value.constructor.name}; + return {name}; }; -export const FunctionNode = ({ value }: { value: Function }) => { +export const FunctionNode = ({ name }: { name: string }) => { const colors = useThemeColors(); - return {value.name || 'anonymous'}; + return name ? ( + {name} + ) : ( + anonymous + ); }; -export const ElementNode = ({ value }: { value: ElementRef['__element__'] }) => { - const { prefix, localName, id, classNames = [], innerText } = value; +export const ElementNode = ({ + prefix, + localName, + id, + classNames = [], + innerText, +}: ElementRef['__element__']) => { const name = prefix ? `${prefix}:${localName}` : localName; const colors = useThemeColors(); return ( @@ -309,8 +320,8 @@ export const ElementNode = ({ value }: { value: ElementRef['__element__'] }) => ); }; -export const DateNode = ({ value }: { value: Date }) => { - const [date, time, ms] = value.toISOString().split(/[T.Z]/); +export const DateNode = ({ value }: { value: string }) => { + const [date, time, ms] = value.split(/[T.Z]/); const colors = useThemeColors(); return ( @@ -323,42 +334,36 @@ export const DateNode = ({ value }: { value: Date }) => { ); }; -export const ErrorNode = ({ value }: { value: Error }) => { +export const ErrorNode = ({ name, message }: { name: string; message: string }) => { const colors = useThemeColors(); return ( - {value.name} - {value.message && ': '} - {value.message && ( - 50 ? value.message : ''} - > - {ellipsize(value.message, 50)} + {name} + {message && ': '} + {message && ( + 50 ? message : ''}> + {ellipsize(message, 50)} )} ); }; -export const RegExpNode = ({ value }: { value: RegExp }) => { +export const RegExpNode = ({ flags, source }: { flags: string; source: string }) => { const colors = useThemeColors(); return ( - /{value.source}/{value.flags} + /{source}/{flags} ); }; -export const SymbolNode = ({ value }: { value: symbol }) => { +export const SymbolNode = ({ description }: { description: string }) => { const colors = useThemeColors(); return ( Symbol( - {value.description && ( - {JSON.stringify(value.description)} - )} - ) + {description && "{description}"}) ); }; diff --git a/addons/interactions/src/components/Subnav/Subnav.stories.tsx b/addons/interactions/src/components/Subnav/Subnav.stories.tsx index 9605bc53252c..40e365d03fb9 100644 --- a/addons/interactions/src/components/Subnav/Subnav.stories.tsx +++ b/addons/interactions/src/components/Subnav/Subnav.stories.tsx @@ -12,6 +12,7 @@ export default { goto: action('goto'), next: action('next'), end: action('end'), + rerun: action('rerun'), }, controlStates: { debugger: true, diff --git a/addons/interactions/src/mocks/index.ts b/addons/interactions/src/mocks/index.ts index 7952b6ecf896..e55fe4d88b8f 100644 --- a/addons/interactions/src/mocks/index.ts +++ b/addons/interactions/src/mocks/index.ts @@ -1,31 +1,122 @@ import { CallStates, Call } from '@storybook/instrumenter'; -export const getCall = (status: CallStates): Call => { - const defaultData = { - id: 'addons-interactions-accountform--standard-email-filled [3] change', - cursor: 0, - path: ['fireEvent'], - method: 'change', - storyId: 'addons-interactions-accountform--standard-email-filled', - args: [ - { - __callId__: 'addons-interactions-accountform--standard-email-filled [2] getByTestId', - retain: false, - }, - { - target: { - value: 'michael@chromatic.com', - }, - }, - ], - interceptable: true, - retain: false, - status, - }; +export const getCalls = (finalStatus: CallStates) => { + const calls: Call[] = [ + { + id: 'story--id [3] within', + storyId: 'story--id', + cursor: 3, + path: [], + method: 'within', + args: [{ __element__: { localName: 'div', id: 'root' } }], + interceptable: false, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [4] findByText', + storyId: 'story--id', + cursor: 4, + path: [{ __callId__: 'story--id [3] within' }], + method: 'findByText', + args: ['Click'], + interceptable: true, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [5] click', + storyId: 'story--id', + cursor: 5, + path: ['userEvent'], + method: 'click', + args: [{ __element__: { localName: 'button', innerText: 'Click' } }], + interceptable: true, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [6] waitFor', + storyId: 'story--id', + cursor: 6, + path: [], + method: 'waitFor', + args: [{ __function__: { name: '' } }], + interceptable: true, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [6] waitFor [0] expect', + parentId: 'story--id [6] waitFor', + storyId: 'story--id', + cursor: 1, + path: [], + method: 'expect', + args: [{ __function__: { name: 'handleSubmit' } }], + interceptable: false, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [6] waitFor [1] stringMatching', + parentId: 'story--id [6] waitFor', + storyId: 'story--id', + cursor: 2, + path: ['expect'], + method: 'stringMatching', + args: [{ __regexp__: { flags: 'gi', source: '([A-Z])w+' } }], + interceptable: false, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [6] waitFor [2] toHaveBeenCalledWith', + parentId: 'story--id [6] waitFor', + storyId: 'story--id', + cursor: 3, + path: [{ __callId__: 'story--id [6] waitFor [0] expect' }], + method: 'toHaveBeenCalledWith', + args: [{ __callId__: 'story--id [6] waitFor [1] stringMatching', retain: false }], + interceptable: true, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [7] expect', + storyId: 'story--id', + cursor: 7, + path: [], + method: 'expect', + args: [{ __function__: { name: 'handleReset' } }], + interceptable: false, + retain: false, + status: CallStates.DONE, + }, + { + id: 'story--id [8] toHaveBeenCalled', + storyId: 'story--id', + cursor: 8, + path: [{ __callId__: 'story--id [7] expect' }, 'not'], + method: 'toHaveBeenCalled', + args: [], + interceptable: true, + retain: false, + status: finalStatus, + }, + ]; - const overrides = CallStates.ERROR - ? { exception: { name: 'Error', stack: '', message: "Things didn't work!" } } - : {}; + if (finalStatus === CallStates.ERROR) { + calls[calls.length - 1].exception = { + name: 'Error', + stack: '', + message: 'Oops!', + callId: calls[calls.length - 1].id, + }; + } - return { ...defaultData, ...overrides }; + return calls; }; + +export const getInteractions = (finalStatus: CallStates) => + getCalls(finalStatus).filter((call) => call.interceptable); diff --git a/docs/writing-tests/test-runner.md b/docs/writing-tests/test-runner.md index 5502adfbcd15..1940f87630c0 100644 --- a/docs/writing-tests/test-runner.md +++ b/docs/writing-tests/test-runner.md @@ -43,8 +43,9 @@ Start your Storybook with: @@ -302,4 +303,4 @@ As the test runner is based on Playwright, you might need to use specific docker - [Accessibility tests](./accessibility-testing.md) for accessibility - [Interaction tests](./interaction-testing.md) for user behavior simulation - [Snapshot tests](./snapshot-testing.md) for rendering errors and warnings -- [Import stories in other tests](./importing-stories-in-tests.md) for other tools \ No newline at end of file +- [Import stories in other tests](./importing-stories-in-tests.md) for other tools diff --git a/lib/instrumenter/src/instrumenter.test.ts b/lib/instrumenter/src/instrumenter.test.ts index b7fc39375757..5c8025c03dc9 100644 --- a/lib/instrumenter/src/instrumenter.test.ts +++ b/lib/instrumenter/src/instrumenter.test.ts @@ -1,6 +1,7 @@ /* eslint-disable no-underscore-dangle */ import { addons, mockChannel } from '@storybook/addons'; +import { logger } from '@storybook/client-logger'; import { FORCE_REMOUNT, SET_CURRENT_STORY, @@ -11,6 +12,8 @@ import global from 'global'; import { EVENTS, Instrumenter } from './instrumenter'; import type { Options } from './types'; +jest.mock('@storybook/client-logger'); + const callSpy = jest.fn(); const syncSpy = jest.fn(); const forceRemountSpy = jest.fn(); @@ -39,6 +42,8 @@ let instrumenter: Instrumenter; const instrument = >(obj: TObj, options: Options = {}) => instrumenter.instrument(obj, options); +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { jest.useRealTimers(); callSpy.mockClear(); @@ -296,25 +301,40 @@ describe('Instrumenter', () => { ); }); - it('catches thrown errors and returns the error', () => { + it('catches thrown errors and throws an ignoredException instead', () => { const { fn } = instrument({ fn: () => { throw new Error('Boom!'); }, }); - expect(fn()).toEqual(new Error('Boom!')); - expect(() => setRenderPhase('played')).toThrow(new Error('Boom!')); + expect(fn).toThrow('ignoredException'); + }); + + it('catches nested exceptions and throws an ignoredException instead', () => { + const { fn1, fn2 } = instrument({ + fn1: (_: any) => {}, + fn2: () => { + throw new Error('Boom!'); + }, + }); + expect(() => fn1(fn2())).toThrow('ignoredException'); }); - it('forwards nested exceptions', () => { + it('bubbles child exceptions up to parent (in callback)', () => { const { fn1, fn2 } = instrument({ - fn1: (...args: any) => {}, // doesn't forward args + fn1: jest.fn((callback: Function) => callback()), fn2: () => { throw new Error('Boom!'); }, }); - expect(fn1(fn2())).toEqual(new Error('Boom!')); - expect(() => setRenderPhase('played')).toThrow(new Error('Boom!')); + expect(() => + fn1(() => { + fn2(); + }) + ).toThrow('ignoredException'); + expect(fn1).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith(new Error('Boom!')); + expect((logger.warn as any).mock.calls[0][0].callId).toBe('kind--story [0] fn1 [0] fn2'); }); it("re-throws anything that isn't an error", () => { @@ -357,6 +377,45 @@ describe('Instrumenter', () => { describe('with intercept: true', () => { const options = { intercept: true }; + it('only includes intercepted calls in the log', async () => { + const fn = (callback?: Function) => callback && callback(); + const { fn1, fn2 } = instrument({ fn1: fn, fn2: fn }, options); + const { fn3 } = instrument({ fn3: fn }, { intercept: false }); + fn1(); + fn2(); + fn3(); + await tick(); + expect(syncSpy).toHaveBeenCalledWith( + expect.objectContaining({ + logItems: [ + { callId: 'kind--story [0] fn1', status: 'done' }, + { callId: 'kind--story [1] fn2', status: 'done' }, + ], + }) + ); + }); + + it('also includes child calls in the log', async () => { + const fn = (callback?: Function) => callback && callback(); + const { fn1, fn2 } = instrument({ fn1: fn, fn2: fn }, options); + fn1(() => { + fn2(); + }); + await tick(); + expect(syncSpy).toHaveBeenCalledWith( + expect.objectContaining({ + logItems: [ + { callId: 'kind--story [0] fn1', status: 'done' }, + { + callId: 'kind--story [0] fn1 [0] fn2', + status: 'done', + parentId: 'kind--story [0] fn1', + }, + ], + }) + ); + }); + it('emits a call event with error data when the function throws', () => { const { fn } = instrument( { @@ -374,35 +433,11 @@ describe('Instrumenter', () => { name: 'Error', message: 'Boom!', stack: expect.stringContaining('Error: Boom!'), + callId: 'kind--story [0] fn', }, }) ); }); - - it('catches thrown errors and throws an ignoredException instead', () => { - const { fn } = instrument( - { - fn: () => { - throw new Error('Boom!'); - }, - }, - options - ); - expect(fn).toThrow('ignoredException'); - }); - - it('catches forwarded exceptions and throws an ignoredException instead', () => { - const { fn1, fn2 } = instrument( - { - fn1: (_: any) => {}, - fn2: () => { - throw new Error('Boom!'); - }, - }, - options - ); - expect(() => fn1(fn2())).toThrow('ignoredException'); - }); }); describe('while debugging', () => { @@ -443,12 +478,13 @@ describe('Instrumenter', () => { }); it.skip('starts debugging at the first non-nested interceptable call', () => { - const { fn } = instrument({ fn: jest.fn((...args: any) => args) }, { intercept: true }); - fn(fn(), fn()); // setup the dependencies + const fn = (...args) => args; + const { fn1, fn2, fn3 } = instrument({ fn1: fn, fn2: fn, fn3: fn }, { intercept: true }); + fn3(fn1(), fn2()); // setup the dependencies addons.getChannel().emit(EVENTS.START, { storyId }); - const a = fn('a'); - const b = fn('b'); - const c = fn(a, b); + const a = fn1('a'); + const b = fn2('b'); + const c = fn3(a, b); expect(a).toEqual(['a']); expect(b).toEqual(['b']); expect(c).toEqual(expect.any(Promise)); @@ -476,13 +512,13 @@ describe('Instrumenter', () => { expect(fn).toHaveBeenCalledTimes(0); addons.getChannel().emit(EVENTS.NEXT, { storyId }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await tick(); expect(mockedInstrumentedFn).toHaveBeenCalledTimes(2); expect(fn).toHaveBeenCalledTimes(1); addons.getChannel().emit(EVENTS.END, { storyId }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await tick(); expect(mockedInstrumentedFn).toHaveBeenCalledTimes(3); expect(fn).toHaveBeenCalledTimes(3); diff --git a/lib/instrumenter/src/instrumenter.ts b/lib/instrumenter/src/instrumenter.ts index 9e54071bab64..4b8253cd3548 100644 --- a/lib/instrumenter/src/instrumenter.ts +++ b/lib/instrumenter/src/instrumenter.ts @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle */ import { addons, Channel } from '@storybook/addons'; import type { StoryId } from '@storybook/addons'; -import { once } from '@storybook/client-logger'; +import { logger, once } from '@storybook/client-logger'; import { FORCE_REMOUNT, IGNORED_EXCEPTION, @@ -10,7 +10,7 @@ import { } from '@storybook/core-events'; import global from 'global'; -import { Call, CallRef, CallStates, State, Options, ControlStates, LogItem } from './types'; +import { Call, CallRef, CallStates, ControlStates, LogItem, Options, State } from './types'; export const EVENTS = { CALL: 'instrumenter/call', @@ -73,7 +73,6 @@ const getInitialState = (): State => ({ playUntil: undefined, resolvers: {}, syncTimeout: undefined, - forwardedException: undefined, }); const getRetainedState = (state: State, isDebugging = false) => { @@ -132,7 +131,7 @@ export class Instrumenter { // Start with a clean slate before playing after a remount, and stop debugging when done. this.channel.on(STORY_RENDER_PHASE_CHANGED, ({ storyId, newPhase }) => { - const { isDebugging, forwardedException } = this.getState(storyId); + const { isDebugging } = this.getState(storyId); this.setState(storyId, { renderPhase: newPhase }); if (newPhase === 'playing') { resetState({ storyId, isDebugging }); @@ -142,10 +141,13 @@ export class Instrumenter { isLocked: false, isPlaying: false, isDebugging: false, - forwardedException: undefined, }); - // Rethrow any unhandled forwarded exception so it doesn't go unnoticed. - if (forwardedException) throw forwardedException; + } + if (newPhase === 'errored') { + this.setState(storyId, { + isLocked: false, + isPlaying: false, + }); } }); @@ -172,7 +174,7 @@ export class Instrumenter { playUntil || shadowCalls .slice(0, firstRowIndex) - .filter((call) => call.interceptable) + .filter((call) => call.interceptable && !call.parentId) .slice(-1)[0]?.id, }; }); @@ -182,12 +184,12 @@ export class Instrumenter { }; const back = ({ storyId }: { storyId: string }) => { - const { isDebugging } = this.getState(storyId); - const log = this.getLog(storyId); - const next = isDebugging - ? log.findIndex(({ status }) => status === CallStates.WAITING) - : log.length; - start({ storyId, playUntil: log[next - 2]?.callId }); + const log = this.getLog(storyId).filter((call) => !call.parentId); + const last = log.reduceRight((res, item, index) => { + if (res >= 0 || item.status === CallStates.WAITING) return res; + return index; + }, -1); + start({ storyId, playUntil: log[last - 1]?.callId }); }; const goto = ({ storyId, callId }: { storyId: string; callId: Call['id'] }) => { @@ -269,8 +271,8 @@ export class Instrumenter { seen.add((node as CallRef).__callId__); } }); - if (call.interceptable && !seen.has(call.id)) { - acc.unshift({ callId: call.id, status: call.status }); + if ((call.interceptable || call.exception) && !seen.has(call.id)) { + acc.unshift({ callId: call.id, status: call.status, parentId: call.parentId }); seen.add(call.id); } return acc; @@ -333,7 +335,8 @@ export class Instrumenter { 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 result = (interceptable ? this.intercept : this.invoke).call(this, fn, call, options); + const interceptOrInvoke = interceptable && !parentId ? this.intercept : this.invoke; + const result = interceptOrInvoke.call(this, fn, call, options); return this.instrument(result, { ...options, mutate: true, path: [{ __callId__: call.id }] }); } @@ -370,25 +373,50 @@ export class Instrumenter { // const { abortSignal } = global.window.__STORYBOOK_PREVIEW__ || {}; // if (abortSignal && abortSignal.aborted) throw IGNORED_EXCEPTION; - const { callRefsByResult, forwardedException, renderPhase } = this.getState(call.storyId); + const { callRefsByResult, renderPhase } = this.getState(call.storyId); - const info: Call = { - ...call, - // Map args that originate from a tracked function call to a call reference to enable nesting. - // These values are often not fully serializable anyway (e.g. HTML elements). - args: call.args.map((arg) => { - if (callRefsByResult.has(arg)) { - return callRefsByResult.get(arg); - } - if (arg instanceof global.window.HTMLElement) { - const { prefix, localName, id, classList, innerText } = arg; - const classNames = Array.from(classList); - return { __element__: { prefix, localName, id, classNames, innerText } }; - } - return arg; - }), + // Map complex values to a JSON-serializable representation. + const serializeValues = (value: any): any => { + if (callRefsByResult.has(value)) { + return callRefsByResult.get(value); + } + if (value instanceof Array) { + return value.map(serializeValues); + } + if (value instanceof Date) { + return { __date__: { value: value.toISOString() } }; + } + if (value instanceof Error) { + const { name, message, stack } = value; + return { __error__: { name, message, stack } }; + } + if (value instanceof RegExp) { + const { flags, source } = value; + return { __regexp__: { flags, source } }; + } + 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 } }; + } + if (typeof value === 'function') { + return { __function__: { name: value.name } }; + } + if (typeof value === 'symbol') { + return { __symbol__: { description: value.description } }; + } + if ( + typeof value === 'object' && + value?.constructor?.name && + value?.constructor?.name !== 'Object' + ) { + return { __class__: { name: value.constructor.name } }; + } + return value; }; + const info: Call = { ...call, args: call.args.map(serializeValues) }; + // Mark any ancestor calls as "chained upon" so we won't attempt to defer it later. call.path.forEach((ref: any) => { if (ref?.__callId__) { @@ -398,10 +426,10 @@ export class Instrumenter { } }); - const handleException = (e: unknown) => { + const handleException = (e: any) => { if (e instanceof Error) { - const { name, message, stack } = e; - const exception = { name, message, stack }; + const { name, message, stack, callId = call.id } = e as Error & { callId: Call['id'] }; + const exception = { name, message, stack, callId }; this.update({ ...info, status: CallStates.ERROR, exception }); // Always track errors to their originating call. @@ -412,50 +440,56 @@ export class Instrumenter { ]), })); + // Exceptions inside callbacks should bubble up to the parent call. + if (call.parentId) { + Object.defineProperty(e, 'callId', { value: call.id }); + throw e; + } + // We need to throw to break out of the play function, but we don't want to trigger a redbox // so we throw an ignoredException, which is caught and silently ignored by Storybook. - if (call.interceptable && e !== alreadyCompletedException) { + if (e !== alreadyCompletedException) { + logger.warn(e); throw IGNORED_EXCEPTION; } - - // Non-interceptable calls need their exceptions forwarded to the next interceptable call. - // In case no interceptable call picks it up, it'll get rethrown in the "completed" phase. - this.setState(call.storyId, { forwardedException: e }); - return e; } throw e; }; try { - // An earlier, non-interceptable call might have forwarded an exception. - if (forwardedException) { - this.setState(call.storyId, { forwardedException: undefined }); - throw forwardedException; - } - if (renderPhase === 'played' && !call.retain) { throw alreadyCompletedException; } - const finalArgs = options.getArgs + // Some libraries override function args through the `getArgs` option. + const actualArgs = options.getArgs ? options.getArgs(call, this.getState(call.storyId)) : call.args; - const result = fn( - // Wrap any callback functions to provide a way to access their "parent" call. - // This is picked up in the `track` function and used for call metadata. - ...finalArgs.map((arg: any) => { - if (typeof arg !== 'function' || Object.keys(arg).length) return arg; - return (...args: any) => { - const { cursor, parentId } = this.getState(call.storyId); - this.setState(call.storyId, { cursor: 0, parentId: call.id }); - const restore = () => this.setState(call.storyId, { cursor, parentId }); - const res = arg(...args); - if (res instanceof Promise) res.then(restore, restore); - else restore(); - return res; - }; - }) - ); + + // Wrap any callback functions to provide a way to access their "parent" call. + // This is picked up in the `track` function and used for call metadata. + const finalArgs = actualArgs.map((arg: any) => { + // We only want to wrap plain functions, not objects. + 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 }); + + // 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(); + + return res; + }; + }); + + const result = fn(...finalArgs); // Track the result so we can trace later uses of it back to the originating call. // Primitive results (undefined, null, boolean, string, number, BigInt) are ignored. @@ -510,6 +544,9 @@ export class Instrumenter { 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; const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); if (debuggerDisabled || isLocked || hasActive || logItems.length === 0) { @@ -528,7 +565,8 @@ export class Instrumenter { next: isPlaying, end: isPlaying, }; - this.channel.emit(EVENTS.SYNC, { controlStates, logItems }); + + this.channel.emit(EVENTS.SYNC, { controlStates, logItems, pausedAt }); } } diff --git a/lib/instrumenter/src/types.ts b/lib/instrumenter/src/types.ts index 48f17fdec0d1..e9cca7003547 100644 --- a/lib/instrumenter/src/types.ts +++ b/lib/instrumenter/src/types.ts @@ -11,7 +11,12 @@ export interface Call { interceptable: boolean; retain: boolean; status?: CallStates.DONE | CallStates.ERROR | CallStates.ACTIVE | CallStates.WAITING; - exception?: Error; + exception?: { + name: Error['name']; + message: Error['message']; + stack: Error['stack']; + callId: Call['id']; + }; } export enum CallStates { @@ -47,6 +52,7 @@ export interface ControlStates { export interface LogItem { callId: Call['id']; status: Call['status']; + parentId?: Call['id']; } export interface Payload { diff --git a/lib/ui/src/components/preview/FramesRenderer.tsx b/lib/ui/src/components/preview/FramesRenderer.tsx index c8ccbae69d5e..4d384b3c968a 100644 --- a/lib/ui/src/components/preview/FramesRenderer.tsx +++ b/lib/ui/src/components/preview/FramesRenderer.tsx @@ -20,6 +20,7 @@ const SkipToSidebarLink = styled(Button)(({ theme }) => ({ display: 'none', '@media (min-width: 600px)': { position: 'absolute', + display: 'block', top: 10, right: 15, padding: '10px 15px',