diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index dcee8863ce..2a5cf19645 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -109,6 +109,9 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
+ - name: Install Chromium for Browser Mode
+ run: vp exec playwright install --with-deps chromium
+
- name: Run dify-ui tests
run: vp test run --coverage --silent=passed-only
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 5e81e95f2f..ae9fdaff01 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,6 +13,7 @@ export default antfu(
'!e2e/**',
'!eslint.config.mjs',
'!package.json',
+ '!pnpm-workspace.yaml',
'!vite.config.ts',
...original,
],
@@ -35,7 +36,6 @@ export default antfu(
},
},
e18e: false,
- pnpm: false,
},
markdownPreferences.configs.standard,
{
diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json
index 2b78b25ed6..b3430ab4ee 100644
--- a/packages/dify-ui/package.json
+++ b/packages/dify-ui/package.json
@@ -99,15 +99,12 @@
"@storybook/addon-themes": "catalog:",
"@storybook/react-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
- "@testing-library/jest-dom": "catalog:",
- "@testing-library/react": "catalog:",
- "@testing-library/user-event": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"class-variance-authority": "catalog:",
- "happy-dom": "catalog:",
+ "playwright": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "catalog:",
@@ -115,6 +112,6 @@
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
- "vitest": "catalog:"
+ "vitest-browser-react": "catalog:"
}
}
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 23fbcb19d6..5248be9a16 100644
--- a/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/alert-dialog/__tests__/index.spec.tsx
@@ -1,5 +1,4 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
+import { render } from 'vitest-browser-react'
import {
AlertDialog,
AlertDialogActions,
@@ -11,10 +10,12 @@ 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', () => {
- render(
+ it('should render alert dialog content when dialog is open', async () => {
+ const screen = await render(
Confirm Delete
@@ -23,13 +24,12 @@ describe('AlertDialog wrapper', () => {
,
)
- const dialog = screen.getByRole('alertdialog')
- expect(dialog).toHaveTextContent('Confirm Delete')
- expect(dialog).toHaveTextContent('This action cannot be undone.')
+ await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Confirm Delete')
+ await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('This action cannot be undone.')
})
- it('should not render content when dialog is closed', () => {
- render(
+ it('should not render content when dialog is closed', async () => {
+ const screen = await render(
Hidden Title
@@ -37,13 +37,13 @@ describe('AlertDialog wrapper', () => {
,
)
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
- it('should apply custom className to popup', () => {
- render(
+ it('should apply custom className to popup', async () => {
+ const screen = await render(
Title
@@ -51,12 +51,11 @@ describe('AlertDialog wrapper', () => {
,
)
- const dialog = screen.getByRole('alertdialog')
- expect(dialog).toHaveClass('custom-class')
+ await expect.element(screen.getByRole('alertdialog')).toHaveClass('custom-class')
})
- it('should not render a close button by default', () => {
- render(
+ it('should not render a close button by default', async () => {
+ const screen = await render(
Title
@@ -64,13 +63,13 @@ describe('AlertDialog wrapper', () => {
,
)
- expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
+ expect(() => screen.getByRole('button', { name: 'Close' }).element()).toThrow()
})
})
describe('User Interactions', () => {
it('should open and close dialog when trigger and cancel button are clicked', async () => {
- render(
+ const screen = await render(
Open Dialog
@@ -83,21 +82,21 @@ describe('AlertDialog wrapper', () => {
,
)
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
- expect(await screen.findByRole('alertdialog')).toHaveTextContent('Action Required')
+ asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
+ await expect.element(screen.getByRole('alertdialog')).toHaveTextContent('Action Required')
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
- await waitFor(() => {
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
+ await vi.waitFor(() => {
+ expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
})
describe('Composition Helpers', () => {
- it('should render actions wrapper and default confirm button styles', () => {
- render(
+ it('should render actions wrapper and default confirm button styles', async () => {
+ const screen = await render(
Action Required
@@ -108,15 +107,14 @@ describe('AlertDialog wrapper', () => {
,
)
- expect(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions')
- const confirmButton = screen.getByRole('button', { name: 'Confirm' })
- expect(confirmButton).toHaveClass('bg-components-button-destructive-primary-bg')
+ await expect.element(screen.getByTestId('actions')).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2', 'self-stretch', 'p-6', 'custom-actions')
+ await expect.element(screen.getByRole('button', { name: 'Confirm' })).toHaveClass('bg-components-button-destructive-primary-bg')
})
it('should keep dialog open after confirm click and close via cancel helper', async () => {
const onConfirm = vi.fn()
- render(
+ const screen = await render(
Open Dialog
@@ -129,16 +127,16 @@ describe('AlertDialog wrapper', () => {
,
)
- fireEvent.click(screen.getByRole('button', { name: 'Open Dialog' }))
- expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
+ asHTMLElement(screen.getByRole('button', { name: 'Open Dialog' }).element()).click()
+ await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
+ asHTMLElement(screen.getByRole('button', { name: 'Confirm' }).element()).click()
expect(onConfirm).toHaveBeenCalledTimes(1)
- expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ await expect.element(screen.getByRole('alertdialog')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
- await waitFor(() => {
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ asHTMLElement(screen.getByRole('button', { name: 'Cancel' }).element()).click()
+ await vi.waitFor(() => {
+ expect(screen.container.querySelector('[role="alertdialog"]')).not.toBeInTheDocument()
})
})
})
diff --git a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
index 8a384139c2..b0ea496282 100644
--- a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
@@ -1,25 +1,25 @@
-import { render, screen } from '@testing-library/react'
+import { render } from 'vitest-browser-react'
import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '..'
describe('Avatar', () => {
describe('Rendering', () => {
- it('should keep the fallback visible when avatar URL is provided before image load', () => {
- render()
+ it('should keep the fallback visible when avatar URL is provided before image load', async () => {
+ const screen = await render()
- expect(screen.getByText('J')).toBeInTheDocument()
+ await expect.element(screen.getByText('J')).toBeInTheDocument()
})
- it('should render fallback with uppercase initial when avatar is null', () => {
- render()
+ it('should render fallback with uppercase initial when avatar is null', async () => {
+ const screen = await render()
- expect(screen.queryByRole('img')).not.toBeInTheDocument()
- expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.container.querySelector('img')).not.toBeInTheDocument()
+ await expect.element(screen.getByText('A')).toBeInTheDocument()
})
- it('should render the fallback when avatar is provided', () => {
- render()
+ it('should render the fallback when avatar is provided', async () => {
+ const screen = await render()
- expect(screen.getByText('J')).toBeInTheDocument()
+ await expect.element(screen.getByText('J')).toBeInTheDocument()
})
})
@@ -33,36 +33,36 @@ describe('Avatar', () => {
{ size: 'xl' as const, expectedClass: 'size-10' },
{ size: '2xl' as const, expectedClass: 'size-12' },
{ size: '3xl' as const, expectedClass: 'size-16' },
- ])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => {
- const { container } = render()
+ ])('should apply $expectedClass for size="$size"', async ({ size, expectedClass }) => {
+ const screen = await render()
- const root = container.firstElementChild as HTMLElement
+ const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass(expectedClass)
})
- it('should default to md size when size is not specified', () => {
- const { container } = render()
+ it('should default to md size when size is not specified', async () => {
+ const screen = await render()
- const root = container.firstElementChild as HTMLElement
+ const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass('size-8')
})
})
describe('className prop', () => {
- it('should merge className with avatar variant classes on root', () => {
- const { container } = render(
+ it('should merge className with avatar variant classes on root', async () => {
+ const screen = await render(
,
)
- const root = container.firstElementChild as HTMLElement
+ const root = screen.container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-class')
expect(root).toHaveClass('rounded-full', 'bg-primary-600')
})
})
describe('Primitives', () => {
- it('should support composed avatar usage through exported primitives', () => {
- render(
+ it('should support composed avatar usage through exported primitives', async () => {
+ const screen = await render(
@@ -71,17 +71,17 @@ describe('Avatar', () => {
,
)
- expect(screen.getByTestId('avatar-root')).toHaveClass('size-6')
- expect(screen.getByText('J')).toBeInTheDocument()
- expect(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' })
+ await expect.element(screen.getByTestId('avatar-root')).toHaveClass('size-6')
+ await expect.element(screen.getByText('J')).toBeInTheDocument()
+ await expect.element(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' })
})
})
describe('Edge Cases', () => {
- it('should handle empty string name gracefully', () => {
- const { container } = render()
+ it('should handle empty string name gracefully', async () => {
+ const screen = await render()
- const fallback = container.querySelector('.text-white') as HTMLElement
+ const fallback = screen.container.querySelector('.text-white') as HTMLElement
expect(fallback).toBeInTheDocument()
expect(fallback.textContent).toBe('')
})
@@ -89,23 +89,23 @@ describe('Avatar', () => {
it.each([
{ name: '中文名', expected: '中', label: 'Chinese characters' },
{ name: '123User', expected: '1', label: 'number' },
- ])('should display first character when name starts with $label', ({ name, expected }) => {
- render()
+ ])('should display first character when name starts with $label', async ({ name, expected }) => {
+ const screen = await render()
- expect(screen.getByText(expected)).toBeInTheDocument()
+ await expect.element(screen.getByText(expected)).toBeInTheDocument()
})
- it('should handle empty string avatar as falsy value', () => {
- render()
+ it('should handle empty string avatar as falsy value', async () => {
+ const screen = await render()
- expect(screen.queryByRole('img')).not.toBeInTheDocument()
- expect(screen.getByText('T')).toBeInTheDocument()
+ expect(screen.container.querySelector('img')).not.toBeInTheDocument()
+ await expect.element(screen.getByText('T')).toBeInTheDocument()
})
})
describe('onLoadingStatusChange', () => {
- it('should render the fallback when avatar and onLoadingStatusChange are provided', () => {
- render(
+ it('should render the fallback when avatar and onLoadingStatusChange are provided', async () => {
+ const screen = await render(
{
/>,
)
- expect(screen.getByText('J')).toBeInTheDocument()
+ await expect.element(screen.getByText('J')).toBeInTheDocument()
})
- it('should not render image when avatar is null even with onLoadingStatusChange', () => {
+ it('should not render image when avatar is null even with onLoadingStatusChange', async () => {
const onStatusChange = vi.fn()
- render(
+ const screen = await render(
,
)
- expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.container.querySelector('img')).not.toBeInTheDocument()
})
})
})
diff --git a/packages/dify-ui/src/button/__tests__/index.spec.tsx b/packages/dify-ui/src/button/__tests__/index.spec.tsx
index e7b9c92c91..f3694c29af 100644
--- a/packages/dify-ui/src/button/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/button/__tests__/index.spec.tsx
@@ -1,46 +1,48 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import { render } from 'vitest-browser-react'
import { Button } from '../index'
+const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
+
describe('Button', () => {
describe('rendering', () => {
- it('renders children text', () => {
- render()
- expect(screen.getByRole('button')).toHaveTextContent('Click me')
+ it('renders children text', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveTextContent('Click me')
})
- it('renders as a native button element by default', () => {
- render()
- expect(screen.getByRole('button').tagName).toBe('BUTTON')
+ it('renders as a native button element by default', async () => {
+ const screen = await render()
+ expect(screen.getByRole('button').element().tagName).toBe('BUTTON')
})
- it('defaults to type="button"', () => {
- render()
- expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
+ it('defaults to type="button"', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'button')
})
- it('allows type override to submit', () => {
- render()
- expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
+ it('allows type override to submit', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveAttribute('type', 'submit')
})
- it('renders custom element via render prop', () => {
- render(}>Link)
- const link = screen.getByRole('link')
- expect(link).toHaveTextContent('Link')
- expect(link).toHaveAttribute('href', '/test')
+ it('renders custom element via render prop', async () => {
+ const screen = await render(}>Link)
+ const button = screen.getByRole('button', { name: 'Link' }).element()
+ expect(button.tagName).toBe('A')
+ expect(button).toHaveAttribute('href', '/test')
})
- it('applies base layout classes', () => {
- render()
- const btn = screen.getByRole('button')
+ it('applies base layout classes', async () => {
+ const screen = await render()
+ const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('inline-flex', 'justify-center', 'items-center', 'cursor-pointer')
})
})
describe('variants', () => {
- it('applies default secondary variant', () => {
- render()
- const btn = screen.getByRole('button')
+ it('applies default secondary variant', async () => {
+ const screen = await render()
+ const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('bg-components-button-secondary-bg', 'text-components-button-secondary-text')
})
@@ -51,124 +53,124 @@ describe('Button', () => {
{ variant: 'ghost' as const, expectedClass: 'text-components-button-ghost-text' },
{ variant: 'ghost-accent' as const, expectedClass: 'hover:bg-state-accent-hover' },
{ variant: 'tertiary' as const, expectedClass: 'bg-components-button-tertiary-bg' },
- ])('applies $variant variant', ({ variant, expectedClass }) => {
- render()
- expect(screen.getByRole('button')).toHaveClass(expectedClass)
+ ])('applies $variant variant', async ({ variant, expectedClass }) => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass(expectedClass)
})
- it('applies destructive tone with default variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg')
+ it('applies destructive tone with default variant', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-secondary-bg')
})
- it('applies destructive tone with primary variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg')
+ it('applies destructive tone with primary variant', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-primary-bg')
})
- it('applies destructive tone with tertiary variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg')
+ it('applies destructive tone with tertiary variant', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass('bg-components-button-destructive-tertiary-bg')
})
- it('applies destructive tone with ghost variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text')
+ it('applies destructive tone with ghost variant', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass('text-components-button-destructive-ghost-text')
})
})
describe('sizes', () => {
- it('applies default medium size', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg')
+ it('applies default medium size', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass('h-8', 'rounded-lg')
})
it.each([
{ size: 'small' as const, expectedClass: 'h-6' },
{ size: 'medium' as const, expectedClass: 'h-8' },
{ size: 'large' as const, expectedClass: 'h-9' },
- ])('applies $size size', ({ size, expectedClass }) => {
- render()
- expect(screen.getByRole('button')).toHaveClass(expectedClass)
+ ])('applies $size size', async ({ size, expectedClass }) => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveClass(expectedClass)
})
})
describe('loading', () => {
- it('shows spinner when loading', () => {
- render()
- expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).toBeInTheDocument()
+ it('shows spinner when loading', async () => {
+ const screen = await render()
+ expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).toBeInTheDocument()
})
- it('hides spinner when not loading', () => {
- render()
- expect(screen.getByRole('button').querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
+ it('hides spinner when not loading', async () => {
+ const screen = await render()
+ expect(screen.getByRole('button').element().querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
})
- it('auto-disables when loading', () => {
- render()
- expect(screen.getByRole('button')).toBeDisabled()
+ it('auto-disables when loading', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toBeDisabled()
})
- it('sets aria-busy when loading', () => {
- render()
- expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
+ it('sets aria-busy when loading', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
})
- it('does not set aria-busy when not loading', () => {
- render()
- expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy')
+ it('does not set aria-busy when not loading', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).not.toHaveAttribute('aria-busy')
})
})
describe('disabled', () => {
- it('disables button when disabled prop is set', () => {
- render()
- expect(screen.getByRole('button')).toBeDisabled()
+ it('disables button when disabled prop is set', async () => {
+ const screen = await render()
+ await expect.element(screen.getByRole('button')).toBeDisabled()
})
- it('keeps focusable when loading with focusableWhenDisabled', () => {
- render()
- const button = screen.getByRole('button')
+ it('keeps focusable when loading with focusableWhenDisabled', async () => {
+ const screen = await render()
+ const button = screen.getByRole('button').element()
expect(button).toHaveAttribute('aria-disabled', 'true')
})
})
describe('events', () => {
- it('fires onClick when clicked', () => {
+ it('fires onClick when clicked', async () => {
const onClick = vi.fn()
- render()
- fireEvent.click(screen.getByRole('button'))
+ const screen = await render()
+ await screen.getByRole('button').click()
expect(onClick).toHaveBeenCalledTimes(1)
})
- it('does not fire onClick when disabled', () => {
+ it('does not fire onClick when disabled', async () => {
const onClick = vi.fn()
- render()
- fireEvent.click(screen.getByRole('button'))
+ const screen = await render()
+ asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
- it('does not fire onClick when loading', () => {
+ it('does not fire onClick when loading', async () => {
const onClick = vi.fn()
- render()
- fireEvent.click(screen.getByRole('button'))
+ const screen = await render()
+ asHTMLElement(screen.getByRole('button').element()).click()
expect(onClick).not.toHaveBeenCalled()
})
})
describe('className merging', () => {
- it('merges custom className with variant classes', () => {
- render()
- const btn = screen.getByRole('button')
+ it('merges custom className with variant classes', async () => {
+ const screen = await render()
+ const btn = screen.getByRole('button').element()
expect(btn).toHaveClass('custom-class')
expect(btn).toHaveClass('inline-flex')
})
})
describe('ref forwarding', () => {
- it('forwards ref to the button element', () => {
+ it('forwards ref to the button element', async () => {
let buttonRef: HTMLButtonElement | null = null
- render(
+ await render(