diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b18d16c6191..070880ffc24 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -267,5 +267,9 @@
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
/web/service/server.ts @iamjoel @lyzno1
+# Frontend - Browser Mode Tests
+/web/app/**/*.browser.spec.ts @lyzno1
+/web/app/**/*.browser.spec.tsx @lyzno1
+
# Docker
/docker/* @laipz8200
diff --git a/.github/workflows/accessibility-e2e.yml b/.github/workflows/accessibility-e2e.yml
index 1840e0676b6..527a4fc8ffa 100644
--- a/.github/workflows/accessibility-e2e.yml
+++ b/.github/workflows/accessibility-e2e.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
accessibility:
name: WCAG Level ${{ inputs.level == 'a' && 'A' || 'AA' }} · ${{ inputs.page }}
- runs-on: depot-ubuntu-24.04-4
+ runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:
diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index efefdd5bb04..6f098bffdfc 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
- run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
+ run: vp test run --project unit --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@@ -67,7 +67,17 @@ jobs:
run: vp exec playwright install --with-deps --only-shell chromium
- name: Run browser tests
- run: vp test run --config vitest.browser.config.ts --silent=passed-only
+ run: vp test run --project browser --silent=passed-only
+
+ - name: Upload browser failure artifacts
+ if: ${{ failure() && !cancelled() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: browser-test-failures
+ path: web/.vitest-browser/
+ if-no-files-found: ignore
+ include-hidden-files: true
+ retention-days: 7
merge-reports:
name: Merge Test Reports
@@ -111,7 +121,7 @@ jobs:
dify-ui-test:
name: dify-ui Tests
- runs-on: depot-ubuntu-24.04-4
+ runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -136,6 +146,16 @@ jobs:
- name: Run dify-ui tests
run: vp test run --project unit --coverage --silent=passed-only
+ - name: Upload dify-ui test failure artifacts
+ if: ${{ failure() && !cancelled() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: dify-ui-test-failures
+ path: packages/dify-ui/.vitest-browser/
+ if-no-files-found: ignore
+ include-hidden-files: true
+ retention-days: 7
+
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
@@ -147,7 +167,7 @@ jobs:
dify-ui-storybook-test:
name: dify-ui Storybook Tests
- runs-on: depot-ubuntu-24.04-4
+ runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
@@ -169,3 +189,13 @@ jobs:
- name: Run dify-ui Storybook tests
run: vp run test:storybook
+
+ - name: Upload dify-ui Storybook test failure artifacts
+ if: ${{ failure() && !cancelled() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: dify-ui-storybook-test-failures
+ path: packages/dify-ui/.vitest-browser/
+ if-no-files-found: ignore
+ include-hidden-files: true
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 183645694eb..7b6a29809a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -263,6 +263,7 @@ scripts/stress-test/reports/
.qoder/*
.context/
# Vitest local reports
+web/.vitest-browser/
web/.vitest-reports/
# dify-agent-runtime
diff --git a/knip.config.ts b/knip.config.ts
index 46d5ecc3573..2914f398f8c 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -15,8 +15,6 @@ const config: KnipConfig = {
'tsslint.config.ts',
'dev-proxy.config.ts',
'plugins/eslint/index.js',
- 'vitest.browser.config.ts',
- 'vitest.browser.setup.ts',
],
project: [
'**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts,css,mdx}!',
@@ -26,6 +24,7 @@ const config: KnipConfig = {
'!.storybook/**!',
'!plugins/**!',
'!test/**!',
+ '!vitest.browser.setup.ts!',
'!vitest.setup.ts!',
],
ignore: ['public/**'],
diff --git a/packages/dify-ui/.gitignore b/packages/dify-ui/.gitignore
index befe881885d..b4b3fdf70f9 100644
--- a/packages/dify-ui/.gitignore
+++ b/packages/dify-ui/.gitignore
@@ -1,3 +1,4 @@
/coverage
+/.vitest-browser
/dist
/storybook-static
diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md
index 75c35061387..1f0987dfc40 100644
--- a/packages/dify-ui/README.md
+++ b/packages/dify-ui/README.md
@@ -276,11 +276,13 @@ See the [web overlay guide] for the web app overlay best practices.
## Development
-- `vp check packages/dify-ui` (from the repository root) — formatting and lint for the package plus the repository-wide TypeScript diagnostics configured by Vite+.
-- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
-- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
-- `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates.
-- `pnpm -C packages/dify-ui type-check` — TypeScript 7 native type checking for this package only.
+Run `vp check packages/dify-ui` from the repository root for package formatting, lint, and repository-wide TypeScript diagnostics. Run the remaining commands from `packages/dify-ui/`:
+
+- `vp test --project unit` — Vitest unit tests for primitives.
+- `vp run storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
+- `vp test --project storybook --run` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates.
+
+Both test projects run in Playwright Chromium Browser Mode; choose `unit` or `storybook` by behavior owner, not runtime. Bare `vp test` runs both projects.
### Test Boundary
@@ -291,10 +293,10 @@ wrapper contracts such as class variants, Base UI passthrough props, hidden inpu
serialization, data attribute hooks, store behavior, and edge cases that do not
need a full story.
-Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`.
-If a story is temporarily marked `todo`, keep the exception local to that story
-and do not treat an interaction `play` test as a replacement for fixing the
-underlying accessibility issue.
+Storybook accessibility testing uses `a11y.test = 'error'` for enabled rules.
+Color contrast is a known design-token gap and is currently excluded globally;
+do not add another global exclusion. Keep other temporary exceptions local to
+the affected story and do not use a `play` test in place of an accessibility fix.
### Disabling Animations In Tests
@@ -311,6 +313,7 @@ Set the Base UI test flag in a Vitest setup file to skip those waits:
```
`packages/dify-ui/vitest.setup.ts` already applies this for primitive tests.
+The Storybook project intentionally uses its preview setup instead; do not disable animation lifecycles globally there.
See [component authoring rules] for:
diff --git a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
index 8231d196e5d..c7af2a633cd 100644
--- a/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/avatar/__tests__/index.spec.tsx
@@ -1,12 +1,24 @@
import { render } from 'vitest-browser-react'
import { Avatar } from '..'
+const avatarDataUrl = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
+
function stubImageLoader() {
const originalImage = window.Image
const images: HTMLImageElement[] = []
function TestImage(_width?: number, _height?: number): HTMLImageElement {
- const image = document.createElement('img')
+ const image = {
+ complete: false,
+ crossOrigin: null,
+ naturalWidth: 0,
+ onerror: null,
+ onload: null,
+ referrerPolicy: '',
+ sizes: '',
+ src: '',
+ srcset: '',
+ } as unknown as HTMLImageElement
images.push(image)
return image
}
@@ -51,11 +63,7 @@ describe('Avatar', () => {
try {
const screen = await render(
- ,
+ ,
)
await expect.element(screen.getByText('J')).toBeVisible()
@@ -63,7 +71,7 @@ describe('Avatar', () => {
expect(onStatusChange).toHaveBeenCalledWith('loading')
})
- images[0]?.onload?.(new Event('load'))
+ images.at(-1)?.onload?.(new Event('load'))
await vi.waitFor(() => {
expect(onStatusChange).toHaveBeenCalledWith('loaded')
diff --git a/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx b/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx
index 4e6c7c79e4e..336c7eb4b55 100644
--- a/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/context-menu/__tests__/index.spec.tsx
@@ -98,7 +98,7 @@ describe('context-menu wrapper', () => {
.element(screen.getByRole('group', { name: 'context content positioner' }))
.toHaveAttribute('id', 'context-content-positioner')
await expect.element(screen.getByRole('menu')).toHaveAttribute('id', 'context-content-popup')
- expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
+ expect(handlePositionerMouseEnter).toHaveBeenCalled()
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
index 36fc4d14f3b..8c690c134dd 100644
--- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
@@ -1,5 +1,5 @@
+import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
-import { userEvent } from 'vitest/browser'
import { createToast, createToastManager, toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json
index 3dac6d6f3f5..c9c9e935410 100644
--- a/packages/dify-ui/tsconfig.json
+++ b/packages/dify-ui/tsconfig.json
@@ -9,7 +9,6 @@
"src/**/*.ts",
"src/**/*.tsx",
"vite.config.ts",
- "vitest.config.ts",
"vitest.setup.ts"
],
"exclude": ["node_modules", "dist", "storybook-static", "coverage"]
diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts
index b5e82761bd3..30bca171649 100644
--- a/packages/dify-ui/vite.config.ts
+++ b/packages/dify-ui/vite.config.ts
@@ -1,9 +1,71 @@
+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'
+
+const dirname = path.dirname(fileURLToPath(import.meta.url))
+const configDir = path.join(dirname, '.storybook')
+const isCI = !!process.env.CI
export default defineConfig({
plugins: [react()],
resolve: {
tsconfigPaths: true,
},
+ optimizeDeps: {
+ include: ['vite-plus/test/browser'],
+ },
+ test: {
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' }],
+ headless: true,
+ screenshotDirectory: './.vitest-browser/screenshots',
+ screenshotFailures: true,
+ },
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/**/*.stories.{ts,tsx}',
+ 'src/**/__tests__/**',
+ 'src/themes/**',
+ 'src/styles/**',
+ ],
+ reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
+ },
+ projects: [
+ {
+ extends: true,
+ plugins: [tailwindcss()],
+ test: {
+ name: 'unit',
+ globals: true,
+ setupFiles: ['./vitest.setup.ts'],
+ include: ['src/**/__tests__/**/*.spec.{ts,tsx}'],
+ browser: {
+ trace: {
+ mode: 'retain-on-failure',
+ tracesDir: './.vitest-browser/traces',
+ },
+ },
+ },
+ },
+ {
+ extends: true,
+ plugins: [
+ storybookTest({
+ configDir,
+ }),
+ ],
+ test: {
+ name: 'storybook',
+ },
+ },
+ ],
+ },
})
diff --git a/packages/dify-ui/vitest.config.ts b/packages/dify-ui/vitest.config.ts
deleted file mode 100644
index 214c8759432..00000000000
--- a/packages/dify-ui/vitest.config.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-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'
-
-const dirname = path.dirname(fileURLToPath(import.meta.url))
-const configDir = path.join(dirname, '.storybook')
-const isCI = !!process.env.CI
-
-export default defineConfig({
- plugins: [react()],
- resolve: {
- tsconfigPaths: true,
- },
- optimizeDeps: {
- include: ['vite-plus/test/browser'],
- },
- test: {
- coverage: {
- provider: 'v8',
- include: ['src/**/*.{ts,tsx}'],
- exclude: [
- 'src/**/*.stories.{ts,tsx}',
- 'src/**/__tests__/**',
- 'src/themes/**',
- 'src/styles/**',
- ],
- reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
- },
- projects: [
- {
- extends: true,
- plugins: [tailwindcss()],
- test: {
- name: 'unit',
- globals: true,
- setupFiles: ['./vitest.setup.ts'],
- include: ['src/**/__tests__/**/*.spec.{ts,tsx}'],
- browser: {
- enabled: true,
- provider: playwright(),
- instances: [{ browser: 'chromium' }],
- headless: true,
- },
- },
- },
- {
- extends: true,
- plugins: [
- storybookTest({
- configDir,
- }),
- ],
- test: {
- name: 'storybook',
- browser: {
- enabled: true,
- provider: playwright(),
- instances: [{ browser: 'chromium' }],
- headless: true,
- },
- },
- },
- ],
- },
-})
diff --git a/web/README.md b/web/README.md
index 3a268f512cb..cd4cc161b1e 100644
--- a/web/README.md
+++ b/web/README.md
@@ -99,20 +99,22 @@ Then follow the [Lint Documentation] to lint the code.
We use [Vitest] and [React Testing Library] for Unit Testing.
-**📖 Frontend Testing Guide**: See [web/docs/test.md] for the canonical testing policy and workflow.
+**📖 Frontend Testing Guide**: See the [Frontend Testing Guide] for the canonical testing policy and workflow.
> [!IMPORTANT]
> As we are using Vite+, the `vitest` command is not available.
> Please make sure to run tests with `vp` commands.
-> For example, use `npx vp test` instead of `npx vitest`.
+> For example, use `vp test` instead of `vitest`.
Run test:
```bash
cd web
-vp test run
+vp test run --project unit
```
+The standard unit command runs in `happy-dom`. Browser Mode is reserved for behavior that depends on a real browser; see the [Frontend Testing Guide] for its admission criteria and commands. Always select a project explicitly: bare `vp test` runs every registered project, including Browser Mode.
+
If a test fails only in CI, inspect the failing job and reproduce it locally when possible. A rerun can help identify a flaky test, but it does not replace diagnosing or reporting the failure.
## Documentation
@@ -124,6 +126,7 @@ Visit to view the full documentation.
The Dify community can be found on [Discord community], where you can ask questions, voice ideas, and share your projects.
[Discord community]: https://discord.gg/5AEfbxcd9k
+[Frontend Testing Guide]: ./docs/test.md
[Lint Documentation]: ./docs/lint.md
[Next.js]: https://nextjs.org
[Node.js]: https://nodejs.org
@@ -133,4 +136,3 @@ The Dify community can be found on [Discord community], where you can ask questi
[Vitest]: https://vitest.dev
[pnpm]: https://pnpm.io
[vinext]: https://github.com/cloudflare/vinext
-[web/docs/test.md]: ./docs/test.md
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.browser.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.browser.spec.tsx
index 7e7d4f8e88b..a3f4ab0dea9 100644
--- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.browser.spec.tsx
+++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/__tests__/file-image-item.browser.spec.tsx
@@ -24,8 +24,10 @@ describe('FileImageItem pointer interaction', () => {
it('keeps the preview clickable when the download action is visible', async () => {
const screen = await render()
const preview = screen.getByRole('button', { name: 'common.operation.view photo.png' })
+ const download = screen.getByRole('button', { name: 'common.operation.download' })
await preview.hover()
+ await expect.element(download).toBeVisible()
await preview.click()
await expect.element(page.getByRole('dialog')).toBeVisible()
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx
index a5eb555b769..dd3f90a9699 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx
@@ -1,8 +1,10 @@
+import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { Model, ModelItem } from '../../declarations'
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { consoleQuery } from '@/service/client'
import { createConsoleQueryClient } from '@/test/console/query-data'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '../../declarations'
import { ModelSelector, SplitModelSelector } from '../index'
@@ -89,8 +91,29 @@ const makeModel = (overrides: Partial = {}): Model => ({
...overrides,
})
+const makeProviderSummary = (): ModelProviderSummaryResponse => ({
+ provider: 'openai',
+ plugin_id: 'langgenius/openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ supported_model_types: ['llm'],
+ configurate_methods: ['predefined-model'],
+ preferred_provider_type: 'system',
+ is_configured: true,
+ custom_configuration: {
+ status: 'active',
+ has_custom_models: false,
+ available_credentials: [],
+ current_credential_usable: false,
+ },
+ system_configuration: { enabled: true },
+})
+
const renderWithQueryClient = (node: ReactNode) => {
const queryClient = createConsoleQueryClient()
+ queryClient.setQueryData(consoleQuery.workspaces.current.modelProviders.summary.get.key(), {
+ data: [makeProviderSummary()],
+ plugins: {},
+ })
return render({node})
}
diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx
index 3d74e85bd8f..8262d9dc267 100644
--- a/web/app/device/__tests__/page-terminal.spec.tsx
+++ b/web/app/device/__tests__/page-terminal.spec.tsx
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
-import { fireEvent, screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import DevicePage from '../page'
@@ -80,7 +80,9 @@ describe('error_expired terminal state', () => {
it('shows "errorExpired.title" heading', async () => {
await reachTerminal(new Error('expired'))
await screen.findByText('deviceFlow.errorExpired.title')
- expect(document.title).toBe('deviceFlow.errorExpired.title - Dify')
+ await waitFor(() => {
+ expect(document.title).toBe('deviceFlow.errorExpired.title - Dify')
+ })
})
it('ghost button resets to code_entry', async () => {
diff --git a/web/docs/test.md b/web/docs/test.md
index 3d41ca31b24..7278d266452 100644
--- a/web/docs/test.md
+++ b/web/docs/test.md
@@ -39,10 +39,22 @@ Use the smallest boundary that includes the behavior owner and proves the produc
- Use a real browser for layout, responsive behavior, browser-specific APIs, animation, and focus behavior that `happy-dom` cannot represent faithfully.
- Follow `packages/dify-ui/README.md` for the Storybook and Vitest boundary of Dify UI primitives.
-Browser Mode provides a real browser runtime for focused component tests. Using it does not by itself provide end-to-end coverage or prove integration with the running app's authentication, APIs, or persistence.
-
Test the behavior owner. Barrel exports, pass-through wrappers, and purely presentational children do not need separate tests when the owning feature already proves their contract. Do not repeat generic behavior already owned by Base UI, React Aria, or the browser; test Dify's integration, overrides, and known regressions.
+### Browser Mode Admission
+
+`happy-dom` is the default choice for tests under `web/`. Use the `unit` project for pure logic, hooks, and DOM-observable component or feature behavior that does not depend on a browser's rendering engine. This split follows [Vitest test projects] and [Why Browser Mode].
+
+Use the `browser` project only when the asserted contract depends on browser-owned behavior that `happy-dom` cannot represent faithfully, such as:
+
+- Layout geometry, CSS hit testing, responsive behavior, or pointer targeting.
+- Native focus, selection, scrolling, keyboard, or pointer behavior.
+- Browser APIs, observers, or animation lifecycles whose real implementation affects the result.
+
+Rendering UI, reducing mocks, increasing confidence, or raising coverage is not enough reason to use Browser Mode. Each `*.browser.spec.{ts,tsx}` test under `web/app/` must name the browser-owned behavior and why `happy-dom` is insufficient, exercise the smallest owner through semantic locators, and justify its additional runtime. Do not use forced interaction, fixed sleeps, private DOM or CSS assertions, or real network requests.
+
+Browser Mode remains a focused component or feature test and currently proves Chromium only. Use the end-to-end suite for a running application, authentication, real routing, backend APIs, persistence, or complete journeys.
+
## Assert Behavior, Not Implementation
- Drive state transitions through props, user interaction, URL changes, or public APIs.
@@ -103,8 +115,9 @@ Mocks must preserve the public contract needed by the test. Do not mock interact
## Dify Test Setup
-- Tests under `web/` run in `happy-dom` through `web/vite.config.ts` and load `web/vitest.setup.ts`.
-- Tests under `packages/dify-ui/` use separate Vitest Browser Mode projects: unit specs load the package styles through `vitest.setup.ts`, while Storybook tests run stories through `@storybook/addon-vitest`.
+- Following [Vite+ testing configuration], tests under `web/` use two explicit projects in `web/vite.config.ts`. Supported commands and CI select one project explicitly: `unit` runs in `happy-dom` and loads `web/vitest.setup.ts`, while `browser` runs matching `app/**/*.browser.spec.{ts,tsx}` files in Playwright Chromium and loads `web/vitest.browser.setup.ts`. Bare `vp test` runs both registered projects.
+- Browser failures keep screenshots and Playwright traces under `web/.vitest-browser/`. CI uploads that directory only when failure artifacts exist; Browser Mode does not own coverage or report merging.
+- Tests under `packages/dify-ui/` use two Chromium Browser Mode projects: `unit` owns focused primitive contracts and loads the package styles through `vitest.setup.ts`; `storybook` owns story render, play, and accessibility contracts through `@storybook/addon-vitest`. The names identify behavior owners, not different runtimes.
- 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.
@@ -127,19 +140,21 @@ When working across several files, order the work by dependency and verify each
Run from `web/`:
```bash
-# Focused spec or directory
-vp test run path/to/spec-or-directory
+# happy-dom; omit the path to run the full unit project
+vp test run --project unit path/to/spec-or-directory
-# All web tests
-vp test run
+# Browser Mode; omit the path to run the full browser project
+vp test run --project browser path/to/spec.browser.spec.tsx
-# Watch mode
-vp test watch path/to/spec
+# Watch mode; select browser instead for Browser Mode
+vp test watch --project unit path/to/spec
-# Diagnostic coverage report; not an acceptance target
-vp test run --coverage path/to/spec-or-directory
+# Diagnostic coverage report for the unit project; not an acceptance target
+vp test run --project unit --coverage path/to/spec-or-directory
```
+Always pass `--project unit` or `--project browser`. Bare `vp test` runs both registered projects and is not the standard Web test command.
+
## Review Checklist
- Does each test protect a reachable product contract or meaningful regression?
@@ -149,12 +164,16 @@ vp test run --coverage path/to/spec-or-directory
- Is the suite deterministic, focused, and cheaper to maintain than the regression it prevents?
- Would the test survive a refactor that preserves behavior?
- Can the reviewer name one realistic regression and the assertion that would fail?
+- For Browser Mode, is the browser-owned contract explicit, impossible to prove faithfully in `happy-dom`, and worth the additional runtime?
## References
- [Vitest documentation]
+- [Vitest test projects]
+- [Why Browser Mode]
- [Vitest Browser Mode documentation]
- [Vitest Browser Mode locators]
+- [Vitest Browser Mode traces]
- [Storybook Vitest addon]
- [Testing Library guiding principles]
- [React Testing Library documentation]
@@ -166,6 +185,10 @@ 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
+[Vite+ testing configuration]: https://viteplus.dev/guide/test
+[Vitest Browser Mode documentation]: https://v4.vitest.dev/guide/browser
+[Vitest Browser Mode locators]: https://v4.vitest.dev/api/browser/locators
+[Vitest Browser Mode traces]: https://v4.vitest.dev/guide/browser/trace-view
+[Vitest documentation]: https://v4.vitest.dev/guide
+[Vitest test projects]: https://v4.vitest.dev/guide/projects
+[Why Browser Mode]: https://v4.vitest.dev/guide/browser/why
diff --git a/web/package.json b/web/package.json
index 0f38a901a09..b9dabcfb667 100644
--- a/web/package.json
+++ b/web/package.json
@@ -29,9 +29,8 @@
"start:vinext": "vinext start",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
- "test": "vp test",
- "test:coverage": "vp test --coverage",
- "test:watch": "vp test --watch",
+ "test": "vp test --project unit",
+ "test:browser": "vp test --project browser",
"type-check": "tsc",
"uglify-embed": "node ./bin/uglify-embed"
},
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 99e18a13170..c2c76ed05e1 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url'
import { configDefaults, defineConfig, lazyPlugins } from 'vite-plus'
+import { playwright } from 'vite-plus/test/browser-playwright'
import {
createCodeInspectorPlugin,
createForceInspectorClientInjectionPlugin,
@@ -11,6 +12,7 @@ import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const isCI = !!process.env.CI
const rootClientInjectTarget = getRootClientInjectTarget(projectRoot)
+const browserTestPattern = 'app/**/*.browser.spec.{ts,tsx}'
export default defineConfig(({ mode }) => {
const isTest = mode === 'test'
@@ -22,9 +24,7 @@ export default defineConfig(({ mode }) => {
plugins: lazyPlugins(async () => {
const { default: react } = await import('@vitejs/plugin-react')
- if (isTest) {
- return [nextStaticImageTestPlugin({ projectRoot }), react()]
- }
+ if (isTest) return [nextStaticImageTestPlugin({ projectRoot }), react()]
if (isStorybook) return [react()]
@@ -80,16 +80,55 @@ export default defineConfig(({ mode }) => {
// Vitest config
test: {
- pool: 'threads',
- environment: 'happy-dom',
- globals: true,
- setupFiles: ['./vitest.setup.ts'],
- exclude: [...configDefaults.exclude, '**/*.browser.spec.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
exclude: ['**/__mocks__/**'],
},
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: 'unit',
+ pool: 'threads',
+ environment: 'happy-dom',
+ globals: true,
+ setupFiles: ['./vitest.setup.ts'],
+ exclude: [...configDefaults.exclude, browserTestPattern],
+ },
+ },
+ {
+ extends: true,
+ define: {
+ 'process.env': '{}',
+ },
+ plugins: lazyPlugins(async () => {
+ const { default: tailwindcss } = await import('@tailwindcss/vite')
+ return [tailwindcss()]
+ }),
+ optimizeDeps: {
+ include: ['vite-plus/test/browser'],
+ },
+ test: {
+ name: 'browser',
+ globals: true,
+ setupFiles: ['./vitest.browser.setup.ts'],
+ include: [browserTestPattern],
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' }],
+ headless: true,
+ screenshotDirectory: './.vitest-browser/screenshots',
+ screenshotFailures: true,
+ trace: {
+ mode: 'retain-on-failure',
+ tracesDir: './.vitest-browser/traces',
+ },
+ },
+ },
+ },
+ ],
},
}
})
diff --git a/web/vitest.browser.config.ts b/web/vitest.browser.config.ts
deleted file mode 100644
index df0a9361a78..00000000000
--- a/web/vitest.browser.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import tailwindcss from '@tailwindcss/vite'
-import react from '@vitejs/plugin-react'
-import { defineConfig } from 'vite-plus'
-import { playwright } from 'vite-plus/test/browser-playwright'
-
-export default defineConfig({
- define: {
- 'process.env': '{}',
- },
- plugins: [tailwindcss(), react()],
- resolve: {
- tsconfigPaths: true,
- },
- optimizeDeps: {
- include: ['vite-plus/test/browser'],
- },
- test: {
- globals: true,
- setupFiles: ['./vitest.browser.setup.ts'],
- include: ['app/**/*.browser.spec.{ts,tsx}'],
- browser: {
- enabled: true,
- provider: playwright(),
- instances: [{ browser: 'chromium' }],
- headless: true,
- },
- },
-})