mirror of
https://github.com/langgenius/dify.git
synced 2026-07-19 16:38:31 +08:00
test: align dify ui tests with browser mode (#39024)
This commit is contained in:
parent
fe96bea1f0
commit
4571c4f276
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
10
packages/dify-ui/vitest.tester.html
Normal file
10
packages/dify-ui/vitest.tester.html
Normal 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>
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user