From 3c7d6739b5adb274261d3f98f6a011e19ee5bf4e Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Fri, 17 Apr 2026 20:32:12 +0800 Subject: [PATCH] test: browser mode for dify ui (#35365) --- .github/workflows/web-tests.yml | 3 + eslint.config.mjs | 2 +- packages/dify-ui/package.json | 7 +- .../src/alert-dialog/__tests__/index.spec.tsx | 72 +- .../src/avatar/__tests__/index.spec.tsx | 82 +- .../src/button/__tests__/index.spec.tsx | 162 +- .../src/context-menu/__tests__/index.spec.tsx | 117 +- .../src/dialog/__tests__/index.spec.tsx | 40 +- .../dropdown-menu/__tests__/index.spec.tsx | 163 +- .../src/number-field/__tests__/index.spec.tsx | 136 +- .../src/popover/__tests__/index.spec.tsx | 46 +- .../src/scroll-area/__tests__/index.spec.tsx | 302 ++- .../src/select/__tests__/index.spec.tsx | 172 +- .../src/slider/__tests__/index.spec.tsx | 90 +- .../src/switch/__tests__/index.spec.tsx | 201 +- .../src/toast/__tests__/index.spec.tsx | 334 ++- .../src/tooltip/__tests__/index.spec.tsx | 53 +- packages/dify-ui/tests/setup.ts | 44 - packages/dify-ui/tsconfig.json | 8 +- packages/dify-ui/vite.config.ts | 9 +- pnpm-lock.yaml | 1903 +++-------------- pnpm-workspace.yaml | 91 +- 22 files changed, 1259 insertions(+), 2778 deletions(-) delete mode 100644 packages/dify-ui/tests/setup.ts 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() - 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() + 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(