mirror of
https://github.com/langgenius/dify.git
synced 2026-06-16 22:11:09 +08:00
test(dify-ui): add Storybook interaction coverage (#37519)
This commit is contained in:
parent
eb2aaf2ac1
commit
2893adf5e4
@ -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.
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user