diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 34514a6bf9eb..45eafeaf09ce 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Button } from 'storybook/internal/components'; -import { ChevronDownIcon, ChevronUpIcon } from '@storybook/icons'; +import { ChevronDownIcon, ChevronRightIcon } from '@storybook/icons'; import { transparentize } from 'polished'; import { styled, typography } from 'storybook/theming'; @@ -22,10 +22,11 @@ const MethodCallWrapper = styled.div({ inlineSize: 'calc( 100% - 40px )', }); -const RowContainer = styled('div', { +const RowContainer = styled('li', { shouldForwardProp: (prop) => !['call', 'pausedAt'].includes(prop.toString()), })<{ call: Call; pausedAt: Call['id'] | undefined }>( ({ theme, call }) => ({ + listStyle: 'none', position: 'relative', display: 'flex', flexDirection: 'column', @@ -62,10 +63,12 @@ const RowContainer = styled('div', { } ); -const RowHeader = styled.div<{ isInteractive: boolean }>(({ theme, isInteractive }) => ({ - display: 'flex', - '&:hover': isInteractive ? {} : { background: theme.background.hoverable }, -})); +const RowHeader = styled.div<{ isNavigationDisabled: boolean }>( + ({ theme, isNavigationDisabled }) => ({ + display: 'flex', + '&:hover': isNavigationDisabled ? {} : { background: theme.background.hoverable }, + }) +); const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].includes(prop.toString()), @@ -129,6 +132,27 @@ const ErrorExplainer = styled.p(({ theme }) => ({ textWrap: 'balance', })); +const stepStatusTextMap: Record, string> = { + [CallStates.DONE]: 'passed', + [CallStates.ERROR]: 'failed', + [CallStates.ACTIVE]: 'running', + [CallStates.WAITING]: 'pending', +}; + +const getInteractionLabel = (call: Call) => { + if (call.method === 'step' && call.path?.length === 0 && typeof call.args?.[0] === 'string') { + const label = call.args[0].trim(); + if (label.length > 0) { + return label; + } + } + + return call.method; +}; + +const getInteractionStatusText = (call: Call) => + call.status ? stepStatusTextMap[call.status] : 'pending'; + const Exception = ({ exception }: { exception: Call['exception'] }) => { const filter = useAnsiToHtmlFilter(); if (!exception) { @@ -194,7 +218,10 @@ export const Interaction = ({ pausedAt?: Call['id']; }) => { const [isHovered, setIsHovered] = React.useState(false); - const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors?.length; + const isNavigationDisabled = + !controlStates.goto || !call.interceptable || !!call.ancestors?.length; + const interactionLabel = getInteractionLabel(call); + const interactionStatus = getInteractionStatusText(call); if (isHidden) { return null; @@ -206,12 +233,14 @@ export const Interaction = ({ return ( - + controls.goto(call.id)} - disabled={isInteractive} + disabled={isNavigationDisabled} onMouseEnter={() => controlStates.goto && setIsHovered(true)} onMouseLeave={() => controlStates.goto && setIsHovered(false)} > @@ -226,10 +255,12 @@ export const Interaction = ({ padding="small" variant="ghost" onClick={toggleCollapsed} - ariaLabel={`${isCollapsed ? 'Show' : 'Hide'} steps`} + ariaLabel={`${ + isCollapsed ? 'Expand' : 'Collapse' + } nested interaction steps for ${interactionLabel}`} + aria-expanded={!isCollapsed} > - {/* FIXME: accordion pattern */} - {isCollapsed ? : } + {isCollapsed ? : } )} diff --git a/code/core/src/component-testing/components/InteractionsPanel.test.tsx b/code/core/src/component-testing/components/InteractionsPanel.test.tsx new file mode 100644 index 000000000000..83c62cc81543 --- /dev/null +++ b/code/core/src/component-testing/components/InteractionsPanel.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import React from 'react'; + +import type { API } from 'storybook/manager-api'; +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import { CallStates } from '../../instrumenter/types'; +import { getCalls, getInteractions } from '../mocks'; +import { InteractionsPanel } from './InteractionsPanel'; + +type InteractionsPanelProps = React.ComponentProps; + +const createProps = (overrides: Partial = {}): InteractionsPanelProps => ({ + storyUrl: 'http://localhost:6006/?path=/story/core-component-test-basics--step', + status: 'completed', + controls: { + start: vi.fn(), + back: vi.fn(), + goto: vi.fn(), + next: vi.fn(), + end: vi.fn(), + rerun: vi.fn(), + }, + controlStates: { + detached: false, + start: true, + back: true, + goto: true, + next: true, + end: true, + }, + interactions: getInteractions(CallStates.DONE), + calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])), + api: { openInEditor: vi.fn() } as unknown as API, + ...overrides, +}); + +const renderPanel = (props: InteractionsPanelProps) => + render( + + + + ); + +describe('InteractionsPanel', () => { + afterEach(() => { + cleanup(); + }); + + it('renders interaction steps as semantic list items with actionable labels', () => { + renderPanel(createProps()); + + const list = screen.getByRole('list'); + expect(list.tagName).toBe('OL'); + expect(within(list).getAllByRole('listitem').length).toBeGreaterThan(0); + expect( + screen.getByRole('button', { + name: 'Go to interaction step: Click button. Status: passed.', + }) + ).toBeInTheDocument(); + }); + + it('labels nested-step toggle buttons with action and expanded state', () => { + const interactions = getInteractions(CallStates.DONE).map((interaction) => + interaction.method === 'step' + ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: false } + : interaction + ); + + renderPanel(createProps({ interactions })); + + const toggle = screen.getByRole('button', { + name: 'Collapse nested interaction steps for Click button', + }); + + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('labels nested-step toggle buttons with action and collapsed state', () => { + const interactions = getInteractions(CallStates.DONE).map((interaction) => + interaction.method === 'step' + ? { ...interaction, childCallIds: ['child-call-id'], isCollapsed: true } + : interaction + ); + + renderPanel(createProps({ interactions })); + + const toggle = screen.getByRole('button', { + name: 'Expand nested interaction steps for Click button', + }); + + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + it('announces run status and updates busy state across lifecycle statuses', () => { + const { rerender } = renderPanel( + createProps({ + status: 'rendering', + interactions: getInteractions(CallStates.ACTIVE), + }) + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test is rendering.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); + + rerender( + + + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test is running.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true'); + + rerender( + + + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Component test failed.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + + rerender( + + + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test completed successfully.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + + rerender( + + + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Component test was aborted.'); + expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false'); + }); +}); diff --git a/code/core/src/component-testing/components/InteractionsPanel.tsx b/code/core/src/component-testing/components/InteractionsPanel.tsx index d9baa4ff2013..e3491bb75b62 100644 --- a/code/core/src/component-testing/components/InteractionsPanel.tsx +++ b/code/core/src/component-testing/components/InteractionsPanel.tsx @@ -24,6 +24,7 @@ export interface Controls { } interface InteractionsPanelProps { + id?: string; storyUrl: string; status: PlayStatus; controls: Controls; @@ -55,6 +56,32 @@ const Container = styled.div(({ theme }) => ({ background: theme.background.content, })); +const InteractionsSection = styled.section({ + position: 'relative', +}); + +const srOnlyStyles = { + border: 0, + clip: 'rect(0, 0, 0, 0)', + clipPath: 'inset(50%)', + height: 1, + margin: -1, + overflow: 'hidden', + padding: 0, + position: 'absolute' as const, + whiteSpace: 'nowrap' as const, + width: 1, +}; + +const InteractionsHeading = styled.h2(srOnlyStyles); + +const InteractionsList = styled.ol({ + margin: 0, + padding: 0, +}); + +const LiveStatus = styled.div(srOnlyStyles); + const CaughtException = styled.div(({ theme }) => ({ borderBottom: `1px solid ${theme.appBorderColor}`, backgroundColor: @@ -91,8 +118,19 @@ const CaughtExceptionStack = styled.pre(({ theme }) => ({ fontSize: theme.typography.size.s1 - 1, })); +const StatusAnnouncementMapping: Record = { + rendering: 'Component test is rendering.', + playing: 'Component test is running.', + completed: 'Component test completed successfully.', + errored: 'Component test failed.', + aborted: 'Component test was aborted.', +} as const; + +let generatedHeadingId = 0; + export const InteractionsPanel: React.FC = React.memo( function InteractionsPanel({ + id, storyUrl, status, calls, @@ -114,6 +152,13 @@ export const InteractionsPanel: React.FC = React.memo( }) { const filter = useAnsiToHtmlFilter(); const hasRealInteractions = interactions.some((i) => i.id !== INTERNAL_RENDER_CALL_ID); + const autoHeadingId = React.useRef(id || `interactions-panel-${generatedHeadingId++}`); + const headingId = id || autoHeadingId.current; + const isListBusy = status === 'rendering' || status === 'playing'; + const statusAnnouncement = + status === 'completed' && hasException + ? 'Component test completed with errors.' + : StatusAnnouncementMapping[status]; return ( @@ -131,22 +176,32 @@ export const InteractionsPanel: React.FC = React.memo( canOpenInEditor={canOpenInEditor} api={api} /> -
- {interactions.map((call) => ( - - ))} -
+ + {statusAnnouncement} + + + Interaction steps + + {interactions.map((call) => ( + + ))} + + {caughtException && !isTestAssertionError(caughtException) && (