Skip to content
Merged
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
9 changes: 9 additions & 0 deletions code/core/custom-matchers.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'storybook/test';

import type { LiveRegionMatcherOptions } from '../../../core/src/shared/utils/toHaveLiveRegion';

declare module 'storybook/test' {
interface Assertion<T> {
toHaveLiveRegion(options: LiveRegionMatcherOptions): Promise<void>;
}
}
147 changes: 143 additions & 4 deletions code/core/src/component-testing/components/Interaction.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { expect, userEvent, within } from 'storybook/test';
import { expect, userEvent } from 'storybook/test';

import { CallStates } from '../../instrumenter/types.ts';
import { type Call, CallStates } from '../../instrumenter/types.ts';
import { getCalls } from '../mocks/index.ts';
import { Interaction } from './Interaction.tsx';
import ToolbarStories from './Toolbar.stories.tsx';

type Story = StoryObj<typeof Interaction>;

const createCall = (overrides: Partial<Call> = {}): Call => ({
id: 'story--id [interaction]',
storyId: 'story--id',
cursor: 1,
ancestors: [],
path: [],
method: 'step',
args: ['Click button', { __function__: { name: '' } }],
interceptable: true,
retain: false,
status: CallStates.DONE,
...overrides,
});

export default {
title: 'Interaction',
component: Interaction,
decorators: [
(Story) => (
<ul style={{ listStyleType: 'none', margin: 0, padding: 0 }}>
<Story />
</ul>
),
],
args: {
callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])),
controls: ToolbarStories.args.controls,
controlStates: ToolbarStories.args.controlStates,
isHidden: false,
isCollapsed: false,
childCallIds: undefined,
toggleCollapsed: () => {},
},
} as Meta<typeof Interaction>;

Expand Down Expand Up @@ -53,6 +78,13 @@ export const Done: Story = {
args: {
call: getCalls(CallStates.DONE, -1)[0],
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Go to interaction row: toHaveBeenCalled. Status: passed.',
})
).toBeInTheDocument();
},
};

export const WithParent: Story = {
Expand All @@ -63,13 +95,120 @@ export const WithParent: Story = {

export const Disabled: Story = {
args: { ...Done.args, controlStates: { ...ToolbarStories.args.controlStates, goto: false } },
play: async ({ canvas }) => {
const button = canvas.getByRole('button', {
name: 'Interaction row: toHaveBeenCalled. Status: passed.',
});
await expect(button).toBeInTheDocument();
},
};

export const TrimmedStepLabelAria: Story = {
args: {
call: createCall({ args: [' My step '] }),
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Go to interaction row: My step. Status: passed.',
})
).toBeInTheDocument();
},
};

export const EmptyStepLabelFallbackAria: Story = {
args: {
call: createCall({
args: [' '],
}),
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Go to interaction row: step. Status: passed.',
})
).toBeInTheDocument();
},
};

/**
* When `step` has no user label, `extractStepName` is the method name `step` — row ARIA must stay
* readable.
*/
export const StepMethodFallbackAria: Story = {
args: {
call: {
id: 'story--id [step-fallback]',
storyId: 'story--id',
cursor: 1,
ancestors: [],
path: [],
method: 'step',
args: [],
interceptable: true,
retain: false,
status: CallStates.WAITING,
},
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Go to interaction row: step. Status: pending.',
})
).toBeInTheDocument();
},
};

export const NestedStepMethodFallbackAria: Story = {
args: {
call: createCall({
path: ['nested'],
args: ['Should be ignored'],
}),
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Go to interaction row: step. Status: passed.',
})
).toBeInTheDocument();
},
};

export const ExpandedNestedStepAria: Story = {
args: {
call: createCall(),
childCallIds: ['child-call-id'],
isCollapsed: false,
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Collapse nested interaction steps for Click button',
})
).toHaveAttribute('aria-expanded', 'true');
},
};

export const CollapsedNestedStepAria: Story = {
args: {
call: createCall(),
childCallIds: ['child-call-id'],
isCollapsed: true,
},
play: async ({ canvas }) => {
await expect(
canvas.getByRole('button', {
name: 'Expand nested interaction steps for Click button',
})
).toHaveAttribute('aria-expanded', 'false');
},
};

export const Hovered: Story = {
...Done,
globals: { sb_theme: 'light' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
play: async ({ canvas }) => {
await userEvent.hover(canvas.getByRole('button'));
await expect(canvas.getByTestId('icon-active')).toBeInTheDocument();
},
Expand Down
87 changes: 75 additions & 12 deletions code/core/src/component-testing/components/Interaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,59 @@ const ErrorExplainer = styled.p(({ theme }) => ({
textWrap: 'balance',
}));

/**
* Human-readable name for an interaction row (visible text and ARIA), matching instrumented data.
*
* Play-function `step('…')` calls are recorded with `method === 'step'` and the user-facing
* description in `args[0]`. For a **top-level** step (`path` is empty) with a non-empty string
* there, that string is used so the UI matches what the author wrote.
*
* Otherwise we use `call.method` (e.g. `click`, `expect`, nested steps without their own title).
*/
export const extractStepName = (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;
};

/**
* Accessible name for the main row control. Uses "interaction row" wording so we never combine
* "Interaction step" with a `stepName` of `step` (awkward "step … step" for screen readers).
*/
export const getRowAriaLabel = ({
isNavigationDisabled,
stepName,
statusText,
}: {
isNavigationDisabled: boolean;
stepName: string;
statusText: string;
}) =>
`${isNavigationDisabled ? 'Interaction row' : 'Go to interaction row'}: ${stepName}. Status: ${statusText}.`;

export const getExpandButtonAriaLabel = ({
isCollapsed,
stepName,
}: {
isCollapsed: boolean;
stepName: string;
}) => `${isCollapsed ? 'Expand' : 'Collapse'} nested interaction steps for ${stepName}`;

const stepStatusTextMap: Record<Exclude<Call['status'], undefined>, string> = {
[CallStates.DONE]: 'passed',
[CallStates.ERROR]: 'failed',
[CallStates.ACTIVE]: 'running',
[CallStates.WAITING]: 'pending',
};

const getInteractionStatusText = (call: Call) =>
call.status ? stepStatusTextMap[call.status] : 'not run';

const Exception = ({ exception }: { exception: Call['exception'] }) => {
const filter = useAnsiToHtmlFilter();
if (!exception) {
Expand Down Expand Up @@ -194,7 +250,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 stepName = extractStepName(call);
const interactionStatus = getInteractionStatusText(call);

if (isHidden) {
return null;
Expand All @@ -206,12 +265,16 @@ export const Interaction = ({

return (
<RowContainer call={call} pausedAt={pausedAt}>
<RowHeader isInteractive={isInteractive}>
<RowHeader $isNavigationDisabled={isNavigationDisabled}>
<RowLabel
aria-label="Interaction step"
aria-label={getRowAriaLabel({
isNavigationDisabled,
stepName,
statusText: 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 +289,10 @@ export const Interaction = ({
padding="small"
variant="ghost"
onClick={toggleCollapsed}
ariaLabel={`${isCollapsed ? 'Show' : 'Hide'} steps`}
ariaLabel={getExpandButtonAriaLabel({ isCollapsed, stepName })}
aria-expanded={!isCollapsed}
>
{/* FIXME: accordion pattern */}
{isCollapsed ? <ChevronDownIcon /> : <ChevronUpIcon />}
{isCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
</StyledButton>
)}
</RowActions>
Expand Down
Loading
Loading