diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index ee230e58fc1..632ff9b4acc 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -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. diff --git a/packages/dify-ui/.storybook/storybook.css b/packages/dify-ui/.storybook/storybook.css index d4a567d9cd9..615c37ba4d4 100644 --- a/packages/dify-ui/.storybook/storybook.css +++ b/packages/dify-ui/.storybook/storybook.css @@ -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; } diff --git a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx index e841b55d0ae..71ae1085ad4 100644 --- a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx @@ -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', () => { , ) - 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() }) diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index d84c0c2a23f..477eb7a8642 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -200,7 +200,7 @@ describe('Autocomplete wrappers', () => { , ) - 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' })) diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx index d5ddfb8a421..b4803f08782 100644 --- a/packages/dify-ui/src/button/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx @@ -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( - , - ) - 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( - , - ) - 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) => event.preventDefault()) - const screen = await render( -
- -
, - ) - - 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) => event.preventDefault()) const screen = await render( diff --git a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx index 2cb4dff52a3..8896c8cce7a 100644 --- a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx @@ -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']) diff --git a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx index f3e9d33972a..f96abb09b82 100644 --- a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx @@ -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() @@ -36,7 +34,7 @@ describe('Checkbox', () => { , ) - 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( - , - ) + const screen = await render() 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 () => { diff --git a/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx index 6f8579cdbb3..6ee69322065 100644 --- a/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx @@ -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() diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index 56ab5c79645..f02cf863616 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -218,7 +218,7 @@ describe('Combobox wrappers', () => { , ) - 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' })) diff --git a/packages/dify-ui/src/dialog/__tests__/index.spec.tsx b/packages/dify-ui/src/dialog/__tests__/index.spec.tsx index 7d954862436..46cc27eead5 100644 --- a/packages/dify-ui/src/dialog/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/dialog/__tests__/index.spec.tsx @@ -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 body , @@ -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() }) }) }) diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx index d402e288812..a363d15589d 100644 --- a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -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() diff --git a/packages/dify-ui/src/field/__tests__/index.spec.tsx b/packages/dify-ui/src/field/__tests__/index.spec.tsx index 44778c25470..0401e7446ea 100644 --- a/packages/dify-ui/src/field/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/field/__tests__/index.spec.tsx @@ -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', () => { , ) - 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' }) diff --git a/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx index c11ee693d88..2b0c9673ca6 100644 --- a/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx @@ -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 ( @@ -71,16 +69,16 @@ describe('FileTree', () => { it('collapses and expands folders with click and native button keyboard behavior', async () => { const screen = await render() - 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() - 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( - onPreview('disabled')}> + disabled.txt @@ -113,9 +110,6 @@ describe('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( - + locked @@ -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') }) diff --git a/packages/dify-ui/src/form/__tests__/index.spec.tsx b/packages/dify-ui/src/form/__tests__/index.spec.tsx index 75a088160c1..99a40007ab3 100644 --- a/packages/dify-ui/src/form/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/form/__tests__/index.spec.tsx @@ -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', () => { , ) - 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({ diff --git a/packages/dify-ui/src/input/__tests__/index.spec.tsx b/packages/dify-ui/src/input/__tests__/index.spec.tsx index a44f5c95600..0a21e8555dc 100644 --- a/packages/dify-ui/src/input/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/input/__tests__/index.spec.tsx @@ -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() diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx index bdcac83f392..4b99651d1d2 100644 --- a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx @@ -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( - - - - - - - - - - - , +
+ + + + + + + + + + + +
, ) 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', () => { , ) - 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' })) diff --git a/packages/dify-ui/src/radio/__tests__/index.spec.tsx b/packages/dify-ui/src/radio/__tests__/index.spec.tsx index 579e287bba5..2f44f4af606 100644 --- a/packages/dify-ui/src/radio/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/radio/__tests__/index.spec.tsx @@ -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 = RadioGroupProps & { 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', () => { , ) - 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( - + SSD 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') }) diff --git a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx index bddcd56e0f7..a45c1eb9a19 100644 --- a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx @@ -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', () => { , ) - 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', () => { , ) - 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', () => { , ) - asHTMLElement(screen.getByRole('button', { name: 'One' }).element()).click() + await screen.getByRole('button', { name: 'One' }).click() expect(onValueChange).toHaveBeenCalledWith([], expect.anything()) await expect diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index 6b5ccbf10bb..a29e73e4a9f 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -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( - @@ -331,9 +329,9 @@ describe('Select wrappers', () => { , ) - 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 () => { diff --git a/packages/dify-ui/src/slider/__tests__/index.spec.tsx b/packages/dify-ui/src/slider/__tests__/index.spec.tsx index 8bd6f78600b..8afb0294bea 100644 --- a/packages/dify-ui/src/slider/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/slider/__tests__/index.spec.tsx @@ -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', () => { , ) - 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( - , - ) + const screen = await render() - 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 () => { diff --git a/packages/dify-ui/src/styles/styles.css b/packages/dify-ui/src/styles/styles.css index 34e7f805a74..7453e81d83c 100644 --- a/packages/dify-ui/src/styles/styles.css +++ b/packages/dify-ui/src/styles/styles.css @@ -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'; diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx index 0a698047e2c..8b044162190 100644 --- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -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() 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( - , - ) + const screen = await render() 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( - , - ) + const screen = await render() 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 () => { diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx index f6c8d9a95c5..736995f346c 100644 --- a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -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', () => { , ) - asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click() + await screen.getByRole('tab', { name: 'Python' }).click() expect(onValueChange).toHaveBeenCalledWith('py', expect.anything()) await expect diff --git a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx index cfa3d236366..ee58cfe72a9 100644 --- a/packages/dify-ui/src/textarea/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/textarea/__tests__/index.spec.tsx @@ -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', () => { , ) - 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', () => { , ) - 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({ diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index c256cc15b9e..45b605a0e9b 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -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() - 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() @@ -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', () => { <>