test(web): migrate form sessions and deployment controls to Browser Mode (#45477)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-04 01:53:56 +00:00
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' })
})
})
@@ -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())
})
@@ -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)
})
})
@@ -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()
})
})
@@ -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())
})
@@ -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())
})
@@ -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()
})
@@ -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 |',
'![Seasonal landscape](/logo/logo-site.png)',
'{{#$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 |',
'![Seasonal landscape](https://example.com/landscape.png)',
'{{#$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()
})
})