test: align dify ui tests with browser mode (#39024)

This commit is contained in:
yyh 2026-07-15 22:00:29 +08:00 committed by GitHub
parent fe96bea1f0
commit 4571c4f276
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 165 additions and 305 deletions

View File

@ -1,11 +1,11 @@
---
name: frontend-testing
description: Write, update, or review Dify frontend tests using Vitest and React Testing Library. Trigger for frontend specs, test coverage requests, regressions, testability, or testing strategy under web/.
description: Write, update, or review Dify frontend tests using Vitest and Testing Library. Trigger for frontend specs, test coverage requests, regressions, testability, or testing strategy under web/ or packages/dify-ui/.
---
# Dify Frontend Testing
Use this skill for Vitest and React Testing Library work under `web/`. Do not use it for Python tests or Cucumber/Playwright tests under `e2e/`.
Use this skill for Vitest work under `web/` and `packages/dify-ui/`. Do not use it for Python tests or Cucumber/Playwright tests under `e2e/`.
## Required Source
@ -14,6 +14,7 @@ Before writing, changing, or reviewing frontend tests, read `web/docs/test.md` c
## Workflow
1. Read the source, its behavior owner, nearby specs, and relevant public dependencies.
1. Identify whether the contract belongs in `web/`, Dify UI Browser Mode, or a styled Storybook test.
1. Apply the canonical guide to decide whether a test is needed and choose its boundary.
1. For a behavior change or bug fix, write or identify the failing scenario first when practical.
1. Implement one coherent scenario at a time and run the focused spec before expanding scope.
@ -22,10 +23,14 @@ Before writing, changing, or reviewing frontend tests, read `web/docs/test.md` c
When reviewing existing tests, recommend deleting low-value tests as readily as adding missing behavior coverage.
Run focused tests from `web/`:
Run focused tests from the owning workspace:
```bash
# web/
vp test run path/to/spec-or-directory
# packages/dify-ui/
vp test run --project unit src/path/to/spec
```
Run broader checks only after the focused behavior passes.
For styled Dify UI behavior, run `vp test --project storybook --run`. Run broader checks only after the focused behavior passes.

View File

@ -1,12 +1,11 @@
@import 'tailwindcss';
@import '../src/styles/styles.css';
@plugin '../src/plugins/icons.ts';
@source '../src';
@source '../.storybook';
@import '../src/styles/styles.css';
html {
color-scheme: light;
}

View File

@ -10,8 +10,6 @@ import {
AlertDialogTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('AlertDialog wrapper', () => {
describe('Rendering', () => {
it('should render alert dialog content when dialog is open', async () => {
@ -86,10 +84,10 @@ describe('AlertDialog wrapper', () => {
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
await screen.getByRole('button', { name: 'Open Dialog' }).click()
await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Action Required')
asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
await screen.getByRole('button', { name: 'Cancel' }).click()
await vi.waitFor(() => {
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
@ -129,14 +127,14 @@ describe('AlertDialog wrapper', () => {
</AlertDialog>,
)
asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
await screen.getByRole('button', { name: 'Open Dialog' }).click()
await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Confirm' }).element()).click()
await screen.getByRole('button', { name: 'Confirm' }).click()
expect(onConfirm).toHaveBeenCalledTimes(1)
await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
await screen.getByRole('button', { name: 'Cancel' }).click()
await vi.waitFor(() => {
expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})

View File

@ -200,7 +200,7 @@ describe('Autocomplete wrappers', () => {
</Autocomplete>,
)
asHTMLElement(screen.getByRole('dialog', { name: 'autocomplete popup' }).element()).click()
await screen.getByRole('dialog', { name: 'autocomplete popup' }).click()
await expect
.element(screen.getByRole('group', { name: 'autocomplete positioner' }))

View File

@ -109,43 +109,6 @@ describe('Button', () => {
expect(onClick).toHaveBeenCalledTimes(1)
})
it('does not fire onClick when disabled', async () => {
const onClick = vi.fn()
const screen = await render(
<Button onClick={onClick} disabled>
Click me
</Button>,
)
asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
it('does not fire onClick when loading', async () => {
const onClick = vi.fn()
const screen = await render(
<Button onClick={onClick} loading>
Click me
</Button>,
)
asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
it('does not submit a form when a loading submit button is clicked', async () => {
const onSubmit = vi.fn((event: React.FormEvent<HTMLFormElement>) => event.preventDefault())
const screen = await render(
<form onSubmit={onSubmit}>
<Button type="submit" loading>
Submit
</Button>
</form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Submit' }).element()).click()
expect(onSubmit).not.toHaveBeenCalled()
})
it('does not implicitly submit a form through a loading submit button', async () => {
const onSubmit = vi.fn((event: React.FormEvent<HTMLFormElement>) => event.preventDefault())
const screen = await render(

View File

@ -5,8 +5,6 @@ import { Field, FieldItem, FieldLabel } from '../../field'
import { Fieldset, FieldsetLegend } from '../../fieldset'
import { CheckboxGroup } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('CheckboxGroup', () => {
it('should manage selected values and parent mixed state', async () => {
function PermissionsDemo() {
@ -35,7 +33,7 @@ describe('CheckboxGroup', () => {
await expect.element(parent).toHaveAttribute('data-indeterminate', '')
await expect.element(write).toHaveAttribute('aria-checked', 'false')
asHTMLElement(parent.element()).click()
await parent.click()
await vi.waitFor(async () => {
await expect.element(parent).toHaveAttribute('aria-checked', 'true')
@ -68,7 +66,7 @@ describe('CheckboxGroup', () => {
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
await expect.element(analytics).toHaveAttribute('aria-checked', 'false')
asHTMLElement(analytics.element()).click()
await analytics.click()
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toEqual(['search', 'analytics'])

View File

@ -1,8 +1,6 @@
import { render } from 'vitest-browser-react'
import { Checkbox, CheckboxIndicator, CheckboxRoot, CheckboxSkeleton } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Checkbox', () => {
it('should render an unchecked checkbox with Base UI semantics', async () => {
const screen = await render(<Checkbox checked={false} aria-label="Accept terms" />)
@ -36,7 +34,7 @@ describe('Checkbox', () => {
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
asHTMLElement(screen.getByRole('checkbox', { name: 'Accept terms' }).element()).click()
await screen.getByRole('checkbox', { name: 'Accept terms' }).click()
expect(onCheckedChange).toHaveBeenCalledTimes(1)
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
@ -49,7 +47,7 @@ describe('Checkbox', () => {
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
asHTMLElement(checkbox.element()).click()
await checkbox.click()
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
await expect.element(checkbox).toHaveAttribute('aria-checked', 'false')
@ -62,22 +60,11 @@ describe('Checkbox', () => {
})
it('should ignore interaction when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox
checked={false}
disabled
aria-label="Accept terms"
onCheckedChange={onCheckedChange}
/>,
)
const screen = await render(<Checkbox checked={false} disabled aria-label="Accept terms" />)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toBeDisabled()
await expect.element(checkbox).toHaveAttribute('data-disabled', '')
asHTMLElement(checkbox.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should submit checked and unchecked form values through the hidden input', async () => {

View File

@ -1,8 +1,6 @@
import { render } from 'vitest-browser-react'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Collapsible wrappers', () => {
it('renders the Base UI anatomy with an accessible trigger', async () => {
const screen = await render(
@ -30,7 +28,7 @@ describe('Collapsible wrappers', () => {
await expect.element(trigger).not.toHaveAttribute('data-panel-open')
asHTMLElement(trigger.element()).click()
await trigger.click()
await expect.element(trigger).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Hidden content')).toBeInTheDocument()

View File

@ -218,7 +218,7 @@ describe('Combobox wrappers', () => {
</Combobox>,
)
asHTMLElement(screen.getByRole('dialog', { name: 'combobox popup' }).element()).click()
await screen.getByRole('dialog', { name: 'combobox popup' }).click()
await expect
.element(screen.getByRole('group', { name: 'combobox positioner' }))

View File

@ -9,8 +9,6 @@ import {
DialogTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Dialog wrapper', () => {
describe('Rendering', () => {
it('should render dialog content when dialog is open', async () => {
@ -90,11 +88,10 @@ describe('Dialog wrapper', () => {
})
it('should forward close button props to base primitive', async () => {
const onClick = vi.fn()
const screen = await render(
<Dialog open>
<DialogContent>
<DialogCloseButton data-testid="close-button" disabled onClick={onClick} />
<DialogCloseButton data-testid="close-button" disabled />
<span>Dialog body</span>
</DialogContent>
</Dialog>,
@ -102,8 +99,6 @@ describe('Dialog wrapper', () => {
const closeButton = screen.getByTestId('close-button')
await expect.element(closeButton).toBeDisabled()
asHTMLElement(closeButton.element()).click()
expect(onClick).not.toHaveBeenCalled()
})
})
})

View File

@ -38,7 +38,7 @@ describe('Drawer wrapper', () => {
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click()
await screen.getByRole('button', { name: 'Open settings' }).click()
await vi.waitFor(() => {
expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument()
@ -51,7 +51,7 @@ describe('Drawer wrapper', () => {
await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument()
await expect.element(screen.getByTestId('drawer-backdrop')).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click()
await screen.getByRole('button', { name: 'Close drawer' }).click()
await vi.waitFor(() => {
expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument()

View File

@ -33,7 +33,7 @@ describe('Field primitives', () => {
description.id,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await screen.getByRole('button', { name: 'Save' }).click()
await vi.waitFor(async () => {
const error = asHTMLElement(screen.getByText('Email is required.').element())
@ -58,7 +58,7 @@ describe('Field primitives', () => {
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await screen.getByRole('button', { name: 'Save' }).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ apiKey: 'sk-test' })

View File

@ -10,8 +10,6 @@ import {
FileTreeList,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
function TestFileTree({ onPreview = vi.fn() }: { onPreview?: (itemId: string) => void }) {
return (
<FileTree aria-label="Project files">
@ -71,16 +69,16 @@ describe('FileTree', () => {
it('collapses and expands folders with click and native button keyboard behavior', async () => {
const screen = await render(<TestFileTree />)
const src = screen.getByRole('button', { name: 'src' }).element() as HTMLElement
const src = screen.getByRole('button', { name: 'src' })
src.click()
await src.click()
await expect
.element(screen.getByRole('button', { name: 'src' }))
.toHaveAttribute('aria-expanded', 'false')
expect(screen.container.textContent).not.toContain('components')
src.click()
await src.click()
await expect
.element(screen.getByRole('button', { name: 'src' }))
@ -92,7 +90,7 @@ describe('FileTree', () => {
const onPreview = vi.fn()
const screen = await render(<TestFileTree onPreview={onPreview} />)
asHTMLElement(screen.getByRole('button', { name: 'README.md' }).element()).click()
await screen.getByRole('button', { name: 'README.md' }).click()
expect(onPreview).toHaveBeenCalledWith('readme')
await expect
@ -101,11 +99,10 @@ describe('FileTree', () => {
})
it('does not activate disabled file buttons', async () => {
const onPreview = vi.fn()
const screen = await render(
<FileTree aria-label="Disabled files">
<FileTreeList>
<FileTreeFile disabled onClick={() => onPreview('disabled')}>
<FileTreeFile disabled>
<FileTreeIcon type="file" />
<FileTreeLabel>disabled.txt</FileTreeLabel>
</FileTreeFile>
@ -113,9 +110,6 @@ describe('FileTree', () => {
</FileTree>,
)
asHTMLElement(screen.getByRole('button', { name: 'disabled.txt' }).element()).click()
expect(onPreview).not.toHaveBeenCalled()
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toBeDisabled()
await expect
.element(screen.getByRole('button', { name: 'disabled.txt' }))
@ -123,11 +117,10 @@ describe('FileTree', () => {
})
it('resolves disabled folder triggers through the collapsible state', async () => {
const onOpenChange = vi.fn()
const screen = await render(
<FileTree aria-label="Disabled folders">
<FileTreeList>
<FileTreeFolder disabled defaultOpen onOpenChange={onOpenChange}>
<FileTreeFolder disabled defaultOpen>
<FileTreeFolderTrigger>
<FileTreeIcon type="folder" />
<FileTreeLabel>locked</FileTreeLabel>
@ -144,9 +137,6 @@ describe('FileTree', () => {
)
const trigger = screen.getByRole('button', { name: 'locked' })
asHTMLElement(trigger.element()).click()
expect(onOpenChange).not.toHaveBeenCalled()
await expect.element(trigger).toHaveAttribute('aria-disabled', 'true')
await expect.element(trigger).toHaveAttribute('aria-expanded', 'true')
})

View File

@ -2,8 +2,6 @@ import { render } from 'vitest-browser-react'
import { Field, FieldControl, FieldLabel } from '../../field'
import { Form } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Form primitive', () => {
it('should render a native named form and merge custom class names', async () => {
const screen = await render(
@ -32,7 +30,7 @@ describe('Form primitive', () => {
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await screen.getByRole('button', { name: 'Save' }).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({

View File

@ -3,8 +3,6 @@ import { Field, FieldError, FieldLabel } from '../../field'
import { Form } from '../../form'
import { Input } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Input', () => {
it('should render a labelled Base UI input with its value', async () => {
const screen = await render(
@ -48,7 +46,7 @@ describe('Input', () => {
const input = screen.getByRole('textbox', { name: 'Email' })
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await screen.getByRole('button', { name: 'Save' }).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()

View File

@ -1,3 +1,4 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
Pagination,
@ -29,26 +30,28 @@ async function renderPagination({
onPageSizeChange?: (pageSize: number) => void
} = {}) {
const screen = await render(
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
data-testid="pagination"
>
<PaginationContent data-testid="content">
<PaginationNavigation data-testid="controls">
<PaginationPrevious />
<PaginationPageJump />
<PaginationNext />
</PaginationNavigation>
<PaginationPageList data-testid="pages" />
<PaginationPageSize
value={pageSize}
options={[10, 25, 50]}
onValueChange={onPageSizeChange}
/>
</PaginationContent>
</PaginationRoot>,
<div className="w-236">
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
data-testid="pagination"
>
<PaginationContent data-testid="content">
<PaginationNavigation data-testid="controls">
<PaginationPrevious />
<PaginationPageJump />
<PaginationNext />
</PaginationNavigation>
<PaginationPageList data-testid="pages" />
<PaginationPageSize
value={pageSize}
options={[10, 25, 50]}
onValueChange={onPageSizeChange}
/>
</PaginationContent>
</PaginationRoot>
</div>,
)
return {
@ -79,9 +82,9 @@ describe('Pagination primitive', () => {
it('uses one-based page changes for previous, next, and page buttons', async () => {
const { screen, onPageChange } = await renderPagination({ page: 4 })
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
await screen.getByRole('button', { name: 'Previous page' }).click()
await screen.getByRole('button', { name: 'Next page' }).click()
await screen.getByRole('button', { name: 'Go to page 6' }).click()
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
@ -114,9 +117,7 @@ describe('Pagination primitive', () => {
it('switches the page summary into a selected labelled number field', async () => {
const { screen } = await renderPagination()
asHTMLElement(
screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(),
).click()
await screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(
@ -137,9 +138,7 @@ describe('Pagination primitive', () => {
it('returns to the summary button when the page input loses focus', async () => {
const { screen } = await renderPagination()
asHTMLElement(
screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(),
).click()
await screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
@ -151,9 +150,7 @@ describe('Pagination primitive', () => {
it('commits the page input editing mode with Enter', async () => {
const { screen } = await renderPagination()
asHTMLElement(
screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(),
).click()
await screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(
screen.getByRole('textbox', { name: 'Page number' }).element(),
@ -163,13 +160,7 @@ describe('Pagination primitive', () => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await userEvent.keyboard('{Enter}')
const summaryButton = screen.getByRole('button', {
name: 'Edit page number, current page 2 of 200',
@ -183,9 +174,7 @@ describe('Pagination primitive', () => {
it('cancels the page input editing mode with Escape', async () => {
const { screen, onPageChange } = await renderPagination()
asHTMLElement(
screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element(),
).click()
await screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(
screen.getByRole('textbox', { name: 'Page number' }).element(),
@ -195,13 +184,7 @@ describe('Pagination primitive', () => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await userEvent.keyboard('{Escape}')
const summaryButton = screen.getByRole('button', {
name: 'Edit page number, current page 2 of 200',
@ -220,7 +203,7 @@ describe('Pagination primitive', () => {
.element(screen.getByRole('button', { name: '25' }))
.toHaveAttribute('aria-pressed', 'true')
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
await screen.getByRole('button', { name: '50' }).click()
expect(onPageSizeChange).toHaveBeenCalledWith(50)
})
@ -321,7 +304,7 @@ describe('Pagination primitive', () => {
</PaginationRoot>,
)
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
await screen.getByRole('button', { name: 'Go to page 4' }).click()
await expect
.element(screen.getByRole('button', { name: 'Go to page 4' }))

View File

@ -5,10 +5,6 @@ import { Field, FieldItem, FieldLabel } from '../../field'
import { Fieldset, FieldsetLegend } from '../../fieldset'
import { Radio, RadioControl, RadioGroup, RadioItem, RadioSkeleton } from '../index'
const clickElement = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
type TestRadioGroupProps<Value = string> = RadioGroupProps<Value> & {
children: React.ReactNode
label: string
@ -80,7 +76,7 @@ describe('RadioGroup', () => {
.element(screen.getByRole('radio', { name: 'SSD' }))
.toHaveAttribute('aria-checked', 'true')
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
await screen.getByRole('radio', { name: 'HDD' }).click()
await vi.waitFor(async () => {
await expect
@ -108,7 +104,7 @@ describe('RadioGroup', () => {
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
clickElement(hdd.element())
await hdd.click()
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
@ -142,7 +138,7 @@ describe('Radio', () => {
</TestRadioGroup>,
)
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
await screen.getByRole('radio', { name: 'HDD' }).click()
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
@ -151,10 +147,9 @@ describe('Radio', () => {
.toHaveAttribute('aria-checked', 'true')
})
it('should ignore interaction when disabled', async () => {
const onValueChange = vi.fn()
it('should expose disabled semantics', async () => {
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
<TestRadioGroup defaultValue="ssd" label="Storage type">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd" disabled>
HDD
@ -164,10 +159,7 @@ describe('Radio', () => {
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('data-disabled', '')
clickElement(hdd.element())
expect(onValueChange).not.toHaveBeenCalled()
await expect.element(hdd).toHaveAttribute('aria-disabled', 'true')
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
})

View File

@ -1,8 +1,6 @@
import { render } from 'vitest-browser-react'
import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('SegmentedControl wrappers', () => {
it('renders a segmented control with Base UI pressed state', async () => {
const screen = await render(
@ -25,7 +23,7 @@ describe('SegmentedControl wrappers', () => {
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
await screen.getByRole('button', { name: 'Two' }).click()
await expect
.element(screen.getByRole('button', { name: 'One' }))
@ -44,7 +42,7 @@ describe('SegmentedControl wrappers', () => {
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
await screen.getByRole('button', { name: 'Two' }).click()
expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything())
await expect
@ -61,7 +59,7 @@ describe('SegmentedControl wrappers', () => {
</SegmentedControl>,
)
asHTMLElement(screen.getByRole('button', { name: 'One' }).element()).click()
await screen.getByRole('button', { name: 'One' }).click()
expect(onValueChange).toHaveBeenCalledWith([], expect.anything())
await expect

View File

@ -310,11 +310,9 @@ describe('Select wrappers', () => {
.toHaveAttribute('data-highlighted')
})
it('should not call onValueChange when disabled item is clicked', async () => {
const onValueChange = vi.fn()
it('should expose disabled item semantics', async () => {
const screen = await render(
<Select open defaultValue="seattle" onValueChange={onValueChange}>
<Select open defaultValue="seattle">
<SelectTrigger aria-label="city select">
<SelectValue />
</SelectTrigger>
@ -331,9 +329,9 @@ describe('Select wrappers', () => {
</Select>,
)
asHTMLElement(screen.getByRole('option', { name: 'Disabled New York' }).element()).click()
expect(onValueChange).not.toHaveBeenCalled()
await expect
.element(screen.getByRole('option', { name: 'Disabled New York' }))
.toHaveAttribute('aria-disabled', 'true')
})
it('should support custom composition with SelectItemText without indicator', async () => {

View File

@ -1,3 +1,4 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
Slider,
@ -44,9 +45,9 @@ describe('Slider', () => {
<Slider value={20} onValueChange={onValueChange} aria-label="Value" />,
)
const slider = screen.getByLabelText('Value').element()
slider.focus()
slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
const slider = screen.getByLabelText('Value')
asHTMLElement(slider.element()).focus()
await userEvent.keyboard('{ArrowRight}')
await vi.waitFor(() => {
expect(onValueChange).toHaveBeenCalledTimes(1)
@ -67,9 +68,9 @@ describe('Slider', () => {
/>,
)
const slider = screen.getByLabelText('Value').element()
slider.focus()
slider.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
const slider = screen.getByLabelText('Value')
asHTMLElement(slider.element()).focus()
await userEvent.keyboard('{ArrowRight}')
await vi.waitFor(() => {
expect(onValueChange).toHaveBeenCalledTimes(1)
@ -78,17 +79,9 @@ describe('Slider', () => {
})
it('should not trigger onValueChange when disabled', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />,
)
const screen = await render(<Slider value={20} disabled aria-label="Value" />)
const slider = screen.getByLabelText('Value').element()
expect(slider).toBeDisabled()
asHTMLElement(slider).click()
expect(onValueChange).not.toHaveBeenCalled()
await expect.element(screen.getByLabelText('Value')).toBeDisabled()
})
it('should apply custom class names on root', async () => {

View File

@ -9,6 +9,12 @@
* contributes design tokens, runtime CSS variables, and project utilities.
*/
@import '../themes/theme.css';
@import '../themes/light.css' layer(base);
@import '../themes/dark.css' layer(base);
@import './utilities.css';
@import './components.css';
/* ---------- Palette overrides ----------------------------------------- */
/* Override Tailwind v4's oklch defaults with the Dify brand palette so that
* `bg-gray-500`, `text-primary-600`, etc. resolve to the design system. */
@ -87,15 +93,3 @@
/* font sizes */
--text-2xs: 0.625rem;
}
/* ---------- Semantic design tokens (`@theme inline`) ------------------ */
@import '../themes/theme.css';
/* ---------- Runtime variable values ----------------------------------- */
/* Real values for the semantic tokens above, scoped per `html[data-theme]`. */
@import '../themes/light.css' layer(base);
@import '../themes/dark.css' layer(base);
/* ---------- Project utilities & components ---------------------------- */
@import './utilities.css';
@import './components.css';

View File

@ -1,7 +1,6 @@
import { render } from 'vitest-browser-react'
import { Switch, SwitchSkeleton } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const getThumb = (switchElement: HTMLElement | SVGElement) => switchElement.querySelector('span')
describe('Switch', () => {
@ -27,7 +26,7 @@ describe('Switch', () => {
const screen = await render(<Switch checked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
asHTMLElement(switchElement.element()).click()
await switchElement.click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
expect(onCheckedChange).toHaveBeenCalledTimes(1)
@ -41,7 +40,7 @@ describe('Switch', () => {
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
await switchElement.click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
@ -56,23 +55,18 @@ describe('Switch', () => {
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
await switchElement.click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Switch checked={false} disabled onCheckedChange={onCheckedChange} />,
)
const screen = await render(<Switch checked={false} disabled />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toBeDisabled()
await expect.element(switchElement).toHaveAttribute('data-disabled', '')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should apply custom className', async () => {
@ -105,17 +99,11 @@ describe('Switch', () => {
describe('loading state', () => {
it('should render as disabled when loading', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Switch checked={false} loading onCheckedChange={onCheckedChange} />,
)
const screen = await render(<Switch checked={false} loading />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-busy', 'true')
await expect.element(switchElement).toHaveAttribute('data-disabled', '')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should show spinner icon for md and lg sizes', async () => {

View File

@ -1,8 +1,6 @@
import { render } from 'vitest-browser-react'
import { Tabs, TabsList, TabsPanel, TabsTab } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Tabs wrappers', () => {
it('renders Base UI tabs with accessible roles', async () => {
const screen = await render(
@ -37,7 +35,7 @@ describe('Tabs wrappers', () => {
</Tabs>,
)
asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click()
await screen.getByRole('tab', { name: 'Python' }).click()
expect(onValueChange).toHaveBeenCalledWith('py', expect.anything())
await expect

View File

@ -5,15 +5,6 @@ import { Form } from '../../form'
import { Textarea } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
const textarea = asHTMLElement(element) as HTMLTextAreaElement
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value',
)?.set
valueSetter?.call(textarea, value)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('Textarea', () => {
it('should render a labelled textarea through Base UI Field.Control', async () => {
@ -53,7 +44,7 @@ describe('Textarea', () => {
)
const textarea = screen.getByRole('textbox', { name: 'Notes' })
setTextareaValue(textarea.element(), 'a')
await textarea.fill('a')
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
await expect.element(textarea).toHaveValue('')
@ -81,8 +72,7 @@ describe('Textarea', () => {
</Form>,
)
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.click()
await screen.getByRole('button', { name: 'Save' }).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
@ -109,7 +99,7 @@ describe('Textarea', () => {
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await screen.getByRole('button', { name: 'Save' }).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
})
@ -169,11 +159,11 @@ describe('Textarea', () => {
await expect.element(screen.getByRole('textbox', { name: 'Disabled note' })).toBeDisabled()
asHTMLElement(profileSummary.element()).focus()
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.focus()
const saveButton = screen.getByRole('button', { name: 'Save' })
asHTMLElement(saveButton.element()).focus()
expect(onBlur).toHaveBeenCalledTimes(1)
saveButton.click()
await saveButton.click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({

View File

@ -7,14 +7,6 @@ type BaseUIAnimationGlobal = typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
}),
)
}
describe('@langgenius/dify-ui/toast', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -62,26 +54,6 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Neutral toast')).toBeInTheDocument()
})
it('should wrap long unbroken toast content within the card width', async () => {
const screen = await render(<ToastHost />)
const longTitle =
'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed'
const longDescription =
'Put "https://plugin/assets/1bd032bb73218a5d141b80cab7111?x-id=PutObject": dial tcp 192.168.0.200:19000: connect: connection refused, icon small en_US failed to remap assets failed to store plugin asset'
toast.error(longTitle, {
description: longDescription,
})
await expect.element(screen.getByText(longTitle)).toBeInTheDocument()
await expect.element(screen.getByText(longDescription)).toBeInTheDocument()
const title = asHTMLElement(screen.getByText(longTitle).element())
const description = asHTMLElement(screen.getByText(longDescription).element())
expect(title.scrollWidth).toBeLessThanOrEqual(title.clientWidth)
expect(description.scrollWidth).toBeLessThanOrEqual(description.clientWidth)
})
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
const screen = await render(<ToastHost limit={1} />)
@ -117,13 +89,12 @@ describe('@langgenius/dify-ui/toast', () => {
onClose,
})
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
dispatchToastMouseOver(viewport)
await screen.getByRole('dialog', { name: 'Dismiss me' }).hover()
await expect
.element(screen.getByRole('button', { name: 'Close notification' }))
.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
await screen.getByRole('button', { name: 'Close notification' }).click()
await vi.waitFor(() => {
expect(document.body).not.toHaveTextContent('Dismiss me')
@ -142,10 +113,8 @@ describe('@langgenius/dify-ui/toast', () => {
<>
<style>
{`
[role="dialog"] {
transition: opacity 10000s, transform 10000s !important;
}
[role="dialog"][data-ending-style] {
transition: opacity 10000s, transform 10000s !important;
opacity: 0 !important;
transform: translateY(-150%) !important;
}
@ -178,24 +147,19 @@ describe('@langgenius/dify-ui/toast', () => {
timeout: 0,
})
await expect.element(screen.getByRole('dialog', { name: 'Dismiss me' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
dispatchToastMouseOver(viewport)
const toastDialog = screen.getByRole('dialog', { name: 'Dismiss me' })
await expect.element(toastDialog).toBeInTheDocument()
await toastDialog.hover()
await expect
.element(screen.getByRole('button', { name: 'Close notification' }))
.toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
await screen.getByRole('button', { name: 'Close notification' }).click()
await vi.waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute(
'data-ending-style',
)
expect(toastDialog.element()).toHaveAttribute('data-ending-style')
})
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
expect(getComputedStyle(toastDialog.element()).pointerEvents).toBe('none')
const underlyingAction = asHTMLElement(
screen.getByRole('button', { name: 'Underlying action' }).element(),
@ -301,7 +265,7 @@ describe('@langgenius/dify-ui/toast', () => {
})
await expect.element(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('button', { name: 'Undo' }).element()).click()
await screen.getByRole('button', { name: 'Undo' }).click()
expect(onAction).toHaveBeenCalledTimes(1)
})

View File

@ -1,7 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, within } from 'storybook/test'
import { toast, ToastHost } from '.'
const longToastTitle =
'operation error S3: PutObject, exceeded maximum number of attempts, 3, StatusCode: 0, RequestID: , HostID: , request send failed'
const longToastDescription =
'Put "https://plugin/assets/1bd032bb73218a5d141b80cab7111?x-id=PutObject": dial tcp 192.168.0.200:19000: connect: connection refused, icon small en_US failed to remap assets failed to store plugin asset'
const buttonClassName =
'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const cardClassName =
@ -224,9 +230,8 @@ const ActionExamples = () => {
}
const createLongCopyToast = () => {
toast.info('Knowledge ingestion in progress', {
description:
'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.',
toast.error(longToastTitle, {
description: longToastDescription,
actionProps: {
children: 'View details',
onClick: () => {
@ -369,4 +374,14 @@ type Story = StoryObj<typeof meta>
export const DocsPattern: Story = {
render: () => <ToastDocsDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(canvas.getByRole('button', { name: 'Long content' }))
const title = await body.findByText(longToastTitle)
const description = await body.findByText(longToastDescription)
expect(title.scrollWidth).toBeLessThanOrEqual(title.clientWidth)
expect(description.scrollWidth).toBeLessThanOrEqual(description.clientWidth)
},
}

View File

@ -1,6 +1,7 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite-plus'
import { playwright } from 'vite-plus/test/browser-playwright'
@ -37,6 +38,7 @@ export default defineConfig({
projects: [
{
extends: true,
plugins: [tailwindcss()],
test: {
name: 'unit',
globals: true,
@ -47,6 +49,7 @@ export default defineConfig({
provider: playwright(),
instances: [{ browser: 'chromium' }],
headless: true,
testerHtmlPath: './vitest.tester.html',
},
},
},

View File

@ -0,0 +1,10 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./.storybook/storybook.css" />
<title>Dify UI Tests</title>
</head>
<body></body>
</html>

View File

@ -1,6 +1,6 @@
# Frontend Testing Guide
This document is the single source of truth for automated frontend tests under `web/`. Tests should protect product behavior and make refactoring safer. They are not a file-by-file completion exercise.
This document is the single source of truth for automated frontend tests under `web/` and `packages/dify-ui/`. Tests should protect product behavior and make refactoring safer. They are not a file-by-file completion exercise.
## Testing Mindset
@ -57,17 +57,18 @@ Prefer selectors in this order:
1. `getByRole` with an accessible name.
1. `getByLabelText` for labeled form controls.
1. Scoped semantic queries with `within`.
1. Scoped semantic queries with `within` in React Testing Library or locator chaining in Browser Mode.
1. `getByText`, `getByPlaceholderText`, or other user-visible queries when appropriate.
1. `getByTestId` only for boundaries with no useful DOM semantics, such as canvas output, editor shims, or mocked non-visual integrations.
If an interactive control cannot be found semantically, first check whether the production markup needs a real button, link, label, landmark, or accessible name.
- Prefer a `userEvent.setup()` instance created inside the test for realistic interaction sequences. Use `fireEvent` for a specific low-level event that `userEvent` does not express.
- In React Testing Library tests, use a `userEvent.setup()` instance inside the test. Use `fireEvent` only when the low-level event itself is the contract.
- In Browser Mode, interact through awaited locators. Use `.element()` only for DOM APIs that locators do not expose.
- Test keyboard and focus behavior when they are part of the interaction contract.
- Assert accessible names and ARIA state when they communicate product state.
- Exact copy assertions are valid when the copy or translation key is the contract; otherwise prefer a semantic query or resilient match.
- Use `queryBy*` for absence and `findBy*` for asynchronously appearing elements.
- In React Testing Library, use `queryBy*` for absence and `findBy*` for asynchronously appearing elements. In Browser Mode, use `expect.element` for eventual assertions.
## Mock at Real Boundaries
@ -88,7 +89,7 @@ Mocks must preserve the public contract needed by the test. Do not replace Dify
## Async, Time, and Isolation
- Await `userEvent`, promises, `findBy*`, and `waitFor`.
- Await user interactions, promises, `findBy*`, and `waitFor`.
- Use `findBy*` for an element that appears asynchronously and `waitFor` for an eventually true external assertion.
- Use fake timers only when timer behavior is part of the contract. Restore real timers after the test.
- Control time, randomness, network responses, and shared stores so tests are deterministic.
@ -97,7 +98,7 @@ Mocks must preserve the public contract needed by the test. Do not replace Dify
## Dify Test Setup
- Vitest runs in `happy-dom` through `web/vite.config.ts` and loads `web/vitest.setup.ts`.
- Tests under `web/` run in `happy-dom` through `web/vite.config.ts` and load `web/vitest.setup.ts`.
- New component and feature specs should generally use a sibling `__tests__/` directory. Existing colocated utility and hook specs may follow their owning module's convention. Cross-feature integration specs belong in `web/__tests__/`.
- The shared `react-i18next` mock is loaded globally. Use `createReactI18nextMock` from `web/test/i18n-mock` only when a test needs custom translations.
- For `nuqs` behavior, use the helpers in `web/test/nuqs-testing.tsx` and assert URL updates. Mock `nuqs` only when URL synchronization is explicitly outside the test contract.
@ -145,6 +146,8 @@ vp test run --coverage path/to/spec-or-directory
## References
- [Vitest documentation]
- [Vitest Browser Mode documentation]
- [Vitest Browser Mode locators]
- [Testing Library guiding principles]
- [React Testing Library documentation]
- [Testing Library query guidance]
@ -154,4 +157,6 @@ vp test run --coverage path/to/spec-or-directory
[Testing Library guiding principles]: https://testing-library.com/docs/guiding-principles
[Testing Library query guidance]: https://testing-library.com/docs/queries/about
[Testing Library user-event guidance]: https://testing-library.com/docs/user-event/intro
[Vitest Browser Mode documentation]: https://vitest.dev/guide/browser
[Vitest Browser Mode locators]: https://vitest.dev/api/browser/locators
[Vitest documentation]: https://vitest.dev/guide