test(web): replace fabricated form, model and feedback controls (#45220)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-01 03:23:39 +00:00
1 parent 5336037aa9
commit b89f43a66a
4 files changed
+180 -207

No files matched your search

@@ -3,56 +3,26 @@ import type {
DatasourceProviderAuthResponse,
ProviderConfig,
} from '@dify/contracts/api/console/auth/types.gen'
import type {
AddApiKeyButtonProps,
AddOAuthButtonProps,
PluginPayload,
} from '@/app/components/plugins/plugin-auth/types'
import { render, screen } from '@testing-library/react'
import type { PluginPayload } from '@/app/components/plugins/plugin-auth/types'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import Configure from '../configure'
// Mock plugin auth components to isolate the unit test for Configure.
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AddApiKeyButton: vi.fn(
({ onUpdate, disabled, buttonText }: AddApiKeyButtonProps & { onUpdate: () => void }) => (
<button onClick={onUpdate} disabled={disabled}>
{buttonText}
</button>
),
),
AddOAuthButton: vi.fn(
({ onUpdate, disabled, buttonText, renderTrigger }: AddOAuthButtonProps) => {
const [permissionOpen, setPermissionOpen] = React.useState(false)
const handleSaveAndAuthorize = () => {
onUpdate?.()
setPermissionOpen(true)
}
const trigger = (
<button onClick={handleSaveAndAuthorize} disabled={disabled}>
{buttonText}
</button>
)
return (
<>
{renderTrigger
? renderTrigger({
disabled,
isConfigured: false,
onClick: handleSaveAndAuthorize,
trigger,
})
: trigger}
{permissionOpen && <div role="dialog" aria-label="Who can use" />}
</>
)
},
),
}))
const service = vi.hoisted(() => ({ addCredential: vi.fn(), saveOAuthClient: vi.fn() }))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'auth.plugin.datasource.byProviderId.post': service.addCredential,
'auth.plugin.datasource.byProviderId.customClient.post': service.saveOAuthClient,
}),
}
})
describe('Configure Component', () => {
const mockOnUpdate = vi.fn()
@@ -95,7 +65,8 @@ describe('Configure Component', () => {
}
beforeEach(() => {
vi.clearAllMocks()
service.addCredential.mockResolvedValue({ result: 'success' })
service.saveOAuthClient.mockResolvedValue({ result: 'success' })
})
describe('Open State Management', () => {
@@ -133,7 +104,9 @@ describe('Configure Component', () => {
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.getByRole('button', { name: 'plugin.auth.addApi' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin.auth.addOAuth' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.auth.setupOAuth' }),
).not.toBeInTheDocument()
})
it('should render AddOAuthButton when oauth_schema with client_schema is non-empty', async () => {
@@ -148,7 +121,7 @@ describe('Configure Component', () => {
render(<Configure item={itemWithOAuth} pluginPayload={mockPluginPayload} />)
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.getByRole('button', { name: 'plugin.auth.addOAuth' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'plugin.auth.setupOAuth' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin.auth.addApi' })).not.toBeInTheDocument()
})
@@ -166,7 +139,7 @@ describe('Configure Component', () => {
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.getByRole('button', { name: 'plugin.auth.addApi' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'plugin.auth.addOAuth' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'plugin.auth.setupOAuth' })).toBeInTheDocument()
expect(screen.getByText('OR')).toBeInTheDocument()
})
})
@@ -189,11 +162,26 @@ describe('Configure Component', () => {
)
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
await user.click(screen.getByRole('button', { name: 'plugin.auth.addOAuth' }))
await user.click(screen.getByRole('button', { name: 'plugin.auth.setupOAuth' }))
const settings = screen.getByRole('dialog', { name: 'plugin.auth.oauthClientSettings' })
expect(mockOnUpdate).not.toHaveBeenCalled()
await user.type(within(settings).getByRole('textbox', { name: /API Key/ }), 'client-secret')
await user.click(within(settings).getByRole('button', { name: 'plugin.auth.saveAndAuth' }))
expect(
await screen.findByRole('dialog', { name: 'plugin.auth.whoCanUse' }),
).toBeInTheDocument()
expect(service.saveOAuthClient).toHaveBeenCalledExactlyOnceWith({
params: { provider_id: 'test-provider' },
body: { client_params: { api_key: 'client-secret' }, enable_oauth_custom_client: true },
})
expect(mockOnUpdate).toHaveBeenCalledOnce()
expect(screen.queryByRole('button', { name: 'plugin.auth.addOAuth' })).not.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'Who can use' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.auth.setupOAuth' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('dialog', { name: 'plugin.auth.oauthClientSettings' }),
).not.toBeInTheDocument()
})
it('should call onUpdate and close the portal when an update is triggered', async () => {
@@ -212,7 +200,19 @@ describe('Configure Component', () => {
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
await user.click(screen.getByRole('button', { name: 'plugin.auth.addApi' }))
expect(mockOnUpdate).not.toHaveBeenCalled()
await user.type(screen.getByRole('textbox', { name: /API Key/ }), 'datasource-key')
await user.type(
screen.getByRole('textbox', { name: 'plugin.auth.authorizationName' }),
'Team source',
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(service.addCredential).toHaveBeenCalledExactlyOnceWith({
params: { provider_id: 'test-provider' },
body: { credentials: { api_key: 'datasource-key' }, name: 'Team source' },
})
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'plugin.auth.addApi' })).not.toBeInTheDocument()
})
@@ -233,7 +233,7 @@ describe('Configure Component', () => {
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.getByRole('button', { name: 'plugin.auth.addApi' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'plugin.auth.addOAuth' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'plugin.auth.setupOAuth' })).toBeDisabled()
})
it('should handle edge cases for missing, empty, or partial item data', async () => {
@@ -243,7 +243,9 @@ describe('Configure Component', () => {
)
await user.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.queryByRole('button', { name: 'plugin.auth.addApi' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin.auth.addOAuth' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.auth.setupOAuth' }),
).not.toBeInTheDocument()
const itemEmpty: DatasourceProviderAuthResponse = {
...mockItemBase,
@@ -254,7 +256,9 @@ describe('Configure Component', () => {
rerender(<Configure item={itemEmpty} pluginPayload={mockPluginPayload} />)
expect(screen.queryByRole('button', { name: 'plugin.auth.addApi' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'plugin.auth.addOAuth' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.auth.setupOAuth' }),
).not.toBeInTheDocument()
const itemPartialOAuth: DatasourceProviderAuthResponse = {
...mockItemBase,
@@ -265,7 +269,9 @@ describe('Configure Component', () => {
rerender(<Configure item={itemPartialOAuth} pluginPayload={mockPluginPayload} />)
expect(screen.queryByRole('button', { name: 'plugin.auth.addOAuth' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'plugin.auth.setupOAuth' }),
).not.toBeInTheDocument()
})
})
})
@@ -1,5 +1,3 @@
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -10,56 +8,6 @@ import SingleRunForm from '../single-run-form'
const mockToastError = vi.hoisted(() => vi.fn())
vi.mock('@/app/notifications', () => ({ toast: { error: mockToastError } }))
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
__esModule: true,
default: ({
content,
formInputFields,
inputs,
onInputChange,
}: {
content: string
formInputFields: FormInputItem[]
inputs: Record<string, HumanInputFieldValue>
onInputChange: (name: string, value: HumanInputFieldValue) => void
}) => {
const fieldName = /\{\{#\$output\.([^#]+)#\}\}/.exec(content)?.[1]
if (!fieldName) return <div>{content}</div>
const field = formInputFields.find((field) => field.output_variable_name === fieldName)
if (!field) return null
if (field.type === 'select') {
return (
<select
aria-label={fieldName}
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
onChange={(event) => onInputChange(fieldName, event.target.value)}
>
<option value="">Select</option>
{field.option_source.value.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
if (field.type === 'paragraph') {
return (
<textarea
aria-label={fieldName}
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
onChange={(event) => onInputChange(fieldName, event.target.value)}
/>
)
}
return <div>{fieldName}</div>
},
}))
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
form_id: 'form-1',
node_id: 'human-1',
@@ -91,10 +39,6 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput
})
describe('SingleRunForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('restores the submit action and keeps entered values after a server failure', async () => {
const user = userEvent.setup()
const onSubmit = vi
@@ -1,6 +1,7 @@
import type { ComponentProps } from 'react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
@@ -9,29 +10,31 @@ import {
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import RecallSettings from '../recall-settings'
// The model selector has its own suite; this one only needs the selected value and a choice.
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: (props: {
onValueChange: (value: { model: string; provider: string }) => void
value?: { model: string; provider: string }
}) => (
<button
type="button"
onClick={() => props.onValueChange({ provider: 'custom/provider', model: 'custom-rerank' })}
>
reranker {props.value ? `${props.value.provider}/${props.value.model}` : 'none'}
</button>
),
}))
function renderSettings(props: Partial<ComponentProps<typeof RecallSettings>> = {}) {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
provider: 'system/provider',
models: [createCatalogModelFixture({ model: 'system-rerank', model_type: 'rerank' })],
models: [
createCatalogModelFixture({
model: 'system-rerank',
label: { en_US: 'system-rerank' },
model_type: 'rerank',
}),
],
}),
createModelCatalogFixture({
provider: 'custom/provider',
models: [
createCatalogModelFixture({
model: 'custom-rerank',
label: { en_US: 'custom-rerank' },
model_type: 'rerank',
}),
],
}),
])
queryClient.setQueryData(
@@ -46,15 +49,37 @@ function renderSettings(props: Partial<ComponentProps<typeof RecallSettings>> =
}),
},
)
const { wrapper: UrlProvider } = createNuqsTestWrapper()
function Settings() {
const [rerankingModel, setRerankingModel] = useState(props.rerankingModel)
const [topK, setTopK] = useState(10)
const [scoreThreshold, setScoreThreshold] = useState(props.scoreThreshold)
return (
<RecallSettings
onModeChange={vi.fn()}
{...props}
topK={topK}
scoreThreshold={scoreThreshold}
rerankingModel={rerankingModel}
onRerankingModelChange={(value) => {
setRerankingModel(value)
props.onRerankingModelChange?.(value)
}}
onScoreThresholdChange={(value) => {
setScoreThreshold(value)
props.onScoreThresholdChange?.(value)
}}
onTopKChange={(value) => {
setTopK(value)
props.onTopKChange?.(value)
}}
/>
)
}
return renderWithConsoleQuery(
<RecallSettings
topK={10}
onModeChange={vi.fn()}
onRerankingModelChange={vi.fn()}
onScoreThresholdChange={vi.fn()}
onTopKChange={vi.fn()}
{...props}
/>,
<UrlProvider>
<Settings />
</UrlProvider>,
{ queryClient },
)
}
@@ -67,7 +92,9 @@ describe('RecallSettings', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
expect(screen.getByText('modelProvider.modelProvider.defaultConfig')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'reranker system/provider/system-rerank' }))
await user.click(screen.getByRole('button', { name: /system-rerank/ }))
await user.click(await screen.findByRole('button', { name: /custom-rerank/ }))
expect(screen.getByRole('button', { name: /custom-rerank/ })).toBeInTheDocument()
expect(onRerankingModelChange).toHaveBeenCalledWith({
provider: 'custom/provider',
model: 'custom-rerank',
@@ -85,6 +112,7 @@ describe('RecallSettings', () => {
})
slider.focus()
await user.keyboard('{End}')
expect(slider).toHaveValue('100')
expect(onTopKChange).toHaveBeenLastCalledWith(100)
})
@@ -95,6 +123,7 @@ describe('RecallSettings', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
expect(onScoreThresholdChange).toHaveBeenLastCalledWith(0.5)
})
@@ -120,10 +149,10 @@ describe('RecallSettings', () => {
})
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
expect(
screen.getByRole('button', { name: 'reranker custom/provider/custom-rerank' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: /custom-rerank/ })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.reset' }))
expect(screen.getByRole('button', { name: /system-rerank/ })).toBeInTheDocument()
expect(screen.getByText('modelProvider.modelProvider.defaultConfig')).toBeInTheDocument()
expect(onRerankingModelChange).toHaveBeenCalledWith(undefined)
})
})
@@ -2,45 +2,20 @@ import type {
AgentLogConversationItemResponse,
AgentLogMessageListResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { OnFeedback } from '@/app/components/base/chat/types'
import { Drawer } from '@langgenius/dify-ui/drawer'
import { QueryClient } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/console'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { AgentLogDetailPanel } from '../components/log-detail-panel'
const mocks = vi.hoisted(() => ({
chatProps: vi.fn(),
feedbackMutationFn: vi.fn(),
messagesQueryFn: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: ({
chatList,
config,
onFeedback,
}: {
chatList: IChatItem[]
config?: { supportFeedback?: boolean }
onFeedback?: OnFeedback
}) => {
mocks.chatProps({ chatList, config, onFeedback })
return (
<button
onClick={() => void onFeedback?.('message-1', { rating: 'like' }).catch(() => undefined)}
>
submit-feedback
</button>
)
},
}))
vi.mock('@/app/notifications', () => ({
toast: {
error: mocks.toastError,
@@ -61,6 +36,10 @@ vi.mock('@/service/console', async (importOriginal) => {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'agent.byAgentId.feedbacks.post': mocks.feedbackMutationFn,
'agent.byAgentId.traceRuns.byMessageId.get': async () => ({ status: 'not_collected' }),
'apps.byAppId.traceRuns.bySourceKind.bySourceId.get': async () => ({
status: 'not_collected',
}),
'agent.byAgentId.logs.byConversationId.messages.get': mocks.messagesQueryFn,
}),
}
@@ -124,12 +103,15 @@ const webappMessages: AgentLogMessageListResponse = {
total: 1,
}
function renderPanel(log: AgentLogConversationItemResponse, messages: AgentLogMessageListResponse) {
mocks.messagesQueryFn.mockResolvedValue(messages)
function renderPanel(
log: AgentLogConversationItemResponse,
messages: AgentLogMessageListResponse,
refreshedMessages = messages,
) {
mocks.messagesQueryFn.mockResolvedValueOnce(messages).mockResolvedValue(refreshedMessages)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
render(
<QueryClientTestProvider queryClient={queryClient}>
@@ -138,8 +120,6 @@ function renderPanel(log: AgentLogConversationItemResponse, messages: AgentLogMe
</Drawer>
</QueryClientTestProvider>,
)
return { invalidateQueries }
}
describe('AgentLogDetailPanel', () => {
@@ -148,54 +128,71 @@ describe('AgentLogDetailPanel', () => {
mocks.feedbackMutationFn.mockResolvedValue({ result: 'success' })
})
it('maps user and admin feedback and submits operator feedback for webapp messages', async () => {
it('maps user and admin feedback and removes operator feedback for webapp messages', async () => {
const user = userEvent.setup()
const { invalidateQueries } = renderPanel(webappLog, webappMessages)
renderPanel(webappLog, webappMessages, {
...webappMessages,
data: [
{
...webappMessages.data[0]!,
answer: 'Refreshed answer',
feedbacks: webappMessages.data[0]!.feedbacks?.filter(
(feedback) => feedback.from_source === 'user',
),
},
],
})
await screen.findByRole('button', { name: 'submit-feedback' })
expect(mocks.chatProps).toHaveBeenLastCalledWith(
expect.objectContaining({
config: expect.objectContaining({ supportFeedback: true }),
chatList: expect.arrayContaining([
expect.objectContaining({
adminFeedback: { content: 'Needs work', from_source: 'admin', rating: 'dislike' },
feedback: { content: 'Helpful', from_source: 'user', rating: 'like' },
feedbackDisabled: false,
id: 'message-1',
}),
]),
expect(
await screen.findByRole('img', {
name: 'appLog.table.header.userRate: appLog.detail.operation.like - Helpful',
}),
).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name: 'appLog.table.header.adminRate: appLog.detail.operation.dislike',
pressed: true,
}),
)
await user.click(screen.getByRole('button', { name: 'submit-feedback' }))
await waitFor(() => {
expect(mocks.feedbackMutationFn).toHaveBeenCalledExactlyOnceWith({
expect(mocks.feedbackMutationFn).toHaveBeenLastCalledWith({
body: {
content: undefined,
message_id: 'message-1',
rating: 'like',
rating: null,
},
params: { agent_id: 'agent-1' },
})
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.agent.byAgentId.logs.get.key(),
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: consoleQuery.agent.byAgentId.logs.byConversationId.messages.get.key(),
})
expect(await screen.findByText('Refreshed answer')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'appLog.table.header.adminRate: appLog.detail.operation.like',
pressed: false,
}),
).toBeInTheDocument()
})
it('reports operator feedback failures without refreshing log queries', async () => {
const user = userEvent.setup()
mocks.feedbackMutationFn.mockRejectedValue(new Error('feedback request failed'))
const { invalidateQueries } = renderPanel(webappLog, webappMessages)
renderPanel(webappLog, webappMessages)
await user.click(await screen.findByRole('button', { name: 'submit-feedback' }))
await user.click(
await screen.findByRole('button', {
name: 'appLog.table.header.adminRate: appLog.detail.operation.dislike',
pressed: true,
}),
)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('feedback request failed'))
expect(invalidateQueries).not.toHaveBeenCalled()
expect(
screen.getByRole('button', {
name: 'appLog.table.header.adminRate: appLog.detail.operation.dislike',
pressed: true,
}),
).toBeInTheDocument()
expect(mocks.messagesQueryFn).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
@@ -212,14 +209,11 @@ describe('AgentLogDetailPanel', () => {
],
})
await screen.findByRole('button', { name: 'submit-feedback' })
expect(mocks.chatProps).toHaveBeenLastCalledWith(
expect.objectContaining({
config: expect.objectContaining({ supportFeedback: false }),
chatList: expect.arrayContaining([
expect.objectContaining({ feedbackDisabled: true, id: 'message-1' }),
]),
}),
)
expect(await screen.findByText('Question')).toBeInTheDocument()
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /appLog.table.header.(adminRate|userRate)/ }),
).not.toBeInTheDocument()
expect(mocks.feedbackMutationFn).not.toHaveBeenCalled()
})
})