diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 06db2d0f41..1833eb4ecd 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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. diff --git a/packages/dify-ui/src/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx index e56d6b942f..c8dcc6ac53 100644 --- a/packages/dify-ui/src/alert-dialog/index.stories.tsx +++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx @@ -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 = { ), + 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 = { diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx index de3fef48bf..91a1d38c2d 100644 --- a/packages/dify-ui/src/button/index.stories.tsx +++ b/packages/dify-ui/src/button/index.stories.tsx @@ -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: { diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx index a1c1badff9..1c8021811c 100644 --- a/packages/dify-ui/src/combobox/index.stories.tsx +++ b/packages/dify-ui/src/combobox/index.stories.tsx @@ -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: () => , + 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 = { diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index cd101017f2..e70cc2465d 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -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 = { ), + 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 = { diff --git a/packages/dify-ui/src/file-tree/index.stories.tsx b/packages/dify-ui/src/file-tree/index.stories.tsx index 5545ad9b8e..a75e40aa65 100644 --- a/packages/dify-ui/src/file-tree/index.stories.tsx +++ b/packages/dify-ui/src/file-tree/index.stories.tsx @@ -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: () => , + 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 = { diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index a5419da798..6ffab6bfee 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -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 export const Playground: Story = { render: () => , + 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', diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 64cd2b8649..7560257201 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -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 export const Default: Story = { render: () => (
- - + Seattle @@ -66,6 +69,27 @@ export const Default: Story = {
), + 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 = { diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index a7537b50b8..40e9ca6e51 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -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 = {