diff --git a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx
new file mode 100644
index 00000000000..a838ba2b470
--- /dev/null
+++ b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.browser.spec.tsx
@@ -0,0 +1,93 @@
+import { createContext } from 'react'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { humanInputV2FormClient } from '@/service/human-input-v2'
+import { createConsoleQueryWrapper } from '@/test/console/query-data'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
+import FormContent from '../form'
+
+const RouteTokenContext = createContext('v2-token')
+
+vi.mock('@/next/navigation', async () => {
+ const { use } = await import('react')
+ return { useParams: () => ({ token: use(RouteTokenContext) }) }
+})
+
+vi.mock('@/service/human-input-v2', () => ({
+ humanInputV2FormClient: { getForm: vi.fn(), requestAccess: vi.fn() },
+}))
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+ vi.mocked(humanInputV2FormClient.getForm).mockImplementation(async ({ params }) => ({
+ form_content: `Review ${params.form_token}\n\n{{#$output.response#}}`,
+ inputs: [
+ {
+ type: 'paragraph',
+ output_variable_name: 'response',
+ default: { type: 'constant', value: `Default for ${params.form_token}`, selector: [] },
+ },
+ ],
+ resolved_default_values: { response: `Default for ${params.form_token}` },
+ user_actions: [{ id: 'approve', title: 'Approve', button_style: 'primary' }],
+ expiration_time: 1_900_000_000,
+ }))
+ vi.mocked(humanInputV2FormClient.requestAccess).mockImplementation(async ({ params }) => ({
+ challenge_token: `challenge-${params.form_token}`,
+ expires_in_seconds: 300,
+ resend_after_seconds: 30,
+ }))
+ onTestFinished(() => {
+ vi.restoreAllMocks()
+ })
+})
+
+it('loads the v2 form for the route token', async () => {
+ const { wrapper: Wrapper } = createConsoleQueryWrapper()
+ await render(
+
+
+
+
+ ,
+ )
+
+ await expect
+ .element(page.getByRole('textbox', { name: 'Review v2-token' }))
+ .toHaveValue('Default for v2-token')
+ await expect
+ .element(page.getByRole('textbox', { name: 'share.humanInputV2.otpLabel' }))
+ .toBeVisible()
+ await expect.element(page.getByRole('button', { name: 'Approve' })).toBeEnabled()
+})
+
+it('replaces an edited form and verification proof when the route token changes', async () => {
+ const { wrapper: Wrapper } = createConsoleQueryWrapper()
+ const view = await render(
+
+
+
+
+ ,
+ )
+ await page.getByRole('textbox', { name: 'Review v2-token' }).fill('Previous draft')
+ const otp = page.getByRole('textbox', { name: 'share.humanInputV2.otpLabel' })
+ await otp.fill('123456')
+
+ await view.rerender(
+
+
+
+
+ ,
+ )
+
+ await expect
+ .element(page.getByRole('textbox', { name: 'Review next-token' }))
+ .toHaveValue('Default for next-token')
+ await expect
+ .element(page.getByRole('textbox', { name: 'Review v2-token' }))
+ .not.toBeInTheDocument()
+ await expect.element(otp).toHaveValue('')
+})
diff --git a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx
deleted file mode 100644
index cb21a2244af..00000000000
--- a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import FormContent from '../form'
-
-const mockRoute = vi.hoisted(() => ({ token: 'v2-token' }))
-const mockHumanInputV2Form = vi.hoisted(() =>
- vi.fn(({ token }: { token: string }) =>
v2-form:{token}
),
-)
-const mockLegacyHook = vi.hoisted(() => vi.fn())
-
-vi.mock('@/next/navigation', () => ({
- useParams: () => ({ token: mockRoute.token }),
-}))
-
-vi.mock('@/features/human-input/public/form', () => ({
- default: (props: { token: string }) => mockHumanInputV2Form(props),
-}))
-
-vi.mock('@/service/use-share', () => ({
- useGetHumanInputForm: mockLegacyHook,
-}))
-
-vi.mock('@/hooks/use-document-title', () => ({
- default: vi.fn(),
-}))
-
-describe('/form-v2/[token] route', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockRoute.token = 'v2-token'
- })
-
- it('passes the v2 route token to the isolated feature and never invokes the legacy form hook', () => {
- render()
-
- expect(screen.getByText('v2-form:v2-token')).toBeInTheDocument()
- expect(mockHumanInputV2Form).toHaveBeenCalledWith({ token: 'v2-token' })
- expect(mockLegacyHook).not.toHaveBeenCalled()
- })
-
- it('keys the feature session by the current route token', () => {
- const { unmount } = render()
- unmount()
- mockRoute.token = 'next-token'
- render()
-
- expect(screen.getByText('v2-form:next-token')).toBeInTheDocument()
- expect(mockHumanInputV2Form).toHaveBeenLastCalledWith({ token: 'next-token' })
- })
-})
diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx
new file mode 100644
index 00000000000..dbdf2951f87
--- /dev/null
+++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.browser.spec.tsx
@@ -0,0 +1,134 @@
+import type {
+ CredentialSlot,
+ WorkflowPath,
+ WorkflowReference,
+} from '@dify/contracts/enterprise-app-deploy/types.gen'
+import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen'
+import { page } from 'vite-plus/test/browser'
+import { render as renderBrowser } from 'vitest-browser-react'
+import { createConsoleQueryWrapper } from '@/test/console/query-data'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
+import { CredentialField } from '../credential-field'
+
+async function renderWithConsoleQueryBrowser(ui: React.ReactElement) {
+ const { wrapper: Wrapper } = createConsoleQueryWrapper()
+ return renderBrowser({ui})
+}
+
+vi.mock('react-i18next', async () => {
+ const { createReactI18nextMock } = await import('@/test/i18n-mock')
+ return createReactI18nextMock({
+ 'deployments.studio.precheck.from': 'From',
+ 'deployments.studio.precheck.nodeCount': '{{count}} nodes',
+ })
+})
+
+const credentialSlot: CredentialSlot = {
+ candidates: [
+ {
+ category: PluginCategory.PLUGIN_CATEGORY_MODEL,
+ credential_id: 'credential-1',
+ display_name: 'Deployment key',
+ from_enterprise: true,
+ provider_id: 'langgenius/deepseek',
+ },
+ ],
+ category: PluginCategory.PLUGIN_CATEGORY_MODEL,
+ provider_id: 'langgenius/deepseek',
+}
+
+function workflowReference(name: string, suffix: string): WorkflowReference {
+ return {
+ app_id: `app-${suffix}`,
+ icon: '🤖',
+ icon_background: '#FFEAD5',
+ icon_type: 'emoji',
+ name,
+ workflow_id: `workflow-${suffix}`,
+ }
+}
+
+function workflowPath(...workflows: WorkflowReference[]): WorkflowPath {
+ return { workflows }
+}
+
+describe('CredentialField', () => {
+ it('opens guidance when no credentials are available', async () => {
+ await renderWithConsoleQueryBrowser(
+ ,
+ )
+
+ const trigger = page.getByRole('combobox', { name: 'Deepseek' })
+ await expect.element(trigger).toBeEnabled()
+
+ await trigger.click()
+
+ const listbox = page.getByRole('listbox')
+ await expect
+ .element(listbox.getByText('deployments.studio.noCredentialsYet'))
+ .toBeInTheDocument()
+ await expect
+ .element(listbox.getByText('deployments.studio.noCredentialsYetDescription'))
+ .toBeInTheDocument()
+ await expect.element(listbox.getByRole('option')).not.toBeInTheDocument()
+ })
+
+ it('shows the only source app name and opens its source preview', async () => {
+ const root = workflowReference('Deployed app', 'root')
+ const source = workflowReference('Order fulfillment', 'order')
+ await renderWithConsoleQueryBrowser(
+ ,
+ )
+
+ const sourceButton = page.getByRole('button', {
+ name: 'Deepseek: From Order fulfillment',
+ })
+ await sourceButton.hover()
+
+ const preview = page.getByRole('dialog', { name: 'Deepseek' })
+ await expect
+ .element(preview.getByRole('link', { name: /Deployed app.*Order fulfillment/ }))
+ .toHaveAttribute('href', '/app/app-order/workflow')
+ })
+
+ it('shows only a count when several source apps use the credential', async () => {
+ await renderWithConsoleQueryBrowser(
+ ,
+ )
+
+ const sourceButton = page.getByRole('button', { name: 'Deepseek: From 2 nodes' })
+ await sourceButton.click()
+
+ const preview = page.getByRole('dialog', { name: 'Deepseek' })
+ await expect.element(preview).toBeVisible()
+ await expect.poll(() => preview.getByRole('link').elements().length).toBe(2)
+ })
+})
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+})
diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx
deleted file mode 100644
index f8e2c563b05..00000000000
--- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import type {
- CredentialSlot,
- WorkflowPath,
- WorkflowReference,
-} from '@dify/contracts/enterprise-app-deploy/types.gen'
-import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen'
-import { screen, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { API_PREFIX } from '@/config'
-import { renderWithConsoleQuery } from '@/test/console/query-data'
-import { CredentialField } from '../credential-field'
-
-vi.mock('react-i18next', async () => {
- const { createReactI18nextMock } = await import('@/test/i18n-mock')
- return createReactI18nextMock({
- 'deployments.studio.precheck.from': 'From',
- 'deployments.studio.precheck.nodeCount': '{{count}} nodes',
- })
-})
-
-const themeState = vi.hoisted(() => ({
- theme: 'light',
-}))
-
-vi.mock('@/hooks/use-theme', () => ({
- default: () => ({ theme: themeState.theme }),
-}))
-
-const credentialSlot: CredentialSlot = {
- candidates: [
- {
- category: PluginCategory.PLUGIN_CATEGORY_MODEL,
- credential_id: 'credential-1',
- display_name: 'Deployment key',
- from_enterprise: true,
- provider_id: 'langgenius/deepseek',
- },
- ],
- category: PluginCategory.PLUGIN_CATEGORY_MODEL,
- icon: 'deepseek-light.svg',
- icon_dark: 'deepseek-dark.svg',
- provider_id: 'langgenius/deepseek',
-}
-
-function workflowReference(name: string, suffix: string): WorkflowReference {
- return {
- app_id: `app-${suffix}`,
- icon: '🤖',
- icon_background: '#FFEAD5',
- icon_type: 'emoji',
- name,
- workflow_id: `workflow-${suffix}`,
- }
-}
-
-function workflowPath(...workflows: WorkflowReference[]): WorkflowPath {
- return { workflows }
-}
-
-function renderCredentialField() {
- return renderWithConsoleQuery(
- ,
- )
-}
-
-function getDecorativeIcon(container: HTMLElement) {
- const icon = container.querySelector('img')
- if (!icon) throw new Error('Expected the credential provider icon.')
-
- return icon
-}
-
-describe('CredentialField', () => {
- beforeEach(() => {
- themeState.theme = 'light'
- })
-
- it('resolves the light credential provider icon through the current workspace', () => {
- const { container } = renderCredentialField()
-
- expect(getDecorativeIcon(container)).toHaveAttribute(
- 'src',
- `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-light.svg`,
- )
- })
-
- it('resolves the dark credential provider icon in dark mode', () => {
- themeState.theme = 'dark'
- const { container } = renderCredentialField()
-
- expect(getDecorativeIcon(container)).toHaveAttribute(
- 'src',
- `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-dark.svg`,
- )
- })
-
- it('opens guidance when no credentials are available', async () => {
- const user = userEvent.setup()
- renderWithConsoleQuery(
- ,
- )
-
- const trigger = screen.getByRole('combobox', { name: 'Deepseek' })
- expect(trigger).toBeEnabled()
-
- await user.click(trigger)
-
- const listbox = await screen.findByRole('listbox')
- expect(within(listbox).getByText('deployments.studio.noCredentialsYet')).toBeInTheDocument()
- expect(
- within(listbox).getByText('deployments.studio.noCredentialsYetDescription'),
- ).toBeInTheDocument()
- expect(within(listbox).queryByRole('option')).not.toBeInTheDocument()
- })
-
- it('shows the only source app name and opens its source preview', async () => {
- const user = userEvent.setup()
- const root = workflowReference('Deployed app', 'root')
- const source = workflowReference('Order fulfillment', 'order')
- renderWithConsoleQuery(
- ,
- )
-
- const sourceButton = screen.getByRole('button', {
- name: 'Deepseek: From Order fulfillment',
- })
- await user.hover(sourceButton)
-
- const preview = await screen.findByRole('dialog', { name: 'Deepseek' })
- expect(
- within(preview).getByRole('link', { name: /Deployed app.*Order fulfillment/ }),
- ).toHaveAttribute('href', '/app/app-order/workflow')
- })
-
- it('shows only a count when several source apps use the credential', async () => {
- const user = userEvent.setup()
- renderWithConsoleQuery(
- ,
- )
-
- const sourceButton = screen.getByRole('button', { name: 'Deepseek: From 2 nodes' })
- expect(sourceButton).not.toHaveTextContent('Order fulfillment')
- await user.click(sourceButton)
-
- const preview = await screen.findByRole('dialog', { name: 'Deepseek' })
- expect(within(preview).getAllByRole('link')).toHaveLength(2)
- })
-})
diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx
similarity index 68%
rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx
rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx
index b0c45aab3d3..806866cf6b5 100644
--- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx
+++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.browser.spec.tsx
@@ -1,5 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { render, screen, waitFor, within } from '@testing-library/react'
+import { useState } from 'react'
+import { page } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
import { DeploymentConfiguration } from '../index'
const APP_ID = 'app-1'
@@ -18,25 +20,44 @@ function createQueryClient() {
})
}
-function renderConfiguration(queryClient: QueryClient) {
+function ConfigurationSession() {
+ const [open, setOpen] = useState(true)
+ return open ? (
+ setOpen(false)}
+ />
+ ) : (
+
+ )
+}
+
+async function renderConfiguration() {
+ const queryClient = createQueryClient()
return render(
-
+
,
)
}
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(() => {
+ vi.restoreAllMocks()
+ })
+})
+
describe('DeploymentConfiguration query freshness', () => {
it('fetches precheck and deployment options again whenever the form is reopened', async () => {
const requestCounts = {
@@ -75,25 +96,19 @@ describe('DeploymentConfiguration query freshness', () => {
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
})
- const queryClient = createQueryClient()
- const firstRender = renderConfiguration(queryClient)
-
- await waitFor(() => {
- expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled()
- })
+ await renderConfiguration()
+ const deploy = page.getByRole('button', { name: 'common.appMenus.deploy' })
+ await expect.element(deploy).toBeEnabled()
expect(requestCounts).toEqual({ deploymentOptions: 1, precheck: 1 })
- firstRender.unmount()
- await waitFor(() => {
- expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
- })
+ await page.getByRole('button', { name: 'common.operation.cancel' }).click()
+ await expect
+ .element(page.getByRole('heading', { name: 'deployments.studio.deployConfiguration' }))
+ .not.toBeInTheDocument()
+ await page.getByRole('button', { name: 'Configure deployment' }).click()
- renderConfiguration(queryClient)
-
- await waitFor(() => {
- expect(requestCounts).toEqual({ deploymentOptions: 2, precheck: 2 })
- expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled()
- })
+ await expect.element(deploy).toBeEnabled()
+ await expect.poll(() => requestCounts).toEqual({ deploymentOptions: 2, precheck: 2 })
})
it('shows the backend message when precheck fails', async () => {
@@ -121,10 +136,11 @@ describe('DeploymentConfiguration query freshness', () => {
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
})
- const view = renderConfiguration(createQueryClient())
+ await renderConfiguration()
- const alert = await within(view.container).findByRole('alert')
- expect(alert).toHaveTextContent('workflow tool dependencies form a cycle')
+ await expect
+ .element(page.getByRole('alert').getByText('workflow tool dependencies form a cycle'))
+ .toBeVisible()
})
it('shows the backend message when deployment options fail', async () => {
@@ -163,9 +179,10 @@ describe('DeploymentConfiguration query freshness', () => {
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
})
- const view = renderConfiguration(createQueryClient())
+ await renderConfiguration()
- const alert = await within(view.container).findByRole('alert')
- expect(alert).toHaveTextContent('workflow deployment options are invalid')
+ await expect
+ .element(page.getByRole('alert').getByText('workflow deployment options are invalid'))
+ .toBeVisible()
})
})
diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx
similarity index 60%
rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx
rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx
index 62d9f0e3929..486f13bc0fd 100644
--- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx
+++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.browser.spec.tsx
@@ -3,8 +3,10 @@ import type {
WorkflowAsToolDependency,
WorkflowReference,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
-import { render, screen, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
+import { page } from 'vite-plus/test/browser'
+import { render as renderBrowser } from 'vitest-browser-react'
+import { createConsoleQueryWrapper } from '@/test/console/query-data'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import { DeploymentPrecheckAlert } from '../deployment-precheck-alert'
vi.mock('react-i18next', async () => {
@@ -19,9 +21,10 @@ vi.mock('react-i18next', async () => {
})
})
-vi.mock('../use-provider-icon', () => ({
- useGetProviderIcon: () => () => undefined,
-}))
+async function render(ui: React.ReactElement) {
+ const { wrapper: Wrapper } = createConsoleQueryWrapper()
+ return renderBrowser({ui})
+}
function workflowReference(name: string, suffix: string): WorkflowReference {
return {
@@ -50,19 +53,18 @@ function unsupportedNode(id: string, owner: Partial = {}): Unsu
}
describe('DeploymentPrecheckAlert', () => {
- it('does not show a source for an unsupported node from the deployed app', () => {
- render()
+ it('does not show a source for an unsupported node from the deployed app', async () => {
+ await render()
- expect(screen.getByText('Slack')).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: /From/ })).not.toBeInTheDocument()
+ await expect.element(page.getByText('Slack')).toBeInTheDocument()
+ await expect.element(page.getByRole('button', { name: /From/ })).not.toBeInTheDocument()
})
it('shows the leaf subworkflow name and its complete dependency path', async () => {
- const user = userEvent.setup()
const root = workflowReference('Deployed app', 'root')
const translation = workflowReference('Translation', 'translation')
const source = workflowReference('Order fulfillment', 'order')
- render(
+ await render(
{
/>,
)
- await user.hover(screen.getByRole('button', { name: 'Slack: From Order fulfillment' }))
+ await page.getByRole('button', { name: 'Slack: From Order fulfillment' }).hover()
- const preview = await screen.findByRole('dialog', { name: 'Slack' })
- const sourceLink = within(preview).getByRole('link', {
+ const preview = page.getByRole('dialog', { name: 'Slack' })
+ const sourceLink = preview.getByRole('link', {
name: /Deployed app.*Translation.*Order fulfillment/,
})
- expect(sourceLink).toHaveAttribute('href', '/app/app-order/workflow')
- expect(sourceLink).toHaveAttribute('target', '_blank')
+ await expect.element(sourceLink).toHaveAttribute('href', '/app/app-order/workflow')
+ await expect.element(sourceLink).toHaveAttribute('target', '_blank')
})
it('includes the deployed app when matching nodes also come from workflow-as-tool paths', async () => {
- const user = userEvent.setup()
const root = workflowReference('Deployed app', 'root')
const orderSource = workflowReference('Order fulfillment', 'order')
const auditSource = workflowReference('Audit workflow', 'audit')
const sharedSource = workflowReference('Shared workflow', 'shared')
const orderPath = [root, orderSource, sharedSource]
const auditPath = [root, auditSource, sharedSource]
- render(
+ await render(
{
/>,
)
- expect(screen.getAllByText('Slack')).toHaveLength(1)
+ await expect.element(page.getByText('Slack', { exact: true })).toBeVisible()
- await user.click(screen.getByRole('button', { name: 'Slack: From 3 nodes' }))
+ await page.getByRole('button', { name: 'Slack: From 3 nodes' }).click()
- const preview = await screen.findByRole('dialog', { name: 'Slack' })
- expect(within(preview).getAllByRole('link')).toHaveLength(3)
- expect(within(preview).getByRole('link', { name: 'Deployed app' })).toHaveAttribute(
- 'href',
- '/app/app-root/workflow',
- )
- expect(
- within(preview).getByRole('link', {
- name: /Deployed app.*Order fulfillment.*Shared workflow/,
- }),
- ).toBeInTheDocument()
- expect(
- within(preview).getByRole('link', {
- name: /Deployed app.*Audit workflow.*Shared workflow/,
- }),
- ).toBeInTheDocument()
+ const preview = page.getByRole('dialog', { name: 'Slack' })
+ await expect.element(preview).toBeVisible()
+ await expect.poll(() => preview.getByRole('link').elements().length).toBe(3)
+ await expect
+ .element(preview.getByRole('link', { name: 'Deployed app' }))
+ .toHaveAttribute('href', '/app/app-root/workflow')
+ await expect
+ .element(
+ preview.getByRole('link', {
+ name: /Deployed app.*Order fulfillment.*Shared workflow/,
+ }),
+ )
+ .toBeInTheDocument()
+ await expect
+ .element(
+ preview.getByRole('link', {
+ name: /Deployed app.*Audit workflow.*Shared workflow/,
+ }),
+ )
+ .toBeInTheDocument()
})
})
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+})
diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx
similarity index 61%
rename from web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx
rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx
index a6cf416710d..67fd8237c90 100644
--- a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx
+++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.browser.spec.tsx
@@ -1,18 +1,19 @@
import type { EnvironmentVariableSlot } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { EnvVarValueSource, EnvVarValueType } from '@dify/contracts/enterprise-app-deploy/types.gen'
-import { screen, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render as renderBrowser } from 'vitest-browser-react'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
-import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
-import { createNuqsTestRenderer } from '@/test/nuqs-testing'
+import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import { EnvironmentVariableField } from '../environment-variable-field'
-const render = createNuqsTestRenderer((ui) => {
+async function render(ui: React.ReactElement) {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
@@ -53,8 +54,13 @@ const render = createNuqsTestRenderer((ui) => {
},
},
)
- return renderWithConsoleQuery(ui, { queryClient })
-})
+ const { wrapper: Wrapper } = createConsoleQueryWrapper({ queryClient })
+ return renderBrowser(
+
+ {ui}
+ ,
+ )
+}
const llmSlot: EnvironmentVariableSlot = {
configured_value: {
@@ -76,9 +82,8 @@ describe('EnvironmentVariableField', () => {
await import('@/app/components/workflow/llm-environment-variable-value-field')
})
it('selects a compatible custom LLM and keeps its parameters structured', async () => {
- const user = userEvent.setup()
const onChange = vi.fn()
- render(
+ await render(
{
/>,
)
- const modelSelector = await screen.findByRole('button', { name: /^Chat model/ })
- expect(modelSelector).toBeDisabled()
- expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument()
+ const modelSelector = page.getByRole('button', { name: /^Chat model/ })
+ await expect.element(modelSelector).toBeDisabled()
+ await expect.element(page.getByText('workflow.blocks.llm')).toBeInTheDocument()
- await user.click(screen.getByRole('combobox', { name: /MODEL/ }))
- await user.click(
- await screen.findByRole('option', {
+ await page.getByRole('combobox', { name: /MODEL/ }).click()
+ await page
+ .getByRole('option', {
name: 'deployments.deployDrawer.envVarSource.literal',
- }),
- )
+ })
+ .click()
- await user.click(screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' }))
- const picker = within(
- await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
- )
- expect(picker.queryByRole('button', { name: /^Completion model/ })).not.toBeInTheDocument()
- await user.click(picker.getByRole('button', { name: /^Chat model v2/ }))
- await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
- await user.click(screen.getByRole('switch', { name: 'Temperature' }))
- const temperature = screen.getByRole('spinbutton', { name: 'Temperature' })
- await user.clear(temperature)
- await user.type(temperature, '0.4')
- await user.tab()
+ await page.getByRole('button', { name: 'plugin.detailPanel.configureModel' }).click()
+ const picker = page.getByRole('dialog', { name: 'plugin.detailPanel.configureModel' })
+ await expect
+ .element(picker.getByRole('button', { name: /^Completion model/ }))
+ .not.toBeInTheDocument()
+ await picker.getByRole('button', { name: /^Chat model v2/ }).click()
+ await page.getByRole('button', { name: /modelProvider.modelSettings/ }).click()
+ await page.getByRole('switch', { name: 'Temperature' }).click()
+ const temperature = page.getByRole('spinbutton', { name: 'Temperature' })
+ await temperature.clear()
+ await userEvent.type(temperature, '0.4')
+ await userEvent.tab()
expect(onChange).toHaveBeenLastCalledWith('workflow-1', 'MODEL', {
customValue: {
@@ -123,7 +128,7 @@ describe('EnvironmentVariableField', () => {
})
it('shows the last deployed LLM as a read-only source', async () => {
- render(
+ await render(
{
/>,
)
- const modelSelector = await screen.findByRole('button', { name: /^Chat model/ })
- expect(modelSelector).toBeDisabled()
- expect(modelSelector).toHaveTextContent('Chat model v2')
- expect(screen.getByRole('combobox', { name: /MODEL/ })).toHaveTextContent(
- 'deployments.deployDrawer.envVarSource.lastDeployment',
- )
+ const modelSelector = page.getByRole('button', { name: /^Chat model/ })
+ await expect.element(modelSelector).toBeDisabled()
+ await expect.element(modelSelector.getByText('Chat model v2', { exact: true })).toBeVisible()
+ await expect
+ .element(page.getByRole('combobox', { name: /MODEL/ }))
+ .toHaveTextContent('deployments.deployDrawer.envVarSource.lastDeployment')
})
})
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+})
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx
new file mode 100644
index 00000000000..42a352afa59
--- /dev/null
+++ b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.browser.spec.tsx
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
+import KnowledgeSpaceList from '../knowledge-space-list'
+
+const spaces = [
+ { control_space_id: 'space-1', name: 'Handbook' },
+ { control_space_id: 'space-2', name: 'Policies' },
+]
+
+function Selection({ initialSpaces = spaces }: { initialSpaces?: typeof spaces }) {
+ const [list, setList] = useState(initialSpaces)
+ return
+}
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+})
+
+it('reveals the focused removal action and removes the space using the keyboard', async () => {
+ await render(
+ <>
+
+
+
+ >,
+ )
+ await page.getByRole('button', { name: 'before' }).click()
+ await userEvent.tab()
+ const remove = page.getByRole('button', { name: 'common.operation.remove' })
+ await expect.element(remove).toHaveFocus()
+ // toBeVisible alone ignores ancestor opacity; this proves the focused action is painted.
+ await expect.element(remove).toBeInViewport()
+ await expect.poll(() => remove.element().checkVisibility({ checkOpacity: true })).toBe(true)
+ await userEvent.keyboard('{Enter}')
+ await expect.element(page.getByText('Handbook')).not.toBeInTheDocument()
+ await expect
+ .element(page.getByText('workflow.nodes.knowledgeRetrievalV2.knowledgeTip'))
+ .toBeVisible()
+})
+
+it('removes only the chosen space through its revealed action', async () => {
+ await render()
+ await page.getByText('Policies').hover()
+ await page.getByRole('button', { name: 'common.operation.remove' }).nth(1).click()
+ await expect.element(page.getByText('Policies')).not.toBeInTheDocument()
+ await expect.element(page.getByText('Handbook')).toBeVisible()
+})
+
+it('hides removal actions when readonly', async () => {
+ await render()
+ await expect.element(page.getByText('Handbook')).toBeVisible()
+ await expect
+ .element(page.getByRole('button', { name: 'common.operation.remove' }))
+ .not.toBeInTheDocument()
+})
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx
deleted file mode 100644
index 27c7fc2a27a..00000000000
--- a/web/app/components/workflow/nodes/knowledge-retrieval-v2/components/__tests__/knowledge-space-list.spec.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import KnowledgeSpaceList from '../knowledge-space-list'
-
-const spaces = [
- { control_space_id: 'space-1', name: 'Handbook' },
- { control_space_id: 'space-2', name: 'Policies' },
-]
-
-describe('KnowledgeSpaceList', () => {
- it('keeps the removal action reachable and operable by keyboard', async () => {
- const user = userEvent.setup()
- const onChange = vi.fn()
- render(
- <>
-
-
-
- >,
- )
-
- await user.click(screen.getByRole('button', { name: 'before' }))
- await user.tab()
-
- const removeButton = screen.getByRole('button', { name: 'common.operation.remove' })
- expect(removeButton).toHaveFocus()
- // The action stays rendered (revealed with opacity) instead of display:none until hover.
- expect(removeButton.parentElement).not.toHaveClass('hidden')
- expect(removeButton.parentElement).toHaveClass('focus-within:opacity-100')
-
- await user.keyboard('{Enter}')
- expect(onChange).toHaveBeenCalledWith([])
- })
-
- it('removes only the chosen space', async () => {
- const user = userEvent.setup()
- const onChange = vi.fn()
- render()
-
- await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!)
-
- expect(onChange).toHaveBeenCalledWith([spaces[0]])
- })
-
- it('hides the removal action when readonly', () => {
- render()
-
- expect(
- screen.queryByRole('button', { name: 'common.operation.remove' }),
- ).not.toBeInTheDocument()
- })
-})
diff --git a/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx b/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx
new file mode 100644
index 00000000000..146f211ac1e
--- /dev/null
+++ b/web/features/human-input/form/__tests__/loaded-form-content.browser.spec.tsx
@@ -0,0 +1,250 @@
+import type { HumanInputFormDefinition, LegacyHumanInputFormData } from '../types'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
+import { InputVarType } from '@/app/components/workflow/types'
+import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
+import LoadedFormContent from '../loaded-form-content'
+import { normalizeLegacyHumanInputForm } from '../normalize-legacy-definition'
+
+// Load the real code-split renderer before assertions start their interaction deadlines.
+beforeAll(async () => {
+ await import('@/app/components/base/markdown/streamdown-wrapper')
+})
+
+describe('LoadedFormContent', () => {
+ const inputs: HumanInputFormDefinition['inputs'] = [
+ {
+ type: InputVarType.paragraph,
+ output_variable_name: 'response',
+ default: {
+ type: 'constant',
+ value: 'initial response',
+ selector: [],
+ },
+ },
+ ]
+ const actions: HumanInputFormDefinition['actions'] = [
+ {
+ id: 'approve',
+ title: 'Approve',
+ button_style: UserActionButtonType.Primary,
+ },
+ ]
+ const legacyData: LegacyHumanInputFormData = {
+ site: {
+ site: {
+ title: 'Legacy app',
+ icon_type: 'emoji',
+ icon: 'L',
+ icon_background: '#fff',
+ icon_url: '',
+ },
+ },
+ form_content: '{{#$output.response#}}',
+ inputs,
+ resolved_default_values: {},
+ user_actions: actions,
+ expiration_time: 1_900_000_000,
+ }
+ const v2Definition: HumanInputFormDefinition = {
+ formContent: '{{#$output.response#}}',
+ inputs,
+ resolvedDefaultValues: {},
+ actions,
+ expirationTime: 1_900_000_000,
+ }
+
+ it.each([
+ ['legacy', normalizeLegacyHumanInputForm(legacyData)],
+ ['v2', v2Definition],
+ ] as const)(
+ 'renders and processes the version-neutral %s definition',
+ async (_version, definition) => {
+ const onSubmit = vi.fn()
+
+ await render(
+ ,
+ )
+ const response = page.getByRole('textbox', { name: 'response' })
+ await expect.element(response).toHaveValue('initial response')
+ await response.clear()
+ await response.fill('updated response')
+ await page.getByRole('button', { name: 'Approve' }).click()
+
+ expect(onSubmit).toHaveBeenCalledWith({ response: 'updated response' }, 'approve')
+ await expect.element(page.getByText('share.chat.poweredBy')).toBeInTheDocument()
+ },
+ )
+
+ it('blocks repeated submissions until the pending action resolves', async () => {
+ let finishSubmission: (() => void) | undefined
+ const submission = new Promise((resolve) => {
+ finishSubmission = resolve
+ })
+ const onSubmit = vi.fn().mockReturnValue(submission)
+
+ await render(
+ ,
+ )
+ const approve = page.getByRole('button', { name: 'Approve' })
+ await approve.click()
+
+ await expect.element(approve).toHaveAttribute('aria-disabled', 'true')
+ await expect.element(approve).toHaveFocus()
+ await userEvent.keyboard('{Enter}')
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+
+ finishSubmission?.()
+ await submission
+ await expect.element(approve).toBeEnabled()
+ })
+
+ it('requires nonblank text and a selected option before submitting their values', async () => {
+ const onSubmit = vi.fn()
+ const definition: HumanInputFormDefinition = {
+ ...v2Definition,
+ formContent: '{{#$output.response#}}\n\n{{#$output.reviewer#}}',
+ resolvedDefaultValues: { response: ' ' },
+ inputs: [
+ {
+ type: InputVarType.paragraph,
+ output_variable_name: 'response',
+ default: { type: 'constant', value: ' ', selector: [] },
+ },
+ {
+ type: InputVarType.select,
+ output_variable_name: 'reviewer',
+ option_source: { type: 'constant', selector: [], value: ['Alice', 'Bob'] },
+ },
+ ],
+ }
+
+ await render(
+ ,
+ )
+ const approve = page.getByRole('button', { name: 'Approve' })
+ await expect.element(approve).toBeEnabled()
+ await approve.click()
+ expect(onSubmit).not.toHaveBeenCalled()
+ await expect.element(page.getByText(/fieldRequired/).nth(1)).toBeVisible()
+
+ const response = page.getByRole('textbox', { name: 'response' })
+ await response.clear()
+ await response.fill('Ready for review')
+ await expect.element(approve).toBeEnabled()
+
+ await page.getByRole('combobox', { name: 'reviewer' }).click()
+ await page.getByRole('option', { name: 'Bob' }).click()
+ await expect.element(approve).toBeEnabled()
+ await approve.click()
+ expect(onSubmit).toHaveBeenCalledWith(
+ { response: 'Ready for review', reviewer: 'Bob' },
+ 'approve',
+ )
+ })
+
+ it('retains entered values and exposes a safe error when workflow submission rejects', async () => {
+ const onSubmit = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('internal transport details'))
+ .mockResolvedValue(undefined)
+ await render(
+ ,
+ )
+ const response = page.getByRole('textbox', { name: 'response' })
+ await response.clear()
+ await response.fill('Keep this draft')
+ await page.getByRole('button', { name: 'Approve' }).click()
+ await expect.element(page.getByRole('alert')).toHaveTextContent('common.api.actionFailed')
+ await expect.element(page.getByText('internal transport details')).not.toBeInTheDocument()
+ await expect.element(response).toHaveValue('Keep this draft')
+ await page.getByRole('button', { name: 'Approve' }).click()
+ expect(onSubmit).toHaveBeenLastCalledWith({ response: 'Keep this draft' }, 'approve')
+ })
+
+ it('gives repeated placeholders distinct label associations while retaining a shared wire value', async () => {
+ const onSubmit = vi.fn()
+ await render(
+ ,
+ )
+ const first = page.getByRole('textbox', { name: 'First review' })
+ const second = page.getByRole('textbox', { name: 'Second review' })
+ await first.click()
+ await userEvent.tab()
+ await expect.element(second).toHaveFocus()
+ await second.clear()
+ await second.fill('One shared response')
+ await expect.element(first).toHaveValue('One shared response')
+ await page.getByRole('button', { name: 'Approve' }).click()
+ expect(onSubmit).toHaveBeenCalledWith({ response: 'One shared response' }, 'approve')
+ })
+
+ it('preserves Markdown content alongside the editable fields', async () => {
+ const definition: HumanInputFormDefinition = {
+ ...v2Definition,
+ formContent: [
+ '# Review request',
+ 'Read the **summary** and [reference](https://example.com/reference). Use `status`.',
+ '3. First review\n4. Final review',
+ '| Field | Value |\n| --- | --- |\n| Owner | Alice |',
+ '',
+ '{{#$output.response#}}',
+ ].join('\n\n'),
+ }
+
+ await render(
+ ,
+ )
+
+ await expect
+ .element(page.getByRole('heading', { level: 1, name: 'Review request' }))
+ .toBeInTheDocument()
+ await expect
+ .element(page.getByRole('link', { name: 'reference' }))
+ .toHaveAttribute('href', 'https://example.com/reference')
+ await expect.element(page.getByText('summary')).toBeVisible()
+ await expect.element(page.getByText('status')).toBeVisible()
+ const list = page.getByRole('list')
+ await expect.element(list).toHaveAttribute('start', '3')
+ await expect.element(list.getByRole('listitem').nth(1)).toHaveTextContent('Final review')
+ await expect
+ .element(page.getByRole('table').getByRole('cell', { name: 'Alice' }))
+ .toBeInTheDocument()
+ await expect
+ .element(page.getByRole('img', { name: 'Seasonal landscape' }))
+ .toHaveAttribute('src', '/logo/logo-site.png')
+ await expect
+ .element(page.getByRole('textbox', { name: 'response' }))
+ .toHaveValue('initial response')
+ })
+
+ it('updates optional branding when the definition changes', async () => {
+ const view = await render(
+ ,
+ )
+ await expect.element(page.getByRole('heading', { name: 'Legacy app' })).toBeInTheDocument()
+ await view.rerender(
+ ,
+ )
+ await expect.element(page.getByRole('heading', { name: 'Legacy app' })).not.toBeInTheDocument()
+ await expect.element(page.getByRole('img', { name: 'Dify', exact: true })).toBeInTheDocument()
+ })
+})
+
+beforeEach(async () => {
+ await page.viewport(1000, 800)
+ onTestFinished(guardUnexpectedFetch())
+})
diff --git a/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx b/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx
deleted file mode 100644
index dfbb2000f56..00000000000
--- a/web/features/human-input/form/__tests__/loaded-form-content.spec.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-import type { HumanInputFormDefinition, LegacyHumanInputFormData } from '../types'
-import { act, render, screen, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
-import { InputVarType } from '@/app/components/workflow/types'
-import LoadedFormContent from '../loaded-form-content'
-import { normalizeLegacyHumanInputForm } from '../normalize-legacy-definition'
-
-// Load the real code-split renderer before assertions start their interaction deadlines.
-beforeAll(async () => {
- await import('@/app/components/base/markdown/streamdown-wrapper')
-})
-
-vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
- default: ({ expirationTime }: { expirationTime: number }) => (
- expiration:{expirationTime}
- ),
-}))
-
-vi.mock('@/app/components/base/app-icon', () => ({
- default: () => application icon
,
-}))
-
-vi.mock('@/app/components/base/logo/dify-logo', () => ({
- DifyLogo: () => dify logo
,
-}))
-
-describe('LoadedFormContent', () => {
- const inputs: HumanInputFormDefinition['inputs'] = [
- {
- type: InputVarType.paragraph,
- output_variable_name: 'response',
- default: {
- type: 'constant',
- value: 'initial response',
- selector: [],
- },
- },
- ]
- const actions: HumanInputFormDefinition['actions'] = [
- {
- id: 'approve',
- title: 'Approve',
- button_style: UserActionButtonType.Primary,
- },
- ]
- const legacyData: LegacyHumanInputFormData = {
- site: {
- site: {
- title: 'Legacy app',
- icon_type: 'emoji',
- icon: 'L',
- icon_background: '#fff',
- icon_url: '',
- },
- },
- form_content: '{{#$output.response#}}',
- inputs,
- resolved_default_values: {},
- user_actions: actions,
- expiration_time: 60,
- }
- const v2Definition: HumanInputFormDefinition = {
- formContent: '{{#$output.response#}}',
- inputs,
- resolvedDefaultValues: {},
- actions,
- expirationTime: 60,
- }
-
- it.each([
- ['legacy', normalizeLegacyHumanInputForm(legacyData)],
- ['v2', v2Definition],
- ] as const)(
- 'renders and processes the version-neutral %s definition',
- async (_version, definition) => {
- const user = userEvent.setup()
- const onSubmit = vi.fn()
-
- render()
- const response = screen.getByRole('textbox', { name: 'response' })
- expect(response).toHaveValue('initial response')
- await user.clear(response)
- await user.type(response, 'updated response')
- await user.click(screen.getByRole('button', { name: 'Approve' }))
-
- expect(onSubmit).toHaveBeenCalledWith({ response: 'updated response' }, 'approve')
- expect(screen.getByText('expiration:60000')).toBeInTheDocument()
- expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
- },
- )
-
- it('blocks repeated submissions until the pending action resolves', async () => {
- const user = userEvent.setup()
- let finishSubmission: (() => void) | undefined
- const submission = new Promise((resolve) => {
- finishSubmission = resolve
- })
- const onSubmit = vi.fn().mockReturnValue(submission)
-
- render()
- const approve = screen.getByRole('button', { name: 'Approve' })
- await user.click(approve)
-
- expect(approve).toHaveAttribute('aria-disabled', 'true')
- await user.click(approve)
- expect(onSubmit).toHaveBeenCalledTimes(1)
-
- await act(async () => {
- finishSubmission?.()
- await submission
- })
- expect(approve).toBeEnabled()
- })
-
- it('requires nonblank text and a selected option before submitting their values', async () => {
- const user = userEvent.setup()
- const onSubmit = vi.fn()
- const definition: HumanInputFormDefinition = {
- ...v2Definition,
- formContent: '{{#$output.response#}}\n\n{{#$output.reviewer#}}',
- resolvedDefaultValues: { response: ' ' },
- inputs: [
- {
- type: InputVarType.paragraph,
- output_variable_name: 'response',
- default: { type: 'constant', value: ' ', selector: [] },
- },
- {
- type: InputVarType.select,
- output_variable_name: 'reviewer',
- option_source: { type: 'constant', selector: [], value: ['Alice', 'Bob'] },
- },
- ],
- }
-
- render()
- const approve = screen.getByRole('button', { name: 'Approve' })
- expect(approve).toBeEnabled()
- await user.click(approve)
- expect(onSubmit).not.toHaveBeenCalled()
- expect(screen.getAllByText(/fieldRequired/)).toHaveLength(2)
-
- const response = screen.getByRole('textbox', { name: 'response' })
- await user.clear(response)
- await user.type(response, 'Ready for review')
- expect(approve).toBeEnabled()
-
- await user.click(screen.getByRole('combobox', { name: 'reviewer' }))
- await user.click(await screen.findByRole('option', { name: 'Bob' }))
- expect(approve).toBeEnabled()
- await user.click(approve)
- expect(onSubmit).toHaveBeenCalledWith(
- { response: 'Ready for review', reviewer: 'Bob' },
- 'approve',
- )
- })
-
- it('retains entered values and exposes a safe error when workflow submission rejects', async () => {
- const user = userEvent.setup()
- const onSubmit = vi
- .fn()
- .mockRejectedValueOnce(new Error('internal transport details'))
- .mockResolvedValue(undefined)
- render()
- const response = screen.getByRole('textbox', { name: 'response' })
- await user.clear(response)
- await user.type(response, 'Keep this draft')
- await user.click(screen.getByRole('button', { name: 'Approve' }))
- expect(await screen.findByRole('alert')).toHaveTextContent('common.api.actionFailed')
- expect(screen.queryByText('internal transport details')).not.toBeInTheDocument()
- expect(response).toHaveValue('Keep this draft')
- await user.click(screen.getByRole('button', { name: 'Approve' }))
- expect(onSubmit).toHaveBeenLastCalledWith({ response: 'Keep this draft' }, 'approve')
- })
-
- it('gives repeated placeholders distinct label associations while retaining a shared wire value', async () => {
- const user = userEvent.setup()
- const onSubmit = vi.fn()
- render(
- ,
- )
- const first = screen.getByRole('textbox', { name: 'First review' })
- const second = screen.getByRole('textbox', { name: 'Second review' })
- const firstLabelId = first.getAttribute('aria-labelledby')!
- const secondLabelId = second.getAttribute('aria-labelledby')!
- expect(firstLabelId).not.toBe(secondLabelId)
- expect(document.querySelectorAll('[id]')).toHaveLength(
- new Set([...document.querySelectorAll('[id]')].map((element) => element.id)).size,
- )
- expect(screen.getByText('Second review', { selector: 'label' })).toHaveClass('sr-only')
- await user.click(screen.getByText('Second review', { selector: 'label' }))
- expect(second).toHaveFocus()
- await user.clear(second)
- await user.type(second, 'One shared response')
- expect(first).toHaveValue('One shared response')
- await user.click(screen.getByRole('button', { name: 'Approve' }))
- expect(onSubmit).toHaveBeenCalledWith({ response: 'One shared response' }, 'approve')
- })
-
- it('preserves Markdown content alongside the editable fields', async () => {
- const definition: HumanInputFormDefinition = {
- ...v2Definition,
- formContent: [
- '# Review request',
- 'Read the **summary** and [reference](https://example.com/reference). Use `status`.',
- '3. First review\n4. Final review',
- '| Field | Value |\n| --- | --- |\n| Owner | Alice |',
- '',
- '{{#$output.response#}}',
- ].join('\n\n'),
- }
-
- render()
-
- expect(
- await screen.findByRole('heading', { level: 1, name: 'Review request' }),
- ).toBeInTheDocument()
- expect(screen.getByRole('link', { name: 'reference' })).toHaveAttribute(
- 'href',
- 'https://example.com/reference',
- )
- expect(screen.getByText('summary')).toBeVisible()
- expect(await screen.findByText('status')).toBeVisible()
- const list = screen.getByRole('list')
- expect(list).toHaveAttribute('start', '3')
- expect(within(list).getAllByRole('listitem')).toHaveLength(2)
- expect(
- within(screen.getByRole('table')).getByRole('cell', { name: 'Alice' }),
- ).toBeInTheDocument()
- expect(screen.getByRole('img', { name: 'Seasonal landscape' })).toHaveAttribute(
- 'src',
- 'https://example.com/landscape.png',
- )
- expect(screen.getByRole('textbox', { name: 'response' })).toHaveValue('initial response')
- })
-
- it('renders optional branding without synthesizing it for v2', () => {
- const { unmount } = render(
- ,
- )
- expect(screen.getByText('Legacy app')).toBeInTheDocument()
- expect(screen.getByText('application icon')).toBeInTheDocument()
- unmount()
-
- render()
- expect(screen.queryByText('Legacy app')).not.toBeInTheDocument()
- expect(screen.queryByText('application icon')).not.toBeInTheDocument()
- expect(screen.getByText('dify logo')).toBeInTheDocument()
- })
-})