test(dify-ui): add Storybook interaction coverage (#37519)

This commit is contained in:
yyh 2026-06-16 17:39:37 +08:00 committed by GitHub
parent eb2aaf2ac1
commit 2893adf5e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 134 additions and 2 deletions

View File

@ -166,8 +166,23 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
- `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates.
- `pnpm -C packages/dify-ui type-check``tsgo --noEmit` for this package only.
### Test Boundary
Use Storybook tests for behavior that belongs to the documented component example:
visible state changes, user interaction, keyboard paths, overlay open/close flows,
and accessibility-facing semantics. Keep regular Vitest unit tests for lower-level
wrapper contracts such as class variants, Base UI passthrough props, hidden input
serialization, data attribute hooks, store behavior, and edge cases that do not
need a full story.
Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`.
If a story is temporarily marked `todo`, keep the exception local to that story
and do not treat an interaction `play` test as a replacement for fixing the
underlying accessibility issue.
### Disabling Animations In Tests
Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior.

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
AlertDialog,
AlertDialogActions,
@ -55,6 +56,21 @@ export const Default: Story = {
</AlertDialogContent>
</AlertDialog>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Delete project' }))
const dialog = body.getByRole('alertdialog', { name: 'Delete project?' })
await waitFor(async () => {
await expect(dialog).toBeVisible()
})
await userEvent.click(body.getByRole('button', { name: 'Cancel' }))
await waitFor(async () => {
await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument()
})
},
}
export const NonDestructive: Story = {

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, fn } from 'storybook/test'
import { Button } from '.'
@ -90,8 +91,21 @@ export const Loading: Story = {
args: {
variant: 'primary',
loading: true,
onClick: fn(),
children: 'Loading Button',
},
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole('button', { name: 'Loading Button' })
await expect(button).toHaveAttribute('aria-disabled', 'true')
await expect(button).toHaveAttribute('aria-busy', 'true')
button.focus()
await expect(button).toHaveFocus()
await userEvent.click(button)
await expect(args.onClick).not.toHaveBeenCalled()
},
parameters: {
docs: {
description: {

View File

@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import { useVirtualizer } from '@tanstack/react-virtual'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
Combobox,
ComboboxChip,
@ -768,6 +769,15 @@ const MultipleChipsDemo = () => {
export const MultipleChips: Story = {
render: () => <MultipleChipsDemo />,
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByText('Maya Chen')).toBeVisible()
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: 'Remove Maya Chen' }))
await expect(canvas.queryByText('Maya Chen')).not.toBeInTheDocument()
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
},
}
export const VirtualizedLongList: Story = {

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
Dialog,
DialogCloseButton,
@ -66,6 +67,22 @@ export const Default: Story = {
</DialogContent>
</Dialog>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }))
const dialog = body.getByRole('dialog', { name: 'Invite collaborators' })
await waitFor(async () => {
await expect(dialog).toBeVisible()
})
await expect(body.getByRole('textbox', { name: 'Email address' })).toBeVisible()
await userEvent.click(body.getByRole('button', { name: 'Close' }))
await waitFor(async () => {
await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument()
})
},
}
export const WithoutCloseButton: Story = {

View File

@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { FileTreeIconType } from '.'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
FileTreeBadge,
FileTreeFile,
@ -330,6 +331,19 @@ function VisualStates() {
export const Default: Story = {
render: () => <ComposedFileTree />,
play: async ({ canvas, userEvent }) => {
const srcFolder = canvas.getByRole('button', { name: 'src' })
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
await userEvent.click(srcFolder)
await expect(srcFolder).toHaveAttribute('aria-expanded', 'false')
await expect(canvas.queryByRole('button', { name: 'components' })).not.toBeInTheDocument()
await userEvent.click(srcFolder)
await expect(srcFolder).toHaveAttribute('aria-expanded', 'true')
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
},
}
export const DataDriven: Story = {

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect } from 'storybook/test'
import {
Pagination,
PaginationSkeleton,
@ -77,6 +78,15 @@ type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => <PaginationDemo />,
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: 'Next page' }))
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: '50' }))
await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true')
},
parameters: {
a11y: {
test: 'todo',

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
Select,
SelectContent,
@ -19,6 +20,8 @@ const triggerWidth = 'w-64'
const cityItems = [
{ label: 'Seattle', value: 'seattle' },
{ label: 'New York', value: 'new-york' },
{ label: 'Tokyo', value: 'tokyo' },
{ label: 'Paris', value: 'paris' },
]
const meta = {
@ -41,11 +44,11 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<div className={triggerWidth}>
<Select defaultValue="seattle">
<Select items={cityItems} defaultValue="seattle">
<SelectTrigger aria-label="City">
<SelectValue placeholder="Select a city" />
</SelectTrigger>
<SelectContent>
<SelectContent listProps={{ 'aria-label': 'City options' }}>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
@ -66,6 +69,27 @@ export const Default: Story = {
</Select>
</div>
),
play: async ({ canvas, canvasElement, userEvent }) => {
const trigger = canvas.getByRole('combobox', { name: 'City' })
const body = within(canvasElement.ownerDocument.body)
await expect(trigger).toHaveTextContent('Seattle')
trigger.focus()
await userEvent.keyboard('{ArrowDown}')
await waitFor(async () => {
await expect(body.getByRole('option', { name: 'Tokyo' })).toBeVisible()
})
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
await expect(trigger).toHaveTextContent('Tokyo')
await userEvent.keyboard('{Escape}')
await waitFor(async () => {
await expect(body.queryByRole('listbox', { name: 'City options' })).not.toBeInTheDocument()
})
},
}
export const WithVisibleLabel: Story = {

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect } from 'storybook/test'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
@ -77,6 +78,17 @@ export const Default: Story = {
checked: false,
disabled: false,
},
play: async ({ canvas, userEvent }) => {
const switchControl = canvas.getByRole('switch', { name: 'Enable auto retry' })
await expect(switchControl).toHaveAttribute('aria-checked', 'false')
await expect(canvas.getByText('Failures require manual retry.')).toBeVisible()
await userEvent.click(switchControl)
await expect(switchControl).toHaveAttribute('aria-checked', 'true')
await expect(canvas.getByText('Failures will retry automatically.')).toBeVisible()
},
}
export const DefaultOn: Story = {