Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions code/core/src/component-testing/components/Interaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -129,6 +132,27 @@ const ErrorExplainer = styled.p(({ theme }) => ({
textWrap: 'balance',
}));

const stepStatusTextMap: Record<Exclude<Call['status'], undefined>, 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) {
Expand Down Expand Up @@ -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;
Expand All @@ -206,12 +233,14 @@ export const Interaction = ({

return (
<RowContainer call={call} pausedAt={pausedAt}>
<RowHeader isInteractive={isInteractive}>
<RowHeader isNavigationDisabled={isNavigationDisabled}>
<RowLabel
aria-label="Interaction step"
aria-label={`${
isNavigationDisabled ? 'Interaction step' : 'Go to interaction step'
}: ${interactionLabel}. Status: ${interactionStatus}.`}
call={call}
onClick={() => controls.goto(call.id)}
disabled={isInteractive}
disabled={isNavigationDisabled}
onMouseEnter={() => controlStates.goto && setIsHovered(true)}
onMouseLeave={() => controlStates.goto && setIsHovered(false)}
>
Expand All @@ -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 ? <ChevronDownIcon /> : <ChevronUpIcon />}
{isCollapsed ? <ChevronRightIcon /> : <ChevronDownIcon />}
</StyledButton>
)}
</RowActions>
Expand Down
165 changes: 165 additions & 0 deletions code/core/src/component-testing/components/InteractionsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof InteractionsPanel>;

const createProps = (overrides: Partial<InteractionsPanelProps> = {}): 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(
<ThemeProvider theme={convert(themes.light)}>
<InteractionsPanel {...props} />
</ThemeProvider>
);

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(
<ThemeProvider theme={convert(themes.light)}>
<InteractionsPanel
{...createProps({
status: 'playing',
interactions: getInteractions(CallStates.ACTIVE),
})}
/>
</ThemeProvider>
);

expect(screen.getByRole('status')).toHaveTextContent('Component test is running.');
expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'true');

rerender(
<ThemeProvider theme={convert(themes.light)}>
<InteractionsPanel
{...createProps({
status: 'errored',
interactions: getInteractions(CallStates.ERROR),
})}
/>
</ThemeProvider>
);

expect(screen.getByRole('alert')).toHaveTextContent('Component test failed.');
expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false');

rerender(
<ThemeProvider theme={convert(themes.light)}>
<InteractionsPanel
{...createProps({
status: 'completed',
interactions: getInteractions(CallStates.DONE),
})}
/>
</ThemeProvider>
);

expect(screen.getByRole('status')).toHaveTextContent('Component test completed successfully.');
expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false');

rerender(
<ThemeProvider theme={convert(themes.light)}>
<InteractionsPanel
{...createProps({
status: 'aborted',
interactions: getInteractions(CallStates.DONE),
})}
/>
</ThemeProvider>
);

expect(screen.getByRole('status')).toHaveTextContent('Component test was aborted.');
expect(screen.getByRole('list')).toHaveAttribute('aria-busy', 'false');
});
});
Loading