mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
test(web): migrate form sessions and deployment controls to Browser Mode (#45477)
This commit is contained in:
1 parent
6e85fa171b
commit
fcefe55c43
11 files changed
+680
-643
No files matched your search
@@ -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(
|
||||
<Wrapper>
|
||||
<RouteTokenContext value="v2-token">
|
||||
<FormContent />
|
||||
</RouteTokenContext>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<RouteTokenContext value="v2-token">
|
||||
<FormContent />
|
||||
</RouteTokenContext>
|
||||
</Wrapper>,
|
||||
)
|
||||
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(
|
||||
<Wrapper>
|
||||
<RouteTokenContext value="next-token">
|
||||
<FormContent />
|
||||
</RouteTokenContext>
|
||||
</Wrapper>,
|
||||
)
|
||||
|
||||
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('')
|
||||
})
|
||||
@@ -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 }) => <div>v2-form:{token}</div>),
|
||||
)
|
||||
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(<FormContent />)
|
||||
|
||||
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(<FormContent />)
|
||||
unmount()
|
||||
mockRoute.token = 'next-token'
|
||||
render(<FormContent />)
|
||||
|
||||
expect(screen.getByText('v2-form:next-token')).toBeInTheDocument()
|
||||
expect(mockHumanInputV2Form).toHaveBeenLastCalledWith({ token: 'next-token' })
|
||||
})
|
||||
})
|
||||
+134
@@ -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(<Wrapper>{ui}</Wrapper>)
|
||||
}
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={{ ...credentialSlot, candidates: [] }}
|
||||
defaultValue={undefined}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={credentialSlot}
|
||||
paths={[workflowPath(root, source)]}
|
||||
defaultValue="credential-1"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={credentialSlot}
|
||||
paths={[
|
||||
workflowPath(
|
||||
workflowReference('Deployed app', 'root'),
|
||||
workflowReference('Order fulfillment', 'order'),
|
||||
),
|
||||
workflowPath(
|
||||
workflowReference('Deployed app', 'root'),
|
||||
workflowReference('Audit workflow', 'audit'),
|
||||
),
|
||||
]}
|
||||
defaultValue="credential-1"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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())
|
||||
})
|
||||
-171
@@ -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(
|
||||
<CredentialField slot={credentialSlot} defaultValue="credential-1" onChange={vi.fn()} />,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={{ ...credentialSlot, candidates: [] }}
|
||||
defaultValue={undefined}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={credentialSlot}
|
||||
paths={[workflowPath(root, source)]}
|
||||
defaultValue="credential-1"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<CredentialField
|
||||
slot={credentialSlot}
|
||||
paths={[
|
||||
workflowPath(
|
||||
workflowReference('Deployed app', 'root'),
|
||||
workflowReference('Order fulfillment', 'order'),
|
||||
),
|
||||
workflowPath(
|
||||
workflowReference('Deployed app', 'root'),
|
||||
workflowReference('Audit workflow', 'audit'),
|
||||
),
|
||||
]}
|
||||
defaultValue="credential-1"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
+53
-36
@@ -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 ? (
|
||||
<DeploymentConfiguration
|
||||
appId={APP_ID}
|
||||
embedded
|
||||
request={{
|
||||
environment: 'Staging',
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
initialVersion: { id: WORKFLOW_ID, name: 'Release 1' },
|
||||
kind: 'deployLatest',
|
||||
}}
|
||||
version={{ id: WORKFLOW_ID, name: 'Release 1' }}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Configure deployment
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderConfiguration() {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DeploymentConfiguration
|
||||
appId={APP_ID}
|
||||
embedded
|
||||
request={{
|
||||
environment: 'Staging',
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
initialVersion: { id: WORKFLOW_ID, name: 'Release 1' },
|
||||
kind: 'deployLatest',
|
||||
}}
|
||||
version={{ id: WORKFLOW_ID, name: 'Release 1' }}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
<ConfigurationSession />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
+46
-36
@@ -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(<Wrapper>{ui}</Wrapper>)
|
||||
}
|
||||
|
||||
function workflowReference(name: string, suffix: string): WorkflowReference {
|
||||
return {
|
||||
@@ -50,19 +53,18 @@ function unsupportedNode(id: string, owner: Partial<UnsupportedNode> = {}): Unsu
|
||||
}
|
||||
|
||||
describe('DeploymentPrecheckAlert', () => {
|
||||
it('does not show a source for an unsupported node from the deployed app', () => {
|
||||
render(<DeploymentPrecheckAlert nodes={[unsupportedNode('root-node')]} />)
|
||||
it('does not show a source for an unsupported node from the deployed app', async () => {
|
||||
await render(<DeploymentPrecheckAlert nodes={[unsupportedNode('root-node')]} />)
|
||||
|
||||
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(
|
||||
<DeploymentPrecheckAlert
|
||||
nodes={[
|
||||
unsupportedNode('child-node', {
|
||||
@@ -72,25 +74,24 @@ describe('DeploymentPrecheckAlert', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<DeploymentPrecheckAlert
|
||||
nodes={[
|
||||
unsupportedNode('root-node'),
|
||||
@@ -107,25 +108,34 @@ describe('DeploymentPrecheckAlert', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
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())
|
||||
})
|
||||
+46
-36
@@ -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(
|
||||
<NuqsTestingAdapter>
|
||||
<Wrapper>{ui}</Wrapper>
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<EnvironmentVariableField
|
||||
slot={llmSlot}
|
||||
workflowId="workflow-1"
|
||||
@@ -87,29 +92,29 @@ describe('EnvironmentVariableField', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<EnvironmentVariableField
|
||||
slot={{
|
||||
...llmSlot,
|
||||
@@ -143,11 +148,16 @@ describe('EnvironmentVariableField', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
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())
|
||||
})
|
||||
+58
@@ -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 <KnowledgeSpaceList list={list} onChange={setList} />
|
||||
}
|
||||
|
||||
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(
|
||||
<>
|
||||
<button type="button">before</button>
|
||||
<Selection initialSpaces={spaces.slice(0, 1)} />
|
||||
<button type="button">after</button>
|
||||
</>,
|
||||
)
|
||||
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(<Selection />)
|
||||
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(<KnowledgeSpaceList list={spaces} onChange={vi.fn()} readonly />)
|
||||
await expect.element(page.getByText('Handbook')).toBeVisible()
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'common.operation.remove' }))
|
||||
.not.toBeInTheDocument()
|
||||
})
|
||||
-52
@@ -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(
|
||||
<>
|
||||
<button type="button">before</button>
|
||||
<KnowledgeSpaceList list={spaces.slice(0, 1)} onChange={onChange} />
|
||||
<button type="button">after</button>
|
||||
</>,
|
||||
)
|
||||
|
||||
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(<KnowledgeSpaceList list={spaces} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([spaces[0]])
|
||||
})
|
||||
|
||||
it('hides the removal action when readonly', () => {
|
||||
render(<KnowledgeSpaceList list={spaces} onChange={vi.fn()} readonly />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.remove' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={onSubmit} />,
|
||||
)
|
||||
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<void>((resolve) => {
|
||||
finishSubmission = resolve
|
||||
})
|
||||
const onSubmit = vi.fn().mockReturnValue(submission)
|
||||
|
||||
await render(
|
||||
<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={onSubmit} />,
|
||||
)
|
||||
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(
|
||||
<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={onSubmit} />,
|
||||
)
|
||||
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(
|
||||
<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={onSubmit} />,
|
||||
)
|
||||
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(
|
||||
<LoadedFormContent
|
||||
definition={{
|
||||
...v2Definition,
|
||||
formContent:
|
||||
'First review\n\n{{#$output.response#}}\n\nSecond review\n\n{{#$output.response#}}',
|
||||
}}
|
||||
isSubmitting={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={vi.fn()} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<LoadedFormContent
|
||||
definition={normalizeLegacyHumanInputForm(legacyData)}
|
||||
isSubmitting={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await expect.element(page.getByRole('heading', { name: 'Legacy app' })).toBeInTheDocument()
|
||||
await view.rerender(
|
||||
<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={vi.fn()} />,
|
||||
)
|
||||
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())
|
||||
})
|
||||
@@ -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 }) => (
|
||||
<div>expiration:{expirationTime}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <div>application icon</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/logo/dify-logo', () => ({
|
||||
DifyLogo: () => <div>dify logo</div>,
|
||||
}))
|
||||
|
||||
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(<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={onSubmit} />)
|
||||
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<void>((resolve) => {
|
||||
finishSubmission = resolve
|
||||
})
|
||||
const onSubmit = vi.fn().mockReturnValue(submission)
|
||||
|
||||
render(<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={onSubmit} />)
|
||||
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(<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={onSubmit} />)
|
||||
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(<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={onSubmit} />)
|
||||
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(
|
||||
<LoadedFormContent
|
||||
definition={{
|
||||
...v2Definition,
|
||||
formContent:
|
||||
'First review\n\n{{#$output.response#}}\n\nSecond review\n\n{{#$output.response#}}',
|
||||
}}
|
||||
isSubmitting={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
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(<LoadedFormContent definition={definition} isSubmitting={false} onSubmit={vi.fn()} />)
|
||||
|
||||
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(
|
||||
<LoadedFormContent
|
||||
definition={normalizeLegacyHumanInputForm(legacyData)}
|
||||
isSubmitting={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Legacy app')).toBeInTheDocument()
|
||||
expect(screen.getByText('application icon')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<LoadedFormContent definition={v2Definition} isSubmitting={false} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Legacy app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('application icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('dify logo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user