test(web): consolidate suites around real feature behavior (#44274)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-18 01:20:32 +00:00
1 parent 7c7f93bd53
commit ed5da3d11a
516 files changed
+39267 -74639

No files matched your search

+2 -40
View File
@@ -85,13 +85,13 @@
"count": 1
},
"jsx-a11y/click-events-have-key-events": {
"count": 3
"count": 1
},
"jsx-a11y/no-autofocus": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 3
"count": 1
},
"react/only-export-components": {
"count": 1
@@ -435,11 +435,6 @@
"count": 1
}
},
"web/app/components/base/chat/chat/answer/agent-content.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/chat/chat/answer/workflow-process.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
@@ -637,11 +632,6 @@
"count": 15
}
},
"web/app/components/base/image-uploader/__tests__/utils.spec.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/image-uploader/image-preview.tsx": {
"jsx-a11y/no-static-element-interactions": {
"count": 1
@@ -995,11 +985,6 @@
"count": 1
}
},
"web/app/components/datasets/create/step-two/__tests__/index.spec.tsx": {
"dify/require-disable-directive-description": {
"count": 1
}
},
"web/app/components/datasets/create/step-two/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 5
@@ -1570,14 +1555,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/types.ts": {
"typescript/no-explicit-any": {
"count": 8
@@ -1807,11 +1784,6 @@
"count": 4
}
},
"web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": {
"eslint-react/static-components": {
"count": 2
}
},
"web/app/components/workflow-app/components/workflow-children.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -1933,11 +1905,6 @@
"count": 6
}
},
"web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts": {
"eslint-react/error-boundaries": {
"count": 1
}
},
"web/app/components/workflow/hooks/use-checklist.ts": {
"typescript/no-empty-object-type": {
"count": 1
@@ -2716,11 +2683,6 @@
"count": 1
}
},
"web/app/components/workflow/panel/__tests__/index.spec.tsx": {
"eslint-react/static-components": {
"count": 2
}
},
"web/app/components/workflow/panel/chat-record/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -0,0 +1,402 @@
import type {
AccessPolicy,
ResourceUserAccessPolicies,
} from '@dify/contracts/api/console/workspaces/types.gen'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AppAccessConfigPage from '@/app/components/app/access-config'
import ErrorBoundary from '@/app/components/base/error-boundary'
import DatasetAccessConfigPage from '@/app/components/datasets/access-config'
import DatasetDetailContext from '@/context/dataset-detail'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import { AppACLPermission, DatasetACLPermission } from '@/utils/permission'
const service = vi.hoisted(() => ({
rules: vi.fn(),
members: vi.fn(),
whitelist: vi.fn(),
config: vi.fn(),
candidates: vi.fn(),
updateConfig: vi.fn(),
updatePolicies: vi.fn(),
remove: vi.fn(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses, withConsoleClientResponses } =
await import('@/test/console/service-responses')
const queries = Object.fromEntries(
['apps.byAppId', 'datasets.byDatasetId'].flatMap((resource) => {
const path = `workspaces.current.rbac.${resource}`
return [
[`${path}.accessPolicy.get`, service.rules],
[`${path}.userAccessPolicies.get`, service.members],
[`${path}.whitelist.get`, service.whitelist],
[`${path}.whitelistConfig.get`, service.config],
]
}),
)
const mutations = Object.fromEntries(
['apps.byAppId', 'datasets.byDatasetId'].flatMap((resource) => {
const path = `workspaces.current.rbac.${resource}`
return [
[`${path}.whitelist.put`, service.updateConfig],
[`${path}.users.byTargetAccountId.accessPolicies.put`, service.updatePolicies],
[`${path}.accessPolicies.byPolicyId.memberBindings.delete`, service.remove],
]
}),
)
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
...queries,
'workspaces.current.members.get': service.candidates,
}),
consoleClient: withConsoleClientResponses(actual.consoleClient, mutations),
}
})
const member = (index: number): ResourceUserAccessPolicies => ({
account: {
account_id: `account-${index}`,
account_name: `Member ${index}`,
email: `member${index}@example.com`,
},
roles: [],
access_policies: [],
})
const tableName = 'permission.accessRule.allowedMembers'
const switchName = 'permission.accessRule.automaticallyIncludeWorkspaceMembers'
// Both resource pages own the same pagination/mutation contract. Exercise each real page
// and editor against request responses, including invalidation and server pagination.
describe.each(['app', 'dataset'] as const)('%s resource access page', (resource) => {
let members: ResourceUserAccessPolicies[]
let automaticInclusion: boolean
let policy: AccessPolicy
const resourceId = `${resource}-1`
const params = { [`${resource}_id`]: resourceId }
const renderPage = ({
allowed = true,
rbacEnabled = true,
mode = 'workflow' as 'workflow' | 'agent',
maintainerAccess = false,
} = {}) => {
const queryClient = createConsoleQueryClient()
const appDetail = createAppDetailFixture({
id: 'app-1',
mode,
maintainer: 'account-1',
permission_keys: allowed && !maintainerAccess ? [AppACLPermission.AccessConfig] : [],
})
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }),
appDetail,
)
const result = renderWithConsoleQuery(
<DatasetDetailContext.Provider
value={{
dataset: createDatasetFixture({
id: 'dataset-1',
maintainer: 'account-1',
permission_keys: allowed ? [DatasetACLPermission.AccessConfig] : [],
}),
}}
>
{resource === 'app' ? (
<AppAccessConfigPage appId={resourceId} />
) : (
<DatasetAccessConfigPage datasetId={resourceId} />
)}
</DatasetDetailContext.Provider>,
{
queryClient,
systemFeatures: { rbac_enabled: rbacEnabled },
accountProfile: { id: maintainerAccess ? 'account-1' : 'user-1' },
workspacePermissionKeys: maintainerAccess ? ['app.create_and_management'] : [],
},
)
return { ...result, appDetail }
}
const expectPage = async (page: number, limit = 10) => {
await waitFor(() =>
expect(service.members).toHaveBeenLastCalledWith({
params,
query: expect.objectContaining({ page, limit, reverse: false }),
}),
)
await screen.findByRole('checkbox', { name: `Member ${(page - 1) * limit + 1}` })
}
beforeEach(() => {
vi.resetAllMocks()
members = Array.from({ length: 21 }, (_, index) => member(index + 1))
automaticInclusion = false
policy = { id: 'policy-1', name: 'Manage', resource_type: resource }
service.rules.mockImplementation(async () => ({ items: [{ policy, roles: [], accounts: [] }] }))
service.members.mockImplementation(async ({ query: { page, limit } }) => ({
data: members.slice((page - 1) * limit, page * limit),
pagination: {
current_page: page,
per_page: limit,
total_count: members.length,
total_pages: Math.ceil(members.length / limit),
},
}))
// The maintainer may be implicitly included and absent from the whitelist.
service.whitelist.mockImplementation(async () => ({
account_ids: members.slice(1).map((item) => item.account.account_id),
}))
service.config.mockImplementation(async () => ({
automatic_include_workspace_members: automaticInclusion,
}))
service.candidates.mockResolvedValue({
accounts: Array.from({ length: 22 }, (_, index) => ({
id: `account-${index + 1}`,
name: `Member ${index + 1}`,
email: `member${index + 1}@example.com`,
avatar: '',
avatar_url: '',
status: 'active',
role: 'normal',
roles: [],
})),
})
service.updateConfig.mockImplementation(async ({ body }) => {
automaticInclusion = body.automatic_include_workspace_members
return {}
})
service.updatePolicies.mockImplementation(async ({ params: inputParams, body }) => {
const id = inputParams.target_account_id
let setting = members.find((item) => item.account.account_id === id)
if (!setting) {
setting = member(Number(id.split('-')[1]))
members.push(setting)
}
members = members.map((item) =>
item.account.account_id === id
? { ...item, access_policies: body.access_policy_ids.includes(policy.id) ? [policy] : [] }
: item,
)
return {}
})
service.remove.mockImplementation(async ({ body }) => {
members = members.filter((item) => !body.account_ids.includes(item.account.account_id))
return {}
})
})
it('shows the server page and keeps the maintainer protected', async () => {
automaticInclusion = true
renderPage()
await expectPage(1)
expect(
screen.getByRole('heading', { name: 'navigation.settings.resourceAccess' }),
).toBeVisible()
expect(screen.getByRole('switch', { name: switchName })).toBeChecked()
const row = screen.getByRole('row', { name: /Member 1\b/ })
expect(within(row).getByText('permission.accessRule.maintainer')).toBeVisible()
expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled()
expect(screen.queryByRole('checkbox', { name: 'Member 11' })).not.toBeInTheDocument()
})
it('requests another page and resets to the first page when page size changes', async () => {
const user = userEvent.setup()
renderPage()
await expectPage(1)
await user.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await expectPage(2)
await user.click(screen.getByRole('radio', { name: '25' }))
await expectPage(1, 25)
expect(screen.getByRole('radio', { name: '25' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'Member 21' })).toBeInTheDocument()
})
it('persists automatic inclusion and returns to the first page when it is disabled', async () => {
const user = userEvent.setup()
automaticInclusion = true
renderPage()
await expectPage(1)
await user.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await expectPage(2)
await user.click(screen.getByRole('switch', { name: switchName }))
await waitFor(() =>
expect(service.updateConfig).toHaveBeenCalledWith({
params,
body: { automatic_include_workspace_members: false },
}),
)
await expectPage(1)
expect(screen.getByRole('switch', { name: switchName })).not.toBeChecked()
})
it('adds a candidate and displays the new last page using the server total', async () => {
const user = userEvent.setup()
members = members.slice(0, 10)
renderPage()
await expectPage(1)
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
const dialog = await screen.findByRole('dialog', {
name: 'permission.accessRule.addMembersTitle',
})
expect(
await within(dialog).findByRole('menuitem', { name: /Member 1 member1@/ }),
).toHaveAttribute('aria-disabled', 'true')
await user.click(within(dialog).getByRole('menuitem', { name: /Member 11 member11@/ }))
await waitFor(() =>
expect(service.updatePolicies).toHaveBeenCalledWith({
params: { ...params, target_account_id: 'account-11' },
body: { access_policy_ids: ['default'] },
}),
)
await expectPage(2)
})
it.each(['single', 'batch'] as const)(
'returns to the previous page after %s removal empties the last page',
async (removal) => {
const user = userEvent.setup()
members = members.slice(0, removal === 'single' ? 11 : 12)
renderPage()
await expectPage(1)
await user.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await expectPage(2)
if (removal === 'single') {
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
} else {
await user.click(screen.getByRole('checkbox', { name: 'common.operation.selectAll' }))
await user.click(screen.getByRole('button', { name: 'common.operation.delete' }))
await user.click(
within(screen.getByRole('alertdialog')).getByRole('button', {
name: 'common.operation.sure',
}),
)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
}
await waitFor(() =>
expect(service.remove).toHaveBeenCalledWith({
params: { ...params, policy_id: 'default' },
body: {
account_ids: removal === 'single' ? ['account-11'] : ['account-11', 'account-12'],
},
}),
)
await expectPage(1)
expect(screen.queryByRole('checkbox', { name: 'Member 11' })).not.toBeInTheDocument()
},
)
it('blocks membership changes during automatic inclusion while still saving a policy change', async () => {
const user = userEvent.setup()
automaticInclusion = true
renderPage()
await expectPage(1)
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
const row = screen.getByRole('row', { name: /Member 2 member2@/ })
expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled()
expect(within(row).getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true')
await user.click(within(row).getByLabelText(/permission\.accessRule\.exceptionPermissionFor/))
await user.click(await screen.findByRole('option', { name: 'Manage' }))
await waitFor(() =>
expect(service.updatePolicies).toHaveBeenCalledWith({
params: { ...params, target_account_id: 'account-2' },
body: { access_policy_ids: ['policy-1'] },
}),
)
await waitFor(() =>
expect(
within(row).getByLabelText(/permission\.accessRule\.exceptionPermissionFor/),
).toHaveTextContent('Manage'),
)
expect(service.remove).not.toHaveBeenCalled()
})
it('keeps the inclusion switch checked while the separate candidate whitelist is loading', async () => {
automaticInclusion = true
service.whitelist.mockImplementation(() => new Promise(() => {}))
renderPage()
await expectPage(1)
expect(screen.getByRole('switch', { name: switchName })).toBeChecked()
})
it.each([{ allowed: false }, { rbacEnabled: false }])(
'does not fetch access data when access is unavailable: %j',
async (options) => {
renderPage(options)
await act(async () => {})
expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument()
expect(service.rules).not.toHaveBeenCalled()
expect(service.members).not.toHaveBeenCalled()
expect(service.whitelist).not.toHaveBeenCalled()
expect(service.config).not.toHaveBeenCalled()
},
)
if (resource === 'app') {
it('does not fetch access data for Agent apps', async () => {
renderPage({ mode: 'agent' })
await act(async () => {})
expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument()
expect(service.rules).not.toHaveBeenCalled()
expect(service.members).not.toHaveBeenCalled()
})
it('allows the maintainer with workspace app-management permission', async () => {
renderPage({ maintainerAccess: true })
await expectPage(1)
expect(screen.getByRole('table', { name: tableName })).toBeVisible()
})
it('preserves the page across metadata refresh and removes access after permission revocation', async () => {
const user = userEvent.setup()
const { queryClient, appDetail } = renderPage()
const key = consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } })
await expectPage(1)
await user.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await expectPage(2)
act(() => queryClient.setQueryData(key, { ...appDetail, maintainer: 'account-11' }))
const row = screen.getByRole('row', { name: /Member 11 member11@/ })
await waitFor(() =>
expect(within(row).getByText('permission.accessRule.maintainer')).toBeVisible(),
)
expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled()
act(() => queryClient.setQueryData(key, { ...appDetail, permission_keys: [] }))
await waitFor(() =>
expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument(),
)
})
it('surfaces an initial app-detail failure before requesting access data', async () => {
const queryClient = createConsoleQueryClient()
const queryKey = consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'unavailable-app' } },
})
queryClient.setQueryDefaults(queryKey, { retryOnMount: false })
await queryClient
.query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) })
.catch(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderWithConsoleQuery(
<ErrorBoundary>
<AppAccessConfigPage appId="unavailable-app" />
</ErrorBoundary>,
{ queryClient },
)
expect(
await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).toBeVisible()
expect(service.rules).not.toHaveBeenCalled()
expect(service.members).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
}
})
+3 -24
View File
@@ -1,7 +1,7 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import { useChatWithHistoryContext } from '@/app/components/base/chat/chat-with-history/context'
@@ -122,30 +122,9 @@ describe('Base Chat Flow', () => {
// Chat-with-history shell integration across layout, responsive shell, and theme setup.
describe('Chat With History Shell', () => {
it('updates the document title and expands the collapsed desktop sidebar on hover', () => {
const { rerender, container } = render(<ChatWithHistory />)
const titles = screen.getAllByText('Test Chat')
expect(titles.length).toBeGreaterThan(0)
it('uses the site title for the document', () => {
render(<ChatWithHistory />)
expect(document.title).toBe('Test Chat - Dify')
vi.mocked(useChatWithHistory).mockReturnValue({
...defaultHookReturn,
sidebarCollapseState: true,
})
rerender(<ChatWithHistory />)
const hoverArea = container.querySelector('.absolute.top-0.z-20')
expect(hoverArea).toBeInTheDocument()
if (hoverArea) {
fireEvent.mouseEnter(hoverArea)
expect(hoverArea).toHaveClass('left-0')
fireEvent.mouseLeave(hoverArea)
expect(hoverArea).toHaveClass('-left-62')
}
})
it('renders a new theme when site configuration changes', () => {
@@ -66,11 +66,6 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
const mockRouterPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockRouterPush }),
@@ -88,13 +88,12 @@ describe('Cloud plan payment flow', () => {
] as const)(
'displays $plan pricing and redirects to its $interval checkout',
async ({ plan, interval, label }) => {
const user = userEvent.setup()
renderPlan({ plan, billingInterval: interval })
const price = ALL_PLANS[plan].price
expect(screen.getByText(`$${price * (interval === 'year' ? 10 : 1)}`)).toBeInTheDocument()
if (interval === 'year') expect(screen.getByText(`$${price * 12}`)).toBeInTheDocument()
await userEvent
.setup()
.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` }))
await user.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` }))
await waitFor(() => expect(window.location.href).toBe('https://pay.example.com/checkout'))
expect(mockGetSubscription).toHaveBeenCalledExactlyOnceWith({ query: { plan, interval } })
expect(mockGetInvoices).not.toHaveBeenCalled()
@@ -109,10 +108,11 @@ describe('Cloud plan payment flow', () => {
] as const)(
'prevents purchasing $plan from $currentPlan',
async ({ currentPlan, plan, label }) => {
const user = userEvent.setup()
renderPlan({ currentPlan, plan })
const button = screen.getByRole('button', { name: `billing.plansCommon.${label}` })
expect(button).toBeDisabled()
await userEvent.setup().click(button)
await user.click(button)
expect(mockGetSubscription).not.toHaveBeenCalled()
expect(mockGetInvoices).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
@@ -120,12 +120,11 @@ describe('Cloud plan payment flow', () => {
)
it('opens the billing portal for the current paid subscription after resolving its URL', async () => {
const user = userEvent.setup()
const request = createDeferred<{ url: string }>()
mockGetInvoices.mockReturnValue(request.promise)
renderPlan({ currentPlan: 'professional', plan: 'professional' })
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
expect(window.open).toHaveBeenCalledWith('about:blank', '_blank', undefined)
expect(popup.opener).toBeNull()
expect(popup.location.href).toBe('')
@@ -136,11 +135,10 @@ describe('Cloud plan payment flow', () => {
})
it('closes the pending portal and reports its URL request failure', async () => {
const user = userEvent.setup()
mockGetInvoices.mockRejectedValueOnce(new Error('Billing unavailable'))
renderPlan({ currentPlan: 'professional', plan: 'professional' })
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
expect(await screen.findByText('Billing unavailable')).toBeInTheDocument()
expect(popup.close).toHaveBeenCalledOnce()
expect(popup.location.href).toBe('')
@@ -165,11 +163,10 @@ describe('Cloud plan payment flow', () => {
] as const)(
'blocks the $label action without billing permission',
async ({ currentPlan, label }) => {
const user = userEvent.setup()
canManageBilling = false
renderPlan({ currentPlan })
await userEvent
.setup()
.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` }))
await user.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` }))
expect(await screen.findByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
expect(mockGetSubscription).not.toHaveBeenCalled()
expect(mockGetInvoices).not.toHaveBeenCalled()
@@ -63,11 +63,6 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
return {
@@ -5,7 +5,7 @@ const testFilePattern = /\.(?:spec|test)\.[cm]?[jt]sx?$/
const interactivePrimitiveMockPattern =
/vi\.mock\(\s*['"]@langgenius\/dify-ui\/(?:alert-dialog|avatar|button|dialog|dropdown-menu|pagination|popover|select|slider|switch|textarea|tooltip)['"]/
const interactiveWrapperMockPattern =
/vi\.mock\(\s*['"][^'"]*(?:block-selector|plugin-version-picker|time-picker)['"]/
/vi\.mock\(\s*['"][^'"]*(?:block-selector|plugin-version-picker|time-picker|prompt-editor)(?:\/index)?['"]/
const collectTestFiles = (directory: string): string[] => {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
@@ -78,12 +78,13 @@ describe('Tracing overview panel', () => {
})
it('offers retry when tracing cannot load instead of remaining in a loading state', async () => {
const user = userEvent.setup()
request.mockRejectedValue(new Error('Tracing unavailable'))
renderPanel()
const retry = await screen.findByRole('button', { name: /common.errorBoundary.tryAgain/ })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
mockServer(false)
await userEvent.setup().click(retry)
await user.click(retry)
expect(await screen.findByText('app.tracing.title')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /common.errorBoundary.tryAgain/ }),
@@ -207,6 +207,7 @@ describe('Splash', () => {
})
it('logs out and returns from the unavailable state through its keyboard action', async () => {
const user = userEvent.setup()
navigationMocks.searchParams = new URLSearchParams({
code: '404',
message: 'The Web App is unavailable.',
@@ -219,7 +220,7 @@ describe('Splash', () => {
)
screen.getByRole('button', { name: 'share.login.backToHome' }).focus()
await userEvent.setup().keyboard('{Enter}')
await user.keyboard('{Enter}')
expect(webAppAuthMocks.webAppLogout).toHaveBeenCalledWith({
kind: 'default',
code: 'share-app',
@@ -15,11 +15,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => searchParams,
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/signin/storage', () => ({
COUNT_DOWN_TIME_MS: 60_000,
useSetCountdownLeftTime: () => setCountdownLeftTimeMock,
@@ -14,11 +14,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => searchParams,
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: { embeddedUserId: string }) => unknown) =>
selector({ embeddedUserId: 'embedded-user-99' }),
+4 -19
View File
@@ -2,26 +2,10 @@ import type { BannerResponse } from '@dify/contracts/api/console/explore/types.g
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Banner } from '@/features/home/banner/banner'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: { id: 'account-123' } }),
}
})
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({}),
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
@@ -56,14 +40,15 @@ const banners: BannerResponse[] = [
]
async function renderBanner() {
const { wrapper: Wrapper } = createConsoleQueryWrapper({ accountProfile: { id: 'account-123' } })
return render(
<>
<Wrapper>
<button type="button">Before carousel</button>
<div style={{ width: 300 }}>
<Banner banners={banners} />
</div>
<button type="button">After carousel</button>
</>,
</Wrapper>,
)
}
@@ -24,7 +24,7 @@ export type AccessPolicyMemberBindingRemoval = {
accountIds: string[]
}
export type AccessRulesEditorProps = {
type AccessRulesEditorProps = {
rules: { policy?: Pick<AccessPolicy, 'id' | 'name'> | null }[]
userAccessSettings: ResourceUserAccessPolicies[]
isLoadingRules: boolean
@@ -3,7 +3,8 @@ import type {
RelatedAppListResponse,
} from '@dify/contracts/api/console/datasets/types.gen'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetDetailSection from '../dataset-detail-section'
@@ -18,12 +19,19 @@ const mockConsoleState = vi.hoisted(() => ({
},
}))
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) => {
const queryClient = createConsoleQueryClient()
const input = { params: { dataset_id: 'dataset-1' } }
queryClient.setQueryData(consoleQuery.datasets.byDatasetId.get.queryKey({ input }), mockDataset)
queryClient.setQueryData(
consoleQuery.datasets.byDatasetId.relatedApps.get.queryKey({ input }),
mockRelatedApps,
)
return renderWithConsoleQuery(ui, {
queryClient,
systemFeatures: { rbac_enabled: mockIsRbacEnabled },
})
}
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
@@ -35,14 +43,6 @@ vi.mock('@/context/permission-state', async () => {
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks')
return mockConsoleOperationHooks(await importOriginal(), {
'datasets.byDatasetId.get': () => ({ data: mockDataset, refetch: vi.fn() }),
'datasets.byDatasetId.relatedApps.get': () => ({ data: mockRelatedApps }),
})
})
vi.mock('../dataset-info', () => ({
default: () => <div>dataset info</div>,
}))
@@ -1,465 +0,0 @@
import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor'
import { act, screen, waitFor } from '@testing-library/react'
import ErrorBoundary from '@/app/components/base/error-boundary'
import {
useAppAccessRules,
useAppResourceWhitelist,
useAppResourceWhitelistConfig,
useAppUserAccessSettings,
} from '@/service/access-control/use-app-access-config'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppACLPermission } from '@/utils/permission'
import AppAccessConfigPage from '../index'
const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}))
let mockIsRbacEnabled = true
let appDetail = createAppDetailFixture()
const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appDetail.id } } }),
appDetail,
)
return renderWithConsoleQuery(ui, {
queryClient,
accountProfile: mockConsoleState.userProfile,
systemFeatures: { rbac_enabled: mockIsRbacEnabled },
})
}
const mockAppAccessRules = vi.hoisted(() => ({
items: [] as AccessRulesEditorProps['rules'],
isLoading: false,
}))
const createUserAccessSetting = (
accountId: string,
): AccessRulesEditorProps['userAccessSettings'][number] => ({
account: {
account_id: accountId,
account_name: accountId,
},
roles: [],
access_policies: [],
})
const mockAppUserAccessSettings = vi.hoisted(() => ({
data: [] as AccessRulesEditorProps['userAccessSettings'],
pagination: {
current_page: 1,
per_page: 10,
total_count: 0,
total_pages: 0,
},
isLoading: false,
isPlaceholderData: false,
}))
const mockAppResourceWhitelist = vi.hoisted(() => ({
data: {
account_ids: [] as string[],
} as { account_ids?: string[] } | undefined,
}))
const mockAppResourceWhitelistConfig = vi.hoisted(() => ({
data: {
automatic_include_workspace_members: false,
} as { automatic_include_workspace_members: boolean } | undefined,
}))
const mockAccessRulesEditor = vi.hoisted(() => ({
props: null as AccessRulesEditorProps | null,
}))
const mockMutations = vi.hoisted(() => ({
updateAutomaticIncludeWorkspaceMembers: vi.fn(),
updateUserAccessSettings: vi.fn(),
removeMemberBindings: vi.fn(),
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/service/access-control/use-app-access-config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/access-control/use-app-access-config')>()),
useAppAccessRules: vi.fn(() => ({
data: { items: mockAppAccessRules.items },
isLoading: mockAppAccessRules.isLoading,
})),
useAppUserAccessSettings: vi.fn(() => ({
data: {
data: mockAppUserAccessSettings.data,
pagination: mockAppUserAccessSettings.pagination,
},
isLoading: mockAppUserAccessSettings.isLoading,
isPlaceholderData: mockAppUserAccessSettings.isPlaceholderData,
})),
useAppResourceWhitelist: vi.fn(() => ({ data: mockAppResourceWhitelist.data })),
useAppResourceWhitelistConfig: vi.fn(() => ({ data: mockAppResourceWhitelistConfig.data })),
}))
vi.mock('@/app/components/access-rules-editor', () => ({
default: (props: AccessRulesEditorProps) => {
mockAccessRulesEditor.props = props
return <div data-testid="access-rules-editor" />
},
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'workspaces.current.rbac.apps.byAppId.whitelist.put':
mockMutations.updateAutomaticIncludeWorkspaceMembers,
'workspaces.current.rbac.apps.byAppId.users.byTargetAccountId.accessPolicies.put':
mockMutations.updateUserAccessSettings,
'workspaces.current.rbac.apps.byAppId.accessPolicies.byPolicyId.memberBindings.delete':
mockMutations.removeMemberBindings,
}),
}
})
describe('AppAccessConfigPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConsoleState.userProfile = { id: 'user-1' }
mockConsoleState.workspacePermissionKeys = []
mockIsRbacEnabled = true
mockAppAccessRules.items = []
mockAppAccessRules.isLoading = false
mockAppUserAccessSettings.data = []
mockAppUserAccessSettings.pagination = {
current_page: 1,
per_page: 10,
total_count: 0,
total_pages: 0,
}
mockAppUserAccessSettings.isLoading = false
mockAppUserAccessSettings.isPlaceholderData = false
mockAppResourceWhitelist.data = {
account_ids: [],
}
mockAppResourceWhitelistConfig.data = {
automatic_include_workspace_members: false,
}
mockMutations.removeMemberBindings.mockResolvedValue({})
mockMutations.updateUserAccessSettings.mockResolvedValue({})
mockMutations.updateAutomaticIncludeWorkspaceMembers.mockResolvedValue({})
mockAccessRulesEditor.props = null
appDetail = createAppDetailFixture({
id: 'app-1',
maintainer: 'account-1',
permission_keys: [AppACLPermission.AccessConfig],
})
})
it('should render the first paginated member page with config and whitelist data', () => {
mockAppResourceWhitelistConfig.data = {
automatic_include_workspace_members: true,
}
mockAppUserAccessSettings.pagination = {
current_page: 1,
per_page: 10,
total_count: 45,
total_pages: 3,
}
mockAppResourceWhitelist.data = {
account_ids: ['account-1', 'account-2'],
}
render(<AppAccessConfigPage appId="app-1" />)
expect(
screen.getByRole('heading', { name: 'navigation.settings.resourceAccess' }),
).toBeInTheDocument()
expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument()
expect(useAppUserAccessSettings).toHaveBeenCalledWith('app-1', expect.any(String), 1, 10)
expect(useAppResourceWhitelist).toHaveBeenCalledWith('app-1')
expect(useAppResourceWhitelistConfig).toHaveBeenCalledWith('app-1')
expect(mockAccessRulesEditor.props?.automaticIncludeWorkspaceMembers).toBe(true)
expect(mockAccessRulesEditor.props?.existingAccountIds).toEqual(['account-1', 'account-2'])
expect(mockAccessRulesEditor.props?.currentPage).toBe(1)
expect(mockAccessRulesEditor.props?.pageSize).toBe(10)
expect(mockAccessRulesEditor.props?.totalCount).toBe(45)
expect(mockAccessRulesEditor.props?.totalPages).toBe(3)
expect(mockAccessRulesEditor.props?.maintainerId).toBe('account-1')
})
it('should keep the switch state while the add-member whitelist is loading', () => {
mockAppResourceWhitelistConfig.data = {
automatic_include_workspace_members: true,
}
mockAppResourceWhitelist.data = undefined
render(<AppAccessConfigPage appId="app-1" />)
expect(mockAccessRulesEditor.props?.automaticIncludeWorkspaceMembers).toBe(true)
expect(mockAccessRulesEditor.props?.existingAccountIds).toBeUndefined()
})
it('should request the selected member page', () => {
render(<AppAccessConfigPage appId="app-1" />)
act(() => mockAccessRulesEditor.props?.onPageChange?.(2))
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 2, 10)
})
it('should request the selected page size from the first page', () => {
render(<AppAccessConfigPage appId="app-1" />)
act(() => mockAccessRulesEditor.props?.onPageChange?.(3))
act(() => mockAccessRulesEditor.props?.onPageSizeChange?.(50))
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 50)
expect(mockAccessRulesEditor.props?.pageSize).toBe(50)
})
it('should return to the first page when automatic member inclusion is disabled', async () => {
mockAppResourceWhitelistConfig.data = {
automatic_include_workspace_members: true,
}
render(<AppAccessConfigPage appId="app-1" />)
act(() => mockAccessRulesEditor.props?.onPageChange?.(2))
await act(async () => {
mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(false)
})
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 10)
})
it('should jump to the new last page after adding a member', async () => {
mockAppUserAccessSettings.pagination = {
current_page: 2,
per_page: 10,
total_count: 20,
total_pages: 2,
}
mockAppResourceWhitelist.data = {
// The whitelist can omit an implicitly included maintainer; pagination is authoritative.
account_ids: Array.from({ length: 19 }, (_, index) => `account-${index + 1}`),
}
render(<AppAccessConfigPage appId="app-1" />)
await act(async () => {
mockAccessRulesEditor.props?.onAddAccessSubject?.('account-21', ['default'])
})
await waitFor(() =>
expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({
params: { app_id: 'app-1', target_account_id: 'account-21' },
body: { access_policy_ids: ['default'] },
}),
)
await act(async () => {})
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10)
})
it('should load the previous page after removing the only member on the last page', async () => {
mockAppUserAccessSettings.data = [createUserAccessSetting('account-11')]
mockAppUserAccessSettings.pagination = {
current_page: 2,
per_page: 10,
total_count: 11,
total_pages: 2,
}
render(<AppAccessConfigPage appId="app-1" />)
act(() => mockAccessRulesEditor.props?.onPageChange?.(2))
act(() =>
mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-11', 'default'),
)
mockAppUserAccessSettings.data = []
mockAppUserAccessSettings.pagination = {
current_page: 2,
per_page: 10,
total_count: 10,
total_pages: 1,
}
mockAppUserAccessSettings.isPlaceholderData = true
await act(async () => {})
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 10)
expect(mockAccessRulesEditor.props?.isLoadingUserAccessSettings).toBe(true)
})
it('should wire switch, policy update, and removal mutations', async () => {
render(<AppAccessConfigPage appId="app-1" />)
mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(true)
await waitFor(() =>
expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith({
params: { app_id: 'app-1' },
body: { automatic_include_workspace_members: true },
}),
)
mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1'])
await waitFor(() =>
expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({
params: { app_id: 'app-1', target_account_id: 'account-1' },
body: { access_policy_ids: ['policy-1'] },
}),
)
mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3')
await waitFor(() =>
expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({
params: { app_id: 'app-1', policy_id: 'policy-3' },
body: { account_ids: ['account-3'] },
}),
)
const removals = [
{ accessPolicyId: 'policy-1', accountIds: ['account-1', 'account-2'] },
{ accessPolicyId: 'default', accountIds: ['account-4'] },
]
await act(async () => {
await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.(removals)
})
expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({
params: { app_id: 'app-1', policy_id: 'policy-1' },
body: { account_ids: ['account-1', 'account-2'] },
})
expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({
params: { app_id: 'app-1', policy_id: 'default' },
body: { account_ids: ['account-4'] },
})
})
it('should block member changes but allow permission updates during automatic inclusion', async () => {
mockAppResourceWhitelistConfig.data = {
automatic_include_workspace_members: true,
}
render(<AppAccessConfigPage appId="app-1" />)
mockAccessRulesEditor.props?.onAddAccessSubject?.('account-2', ['default'])
mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-2', 'policy-1')
await act(async () => {
await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.([
{ accessPolicyId: 'policy-1', accountIds: ['account-2'] },
])
})
expect(mockMutations.updateUserAccessSettings).not.toHaveBeenCalled()
expect(mockMutations.removeMemberBindings).not.toHaveBeenCalled()
mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-2', ['policy-2'])
await waitFor(() =>
expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({
params: { app_id: 'app-1', target_account_id: 'account-2' },
body: { access_policy_ids: ['policy-2'] },
}),
)
})
it('should not mount access config data hooks when access permission is missing', () => {
appDetail = createAppDetailFixture({
id: 'app-1',
maintainer: 'account-1',
permission_keys: [AppACLPermission.ViewLayout],
})
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should not mount access config data hooks when RBAC is disabled', () => {
mockIsRbacEnabled = false
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should not mount access config data hooks for Agent apps', () => {
appDetail = createAppDetailFixture({
id: 'app-1',
mode: 'agent' as const,
maintainer: 'account-1',
permission_keys: [AppACLPermission.AccessConfig],
})
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
expect(useAppUserAccessSettings).not.toHaveBeenCalled()
})
it('should allow the maintainer with app management workspace permission', () => {
mockConsoleState.userProfile = { id: 'account-1' }
mockConsoleState.workspacePermissionKeys = ['app.create_and_management']
appDetail = createAppDetailFixture({
id: 'app-1',
maintainer: 'account-1',
permission_keys: [],
})
render(<AppAccessConfigPage appId="app-1" />)
expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument()
})
it('keeps the current member page on metadata refresh and stops rendering after permission revocation', async () => {
appDetail = createAppDetailFixture({ permission_keys: [AppACLPermission.AccessConfig] })
const { queryClient } = render(<AppAccessConfigPage appId="app-1" />)
const queryKey = consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'app-1' } },
})
act(() => mockAccessRulesEditor.props?.onPageChange?.(3))
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10)
act(() => {
queryClient.setQueryData(queryKey, { ...appDetail, maintainer: 'new-maintainer' })
})
await waitFor(() => expect(mockAccessRulesEditor.props?.maintainerId).toBe('new-maintainer'))
expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10)
act(() => {
queryClient.setQueryData(queryKey, { ...appDetail, permission_keys: [] })
})
await waitFor(() => expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument())
})
it('surfaces an initial detail failure without mounting member queries', async () => {
const queryClient = createConsoleQueryClient()
const queryKey = consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'unavailable-app' } },
})
queryClient.setQueryDefaults(queryKey, { retryOnMount: false })
await queryClient
.query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) })
.catch(() => {})
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderWithConsoleQuery(
<ErrorBoundary>
<AppAccessConfigPage appId="unavailable-app" />
</ErrorBoundary>,
{ queryClient },
)
expect(
await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).toBeInTheDocument()
expect(useAppAccessRules).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
})
@@ -29,11 +29,6 @@ const mocks = vi.hoisted(() => ({
useAppWorkflow: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({ queryKey: ['system-features'] }),
}))
@@ -7,11 +7,6 @@ const mocks = vi.hoisted(() => ({
webAppCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({
EnvironmentServiceApiCard: (props: Record<string, unknown>) => {
mocks.serviceApiCard(props)
@@ -1,504 +1,360 @@
import type { Annotation as AnnotationData } from '@dify/contracts/api/console/apps/types.gen'
import type { ComponentProps, ReactElement } from 'react'
import { act, screen, waitFor } from '@testing-library/react'
import type {
Annotation as AnnotationData,
AnnotationSettingResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { consoleClient } from '@/service/console'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { toast } from '@/app/notifications'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppToastHost } from '@/app/notifications/host'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import Annotation from '../index'
import { AnnotationEnableStatus, JobStatus } from '../type'
let annotationQuota = { size: 0, limit: 10 }
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
const api = vi.hoisted(() => ({
list: vi.fn<typeof consoleClient.apps.byAppId.annotations.get>(),
add: vi.fn<typeof consoleClient.apps.byAppId.annotations.post>(),
remove: vi.fn<typeof consoleClient.apps.byAppId.annotations.byAnnotationId.delete>(),
batchRemove: vi.fn<typeof consoleClient.apps.byAppId.annotations.delete>(),
edit: vi.fn<typeof consoleClient.apps.byAppId.annotations.byAnnotationId.post>(),
config: vi.fn<typeof consoleClient.apps.byAppId.annotationSetting.get>(),
status: vi.fn<typeof consoleClient.apps.byAppId.annotationReply.byAction.post>(),
job: vi.fn<typeof consoleClient.apps.byAppId.annotationReply.byAction.status.byJobId.get>(),
score: vi.fn<typeof consoleClient.apps.byAppId.annotationSettings.byAnnotationSettingId.post>(),
}))
const mocks = vi.hoisted(() => ({
addAnnotation: vi.fn(),
delAnnotation: vi.fn(),
delAnnotations: vi.fn(),
editAnnotation: vi.fn(),
fetchAnnotationConfig: vi.fn(),
fetchAnnotationList: vi.fn(),
queryAnnotationJobStatus: vi.fn(),
updateAnnotationScore: vi.fn(),
updateAnnotationStatus: vi.fn(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses, withConsoleClientResponses } =
await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'apps.byAppId.annotations.count.get': () => ({ count: 0 }),
'apps.byAppId.annotations.byAnnotationId.delete': (input) => api.remove(input),
'apps.byAppId.annotations.delete': (input) => api.batchRemove(input),
}),
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'apps.byAppId.annotations.get': (input) => api.list(input),
'apps.byAppId.annotations.export.get': () => ({ data: [] }),
'apps.byAppId.annotations.post': (input) => api.add(input),
'apps.byAppId.annotations.delete': (input) => api.batchRemove(input),
'apps.byAppId.annotations.byAnnotationId.delete': (input) => api.remove(input),
'apps.byAppId.annotations.byAnnotationId.post': (input) => api.edit(input),
'apps.byAppId.annotations.byAnnotationId.hitHistories.get': () => ({
data: [],
total: 0,
page: 1,
limit: 10,
has_more: false,
}),
'apps.byAppId.annotationSetting.get': (input) => api.config(input),
'apps.byAppId.annotationReply.byAction.post': (input) => api.status(input),
'apps.byAppId.annotationReply.byAction.status.byJobId.get': (input) => api.job(input),
'apps.byAppId.annotationSettings.byAnnotationSettingId.post': (input) => api.score(input),
}),
}
})
vi.mock('@/service/annotation', () => ({
updateAnnotationStatus: mocks.updateAnnotationStatus,
}))
const item: AnnotationData = {
id: 'annotation-1',
question: 'Question 1',
answer: 'Answer 1',
created_at: 1700000000,
hit_count: 0,
}
const initialConfig: AnnotationSettingResponse = {
id: 'config-id',
enabled: false,
score_threshold: 0.5,
embedding_model: {
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-ada-002',
},
}
let annotations: AnnotationData[]
let config: AnnotationSettingResponse
vi.mock('@/service/console', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/console')>()),
consoleClient: {
apps: {
byAppId: {
annotationSetting: { get: mocks.fetchAnnotationConfig },
annotationReply: {
byAction: { status: { byJobId: { get: mocks.queryAnnotationJobStatus } } },
},
annotationSettings: { byAnnotationSettingId: { post: mocks.updateAnnotationScore } },
annotations: {
get: mocks.fetchAnnotationList,
post: mocks.addAnnotation,
delete: mocks.delAnnotations,
byAnnotationId: { delete: mocks.delAnnotation, post: mocks.editAnnotation },
},
},
function setup(quota = { size: 0, limit: 10 }) {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'text-embedding-ada-002',
label: { en_US: 'Ada Embedding' },
model_type: 'text-embedding',
}),
],
}),
createModelCatalogFixture({
provider: 'cohere',
models: [
createCatalogModelFixture({
model: 'embed-english',
label: { en_US: 'English Embedding' },
model_type: 'text-embedding',
}),
],
}),
])
renderWithConsoleQuery(
<NuqsTestingAdapter>
<Annotation appDetail={createAppDetailFixture({ id: 'app-id', mode: 'chat' })} />
<AppToastHost />
</NuqsTestingAdapter>,
{
queryClient,
systemFeatures: { deployment_edition: 'CLOUD' },
features: { annotation_quota_limit: quota },
},
},
}))
vi.mock('../filter', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="filter">{children}</div>
),
}))
vi.mock('../empty-element', () => ({
default: () => <div data-testid="empty-element" />,
}))
vi.mock('../header-opts', () => ({
default: (props: ComponentProps<typeof import('../header-opts').default>) => (
<div data-testid="header-opts">
<button
data-testid="trigger-add"
onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}
>
add
</button>
<button data-testid="trigger-added" onClick={() => props.onAdded()}>
added
</button>
</div>
),
}))
let latestListProps: ComponentProps<typeof import('../list').List> | undefined
vi.mock('../list', () => ({
List: (props: ComponentProps<typeof import('../list').List>) => {
latestListProps = props
if (!props.list.length) return <div data-testid="list-empty" />
return (
<div data-testid="list">
<button data-testid="list-view" onClick={() => props.onView(props.list[0]!)}>
view
</button>
<button data-testid="list-remove" onClick={() => props.onRemoved()}>
remove
</button>
<button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}>
batch-delete
</button>
</div>
)
},
}))
vi.mock('../view-annotation-modal', () => ({
default: (props: ComponentProps<typeof import('../view-annotation-modal').default>) => {
if (!props.isShow) return null
return (
<div data-testid="view-modal">
<div>{props.item.question}</div>
<button data-testid="view-modal-remove" onClick={props.onRemove}>
remove
</button>
<button
data-testid="view-modal-save"
onClick={() => props.onSave('Edited question', 'Edited answer')}
>
save
</button>
<button data-testid="view-modal-close" onClick={props.onHide}>
close
</button>
</div>
)
},
}))
vi.mock(
'@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal',
() => ({
default: (
props: ComponentProps<
typeof import('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal').default
>,
) =>
props.isShow ? (
<div data-testid="config-modal">
<button
data-testid="config-save"
onClick={() =>
props.onSave(
{
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
},
0.7,
)
}
>
save-config
</button>
<button data-testid="config-hide" onClick={props.onHide}>
hide-config
</button>
</div>
) : null,
}),
)
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
default: (
props: ComponentProps<typeof import('@/app/components/billing/annotation-full/modal').default>,
) =>
props.show ? (
<div data-testid="annotation-full-modal">
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>
hide-full
</button>
</div>
) : null,
}))
const mockNotify = vi.fn()
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
mockNotify({ type: 'success', message, ...options })
return 'toast-success-id'
})
vi.spyOn(toast, 'error').mockImplementation((message, options) => {
mockNotify({ type: 'error', message, ...options })
return 'toast-error-id'
})
vi.spyOn(toast, 'warning').mockImplementation((message, options) => {
mockNotify({ type: 'warning', message, ...options })
return 'toast-warning-id'
})
vi.spyOn(toast, 'info').mockImplementation((message, options) => {
mockNotify({ type: 'info', message, ...options })
return 'toast-info-id'
})
const addAnnotationMock = mocks.addAnnotation
const delAnnotationMock = mocks.delAnnotation
const delAnnotationsMock = mocks.delAnnotations
const editAnnotationMock = mocks.editAnnotation
const fetchAnnotationConfigMock = mocks.fetchAnnotationConfig
const fetchAnnotationListMock = mocks.fetchAnnotationList
const queryAnnotationJobStatusMock = mocks.queryAnnotationJobStatus
const updateAnnotationScoreMock = mocks.updateAnnotationScore
const updateAnnotationStatusMock = mocks.updateAnnotationStatus
const appDetail = createAppDetailFixture({
id: 'app-id',
mode: 'chat' as const,
})
const createAnnotation = (overrides: Partial<AnnotationData> = {}): AnnotationData => ({
id: overrides.id ?? 'annotation-1',
question: overrides.question ?? 'Question 1',
answer: overrides.answer ?? 'Answer 1',
created_at: overrides.created_at ?? 1700000000,
hit_count: overrides.hit_count ?? 0,
})
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
systemFeatures: { deployment_edition: 'CLOUD' },
features: { annotation_quota_limit: annotationQuota },
})
)
return userEvent.setup()
}
describe('Annotation', () => {
beforeEach(() => {
vi.clearAllMocks()
for (const mock of Object.values(mocks)) mock.mockReset()
latestListProps = undefined
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: false,
beforeEach(() => {
for (const mock of Object.values(api)) mock.mockReset()
annotations = []
config = { ...initialConfig }
api.list.mockImplementation(async () => ({
data: [...annotations],
total: annotations.length,
has_more: false,
limit: 10,
page: 1,
}))
api.config.mockImplementation(async () => config)
api.add.mockImplementation(async ({ body }) => {
const added = { ...item, question: body.question, answer: body.answer }
annotations.push(added)
return added
})
api.remove.mockImplementation(async () => {
annotations = []
return { result: 'success' }
})
api.batchRemove.mockImplementation(async () => {
annotations = []
return { result: 'success' }
})
api.edit.mockImplementation(async ({ body }) => {
const edited = { ...item, question: body.question, answer: body.answer }
annotations = [edited]
return edited
})
api.status.mockImplementation(async ({ params, body }) => {
config = {
...config,
enabled: params.action === 'enable',
id: params.action === 'enable' ? 'enabled-config-id' : config.id,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
embedding_model_name: body.embedding_model_name,
embedding_provider_name: body.embedding_provider_name,
},
score_threshold: 0.5,
})
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
updateAnnotationScoreMock.mockResolvedValue(undefined)
editAnnotationMock.mockResolvedValue(undefined)
annotationQuota = { size: 0, limit: 10 }
}
return { job_id: 'job-1', job_status: 'completed' }
})
it('should render empty element when no annotations are returned', async () => {
renderComponent()
expect(screen.getByRole('heading', { name: 'appAnnotation.title' })).toBeInTheDocument()
expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute(
'href',
'https://docs.example.com/use-dify/monitor/annotation-reply',
)
expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
expect(fetchAnnotationListMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id },
query: expect.objectContaining({ page: 1, keyword: '' }),
})
api.job.mockResolvedValue({ job_id: 'job-1', job_status: 'completed', error_msg: '' })
api.score.mockImplementation(async ({ body }) => {
config = { ...config, score_threshold: body.score_threshold }
return config
})
it('should handle annotation creation and refresh list data', async () => {
const user = userEvent.setup()
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
addAnnotationMock.mockResolvedValue(undefined)
renderComponent()
await screen.findByTestId('list')
await user.click(screen.getByTestId('trigger-add'))
await waitFor(() => {
expect(addAnnotationMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id },
body: { question: 'new question', answer: 'new answer' },
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}),
)
})
expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
})
afterEach(() => toast.dismiss())
it('creates an annotation from the empty page and displays the saved record', async () => {
const user = setup()
expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible()
await user.click(
await screen.findByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
)
const dialog = await screen.findByRole('dialog', { name: 'appAnnotation.addModal.title' })
await user.type(
within(dialog).getByRole('textbox', { name: 'appAnnotation.addModal.queryName' }),
'New question',
)
await user.type(
within(dialog).getByRole('textbox', { name: 'appAnnotation.addModal.answerName' }),
'New answer',
)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(await screen.findByRole('checkbox', { name: 'New question' })).toBeVisible()
expect(screen.getByRole('cell', { name: 'New answer' })).toBeVisible()
expect(api.add).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id' },
body: { question: 'New question', answer: 'New answer' },
})
it('should refetch the list after a row is removed', async () => {
const user = userEvent.setup()
fetchAnnotationListMock.mockResolvedValue({ data: [createAnnotation()], total: 1 })
renderComponent()
await screen.findByTestId('list')
const fetchCount = fetchAnnotationListMock.mock.calls.length
await user.click(screen.getByTestId('list-remove'))
await waitFor(() => {
expect(fetchAnnotationListMock.mock.calls.length).toBeGreaterThan(fetchCount)
})
})
it('removes a row after confirmation and refreshes the empty state', async () => {
annotations = [item]
const user = setup()
await user.click(
await screen.findByRole('button', { name: 'appDebug.feature.annotation.remove' }),
)
const dialog = await screen.findByRole('alertdialog')
expect(api.remove).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible()
expect(api.remove).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', annotation_id: item.id },
})
it('should support viewing items and running batch deletion success flow', async () => {
const user = userEvent.setup()
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
delAnnotationsMock.mockResolvedValue(undefined)
delAnnotationMock.mockResolvedValue(undefined)
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps!.onSelectedIdsChange([annotation.id])
})
await waitFor(() => {
expect(latestListProps!.selectedIds).toEqual([annotation.id])
})
await act(async () => {
await latestListProps!.onBatchDelete()
})
await waitFor(() => {
expect(delAnnotationsMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id },
query: { annotation_id: [annotation.id] },
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
expect(latestListProps!.selectedIds).toEqual([])
})
await user.click(screen.getByTestId('list-view'))
expect(screen.getByTestId('view-modal')).toBeInTheDocument()
await user.click(screen.getByTestId('view-modal-remove'))
await waitFor(() => {
expect(delAnnotationMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id, annotation_id: annotation.id },
})
})
})
it('keeps a failed batch deletion open for retry and clears the selection after success', async () => {
annotations = [item]
api.batchRemove.mockRejectedValueOnce(new Error('Delete failed'))
const user = setup()
await user.click(await screen.findByRole('checkbox', { name: 'Question 1' }))
await user.click(screen.getByRole('button', { name: 'common.operation.delete' }))
const dialog = await screen.findByRole('alertdialog', { name: 'appAnnotation.list.delete.title' })
expect(api.batchRemove).not.toHaveBeenCalled()
const confirm = within(dialog).getByRole('button', { name: 'common.operation.delete' })
await user.click(confirm)
expect(await screen.findByText('Delete failed')).toBeVisible()
expect(dialog).toBeVisible()
await user.click(confirm)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible()
expect(screen.queryByText('appAnnotation.batchAction.selected')).not.toBeInTheDocument()
expect(api.batchRemove).toHaveBeenCalledTimes(2)
expect(api.batchRemove).toHaveBeenLastCalledWith({
params: { app_id: 'app-id' },
query: { annotation_id: [item.id] },
})
it('should show an error notification when batch deletion fails', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
const error = new Error('failed')
delAnnotationsMock.mockRejectedValue(error)
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps!.onSelectedIdsChange([annotation.id])
})
await waitFor(() => {
expect(latestListProps!.selectedIds).toEqual([annotation.id])
})
await act(async () => {
await expect(latestListProps!.onBatchDelete()).rejects.toThrow(error.message)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: error.message,
})
expect(latestListProps!.selectedIds).toEqual([annotation.id])
})
})
it('saves edits from the detail drawer and shows them when reopened', async () => {
annotations = [item]
const user = setup()
await user.click(await screen.findByRole('button', { name: 'appDebug.feature.annotation.edit' }))
const dialog = await screen.findByRole('dialog', {
name: 'appAnnotation.viewModal.annotatedResponse',
})
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
const user = userEvent.setup()
annotationQuota = { size: 10, limit: 10 }
renderComponent()
const toggle = await screen.findByRole('switch')
await user.click(toggle)
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
await user.click(screen.getByTestId('hide-annotation-full-modal'))
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
await user.click(within(dialog).getAllByRole('button', { name: 'common.operation.edit' })[0]!)
await user.type(
within(dialog).getByRole('textbox', { name: 'appAnnotation.editModal.yourQuery' }),
'Edited question',
)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
await waitFor(() =>
expect(api.edit).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', annotation_id: item.id },
body: { question: 'Edited question', answer: 'Answer 1' },
}),
)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(await screen.findByRole('checkbox', { name: 'Edited question' })).toBeVisible()
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.edit' }))
expect(
within(
await screen.findByRole('dialog', { name: 'appAnnotation.viewModal.annotatedResponse' }),
).getByText('Edited question'),
).toBeVisible()
})
it('removes an annotation through its detail drawer and closes it', async () => {
annotations = [item]
const user = setup()
await user.click(await screen.findByRole('button', { name: 'appDebug.feature.annotation.edit' }))
const dialog = await screen.findByRole('dialog', {
name: 'appAnnotation.viewModal.annotatedResponse',
})
it('should disable annotations and refetch config after the async job completes', async () => {
const user = userEvent.setup()
fetchAnnotationConfigMock
.mockResolvedValueOnce({
id: 'config-id',
enabled: true,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
.mockResolvedValueOnce({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
await user.click(toggle)
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.disable,
expect.objectContaining({
embedding_model_name: 'model',
embedding_provider_name: 'provider',
}),
0.5,
)
expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id, action: AnnotationEnableStatus.disable, job_id: 'job-1' },
})
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
await user.click(
within(dialog).getByRole('button', {
name: 'appAnnotation.editModal.removeThisCache',
}),
)
const confirm = await screen.findByRole('alertdialog')
await user.click(within(confirm).getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible()
expect(api.remove).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', annotation_id: item.id },
})
it('should save annotation config changes and update the score when the modal confirms', async () => {
const user = userEvent.setup()
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
})
it('refreshes annotations after the header clear-all action', async () => {
annotations = [item]
const user = setup()
await screen.findByRole('checkbox', { name: 'Question 1' })
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await user.click(
await screen.findByRole('menuitem', { name: 'appAnnotation.table.header.clearAll' }),
)
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.confirm' }))
expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible()
expect(api.batchRemove).toHaveBeenCalledExactlyOnceWith({ params: { app_id: 'app-id' } })
})
it('shows the quota dialog without enabling annotated replies when the plan is full', async () => {
const user = setup({ size: 10, limit: 10 })
await user.click(await screen.findByRole('switch'))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText('billing.annotatedResponse.fullTipLine1')).toBeVisible()
expect(api.status).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(screen.getByRole('switch')).not.toBeChecked()
})
it('disables annotated replies and reloads settings after the job completes', async () => {
config = { ...initialConfig, enabled: true }
const user = setup()
await waitFor(() => expect(screen.getByRole('switch')).toBeChecked())
await user.click(screen.getByRole('switch'))
await waitFor(() => expect(screen.getByRole('switch')).not.toBeChecked())
expect(api.status).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', action: 'disable' },
body: {
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-ada-002',
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
await user.click(toggle)
expect(screen.getByTestId('config-modal')).toBeInTheDocument()
await user.click(screen.getByTestId('config-save'))
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.enable,
{
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
},
0.7,
)
expect(updateAnnotationScoreMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id, annotation_setting_id: 'config-id' },
body: { score_threshold: 0.7 },
})
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}),
)
})
},
})
expect(api.job).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', action: 'disable', job_id: 'job-1' },
})
})
it('enables replies with the selected model and saves the score to the returned settings id', async () => {
const user = setup()
await user.click(await screen.findByRole('switch'))
const dialog = await screen.findByRole('dialog', { name: 'appAnnotation.initSetup.title' })
await user.click(within(dialog).getByRole('button', { name: /Ada Embedding/ }))
await user.click(await screen.findByRole('button', { name: /English Embedding/ }))
within(dialog).getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(within(dialog).getByRole('slider')).toHaveAttribute('aria-valuenow', '0.51')
await user.click(
within(dialog).getByRole('button', { name: 'appAnnotation.initSetup.confirmBtn' }),
)
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(screen.getByRole('switch')).toBeChecked()
expect(api.status).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', action: 'enable' },
body: {
embedding_provider_name: 'cohere',
embedding_model_name: 'embed-english',
score_threshold: 0.51,
},
})
expect(api.job).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', action: 'enable', job_id: 'job-1' },
})
it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => {
const user = userEvent.setup()
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
renderComponent()
await screen.findByTestId('list')
await user.click(screen.getByTestId('list-view'))
await user.click(screen.getByTestId('view-modal-save'))
await waitFor(() => {
expect(editAnnotationMock).toHaveBeenCalledWith({
params: { app_id: appDetail.id, annotation_id: annotation.id },
body: { question: 'Edited question', answer: 'Edited answer' },
})
})
await user.click(screen.getByTestId('view-modal-close'))
expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument()
const fetchCount = fetchAnnotationListMock.mock.calls.length
await user.click(screen.getByTestId('trigger-added'))
await waitFor(() =>
expect(fetchAnnotationListMock.mock.calls.length).toBeGreaterThan(fetchCount),
)
expect(api.score).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'app-id', annotation_setting_id: 'enabled-config-id' },
body: { score_threshold: 0.51 },
})
})
@@ -1,78 +1,43 @@
import type { ComponentProps } from 'react'
import type { Mock } from 'vite-plus/test'
import type { Locale } from '@/i18n'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { useLocale } from '#i18n'
import userEvent from '@testing-library/user-event'
import { LanguagesSupported } from '@/i18n/language'
import { captureDownloads } from '@/test/download'
import CSVDownload from '../csv-downloader'
let locale: Locale = 'en-US'
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: vi.fn(() => 'en-US'),
useLocale: () => locale,
}))
const downloaderProps: ComponentProps<
ReturnType<typeof import('react-papaparse').useCSVDownloader>['CSVDownloader']
>[] = []
vi.mock('react-papaparse', () => ({
useCSVDownloader: vi.fn(() => ({
CSVDownloader: ({
children,
...props
}: ComponentProps<
ReturnType<typeof import('react-papaparse').useCSVDownloader>['CSVDownloader']
>) => {
downloaderProps.push(props)
return <div data-testid="mock-csv-downloader">{children}</div>
},
Type: { Link: 'link' },
})),
}))
const renderWithLocale = (locale: Locale) => {
;(useLocale as Mock).mockReturnValue(locale)
return render(<CSVDownload />)
}
describe('CSVDownload', () => {
const englishTemplate = [
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
]
const chineseTemplate = [
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
]
beforeEach(() => {
downloaderProps.length = 0
afterEach(() => {
vi.restoreAllMocks()
})
it('should render the structure preview and pass English template data by default', () => {
renderWithLocale('en-US' as Locale)
it.each([
{
language: 'en-US' as Locale,
csv: 'question,answer\r\nquestion1,answer1\r\nquestion2,answer2',
},
{
language: LanguagesSupported[1] as Locale,
csv: '问题,答案\r\n问题 1,答案 1\r\n问题 2,答案 2',
},
])('downloads the $language annotation template as CSV', async ({ language, csv }) => {
const user = userEvent.setup()
locale = language
const downloads = captureDownloads()
render(<CSVDownload />)
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
expect(screen.getByText('share.generation.csvStructureTitle')).toBeVisible()
await user.click(screen.getByText('appAnnotation.batchModal.template'))
expect(downloaderProps[0]).toMatchObject({
filename: 'template-en-US',
type: 'link',
bom: true,
data: englishTemplate,
})
})
it('should switch to the Chinese template when locale matches the secondary language', () => {
const locale = LanguagesSupported[1] as Locale
renderWithLocale(locale)
expect(downloaderProps[0]).toMatchObject({
filename: `template-${locale}`,
data: chineseTemplate,
})
expect(downloads).toHaveLength(1)
const { filename, blob } = downloads[0]!
expect(filename).toBe(`template-${language}.csv`)
expect(blob.type).toBe('text/csv;charset=utf-8;')
expect(await blob.text()).toBe(`\uFEFF${csv}`)
})
})
@@ -3,7 +3,6 @@ import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,7 +23,7 @@ type Props = Readonly<{
export const EditTitle: FC<{ className?: string; title: string }> = ({ className, title }) => (
<div className={cn(className, 'flex h-4.5 items-center system-xs-medium text-text-tertiary')}>
<RiEditFill className="mr-1 size-3.5" />
<span aria-hidden className="mr-1 i-ri-edit-fill size-3.5" />
<div>{title}</div>
<div
className="ml-2 h-px grow"
@@ -94,21 +93,23 @@ const EditItem: FC<Props> = ({ type, readonly, content, onSave }) => {
)}
<div className="mt-2 flex items-center">
{!readonly && (
<div
<button
type="button"
className="flex cursor-pointer items-center space-x-1 system-xs-medium text-text-accent"
onClick={() => {
setIsEdit(true)
}}
>
<RiEditLine className="mr-1 size-3.5" />
<div>{t(($) => $['operation.edit'], { ns: 'common' })}</div>
</div>
<span aria-hidden className="mr-1 i-ri-edit-line size-3.5" />
<span>{t(($) => $['operation.edit'], { ns: 'common' })}</span>
</button>
)}
{showNewContent && (
<div className="ml-2 flex items-center system-xs-medium text-text-tertiary">
<div className="mr-2">·</div>
<div
<button
type="button"
className="flex cursor-pointer items-center space-x-1"
onClick={async () => {
try {
@@ -121,11 +122,11 @@ const EditItem: FC<Props> = ({ type, readonly, content, onSave }) => {
}
}}
>
<div className="size-3.5">
<RiDeleteBinLine className="size-3.5" />
</div>
<div>{t(($) => $['operation.delete'], { ns: 'common' })}</div>
</div>
<span className="size-3.5">
<span aria-hidden className="i-ri-delete-bin-line size-3.5" />
</span>
<span>{t(($) => $['operation.delete'], { ns: 'common' })}</span>
</button>
</div>
)}
</div>
File diff suppressed because it is too large. Load diff
@@ -1,160 +0,0 @@
import { detectPlatform } from '@tanstack/react-hotkeys'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { PublisherPanel } from '../publisher-content/publisher-panel'
vi.mock('../environment-deployment-flow', () => ({
PublisherEnvironmentFlow: ({
onConfigurationOpenChange,
}: {
onConfigurationOpenChange?: (open: boolean) => void
}) => (
<div>
Environment publisher
<button type="button" onClick={() => onConfigurationOpenChange?.(true)}>
Configure deployment
</button>
</div>
),
}))
function PublisherPanelHarness({
initialOpen = true,
onPublish = vi.fn(),
showBuiltInPublisher = false,
}: {
initialOpen?: boolean
onPublish?: () => Promise<void>
showBuiltInPublisher?: boolean
}) {
const [open, setOpen] = useState(initialOpen)
return (
<>
<button type="button">Outside control</button>
<PublisherPanel
builtInPublisher={{
actions: {
appId: 'app-1',
appMode: 'chat' as const,
appURL: 'https://example.com/app',
canViewAccessPoint: false,
disabledFunctionButton: false,
publishedAt: 1_710_000_000_000,
workflowToolIsLoading: false,
onConfigureWorkflowTool: vi.fn(),
},
summary: {
formatTimeFromNow: () => '',
handlePublish: onPublish,
handleRestore: vi.fn(),
isChatApp: false,
isPublishing: false,
published: false,
publishedAt: 1_710_000_000_000,
upgradeHighlightStyle: {},
},
}}
environmentPublisher={{
appId: 'app-1',
canViewAccessPoint: false,
environmentId: 'staging',
environmentName: 'Staging',
environmentTabs: null,
isEnvironmentInUse: true,
isDeploymentError: false,
isDeploymentLoading: false,
onGoToPublish: vi.fn(),
}}
environmentPublisherKey="staging"
open={open}
showBuiltInPublisher={showBuiltInPublisher}
workflowLaunch={{
hiddenVariables: [],
open: false,
targetUrl: '',
onOpenChange: vi.fn(),
}}
onOpenChange={setOpen}
/>
</>
)
}
describe('PublisherPanel', () => {
it('publishes from the actions section on first open and reopen, but not while closed', async () => {
const user = userEvent.setup()
const onPublish = vi.fn().mockResolvedValue(undefined)
const modifier = detectPlatform() === 'mac' ? 'Meta' : 'Control'
const shortcut = `{${modifier}>}{Shift>}P{/Shift}{/${modifier}}`
render(<PublisherPanelHarness initialOpen={false} onPublish={onPublish} showBuiltInPublisher />)
const trigger = screen.getByRole('button', { name: /common\.publish\b/ })
for (let opened = 1; opened <= 2; opened++) {
await user.click(trigger)
const openWebApp = screen.getByRole('link', { name: /common\.openWebApp\b/ })
act(() => openWebApp.focus())
expect(openWebApp).toHaveFocus()
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
await user.keyboard(shortcut)
expect(onPublish).toHaveBeenCalledTimes(opened)
await user.click(trigger)
await waitFor(() =>
expect(
screen.queryByRole('link', { name: /common\.openWebApp\b/ }),
).not.toBeInTheDocument(),
)
expect(trigger).toHaveFocus()
await user.keyboard(shortcut)
expect(onPublish).toHaveBeenCalledTimes(opened)
}
})
it('keeps the publisher open after an outside press when dismissal is prevented', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.click(screen.getByRole('button', { name: 'Outside control' }))
expect(screen.getByText('Environment publisher')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute(
'aria-expanded',
'true',
)
})
it('keeps the default outside-press dismissal outside deployment configuration', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
await user.click(screen.getByRole('button', { name: 'Outside control' }))
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute(
'aria-expanded',
'false',
)
})
it('still closes from the trigger and Escape when outside dismissal is prevented', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
const publishButton = screen.getByRole('button', { name: /common\.publish/ })
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.click(publishButton)
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
await user.click(publishButton)
expect(screen.getByText('Environment publisher')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.keyboard('{Escape}')
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,744 @@
import type { Dependency } from '@/app/components/plugins/types'
import type { consoleClient } from '@/service/console'
import { zAppProviderAgentToolResponse } from '@dify/contracts/api/console/apps/zod.gen'
import { zProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Cookies from 'js-cookie'
import CommonLayoutError from '@/app/(commonLayout)/error'
import ErrorBoundary from '@/app/components/base/error-boundary'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/cookie'
import { PermissionType } from '@/app/components/plugins/types'
import { useStore as usePluginDependenciesStore } from '@/app/components/workflow/plugin-dependency/store'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { createHttpError } from '@/service/http-error'
import {
createCatalogModelFixture,
createModelCatalogFixture,
createProviderSummaryFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import {
createConsoleQueryClient,
renderWithConsoleQuery,
seedAppDetail,
} from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { createDeferred } from '@/test/deferred'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { createMarketplacePluginFixture } from '@/test/fixtures/plugin'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { AppACLPermission } from '@/utils/permission'
import Configuration from '../index'
import { toast as configurationToast } from '../toast'
const flags = vi.hoisted(() => ({ agentV2: false }))
vi.mock('@/env', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/env')>()
return {
...actual,
env: {
...actual.env,
get NEXT_PUBLIC_ENABLE_AGENT_V2() {
return flags.agentV2
},
},
}
})
const api = vi.hoisted(() => ({
app: vi.fn<typeof consoleClient.apps.byAppId.get>(),
publish: vi.fn<typeof consoleClient.apps.byAppId.modelConfig.post>(),
tools: vi.fn<typeof consoleClient.workspaces.current.toolProviders.get>(),
datasets: vi.fn<typeof consoleClient.datasets.get>(),
rules:
vi.fn<
typeof consoleClient.workspaces.current.modelProviders.byProvider.models.parameterRules.get
>(),
stream: vi.fn<typeof import('@/service/base').ssePost>(),
marketplace: vi.fn<typeof import('@/service/base').postMarketplace>(),
install: vi.fn<typeof consoleClient.workspaces.current.plugin.install.marketplace.post>(),
models: vi.fn<typeof consoleClient.workspaces.current.models.modelTypes.byModelType.get>(),
modelSummary: vi.fn<typeof consoleClient.workspaces.current.modelProviders.summary.get>(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses, withConsoleClientResponses } =
await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'apps.byAppId.get': (input) => api.app(input),
'apps.byAppId.modelConfig.post': (input) => api.publish(input),
'datasets.get': (input) => api.datasets(input),
'codeBasedExtension.get': async () => ({ data: [] }),
'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }),
'workspaces.current.tools.builtin.get': async () => [],
'workspaces.current.toolProviders.get': (input) => api.tools(input),
'workspaces.current.models.modelTypes.byModelType.get': (input) => api.models(input),
'workspaces.current.modelProviders.summary.get': (input) => api.modelSummary(input),
'workspaces.current.defaultModel.get': async () => ({ data: null }),
'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) =>
api.rules(input),
}),
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'workspaces.current.plugin.permission.fetch.get': async () => ({
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
}),
'workspaces.current.plugin.tasks.get': async () => ({ tasks: [] }),
'workspaces.current.plugin.install.marketplace.post': (input) => api.install(input),
'apps.byAppId.get': (input) => api.app(input),
'apps.byAppId.modelConfig.post': (input) => api.publish(input),
'workspaces.current.toolProviders.get': (input) => api.tools(input),
'datasets.get': (input) => api.datasets(input),
'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) =>
api.rules(input),
}),
}
})
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
ssePost: api.stream,
postMarketplace: api.marketplace,
}))
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
usePathname: () => '/app/app-1/configuration',
}))
const createApp = (overrides: Parameters<typeof createAppDetailFixture>[0] = {}) =>
createAppDetailFixture({
id: 'app-1',
name: 'Configuration test',
mode: 'chat',
permission_keys: [
AppACLPermission.Edit,
AppACLPermission.ReleaseAndVersion,
AppACLPermission.TestAndRun,
],
model_config: createAppModelConfigFixture({
updated_at: 1710000000,
prompt_type: 'simple',
pre_prompt: 'Explain concepts clearly.',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4',
mode: 'chat',
completion_params: { temperature: 0.7 },
},
}),
...overrides,
})
function setup({ app = createApp() }: { app?: ReturnType<typeof createApp> } = {}) {
const queryClient = createConsoleQueryClient()
seedAppDetail(queryClient, app)
const catalog = createModelCatalogFixture({
provider: 'langgenius/openai/openai',
models: [
createCatalogModelFixture({
model_properties: { mode: 'chat' },
features: ['vision'],
}),
createCatalogModelFixture({
model: 'gpt-instruct',
label: { en_US: 'GPT Instruct' },
model_properties: { mode: 'completion' },
}),
createCatalogModelFixture({
model: 'gpt-4.1',
label: { en_US: 'GPT-4.1' },
model_properties: { mode: 'chat' },
features: ['vision'],
}),
],
})
seedModelProviderQueries(queryClient, [catalog])
api.models.mockImplementation(async ({ params }) => ({
data: params.model_type === 'llm' ? [zProviderWithModelsResponse.parse(catalog)] : [],
}))
api.modelSummary.mockResolvedValue({
data: [createProviderSummaryFixture({ provider: catalog.provider, label: catalog.label })],
plugins: {},
})
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(
consoleQuery.files.upload.get.queryKey(),
createFileUploadConfigFixture(),
)
const { wrapper: UrlState } = createNuqsTestWrapper()
renderWithConsoleQuery(
<UrlState>
<EventEmitterContextProvider>
<ErrorBoundary fallback={(error) => <CommonLayoutError error={error} retry={() => {}} />}>
<Configuration />
</ErrorBoundary>
<AppToastHost />
</EventEmitterContextProvider>
</UrlState>,
{
queryClient,
currentWorkspace: { id: 'workspace-1' },
workspacePermissionKeys: ['app.create_and_management', 'plugin.install'],
systemFeatures: {
enable_marketplace: true,
rbac_enabled: true,
plugin_installation_permission: {
plugin_installation_scope: 'all',
restrict_to_marketplace_only: false,
},
},
},
)
return { user: userEvent.setup() }
}
const prompt = () => screen.getByRole('textbox', { name: 'appDebug.chatSubTitle' })
const waitForPage = () => screen.findByRole('heading', { name: 'appDebug.orchestrate' })
async function openPublisher(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'workflow.common.publish' }))
}
async function publish(user: ReturnType<typeof userEvent.setup>) {
const previousCalls = api.publish.mock.calls.length
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.publishUpdate' }))
await waitFor(() => expect(api.publish).toHaveBeenCalledTimes(previousCalls + 1))
await screen.findByRole('button', { name: 'workflow.common.published' })
}
async function enableComparison(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole('button', { name: 'modelProvider.modelProvider.modelSettings' }),
)
await user.click(screen.getByRole('button', { name: 'appDebug.debugAsMultipleModel' }))
await screen.findByRole('button', { name: /modelProvider.modelProvider.addModel/ })
}
beforeEach(() => {
flags.agentV2 = false
usePluginDependenciesStore.setState({ dependencies: [] })
for (const mock of Object.values(api)) mock.mockReset()
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME)
api.app.mockResolvedValue(createApp())
api.publish.mockResolvedValue({ result: 'success' })
api.tools.mockResolvedValue([])
api.datasets.mockResolvedValue({ data: [], has_more: false, limit: 20, page: 1, total: 0 })
api.rules.mockResolvedValue({
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
required: false,
options: [],
},
],
})
api.stream.mockResolvedValue(undefined)
api.install.mockReset().mockResolvedValue({ all_installed: true, task_id: '' })
api.marketplace.mockReset()
})
afterEach(() =>
act(() => {
toast.dismiss()
configurationToast.dismiss()
vi.unstubAllGlobals()
}),
)
it('loads the actual configuration page before editing and publishing a prompt', async () => {
const request = createDeferred<Awaited<ReturnType<typeof consoleClient.apps.byAppId.get>>>()
api.app.mockReturnValueOnce(request.promise)
const { user } = setup()
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeVisible()
await act(async () => request.resolve(createApp()))
await waitForPage()
expect(prompt()).toHaveTextContent('Explain concepts clearly.')
await user.type(prompt(), ' Answer in one sentence.')
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
params: { app_id: 'app-1' },
body: {
pre_prompt: 'Explain concepts clearly. Answer in one sentence.',
model: { name: 'gpt-4', completion_params: { temperature: 0.7 } },
},
})
})
it('restores the last successful publication after cancelling an earlier restore', async () => {
const { user } = setup()
await waitForPage()
await user.type(prompt(), ' Published revision.')
await publish(user)
await user.keyboard('{Escape}')
await user.type(prompt(), ' Unpublished revision.')
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
const confirmation = await screen.findByRole('alertdialog', {
name: 'appDebug.resetConfig.title',
})
await user.click(within(confirmation).getByRole('button', { name: 'common.operation.cancel' }))
expect(prompt()).toHaveTextContent('Unpublished revision.')
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await user.click(
within(
await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }),
).getByRole('button', { name: 'common.operation.confirm' }),
)
expect(prompt()).toHaveTextContent('Explain concepts clearly. Published revision.')
expect(prompt()).not.toHaveTextContent('Unpublished revision.')
expect(api.publish).toHaveBeenCalledTimes(1)
})
it.each([
{ permission: AppACLPermission.ViewLayout, editable: false, runnable: false, publishable: false },
{ permission: AppACLPermission.Edit, editable: true, runnable: false, publishable: false },
{ permission: AppACLPermission.TestAndRun, editable: false, runnable: true, publishable: false },
{
permission: AppACLPermission.ReleaseAndVersion,
editable: false,
runnable: false,
publishable: true,
},
])(
'enforces independent edit, run, and release permissions for $permission',
async ({ permission, editable, runnable, publishable }) => {
const app = createApp({ permission_keys: [permission] })
api.app.mockResolvedValue(app)
const { user } = setup({ app })
await waitForPage()
expect(prompt()).toHaveAttribute('contenteditable', String(editable))
const send = screen.getByRole('button', { name: 'common.operation.send' })
if (runnable) {
await user.type(screen.getByPlaceholderText(/^common.chat.inputPlaceholder/), 'Explain RAG')
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
expect(api.stream).toHaveBeenCalledWith(
'apps/app-1/chat-messages',
expect.objectContaining({ body: expect.objectContaining({ query: 'Explain RAG' }) }),
expect.anything(),
)
} else {
expect(send).toBeDisabled()
expect(api.stream).not.toHaveBeenCalled()
}
if (publishable) {
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: { pre_prompt: 'Explain concepts clearly.' },
})
} else {
expect(screen.getByRole('button', { name: 'workflow.common.publish' })).toBeDisabled()
expect(api.publish).not.toHaveBeenCalled()
}
},
)
it('selects another model and publishes only parameters supported by that model', async () => {
const { user } = setup()
await waitForPage()
api.rules.mockResolvedValue({ data: [] })
await user.click(screen.getByRole('button', { name: /^GPT-4 CHAT$/ }))
const chooser = await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' })
await user.click(within(chooser).getByRole('button', { name: /^GPT-4.1/ }))
await screen.findByText(/modelProvider.modelProvider.parametersInvalidRemoved/)
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: { model: { name: 'gpt-4.1' } },
})
expect(api.publish.mock.lastCall![0].body.model.completion_params).toEqual({})
})
it('keeps comparison mode when restoring the published prompt', async () => {
const { user } = setup()
await waitForPage()
await enableComparison(user)
const addModel = await screen.findByRole('button', {
name: /modelProvider.modelProvider.addModel/,
})
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
await user.type(prompt(), ' Draft.')
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await user.click(
within(
await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }),
).getByRole('button', { name: 'common.operation.confirm' }),
)
expect(prompt()).toHaveTextContent('Explain concepts clearly.')
expect(prompt()).not.toHaveTextContent('Draft.')
expect(addModel).toBeVisible()
})
it('publishes the selected comparison model and restores its parameters as the published snapshot', async () => {
const { user } = setup()
await waitForPage()
await enableComparison(user)
await user.click(screen.getByRole('button', { name: /modelProvider.modelProvider.selectModel/ }))
const settings = await screen.findByRole('dialog', {
name: 'modelProvider.modelProvider.modelSettings',
})
await user.click(
within(settings).getByRole('button', { name: 'plugin.detailPanel.configureModel' }),
)
await user.click(
within(
await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
).getByRole('button', { name: /^GPT-4.1/ }),
)
const modelSettings = await screen.findByRole('dialog', {
name: 'modelProvider.modelProvider.modelSettings',
})
await user.click(within(modelSettings).getByRole('switch', { name: 'Temperature' }))
await user.clear(within(modelSettings).getByRole('spinbutton', { name: 'Temperature' }))
await user.type(within(modelSettings).getByRole('spinbutton', { name: 'Temperature' }), '0.2')
await user.click(within(modelSettings).getByRole('button', { name: 'common.operation.close' }))
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' }))
await user.click(screen.getByRole('menuitem', { name: /GPT-4.1/ }))
await waitFor(() => expect(api.publish).toHaveBeenCalledTimes(1))
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: { model: { name: 'gpt-4.1', completion_params: { temperature: 0.2 } } },
})
await user.keyboard('{Escape}')
await user.keyboard('{Escape}')
await user.click(screen.getAllByRole('button', { name: 'common.operation.more' })[0]!)
await user.click(screen.getByRole('menuitem', { name: 'appDebug.debugAsSingleModel' }))
expect(screen.getByRole('button', { name: /^GPT-4 CHAT$/ })).toBeVisible()
await user.type(prompt(), ' Unsaved draft.')
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await user.click(
within(
await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }),
).getByRole('button', { name: 'common.operation.confirm' }),
)
expect(screen.getByRole('button', { name: /^GPT-4.1/ })).toBeVisible()
expect(prompt()).not.toHaveTextContent('Unsaved draft.')
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: { model: { name: 'gpt-4.1', completion_params: { temperature: 0.2 } } },
})
})
it('loads and publishes persisted advanced messages with their roles intact', async () => {
const app = createApp({
model_config: createAppModelConfigFixture({
...createApp().model_config,
prompt_type: 'advanced',
chat_prompt_config: {
prompt: [
{ role: 'system', text: 'Answer with sources.' },
{ role: 'user', text: 'Explain RAG.' },
],
},
}),
})
api.app.mockResolvedValue(app)
const { user } = setup({ app })
await waitForPage()
expect(screen.getByText('Answer with sources.')).toBeVisible()
expect(screen.getByText('Explain RAG.')).toBeVisible()
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: {
prompt_type: 'advanced',
chat_prompt_config: {
prompt: [
{ role: 'system', text: 'Answer with sources.' },
{ role: 'user', text: 'Explain RAG.' },
],
},
},
})
})
it('shows the page error fallback for a missing model configuration', async () => {
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
api.app.mockResolvedValue(createApp({ model_config: undefined }))
setup()
expect(
await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).toBeVisible()
expect(screen.queryByRole('heading', { name: 'appDebug.orchestrate' })).not.toBeInTheDocument()
} finally {
report.mockRestore()
}
})
it('keeps the redirect loading fallback when configuration dependencies return unauthorized', async () => {
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const request =
createDeferred<
Awaited<ReturnType<typeof consoleClient.workspaces.current.toolProviders.get>>
>()
api.tools.mockReturnValue(request.promise)
setup()
await act(async () => request.reject(createHttpError(new Response(null, { status: 401 }))))
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'appDebug.orchestrate' })).not.toBeInTheDocument()
} finally {
report.mockRestore()
}
})
it('preserves feature drafts across reopening and restores the last published features', async () => {
const { user } = setup()
await waitForPage()
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' }))
const panel = await screen.findByRole('dialog', { name: 'workflow.common.features' })
await user.click(
within(panel).getByRole('switch', {
name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title',
}),
)
await user.click(within(panel).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'workflow.common.features' }),
).not.toBeInTheDocument(),
)
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' }))
const reopened = await screen.findByRole('dialog', { name: 'workflow.common.features' })
expect(
within(reopened).getByRole('switch', {
name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title',
}),
).toBeChecked()
await user.click(within(reopened).getByRole('button', { name: 'common.operation.close' }))
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: { suggested_questions_after_answer: { enabled: true } },
})
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' }))
const draftPanel = await screen.findByRole('dialog', { name: 'workflow.common.features' })
await user.click(
within(draftPanel).getByRole('switch', {
name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title',
}),
)
await user.click(within(draftPanel).getByRole('button', { name: 'common.operation.close' }))
expect(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' })).toBeVisible()
await openPublisher(user)
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await user.click(
within(
await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }),
).getByRole('button', { name: 'common.operation.confirm' }),
)
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' }))
expect(
within(await screen.findByRole('dialog', { name: 'workflow.common.features' })).getByRole(
'switch',
{ name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title' },
),
).toBeChecked()
expect(api.publish).toHaveBeenCalledTimes(1)
})
it('opens and closes the mobile debug drawer while preserving the configuration draft', async () => {
vi.stubGlobal('innerWidth', 390)
const { user } = setup()
await waitForPage()
await user.type(prompt(), ' Mobile draft.')
const openDebug = () =>
user.click(screen.getByRole('button', { name: 'appDebug.operation.debugConfig' }))
await openDebug()
const drawer = await screen.findByRole('dialog')
const composer = await within(drawer).findByRole('textbox')
await user.type(composer, 'Try this draft')
expect(composer).toHaveValue('Try this draft')
await user.click(within(drawer).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(prompt()).toHaveTextContent('Mobile draft.')
await openDebug()
expect(await within(await screen.findByRole('dialog')).findByRole('textbox')).toBeInTheDocument()
})
it.each([false, true])(
'shows the legacy Agent migration entry only with Agent v2 enabled=%s',
async (enabled) => {
flags.agentV2 = enabled
const app = createApp({ mode: 'agent-chat' })
api.app.mockResolvedValue(app)
const { user } = setup({ app })
await waitForPage()
const badge = screen.queryByRole('button', { name: 'appDebug.legacyAgentBadge.label' })
if (!enabled) {
expect(badge).not.toBeInTheDocument()
return
}
await user.click(screen.getByRole('button', { name: 'appDebug.legacyAgentBadge.label' }))
expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeVisible()
expect(screen.getByRole('link', { name: /appDebug.legacyAgentBadge.action/ })).toHaveAttribute(
'href',
'/agents',
)
},
)
it.each([true, false])(
'updates removed Agent tools after dependency installation succeeds=%s',
async (success) => {
const plugin = createMarketplacePluginFixture({
org: 'vendor',
name: 'search',
plugin_id: 'vendor/search',
latest_package_identifier: 'vendor/search:1.0.0',
label: { en_US: 'Search plugin' },
})
const unselectedPlugin = createMarketplacePluginFixture({
org: 'vendor',
name: 'reference',
plugin_id: 'vendor/reference',
latest_package_identifier: 'vendor/reference:1.0.0',
label: { en_US: 'Reference plugin' },
})
const providerId = 'vendor/search/search'
const tool = zAppProviderAgentToolResponse.parse({
provider_id: providerId,
provider_name: providerId,
provider_type: 'builtin',
tool_name: 'search',
tool_label: 'Search the web',
tool_parameters: {},
enabled: true,
})
const unselectedTool = {
...tool,
provider_id: 'vendor/reference/reference',
provider_name: 'vendor/reference/reference',
tool_name: 'lookup',
tool_label: 'Read reference',
}
const tools = [tool, unselectedTool]
const base = createApp()
const app = createApp({
mode: 'agent-chat',
deleted_tools: tools.map((tool) => ({
provider_id: tool.provider_id,
tool_name: tool.tool_name,
type: 'builtin',
})),
model_config: { ...base.model_config!, agent_mode: { enabled: true, tools } },
})
api.app.mockResolvedValue(app)
api.marketplace.mockResolvedValue({
data: { list: [plugin, unselectedPlugin].map((plugin) => ({ plugin })) },
})
if (!success) api.install.mockRejectedValue(new Error('Installation rejected'))
const { user } = setup({ app })
await waitForPage()
expect(screen.getAllByRole('button', { name: 'tools.toolRemoved' })).toHaveLength(2)
const dependencies: Dependency[] = [plugin, unselectedPlugin].map((plugin) => ({
type: 'marketplace',
value: { marketplace_plugin_unique_identifier: plugin.latest_package_identifier },
}))
act(() => usePluginDependenciesStore.getState().setDependencies(dependencies))
const dialog = await screen.findByRole('dialog', { name: 'plugin.installModal.installPlugin' })
const install = within(dialog).getByRole('button', { name: 'plugin.installModal.install' })
await waitFor(() => expect(install).toBeEnabled())
await user.click(within(dialog).getByRole('checkbox', { name: 'reference' }))
await user.click(install)
await screen.findByRole('dialog', { name: 'plugin.installModal.installedSuccessfully' })
expect(api.install).toHaveBeenCalledExactlyOnceWith({
body: { plugin_unique_identifiers: [plugin.latest_package_identifier] },
})
await user.click(screen.getAllByRole('button', { name: 'common.operation.close' }).at(-1)!)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getAllByRole('button', { name: 'tools.toolRemoved' })).toHaveLength(
success ? 1 : 2,
)
if (success) expect(screen.getByRole('button', { name: 'search' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: 'search' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'lookup' })).not.toBeInTheDocument()
},
)
it('adds a prompt variable, edits its label, and publishes the actual form schema', async () => {
const { user } = setup()
await waitForPage()
await user.click(prompt())
await user.paste('Explain {{topic}} clearly.')
await user.tab()
const confirmation = await screen.findByRole('group', { name: 'appDebug.autoAddVar' })
await user.click(within(confirmation).getByRole('button', { name: 'common.operation.add' }))
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const dialog = await screen.findByRole('dialog')
const label = within(dialog).getByRole('textbox', { name: 'appDebug.variableConfig.labelName' })
await user.clear(label)
await user.type(label, 'Discussion topic')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: {
pre_prompt: expect.stringContaining('{{topic}}'),
user_input_form: [{ 'text-input': { variable: 'topic', label: 'Discussion topic' } }],
},
})
})
it('edits advanced completion history prefixes through the page and publishes them', async () => {
const app = createApp({
model_config: createAppModelConfigFixture({
...createApp().model_config,
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-instruct',
mode: 'completion',
completion_params: {},
},
prompt_type: 'advanced',
completion_prompt_config: {
prompt: { text: 'Answer accurately. {{#histories#}} {{#query#}}' },
conversation_histories_role: { user_prefix: 'Human', assistant_prefix: 'Assistant' },
},
}),
})
api.app.mockResolvedValue(app)
const { user } = setup({ app })
await waitForPage()
expect(
screen.getByRole('heading', { name: 'appDebug.feature.conversationHistory.title' }),
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const dialog = await screen.findByRole('dialog', {
name: 'appDebug.feature.conversationHistory.editModal.title',
})
const userPrefix = within(dialog).getByDisplayValue('Human')
await user.clear(userPrefix)
await user.type(userPrefix, 'Customer')
await user.clear(within(dialog).getByDisplayValue('Assistant'))
await user.type(
within(dialog).getByPlaceholderText('common.chat.conversationNamePlaceholder'),
'Expert',
)
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await publish(user)
expect(api.publish.mock.lastCall![0]).toMatchObject({
body: {
prompt_type: 'advanced',
completion_prompt_config: {
conversation_histories_role: { user_prefix: 'Customer', assistant_prefix: 'Expert' },
},
},
})
})
@@ -1,304 +0,0 @@
import type { ComponentProps } from 'react'
import type { ConfigurationViewModel } from '../hooks/configuration-view-model'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle'
import type { Plugin } from '@/app/components/plugins/types'
import type { AgentTool } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { createDebugConfigurationFixture } from '@/test/fixtures/debug-configuration'
import ConfigurationView from '../configuration-view'
const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: () => mockIsAgentV2Enabled(),
}))
vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({
default: () => <div data-testid="app-publisher" />,
}))
vi.mock('@/app/components/app/configuration/config', () => ({
default: () => <div data-testid="config-panel" />,
}))
vi.mock('@/app/components/app/configuration/debug', () => ({
default: () => <div data-testid="debug-panel" />,
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div data-testid="model-parameter-modal" />,
}),
)
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
default: () => <div data-testid="select-dataset" />,
}))
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
default: () => <div data-testid="history-modal" />,
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: () => <div data-testid="feature-panel" />,
}))
let pluginDependencyOnInstallComplete: InstallBundleCompleteCallback | undefined
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: ({ onInstallComplete }: { onInstallComplete?: InstallBundleCompleteCallback }) => {
pluginDependencyOnInstallComplete = onInstallComplete
return <div data-testid="plugin-dependency" />
},
}))
const createPlugin = (name: string): Plugin => ({
type: 'plugin',
org: 'vendor',
name,
plugin_id: 'vendor',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: `vendor/${name}:1.0.0`,
icon: 'icon.svg',
verified: true,
label: { 'en-US': name },
brief: { 'en-US': name },
description: { 'en-US': name },
introduction: '',
repository: `https://example.com/vendor/${name}`,
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
})
const createDeletedAgentTool = (providerId: string): AgentTool => ({
provider_id: providerId,
provider_type: CollectionType.builtIn,
provider_name: providerId,
tool_name: 'search',
tool_label: 'Search',
tool_parameters: {},
enabled: true,
isDeleted: true,
})
const createContextValue = () =>
createDebugConfigurationFixture({ appId: 'app-1', setModelConfig: vi.fn() })
const createViewModel = (
overrides: Partial<ConfigurationViewModel> = {},
): ConfigurationViewModel => ({
appPublisherProps: {
appId: 'app-1',
publishDisabled: false,
publishedAt: 0,
debugWithMultipleModel: false,
multipleModelConfigs: [],
onPublish: vi.fn(),
publishedConfig: {
modelConfig: createContextValue().modelConfig,
completionParams: {},
promptMode: createContextValue().promptMode,
chatPromptConfig: createContextValue().chatPromptConfig,
completionPromptConfig: createContextValue().completionPromptConfig,
datasetConfigs: createContextValue().datasetConfigs,
externalDataToolsConfig: createContextValue().externalDataToolsConfig,
},
resetAppConfig: vi.fn(),
} as ComponentProps<typeof AppPublisher>,
contextValue: createContextValue(),
featuresData: {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false, voice: '', language: '' },
file: {
enabled: false,
image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] },
} as never,
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
isAgent: false,
isAdvancedMode: false,
isMobile: false,
isShowDebugPanel: false,
isShowHistoryModal: false,
isShowSelectDataSet: false,
modelConfig: createContextValue().modelConfig,
multipleModelConfigs: [],
onAutoAddPromptVariable: vi.fn(),
onAgentSettingChange: vi.fn(),
onCloseFeaturePanel: vi.fn(),
onCloseHistoryModal: vi.fn(),
onCloseSelectDataSet: vi.fn(),
onCompletionParamsChange: vi.fn(),
onConfirmUseGPT4: vi.fn(),
onEnableMultipleModelDebug: vi.fn(),
onFeaturesChange: vi.fn(),
onHideDebugPanel: vi.fn(),
onModelChange: vi.fn(),
onMultipleModelConfigsChange: vi.fn(),
onOpenAccountSettings: vi.fn(),
onOpenDebugPanel: vi.fn(),
onSaveHistory: vi.fn(),
onSelectDataSets: vi.fn(),
promptVariables: [],
selectedIds: [],
showAppConfigureFeaturesModal: false,
showLoading: false,
showUseGPT4Confirm: false,
setShowUseGPT4Confirm: vi.fn(),
...overrides,
})
describe('ConfigurationView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsAgentV2Enabled.mockReturnValue(false)
pluginDependencyOnInstallComplete = undefined
})
it('should render a loading state before configuration data is ready', () => {
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument()
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
})
it('provides the page heading inside the parent-owned main landmark', () => {
render(<ConfigurationView {...createViewModel()} />)
expect(
screen.getByRole('heading', { level: 1, name: 'appDebug.orchestrate' }),
).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
it('should open the mobile debug panel from the header button', async () => {
const user = userEvent.setup()
const onOpenDebugPanel = vi.fn()
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
await user.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i }))
expect(onOpenDebugPanel).toHaveBeenCalledTimes(1)
})
it('should close the GPT-4 confirmation dialog when cancel is clicked', async () => {
const user = userEvent.setup()
const setShowUseGPT4Confirm = vi.fn()
render(
<ConfigurationView
{...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })}
/>,
)
await user.click(screen.getByRole('button', { name: /operation.cancel/i }))
expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false)
})
it('should show the legacy Agent badge for legacy Agent apps when Agent v2 is enabled', async () => {
const user = userEvent.setup()
mockIsAgentV2Enabled.mockReturnValue(true)
const contextValue = createContextValue()
contextValue.mode = 'agent-chat'
render(<ConfigurationView {...createViewModel({ contextValue })} />)
const badge = screen.getByRole('button', { name: 'appDebug.legacyAgentBadge.label' })
await user.click(badge)
expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeInTheDocument()
expect(
screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }),
).toHaveAttribute('href', '/agents')
expect(
screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }),
).toHaveAttribute('target', '_blank')
expect(
screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }),
).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should not show the legacy Agent badge when Agent v2 is disabled', () => {
const contextValue = createContextValue()
contextValue.mode = 'agent-chat'
render(<ConfigurationView {...createViewModel({ contextValue })} />)
expect(screen.queryByText('appDebug.legacyAgentBadge.label')).not.toBeInTheDocument()
})
it('should reinstate selected plugin tools when at least one installation succeeds', () => {
const contextValue = createContextValue()
contextValue.isAgent = true
const tools = [
createDeletedAgentTool('vendor/successful-plugin'),
createDeletedAgentTool('vendor/failed-plugin'),
createDeletedAgentTool('vendor/unselected-plugin'),
]
contextValue.modelConfig.agentConfig.tools = tools
render(<ConfigurationView {...createViewModel({ contextValue, isAgent: true })} />)
if (!pluginDependencyOnInstallComplete)
throw new Error('Plugin completion callback not registered')
pluginDependencyOnInstallComplete(
[createPlugin('successful-plugin'), createPlugin('failed-plugin')],
[
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: true },
],
[
{ hasInstalled: false, toInstallVersion: '1.0.0' },
{ hasInstalled: false, toInstallVersion: '1.0.0' },
],
)
expect(contextValue.setModelConfig).toHaveBeenCalledOnce()
const nextModelConfig = vi.mocked(contextValue.setModelConfig).mock.calls[0]![0]
expect(nextModelConfig.agentConfig.tools).toEqual([
expect.objectContaining({ provider_id: 'vendor/successful-plugin', isDeleted: false }),
expect.objectContaining({ provider_id: 'vendor/failed-plugin', isDeleted: false }),
expect.objectContaining({ provider_id: 'vendor/unselected-plugin', isDeleted: true }),
])
})
it('should keep deleted tools unchanged when every installation fails', () => {
const contextValue = createContextValue()
contextValue.isAgent = true
contextValue.modelConfig.agentConfig.tools = [createDeletedAgentTool('vendor/failed-plugin')]
render(<ConfigurationView {...createViewModel({ contextValue, isAgent: true })} />)
if (!pluginDependencyOnInstallComplete)
throw new Error('Plugin completion callback not registered')
pluginDependencyOnInstallComplete(
[createPlugin('failed-plugin')],
[{ success: false, isFromMarketPlace: true }],
[{ hasInstalled: false, toInstallVersion: '1.0.0' }],
)
expect(contextValue.setModelConfig).not.toHaveBeenCalled()
})
it('should not attach the agent tool completion handler to non-agent configurations', () => {
render(<ConfigurationView {...createViewModel()} />)
expect(pluginDependencyOnInstallComplete).toBeUndefined()
})
})
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfirmAddVar from '../index'
@@ -15,8 +15,9 @@ describe('ConfirmAddVar', () => {
onHide={vi.fn()}
/>,
)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
const confirmation = screen.getByRole('group', { name: 'appDebug.autoAddVar' })
expect(within(confirmation).getByText('foo')).toBeInTheDocument()
expect(within(confirmation).getByText('bar')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: `common.operation.${action}` }))
expect(action === 'add' ? onConfirm : onCancel).toHaveBeenCalledOnce()
expect(action === 'add' ? onCancel : onConfirm).not.toHaveBeenCalled()
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useRef } from 'react'
import { useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import VarHighlight from '../../base/var-highlight'
@@ -37,6 +37,7 @@ const ConfirmAddVar: FC<IConfirmAddVarProps> = ({
// onHide,
}) => {
const { t } = useTranslation(['appDebug', 'common'])
const titleId = useId()
const mainContentRef = useRef<HTMLDivElement>(null)
// new prompt editor blur trigger click...
// useClickAway(() => {
@@ -51,6 +52,8 @@ const ConfirmAddVar: FC<IConfirmAddVarProps> = ({
>
<div
ref={mainContentRef}
role="group"
aria-labelledby={titleId}
className="w-105 rounded-xl bg-components-panel-bg p-6"
style={{
boxShadow:
@@ -62,7 +65,7 @@ const ConfirmAddVar: FC<IConfirmAddVarProps> = ({
{VarIcon}
</div>
<div className="grow">
<div className="text-sm font-medium text-text-primary">
<div id={titleId} className="text-sm font-medium text-text-primary">
{t(($) => $.autoAddVar, { ns: 'appDebug' })}
</div>
<div className="mt-3.75 flex max-h-16.5 flex-wrap space-x-1 overflow-y-auto px-1">
@@ -1,259 +0,0 @@
import type { ComponentProps } from 'react'
import type { ConfigurationAppMode } from '@/app/components/app/configuration/hooks/configuration-lifecycle/types'
import type { ModelConfig, PromptVariable } from '@/models/debug'
import type { ToolItem } from '@/types/app'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithDebugConfiguration } from '@/test/debug-configuration'
import { AgentStrategy, ModelModeType } from '@/types/app'
import Config from '../index'
let configuration: Parameters<typeof renderWithDebugConfiguration>[1] = {}
const render = (ui: Parameters<typeof renderWithDebugConfiguration>[0]) =>
renderWithDebugConfiguration(ui, configuration)
const mockFormattingDispatcher = vi.fn()
vi.mock('../../debug/hooks', () => ({
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
}))
vi.mock('@/app/components/app/configuration/config-prompt', () => ({
default: (
props: ComponentProps<
typeof import('@/app/components/app/configuration/config-prompt').default
>,
) => (
<>
<textarea
aria-label="Prompt draft"
defaultValue={props.promptTemplate}
onChange={(event) =>
props.onChange?.(event.target.value, [{ key: 'new', name: 'New', type: 'string' }])
}
/>
<button
type="button"
onClick={() =>
props.onChange?.(props.promptTemplate, [{ key: 'added', name: 'Added', type: 'string' }])
}
>
Add prompt variable
</button>
</>
),
}))
vi.mock('@/app/components/app/configuration/config-var', () => ({
default: (
props: ComponentProps<typeof import('@/app/components/app/configuration/config-var').default>,
) => (
<button
type="button"
onClick={() =>
props.onPromptVariablesChange?.([{ key: 'replacement', name: 'Variable', type: 'string' }])
}
>
Replace variables
</button>
),
}))
vi.mock('../../dataset-config', () => ({
default: () => <div data-testid="dataset-config" />,
}))
vi.mock('../agent/agent-tools', () => ({
default: () => <div data-testid="agent-tools" />,
}))
vi.mock('../../config-vision', () => ({
default: () => <div data-testid="config-vision" />,
}))
vi.mock('../config-document', () => ({
default: () => <div data-testid="config-document" />,
}))
vi.mock('../config-audio', () => ({
default: () => <div data-testid="config-audio" />,
}))
type MockContext = {
mode: ConfigurationAppMode
isAdvancedMode: boolean
modelModeType: ModelModeType
isAgent: boolean
hasSetBlockStatus: {
context: boolean
history: boolean
query: boolean
}
showHistoryModal: () => void
modelConfig: ModelConfig
setModelConfig: (modelConfig: ModelConfig) => void
setPrevPromptConfig: (configs: ModelConfig['configs']) => void
}
const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => ({
key: 'variable',
name: 'Variable',
type: 'string',
...overrides,
})
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello {{variable}}',
prompt_variables: [createPromptVariable({ key: 'existing' })],
},
chat_prompt_config: null,
completion_prompt_config: null,
opening_statement: null,
more_like_this: null,
suggested_questions: null,
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: null,
dataSets: [],
agentConfig: {
enabled: false,
strategy: AgentStrategy.react,
max_iteration: 1,
tools: [] as ToolItem[],
},
...overrides,
})
const createContextValue = (overrides: Partial<MockContext> = {}): MockContext => ({
mode: 'chat' as const,
isAdvancedMode: false,
modelModeType: ModelModeType.chat,
isAgent: false,
hasSetBlockStatus: {
context: false,
history: true,
query: false,
},
showHistoryModal: vi.fn(),
modelConfig: createModelConfig(),
setModelConfig: vi.fn(),
setPrevPromptConfig: vi.fn(),
...overrides,
})
const renderConfig = (contextOverrides: Partial<MockContext> = {}) => {
const contextValue = createContextValue(contextOverrides)
configuration = contextValue
return {
contextValue,
...render(<Config />),
}
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering scenarios ensure the layout toggles agent/history specific sections correctly.
describe('Config - Rendering', () => {
it('should render baseline sections without agent specific panels', () => {
renderConfig()
expect(screen.getByRole('textbox', { name: 'Prompt draft' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace variables' })).toBeInTheDocument()
expect(screen.getByTestId('dataset-config')).toBeInTheDocument()
expect(screen.getByTestId('config-vision')).toBeInTheDocument()
expect(screen.getByTestId('config-document')).toBeInTheDocument()
expect(screen.getByTestId('config-audio')).toBeInTheDocument()
expect(screen.queryByTestId('agent-tools')).not.toBeInTheDocument()
expect(
screen.queryByRole('heading', { name: 'appDebug.feature.conversationHistory.title' }),
).not.toBeInTheDocument()
})
it('should show AgentTools when app runs in agent mode', () => {
renderConfig({ isAgent: true })
expect(screen.getByTestId('agent-tools')).toBeInTheDocument()
})
it('should display HistoryPanel for a chat app using an advanced completion prompt', async () => {
const user = userEvent.setup()
const showHistoryModal = vi.fn()
renderConfig({
isAdvancedMode: true,
mode: 'chat' as const,
modelModeType: ModelModeType.completion,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
showHistoryModal,
})
expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
expect(showHistoryModal).toHaveBeenCalledTimes(1)
})
})
// Prompt handling scenarios validate integration between Config and prompt children.
describe('Config - Prompt Handling', () => {
it('should update prompt template and dispatch formatting event when text changes', async () => {
const user = userEvent.setup()
const { contextValue } = renderConfig()
const previousVariables = contextValue.modelConfig.configs.prompt_variables
const additions = [createPromptVariable({ key: 'new', name: 'New' })]
const prompt = screen.getByRole('textbox', { name: 'Prompt draft' })
await user.clear(prompt)
await user.paste('Updated template')
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(contextValue.setModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
configs: expect.objectContaining({
prompt_template: 'Updated template',
prompt_variables: [...previousVariables, ...additions],
}),
}),
)
expect(mockFormattingDispatcher).toHaveBeenCalledTimes(2)
})
it('should skip formatting dispatcher when template remains identical', async () => {
const user = userEvent.setup()
const { contextValue } = renderConfig()
await user.click(screen.getByRole('button', { name: 'Add prompt variable' }))
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(mockFormattingDispatcher).not.toHaveBeenCalled()
})
it('should replace prompt variables when ConfigVar reports updates', async () => {
const user = userEvent.setup()
const { contextValue } = renderConfig()
const replacementVariables = [createPromptVariable({ key: 'replacement' })]
await user.click(screen.getByRole('button', { name: 'Replace variables' }))
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(contextValue.setModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
configs: expect.objectContaining({
prompt_variables: replacementVariables,
}),
}),
)
})
})
@@ -1,266 +1,185 @@
import type { Tool, ToolParameter } from '@/app/components/tools/types'
import { render, screen, waitFor } from '@testing-library/react'
import type { ComponentProps } from 'react'
import type { Credential } from '@/app/components/plugins/plugin-auth/types'
import { zToolApiEntity } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, cleanup, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { selectToolProvider } from '@/app/components/tools/utils/query-select'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createToolProviderFixture } from '@/test/console/tool-provider-fixture'
import SettingBuiltInTool from '../setting-built-in-tool'
const fetchBuiltInToolList = vi.fn()
const fetchCustomToolList = vi.fn()
const fetchWorkflowToolList = vi.fn()
vi.mock('@/service/tools', () => ({
fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName),
fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName),
fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId),
const api = vi.hoisted(() => ({
builtin:
vi.fn<
typeof import('@/service/console').consoleClient.workspaces.current.toolProvider.builtin.byProvider.tools.get
>(),
custom:
vi.fn<
typeof import('@/service/console').consoleClient.workspaces.current.toolProvider.api.tools.get
>(),
workflow:
vi.fn<
typeof import('@/service/console').consoleClient.workspaces.current.toolProvider.workflow.tools.get
>(),
credentials: vi.fn<
(url: string) => Promise<{
supported_credential_types: string[]
credentials: Credential[]
is_oauth_custom_client_enabled: boolean
}>
>(),
}))
let pluginAuthClickValue = 'credential-from-plugin'
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: { tool: 'tool' },
PluginAuthInAgent: (props: { onAuthorizationItemClick?: (id: string) => void }) => (
<div data-testid="plugin-auth">
<button type="button" onClick={() => props.onAuthorizationItemClick?.(pluginAuthClickValue)}>
choose-plugin-credential
</button>
</div>
),
}))
vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: ({ className }: { className?: string }) => (
<div className={className}>readme</div>
),
}))
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'settingParam',
label: {
en_US: 'Setting Param',
zh_Hans: 'Setting Param',
},
human_description: {
en_US: 'Setting description',
zh_Hans: 'Setting description',
},
type: 'string',
form: 'config',
llm_description: '',
required: true,
multiple: false,
default: '',
...overrides,
})
const createTool = (overrides?: Partial<Tool>): Tool => ({
name: 'search',
author: 'tester',
label: {
en_US: 'Search Tool',
zh_Hans: 'Search Tool',
},
description: {
en_US: 'tool description',
zh_Hans: 'tool description',
},
parameters: [
createParameter({
name: 'infoParam',
label: {
en_US: 'Info Param',
zh_Hans: 'Info Param',
},
human_description: {
en_US: 'Info description',
zh_Hans: 'Info description',
},
form: 'llm',
required: false,
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'workspaces.current.toolProvider.builtin.byProvider.tools.get': api.builtin,
'workspaces.current.toolProvider.api.tools.get': api.custom,
'workspaces.current.toolProvider.workflow.tools.get': api.workflow,
}),
createParameter(),
],
labels: [],
output_schema: {},
...overrides,
}
})
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
get: api.credentials,
}))
const baseCollection = {
id: 'provider-1',
name: 'vendor/provider-1',
author: 'tester',
description: {
en_US: 'desc',
zh_Hans: 'desc',
},
icon: 'https://example.com/icon.png',
label: {
en_US: 'Provider Label',
zh_Hans: 'Provider Label',
},
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: true,
labels: [],
tools: [createTool()],
}
const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuiltInTool>>) => {
const onHide = vi.fn()
const tool = zToolApiEntity.parse({
name: 'search',
author: 'Team',
label: { en_US: 'Search Tool' },
description: { en_US: 'Search documents' },
parameters: [
{
name: 'query',
label: { en_US: 'Query' },
type: 'string',
form: 'llm',
human_description: { en_US: 'Search query' },
},
{
name: 'settingParam',
label: { en_US: 'Setting Param' },
type: 'string',
form: 'form',
required: true,
human_description: { en_US: 'Setting description' },
},
],
})
const provider = createToolProviderFixture({
id: 'provider-id',
name: 'provider',
allow_delete: false,
tools: [tool],
})
const renderTool = (props: Partial<ComponentProps<typeof SettingBuiltInTool>> = {}) => {
const onSave = vi.fn()
const onAuthorizationItemClick = vi.fn()
const utils = render(
renderWithConsoleQuery(
<SettingBuiltInTool
collection={baseCollection}
collection={selectToolProvider(provider)}
toolName="search"
setting={{ settingParam: 'value' }}
onHide={onHide}
onHide={vi.fn()}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>,
{ workspacePermissionKeys: ['credential.use'] },
)
return {
...utils,
onHide,
onSave,
onAuthorizationItemClick,
}
return { onSave, onAuthorizationItemClick }
}
describe('SettingBuiltInTool', () => {
beforeEach(() => {
vi.resetAllMocks()
pluginAuthClickValue = 'credential-from-plugin'
})
it('should fetch tool list when collection has no tools', async () => {
fetchBuiltInToolList.mockResolvedValueOnce([createTool()])
renderComponent({
collection: {
...baseCollection,
tools: [],
},
})
await waitFor(() => {
expect(fetchBuiltInToolList).toHaveBeenCalledTimes(1)
expect(fetchBuiltInToolList).toHaveBeenCalledWith('vendor/provider-1')
})
expect(await screen.findByText('Search Tool')).toBeInTheDocument()
})
it('preserves the settings draft when switching named tab panels', async () => {
const user = userEvent.setup()
renderComponent()
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toBeInTheDocument()
})
const settingInput = screen.getByRole('textbox', { name: /Setting Param/ })
await user.clear(settingInput)
await user.type(settingInput, 'unsaved draft')
await user.click(screen.getByRole('tab', { name: 'tools.setBuiltInTools.parameters' }))
expect(
screen.getByRole('tabpanel', { name: 'tools.setBuiltInTools.parameters' }),
).toHaveTextContent('Info Param')
expect(screen.queryByRole('textbox', { name: /Setting Param/ })).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'tools.setBuiltInTools.setting' }))
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toHaveValue('unsaved draft')
expect(screen.getByRole('dialog', { name: 'Search Tool' })).toBeInTheDocument()
})
it('should keep the setting tab hidden when readonly by default', async () => {
renderComponent({ readonly: true })
expect(await screen.findByText('Info Param')).toBeInTheDocument()
expect(screen.queryByText('tools.setBuiltInTools.setting')).not.toBeInTheDocument()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument()
})
it('should expose readonly setting details when explicitly enabled', async () => {
const user = userEvent.setup()
renderComponent({ readonly: true, showReadOnlySettingDetails: true })
expect(await screen.findByText('Info Param')).toBeInTheDocument()
await user.click(screen.getByText('tools.setBuiltInTools.setting'))
expect(screen.getByText('Setting Param')).toBeInTheDocument()
expect(screen.getByText('tools.setBuiltInTools.string')).toBeInTheDocument()
expect(screen.getByText('Setting description')).toBeInTheDocument()
expect(screen.queryByText('Info Param')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: /Setting Param/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
})
it('should call onSave with updated values when save button clicked', async () => {
const { onSave } = renderComponent()
await waitFor(() =>
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toBeInTheDocument(),
)
const user = userEvent.setup()
const settingInput = screen.getByRole('textbox', { name: /Setting Param/ })
await user.clear(settingInput)
await user.type(settingInput, 'updated')
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ settingParam: 'updated' }))
})
it('should keep save disabled until required field provided', async () => {
renderComponent({
setting: {},
})
await waitFor(() =>
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toBeInTheDocument(),
)
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
expect(saveButton).toBeDisabled()
const user = userEvent.setup()
await user.type(screen.getByRole('textbox', { name: /Setting Param/ }), 'filled')
expect(saveButton).not.toBeDisabled()
})
it('should call onHide when cancel button is pressed', async () => {
const { onHide } = renderComponent()
await waitFor(() =>
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toBeInTheDocument(),
)
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onHide).toHaveBeenCalled()
})
it('should trigger authorization callback from plugin auth section', async () => {
const { onAuthorizationItemClick } = renderComponent()
await userEvent.click(screen.getByRole('button', { name: 'choose-plugin-credential' }))
expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-from-plugin')
})
it('should call onHide when back button is clicked', async () => {
const { onHide } = renderComponent({
showBackButton: true,
})
await userEvent.click(screen.getByText('plugin.detailPanel.operation.back'))
expect(onHide).toHaveBeenCalled()
})
it('should load workflow tools when workflow collection is provided', async () => {
fetchWorkflowToolList.mockResolvedValueOnce([
createTool({
name: 'workflow-tool',
}),
])
renderComponent({
collection: {
...baseCollection,
type: CollectionType.workflow,
tools: [],
id: 'workflow-1',
},
isBuiltIn: false,
})
await waitFor(() => {
expect(fetchWorkflowToolList).toHaveBeenCalledWith('workflow-1')
})
beforeEach(() => {
api.builtin.mockReset().mockResolvedValue([tool])
api.custom.mockReset().mockResolvedValue([tool])
api.workflow.mockReset().mockResolvedValue([tool])
api.credentials.mockReset().mockImplementation(async (url) => {
if (url !== '/workspaces/current/tool-provider/builtin/vendor/provider/credential/info')
throw new Error(`Unexpected credential request: ${url}`)
return {
supported_credential_types: ['api-key'],
is_oauth_custom_client_enabled: false,
credentials: [
{
id: 'credential-id',
name: 'Team credential',
provider: 'vendor/provider',
credential_type: 'api-key',
is_default: true,
},
],
}
})
})
afterEach(async () => {
await act(async () => cleanup())
})
it.each(['builtin', 'api', 'workflow'] as const)(
'loads %s actions when the provider does not include them',
async (type) => {
renderTool({
collection: selectToolProvider(createToolProviderFixture({ ...provider, type, tools: [] })),
isBuiltIn: type === 'builtin',
})
expect(await screen.findByRole('dialog', { name: 'Search Tool' })).toBeInTheDocument()
expect(await screen.findByRole('textbox', { name: /Setting Param/ })).toHaveValue('value')
if (type === 'builtin')
expect(api.builtin).toHaveBeenCalledWith({ params: { provider: 'provider' } })
else if (type === 'api')
expect(api.custom).toHaveBeenCalledWith({ query: { provider: 'provider' } })
else expect(api.workflow).toHaveBeenCalledWith({ query: { workflow_tool_id: 'provider-id' } })
},
)
it('requires settings before saving and preserves the draft across the parameter tab', async () => {
const user = userEvent.setup()
const { onSave } = renderTool({ setting: {} })
const input = await screen.findByRole('textbox', { name: /Setting Param/ })
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
await user.type(input, 'Unsaved draft')
await user.click(screen.getByRole('tab', { name: 'tools.setBuiltInTools.parameters' }))
expect(
screen.getByRole('tabpanel', { name: 'tools.setBuiltInTools.parameters' }),
).toHaveTextContent('Search query')
await user.click(screen.getByRole('tab', { name: 'tools.setBuiltInTools.setting' }))
expect(screen.getByRole('textbox', { name: /Setting Param/ })).toHaveValue('Unsaved draft')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledExactlyOnceWith({ settingParam: 'Unsaved draft' })
})
it('limits the default read-only view to action parameters', async () => {
renderTool({ readonly: true })
expect(await screen.findByText('Query')).toBeInTheDocument()
expect(
screen.queryByRole('tab', { name: 'tools.setBuiltInTools.setting' }),
).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
})
it('selects an available credential through the real authorization menu', async () => {
const user = userEvent.setup()
const { onAuthorizationItemClick } = renderTool({
collection: selectToolProvider(
createToolProviderFixture({
...provider,
name: 'vendor/provider',
plugin_id: 'vendor/provider',
allow_delete: true,
is_team_authorization: true,
}),
),
})
await user.click(await screen.findByRole('button', { name: 'plugin.auth.workspaceDefault' }))
const menu = await screen.findByRole('dialog', { name: 'plugin.auth.authorizations' })
await user.click(within(menu).getByText('Team credential'))
expect(onAuthorizationItemClick).toHaveBeenCalledExactlyOnceWith('credential-id')
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'plugin.auth.authorizations' }),
).not.toBeInTheDocument(),
)
})
@@ -1,6 +1,14 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import GetAutomaticRes from '../get-automatic-res'
vi.mock('react-i18next', async () => {
@@ -11,27 +19,6 @@ vi.mock('react-i18next', async () => {
return createReactI18nextMock({ ...appDebug, ...appGeneration, ...common })
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
async (importOriginal) => ({
...(await importOriginal<
typeof import('@/app/components/header/account-setting/model-provider-page/hooks')
>()),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: { model: 'gpt-4.1-mini', provider: { provider: 'openai' } },
}),
}),
)
// The independent model picker is outside this regression; the real generator,
// Lexical editors, result actions, and overlay remain the layout owners under test.
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <button type="button">gpt-4.1-mini</button>,
}),
)
vi.mock('@/service/debug', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/debug')>()),
generateBasicAppFirstTimeRule: async () => ({
@@ -56,28 +43,47 @@ describe('Prompt generator reflow', () => {
sessionStorage.clear()
})
afterEach(async () => {
await page.viewport(1280, 720)
})
it.each([320])(
'keeps inputs and generated result actions within a %i CSS pixel viewport',
async (width) => {
// Unit tests cannot detect the original fixed 1140px popup being clipped.
// Rendering the production dialog and Result proves both panels remain usable.
await page.viewport(width, 900)
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'gpt-4.1-mini',
label: { en_US: 'GPT-4.1 mini' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4.1-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{ initialData: { data: [] } },
)
const screen = await render(
<QueryClientProvider client={queryClient}>
<GetAutomaticRes
modelMode="chat"
isShow
onClose={vi.fn()}
onFinished={vi.fn()}
flowId={`reflow-${width}`}
isBasicMode
/>
</QueryClientProvider>,
<NuqsTestingAdapter>
<QueryProvider>
<GetAutomaticRes
modelMode="chat"
isShow
onClose={vi.fn()}
onFinished={vi.fn()}
flowId={`reflow-${width}`}
isBasicMode
/>
</QueryProvider>
</NuqsTestingAdapter>,
)
const dialog = screen.getByRole('dialog', { name: 'Prompt Generator' })
@@ -101,7 +107,19 @@ describe('Prompt generator reflow', () => {
expect(bounds.left).toBeGreaterThanOrEqual(0)
expect(bounds.right).toBeLessThanOrEqual(width)
expect(dialogElement.scrollWidth).toBeLessThanOrEqual(dialogElement.clientWidth + 1)
for (const element of [instructions, generate, apply, copy, result])
const modelSelector = screen.getByRole('button', { name: /^GPT-4.1 mini/ })
const modelSettings = screen.getByRole('button', { name: /modelProvider.modelSettings/ })
await expect.element(modelSelector).toBeVisible()
await expect.element(modelSettings).toBeVisible()
for (const element of [
instructions,
generate,
apply,
copy,
result,
modelSelector,
modelSettings,
])
expectWithin(element.element(), dialogElement)
const instructionsRect = instructions.element().getBoundingClientRect()
@@ -1,22 +1,24 @@
import type { ReactElement } from 'react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryWrapper, seedAppDslVersion } from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import GetAutomaticRes from '../get-automatic-res'
const mockGenerateBasicAppFirstTimeRule = vi.fn()
const mockGenerateRule = vi.fn()
const mockToastError = vi.fn()
let mockDefaultModel: {
model: string
provider: {
provider: string
}
} | null = null
let mockInstructionTemplate: { data: string } | undefined
vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
@@ -24,34 +26,63 @@ vi.mock('@/app/components/app/configuration/toast', () => ({
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: mockDefaultModel,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: () => ({ availableVars: [], availableNodes: [] }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-variables', () => ({
useWorkflowVariableType: () => () => 'string',
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({ getState: () => ({ nodesWithInspectVars: [] }) }),
}))
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
const render = createNuqsTestRenderer((ui) => {
const { queryClient } = createConsoleQueryWrapper()
seedAppDslVersion(queryClient)
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: ['gpt-4.1-mini', 'gpt-4o-mini'].map((model) =>
createCatalogModelFixture({
model,
label: { en_US: model },
model_properties: { mode: 'chat' },
}),
),
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4.1-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{
initialData: {
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
},
],
},
},
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({
input: { body: { type: 'prompt' } },
}).queryKey,
mockInstructionTemplate ?? { data: '' },
)
return renderWithConsoleQuery(<EventEmitterContextProvider>{ui}</EventEmitterContextProvider>, {
queryClient,
})
}
return renderWorkflowFlowComponent(
<EventEmitterContextProvider>{ui}</EventEmitterContextProvider>,
{
queryClient,
},
)
})
const instruction = () => screen.getByRole('textbox', { name: /instruction/ })
vi.mock('@/service/debug', () => ({
@@ -59,37 +90,6 @@ vi.mock('@/service/debug', () => ({
generateRule: (...args: unknown[]) => mockGenerateRule(...args),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: ({
setModel,
onCompletionParamsChange,
}: {
setModel: (value: {
modelId: string
provider: string
mode?: string
features?: string[]
}) => void
onCompletionParamsChange: (value: Record<string, unknown>) => void
}) => (
<div>
<button
onClick={() =>
setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' as const })
}
>
change-model
</button>
<button onClick={() => onCompletionParamsChange({ temperature: 0.3 })}>
change-params
</button>
</div>
),
}),
)
describe('GetAutomaticRes', () => {
const mockOnClose = vi.fn()
const mockOnFinished = vi.fn()
@@ -98,12 +98,6 @@ describe('GetAutomaticRes', () => {
vi.resetAllMocks()
localStorage.clear()
sessionStorage.clear()
mockDefaultModel = {
model: 'gpt-4.1-mini',
provider: {
provider: 'openai',
},
}
mockInstructionTemplate = undefined
})
@@ -125,15 +119,24 @@ describe('GetAutomaticRes', () => {
await user.click(screen.getByText(/(?:^|\.)generate\.template\.pythonDebugger\.name(?=$|:)/))
await waitFor(() => {
expect(instruction()).toHaveValue(
expect(instruction()).toHaveTextContent(
'appGeneration.generate.template.pythonDebugger.instruction',
)
})
await user.click(screen.getByText('change-model'))
await user.click(screen.getByRole('button', { name: /^gpt-4.1-mini/ }))
await user.click(
within(
await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
).getByRole('button', { name: /^gpt-4o-mini/ }),
)
expect(localStorage.getItem('auto-gen-model')).toContain('"name":"gpt-4o-mini"')
await user.click(screen.getByText('change-params'))
await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
await user.click(screen.getByRole('switch', { name: 'Temperature' }))
await user.clear(screen.getByRole('spinbutton', { name: 'Temperature' }))
await user.type(screen.getByRole('spinbutton', { name: 'Temperature' }), '0.3')
await user.tab()
expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.3')
})
@@ -152,12 +155,12 @@ describe('GetAutomaticRes', () => {
)
await waitFor(() => {
expect(instruction()).toHaveValue('template instruction')
expect(instruction()).toHaveTextContent('template instruction')
})
await user.clear(instruction())
await replacePromptEditorText(instruction(), '')
await user.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/))
expect(instruction()).toHaveValue('')
expect(instruction().textContent).toBe('')
expect(mockToastError).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/),
)
@@ -205,7 +208,8 @@ describe('GetAutomaticRes', () => {
/>,
)
await user.type(instruction(), 'basic instruction')
await user.click(instruction())
await user.paste('basic instruction')
await user.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/))
await waitFor(() => {
@@ -269,9 +273,11 @@ describe('GetAutomaticRes', () => {
/>,
)
await user.type(instruction(), 'workflow instruction')
await user.click(instruction())
await user.paste('workflow instruction')
await user.click(screen.getByRole('button', { name: /generate.idealOutput/ }))
await user.type(screen.getByRole('textbox', { name: /generate.idealOutput/ }), 'ideal output')
await user.click(screen.getByRole('textbox', { name: /generate.idealOutput/ }))
await user.paste('ideal output')
await user.click(screen.getByText(/(?:^|\.)generate\.generate(?=$|:)/))
await waitFor(() => {
@@ -294,20 +300,3 @@ describe('GetAutomaticRes', () => {
expect(screen.getByText(/generate.newNoDataLine1/)).toBeInTheDocument()
})
})
// PromptEditor owns rich text behavior; this feature owns instruction persistence and generation.
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({
value,
onChange,
'aria-label': label,
'aria-labelledby': labelledBy,
}: import('@/app/components/base/prompt-editor').PromptEditorProps) => (
<textarea
aria-label={label}
aria-labelledby={labelledBy}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
),
}))
@@ -284,6 +284,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
</div>
<div>
<ModelParameterModal
triggerContainerClassName="min-w-0"
popupClassName="w-130! max-w-[calc(100vw-2rem)]"
isAdvancedMode={true}
provider={model.provider}
@@ -1,22 +1,24 @@
import type { ReactElement } from 'react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryWrapper, seedAppDslVersion } from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import GetCodeGeneratorResModal from '../get-code-generator-res'
const mockGenerateRule = vi.fn()
const mockToastError = vi.fn()
let mockDefaultModel: {
model: string
provider: {
provider: string
}
} | null = null
let mockInstructionTemplate: { data: string } | undefined
vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
@@ -24,78 +26,69 @@ vi.mock('@/app/components/app/configuration/toast', () => ({
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: mockDefaultModel,
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: () => ({ availableVars: [], availableNodes: [] }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-variables', () => ({
useWorkflowVariableType: () => () => 'string',
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({ getState: () => ({ nodesWithInspectVars: [] }) }),
}))
const render = (ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
const render = createNuqsTestRenderer((ui) => {
const { queryClient } = createConsoleQueryWrapper()
seedAppDslVersion(queryClient)
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: ['gpt-4.1-mini', 'gpt-4o-mini'].map((model) =>
createCatalogModelFixture({
model,
label: { en_US: model },
model_properties: { mode: 'chat' },
}),
),
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4.1-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{
initialData: {
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
},
],
},
},
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({
input: { body: { type: 'code' } },
}).queryKey,
mockInstructionTemplate ?? { data: '' },
)
return renderWithConsoleQuery(<EventEmitterContextProvider>{ui}</EventEmitterContextProvider>, {
queryClient,
})
}
return renderWorkflowFlowComponent(
<EventEmitterContextProvider>{ui}</EventEmitterContextProvider>,
{
queryClient,
},
)
})
const instruction = () => screen.getByRole('textbox', { name: /instruction/ })
vi.mock('@/service/debug', () => ({
generateRule: (...args: unknown[]) => mockGenerateRule(...args),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: ({
setModel,
onCompletionParamsChange,
}: {
setModel: (value: {
modelId: string
provider: string
mode?: string
features?: string[]
}) => void
onCompletionParamsChange: (value: Record<string, unknown>) => void
}) => (
<div>
<button
onClick={() =>
setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' as const })
}
>
change-model
</button>
<button onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>
change-params
</button>
</div>
),
}),
)
vi.mock(
'@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor',
() => ({
default: ({ value }: { value: string }) => <div data-testid="code-result-panel">{value}</div>,
}),
)
describe('GetCodeGeneratorResModal', () => {
const mockOnClose = vi.fn()
const mockOnFinished = vi.fn()
@@ -104,12 +97,6 @@ describe('GetCodeGeneratorResModal', () => {
vi.resetAllMocks()
localStorage.clear()
sessionStorage.clear()
mockDefaultModel = {
model: 'gpt-4.1-mini',
provider: {
provider: 'openai',
},
}
mockInstructionTemplate = undefined
})
@@ -131,13 +118,21 @@ describe('GetCodeGeneratorResModal', () => {
)
await waitFor(() => {
expect(instruction()).toHaveValue('code template')
expect(instruction()).toHaveTextContent('code template')
})
await user.click(screen.getByText('change-model'))
await user.click(screen.getByRole('button', { name: /^gpt-4.1-mini/ }))
await user.click(
within(
await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
).getByRole('button', { name: /^gpt-4o-mini/ }),
)
expect(localStorage.getItem('auto-gen-model')).toContain('"name":"gpt-4o-mini"')
await user.click(screen.getByText('change-params'))
await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
await user.clear(screen.getByRole('spinbutton', { name: 'Temperature' }))
await user.type(screen.getByRole('spinbutton', { name: 'Temperature' }), '0.2')
await user.tab()
expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.2')
})
@@ -159,12 +154,12 @@ describe('GetCodeGeneratorResModal', () => {
)
await waitFor(() => {
expect(instruction()).toHaveValue('code template')
expect(instruction()).toHaveTextContent('code template')
})
await user.clear(instruction())
await replacePromptEditorText(instruction(), '')
await user.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/))
expect(instruction()).toHaveValue('')
expect(instruction().textContent).toBe('')
expect(mockToastError).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)errorMsg\.fieldRequired(?=$|:)/),
)
@@ -190,9 +185,11 @@ describe('GetCodeGeneratorResModal', () => {
/>,
)
await user.type(instruction(), 'code instruction')
await user.click(instruction())
await user.paste('code instruction')
await user.click(screen.getByRole('button', { name: /generate.idealOutput/ }))
await user.type(screen.getByRole('textbox', { name: /generate.idealOutput/ }), 'code output')
await user.click(screen.getByRole('textbox', { name: /generate.idealOutput/ }))
await user.paste('code output')
await user.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/))
await waitFor(() => {
@@ -210,7 +207,7 @@ describe('GetCodeGeneratorResModal', () => {
})
await waitFor(() => {
expect(screen.getByTestId('code-result-panel')).toHaveTextContent('print("hello")')
expect(screen.getByTestId('monaco-editor')).toHaveValue('print("hello")')
})
await user.click(screen.getByRole('button', { name: /(?:^|\.)generate\.apply(?=$|:)/ }))
@@ -259,30 +256,14 @@ describe('GetCodeGeneratorResModal', () => {
/>,
)
await user.type(instruction(), 'code instruction')
await user.click(instruction())
await user.paste('code instruction')
await user.click(screen.getByText(/(?:^|\.)codegen\.generate(?=$|:)/))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('generation failed')
})
expect(screen.queryByTestId('code-result-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument()
})
})
// PromptEditor owns rich text behavior; this feature owns instruction persistence and generation.
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({
value,
onChange,
'aria-label': label,
'aria-labelledby': labelledBy,
}: import('@/app/components/base/prompt-editor').PromptEditorProps) => (
<textarea
aria-label={label}
aria-labelledby={labelledBy}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
),
}))
@@ -1,15 +1,11 @@
import type { DatasetDetailWithPartialMembersResponse } from '@dify/contracts/api/console/datasets/types.gen'
import type { MockedFunction, MockInstance } from 'vite-plus/test'
import type { MockInstance } from 'vite-plus/test'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { DatasetConfigs } from '@/models/debug'
import type { RetrievalConfig } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/app/configuration/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
ChunkingMode,
DataSourceType,
@@ -17,50 +13,19 @@ import {
WeightedScoreEnum,
} from '@/models/datasets'
import { PermissionLevel } from '@/models/permission'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import ConfigContent from '../config-content'
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
value?: { provider: string; model: string }
onValueChange?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ value, onValueChange }: Props) => (
<button
type="button"
onClick={() => onValueChange?.(value ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
ModelSelector: MockModelSelector,
}
const render = createNuqsTestRenderer((ui: React.ReactElement) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
return renderWithConsoleQuery(ui, { queryClient })
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div data-testid="model-parameter-modal" />,
}),
)
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(),
useCurrentProviderAndModel: vi.fn(),
}))
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel =
useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<
typeof useModelListAndDefaultModelAndCurrentProviderAndModel
>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<
typeof useCurrentProviderAndModel
>
let toastErrorSpy: MockInstance
const baseRetrievalConfig: RetrievalConfig = {
@@ -177,16 +142,6 @@ describe('ConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
afterEach(() => {
@@ -1,58 +1,24 @@
import type { MockedFunction, MockInstance } from 'vite-plus/test'
import type { MockInstance } from 'vite-plus/test'
import type { DatasetConfigs } from '@/models/debug'
import { render, screen, waitFor, within } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast } from '@/app/components/app/configuration/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import ConfigContext from '@/context/debug-configuration'
import { RerankingModeEnum } from '@/models/datasets'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDebugConfigurationFixture } from '@/test/fixtures/debug-configuration'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { RETRIEVE_TYPE } from '@/types/app'
import ParamsConfig from '../index'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(),
useCurrentProviderAndModel: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
value?: { provider: string; model: string }
onValueChange?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ value, onValueChange }: Props) => (
<button
type="button"
onClick={() => onValueChange?.(value ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
ModelSelector: MockModelSelector,
}
const render = createNuqsTestRenderer((ui: React.ReactElement) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
return renderWithConsoleQuery(ui, { queryClient })
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div data-testid="model-parameter-modal" />,
}),
)
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel =
useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<
typeof useModelListAndDefaultModelAndCurrentProviderAndModel
>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<
typeof useCurrentProviderAndModel
>
let toastErrorSpy: MockInstance
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
@@ -101,7 +67,11 @@ const renderParamsConfig = ({
return <ConfigContext.Provider value={contextValue}>{children}</ConfigContext.Provider>
}
return render(<ParamsConfig disabled={disabled} selectedDatasets={[]} />, { wrapper: Wrapper })
return render(
<Wrapper>
<ParamsConfig disabled={disabled} selectedDatasets={[]} />
</Wrapper>,
)
}
describe('dataset-config/params-config', () => {
@@ -109,42 +79,25 @@ describe('dataset-config/params-config', () => {
vi.clearAllMocks()
vi.useRealTimers()
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => '')
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
afterEach(() => {
toastErrorSpy.mockRestore()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should disable settings trigger when disabled is true', () => {
// Arrange
renderParamsConfig({ disabled: true })
// Assert
// Assert
expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))!.toBeDisabled()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open modal and persist changes when save is clicked', async () => {
// Arrange
renderParamsConfig()
const user = userEvent.setup()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
@@ -168,17 +121,13 @@ describe('dataset-config/params-config', () => {
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
// Assert
expect(reopenedTopKInput)!.toHaveValue('5')
})
it('should discard changes when cancel is clicked', async () => {
// Arrange
renderParamsConfig()
const user = userEvent.setup()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
@@ -205,13 +154,10 @@ describe('dataset-config/params-config', () => {
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert
// Assert
expect(reopenedTopKInput)!.toHaveValue('4')
})
it('should prevent saving when rerank model is required but invalid', async () => {
// Arrange
renderParamsConfig({
datasetConfigs: createDatasetConfigs({
reranking_enable: true,
@@ -221,12 +167,10 @@ describe('dataset-config/params-config', () => {
})
const user = userEvent.setup()
// Act
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(toastErrorSpy).toHaveBeenCalledWith('appDebug.datasetConfig.rerankModelRequired')
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
@@ -12,6 +12,11 @@ import { PermissionLevel } from '@/models/permission'
import { consoleQuery } from '@/service/console'
import { updateDatasetSetting } from '@/service/datasets'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { renderWithConsoleQuery as renderWithoutUrlState } from '@/test/console/query-data'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { createDatasetFixture } from '@/test/fixtures/dataset'
@@ -54,40 +59,16 @@ const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockModelListQuery = vi.fn()
const mockModelListQueryAndDefaultModel = vi.fn()
const mockModelListQueryAndDefaultModelAndCurrentProviderAndModel = vi.fn()
const mockUseCurrentProviderAndModel = vi.fn()
vi.mock('@/app/components/datasets/create/step-two', () => ({
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
},
}))
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
vi.mock('@/context/i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/i18n')>()),
useDocLink: () => (path: string) => `https://docs${path}`,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModel: (...args: unknown[]) => mockModelListQueryAndDefaultModel(...args),
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockModelListQueryAndDefaultModelAndCurrentProviderAndModel(...args),
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div data-testid="model-selector">
{value ? `${value.provider}/${value.model}` : 'no-model'}
</div>
),
}))
const mockUpdateDatasetSetting = updateDatasetSetting as MockedFunction<typeof updateDatasetSetting>
const mockUseMembers = mockMembersQuery as MockedFunction<typeof mockMembersQuery>
@@ -182,14 +163,12 @@ const renderWithProviders = (dataset: DatasetDetailWithPartialMembersResponse) =
consoleQuery.workspaces.current.members.get.queryOptions({ input: {} }).queryKey,
mockUseMembers().data,
)
for (const modelType of [ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank]) {
queryClient.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
input: { params: { model_type: modelType } },
}).queryKey,
mockModelListQuery(modelType),
)
}
seedModelProviderQueries(
queryClient,
[ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].flatMap(
(type) => mockModelListQuery(type).data,
),
)
return render(
<SettingsModal currentDataset={dataset} onCancel={mockOnCancel} onSave={mockOnSave} />,
{ queryClient },
@@ -231,25 +210,19 @@ describe('SettingsModal', () => {
],
},
} as ReturnType<typeof mockMembersQuery>)
mockModelListQuery.mockImplementation((type: ModelTypeEnum) => {
if (type === ModelTypeEnum.rerank) {
return {
data: [
{
provider: 'rerank-provider',
models: [{ model: 'rerank-model' }],
},
mockModelListQuery.mockImplementation((type: ModelTypeEnum) => ({
data: [
createModelCatalogFixture({
provider: type === ModelTypeEnum.rerank ? 'rerank-provider' : 'embed-provider',
models: [
createCatalogModelFixture({
model: type === ModelTypeEnum.rerank ? 'rerank-model' : 'embed-model',
model_type: type,
}),
],
}
}
return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] }
})
mockModelListQueryAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
mockModelListQueryAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
defaultModel: null,
currentModel: null,
})
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
}),
],
}))
mockUpdateDatasetSetting.mockResolvedValue(createDataset())
})
@@ -10,41 +10,17 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro
import { ChunkingMode, DataSourceType, RerankingModeEnum } from '@/models/datasets'
import { PermissionLevel } from '@/models/permission'
import { consoleQuery } from '@/service/console'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import { withSelectorKey } from '@/test/i18n-mock'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { RETRIEVE_METHOD } from '@/types/app'
import { RetrievalChangeTip, RetrievalSection } from '../retrieval-section'
const mockModelListQueryAndDefaultModel = vi.fn()
const mockModelListQueryAndDefaultModelAndCurrentProviderAndModel = vi.fn()
const mockUseCurrentProviderAndModel = vi.fn()
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockModelListQueryAndDefaultModelAndCurrentProviderAndModel(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockModelListQueryAndDefaultModel(...args),
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: ({ value }: { value?: { provider: string; model: string } }) => (
<div data-testid="model-selector">
{value ? `${value.provider}/${value.model}` : 'no-model'}
</div>
),
}))
vi.mock('@/app/components/datasets/create/step-two', () => ({
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
},
}))
const render = (ui: ReactElement) => {
const render = createNuqsTestRenderer((ui: ReactElement) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
queryClient.setQueryData(consoleQuery.datasets.retrievalSetting.get.queryOptions().queryKey, {
retrieval_method: [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid],
})
@@ -55,7 +31,7 @@ const render = (ui: ReactElement) => {
{ data: [] },
)
return renderWithConsoleQuery(ui, { queryClient })
}
})
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
@@ -170,12 +146,6 @@ describe('RetrievalSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelListQueryAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
mockModelListQueryAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
defaultModel: null,
currentModel: null,
})
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
})
it('renders external retrieval details and propagates changes', async () => {
File diff suppressed because it is too large. Load diff
@@ -1,525 +0,0 @@
import type { ReactElement, ReactNode } from 'react'
import type { ModelAndParameter } from '../../types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { ChatConfig, ChatItem as ChatItemType, OnSend } from '@/app/components/base/chat/types'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Log from '@/app/components/base/chat/chat/log'
import { TransferMethod } from '@/app/components/base/chat/types'
import {
ModelFeatureEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../../types'
import ChatItem from '../chat-item'
import { DebugWithMultipleModelContextProvider } from '../context-provider'
const mockUseDebugConfigurationContext = vi.fn()
const modelListResponse = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseConfigFromDebugContext = vi.fn()
const mockUseFormattingChangedSubscription = vi.fn()
const mockUseChat = vi.fn()
const mockFetchConversationMessages = vi.fn()
const mockFetchSuggestedQuestions = vi.fn()
const mockStopChatMessageResponding = vi.fn()
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
let capturedChatProps: {
onOpenLog: (item: IChatItem) => void
config: ChatConfig
chatList: ChatItemType[]
isResponding: boolean
onSend: OnSend
suggestedQuestions: string[]
allToolIcons: Record<string, string | undefined>
} | null = null
let emitDebugEvent: ((v: { type: string; payload?: Record<string, unknown> }) => void) | null = null
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
}))
vi.mock('../../hooks', () => ({
useConfigFromDebugContext: () => mockUseConfigFromDebugContext(),
useFormattingChangedSubscription: (chatList: ChatItemType[]) =>
mockUseFormattingChangedSubscription(chatList),
}))
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
useChat: () => mockUseChat(),
}))
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
}))
vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/app/components/base/chat/chat', () => ({
default: (props: typeof capturedChatProps) => {
capturedChatProps = props
return (
<div data-testid="chat-component">
{props?.chatList[0] && <Log logItem={props.chatList[0]} onOpenLog={props.onOpenLog} />}
<span data-testid="chat-list-length">{props?.chatList?.length || 0}</span>
<span data-testid="is-responding">{props?.isResponding ? 'yes' : 'no'}</span>
<button
data-testid="send-button"
onClick={() =>
props?.onSend?.('test message', [
{
id: 'file-1',
name: 'test.txt',
size: 100,
type: 'text/plain',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
},
])
}
>
Send
</button>
</div>
)
},
}))
function EventSource({ children }: { children: ReactNode }) {
const { eventEmitter } = useEventEmitterContextContext()
emitDebugEvent = (event) => eventEmitter!.emit(event)
return children
}
function render(ui: ReactElement) {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
}).queryKey,
modelListResponse(),
)
return renderWithConsoleQuery(
<EventEmitterContextProvider>
<EventSource>{ui}</EventSource>
</EventEmitterContextProvider>,
{ queryClient },
)
}
const createModelAndParameter = (
overrides: Partial<ModelAndParameter> = {},
): ModelAndParameter => ({
id: 'model-1',
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
const createDefaultMocks = () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
configs: { prompt_variables: [] },
agentConfig: { tools: [] },
},
appId: 'app-123',
inputs: { key: 'value' },
collectionList: [],
canTestAndRun: true,
})
modelListResponse.mockReturnValue({
data: [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
features: [ModelFeatureEnum.vision],
model_properties: { mode: 'chat' },
},
],
},
],
})
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
const state = {
features: {
moreLikeThis: { enabled: false },
opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
moderation: { enabled: false },
speech2text: { enabled: true },
text2speech: { enabled: false },
file: { enabled: true },
suggested: { enabled: true },
citation: { enabled: false },
annotationReply: { enabled: false },
},
}
return selector(state)
})
mockUseConfigFromDebugContext.mockReturnValue({
base_config: 'test',
})
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1', content: 'Hello', isAnswer: true }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: ['Question 1', 'Question 2'],
handleRestart: vi.fn(),
})
}
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
const defaultProps = {
modelAndParameter: createModelAndParameter(),
...props,
}
return render(<ChatItem {...defaultProps} />)
}
describe('ChatItem', () => {
beforeEach(() => {
vi.resetAllMocks()
capturedChatProps = null
emitDebugEvent = null
createDefaultMocks()
})
it('opens this model result through the owning debug session', async () => {
const user = userEvent.setup()
const onOpenLog = vi.fn()
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={[]}
onMultipleModelConfigsChange={vi.fn()}
onDebugWithMultipleModelChange={vi.fn()}
onOpenLog={onOpenLog}
>
<ChatItem modelAndParameter={createModelAndParameter()} />
</DebugWithMultipleModelContextProvider>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 'msg-1', isAnswer: true }),
)
})
describe('rendering', () => {
it('should not render when chatList is empty', () => {
mockUseChat.mockReturnValue({
chatList: [],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
renderComponent()
expect(screen.queryByTestId('chat-component')).not.toBeInTheDocument()
})
it('should pass correct config to Chat', () => {
renderComponent()
expect(capturedChatProps?.config).toMatchObject({
base_config: 'test',
opening_statement: 'Hello!',
suggested_questions: ['Q1'],
})
})
})
describe('config composition', () => {
it('should use empty opening statement when disabled', () => {
mockUseFeatures.mockImplementation(
(selector: (state: Record<string, unknown>) => unknown) => {
const state = {
features: {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: 'Should not appear' },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
file: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
}
return selector(state)
},
)
renderComponent()
expect(capturedChatProps?.config.opening_statement).toBe('')
expect(capturedChatProps?.config.suggested_questions).toEqual([])
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
renderComponent()
// Trigger the event
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Hello', files: [{ id: 'file-1' }] },
})
expect(handleSend).toHaveBeenCalledWith(
'apps/app-123/chat-messages',
expect.objectContaining({
query: 'Hello',
inputs: { key: 'value' },
files: [{ id: 'file-1' }],
}),
expect.objectContaining({
onNotifyError: expect.any(Function),
}),
)
const callbacks = handleSend.mock.calls[0]![2] as {
onNotifyError: (message: string) => void
}
callbacks.onNotifyError('Base model not found')
expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', () => {
const handleRestart = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart,
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
})
expect(handleRestart).toHaveBeenCalled()
})
it('should ignore unrelated events', () => {
const handleSend = vi.fn()
const handleRestart = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart,
})
renderComponent()
emitDebugEvent?.({
type: 'SOME_OTHER_EVENT',
payload: {},
})
expect(handleSend).not.toHaveBeenCalled()
expect(handleRestart).not.toHaveBeenCalled()
})
})
describe('doSend function', () => {
it('should not include files when vision is not supported', () => {
modelListResponse.mockReturnValue({
data: [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
features: [], // No vision support
model_properties: { mode: 'chat' },
},
],
},
],
})
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Hello', files: [{ id: 'file-1' }] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({
files: expect.anything(),
}),
expect.any(Object),
)
})
it('should include model configuration in request', () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({
provider: 'openai',
model: 'gpt-3.5-turbo',
parameters: { temperature: 0.5 },
})
renderComponent({ modelAndParameter })
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Hello', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-3.5-turbo',
completion_params: { temperature: 0.5 },
}),
}),
}),
expect.any(Object),
)
})
it('should use parent_message_id from last answer', () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [
{ id: 'msg-1', content: 'Hi', isAnswer: false },
{ id: 'msg-2', content: 'Hello', isAnswer: true },
],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
renderComponent()
emitDebugEvent?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Hello', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
parent_message_id: 'msg-2',
}),
expect.any(Object),
)
})
})
describe('allToolIcons', () => {
it('should compute tool icons from collectionList', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
configs: { prompt_variables: [] },
agentConfig: {
tools: [
{ tool_name: 'tool1', provider_id: 'collection1' },
{ tool_name: 'tool2', provider_id: 'collection2' },
],
},
},
appId: 'app-123',
inputs: {},
collectionList: [
{ id: 'collection1', icon: 'icon1' },
{ id: 'collection2', icon: 'icon2' },
],
canTestAndRun: true,
})
renderComponent()
expect(capturedChatProps?.allToolIcons).toEqual({
tool1: 'icon1',
tool2: 'icon2',
})
})
it('should handle tools without matching collection', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
configs: { prompt_variables: [] },
agentConfig: {
tools: [{ tool_name: 'tool1', provider_id: 'nonexistent' }],
},
},
appId: 'app-123',
inputs: {},
collectionList: [],
canTestAndRun: true,
})
renderComponent()
expect(capturedChatProps?.allToolIcons).toEqual({
tool1: undefined,
})
})
})
})
@@ -1,410 +1,372 @@
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../../types'
import type { DebugWithMultipleModelContextType } from '../context'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Inputs, ModelConfig, PromptVariable } from '@/models/debug'
import { render as renderView, screen } from '@testing-library/react'
import type { Features } from '@/app/components/base/features/types'
import type { consoleClient } from '@/service/console'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import {
appConfigurationToastManager,
toast as configurationToast,
} from '@/app/components/app/configuration/toast'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { createFeaturesStore } from '@/app/components/base/features/store'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { createModelConfigFixture } from '@/test/fixtures/debug-configuration'
import { Resolution, TransferMethod } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createDebugConfigurationFixture,
createModelConfigFixture,
} from '@/test/fixtures/debug-configuration'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { ModelModeType, TransferMethod } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../../types'
import DebugWithMultipleModel from '../index'
type PromptVariableWithMeta = Omit<PromptVariable, 'type' | 'required'> & {
type: PromptVariable['type'] | 'api'
required?: boolean
hide?: boolean
}
const api = vi.hoisted(() => ({
stream: vi.fn<typeof import('@/service/base').ssePost>(),
upload: vi.fn<typeof import('@/service/base').upload>(),
messages: vi.fn<typeof consoleClient.apps.byAppId.chatMessages.get>(),
suggestions:
vi.fn<typeof consoleClient.apps.byAppId.chatMessages.byMessageId.suggestedQuestions.get>(),
}))
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
ssePost: api.stream,
upload: api.upload,
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'apps.byAppId.chatMessages.get': (input) => api.messages(input),
'apps.byAppId.chatMessages.byMessageId.suggestedQuestions.get': (input) =>
api.suggestions(input),
}),
}
})
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
usePathname: () => '/app/app-1/configuration',
}))
const mockUseDebugConfigurationContext = vi.fn()
const mockUseFeaturesSelector = vi.fn()
const observeDebugEvent = vi.fn()
let capturedChatInputProps: MockChatInputAreaProps | null = null
let modelIdCounter = 0
let featureState: FeatureStoreState
type MockChatInputAreaProps = {
readonly?: boolean
onSend?: (message: string, files?: FileEntity[]) => void
onFeatureBarClick?: (state: boolean) => void
showFeatureBar?: boolean
showFileUpload?: boolean
featureBarReadonly?: boolean
disabled?: boolean
inputs?: Record<string, string | number | boolean>
inputsForm?: InputForm[]
speechToTextConfig?: unknown
visionConfig?: unknown
}
const mockFiles: FileEntity[] = [
{
id: 'file-1',
name: 'file.txt',
size: 10,
type: 'text/plain',
progress: 100,
transferMethod: TransferMethod.remote_url,
supportFileType: 'text',
},
const models = [
{ id: 'first', model: 'gpt-4', provider: 'openai', parameters: { temperature: 0.2 } },
{ id: 'second', model: 'gpt-3.5-turbo', provider: 'openai', parameters: { temperature: 0.8 } },
]
const onOpenFeatures = vi.fn()
const onOpenLog = vi.fn()
function Restart() {
const { eventEmitter } = useEventEmitterContextContext()
return (
<button
type="button"
onClick={() => eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART })}
>
Restart comparison
</button>
)
}
function setup({
context = {},
features = {},
checkCanSend,
}: {
context?: Parameters<typeof createDebugConfigurationFixture>[0]
features?: Features
checkCanSend?: () => boolean
} = {}) {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({ model_properties: { mode: 'chat' }, features: ['vision'] }),
createCatalogModelFixture({
model: 'gpt-3.5-turbo',
label: { en_US: 'GPT-3.5 Turbo' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{ initialData: { data: [] } },
)
const config = createDebugConfigurationFixture({
appId: 'app-1',
canTestAndRun: true,
onOpenFeatures,
inputs: { audience: 'engineers' },
modelConfig: createModelConfigFixture({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Explain to {{audience}}',
prompt_variables: [{ key: 'audience', name: 'Audience', type: 'string', required: true }],
},
}),
...context,
})
renderWithConsoleQuery(
<NuqsTestingAdapter>
{/* oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its own Provider. */}
<ConfigContext.Provider value={config}>
<FeaturesProvider features={{ ...createFeaturesStore().getState().features, ...features }}>
<EventEmitterContextProvider>
<Restart />
<DebugWithMultipleModel
multipleModelConfigs={models}
onMultipleModelConfigsChange={vi.fn()}
onDebugWithMultipleModelChange={vi.fn()}
onOpenLog={onOpenLog}
checkCanSend={checkCanSend}
/>
<AppToastHost />
<AppToastHost manager={appConfigurationToastManager} />
</EventEmitterContextProvider>
</FeaturesProvider>
</ConfigContext.Provider>
</NuqsTestingAdapter>,
{ queryClient },
)
return userEvent.setup()
}
async function send(user: ReturnType<typeof userEvent.setup>, message = 'Explain RAG') {
await user.type(screen.getByRole('textbox'), message)
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
}
async function deliverAnswers() {
const requests = api.stream.mock.calls.slice()
await act(async () => {
for (const [index, request] of requests.entries()) {
request[2]?.onData?.(`Answer from model ${index + 1}`, true, {
messageId: `answer-${index + 1}`,
conversationId: `conversation-${index + 1}`,
taskId: `task-${index + 1}`,
})
await request[2]?.onCompleted?.()
}
})
}
beforeEach(() => {
for (const mock of Object.values(api)) mock.mockReset()
onOpenFeatures.mockClear()
onOpenLog.mockClear()
api.stream.mockResolvedValue(undefined)
api.upload.mockResolvedValue({ id: 'uploaded-file-1' })
api.messages.mockResolvedValue({ data: [], has_more: false, limit: 10 })
api.suggestions.mockResolvedValue({ data: [] })
})
afterEach(() => {
toast.dismiss()
configurationToast.dismiss()
})
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
it('sends one question to both configured models and keeps their streamed answers and conversations separate', async () => {
const user = setup()
await send(user)
expect(api.stream).toHaveBeenCalledTimes(2)
models.forEach((model, index) => {
expect(api.stream.mock.calls[index]?.[0]).toBe('apps/app-1/chat-messages')
expect(api.stream.mock.calls[index]?.[1].body).toMatchObject({
query: 'Explain RAG',
inputs: { audience: 'engineers' },
parent_message_id: null,
model_config: {
model: { provider: model.provider, name: model.model, completion_params: model.parameters },
},
})
})
await deliverAnswers()
expect(await screen.findByText('Answer from model 1')).toBeVisible()
expect(screen.getByText('Answer from model 2')).toBeVisible()
await user.click(screen.getAllByRole('button', { name: 'common.operation.log' })[1]!)
expect(onOpenLog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ id: 'answer-2', content: 'Answer from model 2' }),
)
await send(user, 'Give an example')
expect(api.stream).toHaveBeenCalledTimes(4)
for (const index of [0, 1]) {
expect(api.stream.mock.calls[index + 2]?.[1].body).toMatchObject({
query: 'Give an example',
parent_message_id: `answer-${index + 1}`,
conversation_id: `conversation-${index + 1}`,
})
}
})
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) =>
mockUseFeaturesSelector(selector),
}))
it('restarts both conversations through the comparison restart event', async () => {
const user = setup()
await send(user)
await deliverAnswers()
await user.click(screen.getByRole('button', { name: 'Restart comparison' }))
expect(screen.queryByText('Answer from model 1')).not.toBeInTheDocument()
expect(screen.queryByText('Answer from model 2')).not.toBeInTheDocument()
await send(user, 'Start again')
expect(api.stream).toHaveBeenCalledTimes(4)
for (const request of api.stream.mock.calls.slice(2)) {
expect(request[1].body).toMatchObject({ query: 'Start again', parent_message_id: null })
expect(request[1].body).toHaveProperty('conversation_id', '')
}
})
vi.mock('../debug-item', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="debug-item" data-model-id={modelAndParameter.id}>
{modelAndParameter.model}
</div>
),
}))
it('allows comparison runs with readonly configuration while disabling feature editing', async () => {
const user = setup({ context: { readonly: true } })
expect(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' })).toBeDisabled()
await send(user)
expect(api.stream).toHaveBeenCalledTimes(2)
expect(onOpenFeatures).not.toHaveBeenCalled()
})
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
default: (props: MockChatInputAreaProps) => {
capturedChatInputProps = props
return (
<div data-testid="chat-input-area">
<button
type="button"
disabled={props.disabled}
onClick={() => props.onSend?.('test message', mockFiles)}
>
send
</button>
{props.showFeatureBar && (
<button
type="button"
disabled={props.featureBarReadonly}
onClick={() => props.onFeatureBarClick?.(true)}
>
feature
</button>
)}
</div>
)
},
}))
it('blocks the composer without run permission while allowing feature configuration', async () => {
const user = setup({ context: { canTestAndRun: false } })
expect(screen.getByRole('textbox')).toBeDisabled()
const sendButton = screen.getByRole('button', { name: 'common.operation.send' })
expect(sendButton).toBeDisabled()
await user.click(sendButton)
expect(api.stream).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' }))
expect(onOpenFeatures).toHaveBeenCalledExactlyOnceWith(true)
})
const createFeatureState = (): FeatureStoreState => ({
features: {
speech2text: { enabled: true },
file: {
image: {
it('does not send to either model when parent validation rejects the request', async () => {
const checkCanSend = vi.fn(() => false)
const user = setup({ checkCanSend })
await send(user)
expect(checkCanSend).toHaveBeenCalledTimes(1)
expect(api.stream).not.toHaveBeenCalled()
expect(screen.queryByText('Explain RAG')).not.toBeInTheDocument()
})
it('reports the required variable name and retains the draft instead of sending', async () => {
const user = setup({ context: { inputs: {} } })
await send(user)
expect(
await screen.findByText('appDebug.errorMessage.valueOfVarRequired:{"key":"Audience"}'),
).toBeVisible()
expect(screen.getByRole('textbox')).toHaveValue('Explain RAG')
expect(api.stream).not.toHaveBeenCalled()
})
it('accepts zero for a required number and does not require hidden optional or API-only variables', async () => {
const user = setup({
context: {
inputs: { count: 0 },
modelConfig: createModelConfigFixture({
configs: {
prompt_template: 'Use {{count}}',
prompt_variables: [
{ key: 'count', name: 'Count', type: 'number', required: true },
{ key: 'optional', name: 'Optional', type: 'string', hide: true, required: false },
{ key: 'external', name: 'External', type: 'api', required: true },
],
},
}),
},
})
await send(user)
expect(api.stream).toHaveBeenCalledTimes(2)
for (const request of api.stream.mock.calls)
expect(request[1].body).toHaveProperty('inputs', { count: 0 })
})
it('uploads once and includes the attachment only for the model supporting vision', async () => {
const user = setup({
features: {
file: {
enabled: true,
detail: Resolution.high,
number_limits: 2,
transfer_methods: [TransferMethod.remote_url],
allowed_file_types: ['image'],
allowed_file_upload_methods: [TransferMethod.local_file],
allowed_file_extensions: [],
number_limits: 3,
fileUploadConfig: createFileUploadConfigFixture(),
},
},
})
const file = new File(['image'], 'diagram.png', { type: 'image/png' })
fireEvent.drop(screen.getByRole('textbox'), { dataTransfer: { files: [file] } })
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1))
expect(api.upload.mock.lastCall![0].data.get('file')).toEqual(file)
await send(user, 'Describe this diagram')
expect(api.stream).toHaveBeenCalledTimes(2)
expect(api.stream.mock.calls[0]?.[1].body).toHaveProperty('files', [
{
type: 'image',
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'uploaded-file-1',
},
])
expect(api.stream.mock.calls[1]?.[1].body).toHaveProperty('files', [])
})
it('sends agent-chat questions through the shared comparison composer', async () => {
const user = setup({ context: { mode: 'agent-chat', isAgent: true } })
await send(user)
expect(api.stream).toHaveBeenCalledTimes(2)
await deliverAnswers()
expect(await screen.findByText('Answer from model 1')).toBeVisible()
expect(screen.getByText('Answer from model 2')).toBeVisible()
})
it('shows the selected completion models without a shared chat composer', () => {
setup({ context: { mode: 'completion' } })
expect(screen.getByRole('button', { name: /^GPT-4/ })).toBeVisible()
expect(screen.getByRole('button', { name: /^GPT-3.5 Turbo/ })).toBeVisible()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.send' })).not.toBeInTheDocument()
})
it.each([true, false])(
'applies the opening feature to both model conversations (%s)',
async (enabled) => {
const user = setup({
features: {
opening: {
enabled,
opening_statement: 'Welcome to the comparison',
suggested_questions: ['Compare retrieval methods'],
},
},
})
expect(screen.queryAllByText('Welcome to the comparison')).toHaveLength(enabled ? 2 : 0)
await send(user)
expect(api.stream).toHaveBeenCalledTimes(2)
for (const request of api.stream.mock.calls) {
expect(request[1].body).toMatchObject({
model_config: {
opening_statement: enabled ? 'Welcome to the comparison' : '',
suggested_questions: enabled ? ['Compare retrieval methods'] : [],
},
})
}
},
setFeatures: vi.fn(),
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
})
const createModelConfig = (promptVariables: PromptVariableWithMeta[] = []): ModelConfig =>
createModelConfigFixture({
configs: { prompt_template: '', prompt_variables: promptVariables },
})
type DebugConfiguration = {
onOpenFeatures: () => void
mode: AppMode
inputs: Inputs
modelConfig: ModelConfig
readonly: boolean
canTestAndRun: boolean
}
const createDebugConfiguration = (
overrides: Partial<DebugConfiguration> = {},
): DebugConfiguration => ({
onOpenFeatures: vi.fn(),
mode: 'chat' as const,
inputs: {},
modelConfig: createModelConfig(),
readonly: false,
canTestAndRun: true,
...overrides,
})
const createModelAndParameter = (
overrides: Partial<ModelAndParameter> = {},
): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: {},
...overrides,
})
const createProps = (
overrides: Partial<DebugWithMultipleModelContextType> = {},
): DebugWithMultipleModelContextType => ({
multipleModelConfigs: [createModelAndParameter()],
onOpenLog: vi.fn(),
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
...overrides,
})
function EventObserver({ children }: { children: ReactNode }) {
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription(observeDebugEvent)
return children
}
function render(ui: Parameters<typeof renderView>[0]) {
return renderView(ui, {
wrapper: ({ children }) => (
<EventEmitterContextProvider>
<EventObserver>{children}</EventObserver>
</EventEmitterContextProvider>
),
})
}
const renderComponent = (props?: Partial<DebugWithMultipleModelContextType>) => {
const mergedProps = createProps(props)
return render(<DebugWithMultipleModel {...mergedProps} />)
}
describe('DebugWithMultipleModel', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedChatInputProps = null
modelIdCounter = 0
featureState = createFeatureState()
mockUseFeaturesSelector.mockImplementation((selector) => selector(featureState))
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration())
})
describe('props and callbacks', () => {
it('updates the selected model when its configuration changes', () => {
const props1 = createProps({
multipleModelConfigs: [createModelAndParameter({ id: 'model-1' })],
})
const { rerender } = renderComponent(props1)
const props2 = createProps({
multipleModelConfigs: [createModelAndParameter({ id: 'model-2' })],
})
rerender(<DebugWithMultipleModel {...props2} />)
const items = screen.getAllByTestId('debug-item')
expect(items[0])!.toHaveAttribute('data-model-id', 'model-2')
})
})
describe('prompt variables transformation', () => {
it('should preserve original hide and required values', () => {
const promptVariables: PromptVariableWithMeta[] = [
{
key: 'hidden-optional',
name: 'Hidden Optional',
type: 'string',
hide: true,
required: false,
},
{
key: 'visible-required',
name: 'Visible Required',
type: 'number',
hide: false,
required: true,
},
]
const debugConfiguration = createDebugConfiguration({
modelConfig: createModelConfig(promptVariables),
})
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
renderComponent()
expect(capturedChatInputProps?.inputsForm).toEqual([
expect.objectContaining({
label: 'Hidden Optional',
variable: 'hidden-optional',
hide: true,
required: false,
}),
expect.objectContaining({
label: 'Visible Required',
variable: 'visible-required',
hide: false,
required: true,
}),
])
})
})
describe('chat input rendering', () => {
it('should render chat input in chat mode with transformed prompt variables and feature handler', async () => {
const user = userEvent.setup()
const promptVariables: PromptVariableWithMeta[] = [
{ key: 'city', name: 'City', type: 'string', required: true },
{ key: 'audience', name: 'Audience', type: 'number' },
{ key: 'hidden', name: 'Hidden', type: 'select', hide: true },
{ key: 'api-only', name: 'API Only', type: 'api' },
]
const debugConfiguration = createDebugConfiguration({
inputs: { audience: 'engineers' },
modelConfig: createModelConfig(promptVariables),
})
mockUseDebugConfigurationContext.mockReturnValue(debugConfiguration)
renderComponent()
await user.click(screen.getByRole('button', { name: /feature/i }))
expect(screen.getByTestId('chat-input-area'))!.toBeInTheDocument()
expect(capturedChatInputProps?.inputs).toEqual({ audience: 'engineers' })
expect(capturedChatInputProps?.inputsForm).toEqual([
expect.objectContaining({ label: 'City', variable: 'city', hide: false, required: true }),
expect.objectContaining({
label: 'Audience',
variable: 'audience',
hide: false,
required: false,
}),
expect.objectContaining({
label: 'Hidden',
variable: 'hidden',
hide: true,
required: false,
}),
])
expect(capturedChatInputProps?.showFeatureBar).toBe(true)
expect(capturedChatInputProps?.showFileUpload).toBe(false)
expect(capturedChatInputProps?.speechToTextConfig).toEqual(featureState.features.speech2text)
expect(capturedChatInputProps?.visionConfig).toEqual(featureState.features.file)
expect(debugConfiguration.onOpenFeatures).toHaveBeenCalledTimes(1)
})
it('should allow sending but disable feature configuration when configuration is readonly and test/run is allowed', async () => {
const user = userEvent.setup()
mockUseDebugConfigurationContext.mockReturnValue(
createDebugConfiguration({
readonly: true,
canTestAndRun: true,
}),
)
renderComponent()
await user.click(screen.getByRole('button', { name: /send/i }))
expect(capturedChatInputProps?.readonly).toBe(false)
expect(capturedChatInputProps?.disabled).toBe(false)
expect(capturedChatInputProps?.showFeatureBar).toBe(true)
expect(capturedChatInputProps?.featureBarReadonly).toBe(true)
expect(screen.getByRole('button', { name: /feature/i })).toBeDisabled()
expect(observeDebugEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
}),
)
})
it('should block sending when test/run permission is missing', async () => {
const user = userEvent.setup()
mockUseDebugConfigurationContext.mockReturnValue(
createDebugConfiguration({
canTestAndRun: false,
}),
)
renderComponent()
await user.click(screen.getByRole('button', { name: /send/i }))
expect(capturedChatInputProps?.readonly).toBe(true)
expect(capturedChatInputProps?.disabled).toBe(true)
expect(observeDebugEvent).not.toHaveBeenCalled()
})
it('should render chat input in agent chat mode', () => {
mockUseDebugConfigurationContext.mockReturnValue(
createDebugConfiguration({
mode: 'agent-chat' as const,
}),
)
renderComponent()
expect(screen.getByTestId('chat-input-area'))!.toBeInTheDocument()
})
it('should hide chat input when not in chat mode', () => {
mockUseDebugConfigurationContext.mockReturnValue(
createDebugConfiguration({
mode: 'completion' as const,
}),
)
const multipleModelConfigs = [createModelAndParameter()]
renderComponent({ multipleModelConfigs })
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
expect(screen.getAllByTestId('debug-item')).toHaveLength(1)
})
})
describe('sending flow', () => {
it('should emit chat event when allowed to send', async () => {
const user = userEvent.setup()
const checkCanSend = vi.fn(() => true)
const multipleModelConfigs = [createModelAndParameter(), createModelAndParameter()]
renderComponent({ multipleModelConfigs, checkCanSend })
await user.click(screen.getByRole('button', { name: /send/i }))
expect(checkCanSend).toHaveBeenCalled()
expect(observeDebugEvent).toHaveBeenCalledWith({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: 'test message',
files: mockFiles,
},
})
})
it('should emit when no checkCanSend is provided', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button', { name: /send/i }))
expect(observeDebugEvent).toHaveBeenCalledWith({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: 'test message',
files: mockFiles,
},
})
})
it('should block sending when checkCanSend returns false', async () => {
const user = userEvent.setup()
const checkCanSend = vi.fn(() => false)
renderComponent({ checkCanSend })
await user.click(screen.getByRole('button', { name: /send/i }))
expect(checkCanSend).toHaveBeenCalled()
expect(observeDebugEvent).not.toHaveBeenCalled()
})
)
it('shows a model transport error while another model continues answering', async () => {
const user = setup()
await send(user)
await act(async () => {
api.stream.mock.calls[0]?.[2]?.onNotifyError?.('Model is unavailable')
api.stream.mock.calls[1]?.[2]?.onData?.('The other model answered', true, {
messageId: 'answer-2',
conversationId: 'conversation-2',
taskId: 'task-2',
})
await api.stream.mock.calls[1]?.[2]?.onCompleted?.()
})
expect(await screen.findByText('Model is unavailable')).toBeVisible()
expect(screen.getByText('The other model answered')).toBeVisible()
})
@@ -1,97 +1,21 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../../types'
import type {
FormValue,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { screen } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useState } from 'react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import DebugConfigurationContext from '@/context/debug-configuration'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDebugConfigurationFixture } from '@/test/fixtures/debug-configuration'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { DebugWithMultipleModelContext } from '../context'
import ModelParameterTrigger from '../model-parameter-trigger'
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockProvidersSummary = vi.fn()
const mockUseCredentialPanelState = vi.fn()
const mockUseCurrentModel = vi.fn()
type ParameterModalProps = {
isAdvancedMode: boolean
provider: string
modelId: string
completionParams: FormValue
onCompletionParamsChange: (params: FormValue) => void
setModel: (model: { modelId: string; provider: string }) => void
debugWithMultipleModel: boolean
onDebugWithMultipleModelChange: () => void
trigger: ReactNode
}
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('../context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state',
() => ({
useCredentialPanelState: () => mockUseCredentialPanelState(),
}),
)
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => mockUseCurrentModel(),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: (props: ParameterModalProps) => {
return (
<div>
{props?.trigger}
<button onClick={() => props?.setModel({ modelId: 'gpt-4', provider: 'openai' })}>
Select GPT-4
</button>
<button onClick={() => props?.onCompletionParamsChange({ temperature: 0.8 })}>
Save parameters
</button>
<button onClick={() => props?.onDebugWithMultipleModelChange()}>
Use this model only
</button>
</div>
)
},
}),
)
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ provider, modelName }: { provider: { provider: string }; modelName?: string }) => (
<div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}>
ModelIcon
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => (
<div data-testid="model-name">{modelItem?.model}</div>
),
}))
const createModelAndParameter = (
overrides: Partial<ModelAndParameter> = {},
): ModelAndParameter => ({
@@ -102,249 +26,150 @@ const createModelAndParameter = (
...overrides,
})
const createModelProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'Primary Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Primary Key' }],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
...overrides,
})
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
const defaultProps = {
modelAndParameter: createModelAndParameter(),
...props,
}
const renderComponent = ({
models = [createModelAndParameter()],
index = 0,
status = ModelStatusEnum.active,
missingModel = false,
}: {
models?: ModelAndParameter[]
index?: number
status?: ModelStatusEnum
missingModel?: boolean
} = {}) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions().queryKey,
{ data: mockProvidersSummary(), plugins: {} },
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
...(!missingModel
? [
createCatalogModelFixture({
model: 'gpt-3.5-turbo',
label: { en_US: 'GPT-3.5 Turbo' },
model_properties: { mode: 'chat' },
status,
}),
]
: []),
createCatalogModelFixture({ model_properties: { mode: 'chat' } }),
],
}),
])
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{
initialData: {
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
},
],
},
},
)
return renderWithConsoleQuery(<ModelParameterTrigger {...defaultProps} />, { queryClient })
const onMultipleModelConfigsChange = vi.fn()
const onDebugWithMultipleModelChange = vi.fn()
function Owner() {
const [configs, setConfigs] = useState(models)
return (
<DebugConfigurationContext.Provider value={createDebugConfigurationFixture()}>
<DebugWithMultipleModelContext.Provider
value={{
multipleModelConfigs: configs,
onOpenLog: vi.fn(),
onDebugWithMultipleModelChange,
onMultipleModelConfigsChange: (multiple, nextConfigs) => {
onMultipleModelConfigsChange(multiple, nextConfigs)
setConfigs(nextConfigs)
},
}}
>
<ModelParameterTrigger modelAndParameter={configs[index]!} />
</DebugWithMultipleModelContext.Provider>
</DebugConfigurationContext.Provider>
)
}
const render = createNuqsTestRenderer((ui) => renderWithConsoleQuery(ui, { queryClient }))
return { ...render(<Owner />), onMultipleModelConfigsChange, onDebugWithMultipleModelChange }
}
describe('ModelParameterTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
it('changes the selected model without changing the other comparison models', async () => {
const user = userEvent.setup()
const models = ['model-1', 'model-2', 'model-3'].map((id) => createModelAndParameter({ id }))
const { onMultipleModelConfigsChange } = renderComponent({ models, index: 1 })
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockProvidersSummary.mockReturnValue(
{
data: [
{
...createModelProvider(),
is_configured: true,
plugin_id: 'langgenius/openai',
} as unknown as ModelProviderSummaryResponse,
],
}.data,
await user.click(screen.getByRole('button', { name: /^GPT-3.5 Turbo/ }))
const settings = within(screen.getByRole('dialog', { name: /modelProvider.modelSettings/ }))
await user.click(settings.getByRole('button', { name: /^GPT-3.5 Turbo/ }))
const picker = within(
await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }),
)
mockUseCredentialPanelState.mockReturnValue({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary Key',
credits: 10,
})
mockUseCurrentModel.mockReturnValue({ currentProvider: null, currentModel: null })
await user.click(picker.getByRole('button', { name: /^GPT-4/ }))
expect(onMultipleModelConfigsChange).toHaveBeenLastCalledWith(true, [
models[0],
{ ...models[1], model: 'gpt-4', provider: 'openai' },
models[2],
])
await user.keyboard('{Escape}')
expect(screen.getByRole('button', { name: /^GPT-4/ })).toBeInTheDocument()
})
describe('handleSelectModel', () => {
it('should update correct model in array', async () => {
const user = userEvent.setup()
const onMultipleModelConfigsChange = vi.fn()
const models = [
createModelAndParameter({ id: 'model-1' }),
createModelAndParameter({ id: 'model-2' }),
createModelAndParameter({ id: 'model-3' }),
]
it('updates parameters while preserving the model and other settings', async () => {
const user = userEvent.setup()
const model = createModelAndParameter({ parameters: { temperature: 0.7, max_tokens: 256 } })
const { onMultipleModelConfigsChange } = renderComponent({ models: [model] })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: models,
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
await user.click(screen.getByRole('button', { name: /^GPT-3.5 Turbo/ }))
const temperature = screen.getByRole('spinbutton', { name: 'Temperature' })
await user.clear(temperature)
await user.type(temperature, '0.8')
await user.tab()
renderComponent({ modelAndParameter: models[1] })
await user.click(screen.getByRole('button', { name: 'Select GPT-4' }))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(true, [
models[0],
expect.objectContaining({
id: 'model-2',
model: 'gpt-4',
provider: 'openai',
}),
models[2],
])
})
expect(onMultipleModelConfigsChange).toHaveBeenLastCalledWith(true, [
{ ...model, parameters: { temperature: 0.8, max_tokens: 256 } },
])
})
describe('handleParamsChange', () => {
it('should preserve other model properties when changing params', async () => {
const user = userEvent.setup()
const onMultipleModelConfigsChange = vi.fn()
const modelAndParameter = createModelAndParameter({
id: 'model-1',
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
})
it('continues debugging with only the chosen comparison model', async () => {
const user = userEvent.setup()
const model = createModelAndParameter()
const { onDebugWithMultipleModelChange } = renderComponent({ models: [model] })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
await user.click(screen.getByRole('button', { name: /^GPT-3.5 Turbo/ }))
await user.click(screen.getByRole('button', { name: 'appDebug.debugAsSingleModel' }))
renderComponent({ modelAndParameter })
await user.click(screen.getByRole('button', { name: 'Save parameters' }))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(true, [
expect.objectContaining({
id: 'model-1',
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.8 },
}),
])
})
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(model)
})
describe('onDebugWithMultipleModelChange', () => {
it('should call context onDebugWithMultipleModelChange with modelAndParameter', async () => {
const user = userEvent.setup()
const onDebugWithMultipleModelChange = vi.fn()
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange,
})
renderComponent({ modelAndParameter })
await user.click(screen.getByRole('button', { name: 'Use this model only' }))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
it('offers model selection when no model is configured', () => {
renderComponent({ models: [createModelAndParameter({ provider: '', model: '' })] })
expect(
screen.getByRole('button', { name: 'modelProvider.modelProvider.selectModel' }),
).toBeInTheDocument()
})
describe('trigger rendering', () => {
it('should render "Select Model" text when no provider or model is configured', () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: '',
model: '',
}),
})
// When currentProvider and currentModel are null, shows "Select Model"
expect(screen.getByText('modelProvider.modelProvider.selectModel')).toBeInTheDocument()
})
it('should render configured model id and incompatible tooltip when model is missing from the provider list', async () => {
const user = userEvent.setup()
renderComponent()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
const trigger = screen.getByRole('button', {
name: /modelProvider.modelProvider.selector.incompatibleTip/,
})
await user.hover(trigger)
expect(
await screen.findByText(
(content, element) =>
content === 'modelProvider.modelProvider.selector.incompatibleTip' &&
!!element &&
!trigger.contains(element),
),
).toBeInTheDocument()
})
it('should render configure required tooltip for no-configure status', async () => {
const user = userEvent.setup()
mockUseCurrentModel.mockReturnValue({
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
renderComponent()
const trigger = screen.getByRole('button', {
name: /modelProvider.modelProvider.selector.configureRequired/,
})
await user.hover(trigger)
expect(
await screen.findByText(
(content, element) =>
content === 'modelProvider.modelProvider.selector.configureRequired' &&
!!element &&
!trigger.contains(element),
),
).toBeInTheDocument()
})
it('should render disabled tooltip for disabled status', async () => {
const user = userEvent.setup()
mockUseCurrentModel.mockReturnValue({
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.disabled },
})
renderComponent()
const trigger = screen.getByRole('button', {
name: /modelProvider.modelProvider.selector.disabled/,
})
await user.hover(trigger)
expect(
await screen.findByText(
(content, element) =>
content === 'modelProvider.modelProvider.selector.disabled' &&
!!element &&
!trigger.contains(element),
),
).toBeInTheDocument()
})
it.each([
{ missingModel: true, status: ModelStatusEnum.active, tip: 'incompatibleTip' },
{ missingModel: false, status: ModelStatusEnum.noConfigure, tip: 'configureRequired' },
{ missingModel: false, status: ModelStatusEnum.disabled, tip: 'disabled' },
])('explains the $tip state of the configured model', async ({ missingModel, status, tip }) => {
const user = userEvent.setup()
renderComponent({ missingModel, status })
const message = `modelProvider.modelProvider.selector.${tip}`
const trigger = screen.getByRole('button', { name: new RegExp(message) })
expect(trigger.textContent).toContain(missingModel ? 'gpt-3.5-turbo' : 'GPT-3.5 Turbo')
await user.hover(trigger)
expect(
await screen.findByText(
(content, element) => content === message && !!element && !trigger.contains(element),
),
).toBeInTheDocument()
})
})
@@ -1,651 +1,312 @@
import type { ReactNode, RefObject } from 'react'
import type { DebugWithSingleModelRefType } from '../index'
import type { ChatItem } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { ModelConfig } from '@/models/debug'
import { act, screen, waitFor } from '@testing-library/react'
import type { Features } from '@/app/components/base/features/types'
import type { consoleClient } from '@/service/console'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createRef } from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import {
appConfigurationToastManager,
toast as configurationToast,
} from '@/app/components/app/configuration/toast'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { createFeaturesStore } from '@/app/components/base/features/store'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import ConfigContext from '@/context/debug-configuration'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import {
createDebugConfigurationFixture,
createModelConfigFixture,
} from '@/test/fixtures/debug-configuration'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { ModelModeType, TransferMethod } from '@/types/app'
import DebugWithSingleModel from '../index'
function createMockModelConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
return createModelConfigFixture({
provider: 'openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Test template',
prompt_variables: [{ key: 'var1', name: 'Variable 1', type: 'string', required: false }],
const api = vi.hoisted(() => ({
stream: vi.fn<typeof import('@/service/base').ssePost>(),
upload: vi.fn<typeof import('@/service/base').upload>(),
messages: vi.fn<typeof consoleClient.apps.byAppId.chatMessages.get>(),
suggestions:
vi.fn<typeof consoleClient.apps.byAppId.chatMessages.byMessageId.suggestedQuestions.get>(),
stop: vi.fn<typeof consoleClient.apps.byAppId.chatMessages.byTaskId.stop.post>(),
}))
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
ssePost: api.stream,
upload: api.upload,
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'apps.byAppId.chatMessages.get': (input) => api.messages(input),
'apps.byAppId.chatMessages.byMessageId.suggestedQuestions.get': (input) =>
api.suggestions(input),
'apps.byAppId.chatMessages.byTaskId.stop.post': (input) => api.stop(input),
}),
}
})
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'test-app-id' }),
usePathname: () => '/app/test-app-id/configuration',
}))
const onOpenFeatures = vi.fn()
const onOpenLog = vi.fn()
function setup({
context = {},
features = {},
checkCanSend,
}: {
context?: Parameters<typeof createDebugConfigurationFixture>[0]
features?: Features
checkCanSend?: () => boolean
} = {}) {
const ref = createRef<DebugWithSingleModelRefType>()
const config = createDebugConfigurationFixture({
readonly: false,
canTestAndRun: true,
appId: 'test-app-id',
onOpenFeatures,
inputs: { var1: 'test input' },
completionParams: { max_tokens: 100, temperature: 0.7 },
modelConfig: createModelConfigFixture({
provider: 'openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Test template',
prompt_variables: [{ key: 'var1', name: 'Variable 1', type: 'string', required: false }],
},
}),
...context,
})
renderWithConsoleQuery(
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its own Provider rather than React's context shorthand.
<ConfigContext.Provider value={config}>
<FeaturesProvider features={{ ...createFeaturesStore().getState().features, ...features }}>
<EventEmitterContextProvider>
<button type="button" onClick={() => ref.current?.handleRestart()}>
Restart debug conversation
</button>
<DebugWithSingleModel
ref={ref}
onOpenLog={onOpenLog}
chatContainerRef={createRef<HTMLDivElement>()}
checkCanSend={checkCanSend}
/>
<AppToastHost manager={appConfigurationToastManager} />
<AppToastHost />
</EventEmitterContextProvider>
</FeaturesProvider>
</ConfigContext.Provider>,
)
return userEvent.setup()
}
async function send(user: ReturnType<typeof userEvent.setup>, query = 'Test question') {
await user.type(screen.getByRole('textbox'), query)
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
}
async function deliverAnswer(answer = 'Streamed answer') {
const callbacks = api.stream.mock.lastCall![2]!
await act(async () => {
callbacks.onData?.(answer, true, {
messageId: 'answer-1',
conversationId: 'conversation-1',
taskId: 'task-1',
})
})
return callbacks
}
beforeEach(() => {
for (const mock of Object.values(api)) mock.mockReset()
onOpenFeatures.mockClear()
onOpenLog.mockClear()
api.stream.mockResolvedValue(undefined)
api.messages.mockResolvedValue({ data: [], has_more: false, limit: 10 })
api.suggestions.mockResolvedValue({ data: [] })
api.stop.mockResolvedValue({ result: 'success' })
api.upload.mockResolvedValue({ id: 'uploaded-file-1' })
})
afterEach(() => {
toast.dismiss()
configurationToast.dismiss()
})
it('sends the configured prompt and inputs, renders the stream, and exposes the answer log', async () => {
const user = setup()
await send(user)
expect(api.stream).toHaveBeenCalledTimes(1)
expect(api.stream.mock.lastCall![0]).toBe('apps/test-app-id/chat-messages')
expect(api.stream.mock.lastCall![1].body).toMatchObject({
query: 'Test question',
inputs: { var1: 'test input' },
parent_message_id: null,
model_config: {
pre_prompt: 'Test template',
model: {
provider: 'openai',
name: 'gpt-3.5-turbo',
completion_params: { max_tokens: 100, temperature: 0.7 },
},
},
...overrides,
})
}
const { mockSsePost, mockToastError } = vi.hoisted(() => ({
mockSsePost: vi.fn<typeof import('@/service/base').ssePost>(() => Promise.resolve()),
mockToastError: vi.fn(),
}))
vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/service/base', () => ({
ssePost: mockSsePost,
post: vi.fn(() => Promise.resolve({ data: {} })),
get: vi.fn(() => Promise.resolve({ data: {} })),
del: vi.fn(() => Promise.resolve({ data: {} })),
patch: vi.fn(() => Promise.resolve({ data: {} })),
}))
vi.mock('@/service/fetch', () => ({
fetch: vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
}))
const {
mockFetchConversationMessages,
mockFetchSuggestedQuestions,
mockStopChatMessageResponding,
} = vi.hoisted(() => ({
mockFetchConversationMessages: vi.fn(),
mockFetchSuggestedQuestions: vi.fn(),
mockStopChatMessageResponding: vi.fn(),
}))
vi.mock('@/service/debug', () => ({
fetchConversationMessages: mockFetchConversationMessages,
fetchSuggestedQuestions: mockFetchSuggestedQuestions,
stopChatMessageResponding: mockStopChatMessageResponding,
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
useParams: () => ({}),
}))
const mockDebugConfigContext = createDebugConfigurationFixture({
readonly: false,
canTestAndRun: true,
appId: 'test-app-id',
onOpenFeatures: vi.fn(),
inputs: { var1: 'test input' },
completionParams: { max_tokens: 100, temperature: 0.7 },
modelConfig: createMockModelConfig(),
const callbacks = await deliverAnswer()
expect(await screen.findByText('Streamed answer')).toBeVisible()
await act(async () => {
await callbacks.onCompleted?.()
})
await waitFor(() =>
expect(
screen.queryByRole('button', { name: 'appDebug.operation.stopResponding' }),
).not.toBeInTheDocument(),
)
await user.click(screen.getByRole('button', { name: 'common.operation.log' }))
expect(onOpenLog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'answer-1', content: 'Streamed answer' }),
)
})
const { mockUseDebugConfigurationContext } = vi.hoisted(() => ({
mockUseDebugConfigurationContext: vi.fn(),
}))
mockUseDebugConfigurationContext.mockReturnValue(mockDebugConfigContext)
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: mockUseDebugConfigurationContext,
}))
type FeatureState = {
moreLikeThis: { enabled: boolean }
opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] }
moderation: { enabled: boolean }
speech2text: { enabled: boolean }
text2speech: { enabled: boolean }
file: { enabled: boolean }
suggested: { enabled: boolean }
citation: { enabled: boolean }
annotationReply: { enabled: boolean }
}
const defaultFeatures: FeatureState = {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
file: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
}
type FeatureSelector = (state: { features: FeatureState }) => unknown
let mockFeaturesState: FeatureState = { ...defaultFeatures }
const { mockUseFeatures } = vi.hoisted(() => ({
mockUseFeatures: vi.fn(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: mockUseFeatures,
}))
const mockConfigFromDebugContext = {
pre_prompt: 'Test prompt',
prompt_type: 'simple',
user_input_form: [],
dataset_query_variable: '',
opening_statement: '',
more_like_this: { enabled: false },
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
agent_mode: {},
dataset_configs: {},
file_upload: { enabled: false },
annotation_reply: { enabled: false },
supportAnnotation: true,
appId: 'test-app-id',
supportCitationHitInfo: true,
}
const { mockUseConfigFromDebugContext, mockUseFormattingChangedSubscription } = vi.hoisted(() => ({
mockUseConfigFromDebugContext: vi.fn(),
mockUseFormattingChangedSubscription: vi.fn(),
}))
mockUseConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext)
vi.mock('../../hooks', () => ({
useConfigFromDebugContext: mockUseConfigFromDebugContext,
useFormattingChangedSubscription: mockUseFormattingChangedSubscription,
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: vi.fn(() => ({
formatTime: vi.fn((timestamp: number) => new Date(timestamp).toLocaleString()),
})),
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: vi.fn(() => ({
getAudioPlayer: vi.fn(),
resetAudioPlayer: vi.fn(),
})),
},
}))
type MockChatProps = {
readonly?: boolean
chatList?: ChatItem[]
isResponding?: boolean
onSend?: (message: string, files?: FileEntity[]) => void
onRegenerate?: (
chatItem: ChatItem,
editedQuestion?: { message: string; files?: FileEntity[] },
) => void
onStopResponding?: () => void
suggestedQuestions?: string[]
questionIcon?: ReactNode
answerIcon?: ReactNode
onAnnotationAdded?: (
annotationId: string,
authorName: string,
question: string,
answer: string,
index: number,
) => void
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
switchSibling?: (siblingMessageId: string) => void
onFeatureBarClick?: (state: boolean) => void
showFeatureBar?: boolean
featureBarReadonly?: boolean
inputDisabled?: boolean
}
const mockFile: FileEntity = {
id: 'file-1',
name: 'test.png',
size: 123,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
}
const mockDocumentFile: FileEntity = {
id: 'file-2',
name: 'test.pdf',
size: 456,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.document,
}
vi.mock('@/app/components/base/chat/chat', () => ({
default: function MockChat({
readonly,
chatList,
isResponding,
onSend,
onRegenerate,
onStopResponding,
suggestedQuestions,
questionIcon,
answerIcon,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
switchSibling,
onFeatureBarClick,
showFeatureBar,
featureBarReadonly,
inputDisabled,
}: MockChatProps) {
const items = chatList || []
const suggested = suggestedQuestions ?? []
return (
<div data-testid="chat-component">
<div data-testid="chat-list">
{items.map((item: ChatItem) => (
<div key={item.id} data-testid={`chat-item-${item.id}`}>
{item.content}
</div>
))}
</div>
{!!questionIcon && <div data-testid="question-icon">{questionIcon}</div>}
{!!answerIcon && <div data-testid="answer-icon">{answerIcon}</div>}
<textarea
data-testid="chat-input"
placeholder="Type a message"
readOnly={readonly}
disabled={inputDisabled}
onChange={() => {}}
/>
<button
data-testid="send-button"
onClick={() => onSend?.('test message', [])}
disabled={isResponding || readonly || inputDisabled}
>
Send
</button>
<button
data-testid="send-with-files"
onClick={() => onSend?.('test message', [mockFile])}
disabled={isResponding || readonly || inputDisabled}
>
Send With Files
</button>
<button
data-testid="send-with-document"
onClick={() => onSend?.('test message', [mockDocumentFile])}
disabled={isResponding || readonly || inputDisabled}
>
Send With Document
</button>
{isResponding && (
<button data-testid="stop-button" onClick={onStopResponding}>
Stop
</button>
)}
{suggested.length > 0 && (
<div data-testid="suggested-questions">
{suggested.map((q: string) => (
<button key={q} type="button" onClick={() => onSend?.(q, [])}>
{q}
</button>
))}
</div>
)}
{onRegenerate && (
<button
data-testid="regenerate-button"
onClick={() =>
onRegenerate({
id: 'msg-1',
content: 'Question',
isAnswer: false,
message_files: [],
parentMessageId: 'msg-0',
})
}
>
Regenerate
</button>
)}
{switchSibling && (
<button data-testid="switch-sibling-button" onClick={() => switchSibling('sibling-1')}>
Switch
</button>
)}
{showFeatureBar && onFeatureBarClick && (
<button
data-testid="feature-bar-button"
disabled={featureBarReadonly}
onClick={() => onFeatureBarClick(true)}
>
Features
</button>
)}
{onAnnotationAdded && (
<button
data-testid="add-annotation-button"
onClick={() => onAnnotationAdded('ann-1', 'user', 'q', 'a', 0)}
>
Add Annotation
</button>
)}
{onAnnotationEdited && (
<button
data-testid="edit-annotation-button"
onClick={() => onAnnotationEdited('q', 'a', 0)}
>
Edit Annotation
</button>
)}
{onAnnotationRemoved && (
<button data-testid="remove-annotation-button" onClick={() => onAnnotationRemoved(0)}>
Remove Annotation
</button>
)}
</div>
)
},
}))
const logProps = { onOpenLog: vi.fn(), chatContainerRef: createRef<HTMLDivElement>() }
describe('DebugWithSingleModel', () => {
let ref: RefObject<DebugWithSingleModelRefType | null>
beforeEach(() => {
vi.clearAllMocks()
mockSsePost.mockReset().mockResolvedValue(undefined)
ref = createRef<DebugWithSingleModelRefType | null>()
mockUseDebugConfigurationContext.mockReturnValue(mockDebugConfigContext)
mockUseConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext)
mockUseFormattingChangedSubscription.mockReturnValue(undefined)
mockFeaturesState = { ...defaultFeatures }
mockUseFeatures.mockImplementation((selector?: FeatureSelector) => {
if (typeof selector === 'function') return selector({ features: mockFeaturesState })
return mockFeaturesState
})
mockFetchConversationMessages.mockResolvedValue({ data: [] })
mockFetchSuggestedQuestions.mockResolvedValue({ data: [] })
mockStopChatMessageResponding.mockResolvedValue({})
})
describe('Props', () => {
it('should respect checkCanSend returning true', async () => {
const user = userEvent.setup()
const checkCanSend = vi.fn(() => true)
render(
<DebugWithSingleModel
{...logProps}
ref={ref as RefObject<DebugWithSingleModelRefType>}
checkCanSend={checkCanSend}
/>,
)
const sendButton = screen.getByTestId('send-button')
await user.click(sendButton)
await waitFor(() => {
expect(checkCanSend).toHaveBeenCalled()
expect(mockSsePost).toHaveBeenCalled()
})
expect(mockSsePost.mock.calls[0]![0]).toBe('apps/test-app-id/chat-messages')
expect(mockSsePost.mock.calls[0]![1].body).toMatchObject({
query: 'test message',
inputs: { var1: 'test input' },
parent_message_id: null,
model_config: {
model: {
provider: 'openai',
name: 'gpt-3.5-turbo',
completion_params: { max_tokens: 100, temperature: 0.7 },
},
},
})
expect(mockSsePost.mock.calls[0]![2]).toEqual(
expect.objectContaining({
onNotifyError: expect.any(Function),
}),
)
const callbacks = mockSsePost.mock.calls[0]![2] as {
onNotifyError: (message: string) => void
}
callbacks.onNotifyError('Base model not found')
expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should prevent send when checkCanSend returns false', async () => {
const user = userEvent.setup()
const checkCanSend = vi.fn(() => false)
render(
<DebugWithSingleModel
{...logProps}
ref={ref as RefObject<DebugWithSingleModelRefType>}
checkCanSend={checkCanSend}
/>,
)
const sendButton = screen.getByTestId('send-button')
await user.click(sendButton)
await waitFor(() => {
expect(checkCanSend).toHaveBeenCalled()
expect(checkCanSend).toHaveReturnedWith(false)
})
expect(mockSsePost).not.toHaveBeenCalled()
})
})
describe('User Interactions', () => {
it('should open feature configuration when feature bar is clicked', async () => {
const user = userEvent.setup()
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByTestId('feature-bar-button'))
expect(mockDebugConfigContext.onOpenFeatures).toHaveBeenCalledTimes(1)
})
it('should allow sending but disable feature configuration when configuration is readonly and test/run is allowed', async () => {
const user = userEvent.setup()
mockUseDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
readonly: true,
canTestAndRun: true,
})
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-input')).not.toHaveAttribute('readonly')
expect(screen.getByTestId('feature-bar-button')).toBeDisabled()
expect(screen.queryByTestId('add-annotation-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('edit-annotation-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('remove-annotation-button')).not.toBeInTheDocument()
await user.click(screen.getByTestId('send-button'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
})
it('should block sending when test/run permission is missing', () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
readonly: false,
canTestAndRun: false,
})
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
expect(screen.getByTestId('chat-input')).toHaveAttribute('readonly')
expect(screen.getByTestId('chat-input')).toBeDisabled()
expect(screen.getByTestId('send-button')).toBeDisabled()
expect(screen.getByTestId('feature-bar-button')).toBeInTheDocument()
expect(screen.queryByTestId('add-annotation-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('edit-annotation-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('remove-annotation-button')).not.toBeInTheDocument()
})
})
describe('Model Configuration', () => {
it('should include opening features in request when enabled', async () => {
const user = userEvent.setup()
mockFeaturesState = {
...defaultFeatures,
opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
}
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByTestId('send-button'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
const body = mockSsePost.mock.calls[0]![1].body
expect(body).toHaveProperty('model_config.opening_statement', 'Hello!')
expect(body).toHaveProperty('model_config.suggested_questions', ['Q1'])
})
it('should omit opening statement when feature is disabled', async () => {
const user = userEvent.setup()
mockFeaturesState = {
...defaultFeatures,
opening: {
enabled: false,
opening_statement: 'Should not appear',
suggested_questions: ['Q1'],
},
}
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByTestId('send-button'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
const body = mockSsePost.mock.calls[0]![1].body
expect(body).toHaveProperty('model_config.opening_statement', '')
expect(body).toHaveProperty('model_config.suggested_questions', [])
})
})
it('restarts the active conversation through the parent ref and allows a fresh request', async () => {
const user = userEvent.setup()
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByRole('button', { name: 'Send' }))
const callbacks = mockSsePost.mock.calls[0]![2]!
act(() =>
callbacks.onData?.('Streamed answer', true, {
messageId: 'answer-1',
conversationId: 'conversation-1',
taskId: 'task-1',
}),
)
expect(await screen.findByText('Streamed answer')).toBeVisible()
act(() => ref.current!.handleRestart())
expect(screen.queryByText('Streamed answer')).not.toBeInTheDocument()
expect(screen.queryByText('test message')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(mockSsePost).toHaveBeenCalledTimes(2))
expect(mockSsePost.mock.calls[1]![1].body).toHaveProperty('parent_message_id', null)
})
describe('File Upload', () => {
it('serializes a document attachment when uploads are enabled', async () => {
const user = userEvent.setup()
mockUseDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
modelConfig: createMockModelConfig({
model_id: 'gpt-3.5-turbo',
}),
})
mockFeaturesState = {
...defaultFeatures,
file: { enabled: true },
}
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByTestId('send-with-document'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
const body = mockSsePost.mock.calls[0]![1].body
expect(body).toHaveProperty('files', [
{
type: SupportUploadFileTypes.document,
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: '',
},
])
})
it('serializes an image attachment when uploads are enabled', async () => {
const user = userEvent.setup()
mockUseDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
modelConfig: createMockModelConfig({
model_id: 'gpt-4-vision',
}),
})
mockFeaturesState = {
...defaultFeatures,
file: { enabled: true },
}
render(
<DebugWithSingleModel {...logProps} ref={ref as RefObject<DebugWithSingleModelRefType>} />,
)
await user.click(screen.getByTestId('send-with-files'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
const body = mockSsePost.mock.calls[0]![1].body
expect(body).toHaveProperty('files', [
{ type: 'image', transfer_method: TransferMethod.local_file, url: '', upload_file_id: '' },
])
})
it('shows a transport error through the configuration toast host', async () => {
const user = setup()
await send(user)
await act(async () => {
api.stream.mock.lastCall![2]!.onNotifyError?.('Base model not found')
})
expect(await screen.findByText('Base model not found')).toBeVisible()
})
it('does not start a request when the parent validation rejects it', async () => {
const checkCanSend = vi.fn(() => false)
const user = setup({ checkCanSend })
await send(user)
expect(checkCanSend).toHaveBeenCalledTimes(1)
expect(api.stream).not.toHaveBeenCalled()
expect(
screen.queryByRole('button', { name: 'appDebug.operation.stopResponding' }),
).not.toBeInTheDocument()
})
it('opens feature configuration through the real feature bar', async () => {
const user = setup()
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' }))
expect(onOpenFeatures).toHaveBeenCalledExactlyOnceWith(true)
})
it('allows test runs with read-only configuration but hides annotation management and disables feature editing', async () => {
const user = setup({ context: { readonly: true, canTestAndRun: true } })
expect(screen.getByRole('textbox')).not.toBeDisabled()
expect(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' })).toBeDisabled()
await send(user)
const callbacks = await deliverAnswer()
await act(async () => {
await callbacks.onCompleted?.()
})
expect(await screen.findByText('Streamed answer')).toBeVisible()
expect(
screen.queryByRole('button', { name: 'appDebug.feature.annotation.add' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'appDebug.feature.annotation.edit' }),
).not.toBeInTheDocument()
expect(api.stream).toHaveBeenCalledTimes(1)
})
it('disables the composer without test/run permission while keeping configuration editable', async () => {
const user = setup({ context: { readonly: false, canTestAndRun: false } })
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' }))
expect(onOpenFeatures).toHaveBeenCalledExactlyOnceWith(true)
expect(api.stream).not.toHaveBeenCalled()
})
it.each([true, false])(
'uses the enabled state of opening features in the chat and request (%s)',
async (enabled) => {
const user = setup({
features: {
opening: { enabled, opening_statement: 'Hello!', suggested_questions: ['First question'] },
},
})
if (enabled) expect(await screen.findByText('Hello!')).toBeVisible()
else expect(screen.queryByText('Hello!')).not.toBeInTheDocument()
await send(user)
expect(api.stream.mock.lastCall![1].body).toMatchObject({
model_config: {
opening_statement: enabled ? 'Hello!' : '',
suggested_questions: enabled ? ['First question'] : [],
},
})
},
)
it('stops the current task through the real stop control', async () => {
const user = setup()
await send(user)
await deliverAnswer()
await user.click(screen.getByRole('button', { name: 'appDebug.operation.stopResponding' }))
await waitFor(() =>
expect(api.stop).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'test-app-id', task_id: 'task-1' },
}),
)
expect(
screen.queryByRole('button', { name: 'appDebug.operation.stopResponding' }),
).not.toBeInTheDocument()
expect(screen.getByText('Streamed answer')).toBeVisible()
})
it('restarts through the parent ref and sends the next message without the previous conversation', async () => {
const user = setup()
await send(user)
await deliverAnswer()
await user.click(screen.getByRole('button', { name: 'Restart debug conversation' }))
expect(screen.queryByText('Streamed answer')).not.toBeInTheDocument()
expect(screen.queryByText('Test question')).not.toBeInTheDocument()
await send(user, 'New conversation')
expect(api.stream).toHaveBeenCalledTimes(2)
expect(api.stream.mock.lastCall![1].body).toMatchObject({
query: 'New conversation',
parent_message_id: null,
})
expect(api.stream.mock.lastCall![1].body).not.toHaveProperty('conversation_id', 'conversation-1')
})
it.each([
['test.pdf', 'application/pdf', 'document'],
['test.png', 'image/png', 'image'],
] as const)('sends the uploaded %s attachment using its server id', async (name, mime, type) => {
const user = setup({
features: {
file: {
enabled: true,
allowed_file_types: ['document', 'image'],
allowed_file_upload_methods: [TransferMethod.local_file],
allowed_file_extensions: [],
number_limits: 3,
fileUploadConfig: createFileUploadConfigFixture(),
},
},
})
const file = new File(['attachment'], name, { type: mime })
fireEvent.drop(screen.getByRole('textbox'), { dataTransfer: { files: [file] } })
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(1))
expect(api.upload.mock.lastCall![0].data.get('file')).toEqual(file)
await send(user, 'Read the attachment')
expect(api.stream.mock.lastCall![1].body).toHaveProperty('files', [
{
type,
transfer_method: TransferMethod.local_file,
url: '',
upload_file_id: 'uploaded-file-1',
},
])
})
@@ -35,7 +35,7 @@ const DebugWithSingleModel = ({
onOpenLog,
chatContainerRef,
}: DebugWithSingleModelProps & {
ref: React.RefObject<DebugWithSingleModelRefType>
ref: Ref<DebugWithSingleModelRefType>
}) => {
const { data: userProfile } = useSuspenseQuery({
...userProfileQueryOptions(),
@@ -1,817 +0,0 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { act, screen, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import CommonLayoutError from '@/app/(commonLayout)/error'
import ErrorBoundary from '@/app/components/base/error-boundary'
import { DETAIL_SIDEBAR_COOKIE_NAME } from '@/app/components/detail-sidebar/cookie'
import { consoleClient, consoleQuery } from '@/service/console'
import { createHttpError } from '@/service/http-error'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { seedAppDetail } from '@/test/console/query-data'
import { renderHook as renderHookWithConsoleState } from '@/test/console/render'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { ModelModeType } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import { useConfiguration } from '../use-configuration'
let mockAppPermissionKeys: string[] = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion]
const createRouteAppDetail = (overrides: Partial<AppDetailWithSite> = {}) =>
createAppDetailFixture({
id: 'app-1',
model_config: createAppModelConfigFixture({
updated_at: 1710000000,
}),
mode: 'chat' as const,
permission_keys: mockAppPermissionKeys,
...overrides,
})
const createSeededQueryClient = (appDetail: Partial<AppDetailWithSite> = {}) => {
const queryClient = createTestQueryClient()
void queryClient.query({
...consoleQuery.files.upload.get.queryOptions(),
queryFn: () => new Promise(() => {}),
})
seedAccountProfileQuery(queryClient, { id: 'user-1' })
seedAppDetail(queryClient, createRouteAppDetail(appDetail))
return queryClient
}
const onUrlUpdate = vi.fn()
const renderHook = (
callback: () => ReturnType<typeof useConfiguration>,
appDetail?: Partial<AppDetailWithSite>,
) => {
const queryClient = createSeededQueryClient(appDetail)
const QueryWrapper = createQueryClientWrapper(queryClient)
return {
...renderHookWithConsoleState(callback, {
wrapper: ({ children }) => (
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
}),
queryClient,
}
}
const mockHandleMultipleModelConfigsChange = vi.fn()
const mockFetchCollectionList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockUpdateModelConfig = vi.hoisted(() => vi.fn())
const mockFetchDatasets = vi.fn()
const mockFetchAndMergeValidCompletionParams = vi.fn()
const mockFormattingChangedDispatcher = vi.fn()
const mockMigrateToDefaultPrompt = vi.fn()
const mockSetConversationHistoriesRole = vi.fn()
const mockSetChatPromptConfig = vi.fn()
const mockSetCompletionPromptConfig = vi.fn()
const mockSetCurrentAdvancedPrompt = vi.fn()
type AdvancedPromptConfigOptions = {
onUserChangedPrompt: () => void
setStop: (stop: string[]) => void
}
let latestAdvancedPromptConfigOptions: AdvancedPromptConfigOptions | undefined
let mockCurrentModelFeatures = ['vision']
let mockCurrentModelMode = ModelModeType.chat
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: false,
userProfile: { id: 'user-1' },
workspacePermissionKeys: ['app.create_and_management'],
}))
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
currentWorkspace: { id: 'workspace-1' },
isLoadingCurrentWorkspace: false,
userProfile: { id: 'user-1' },
workspacePermissionKeys: ['app.create_and_management'],
}))
})
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
},
}))
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
usePathname: () => '/app/app-1/configuration',
useParams: () => ({ appId: 'app-1' }),
}))
vi.mock('@/app/components/app/configuration/debug/hooks', () => ({
useDebugWithSingleOrMultipleModel: () => ({
debugWithMultipleModel: false,
multipleModelConfigs: [],
handleMultipleModelConfigsChange: mockHandleMultipleModelConfigsChange,
}),
useFormattingChangedDispatcher: () => mockFormattingChangedDispatcher,
}))
vi.mock('../use-advanced-prompt-config', () => ({
default: (options: AdvancedPromptConfigOptions) => {
latestAdvancedPromptConfigOptions = options
return {
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] },
setChatPromptConfig: mockSetChatPromptConfig,
completionPromptConfig: {
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
},
setCompletionPromptConfig: mockSetCompletionPromptConfig,
currentAdvancedPrompt: [],
setCurrentAdvancedPrompt: mockSetCurrentAdvancedPrompt,
hasSetBlockStatus: {
context: false,
history: true,
query: true,
},
setConversationHistoriesRole: mockSetConversationHistoriesRole,
migrateToDefaultPrompt: mockMigrateToDefaultPrompt,
}
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
currentModel: { model: 'rerank-1' },
currentProvider: { provider: 'langgenius/cohere/cohere' },
}),
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentModel: {
features: mockCurrentModelFeatures,
model_properties: {
mode: mockCurrentModelMode,
},
},
}),
}))
vi.mock('@/service/tools', () => ({
fetchCollectionList: (...args: unknown[]) => mockFetchCollectionList(...args),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses, withConsoleClientResponses } =
await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'apps.byAppId.get': () => createRouteAppDetail(),
'apps.byAppId.modelConfig.post': (input) => mockUpdateModelConfig(input),
}),
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'apps.byAppId.get': (input) => mockFetchAppDetailDirect(input),
'apps.byAppId.modelConfig.post': mockUpdateModelConfig,
}),
}
})
vi.mock('@/service/datasets', () => ({
fetchDatasets: (...args: unknown[]) => mockFetchDatasets(...args),
}))
vi.mock('@/utils/completion-params', () => ({
fetchAndMergeValidCompletionParams: (...args: unknown[]) =>
mockFetchAndMergeValidCompletionParams(...args),
}))
describe('useConfiguration', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME)
latestAdvancedPromptConfigOptions = undefined
mockCurrentModelFeatures = ['vision']
mockCurrentModelMode = ModelModeType.chat
mockAppPermissionKeys = [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion]
mockFetchCollectionList.mockResolvedValue([])
mockFetchDatasets.mockResolvedValue({ data: [] })
mockFetchAndMergeValidCompletionParams.mockResolvedValue({
params: { temperature: 0.3 },
removedDetails: {},
})
vi.mocked(consoleClient.apps.byAppId.modelConfig.post).mockResolvedValue(undefined as never)
mockFetchAppDetailDirect.mockResolvedValue({
deleted_tools: [],
mode: 'chat' as const,
model_config: createAppModelConfigFixture({
prompt_type: 'advanced',
chat_prompt_config: {
prompt: [{ role: 'system', text: 'hi' }],
},
completion_prompt_config: {
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [],
},
},
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: ModelModeType.chat,
completion_params: { temperature: 0.7 },
},
user_input_form: [],
pre_prompt: '',
opening_statement: 'hello',
suggested_questions: ['how are you?'],
more_like_this: { enabled: true },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
retriever_resource: { enabled: true },
annotation_reply: { enabled: false },
sensitive_word_avoidance: { enabled: false },
external_data_tools: [],
}),
})
})
it('opens, closes, and reopens feature configuration within the current session', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => expect(result.current.showLoading).toBe(false))
expect(result.current.showAppConfigureFeaturesModal).toBe(false)
act(() => result.current.contextValue.onOpenFeatures())
expect(result.current.showAppConfigureFeaturesModal).toBe(true)
act(() => result.current.onCloseFeaturePanel())
expect(result.current.showAppConfigureFeaturesModal).toBe(false)
act(() => result.current.contextValue.onOpenFeatures())
expect(result.current.showAppConfigureFeaturesModal).toBe(true)
})
it('should leave loading through the error boundary when the app has no model configuration', async () => {
mockFetchAppDetailDirect.mockResolvedValueOnce(createAppDetailFixture({ model_config: null }))
const queryClient = createSeededQueryClient()
const QueryWrapper = createQueryClientWrapper(queryClient)
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderHookWithConsoleState(() => useConfiguration(), {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>
<ErrorBoundary fallback={(error) => <div role="alert">{error.message}</div>}>
{children}
</ErrorBoundary>
</QueryWrapper>
</NuqsTestingAdapter>
),
})
expect(await screen.findByRole('alert')).toHaveTextContent(
'App app-1 has no model configuration',
)
} finally {
report.mockRestore()
}
})
it('should retain unauthorized ORPCError for the redirect loading fallback', async () => {
const response = createHttpError(new Response(null, { status: 401 }))
mockFetchCollectionList.mockRejectedValueOnce(response)
const queryClient = createSeededQueryClient()
const QueryWrapper = createQueryClientWrapper(queryClient)
const onError = vi.fn()
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
renderHookWithConsoleState(() => useConfiguration(), {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>
<ErrorBoundary
onError={onError}
fallback={(error) => <CommonLayoutError error={error} retry={vi.fn()} />}
>
{children}
</ErrorBoundary>
</QueryWrapper>
</NuqsTestingAdapter>
),
})
await waitFor(() => expect(onError).toHaveBeenCalledWith(response, expect.anything()))
expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
} finally {
report.mockRestore()
}
})
it('should load configuration state and expose the derived view model', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.isAdvancedMode).toBe(true)
expect(result.current.appPublisherProps.publishedAt).toBe(1710000000000)
expect(result.current.contextValue.introduction).toBe('hello')
expect(result.current.contextValue.suggestedQuestions).toEqual(['how are you?'])
expect(result.current.appPublisherProps.publishedConfig.modelConfig.model_id).toBe('gpt-4o')
expect(result.current.contextValue.isShowVisionConfig).toBe(true)
})
it('should update model parameters and publish the current configuration', async () => {
const { result, queryClient } = renderHook(() => useConfiguration(), {
enable_api: false,
enable_site: false,
icon_url: null,
name: 'Cached app',
})
const detailQueryKey = consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: 'app-1' } },
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
await act(async () => {
await result.current.onModelChange({
features: ['vision'],
mode: ModelModeType.chat,
modelId: 'gpt-4.1',
provider: 'langgenius/openai/openai',
})
})
expect(mockFetchAndMergeValidCompletionParams).toHaveBeenCalledWith(
'langgenius/openai/openai',
'gpt-4.1',
{ temperature: 0.7 },
true,
)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(consoleClient.apps.byAppId.modelConfig.post).toHaveBeenCalledWith(
expect.objectContaining({
params: { app_id: 'app-1' },
body: expect.objectContaining({
model: expect.objectContaining({
name: 'gpt-4.1',
completion_params: { temperature: 0.3 },
}),
}),
}),
)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: detailQueryKey })
})
it('should publish and restore the complete configuration snapshot', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(result.current.appPublisherProps.publishedConfig.datasetConfigs.top_k).toBe(8)
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 10,
})
})
mockSetChatPromptConfig.mockClear()
mockSetCompletionPromptConfig.mockClear()
act(() => {
result.current.appPublisherProps.resetAppConfig?.()
})
expect(result.current.contextValue.datasetConfigs.top_k).toBe(8)
expect(mockSetChatPromptConfig).toHaveBeenCalledWith({
prompt: [{ role: 'system', text: 'hi' }],
})
expect(mockSetCompletionPromptConfig).toHaveBeenCalledWith({
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
})
})
it('should enable multiple-model mode', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.onEnableMultipleModelDebug()
})
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
expect.arrayContaining([
expect.objectContaining({
model: 'gpt-4o',
provider: 'langgenius/openai/openai',
}),
]),
)
})
it('should keep multiple-model debug state when restoring published config', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.onEnableMultipleModelDebug()
})
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
mockHandleMultipleModelConfigsChange.mockClear()
act(() => {
result.current.appPublisherProps.resetAppConfig?.()
})
expect(mockHandleMultipleModelConfigsChange).not.toHaveBeenCalled()
})
it('should sync the selected multiple-model config after publishing', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
await act(async () => {
await result.current.appPublisherProps.onPublish!(
{
id: 'model-2',
model: 'gpt-4.1',
provider: 'langgenius/openai/openai',
parameters: { temperature: 0.2 },
},
result.current.featuresData,
)
})
expect(result.current.contextValue.modelConfig.model_id).toBe('gpt-4.1')
expect(result.current.contextValue.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.contextValue.completionParams).toEqual({ temperature: 0.2 })
expect(result.current.appPublisherProps.publishedConfig.modelConfig.model_id).toBe('gpt-4.1')
})
it('should block publishing when app release permission is missing', async () => {
mockAppPermissionKeys = [AppACLPermission.ViewLayout]
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.contextValue.readonly).toBe(true)
expect(result.current.contextValue.canTestAndRun).toBe(false)
expect(result.current.appPublisherProps.disabled).toBe(true)
expect(result.current.appPublisherProps.publishDisabled).toBe(true)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should allow test and run while keeping configuration readonly when only app test/run permission exists', async () => {
mockAppPermissionKeys = [AppACLPermission.TestAndRun]
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.contextValue.readonly).toBe(true)
expect(result.current.contextValue.canTestAndRun).toBe(true)
expect(result.current.appPublisherProps.disabled).toBe(true)
expect(result.current.appPublisherProps.publishDisabled).toBe(true)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should keep configuration editable but block publishing when only app edit permission exists', async () => {
mockAppPermissionKeys = [AppACLPermission.Edit]
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.contextValue.readonly).toBe(false)
expect(result.current.contextValue.canTestAndRun).toBe(false)
expect(result.current.appPublisherProps.disabled).toBe(true)
expect(result.current.appPublisherProps.publishDisabled).toBe(true)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(consoleClient.apps.byAppId.modelConfig.post).not.toHaveBeenCalled()
})
it('should allow publishing with app release permission even when configuration is readonly', async () => {
mockAppPermissionKeys = [AppACLPermission.ReleaseAndVersion]
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.contextValue.readonly).toBe(true)
expect(result.current.contextValue.canTestAndRun).toBe(false)
expect(result.current.appPublisherProps.disabled).toBe(false)
expect(result.current.appPublisherProps.publishDisabled).toBe(false)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(consoleClient.apps.byAppId.modelConfig.post).toHaveBeenCalledWith(
expect.objectContaining({
params: { app_id: 'app-1' },
}),
)
})
it('should expose derived feature flags and imperative callbacks', async () => {
mockCurrentModelFeatures = ['vision', 'document', 'audio', 'video']
mockFetchAppDetailDirect.mockResolvedValueOnce({
deleted_tools: [],
mode: 'chat' as const,
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: {
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: { datasets: [] },
},
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: ModelModeType.chat,
completion_params: { temperature: 0.7 },
},
user_input_form: [],
pre_prompt: 'hello {{name}}',
opening_statement: 'intro',
suggested_questions: [],
more_like_this: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
retriever_resource: { enabled: false },
annotation_reply: {
enabled: true,
id: 'annotation-1',
score_threshold: 0.6,
embedding_model: {
embedding_provider_name: 'langgenius/openai/openai',
embedding_model_name: 'text-embedding-3-small',
},
},
sensitive_word_avoidance: { enabled: false },
external_data_tools: [],
file_upload: {
image: {
enabled: true,
number_limits: 1,
detail: 'low',
transfer_methods: ['local_file'],
},
},
}),
})
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.contextValue.isShowVisionConfig).toBe(true)
expect(result.current.contextValue.isShowDocumentConfig).toBe(true)
expect(result.current.contextValue.isShowAudioConfig).toBe(true)
expect(result.current.contextValue.isAllowVideoUpload).toBe(true)
await act(async () => {
await result.current.contextValue.setPromptMode('advanced')
})
expect(mockMigrateToDefaultPrompt).toHaveBeenCalled()
act(() => {
result.current.onFeaturesChange({ moreLikeThis: { enabled: true } })
result.current.onAutoAddPromptVariable([
{ key: 'city', name: 'City', type: 'string', required: true },
])
})
act(() => {
result.current.onAgentSettingChange({
enabled: true,
max_iteration: 5,
strategy: 'react',
tools: [],
} as never)
result.current.onEnableMultipleModelDebug()
result.current.setShowUseGPT4Confirm(true)
result.current.onConfirmUseGPT4()
result.current.onOpenAccountSettings()
result.current.onSaveHistory({
assistant_prefix: 'bot',
user_prefix: 'user',
})
})
expect(result.current.showAppConfigureFeaturesModal).toBe(true)
expect(result.current.featuresData.moreLikeThis).toEqual({ enabled: false })
expect(result.current.contextValue.modelConfig.configs.prompt_variables).toContainEqual(
expect.objectContaining({ key: 'city', name: 'City' }),
)
expect(result.current.contextValue.modelConfig.agentConfig).toMatchObject({
enabled: true,
max_iteration: 5,
})
expect(mockFormattingChangedDispatcher).toHaveBeenCalled()
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled()
expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse')
await waitFor(() =>
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'),
)
expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({
assistant_prefix: 'bot',
user_prefix: 'user',
})
expect(result.current.showUseGPT4Confirm).toBe(false)
})
it('should preserve temporary stops, dataset selections, and manual formatting changes', async () => {
mockCurrentModelFeatures = ['vision']
mockCurrentModelMode = ModelModeType.completion
mockFetchDatasets.mockResolvedValueOnce({
data: [{ id: 'dataset-1', name: 'Dataset One' }],
})
mockFetchAppDetailDirect.mockResolvedValueOnce({
deleted_tools: [],
mode: 'chat' as const,
model_config: createAppModelConfigFixture({
prompt_type: 'simple',
chat_prompt_config: { prompt: [] },
completion_prompt_config: {
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
},
dataset_configs: {
retrieval_model: 'multiple',
datasets: {
datasets: [{ dataset: { id: 'dataset-1', enabled: true } }],
},
},
dataset_query_variable: 'context',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: ModelModeType.completion,
completion_params: { temperature: 0.7 },
},
user_input_form: [
{
'text-input': {
variable: 'context',
label: 'Context',
required: false,
},
},
],
pre_prompt: '',
opening_statement: 'intro',
suggested_questions: [],
more_like_this: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
retriever_resource: { enabled: false },
annotation_reply: { enabled: false },
sensitive_word_avoidance: { enabled: false },
external_data_tools: [],
file_upload: {},
}),
})
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.selectedIds).toEqual(['dataset-1'])
expect(result.current.contextValue.canReturnToSimpleMode).toBe(true)
act(() => {
latestAdvancedPromptConfigOptions?.onUserChangedPrompt?.()
latestAdvancedPromptConfigOptions?.setStop?.(['END'])
})
act(() => {
result.current.contextValue.setAnnotationConfig({
id: 'annotation-1',
enabled: true,
score_threshold: 0.6,
embedding_model: {
embedding_provider_name: 'langgenius/openai/openai',
embedding_model_name: 'text-embedding-3-small',
},
})
result.current.contextValue.setVisionConfig({
enabled: true,
number_limits: 2,
detail: 'high',
transfer_methods: ['local_file'],
})
result.current.contextValue.setCompletionParams({ temperature: 0.2 })
result.current.onCloseFeaturePanel()
})
await waitFor(() => {
expect(result.current.contextValue.completionParams).toEqual({
stop: ['END'],
temperature: 0.2,
})
})
expect(result.current.contextValue.annotationConfig.id).toBe('annotation-1')
expect(result.current.contextValue.visionConfig.enabled).toBe(true)
expect(result.current.contextValue.canReturnToSimpleMode).toBe(false)
expect(mockFormattingChangedDispatcher).toHaveBeenCalledTimes(2)
expect(result.current.showAppConfigureFeaturesModal).toBe(false)
})
})
@@ -64,9 +64,6 @@ vi.mock('@/context/permission-state', async () => {
vi.mock('@/context/i18n', () => ({
useDocLink: () => () => '/guides',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
const mockUseRouter = vi.mocked(useRouter)
const mockPush = vi.fn()
@@ -864,11 +864,6 @@ vi.mock('@/context/i18n', () => ({
getEnterpriseDocUrl: mockGetEnterpriseDocUrl,
}))
vi.mock('#i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('#i18n')>()),
useLocale: () => 'en-US',
}))
vi.mock('@/app/notifications', () => ({
toast: {
error: vi.fn(),
@@ -1,105 +1,60 @@
import type { EnvironmentVariableSlot } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { EnvVarValueSource, EnvVarValueType } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { render, screen } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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 { EnvironmentVariableField } from '../environment-variable-field'
const activeModelList = vi.hoisted(() => [
{
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
{
deprecated: false,
has_invalid_load_balancing_configs: false,
label: { en_US: 'Chat model', zh_Hans: 'Chat model' },
load_balancing_enabled: false,
model: 'chat-model',
model_properties: { mode: 'chat' },
model_type: 'llm',
status: 'active',
const render = createNuqsTestRenderer((ui) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
provider: 'langgenius/openai/openai',
models: [
createCatalogModelFixture({
model: 'chat-model',
label: { en_US: 'Chat model' },
model_properties: { mode: 'chat' },
}),
createCatalogModelFixture({
model: 'chat-model-v2',
label: { en_US: 'Chat model v2' },
model_properties: { mode: 'chat' },
}),
createCatalogModelFixture({
model: 'completion-model',
label: { en_US: 'Completion model' },
model_properties: { mode: 'completion' },
}),
],
}),
])
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{
initialData: {
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
},
],
},
{
deprecated: false,
has_invalid_load_balancing_configs: false,
label: { en_US: 'Chat model v2', zh_Hans: 'Chat model v2' },
load_balancing_enabled: false,
model: 'chat-model-v2',
model_properties: { mode: 'chat' },
model_type: 'llm',
status: 'active',
},
{
deprecated: false,
has_invalid_load_balancing_configs: false,
label: { en_US: 'Completion model', zh_Hans: 'Completion model' },
load_balancing_enabled: false,
model: 'completion-model',
model_properties: { mode: 'completion' },
model_type: 'llm',
status: 'active',
},
],
provider: 'langgenius/openai/openai',
},
])
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
activeTextGenerationModelList: activeModelList,
}),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: ({
completionParams,
modelId,
modelList = [],
modelSelectorReadonly,
provider,
readonly,
onCompletionParamsChange,
setModel,
}: ModelParameterModalProps) => {
const selectableModels = modelList.flatMap((providerItem) =>
providerItem.models.map((model) => ({ model, provider: providerItem.provider })),
)
const nextModel = selectableModels.find(({ model }) => model.model === 'chat-model-v2')
return (
<div>
<button
type="button"
aria-label="Select deployment model"
disabled={readonly || modelSelectorReadonly}
onClick={() => {
if (!nextModel) return
setModel({
modelId: nextModel.model.model,
mode: nextModel.model.model_properties.mode as string,
provider: nextModel.provider,
})
}}
>
{provider && modelId ? `${provider}/${modelId}` : 'No model selected'}
</button>
<button
type="button"
onClick={() => onCompletionParamsChange({ ...completionParams, temperature: 0.4 })}
>
Set temperature
</button>
{selectableModels.map(({ model }) => (
<span key={model.model}>{model.model}</span>
))}
</div>
)
},
}),
)
)
return renderWithConsoleQuery(ui, { queryClient })
})
const llmSlot: EnvironmentVariableSlot = {
configured_value: {
@@ -116,6 +71,10 @@ const llmSlot: EnvironmentVariableSlot = {
}
describe('EnvironmentVariableField', () => {
beforeAll(async () => {
// Load the production chunk before testing the lazy field's interactions.
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()
@@ -128,12 +87,9 @@ describe('EnvironmentVariableField', () => {
/>,
)
const modelSelector = await screen.findByRole('button', { name: 'Select deployment model' })
const modelSelector = await screen.findByRole('button', { name: /^Chat model/ })
expect(modelSelector).toBeDisabled()
expect(modelSelector).toHaveTextContent('langgenius/openai/openai/chat-model')
expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument()
expect(screen.getByText('chat-model-v2')).toBeInTheDocument()
expect(screen.queryByText('completion-model')).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: /MODEL/ }))
await user.click(
@@ -142,9 +98,18 @@ describe('EnvironmentVariableField', () => {
}),
)
expect(modelSelector).toBeEnabled()
await user.click(modelSelector)
await user.click(screen.getByRole('button', { name: 'Set temperature' }))
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()
expect(onChange).toHaveBeenLastCalledWith('workflow-1', 'MODEL', {
customValue: {
@@ -178,9 +143,9 @@ describe('EnvironmentVariableField', () => {
/>,
)
const modelSelector = await screen.findByRole('button', { name: 'Select deployment model' })
const modelSelector = await screen.findByRole('button', { name: /^Chat model/ })
expect(modelSelector).toBeDisabled()
expect(modelSelector).toHaveTextContent('langgenius/openai/openai/chat-model-v2')
expect(modelSelector).toHaveTextContent('Chat model v2')
expect(screen.getByRole('combobox', { name: /MODEL/ })).toHaveTextContent(
'deployments.deployDrawer.envVarSource.lastDeployment',
)
@@ -10,17 +10,7 @@ import { useGetProviderIcon } from '../use-provider-icon'
const request = vi.hoisted(() => vi.fn<(url: string) => Promise<Response>>())
vi.mock('@/service/base', () => ({ request }))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const { mockConsoleOperationHooks } = await import('@/test/console/operation-hooks')
return mockConsoleOperationHooks(await importOriginal(), {
'workspaces.current.tools.builtin.get': () => ({}),
'workspaces.current.tools.api.get': () => ({}),
'workspaces.current.tools.workflow.get': () => ({}),
'workspaces.current.tools.mcp.get': () => ({}),
})
})
vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({}) }))
vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) }))
vi.mock('@/utils/var', async (importOriginal) => ({
...(await importOriginal<typeof import('@/utils/var')>()),
basePath: '/base',
@@ -241,6 +241,7 @@ describe('DuplicateAppModal', () => {
})
it('waits for the real Cloud quota and allows an unlimited quota without an upgrade notice', async () => {
const user = userEvent.setup()
const queryClient = createConsoleQueryClient()
void queryClient.query({
...consoleQuery.features.get.queryOptions(),
@@ -265,6 +266,6 @@ it('waits for the real Cloud quota and allows an unlimited quota without an upgr
seedFeatures(queryClient, { apps: { size: 100, limit: 0 } })
})
await waitFor(() => expect(button).toBeEnabled())
await userEvent.setup().click(button)
await user.click(button)
expect(onConfirm).toHaveBeenCalledOnce()
})
File diff suppressed because it is too large. Load diff
+2
View File
@@ -540,6 +540,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
<div
className="py-4"
id="scrollableDiv"
role="log"
aria-live="off"
style={{
display: 'flex',
flexDirection: 'column-reverse',
@@ -73,11 +73,15 @@ const mockAppInfo = {
const triggerName = 'navigation.settings.settings'
const dialogName = 'appOverview.overview.appInfo.settings.title'
const renderSettingsDialog = async (appInfo: SettingsAppInfo = mockAppInfo, canDeploy = false) => {
const renderSettingsDialog = async (
user: ReturnType<typeof userEvent.setup>,
appInfo: SettingsAppInfo = mockAppInfo,
canDeploy = false,
) => {
const view = render(
<SettingsDialog isChat canDeploy={canDeploy} appInfo={appInfo} onSave={mockOnSave} />,
)
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
await user.click(screen.getByRole('button', { name: triggerName }))
return view
}
@@ -106,7 +110,7 @@ describe('SettingsDialog', () => {
it('edits a site with nullable optional fields as empty form values', async () => {
const user = userEvent.setup()
await renderSettingsDialog({
await renderSettingsDialog(user, {
id: 'app-null-fields',
mode: 'chat',
site: createAppSiteFixture(),
@@ -135,7 +139,8 @@ describe('SettingsDialog', () => {
})
it('should explain that Web app settings apply to every environment when ACL allows deploy', async () => {
await renderSettingsDialog(mockAppInfo, true)
const user = userEvent.setup()
await renderSettingsDialog(user, mockAppInfo, true)
expect(screen.getByRole('status')).toHaveTextContent(
'appOverview.overview.appInfo.settings.multiEnvironmentNotice',
@@ -144,7 +149,7 @@ describe('SettingsDialog', () => {
it('toggles inversion through its label without changing the theme color', async () => {
const user = userEvent.setup()
await renderSettingsDialog()
await renderSettingsDialog(user)
const invertedSwitch = screen.getByRole('switch', {
name: 'appOverview.overview.appInfo.settings.chatColorThemeInverted',
@@ -184,7 +189,7 @@ describe('SettingsDialog', () => {
'associates the $label validation error with its control and permits correction',
async ({ label, invalid, valid, error }) => {
const user = userEvent.setup()
await renderSettingsDialog()
await renderSettingsDialog(user)
const input = screen.getByRole('textbox', {
name: `appOverview.overview.appInfo.settings.${label}`,
})
@@ -206,7 +211,7 @@ describe('SettingsDialog', () => {
it('preserves the copyright draft when toggled and saves it with the selected language', async () => {
const user = userEvent.setup()
await renderSettingsDialog()
await renderSettingsDialog(user)
const prefix = 'appOverview.overview.appInfo.settings'
const copyrightGroup = within(screen.getByRole('group', { name: `${prefix}.more.copyright` }))
const copyrightSwitch = copyrightGroup.getByRole('switch', { name: `${prefix}.more.copyright` })
@@ -234,7 +239,7 @@ describe('SettingsDialog', () => {
it('should save valid settings and close the modal', async () => {
const user = userEvent.setup()
mockOnSave.mockResolvedValueOnce(true)
await renderSettingsDialog()
await renderSettingsDialog(user)
await user.click(screen.getByText('common.operation.save'))
@@ -265,7 +270,7 @@ describe('SettingsDialog', () => {
it('discards cancelled edits and initializes the next session from committed settings', async () => {
const user = userEvent.setup()
await renderSettingsDialog()
await renderSettingsDialog(user)
const placeholder = screen.getByRole('textbox', { name: inputPlaceholderName })
await user.clear(placeholder)
await user.type(placeholder, 'Cancelled prompt')
@@ -282,7 +287,7 @@ describe('SettingsDialog', () => {
it('keeps drafts for equivalent source data and resets them when committed settings change', async () => {
const user = userEvent.setup()
const { rerender } = await renderSettingsDialog()
const { rerender } = await renderSettingsDialog(user)
await user.clear(screen.getByRole('textbox', { name: inputPlaceholderName }))
await user.type(screen.getByRole('textbox', { name: inputPlaceholderName }), 'Current draft')
rerender(
@@ -314,7 +319,7 @@ describe('SettingsDialog', () => {
const user = userEvent.setup()
if (failure === 'false') mockOnSave.mockResolvedValueOnce(false)
else mockOnSave.mockRejectedValueOnce(new Error('Unexpected failure'))
await renderSettingsDialog()
await renderSettingsDialog(user)
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
await user.clear(name)
await user.type(name, 'Unsaved title')
@@ -342,7 +347,7 @@ describe('SettingsDialog', () => {
finishSave = resolve
}),
)
const { rerender } = await renderSettingsDialog()
const { rerender } = await renderSettingsDialog(user)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
rerender(
<SettingsDialog
@@ -408,7 +413,7 @@ describe('SettingsDialog', () => {
it('keeps the open draft and blocks saving while the entry is disabled', async () => {
const user = userEvent.setup()
const { rerender } = await renderSettingsDialog()
const { rerender } = await renderSettingsDialog(user)
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
await user.clear(name)
await user.type(name, 'Draft title')
@@ -435,7 +440,7 @@ describe('SettingsDialog', () => {
finishSave = resolve
}),
)
const { rerender } = await renderSettingsDialog()
const { rerender } = await renderSettingsDialog(user)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
rerender(<SettingsDialog isChat onSave={mockOnSave} />)
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
@@ -459,7 +464,7 @@ describe('SettingsDialog', () => {
deploymentEdition = 'CLOUD'
copyrightEnabled = false
await renderSettingsDialog()
await renderSettingsDialog(user)
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
expect(inputPlaceholder).toBeDisabled()
@@ -488,7 +493,7 @@ describe('SettingsDialog', () => {
deploymentEdition = 'COMMUNITY'
copyrightEnabled = false
await renderSettingsDialog()
await renderSettingsDialog(user)
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
await user.clear(inputPlaceholder)
await user.type(inputPlaceholder, 'Self-hosted prompt')
@@ -507,10 +512,11 @@ describe('SettingsDialog', () => {
})
it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => {
const user = userEvent.setup()
deploymentEdition = 'CLOUD'
copyrightEnabled = false
await renderSettingsDialog()
await renderSettingsDialog(user)
const copyrightGroup = within(
screen.getByRole('group', {
name: 'appOverview.overview.appInfo.settings.more.copyright',
@@ -518,7 +524,7 @@ describe('SettingsDialog', () => {
)
expect(copyrightGroup.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
expect(copyrightGroup.getByRole('textbox')).toBeDisabled()
await userEvent.setup().click(
await user.click(
copyrightGroup.getByRole('button', {
name: 'billing.upgradeBtn.encourageShort',
}),
@@ -530,10 +536,11 @@ describe('SettingsDialog', () => {
})
it('should hide the upgrade badge for non-sandbox plans', async () => {
const user = userEvent.setup()
deploymentEdition = 'CLOUD'
copyrightEnabled = true
await renderSettingsDialog()
await renderSettingsDialog(user)
await waitFor(() => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
})
@@ -553,7 +560,7 @@ describe('SettingsDialog', () => {
},
} satisfies SettingsAppInfo
await renderSettingsDialog(imageAppInfo)
await renderSettingsDialog(user, imageAppInfo)
const description = screen.getByDisplayValue('A description')
await user.clear(description)
@@ -607,7 +614,7 @@ it('saves unrelated settings while entitlements are pending without clearing pro
features: undefined,
systemFeatures: { deployment_edition: 'CLOUD' },
})
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
await user.click(screen.getByRole('button', { name: triggerName }))
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeDisabled()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
@@ -70,12 +70,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => {
return { theme: 'light' }
},
}))
const renderWithQueryClient = (ui: React.ReactElement) => {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
@@ -46,12 +46,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon">BlockIcon</div>,
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => {
return { theme: 'light' }
},
}))
const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWithSite =>
createAppDetailFixture({
id: 'test-app-id',
File diff suppressed because it is too large. Load diff
+249 -245
View File
@@ -1,103 +1,77 @@
import type { Import } from '@dify/contracts/api/console/apps/types.gen'
import type {
RecommendedAppDetailResponse,
RecommendedAppResponse,
} from '@dify/contracts/api/console/explore/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
import type { trackEvent } from '@/app/components/base/amplitude/utils'
import type { consoleClient } from '@/service/console'
import type { marketplaceClient } from '@/service/marketplace'
import type { fetchMarketplaceTemplateDSL } from '@/service/marketplace-templates'
import { zAppPagination, zImport } from '@dify/contracts/api/console/apps/zod.gen'
import { zGetFeaturesResponse } from '@dify/contracts/api/console/features/zod.gen'
import { cleanup, screen, waitFor, within } from '@testing-library/react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import { consoleQuery } from '@/service/console'
import {
createConsoleQueryClient,
renderWithConsoleQuery as render,
} from '@/test/console/query-data'
import { marketplaceQuery } from '@/service/marketplace'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createTrialAppDetailFixture } from '@/test/fixtures/app'
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
import { Apps } from '../index'
const { request, toast, checkDependencies, redirect, trackCreateApp, push, replace, search } =
vi.hoisted(() => ({
request: vi.fn(),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
checkDependencies: vi.fn(),
redirect: vi.fn(),
trackCreateApp: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
search: { params: new URLSearchParams() },
}))
vi.mock('@/service/base', () => ({ request }))
vi.mock('@/app/notifications', () => ({ toast }))
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({ handleCheckPluginDependencies: checkDependencies }),
const api = vi.hoisted(() => ({
detail: vi.fn<typeof consoleClient.explore.apps.byAppId.get>(),
trial: vi.fn<typeof consoleClient.trialApps.byAppId.get>(),
import: vi.fn<typeof consoleClient.apps.imports.post>(),
confirm: vi.fn<typeof consoleClient.apps.imports.byImportId.confirm.post>(),
dependencies: vi.fn<typeof consoleClient.apps.imports.byAppId.checkDependencies.get>(),
dsl: vi.fn<typeof fetchMarketplaceTemplateDSL>(),
track: vi.fn<typeof trackEvent>(),
push: vi.fn(),
replace: vi.fn(),
params: new URLSearchParams(),
}))
vi.mock('@/utils/app-redirection', () => ({ getRedirection: redirect }))
vi.mock('@/utils/create-app-tracking', () => ({ trackCreateApp }))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push, replace }),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useRouter: () => ({ push: api.push, replace: api.replace }),
useParams: () => ({}),
useSearchParams: () => search.params,
usePathname: () => '/apps',
useSearchParams: () => api.params,
}))
vi.mock('next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComponent = React.lazy(loader)
return function Dynamic(props: Record<string, unknown>) {
return (
<React.Suspense fallback={null}>
<LazyComponent {...props} />
</React.Suspense>
)
}
},
vi.mock('@/app/components/base/amplitude/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/components/base/amplitude/utils')>()),
trackEvent: api.track,
}))
vi.mock('@/app/education/expire-notice', () => ({ EducationExpireNotice: () => null }))
vi.mock('../list', async () => {
const { default: LearnDify } = await import('@/app/components/explore/learn-dify')
vi.mock('@/service/marketplace-templates', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/marketplace-templates')>()),
fetchMarketplaceTemplateDSL: api.dsl,
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses, withConsoleClientResponses } =
await import('@/test/console/service-responses')
return {
List: ({
onCreateLearnDify,
onTryLearnDify,
}: {
onCreateLearnDify: (app: RecommendedAppResponse) => void
onTryLearnDify: (app: RecommendedAppResponse) => void
}) => (
<LearnDify
canCreate
onCreate={onCreateLearnDify}
onTry={onTryLearnDify}
dismissible={false}
/>
),
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'apps.get': async () =>
zAppPagination.parse({ data: [], page: 1, limit: 30, total: 0, has_more: false }),
'apps.starred.get': async () =>
zAppPagination.parse({ data: [], page: 1, limit: 100, total: 0, has_more: false }),
'trialApps.byAppId.get': api.trial,
'features.get': async () => zGetFeaturesResponse.parse({ apps: { size: 1, limit: 10 } }),
'tags.get': async () => [],
'workspaces.current.members.get': async () => ({ accounts: [] }),
'workspaces.current.toolProviders.get': async () => [],
'apps.imports.post': api.import,
'apps.imports.byImportId.confirm.post': api.confirm,
'apps.imports.byAppId.checkDependencies.get': api.dependencies,
}),
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'explore.apps.byAppId.get': api.detail,
}),
}
})
// The running preview and marketplace download are separate feature boundaries.
vi.mock('../../explore/try-app', () => ({
default: ({ onCreate, onClose }: { onCreate: () => void; onClose: () => void }) => (
<section aria-label="Template preview">
<button onClick={onCreate}>Create preview</button>
<button onClick={onClose}>Close preview</button>
</section>
),
}))
vi.mock('../import-from-marketplace-template-modal', () => ({
default: ({
templateId,
onClose,
onConfirm,
}: {
templateId: string
onClose: () => void
onConfirm: (dsl: string) => void
}) => (
<section aria-label="Marketplace download">
<span>{templateId}</span>
<button onClick={onClose}>Close download</button>
<button onClick={() => onConfirm('marketplace-dsl')}>Import download</button>
</section>
),
}))
const template: RecommendedAppResponse = {
const template = {
app_id: 'canonical-template',
app: {
id: 'different-nested-id',
@@ -110,55 +84,59 @@ const template: RecommendedAppResponse = {
},
description: 'Catalog description',
can_trial: true,
}
const detail: RecommendedAppDetailResponse = {
} satisfies RecommendedAppResponse
const detail = {
id: 'canonical-template',
name: 'Sample App',
mode: 'chat',
can_trial: true,
export_data: 'fresh-dsl',
}
const completed: Import = {
} satisfies Awaited<ReturnType<typeof api.detail>>
const completed = zImport.parse({
id: 'import-1',
status: 'completed',
app_id: 'created-app',
app_mode: 'chat',
permission_keys: ['app.edit'],
}
let importResponse: Import
let detailFails: boolean
const clients = new Set<QueryClient>()
const features = zGetFeaturesResponse.parse({ apps: { size: 1, limit: 10 } })
permission_keys: ['app.acl.edit'],
})
const marketplaceTemplate = {
data: {
id: 'tpl-42',
template_name: 'Marketplace template',
overview: 'A useful template',
icon: '🤖',
icon_background: '#fff',
icon_file_key: '',
publisher_unique_handle: 'langgenius',
usage_count: 12,
categories: [],
},
} satisfies Awaited<ReturnType<typeof marketplaceClient.templateDetail>>
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
search.params = new URLSearchParams()
importResponse = completed
detailFails = false
request.mockImplementation(async (url: string) => {
const path = new URL(url).pathname.replace('/console/api', '') + new URL(url).search
if (path === '/explore/apps/canonical-template')
return detailFails
? Response.json({ message: 'Unavailable' }, { status: 503 })
: Response.json(detail)
if (path === '/features') return Response.json(features)
if (path === '/apps/imports') return Response.json(importResponse)
if (path === '/apps/imports/import-1/confirm') return Response.json(completed)
throw new Error(`Unexpected request: ${url}`)
})
api.params = new URLSearchParams()
api.push.mockReset()
api.replace.mockReset()
api.track.mockReset()
api.dsl.mockReset().mockResolvedValue('marketplace-dsl')
api.detail.mockReset().mockResolvedValue(detail)
api.trial
.mockReset()
.mockResolvedValue(createTrialAppDetailFixture({ id: template.app_id, name: 'Sample App' }))
api.import.mockReset().mockResolvedValue(completed)
api.confirm.mockReset().mockResolvedValue(completed)
api.dependencies.mockReset().mockResolvedValue({ leaked_dependencies: [] })
sessionStorage.clear()
})
afterEach(() => act(() => toast.dismiss()))
afterEach(async () => {
cleanup()
await Promise.all([...clients].map((client) => client.cancelQueries()))
for (const client of clients) client.clear()
clients.clear()
})
const setup = ({ cloud = false, permission = true, item = template } = {}) => {
async function setup({
cloud = false,
permission = true,
item = template,
}: { cloud?: boolean; permission?: boolean; item?: RecommendedAppResponse } = {}) {
const queryClient = createConsoleQueryClient()
clients.add(queryClient)
seedModelProviderQueries(queryClient)
queryClient.setQueryData(
consoleQuery.explore.apps.learnDify.get.queryKey({ input: { query: { language: 'en-US' } } }),
{ recommended_apps: [item] },
@@ -169,160 +147,186 @@ const setup = ({ cloud = false, permission = true, item = template } = {}) => {
}),
{ ...detail, export_data: 'stale-dsl' },
)
return render(<Apps />, {
queryClient,
workspacePermissionKeys: permission ? ['app.create_and_management'] : [],
systemFeatures: { deployment_edition: cloud ? 'CLOUD' : 'COMMUNITY', enable_learn_app: true },
features: { apps: { size: 0, limit: 10 } },
})
queryClient.setQueryData(
marketplaceQuery.templateDetail.queryKey({ input: { params: { templateId: 'tpl-42' } } }),
marketplaceTemplate,
)
await act(async () =>
renderWithConsoleQuery(
<NuqsTestingAdapter hasMemory searchParams={api.params}>
<Apps />
<AppToastHost timeout={0} />
</NuqsTestingAdapter>,
{
queryClient,
workspacePermissionKeys: permission ? ['app.create_and_management'] : [],
systemFeatures: {
deployment_edition: cloud ? 'CLOUD' : 'COMMUNITY',
enable_learn_app: true,
rbac_enabled: true,
enable_collaboration_mode: false,
},
features: { apps: { size: 0, limit: 10 } },
},
),
)
return { user: userEvent.setup() }
}
const openCreate = async (user: ReturnType<typeof userEvent.setup>, preview = false) => {
await user.click(screen.getByRole('button', { name: 'Sample App' }))
if (preview) await user.click(await screen.findByRole('button', { name: 'Create preview' }))
return screen.findByRole('dialog')
await user.click(await screen.findByRole('button', { name: 'Sample App' }))
if (preview) {
const previewDialog = await screen.findByRole(
'dialog',
{ name: 'Sample App' },
{ timeout: 5000 },
)
await user.click(
await within(previewDialog).findByRole('button', {
name: 'explore.tryApp.createFromSampleApp',
}),
)
}
return screen.findByRole('dialog', { name: /^explore.appCustomize.title/ })
}
const submit = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
const form = screen.getByRole('dialog', { name: /^explore.appCustomize.title/ })
await user.click(within(form).getByRole('button', { name: /^common.operation.create/ }))
}
it('imports fresh canonical detail through the real form and import orchestration', async () => {
const user = userEvent.setup()
setup()
await openCreate(user)
expect(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder')).toHaveValue('')
await submit(user)
await waitFor(() => expect(redirect).toHaveBeenCalled())
expect(
request.mock.calls
.map(([url]) => new URL(url).pathname.replace('/console/api', ''))
.filter((path) => path !== '/features'),
).toEqual(['/explore/apps/canonical-template', '/apps/imports'])
const importRequest = request.mock.calls.find(([url]) =>
new URL(url).pathname.endsWith('/apps/imports'),
const expectCreateEvent = (source: string, templateId = template.app_id) =>
expect(api.track).toHaveBeenCalledWith(
'create_app',
expect.objectContaining({ source, template_id: templateId, app_mode: 'chatflow' }),
)
expect(await importRequest?.[2].request.json()).toMatchObject({
yaml_content: 'fresh-dsl',
name: 'Sample App',
description: '',
const expectNoCreateEvent = () =>
expect(api.track).not.toHaveBeenCalledWith('create_app', expect.anything())
it('imports fresh canonical detail through the real list and form, then checks dependencies and navigates', async () => {
const { user } = await setup()
const dialog = await openCreate(user)
expect(within(dialog).getByPlaceholderText('app.newApp.appDescriptionPlaceholder')).toHaveValue(
'',
)
await submit(user)
await waitFor(() => expect(api.push).toHaveBeenCalledWith('/app/created-app/configuration'))
expect(api.detail).toHaveBeenCalledExactlyOnceWith({ params: { app_id: 'canonical-template' } })
expect(api.import).toHaveBeenCalledWith({
body: expect.objectContaining({
yaml_content: 'fresh-dsl',
name: 'Sample App',
description: '',
}),
})
expect(checkDependencies).toHaveBeenCalledWith('created-app')
expect(toast.success).toHaveBeenCalledWith('app.newApp.appCreated')
expect(api.dependencies).toHaveBeenCalledWith({ params: { app_id: 'created-app' } })
expect(await screen.findByText('app.newApp.appCreated')).toBeInTheDocument()
})
it('tracks successful preview creation and closes the preview', async () => {
const user = userEvent.setup()
setup({ cloud: true })
it('tracks creation through the real preview and closes it after submission', async () => {
const { user } = await setup({ cloud: true })
await openCreate(user, true)
await submit(user)
await waitFor(() =>
expect(trackCreateApp).toHaveBeenCalledWith({
source: 'studio_template_preview',
templateId: 'canonical-template',
appMode: 'chat',
}),
)
expect(screen.queryByRole('region', { name: 'Template preview' })).not.toBeInTheDocument()
await waitFor(() => expectCreateEvent('studio_template_preview'))
expect(screen.queryByRole('dialog', { name: 'Sample App' })).not.toBeInTheDocument()
expect(api.push).toHaveBeenCalledWith('/app/created-app/configuration')
})
it('handles detail rejection once without import, navigation, or reopening the submitted modal', async () => {
const user = userEvent.setup()
detailFails = true
setup()
it('shows a detail error once without importing, navigating, or reopening the submitted form', async () => {
api.detail.mockRejectedValue(new Error('Unavailable'))
const { user } = await setup()
await openCreate(user)
await submit(user)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed'))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
const paths = request.mock.calls.map(([url]) => new URL(url).pathname.replace('/console/api', ''))
expect(paths.filter((path) => path === '/explore/apps/canonical-template')).toHaveLength(1)
expect(paths.some((path) => path.startsWith('/apps/imports'))).toBe(false)
expect(toast.error).toHaveBeenCalledOnce()
expect(redirect).not.toHaveBeenCalled()
expect(trackCreateApp).not.toHaveBeenCalled()
expect(await screen.findAllByText('app.newApp.appCreateFailed')).toHaveLength(1)
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: /^explore.appCustomize.title/ }),
).not.toBeInTheDocument(),
)
expect(api.detail).toHaveBeenCalledOnce()
expect(api.import).not.toHaveBeenCalled()
expect(api.push).not.toHaveBeenCalled()
expectNoCreateEvent()
})
it('keeps the pending import confirmation and tracks only after successful confirmation', async () => {
const user = userEvent.setup()
importResponse = {
id: 'import-1',
status: 'pending',
imported_dsl_version: '0.8.0',
current_dsl_version: '0.6.0',
}
setup({ cloud: true })
it('waits for version confirmation before importing and tracking preview creation', async () => {
api.import.mockResolvedValue(
zImport.parse({
id: 'import-1',
status: 'pending',
imported_dsl_version: '0.8.0',
current_dsl_version: '0.6.0',
}),
)
const { user } = await setup({ cloud: true })
await openCreate(user, true)
await submit(user)
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText('0.8.0')).toBeInTheDocument()
expect(trackCreateApp).not.toHaveBeenCalled()
expectNoCreateEvent()
expect(api.push).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => expect(redirect).toHaveBeenCalled())
expect(
request.mock.calls
.map(([url]) => new URL(url).pathname.replace('/console/api', ''))
.filter((path) => path !== '/features'),
).toEqual(['/explore/apps/canonical-template', '/apps/imports', '/apps/imports/import-1/confirm'])
expect(trackCreateApp).toHaveBeenCalledWith({
source: 'studio_template_preview',
templateId: 'canonical-template',
appMode: 'chat',
})
await waitFor(() => expect(api.push).toHaveBeenCalledWith('/app/created-app/configuration'))
expect(api.confirm).toHaveBeenCalledExactlyOnceWith({ params: { import_id: 'import-1' } })
expectCreateEvent('studio_template_preview')
})
it.each([undefined, 'Missing app data in YAML content'])(
'reports import failure (%s) once without tracking or navigating',
'shows import failure (%s) without tracking or navigating',
async (error) => {
const user = userEvent.setup()
importResponse = { id: 'import-1', status: 'failed', error }
setup({ cloud: true })
api.import.mockResolvedValue(zImport.parse({ id: 'import-1', status: 'failed', error }))
const { user } = await setup({ cloud: true })
await openCreate(user, true)
await submit(user)
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed', {
description: error,
}),
)
expect(toast.error).toHaveBeenCalledOnce()
expect(redirect).not.toHaveBeenCalled()
expect(trackCreateApp).not.toHaveBeenCalled()
expect(await screen.findAllByText('app.newApp.appCreateFailed')).toHaveLength(1)
if (error) expect(screen.getByText(error)).toBeInTheDocument()
expect(api.push).not.toHaveBeenCalled()
expectNoCreateEvent()
},
)
it('keeps nullable template metadata editable through the actual modal', async () => {
const user = userEvent.setup()
setup({ item: { app_id: 'null-template', app: null, can_trial: false } })
await user.click(screen.getByRole('button', { name: '' }))
await screen.findByRole('dialog')
expect(screen.getByPlaceholderText('app.newApp.appNamePlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder')).toHaveValue('')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(request).not.toHaveBeenCalled()
})
it('does not allow template creation or marketplace import without permission', async () => {
const user = userEvent.setup()
search.params = new URLSearchParams('template-id=tpl-42')
setup({ permission: false })
await user.click(screen.getByRole('button', { name: 'Sample App' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Marketplace download' })).not.toBeInTheDocument()
expect(request).not.toHaveBeenCalled()
})
it('preserves marketplace import tracking and removes only its URL parameter', async () => {
const user = userEvent.setup()
search.params = new URLSearchParams('template-id=tpl-42&category=chat')
setup()
await user.click(await screen.findByRole('button', { name: 'Import download' }))
await waitFor(() =>
expect(trackCreateApp).toHaveBeenCalledWith({
source: 'external',
templateId: 'tpl-42',
appMode: 'chat',
}),
it('keeps nullable template metadata editable in the real creation form', async () => {
const { user } = await setup({ item: { app_id: 'null-template', app: null, can_trial: false } })
await user.click(await screen.findByRole('button', { name: '' }))
const dialog = await screen.findByRole('dialog', { name: /^explore.appCustomize.title/ })
expect(within(dialog).getByPlaceholderText('app.newApp.appNamePlaceholder')).toHaveValue('')
expect(within(dialog).getByPlaceholderText('app.newApp.appDescriptionPlaceholder')).toHaveValue(
'',
)
expect(await request.mock.calls[0]![2].request.json()).toEqual({
mode: 'yaml-content',
yaml_content: 'marketplace-dsl',
await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
expect(api.detail).not.toHaveBeenCalled()
expect(api.import).not.toHaveBeenCalled()
})
it('hides template creation and marketplace import without creation permission', async () => {
api.params = new URLSearchParams('template-id=tpl-42')
await setup({ permission: false })
await waitFor(() =>
expect(screen.getByRole('status', { name: '' })).toHaveTextContent('app.filterEmpty.noApps'),
)
expect(screen.queryByRole('button', { name: 'Sample App' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(api.dsl).not.toHaveBeenCalled()
expect(api.import).not.toHaveBeenCalled()
})
it('imports a real marketplace download, retains attribution, and removes only its URL parameter', async () => {
api.params = new URLSearchParams('template-id=tpl-42&category=chat')
rememberCreateAppExternalAttribution({
searchParams: new URLSearchParams('utm_source=newsletter'),
})
expect(replace).toHaveBeenCalledWith('?category=chat', { scroll: false })
const { user } = await setup()
const dialog = await screen.findByRole('dialog', { name: 'app.marketplace.template.modalTitle' })
expect(within(dialog).getByText('Marketplace template')).toBeInTheDocument()
await user.click(
within(dialog).getByRole('button', { name: 'app.marketplace.template.importConfirm' }),
)
await waitFor(() => expect(api.push).toHaveBeenCalledWith('/app/created-app/configuration'))
expect(api.dsl).toHaveBeenCalledExactlyOnceWith('tpl-42')
expect(api.import).toHaveBeenCalledWith({
body: { mode: 'yaml-content', yaml_content: 'marketplace-dsl' },
})
expectCreateEvent('external', 'tpl-42')
expect(api.track).toHaveBeenCalledWith(
'create_app',
expect.objectContaining({ utm_source: 'newsletter' }),
)
expect(api.replace).toHaveBeenCalledWith('?category=chat', { scroll: false })
})
File diff suppressed because it is too large. Load diff
@@ -113,34 +113,38 @@ describe('AgentLogModal', () => {
})
it('should call onCancel when close button is clicked', async () => {
const user = userEvent.setup()
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
render(<AgentLogModal appId="app-id" {...mockProps} />)
const closeBtn = screen.getByRole('button', { name: 'common.operation.close' })
await userEvent.setup().click(closeBtn)
await user.click(closeBtn)
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when clicking outside the panel', async () => {
const user = userEvent.setup()
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
render(<AgentLogModal appId="app-id" {...mockProps} />)
await userEvent.setup().click(document.body)
await user.click(document.body)
expect(mockProps.onCancel).toHaveBeenCalledOnce()
})
it('does not close when clicking inside the panel', async () => {
const user = userEvent.setup()
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
render(<AgentLogModal appId="app-id" {...mockProps} />)
await userEvent.setup().click(screen.getByText('appLog.runDetail.workflowTitle'))
await user.click(screen.getByText('appLog.runDetail.workflowTitle'))
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
it('keeps the floating dialog open when clicking its content', async () => {
const user = userEvent.setup()
fetchAgentLogDetail.mockReturnValue(new Promise(() => {}))
render(<AgentLogModal appId="app-id" {...mockProps} floating />)
await userEvent.setup().click(screen.getByRole('dialog'))
await user.click(screen.getByRole('dialog'))
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
@@ -178,9 +182,10 @@ describe('AgentLogModal', () => {
expect(screen.queryByText('Old executor')).not.toBeInTheDocument()
})
it('preserves the selected tab when another message replaces the open record', async () => {
const user = userEvent.setup()
const { rerender } = render(<AgentLogModal appId="app-id" {...mockProps} />)
await screen.findByText('User')
await userEvent.setup().click(screen.getByRole('button', { name: 'runLog.tracing' }))
await user.click(screen.getByRole('button', { name: 'runLog.tracing' }))
rerender(
<AgentLogModal
appId="app-id"
@@ -30,8 +30,12 @@ describe('AudioBtn', () => {
vi.mocked(usePathname).mockReturnValue(value)
}
const hoverAndCheckTooltip = async (expectedText: string) => {
await userEvent.hover(getButton())
const hoverAndCheckTooltip = async (
user: ReturnType<typeof userEvent.setup>,
expectedText: string,
) => {
await user.unhover(getButton())
await user.hover(getButton())
expect(await screen.findByText(expectedText))!.toBeInTheDocument()
}
@@ -62,21 +66,23 @@ describe('AudioBtn', () => {
// Core rendering and base UI integration.
describe('Rendering', () => {
it('should render button with play tooltip by default', async () => {
const user = userEvent.setup()
render(<AudioBtn value="hello" />)
expect(getButton())!.toBeInTheDocument()
expect(getButton()).not.toBeDisabled()
await hoverAndCheckTooltip('play')
await hoverAndCheckTooltip(user, 'play')
})
})
// URL path resolution for app, agent, and public audio endpoints.
describe('URL routing', () => {
it('should call public text-to-audio endpoint when token exists', async () => {
const user = userEvent.setup()
mockUseParams({ token: 'public-token' })
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
@@ -85,11 +91,12 @@ describe('AudioBtn', () => {
})
it('should call app endpoint when appId exists', async () => {
const user = userEvent.setup()
mockUseParams({ appId: '123' })
mockUsePathname('/apps/123/chat')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
@@ -98,11 +105,12 @@ describe('AudioBtn', () => {
})
it('should call installed app endpoint for installed app routes', async () => {
const user = userEvent.setup()
mockUseParams({ appId: '456' })
mockUsePathname('/installed/456')
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
@@ -133,29 +141,31 @@ describe('AudioBtn', () => {
// User-visible playback state transitions.
describe('Playback interactions', () => {
it('should start loading and call playAudio when button is clicked', async () => {
const user = userEvent.setup()
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => {
expect(mockPlayAudio).toHaveBeenCalledTimes(1)
expect(getButton())!.toBeDisabled()
})
expect(getButton()).toHaveAccessibleName('loading')
await hoverAndCheckTooltip('loading')
await hoverAndCheckTooltip(user, 'loading')
})
it('should pause audio when clicked while playing', async () => {
const user = userEvent.setup()
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await act(() => {
getLatestAudioCallback()('play')
})
await hoverAndCheckTooltip('playing')
await hoverAndCheckTooltip(user, 'playing')
expect(getButton()).not.toBeDisabled()
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => expect(mockPauseAudio).toHaveBeenCalledTimes(1))
})
})
@@ -163,28 +173,30 @@ describe('AudioBtn', () => {
// Audio event callback handling from the player manager.
describe('Audio callback events', () => {
it('should set loading tooltip when loaded event is received', async () => {
const user = userEvent.setup()
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await act(() => {
getLatestAudioCallback()('loaded')
})
await hoverAndCheckTooltip('loading')
await hoverAndCheckTooltip(user, 'loading')
expect(getButton())!.toBeDisabled()
})
it.each(['ended', 'paused', 'error'])(
'should return to play tooltip when %s event is received',
async (event) => {
const user = userEvent.setup()
render(<AudioBtn value="test" />)
await userEvent.click(getButton())
await user.click(getButton())
await act(() => {
getLatestAudioCallback()(event)
})
await hoverAndCheckTooltip('play')
await hoverAndCheckTooltip(user, 'play')
expect(getButton()).not.toBeDisabled()
},
)
@@ -193,8 +205,9 @@ describe('AudioBtn', () => {
// Prop forwarding and minimal-input behavior.
describe('Props and edge cases', () => {
it('should pass id, value, and voice to getAudioPlayer', async () => {
const user = userEvent.setup()
render(<AudioBtn id="msg-1" value="hello" voice="en-US" />)
await userEvent.click(getButton())
await user.click(getButton())
await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled())
const call = mockGetAudioPlayer.mock.calls[0]
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import AudioPlayer from '../AudioPlayer'
vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) }))
const decode = vi.fn()
const close = vi.fn()
const drawBar = vi.fn()
@@ -142,23 +142,25 @@ describe('Carousel', () => {
// Users can move slides through previous and next controls.
describe('User interactions', () => {
it('should call scroll handlers when previous and next buttons are clicked', async () => {
const user = userEvent.setup()
mockCanScrollPrev = true
mockCanScrollNext = true
renderCarouselWithControls()
await userEvent.setup().click(screen.getByRole('button', { name: 'Prev' }))
await userEvent.setup().click(screen.getByRole('button', { name: 'Next' }))
await user.click(screen.getByRole('button', { name: 'Prev' }))
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
})
it('should call scrollTo with clicked index when a dot is clicked', async () => {
const user = userEvent.setup()
renderCarouselWithControls()
const dots = screen.getAllByRole('button', { name: 'Dot' })
await userEvent.setup().click(dots[2]!)
await user.click(dots[2]!)
expect(mockApi.scrollTo).toHaveBeenCalledWith(2)
})
File diff suppressed because it is too large. Load diff
@@ -168,6 +168,7 @@ describe('HeaderInMobile', () => {
})
it('should hide chat settings option when no input forms', async () => {
const user = userEvent.setup()
setContext({
...defaultContextValue,
inputsForms: [],
@@ -175,9 +176,7 @@ describe('HeaderInMobile', () => {
render(<HeaderInMobile />)
await userEvent
.setup()
.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await waitFor(() => {
expect(screen.queryByText(/share\.chat\.viewChatSettings/i)).not.toBeInTheDocument()
@@ -185,6 +184,7 @@ describe('HeaderInMobile', () => {
})
it('should handle new conversation', async () => {
const user = userEvent.setup()
const handleNewConversation = vi.fn()
setContext({
...defaultContextValue,
@@ -193,14 +193,12 @@ describe('HeaderInMobile', () => {
render(<HeaderInMobile />)
await userEvent
.setup()
.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
await waitFor(() => {
expect(screen.getByText(/share\.chat\.resetChat/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/share\.chat\.resetChat/i))
await user.click(screen.getByText(/share\.chat\.resetChat/i))
await waitFor(() => {
expect(handleNewConversation).toHaveBeenCalled()
@@ -208,6 +206,7 @@ describe('HeaderInMobile', () => {
})
it('should handle pin conversation', async () => {
const user = userEvent.setup()
const handlePin = vi.fn()
setContext({
...defaultContextValue,
@@ -219,16 +218,17 @@ describe('HeaderInMobile', () => {
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.pin/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.pin/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.pin/i))
expect(handlePin).toHaveBeenCalledWith('1')
})
it('should handle unpin conversation', async () => {
const user = userEvent.setup()
const handleUnpin = vi.fn()
setContext({
...defaultContextValue,
@@ -240,16 +240,17 @@ describe('HeaderInMobile', () => {
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.unpin/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.unpin/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.unpin/i))
expect(handleUnpin).toHaveBeenCalledWith('1')
})
it('should handle rename conversation', async () => {
const user = userEvent.setup()
const handleRename = vi.fn()
setContext({
...defaultContextValue,
@@ -260,24 +261,25 @@ describe('HeaderInMobile', () => {
})
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.rename/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
const input = screen.getByDisplayValue('Conv 1')
await userEvent.setup().clear(input)
await userEvent.setup().type(input, 'New Name')
await user.clear(input)
await user.type(input, 'New Name')
const saveButton = screen.getByRole('button', { name: /common\.operation\.save/i })
await userEvent.setup().click(saveButton)
await user.click(saveButton)
expect(handleRename).toHaveBeenCalledWith('1', 'New Name', expect.any(Object))
})
it('should cancel rename conversation', async () => {
const user = userEvent.setup()
const handleRename = vi.fn()
setContext({
...defaultContextValue,
@@ -288,17 +290,17 @@ describe('HeaderInMobile', () => {
})
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.rename/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
const cancelButton = screen.getByRole('button', { name: /common\.operation\.cancel/i })
await userEvent.setup().click(cancelButton)
await user.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
@@ -307,6 +309,7 @@ describe('HeaderInMobile', () => {
})
it('should show loading state while renaming', async () => {
const user = userEvent.setup()
setContext({
...defaultContextValue,
currentConversationId: '1',
@@ -317,21 +320,22 @@ describe('HeaderInMobile', () => {
})
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.rename/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
expect(await screen.findByRole('dialog')).toBeInTheDocument()
const save = screen.getByRole('button', { name: /common\.operation\.save/i })
expect(save).toHaveAttribute('aria-disabled', 'true')
await userEvent.setup().click(save)
await user.click(save)
expect(context.handleRenameConversation).not.toHaveBeenCalled()
})
it('should handle delete conversation', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
setContext({
...defaultContextValue,
@@ -342,23 +346,22 @@ describe('HeaderInMobile', () => {
})
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.delete/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.delete/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.delete/i))
await waitFor(() => {
expect(screen.getAllByText(/share\.chat\.deleteConversation\.title/i)[0])!.toBeInTheDocument()
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
expect(handleDelete).toHaveBeenCalledWith('1', expect.any(Object))
})
it('should cancel delete conversation', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
setContext({
...defaultContextValue,
@@ -369,20 +372,18 @@ describe('HeaderInMobile', () => {
})
render(<HeaderInMobile />)
await userEvent.setup().click(await screen.findByText('Conv 1'))
await user.click(await screen.findByText('Conv 1'))
await waitFor(() => {
expect(screen.getByText(/explore\.sidebar\.action\.delete/i))!.toBeInTheDocument()
})
await userEvent.setup().click(screen.getByText(/explore\.sidebar\.action\.delete/i))
await user.click(screen.getByText(/explore\.sidebar\.action\.delete/i))
await waitFor(() => {
expect(screen.getAllByText(/share\.chat\.deleteConversation\.title/i)[0])!.toBeInTheDocument()
})
await userEvent
.setup()
.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
await waitFor(() => {
expect(screen.queryByText(/share\.chat\.deleteConversation\.title/i)).not.toBeInTheDocument()
@@ -391,6 +392,7 @@ describe('HeaderInMobile', () => {
})
it('should use empty string fallback for rename modal name', async () => {
const user = userEvent.setup()
const handleRename = vi.fn()
setContext({
...defaultContextValue,
@@ -406,16 +408,16 @@ describe('HeaderInMobile', () => {
.find(
(button) => button.getAttribute('aria-haspopup') === 'menu' && button.textContent === '',
)!
await userEvent.setup().click(operationTrigger)
await userEvent
.setup()
.click(await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i))
await user.click(operationTrigger)
await user.click(
await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i),
)
const input = await screen.findByRole('textbox')
expect(input)!.toHaveValue('')
await userEvent.setup().type(input, 'Renamed from empty')
await userEvent.setup().click(screen.getByRole('button', { name: /common\.operation\.save/i }))
await user.type(input, 'Renamed from empty')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
expect(handleRename).toHaveBeenCalledWith('1', 'Renamed from empty', expect.any(Object))
})
})
File diff suppressed because it is too large. Load diff
@@ -24,7 +24,6 @@ import {
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { getAvatarInitial } from '@/utils/avatar'
import { formatBooleanInputs } from '@/utils/model-config'
import Chat from '../chat'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
@@ -209,10 +208,7 @@ const ChatWrapper = () => {
const data: any = {
query: message,
files,
inputs: formatBooleanInputs(
inputsForms,
currentConversationId ? currentConversationInputs : newConversationInputs,
),
inputs: currentConversationId ? currentConversationInputs : newConversationInputs,
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
@@ -231,7 +227,6 @@ const ChatWrapper = () => {
trackWebAppEvent('webapp_run', { app_mode: appMode })
},
[
inputsForms,
currentConversationId,
currentConversationInputs,
newConversationInputs,
@@ -88,26 +88,29 @@ describe('Header Component', () => {
describe('Interactions', () => {
it('should handle new conversation', async () => {
const user = userEvent.setup()
const handleNewConversation = vi.fn()
setup({ handleNewConversation, sidebarCollapseState: true, currentConversationId: 'conv-1' })
const resetChatBtn = screen.getByRole('button', { name: 'share.chat.resetChat' })
await userEvent.setup().click(resetChatBtn)
await user.click(resetChatBtn)
expect(handleNewConversation).toHaveBeenCalled()
})
it('should handle sidebar toggle', async () => {
const user = userEvent.setup()
const handleSidebarCollapse = vi.fn()
setup({ handleSidebarCollapse, sidebarCollapseState: true })
const sidebarBtn = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
await userEvent.setup().click(sidebarBtn)
await user.click(sidebarBtn)
expect(handleSidebarCollapse).toHaveBeenCalledWith(false)
})
it('should render operation menu and handle pin', async () => {
const user = userEvent.setup()
const handlePinConversation = vi.fn()
const mockConv = conversation()
setup({
@@ -118,17 +121,18 @@ describe('Header Component', () => {
})
const trigger = screen.getByText('My Chat')
await userEvent.setup().click(trigger)
await user.click(trigger)
const pinBtn = await screen.findByText('explore.sidebar.action.pin')
expect(pinBtn)!.toBeInTheDocument()
await userEvent.setup().click(pinBtn)
await user.click(pinBtn)
expect(handlePinConversation).toHaveBeenCalledWith('conv-1')
})
it('should handle unpin', async () => {
const user = userEvent.setup()
const handleUnpinConversation = vi.fn()
const mockConv = conversation()
setup({
@@ -139,15 +143,16 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
await userEvent.setup().click(screen.getByText('My Chat'))
await user.click(screen.getByText('My Chat'))
const unpinBtn = await screen.findByText('explore.sidebar.action.unpin')
await userEvent.setup().click(unpinBtn)
await user.click(unpinBtn)
expect(handleUnpinConversation).toHaveBeenCalledWith('conv-1')
})
it('should handle rename cancellation', async () => {
const user = userEvent.setup()
const mockConv = conversation()
setup({
currentConversationId: 'conv-1',
@@ -155,13 +160,13 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
await userEvent.setup().click(screen.getByText('My Chat'))
await user.click(screen.getByText('My Chat'))
const renameMenuBtn = await screen.findByText('explore.sidebar.action.rename')
await userEvent.setup().click(renameMenuBtn)
await user.click(renameMenuBtn)
const cancelBtn = await screen.findByText('common.operation.cancel')
await userEvent.setup().click(cancelBtn)
await user.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument()
@@ -170,6 +175,7 @@ describe('Header Component', () => {
})
it('should handle rename success flow', async () => {
const user = userEvent.setup()
const handleRenameConversation = vi.fn()
const mockConv = conversation()
setup({
@@ -179,19 +185,19 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
await userEvent.setup().click(screen.getByText('My Chat'))
await user.click(screen.getByText('My Chat'))
const renameMenuBtn = await screen.findByText('explore.sidebar.action.rename')
await userEvent.setup().click(renameMenuBtn)
await user.click(renameMenuBtn)
expect(await screen.findByText('common.chat.renameConversation'))!.toBeInTheDocument()
const input = screen.getByDisplayValue('My Chat')
await userEvent.setup().clear(input)
await userEvent.setup().type(input, 'New Name')
await user.clear(input)
await user.type(input, 'New Name')
const saveBtn = await screen.findByText('common.operation.save')
await userEvent.setup().click(saveBtn)
await user.click(saveBtn)
expect(handleRenameConversation).toHaveBeenCalledWith(
'conv-1',
@@ -210,6 +216,7 @@ describe('Header Component', () => {
})
it('should handle delete flow', async () => {
const user = userEvent.setup()
const handleDeleteConversation = vi.fn()
const mockConv = conversation()
setup({
@@ -219,16 +226,16 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
await userEvent.setup().click(screen.getByText('My Chat'))
await user.click(screen.getByText('My Chat'))
const deleteMenuBtn = await screen.findByText('explore.sidebar.action.delete')
await userEvent.setup().click(deleteMenuBtn)
await user.click(deleteMenuBtn)
expect(handleDeleteConversation).not.toHaveBeenCalled()
expect(await screen.findByText('share.chat.deleteConversation.title'))!.toBeInTheDocument()
const confirmBtn = await screen.findByText('common.operation.confirm')
await userEvent.setup().click(confirmBtn)
await user.click(confirmBtn)
expect(handleDeleteConversation).toHaveBeenCalledWith('conv-1', expect.any(Object))
@@ -243,6 +250,7 @@ describe('Header Component', () => {
})
it('should handle delete cancellation', async () => {
const user = userEvent.setup()
const mockConv = conversation()
setup({
currentConversationId: 'conv-1',
@@ -250,13 +258,13 @@ describe('Header Component', () => {
sidebarCollapseState: true,
})
await userEvent.setup().click(screen.getByText('My Chat'))
await user.click(screen.getByText('My Chat'))
const deleteMenuBtn = await screen.findByText('explore.sidebar.action.delete')
await userEvent.setup().click(deleteMenuBtn)
await user.click(deleteMenuBtn)
const cancelBtn = await screen.findByText('common.operation.cancel')
await userEvent.setup().click(cancelBtn)
await user.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByText('share.chat.deleteConversation.title')).not.toBeInTheDocument()
@@ -98,10 +98,11 @@ describe('Conversation sidebar item', () => {
})
it('omits operations for an unsaved conversation but still allows selecting it', async () => {
const user = userEvent.setup()
const props = createProps()
render(<Item {...props} item={{ ...item, id: '' }} />)
expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument()
await userEvent.setup().click(screen.getByRole('button', { name: item.name }))
await user.click(screen.getByRole('button', { name: item.name }))
expect(props.onChangeConversation).toHaveBeenCalledExactlyOnceWith('')
})
@@ -1,15 +1,16 @@
import type { ChatConfig, ChatItemInTree } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { act, renderHook } from '@testing-library/react'
import { act } from '@testing-library/react'
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useParams, usePathname } from '@/next/navigation'
import { sseGet, ssePost } from '@/service/base'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import {
createHumanInputFormFixture,
createWorkflowProcessFixture,
} from '@/test/fixtures/workflow-process'
import { useChat } from '../hooks'
const useTimestampMock = vi.hoisted(() =>
vi.fn(() => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') })),
)
vi.mock('@/service/base', () => ({
sseGet: vi.fn(),
ssePost: vi.fn(),
@@ -34,10 +35,6 @@ vi.mock('@/app/notifications', () => ({
},
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: useTimestampMock,
}))
vi.mock('@/next/navigation', () => ({
useParams: vi.fn(() => ({})),
usePathname: vi.fn(() => ''),
@@ -49,6 +46,25 @@ const createAbortControllerMock = () => {
vi.spyOn(controller, 'abort')
return controller
}
const createPausedChatTree = (): ChatItemInTree[] => [
{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [
{
id: 'm-1',
content: '',
isAnswer: true,
workflow_run_id: 'wr-1',
humanInputFormDataList: [createHumanInputFormFixture({ node_id: 'human-1' })],
workflowProcess: createWorkflowProcessFixture({ status: WorkflowRunningStatus.Paused }),
siblingIndex: 0,
},
],
},
]
type HookCallbacks = {
getAbortController: (abortController: AbortController) => void
onCompleted: (hasError?: boolean, errorMessage?: string) => Promise<void> | void
@@ -94,22 +110,12 @@ describe('useChat', () => {
})
it('should initialize correctly with empty config', () => {
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
expect(result.current.chatList).toEqual([])
expect(result.current.isResponding).toBe(false)
expect(result.current.suggestedQuestions).toEqual([])
})
it('should pass timestamp options to timestamp formatter', () => {
renderHook(() =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, {
timezone: 'UTC',
}),
)
expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' })
})
it('should initialize with opening statement and suggested questions', () => {
const config = {
opening_statement: 'Hello {{name}}',
@@ -119,7 +125,7 @@ describe('useChat', () => {
inputs: { name: 'Alice' },
inputsForm: [],
}
const { result } = renderHook(() => useChat(config as ChatConfig, formSettings))
const { result } = renderHookWithConsoleQuery(() => useChat(config as ChatConfig, formSettings))
expect(result.current.chatList).toHaveLength(1)
expect(result.current.chatList[0]!.content).toBe('Hello Alice')
@@ -145,14 +151,16 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(config as ChatConfig, formSettings, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(config as ChatConfig, formSettings, prevChatTree),
)
expect(result.current.chatList[0]!.content).toBe('Hello Bob')
expect(result.current.chatList[0]!.suggestedQuestions).toEqual(['Ask Bob'])
})
describe('opening statement referential stability', () => {
it('should keep the same item reference across multiple streaming chatTree mutations', () => {
describe('opening statement updates', () => {
it('preserves the opening statement and questions while streaming an answer', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
@@ -163,11 +171,7 @@ describe('useChat', () => {
opening_statement: 'Welcome!',
suggested_questions: ['Q1', 'Q2'],
}
const { result } = renderHook(() => useChat(config as ChatConfig))
const openerInitial = result.current.chatList[0]
expect(openerInitial!.isOpeningStatement).toBe(true)
expect(openerInitial!.content).toBe('Welcome!')
const { result } = renderHookWithConsoleQuery(() => useChat(config as ChatConfig))
act(() => {
result.current.handleSend('url', { query: 'hello' }, {})
@@ -176,7 +180,6 @@ describe('useChat', () => {
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
})
expect(result.current.chatList[0]).toBe(openerInitial)
act(() => {
callbacks.onData('chunk-1 ', true, {
@@ -185,13 +188,10 @@ describe('useChat', () => {
taskId: 't-1',
})
})
expect(result.current.chatList.length).toBeGreaterThan(1)
expect(result.current.chatList[0]).toBe(openerInitial)
act(() => {
callbacks.onData('chunk-2 ', false, { messageId: 'm-1' })
})
expect(result.current.chatList[0]).toBe(openerInitial)
act(() => {
callbacks.onData('chunk-3', false, { messageId: 'm-1' })
@@ -199,26 +199,31 @@ describe('useChat', () => {
callbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
callbacks.onCompleted()
})
expect(result.current.chatList[0]).toBe(openerInitial)
expect(result.current.chatList.filter((item) => item.isOpeningStatement)).toEqual([
expect.objectContaining({
content: 'Welcome!',
suggestedQuestions: ['Q1', 'Q2'],
}),
])
expect(result.current.chatList.at(-1)!.content).toBe('chunk-1 chunk-2 chunk-3')
})
it('should produce a new item when the processed content actually changes', () => {
it('updates the opening statement and questions when user inputs change', () => {
const config = {
opening_statement: 'Hello {{name}}',
suggested_questions: ['Ask {{name}}'],
}
const { result, rerender } = renderHook(({ fs }) => useChat(config as ChatConfig, fs), {
initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } },
})
const before = result.current.chatList[0]
const { result, rerender } = renderHookWithConsoleQuery(
({ fs }) => useChat(config as ChatConfig, fs),
{
initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } },
},
)
rerender({ fs: { inputs: { name: 'Bob' }, inputsForm: [] } })
const after = result.current.chatList[0]
expect(after).not.toBe(before)
expect(after!.content).toBe('Hello Bob')
expect(after!.suggestedQuestions).toEqual(['Ask Bob'])
})
@@ -232,7 +237,7 @@ describe('useChat', () => {
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, {
isNewAgent: true,
}),
@@ -266,7 +271,7 @@ describe('useChat', () => {
})
it('should send with the latest externally selected conversation', () => {
const { result, rerender } = renderHook(
const { result, rerender } = renderHookWithConsoleQuery(
({ conversationId }) =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, conversationId),
{
@@ -296,7 +301,7 @@ describe('useChat', () => {
})
it('should not reuse a previous conversation when the selected session has no id', () => {
const { result, rerender } = renderHook(
const { result, rerender } = renderHookWithConsoleQuery(
({ conversationId }: { conversationId?: string }) =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, conversationId),
{
@@ -326,7 +331,7 @@ describe('useChat', () => {
})
it('should block send if already responding', async () => {
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
let sendResult1: boolean | void = true
let sendResult2: boolean | void = true
@@ -349,7 +354,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hello' }, { onNotifyError })
@@ -379,7 +384,7 @@ describe('useChat', () => {
it('should process inputs with the per-send input form override', () => {
vi.mocked(ssePost).mockImplementation(async () => undefined)
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, {
inputs: {},
inputsForm: [
@@ -437,7 +442,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
@@ -466,7 +471,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'agent test' }, {})
@@ -540,7 +545,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'human input test' }, {})
@@ -612,7 +617,7 @@ describe('useChat', () => {
resumedCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'human input test' }, {})
@@ -644,7 +649,7 @@ describe('useChat', () => {
continuationCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'human input test' }, {})
postCallbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
@@ -705,7 +710,7 @@ describe('useChat', () => {
const onGetSuggestedQuestions = vi.fn().mockResolvedValue({ data: ['Next question'] })
const config = { suggested_questions_after_answer: { enabled: true } }
const { result } = renderHook(() => useChat(config as ChatConfig))
const { result } = renderHookWithConsoleQuery(() => useChat(config as ChatConfig))
act(() => {
result.current.handleSend(
'test-url',
@@ -755,7 +760,7 @@ describe('useChat', () => {
postCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'human input test' }, {})
postCallbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
@@ -786,7 +791,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'file test' }, {})
@@ -815,70 +820,88 @@ describe('useChat', () => {
])
})
it('should fetch conversation messages and suggested questions onCompleted', async () => {
let callbacks: HookCallbacks
it.each([
['UTC', '10:00 AM'],
['Asia/Shanghai', '06:00 PM'],
])(
'loads completed history and suggestions with message time in %s',
async (timezone, expectedTime) => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (url, params, options) => {
callbacks = options as HookCallbacks
})
vi.mocked(ssePost).mockImplementation(async (url, params, options) => {
callbacks = options as HookCallbacks
})
const onGetConversationMessages = vi.fn().mockResolvedValue({
data: [
{
id: 'm-5',
answer: 'Updated answer from history',
message: [{ role: 'user', text: 'hi' }],
message_files: [{ id: 'assistant-file', belongs_to: 'assistant' }],
created_at: Date.now(),
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 0.5,
workflow_run_id: 'workflow-run-from-history',
inputs: {},
query: 'hi',
},
],
})
const onGetConversationMessages = vi.fn().mockResolvedValue({
data: [
{
id: 'm-5',
answer: 'Updated answer from history',
message: [{ role: 'user', text: 'hi' }],
message_files: [{ id: 'assistant-file', belongs_to: 'assistant' }],
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 0.5,
workflow_run_id: 'workflow-run-from-history',
inputs: {},
query: 'hi',
},
],
})
const onGetSuggestedQuestions = vi.fn().mockResolvedValue({
data: ['Suggested 1', 'Suggested 2'],
})
const onGetSuggestedQuestions = vi.fn().mockResolvedValue({
data: ['Suggested 1', 'Suggested 2'],
})
const onConversationComplete = vi.fn()
const onConversationComplete = vi.fn()
const config = {
suggested_questions_after_answer: { enabled: true },
}
const config = {
suggested_questions_after_answer: { enabled: true },
}
const { result } = renderHook(() => useChat(config as ChatConfig))
act(() => {
result.current.handleSend(
'test-url',
{ query: 'fetch test' },
{
onGetConversationMessages,
onGetSuggestedQuestions,
onConversationComplete,
},
const { result } = renderHookWithConsoleQuery(() =>
useChat(
config as ChatConfig,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ timezone },
),
)
})
await act(async () => {
// Setup state needed for completed handlers
callbacks.onData(' data', true, { messageId: 'm-5', conversationId: 'c-1' })
act(() => {
result.current.handleSend(
'test-url',
{ query: 'fetch test' },
{
onGetConversationMessages,
onGetSuggestedQuestions,
onConversationComplete,
},
)
})
await callbacks.onCompleted()
})
await act(async () => {
// Setup state needed for completed handlers
callbacks.onData(' data', true, { messageId: 'm-5', conversationId: 'c-1' })
expect(onGetConversationMessages).toHaveBeenCalledWith('c-1', expect.any(Function))
expect(onGetSuggestedQuestions).toHaveBeenCalledWith('m-5', expect.any(Function))
expect(onConversationComplete).toHaveBeenCalledWith('c-1', 'workflow-run-from-history')
await callbacks.onCompleted()
})
const updatedResponse = result.current.chatList[1]
expect(updatedResponse!.content).toBe('Updated answer from history') // Fetched from mock
expect(result.current.suggestedQuestions).toEqual(['Suggested 1', 'Suggested 2'])
})
expect(onGetConversationMessages).toHaveBeenCalledWith('c-1', expect.any(Function))
expect(onGetSuggestedQuestions).toHaveBeenCalledWith('m-5', expect.any(Function))
expect(onConversationComplete).toHaveBeenCalledWith('c-1', 'workflow-run-from-history')
const updatedResponse = result.current.chatList[1]
expect(updatedResponse!.content).toBe('Updated answer from history')
expect(updatedResponse!.more?.time).toBe(expectedTime)
expect(result.current.suggestedQuestions).toEqual(['Suggested 1', 'Suggested 2'])
},
)
it('should early return onCompleted if hasError is true', async () => {
let callbacks: HookCallbacks
@@ -889,7 +912,7 @@ describe('useChat', () => {
const onConversationComplete = vi.fn()
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
@@ -915,7 +938,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'trace test' }, {})
@@ -990,7 +1013,7 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
)
@@ -1020,7 +1043,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'failed workflow' }, {})
@@ -1059,7 +1082,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleSend(
@@ -1108,7 +1133,7 @@ describe('useChat', () => {
size: 456,
}
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
@@ -1142,7 +1167,7 @@ describe('useChat', () => {
})
const previousWorkflowAbort = createAbortControllerMock()
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'first request' }, {})
@@ -1171,7 +1196,7 @@ describe('useChat', () => {
data: [{ id: 'other-message', answer: 'unused' }],
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
@@ -1206,7 +1231,7 @@ describe('useChat', () => {
suggested_questions_after_answer: { enabled: true },
}
const onGetSuggestedQuestions = vi.fn().mockRejectedValue(new Error('network'))
const { result } = renderHook(() => useChat(config as ChatConfig))
const { result } = renderHookWithConsoleQuery(() => useChat(config as ChatConfig))
act(() => {
result.current.handleSend(
@@ -1235,7 +1260,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
@@ -1299,7 +1324,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true, onNotifyError })
@@ -1386,26 +1413,9 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const prevChatTree = [
{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [
{
id: 'm-1',
content: '',
isAnswer: true,
workflow_run_id: 'wr-1',
humanInputFormDataList: [{ node_id: 'human-1' }],
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
siblingIndex: 0,
},
],
},
]
const { result } = renderHook(() =>
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
const prevChatTree = createPausedChatTree()
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
let isReady: boolean | undefined
@@ -1441,26 +1451,9 @@ describe('useChat', () => {
callbacksList.push(options as HookCallbacks)
})
const prevChatTree = [
{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [
{
id: 'm-1',
content: '',
isAnswer: true,
workflow_run_id: 'wr-1',
humanInputFormDataList: [{ node_id: 'human-1' }],
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
siblingIndex: 0,
},
],
},
]
const { result } = renderHook(() =>
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
const prevChatTree = createPausedChatTree()
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
@@ -1498,26 +1491,9 @@ describe('useChat', () => {
callbacksList.push(options as HookCallbacks)
})
const prevChatTree = [
{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [
{
id: 'm-1',
content: '',
isAnswer: true,
workflow_run_id: 'wr-1',
humanInputFormDataList: [{ node_id: 'human-1' }],
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
siblingIndex: 0,
},
],
},
]
const { result } = renderHook(() =>
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
const prevChatTree = createPausedChatTree()
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
@@ -1560,7 +1536,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-2', 'wr-1', { isPublicAPI: true })
@@ -1599,7 +1577,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(config as ChatConfig, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-resume-error', 'wr-error', {
@@ -1640,7 +1620,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-resume-error', 'wr-error', {
@@ -1681,7 +1663,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
const previousWorkflowAbort = createAbortControllerMock()
act(() => {
@@ -1726,7 +1710,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(config as ChatConfig, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-suggest-resume', 'wr-1', {
@@ -1773,7 +1759,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-human-resume', 'wr-1', { isPublicAPI: true })
@@ -1822,7 +1810,7 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
)
@@ -1907,7 +1895,7 @@ describe('useChat', () => {
children: [{ id: 'm-tts', content: 'Answer', isAnswer: true, siblingIndex: 0 }],
},
]
const { result } = renderHook(() => useChat(undefined, undefined, tree))
const { result } = renderHookWithConsoleQuery(() => useChat(undefined, undefined, tree))
act(() => {
if (mode === 'send') result.current.handleSend('url', { query: 'speech' }, {})
else result.current.handleResume('m-tts', 'wr-tts', { isPublicAPI: true })
@@ -1940,7 +1928,7 @@ describe('useChat', () => {
describe('handleStop and handleRestart', () => {
it('clears messages and suggested questions when restarting', async () => {
const cb = vi.fn()
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat({ suggested_questions_after_answer: { enabled: true } } as ChatConfig),
)
@@ -1992,7 +1980,7 @@ describe('useChat', () => {
id: 'm-stop',
answer: 'done',
message: [{ role: 'assistant', text: 'done' }],
created_at: Date.now(),
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
answer_tokens: 3,
message_tokens: 2,
provider_response_latency: 1,
@@ -2015,7 +2003,7 @@ describe('useChat', () => {
},
)
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(config as ChatConfig, undefined, undefined, stopChat),
)
@@ -2061,7 +2049,9 @@ describe('useChat', () => {
children: [{ id: 'm-1', content: '', isAnswer: true, siblingIndex: 0 }],
},
]
const { result, unmount } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result, unmount } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
@@ -2082,7 +2072,7 @@ describe('useChat', () => {
const clearChatListCallback = vi.fn((nextClearChatList: boolean) => {
clearChatList = nextClearChatList
})
const { rerender, result } = renderHook(() =>
const { rerender, result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, undefined, undefined, clearChatList, clearChatListCallback),
)
@@ -2135,7 +2125,7 @@ describe('useChat', () => {
]
it('should handle annotation events', () => {
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
)
@@ -2161,7 +2151,7 @@ describe('useChat', () => {
})
it('should handle switch sibling and trigger handleResume if human input', () => {
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
)
@@ -2209,7 +2199,7 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, nestedTree))
const { result } = renderHookWithConsoleQuery(() => useChat(undefined, undefined, nestedTree))
act(() => {
result.current.handleSwitchSibling('a-deep', { isPublicAPI: true })
})
@@ -2230,7 +2220,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'file types' }, {})
})
@@ -2266,7 +2256,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'citations' }, {})
})
@@ -2303,7 +2293,9 @@ describe('useChat', () => {
],
},
]
const { result } = renderHook(() => useChat(undefined, undefined, tree as ChatItemInTree[]))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, tree as ChatItemInTree[]),
)
await act(async () => result.current.handleResume('m', 'wr', { isPublicAPI: true }))
const callbacks = vi.mocked(sseGet).mock.calls[0]![2] as HookCallbacks
act(() =>
@@ -2333,7 +2325,7 @@ describe('useChat', () => {
answer: 'final answer',
message: [{ role: 'user', text: 'hi' }],
agent_thoughts: [{ thought: 'thinking different from answer' }],
created_at: Date.now(),
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 0,
@@ -2343,7 +2335,7 @@ describe('useChat', () => {
],
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
'test-url',
@@ -2377,14 +2369,14 @@ describe('useChat', () => {
id: 'm-without-log',
answer: 'final answer',
agent_thoughts: [],
created_at: Date.now(),
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
inputs: {},
query: 'hi',
},
],
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
'test-url',
@@ -2450,7 +2442,7 @@ describe('useChat', () => {
],
retriever_resources: [{ id: 'history-citation', content: 'history citation' }],
metadata: { reasoning: { history: 'history reasoning' } },
created_at: Date.now(),
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 0.5,
@@ -2462,7 +2454,7 @@ describe('useChat', () => {
],
})
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, {
isNewAgent: true,
}),
@@ -2540,7 +2532,7 @@ describe('useChat', () => {
answer: 'matched thought',
message: [{ role: 'user', text: 'hi' }],
agent_thoughts: [{ thought: 'matched thought' }],
created_at: Date.now(),
created_at: Date.parse('2024-01-01T10:00:00Z') / 1000,
answer_tokens: 10,
message_tokens: 5,
provider_response_latency: 0.5,
@@ -2550,7 +2542,7 @@ describe('useChat', () => {
],
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend(
'test-url',
@@ -2576,7 +2568,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'agent onThought' }, {})
})
@@ -2603,7 +2595,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, undefined, undefined, undefined, undefined, undefined, {
isNewAgent: true,
}),
@@ -2679,7 +2671,7 @@ describe('useChat', () => {
]
// Render the chat with the nested tree
const { result } = renderHook(() => useChat(undefined, undefined, nestedTree))
const { result } = renderHookWithConsoleQuery(() => useChat(undefined, undefined, nestedTree))
// Setting TargetNodeId triggers state update using produceChatTreeNode internally
act(() => {
@@ -2706,7 +2698,7 @@ describe('useChat', () => {
sendCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'test base file' }, {})
@@ -2727,7 +2719,9 @@ describe('useChat', () => {
],
},
]
const { result: resumeResult } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result: resumeResult } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
resumeResult.current.handleResume('m-resume', 'wr-1', { isPublicAPI: true })
})
@@ -2759,7 +2753,7 @@ describe('useChat', () => {
sendCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'test parallel_id' }, {})
})
@@ -2803,7 +2797,7 @@ describe('useChat', () => {
sendCallbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('url', { query: 'test exact file' }, {})
@@ -2851,7 +2845,7 @@ describe('useChat', () => {
],
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() => useChat(undefined, undefined, prevChatTree))
act(() => {
result.current.handleResume('m-index', 'wr-1', { isPublicAPI: true })
})
@@ -2889,13 +2883,13 @@ describe('useChat', () => {
.fn()
.mockImplementationOnce((_id, getAbort) => {
if (getAbort) {
getAbort({ abort: vi.fn() } as unknown as AbortController)
getAbort(new AbortController())
}
return Promise.resolve({ data: ['Suggested 1', 'Suggested 2'] })
})
.mockImplementationOnce((_id, getAbort) => {
if (getAbort) {
getAbort({ abort: vi.fn() } as unknown as AbortController)
getAbort(new AbortController())
}
return Promise.reject(new Error('error'))
})
@@ -2914,7 +2908,9 @@ describe('useChat', () => {
]
// Success branch
const { result } = renderHook(() => useChat(config as ChatConfig, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(config as ChatConfig, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true, onGetSuggestedQuestions })
})
@@ -2958,7 +2954,7 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() =>
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
)
act(() => {
@@ -2990,7 +2986,7 @@ describe('useChat', () => {
children: [{ id: 'a-1', content: 'a', isAnswer: true, siblingIndex: 0 }],
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() => useChat(undefined, undefined, prevChatTree))
await act(async () => {
// (annotationId, authorName, query, answer, index)
result.current.handleAnnotationAdded('anno-id', 'author', 'q-new', 'a-new', 1)
@@ -3008,7 +3004,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hi' }, {})
@@ -3043,7 +3039,7 @@ describe('useChat', () => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
const { result } = renderHookWithConsoleQuery(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hi' }, {})
@@ -3088,7 +3084,9 @@ describe('useChat', () => {
},
]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree))
const { result } = renderHookWithConsoleQuery(() =>
useChat(undefined, undefined, prevChatTree),
)
act(() => {
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
@@ -1,20 +1,11 @@
import type { Theme } from '../../embedded-chatbot/theme/theme'
import type { ChatConfig, ChatItem, OnRegenerate } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { ChatItem, OnRegenerate } from '../../types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat'
import { ChatContextProvider } from '../context-provider'
import Question from '../question'
const mockCopy = vi.fn()
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: false,
}),
}))
type RenderProps = {
theme?: Theme | null
questionIcon?: React.ReactNode
@@ -24,26 +15,24 @@ type RenderProps = {
answerIcon?: React.ReactNode
}
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem =>
({
id: 'q-1',
content: 'This is the question content',
message_files: [],
siblingCount: 3,
siblingIndex: 0,
prevSibling: null,
nextSibling: 'q-2',
...overrides,
}) as unknown as ChatItem
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: 'q-1',
isAnswer: false,
content: 'This is the question content',
message_files: [],
siblingCount: 1,
siblingIndex: 0,
...overrides,
})
const renderWithProvider = (
item: ChatItem,
onRegenerate: OnRegenerate = vi.fn() as unknown as OnRegenerate,
onRegenerate: OnRegenerate = vi.fn<OnRegenerate>(),
props: RenderProps = {},
) => {
return render(
<ChatContextProvider
config={{} as unknown as ChatConfig | undefined}
config={createChatConfigFixture()}
isResponding={false}
chatList={[]}
questionIcon={props.questionIcon}
@@ -70,10 +59,6 @@ const renderWithProvider = (
}
describe('Question component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([false, true])('shows a supplied avatar unless hidden=%s', async (hideAvatar) => {
renderWithProvider(makeItem(), vi.fn(), {
questionIcon: <span aria-label="Your avatar" role="img" />,
@@ -93,11 +78,11 @@ describe('Question component', () => {
const copyBtn = screen.getByRole('button', { name: 'common.operation.copy' })
await user.click(copyBtn)
expect(mockCopy).toHaveBeenCalledWith('This is the question content')
expect(await navigator.clipboard.readText()).toBe('This is the question content')
})
it('should not show edit action when enableEdit is false', () => {
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false })
renderWithProvider(makeItem(), vi.fn<OnRegenerate>(), { enableEdit: false })
expect(screen.getByRole('button', { name: 'common.operation.copy' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument()
@@ -105,7 +90,7 @@ describe('Question component', () => {
it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const onRegenerate = vi.fn<OnRegenerate>()
const item = makeItem()
renderWithProvider(item, onRegenerate)
@@ -153,7 +138,7 @@ describe('Question component', () => {
it('should confirm editing when Enter is pressed', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const onRegenerate = vi.fn<OnRegenerate>()
renderWithProvider(makeItem(), onRegenerate)
@@ -175,7 +160,7 @@ describe('Question component', () => {
it('should insert a new line when Shift+Enter is pressed', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const onRegenerate = vi.fn<OnRegenerate>()
renderWithProvider(makeItem(), onRegenerate)
@@ -191,16 +176,17 @@ describe('Question component', () => {
})
it('should keep text unchanged and suppress Enter if a new composition starts before previous composition-end timer finishes', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn<OnRegenerate>()
renderWithProvider(makeItem(), onRegenerate)
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const textbox = screen.getByRole('textbox')
await user.clear(textbox)
await user.type(textbox, 'IME guard text')
vi.useFakeTimers()
try {
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const textbox = screen.getByRole('textbox')
fireEvent.change(textbox, { target: { value: 'IME guard text' } })
fireEvent.compositionStart(textbox)
fireEvent.compositionEnd(textbox)
fireEvent.compositionStart(textbox)
@@ -220,23 +206,28 @@ describe('Question component', () => {
fireEvent.compositionEnd(textbox)
act(() => vi.advanceTimersByTime(50))
fireEvent.keyDown(textbox, { key: 'Enter', code: 'Enter' })
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), {
message: 'IME guard text',
files: [],
})
} finally {
vi.useRealTimers()
}
await user.keyboard('{Enter}')
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), {
message: 'IME guard text',
files: [],
})
})
it('should switch siblings when prev/next buttons are clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
const item = makeItem({ prevSibling: 'q-prev', nextSibling: 'q-next', siblingIndex: 1 })
const item = makeItem({
prevSibling: 'q-prev',
nextSibling: 'q-next',
siblingIndex: 1,
siblingCount: 3,
})
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
renderWithProvider(item, vi.fn<OnRegenerate>(), { switchSibling })
const prevBtn = screen.getByRole('button', { name: /previous/i })
const nextBtn = screen.getByRole('button', { name: /next/i })
@@ -251,21 +242,24 @@ describe('Question component', () => {
it('should ignore enter when nativeEvent.isComposing is true', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const onRegenerate = vi.fn<OnRegenerate>()
renderWithProvider(makeItem(), onRegenerate)
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const textbox = await screen.findByRole('textbox')
// Create an event with nativeEvent.isComposing = true
const event = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })
Object.defineProperty(event, 'isComposing', { value: true, configurable: true })
const event = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
isComposing: true,
})
fireEvent(textbox, event)
expect(onRegenerate).not.toHaveBeenCalled()
})
it('should not call switchSibling when next button clicked but nextSibling is null', async () => {
it('should not call switchSibling when next button clicked but nextSibling is absent', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
const item = makeItem({
@@ -275,7 +269,7 @@ describe('Question component', () => {
siblingCount: 3,
})
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
renderWithProvider(item, vi.fn<OnRegenerate>(), { switchSibling })
const nextBtn = screen.getByRole('button', { name: /next/i })
await user.click(nextBtn)
@@ -284,7 +278,7 @@ describe('Question component', () => {
expect(nextBtn).toBeDisabled()
})
it('should not call switchSibling when prev button clicked but prevSibling is null', async () => {
it('should not call switchSibling when prev button clicked but prevSibling is absent', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
const item = makeItem({
@@ -294,7 +288,7 @@ describe('Question component', () => {
siblingCount: 3,
})
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
renderWithProvider(item, vi.fn<OnRegenerate>(), { switchSibling })
const prevBtn = screen.getByRole('button', { name: /previous/i })
await user.click(prevBtn)
@@ -303,14 +297,14 @@ describe('Question component', () => {
expect(prevBtn).toBeDisabled()
})
it('should handle both prev and next siblings being null (only one message)', () => {
it('should handle both prev and next siblings being absent (only one message)', () => {
const item = makeItem({
prevSibling: undefined,
nextSibling: undefined,
siblingIndex: 0,
siblingCount: 1,
})
renderWithProvider(item, vi.fn() as unknown as OnRegenerate)
renderWithProvider(item, vi.fn<OnRegenerate>())
const prevBtn = screen.queryByRole('button', { name: /previous/i })
const nextBtn = screen.queryByRole('button', { name: /next/i })
@@ -319,41 +313,11 @@ describe('Question component', () => {
expect(nextBtn).not.toBeInTheDocument()
})
it('should render with message_files having multiple files', () => {
const files = [
{
name: 'document.pdf',
url: 'https://example.com/doc.pdf',
type: 'application/pdf',
previewUrl: 'https://example.com/doc.pdf',
size: 5000,
} as unknown as FileEntity,
{
name: 'image.png',
url: 'https://example.com/img.png',
type: 'image/png',
previewUrl: 'https://example.com/img.png',
size: 3000,
} as unknown as FileEntity,
]
renderWithProvider(makeItem({ message_files: files }))
expect(screen.getByText(/document.pdf/i)).toBeInTheDocument()
expect(screen.getByText(/image.png/i)).toBeInTheDocument()
})
it('should trigger onRegenerate with actual message_files in item', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
const onRegenerate = vi.fn<OnRegenerate>()
const files = [
{
name: 'edit-file.txt',
url: 'https://example.com/edit-file.txt',
type: 'text/plain',
previewUrl: 'https://example.com/edit-file.txt',
size: 200,
} as unknown as FileEntity,
createChatFileFixture({ name: 'edit-file.txt', url: 'https://example.com/edit-file.txt' }),
]
const item = makeItem({ message_files: files })
@@ -1,151 +1,104 @@
import type { ChatItem } from '../../../types'
import type { ThoughtItem } from '../../type'
import type { IThoughtProps } from '@/app/components/base/chat/chat/thought'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { render, screen } from '@testing-library/react'
import type { FileResponse } from '@/types/workflow'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { addFileInfos } from '@/app/components/tools/utils'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import {
createChatAnnotationFixture,
createChatFileFixture,
createChatThoughtFixture as thought,
} from '@/test/fixtures/chat'
import { TransferMethod } from '@/types/app'
import AgentContent from '../agent-content'
// Markdown, tool details, and file actions have independent suites; this owner chooses their data.
vi.mock('@/app/components/base/markdown', () => ({
Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => (
<div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)}>
{String(props.content)}
</div>
),
}))
const item: ChatItem = { id: 'answer', content: '', isAnswer: true }
vi.mock('@/app/components/base/chat/chat/thought', () => ({
default: ({ thought, isFinished }: IThoughtProps) => (
<div data-testid="thought-component" data-finished={isFinished}>
{thought.thought}
</div>
),
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list-component">{files.map((f) => f.name).join(', ')}</div>
),
}))
const thought = (overrides: Partial<ThoughtItem> = {}): ThoughtItem => ({
id: 'thought-1',
tool: '',
thought: '',
tool_input: '{}',
message_id: 'message-1',
conversation_id: 'conversation-1',
observation: '',
position: 0,
...overrides,
it('shows saved annotation content instead of the original response', async () => {
render(
<AgentContent
item={{ ...item, annotation: createChatAnnotationFixture('Edited response') }}
content="Original response"
/>,
)
expect(await screen.findByText('Edited response')).toBeInTheDocument()
expect(screen.queryByText('Original response')).not.toBeInTheDocument()
})
describe('AgentContent', () => {
const mockItem: ChatItem = {
id: '1',
content: '',
isAnswer: true,
it('shows the final response instead of superseded thoughts', async () => {
render(
<AgentContent
item={{ ...item, agent_thoughts: [thought({ thought: 'Superseded thought' })] }}
content="Final response"
/>,
)
expect(await screen.findByText('Final response')).toBeInTheDocument()
expect(screen.queryByText('Superseded thought')).not.toBeInTheDocument()
})
it('shows tool progress and results alongside thought text until the response finishes', async () => {
const user = userEvent.setup()
const answer: ChatItem = {
...item,
agent_thoughts: [
thought({ thought: 'Search documents', tool: 'search', observation: 'Found a document' }),
thought({ id: 'thought-2', thought: 'Fetch details', tool: 'fetch', position: 1 }),
],
}
const { rerender } = render(<AgentContent item={answer} responding />)
expect(await screen.findByText('Search documents')).toBeInTheDocument()
expect(screen.getByText('Fetch details')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'tools.thought.used search' }))
expect(screen.getByText('Found a document')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'tools.thought.using fetch' })).toBeInTheDocument()
rerender(<AgentContent item={answer} responding={false} />)
expect(screen.getByRole('button', { name: 'tools.thought.used fetch' })).toBeInTheDocument()
})
it('renders logAnnotation if present', () => {
const itemWithAnnotation = {
...mockItem,
annotation: {
id: 'annotation-1',
authorName: 'Editor',
logAnnotation: {
content: 'Log Annotation Content',
id: 'annotation-1',
created_at: 1700000000,
account: { id: 'user-1', name: 'Editor', email: 'editor@example.com' },
},
},
}
render(<AgentContent item={itemWithAnnotation} content="Original response" />)
expect(screen.queryByText('Original response')).not.toBeInTheDocument()
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
})
it('preserves normalized streaming attachment names and download actions', () => {
render(
<AgentContent
item={{
...item,
agent_thoughts: [
thought({
message_files: [
createChatFileFixture({
name: 'report.pdf',
type: 'application/pdf',
url: 'https://example.com/report.pdf',
}),
createChatFileFixture({
id: 'file-2',
name: 'notes.txt',
url: 'https://example.com/notes.txt',
}),
],
}),
],
}}
/>,
)
expect(screen.getByRole('button', { name: 'report.pdf' })).toBeInTheDocument()
expect(screen.getByText('notes.txt')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'common.operation.download' })).toHaveLength(2)
})
it('renders content prop if provided and no annotation', () => {
render(
<AgentContent
item={{ ...mockItem, agent_thoughts: [thought({ thought: 'Superseded thought' })] }}
content="Direct Content"
/>,
)
expect(screen.queryByText('Superseded thought')).not.toBeInTheDocument()
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
})
it('renders agent_thoughts if content is absent', () => {
const itemWithThoughts = {
...mockItem,
agent_thoughts: [
thought({ thought: 'Thought 1', tool: 'tool1' }),
thought({ id: 'thought-2', thought: 'Thought 2', position: 1 }),
],
}
render(<AgentContent item={itemWithThoughts} responding={false} />)
const items = screen.getAllByTestId('agent-thought-item')
expect(items).toHaveLength(2)
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1')
expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2')
expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1')
})
it('passes correct isFinished to Thought component', () => {
const itemWithThoughts = {
...mockItem,
agent_thoughts: [
thought({ thought: 'T1', tool: 'tool1', observation: 'obs1' }), // finished by observation
thought({ id: 'thought-2', thought: 'T2', tool: 'tool2', position: 1 }), // finished by responding=false
],
}
const { rerender } = render(<AgentContent item={itemWithThoughts} responding={true} />)
const thoughts = screen.getAllByTestId('thought-component')
expect(thoughts[0]).toHaveAttribute('data-finished', 'true')
expect(thoughts[1]).toHaveAttribute('data-finished', 'false')
rerender(<AgentContent item={itemWithThoughts} responding={false} />)
expect(screen.getAllByTestId('thought-component')[1]).toHaveAttribute('data-finished', 'true')
})
it('renders FileList if thought has message_files', () => {
const itemWithFiles = {
...mockItem,
agent_thoughts: [
{
thought: 'T1',
message_files: [
{
id: 'file1',
related_id: 'upload-1',
filename: 'report.pdf',
mime_type: 'application/pdf',
type: 'document',
transfer_method: 'local_file',
},
{
id: 'file2',
related_id: 'upload-2',
filename: 'notes.txt',
mime_type: 'text/plain',
type: 'document',
transfer_method: 'local_file',
},
],
},
],
}
// Historical thought responses carry server file metadata before AgentContent normalizes it.
render(<AgentContent item={itemWithFiles as unknown as ChatItem} />)
expect(screen.getByTestId('file-list-component')).toHaveTextContent('report.pdf, notes.txt')
})
it('renders nothing if no annotation, content, or thoughts', () => {
render(<AgentContent item={mockItem} />)
expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement()
})
it('renders historical attachments resolved from thought file ids', () => {
const file = {
id: 'history-file',
related_id: 'history-file',
filename: 'history.pdf',
extension: 'pdf',
mime_type: 'application/pdf',
type: 'document',
size: 100,
transfer_method: TransferMethod.local_file,
upload_file_id: 'uploaded-file',
url: 'https://example.com/history.pdf',
remote_url: '',
} satisfies FileResponse & { id: string }
const thoughts = addFileInfos([thought({ files: [file.id] })], [file])
render(<AgentContent item={{ ...item, agent_thoughts: thoughts }} />)
expect(screen.getByRole('button', { name: 'history.pdf' })).toBeInTheDocument()
})
@@ -1,89 +1,31 @@
import type { ChatItem } from '../../../types'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createChatAnnotationFixture } from '@/test/fixtures/chat'
import BasicContent from '../basic-content'
// Markdown parsing is independently covered; this suite owns annotation precedence and UNC preprocessing.
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content, className }: MarkdownProps) => (
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
{String(content)}
</div>
),
}))
const item: ChatItem = { id: 'answer', content: 'Original answer', isAnswer: true }
describe('BasicContent', () => {
const mockItem: ChatItem = {
id: '1',
content: 'Hello World',
isAnswer: true,
}
it.each(['Edited answer', ''])(
'replaces the answer with its saved annotation %j',
async (content) => {
const { rerender } = render(<BasicContent item={item} />)
expect(await screen.findByText('Original answer')).toBeInTheDocument()
rerender(<BasicContent item={{ ...item, annotation: createChatAnnotationFixture(content) }} />)
expect(screen.queryByText('Original answer')).not.toBeInTheDocument()
if (content) expect(await screen.findByText(content)).toBeInTheDocument()
},
)
it('renders logAnnotation content if present', () => {
const itemWithAnnotation = {
...mockItem,
annotation: {
logAnnotation: {
content: 'Annotated Content',
},
},
}
const { rerender } = render(<BasicContent item={mockItem} />)
expect(screen.getByTestId('basic-content-markdown')).toHaveTextContent('Hello World')
rerender(<BasicContent item={itemWithAnnotation as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
})
it.each(['\\\\server\\share\\file.txt', '`\\\\server\\share\\file.txt`'])(
'preserves the visible Windows path from %j',
async (content) => {
render(<BasicContent item={{ ...item, content }} />)
expect(await screen.findByText('\\\\server\\share\\file.txt')).toBeInTheDocument()
},
)
it('renders empty string if logAnnotation content is missing', () => {
const itemWithEmptyAnnotation = {
...mockItem,
annotation: {
logAnnotation: {
content: '',
},
},
}
const { rerender } = render(<BasicContent item={itemWithEmptyAnnotation as ChatItem} />)
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-content', '')
const itemWithUndefinedAnnotation = {
...mockItem,
annotation: {
logAnnotation: {},
},
}
rerender(<BasicContent item={itemWithUndefinedAnnotation as ChatItem} />)
expect(screen.getByTestId('basic-content-markdown')).toHaveAttribute('data-content', '')
})
it('wraps Windows UNC paths in backticks', () => {
const itemWithUNC = {
...mockItem,
content: '\\\\server\\share\\file.txt',
}
render(<BasicContent item={itemWithUNC as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
})
it('does not wrap content in backticks if it already is', () => {
const itemWithBackticks = {
...mockItem,
content: '`\\\\server\\share\\file.txt`',
}
render(<BasicContent item={itemWithBackticks as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
})
it('does not wrap backslash strings that are not UNC paths', () => {
const itemWithBackslashes = {
...mockItem,
content: '\\not-a-unc',
}
render(<BasicContent item={itemWithBackslashes as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '\\not-a-unc')
})
it('preserves a backslash string that is not a UNC path', async () => {
render(<BasicContent item={{ ...item, content: '\\not-a-unc' }} />)
expect(await screen.findByText('\\not-a-unc')).toBeInTheDocument()
})
@@ -1,33 +1,26 @@
import type { ComponentProps } from 'react'
import type {
DeliveryMethod,
HumanInputNodeType,
} from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
DeliveryMethodType,
UserActionButtonType,
} from '@/app/components/workflow/nodes/human-input/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import HumanInputFormList from '../human-input-form-list'
type ContentProps = ComponentProps<
typeof import('../human-input-content/unsubmitted').UnsubmittedHumanInputContent
>
const mockContent = vi.fn<(props: ContentProps) => void>()
// Form filling has its own tests; this list owns visibility and delivery-mode flags.
vi.mock('../human-input-content/unsubmitted', () => ({
UnsubmittedHumanInputContent: (props: ContentProps) => {
mockContent(props)
return null
},
}))
const createForm = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
form_id: 'form1',
node_id: 'node1',
node_title: 'Visible form',
form_content: '',
inputs: [],
actions: [],
actions: [{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }],
form_token: 'token',
resolved_default_values: {},
display_in_ui: true,
@@ -50,9 +43,10 @@ const createNode = (deliveryMethods: DeliveryMethod[]): Node<HumanInputNodeType>
},
})
it('renders only visible forms and forwards the selected form and submit boundary', () => {
it('submits only the visible form using its token and action', async () => {
const user = userEvent.setup()
const form = createForm()
const onSubmit = vi.fn()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(
<HumanInputFormList
humanInputFormDataList={[
@@ -69,8 +63,9 @@ it('renders only visible forms and forwards the selected form and submit boundar
)
expect(screen.getByText('Visible form')).toBeInTheDocument()
expect(screen.queryByText('Hidden form')).not.toBeInTheDocument()
expect(mockContent).toHaveBeenCalledOnce()
expect(mockContent).toHaveBeenCalledWith(expect.objectContaining({ formData: form, onSubmit }))
expect(screen.getAllByRole('button', { name: 'Approve' })).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(onSubmit).toHaveBeenCalledExactlyOnceWith('token', { inputs: {}, action: 'approve' })
})
it.each([
@@ -119,11 +114,13 @@ it.each([
getHumanInputNodeData={() => node}
/>,
)
expect(mockContent).toHaveBeenCalledWith(
expect.objectContaining({
showEmailTip: email,
isEmailDebugMode: emailDebug,
showDebugModeTip: debug,
}),
)
const emailTip = screen.queryByText('workflow.common.humanInputEmailTip')
const emailDebugTip = screen.queryByText(/workflow.common.humanInputEmailTipInDebugMode/)
const webappTip = screen.queryByText('workflow.common.humanInputWebappTip')
if (email && !emailDebug) expect(emailTip).toBeInTheDocument()
else expect(emailTip).not.toBeInTheDocument()
if (email && emailDebug) expect(emailDebugTip).toBeInTheDocument()
else expect(emailDebugTip).not.toBeInTheDocument()
if (debug) expect(webappTip).toBeInTheDocument()
else expect(webappTip).not.toBeInTheDocument()
})
@@ -1,3 +1,4 @@
import type { ComponentProps } from 'react'
import type { ChatItem } from '../../../types'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { screen } from '@testing-library/react'
@@ -5,7 +6,7 @@ import userEvent from '@testing-library/user-event'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { TransferMethod } from '@/types/app'
import { createChatFileFixture, createChatThoughtFixture } from '@/test/fixtures/chat'
import Answer from '../index'
const pendingForm: HumanInputFormData = {
@@ -34,10 +35,10 @@ describe('Answer Component', () => {
id: 'msg-1',
content: 'Test response',
isAnswer: true,
} as unknown as ChatItem,
},
question: 'Hello?',
index: 0,
}
} satisfies ComponentProps<typeof Answer>
describe('Rendering', () => {
it('should render basic content correctly', async () => {
@@ -51,24 +52,11 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
content: '',
agent_thoughts: [
{
id: '1',
thought: 'Thinking...',
tool: '',
tool_input: '',
observation: '',
message_id: 'msg-1',
conversation_id: 'chat-1',
position: 1,
},
],
} as unknown as ChatItem
}
item={{
...defaultProps.item,
content: '',
agent_thoughts: [createChatThoughtFixture({ thought: 'Thinking...' })],
}}
/>,
)
expect(await screen.findByText('Thinking...')).toBeInTheDocument()
@@ -78,31 +66,27 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
id: 'msg-with-parts',
content: '',
isAnswer: true,
agent_response_parts: [{ type: 'message', content: 'streamed answer' }],
} as ChatItem
}
renderAgentContent={() => <div data-testid="custom-agent-content">streamed answer</div>}
item={{
id: 'msg-with-parts',
content: '',
isAnswer: true,
agent_response_parts: [{ type: 'message', content: 'streamed answer' }],
}}
renderAgentContent={() => <p>streamed answer</p>}
/>,
)
expect(screen.getByTestId('custom-agent-content')).toHaveTextContent('streamed answer')
expect(screen.getByText('streamed answer')).toHaveTextContent('streamed answer')
})
it('should render annotation edit title', async () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
annotation: { id: 'a1', authorName: 'John Doe' },
} as unknown as ChatItem
}
item={{
...defaultProps.item,
annotation: { id: 'a1', authorName: 'John Doe' },
}}
/>,
)
expect(await screen.findByText(/John Doe/i)).toBeInTheDocument()
@@ -118,14 +102,12 @@ describe('Answer Component', () => {
<Answer
{...defaultProps}
responding={true}
item={
{
...defaultProps.item,
// Thinking ⇒ the answer has not started yet, so content must be empty.
content: '',
reasoningContent: { llm: 'deep thought' },
} as unknown as ChatItem
}
item={{
...defaultProps.item,
// Thinking ⇒ the answer has not started yet, so content must be empty.
content: '',
reasoningContent: { llm: 'deep thought' },
}}
/>,
)
expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument()
@@ -135,13 +117,11 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
reasoningContent: { llm: 'recalled reasoning' },
reasoningFinished: true,
} as unknown as ChatItem
}
item={{
...defaultProps.item,
reasoningContent: { llm: 'recalled reasoning' },
reasoningFinished: true,
}}
/>,
)
expect(screen.getByText(/chat\.thought/)).toBeInTheDocument()
@@ -151,13 +131,11 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
reasoningContent: { llm: 'human-input reasoning' },
humanInputFormDataList: [pendingForm],
} as unknown as ChatItem
}
item={{
...defaultProps.item,
reasoningContent: { llm: 'human-input reasoning' },
humanInputFormDataList: [pendingForm],
}}
/>,
)
// hasHumanInputs is true, so this can only come from the human-input slot
@@ -170,15 +148,13 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
content: '',
reasoningContent: { llm: 'reload reasoning' },
reasoningFinished: true,
humanInputFilledFormDataList: [filledForm],
} as unknown as ChatItem
}
item={{
...defaultProps.item,
content: '',
reasoningContent: { llm: 'reload reasoning' },
reasoningFinished: true,
humanInputFilledFormDataList: [filledForm],
}}
/>,
)
expect(screen.getByText(/chat\.thought/)).toBeInTheDocument()
@@ -193,12 +169,10 @@ describe('Answer Component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
reasoningContent: {},
} as unknown as ChatItem
}
item={{
...defaultProps.item,
reasoningContent: {},
}}
/>,
)
expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument()
@@ -207,30 +181,28 @@ describe('Answer Component', () => {
describe('Interactions', () => {
it('should handle switch sibling', async () => {
const user = userEvent.setup()
const mockSwitchSibling = vi.fn()
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
siblingCount: 3,
siblingIndex: 1,
prevSibling: 'msg-0',
nextSibling: 'msg-2',
} as unknown as ChatItem
}
item={{
...defaultProps.item,
siblingCount: 3,
siblingIndex: 1,
prevSibling: 'msg-0',
nextSibling: 'msg-2',
}}
switchSibling={mockSwitchSibling}
/>,
)
const prevBtn = screen.getByRole('button', { name: 'Previous' })
await userEvent.setup().click(prevBtn)
await user.click(prevBtn)
expect(mockSwitchSibling).toHaveBeenCalledWith('msg-0')
// reset mock for next sibling click
const nextBtn = screen.getByRole('button', { name: 'Next' })
await userEvent.setup().click(nextBtn)
await user.click(nextBtn)
expect(mockSwitchSibling).toHaveBeenCalledWith('msg-2')
})
})
@@ -238,32 +210,30 @@ describe('Answer Component', () => {
describe('Edge Cases and Props', () => {
it('should render custom answerIcon', () => {
render(
<Answer
{...defaultProps}
answerIcon={<div data-testid="custom-answer-icon">Custom Icon</div>}
/>,
<Answer {...defaultProps} answerIcon={<span role="img" aria-label="Assistant avatar" />} />,
)
expect(screen.getByTestId('custom-answer-icon')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Assistant avatar' })).toBeInTheDocument()
})
it('should render More component', () => {
render(
<Answer
{...defaultProps}
item={
{
...defaultProps.item,
more: { latency: 1.5, tokens: 1200, time: '2026-01-01', tokens_per_second: 20 },
} as unknown as ChatItem
}
item={{
...defaultProps.item,
more: { latency: 1.5, tokens: 1200, time: '2026-01-01', tokens_per_second: 20 },
}}
/>,
)
expect(screen.getByTestId('more-latency')).toHaveTextContent('1.5')
expect(screen.getByTestId('more-tokens')).toHaveTextContent('1,200')
expect(
screen.getByText('appLog.detail.timeConsuming 1.5appLog.detail.second'),
).toBeInTheDocument()
expect(screen.getByText('appLog.detail.tokenCost 1,200')).toBeInTheDocument()
expect(screen.getByText('20 tokens/s')).toBeInTheDocument()
})
})
it('submits the actual human input form using its token and action', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(
<Answer
@@ -273,7 +243,7 @@ describe('Answer Component', () => {
/>,
)
expect(await screen.findByText('Please approve this response.')).toBeInTheDocument()
await userEvent.setup().click(screen.getByRole('button', { name: 'Approve' }))
await user.click(screen.getByRole('button', { name: 'Approve' }))
expect(onSubmit).toHaveBeenCalledExactlyOnceWith('form-token', {
inputs: {},
action: 'approve',
@@ -281,6 +251,7 @@ describe('Answer Component', () => {
})
it('expands a completed human input form to reveal its response and action', async () => {
const user = userEvent.setup()
render(
<Answer
{...defaultProps}
@@ -288,9 +259,7 @@ describe('Answer Component', () => {
/>,
)
expect(screen.queryByText('Approved response')).not.toBeInTheDocument()
await userEvent
.setup()
.click(screen.getByRole('button', { name: /chat.expand Completed review/ }))
await user.click(screen.getByRole('button', { name: /chat.expand Completed review/ }))
expect(await screen.findByText('Approved response')).toBeInTheDocument()
expect(screen.getByTestId('executed-action')).toHaveTextContent(
'workflowHumanInput.nodes.humanInput.userActions.triggered',
@@ -298,6 +267,7 @@ describe('Answer Component', () => {
})
it('switches siblings in the form-only layout without requiring answer text', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(
<Answer
@@ -313,7 +283,7 @@ describe('Answer Component', () => {
switchSibling={switchSibling}
/>,
)
await userEvent.setup().click(screen.getByRole('button', { name: 'Previous' }))
await user.click(screen.getByRole('button', { name: 'Previous' }))
expect(switchSibling).toHaveBeenCalledExactlyOnceWith('previous')
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
})
@@ -321,6 +291,7 @@ describe('Answer Component', () => {
it.each([false, true])(
'respects workflow visibility settings with human inputs = %s',
async (hasHumanInputs) => {
const user = userEvent.setup()
const item: ChatItem = {
...defaultProps.item,
workflowProcess: { status: WorkflowRunningStatus.Running, tracing: [] },
@@ -356,23 +327,18 @@ describe('Answer Component', () => {
expect(
screen.getByRole('img', { name: 'workflow.common.workflowProcessRunning' }),
).toBeInTheDocument()
await userEvent.setup().click(process)
await user.click(process)
expect(process).toHaveAttribute('aria-expanded', 'true')
},
)
it('shows downloadable files from both generated and message attachments', async () => {
const file = {
const file = createChatFileFixture({
id: 'generated',
name: 'generated.pdf',
type: 'application/pdf',
size: 100,
progress: 100,
uploadedId: 'upload',
transferMethod: TransferMethod.local_file,
supportFileType: 'document' as const,
url: 'https://example.com/generated.pdf',
}
})
render(
<Answer
{...defaultProps}
@@ -418,8 +384,6 @@ describe('Answer Component', () => {
expect(screen.queryByText('common.chat.citation.title')).not.toBeInTheDocument()
rerender(<Answer {...defaultProps} item={item} responding={false} />)
expect(screen.getByText('common.chat.citation.title')).toBeInTheDocument()
const expand = screen.queryByRole('button', { name: /chat.expand.*citation.title/ })
if (expand) await user.click(expand)
await user.click(screen.getByRole('button', { name: 'Source.pdf' }))
expect(await screen.findByText('Supporting passage')).toBeInTheDocument()
},
@@ -1,57 +1,46 @@
import type { ChatConfig, ChatItem } from '../../../types'
import type { ComponentProps } from 'react'
import type { ChatItem } from '../../../types'
import type { ChatContextValue } from '../../context'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
import {
createChatThoughtFixture,
createChatConfigFixture as makeChatConfig,
} from '@/test/fixtures/chat'
import { ChatContextProvider } from '../../context-provider'
import Operation from '../operation'
const {
mockSetShowAnnotationFullModal,
mockAddAnnotation,
mockEditAnnotation,
mockGetAudioPlayer,
mockPlayAudio,
} = vi.hoisted(() => {
const { mockAddAnnotation, mockEditAnnotation, mockGetAudioPlayer, mockPlayAudio } = vi.hoisted(
() => {
return {
mockAddAnnotation:
vi.fn<typeof import('@/service/console').consoleClient.apps.byAppId.annotations.post>(),
mockEditAnnotation:
vi.fn<
typeof import('@/service/console').consoleClient.apps.byAppId.annotations.byAnnotationId.post
>(),
mockGetAudioPlayer: vi.fn(),
mockPlayAudio: vi.fn(),
}
},
)
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleClientResponses } = await import('@/test/console/service-responses')
return {
mockAddAnnotation: vi.fn(),
mockSetShowAnnotationFullModal: vi.fn(),
mockEditAnnotation: vi.fn(),
mockGetAudioPlayer: vi.fn(),
mockPlayAudio: vi.fn(),
...actual,
consoleClient: withConsoleClientResponses(actual.consoleClient, {
'apps.byAppId.annotations.post': mockAddAnnotation,
'apps.byAppId.annotations.byAnnotationId.post': mockEditAnnotation,
}),
}
})
const mockCopy = vi.fn()
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: false,
}),
}))
vi.mock('@/app/notifications', () => ({
toast: { success: vi.fn() },
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
}),
}))
vi.mock('@/service/console', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/console')>()),
consoleClient: {
apps: {
byAppId: {
annotations: { post: mockAddAnnotation, byAnnotationId: { post: mockEditAnnotation } },
},
},
},
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: { getInstance: () => ({ getAudioPlayer: mockGetAudioPlayer }) },
}))
@@ -61,33 +50,6 @@ vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(() => '/apps/test-app'),
}))
const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig =>
({
opening_statement: '',
pre_prompt: '',
prompt_type: 'simple' as ChatConfig['prompt_type'],
user_input_form: [],
dataset_query_variable: '',
more_like_this: { enabled: false },
suggested_questions_after_answer: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
agent_mode: { enabled: false, tools: [] },
dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'],
system_parameters: {
audio_file_size_limit: 10,
file_size_limit: 10,
image_file_size_limit: 10,
video_file_size_limit: 10,
workflow_file_upload_limit: 10,
},
supportFeedback: false,
supportAnnotation: false,
...overrides,
}) as ChatConfig
const mockContextValue: ChatContextValue = {
chatList: [],
config: makeChatConfig({ supportFeedback: true }),
@@ -100,16 +62,7 @@ const mockContextValue: ChatContextValue = {
readonly: false,
}
type OperationProps = {
answerActionPosition?: 'auto' | 'below'
item: ChatItem
question: string
index: number
maxSize: number
contentWidth: number
hasWorkflowProcess: boolean
noChatInput?: boolean
}
type OperationProps = ComponentProps<typeof Operation>
const baseItem: ChatItem = {
id: 'msg-1',
@@ -118,16 +71,7 @@ const baseItem: ChatItem = {
}
const createInterruptedItem = (messages: string[]): ChatItem => {
const thought = {
id: '1',
thought: 'internal thought should not be used',
tool: '',
tool_input: '',
observation: '',
message_id: '',
conversation_id: '',
position: 0,
}
const thought = createChatThoughtFixture({ thought: 'internal thought should not be used' })
return {
...baseItem,
@@ -149,6 +93,8 @@ const baseProps: OperationProps = {
hasWorkflowProcess: false,
}
afterEach(() => toast.dismiss())
describe('Operation', () => {
const renderOperation = (props = baseProps) => {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
@@ -160,6 +106,7 @@ describe('Operation', () => {
wrapper: ({ children }) => (
<QueryWrapper>
<ChatContextProvider {...mockContextValue}>{children}</ChatContextProvider>
<AppToastHost timeout={0} />
</QueryWrapper>
),
},
@@ -218,11 +165,8 @@ describe('Operation', () => {
)
it('keeps one pending submission open and prevents edits or duplicate submissions', async () => {
let resolve!: () => void
const pending = new Promise<void>((done) => {
resolve = done
})
const onFeedback = vi.fn().mockReturnValue(pending)
const pending = createDeferred<void>()
const onFeedback = vi.fn().mockReturnValue(pending.promise)
mockContextValue.onFeedback = onFeedback
const user = userEvent.setup()
renderOperation()
@@ -245,8 +189,8 @@ describe('Operation', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(onFeedback).toHaveBeenCalledTimes(1)
await act(async () => {
resolve()
await pending
pending.resolve()
await pending.promise
})
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
@@ -268,9 +212,7 @@ describe('Operation', () => {
name: 'legacy public thought answer',
item: {
...createInterruptedItem([]),
agent_thoughts: [
{ ...createInterruptedItem([]).agent_thoughts![0]!, answer: 'Public legacy answer' },
],
agent_thoughts: [createChatThoughtFixture({ answer: 'Public legacy answer' })],
},
expected: 'Public legacy answer',
},
@@ -278,7 +220,8 @@ describe('Operation', () => {
const user = userEvent.setup()
renderOperation({ ...baseProps, item })
await user.click(screen.getByRole('button', { name: 'common.operation.copy' }))
expect(mockCopy).toHaveBeenCalledExactlyOnceWith(expected)
expect(await navigator.clipboard.readText()).toBe(expected)
expect(screen.getByRole('button', { name: 'common.operation.copied' })).toBeInTheDocument()
})
it('routes regenerate and log actions to their answer', async () => {
@@ -324,8 +267,21 @@ describe('Operation', () => {
name: 'waiting for human input',
item: {
...baseItem,
humanInputFormDataList: [{ node_id: 'human', form_token: 'form-1' }],
} as ChatItem,
humanInputFormDataList: [
{
form_id: 'form-1',
node_id: 'human',
node_title: 'Review',
form_token: 'form-1',
form_content: 'Please review',
inputs: [],
actions: [],
resolved_default_values: {},
display_in_ui: true,
expiration_time: null,
},
],
} satisfies ChatItem,
},
])('hides content actions with $name', ({ item }) => {
mockContextValue.config = makeChatConfig({
@@ -411,6 +367,7 @@ describe('Operation', () => {
mockContextValue.config = makeChatConfig(annotationConfig)
renderOperation({ ...baseProps, item: createInterruptedItem(['Public interrupted answer']) })
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' }))
expect(await screen.findByText('common.api.actionSuccess')).toBeInTheDocument()
expect(mockAddAnnotation).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'test-app' },
body: { message_id: 'msg-1', question: 'What is this?', answer: 'Public interrupted answer' },
@@ -432,12 +389,13 @@ describe('Operation', () => {
renderOperation({ ...baseProps, item: annotatedItem })
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.edit' }))
const drawer = await screen.findByRole('dialog', { name: 'appAnnotation.editModal.title' })
await user.click(within(drawer).getAllByText('common.operation.edit')[1]!)
await user.click(within(drawer).getAllByRole('button', { name: 'common.operation.edit' })[1]!)
await user.type(
within(drawer).getByRole('textbox', { name: 'appAnnotation.editModal.yourAnswer' }),
'Corrected answer',
)
await user.click(within(drawer).getByRole('button', { name: 'common.operation.save' }))
expect(await screen.findByText('common.api.actionSuccess')).toBeInTheDocument()
expect(mockEditAnnotation).toHaveBeenCalledExactlyOnceWith({
params: { app_id: 'test-app', annotation_id: 'ann-1' },
body: { question: 'What is this?', answer: 'Corrected answer' },
@@ -448,7 +406,11 @@ describe('Operation', () => {
0,
)
await user.click(within(drawer).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'appAnnotation.editModal.title' }),
).not.toBeInTheDocument(),
)
})
it('only removes an annotation after confirmation', async () => {
@@ -1,45 +1,41 @@
import { act, render, screen } from '@testing-library/react'
import { act, screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import ReasoningPanel from '../reasoning-panel'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div>{content}</div>,
}))
afterEach(() => vi.useRealTimers())
describe('ReasoningPanel', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('freezes elapsed thinking time after completion even if done flips back', () => {
vi.useFakeTimers()
const { rerender } = render(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
act(() => vi.advanceTimersByTime(700))
expect(screen.getByText(/chat\.thinking\(0\.7s\)/)).toBeInTheDocument()
it('freezes elapsed thinking time after completion even if done flips back', () => {
const { rerender } = render(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
act(() => vi.advanceTimersByTime(700))
expect(screen.getByText(/chat\.thinking\(0\.7s\)/)).toBeInTheDocument()
rerender(<ReasoningPanel content={{ llm: 'thinking' }} done />)
rerender(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByText(/chat\.thought\(0\.7s\)/)).toBeInTheDocument()
})
rerender(<ReasoningPanel content={{ llm: 'thinking' }} done />)
rerender(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByText(/chat\.thought\(0\.7s\)/)).toBeInTheDocument()
})
it('does not start an elapsed timer for historical reasoning', () => {
vi.useFakeTimers()
render(<ReasoningPanel content={{ llm: 'done thinking' }} done />)
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByText(/chat\.thought\(0\.0s\)/)).toBeInTheDocument()
})
it('does not start an elapsed timer for historical reasoning', () => {
render(<ReasoningPanel content={{ llm: 'done thinking' }} done />)
act(() => vi.advanceTimersByTime(1000))
expect(screen.getByText(/chat\.thought\(0\.0s\)/)).toBeInTheDocument()
})
it('updates streaming content in place and keeps different nodes in separate paragraphs', async () => {
const content: Record<string, string> = {}
const { rerender } = render(<ReasoningPanel content={content} done={false} />)
expect(screen.queryByText(/chat\.thinking/)).not.toBeInTheDocument()
it('updates streaming content in place and separates reasoning from different nodes', () => {
const content: Record<string, string> = {}
const { container, rerender } = render(<ReasoningPanel content={content} done={false} />)
expect(container).toBeEmptyDOMElement()
content.llm = 'first'
rerender(<ReasoningPanel content={content} done={false} />)
expect(await screen.findByText('first')).toBeInTheDocument()
content.llm = 'first'
rerender(<ReasoningPanel content={content} done={false} />)
expect(screen.getByText('first')).toBeInTheDocument()
content.llm = 'first delta'
content.second = 'another node'
rerender(<ReasoningPanel content={content} done={false} />)
expect(screen.getByText('first delta another node').textContent).toBe(
'first delta\n\nanother node',
)
})
content.llm = 'first delta'
content.second = 'another node'
rerender(<ReasoningPanel content={content} done={false} />)
expect(await screen.findByText('first delta')).toBeInTheDocument()
expect(screen.getByText('another node')).toBeInTheDocument()
expect(screen.queryByText('first')).not.toBeInTheDocument()
})
@@ -1,5 +1,7 @@
import type { FC } from 'react'
import type { ChatItem } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileResponse } from '@/types/workflow'
import { memo } from 'react'
import Thought from '@/app/components/base/chat/chat/thought'
import { FileList } from '@/app/components/base/file-uploader'
@@ -41,8 +43,14 @@ const AgentContent: FC<AgentContentProps> = ({ item, responding, content }) => {
{!!thought.message_files?.length && (
<FileList
files={getProcessedFilesFromResponse(
thought.message_files.map((item: any) => ({ ...item, related_id: item.id })),
files={thought.message_files.flatMap(
(file: FileEntity | (FileResponse & { id?: string })) =>
// Streaming files are normalized already; historical thoughts can carry API metadata.
'transferMethod' in file
? [file]
: getProcessedFilesFromResponse([
{ ...file, related_id: file.id ?? file.related_id },
]),
)}
showDeleteAction={false}
showDownloadAction={true}
@@ -6,7 +6,7 @@ import { useLocale } from '#i18n'
import ToolDetail from '@/app/components/base/chat/chat/answer/tool-detail'
import { getPluginLanguage, renderI18nObject } from '@/i18n/metadata'
export type IThoughtProps = {
type IThoughtProps = {
thought: ThoughtItem
isFinished: boolean
}
@@ -1,11 +1,12 @@
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import type { ChatConfig } from '../../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import type { FileResponse } from '@/types/workflow'
import { gzipSync } from 'node:zlib'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { AppToastHost } from '@/app/notifications/host'
import { useWebAppStore } from '@/context/web-app-context'
import { consoleQuery } from '@/service/console'
@@ -14,27 +15,20 @@ import {
fetchChatList,
fetchConversations,
generationConversationName,
updateFeedback,
} from '@/service/share'
import * as tryAppService from '@/service/try-app'
import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat'
import { createTestQueryClient } from '@/test/query-client'
import { TransferMethod } from '@/types/app'
import { CONVERSATION_ID_INFO, TAB_CONVERSATION_ID_INFO } from '../../constants'
import { useEmbeddedChatbot } from '../hooks'
type InputForm = {
variable: string
type: string
default?: unknown
required?: boolean
label?: string
max_length?: number
options?: string[]
hide?: boolean
}
vi.mock('@/i18n/client', () => ({
changeLanguage: vi.fn().mockResolvedValue(undefined),
}))
const mockStoreState: {
const webAppState: {
appInfo: AppData | null
appMeta: AppMeta | null
appParams: ChatConfig | null
@@ -62,18 +56,13 @@ vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/share')>()
return {
...actual,
fetchChatList: vi.fn(),
fetchConversations: vi.fn(),
generationConversationName: vi.fn(),
fetchAppInfo: vi.fn(),
fetchAppMeta: vi.fn(),
fetchAppParams: vi.fn(),
getAppAccessModeByAppCode: vi.fn(),
updateFeedback: vi.fn(),
fetchChatList: vi.fn<typeof actual.fetchChatList>(),
fetchConversations: vi.fn<typeof actual.fetchConversations>(),
generationConversationName: vi.fn<typeof actual.generationConversationName>(),
updateFeedback: vi.fn<typeof actual.updateFeedback>(),
}
})
const STABLE_MOCK_DATA = { data: {} }
const createTryAppDetail = (id: string, title: string): TrialAppDetailResponse => ({
id,
name: title,
@@ -82,23 +71,22 @@ const createTryAppDetail = (id: string, title: string): TrialAppDetailResponse =
enable_site: true,
site: { title, default_language: 'en-US' },
})
vi.mock('@/service/use-try-app', () => ({
useGetTryAppParams: vi.fn(() => STABLE_MOCK_DATA),
}))
const seedTryApp = (client: QueryClient, id: string) => {
const input = { params: { app_id: id } }
client.setQueryData(
consoleQuery.trialApps.byAppId.get.queryKey({ input }),
createTryAppDetail(id, 'Try App'),
)
vi.spyOn(tryAppService, 'fetchTryAppParams').mockResolvedValue(
createChatConfigFixture({ opening_statement: 'Try a conversation' }),
)
}
const mockFetchConversations = vi.mocked(fetchConversations)
const mockFetchChatList = vi.mocked(fetchChatList)
const mockGenerationConversationName = vi.mocked(generationConversationName)
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
@@ -109,23 +97,15 @@ const createWrapper = (queryClient: QueryClient) => {
}
const renderWithClient = async <T,>(hook: () => T, setup?: (client: QueryClient) => void) => {
useWebAppStore.setState(mockStoreState)
const queryClient = createQueryClient()
useWebAppStore.setState(webAppState)
const queryClient = createTestQueryClient()
setup?.(queryClient)
const wrapper = createWrapper(queryClient)
let result: ReturnType<typeof renderHook<T, unknown>> | undefined
act(() => {
result = renderHook(hook, { wrapper })
})
await waitFor(
() => {
if (queryClient.isFetching() > 0) throw new Error('Queries are still fetching')
},
{ timeout: 2000 },
)
const result = renderHook(hook, { wrapper })
await waitFor(() => expect(queryClient.isFetching()).toBe(0))
return {
queryClient,
...result!,
...result,
}
}
@@ -146,14 +126,35 @@ const createConversationData = (
...overrides,
})
const createMessageFile = (
id: string,
belongsTo: 'user' | 'assistant',
): FileResponse & {
id: string
belongs_to: 'user' | 'assistant'
} => ({
id,
belongs_to: belongsTo,
related_id: id,
extension: '.txt',
filename: `${id}.txt`,
size: 100,
mime_type: 'text/plain',
transfer_method: TransferMethod.local_file,
type: 'document',
url: `https://example.com/${id}.txt`,
upload_file_id: `upload-${id}`,
remote_url: '',
})
describe('useEmbeddedChatbot', () => {
beforeEach(() => {
vi.clearAllMocks()
window.history.replaceState({}, '', '/')
mockGenerationConversationName.mockReset()
vi.mocked(updateFeedback).mockReset().mockResolvedValue({})
localStorage.removeItem(CONVERSATION_ID_INFO)
sessionStorage.removeItem(TAB_CONVERSATION_ID_INFO)
mockStoreState.appInfo = {
webAppState.appInfo = {
app_id: 'app-1',
end_user_id: 'user-1',
custom_config: null,
@@ -162,14 +163,14 @@ describe('useEmbeddedChatbot', () => {
default_language: 'en-US',
},
}
mockStoreState.appMeta = {
webAppState.appMeta = {
tool_icons: {},
}
mockStoreState.appParams = null
mockStoreState.embeddedConversationId = 'conversation-1'
mockStoreState.embeddedUserId = 'embedded-user-1'
mockFetchConversations.mockResolvedValue({ data: [], has_more: false, limit: 100 })
mockFetchChatList.mockResolvedValue({ data: [] })
webAppState.appParams = null
webAppState.embeddedConversationId = 'conversation-1'
webAppState.embeddedUserId = 'embedded-user-1'
mockFetchConversations.mockReset().mockResolvedValue({ data: [], has_more: false, limit: 100 })
mockFetchChatList.mockReset().mockResolvedValue({ data: [] })
})
afterEach(() => {
@@ -228,8 +229,7 @@ describe('useEmbeddedChatbot', () => {
})
it('should format chat list history correctly into appPrevChatList', async () => {
// Provide a currentConversationId by rendering successfully
mockStoreState.embeddedConversationId = 'conversation-1'
webAppState.embeddedConversationId = 'conversation-1'
setSystemVariables({
conversation_id: 'conversation-1',
})
@@ -240,10 +240,10 @@ describe('useEmbeddedChatbot', () => {
query: 'Hello',
answer: 'Hi there!',
message_files: [
{ belongs_to: 'user', id: 'mf-1' },
{ belongs_to: 'assistant', id: 'mf-2' },
createMessageFile('mf-1', 'user'),
createMessageFile('mf-2', 'assistant'),
],
agent_thoughts: [{ id: 'at-1' }],
agent_thoughts: [],
feedback: { rating: 'like' },
},
],
@@ -251,7 +251,6 @@ describe('useEmbeddedChatbot', () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Wait for the mock to be called
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith(
'conversation-1',
@@ -260,12 +259,10 @@ describe('useEmbeddedChatbot', () => {
)
})
// Wait for the chat list to be populated
await waitFor(() => {
expect(result.current.appPrevChatList.length).toBeGreaterThan(0)
})
// We expect the formatting logic to split the message into question and answer ChatItems
const chatList = result.current.appPrevChatList
expect(chatList).toHaveLength(1)
@@ -388,25 +385,19 @@ describe('useEmbeddedChatbot', () => {
describe('TryApp mode', () => {
it('should use tryApp source type and skip URL overrides and user fetch', async () => {
const mockTryAppInfo = createTryAppDetail('try-app-1', 'Try App')
mockStoreState.embeddedConversationId = null
webAppState.embeddedConversationId = null
setSystemVariables({ conversation_id: 'web-only-conversation' })
const { result } = await renderWithClient(
() => useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1'),
(client) => {
const key = consoleQuery.trialApps.byAppId.get.queryKey({
input: { params: { app_id: 'try-app-1' } },
})
client.setQueryDefaults(key, { staleTime: Infinity })
client.setQueryData(key, mockTryAppInfo)
},
(client) => seedTryApp(client, 'try-app-1'),
)
expect(result.current.isInstalledApp).toBe(false)
expect(result.current.appId).toBe('try-app-1')
expect(result.current.appData?.site.title).toBe('Try App')
expect(result.current.appParams.opening_statement).toBe('Try a conversation')
expect(result.current.allowResetChat).toBe(true)
})
@@ -414,7 +405,6 @@ describe('useEmbeddedChatbot', () => {
describe('removeConversationIdInfo', () => {
it('should successfully remove a stored conversation ID info by appId', async () => {
// Setup some initial info
localStorage.setItem(
CONVERSATION_ID_INFO,
JSON.stringify({ 'app-1': { 'user-1': 'conv-id' } }),
@@ -442,53 +432,76 @@ describe('useEmbeddedChatbot', () => {
})
describe('inputsForms mapping and default parsing', () => {
const mockAppParamsWithInputs = {
const appParamsWithInputs = createChatConfigFixture({
user_input_form: [
{ paragraph: { variable: 'p1', default: 'para', max_length: 5 } },
{ number: { variable: 'n1', default: 42 } },
{ checkbox: { variable: 'c1', default: true } },
{ select: { variable: 's1', options: ['A', 'B'], default: 'A' } },
{ 'file-list': { variable: 'fl1' } },
{ file: { variable: 'f1' } },
{ json_object: { variable: 'j1' } },
{ 'text-input': { variable: 't1', default: 'txt', max_length: 3 } },
{
paragraph: {
variable: 'p1',
default: 'para',
max_length: 5,
label: 'p1',
required: false,
},
},
{ number: { variable: 'n1', default: 42, label: 'n1', required: false } },
{ checkbox: { variable: 'c1', default: true, label: 'c1', required: false } },
{
select: {
variable: 's1',
options: ['A', 'B'],
default: 'A',
label: 's1',
required: false,
},
},
{ 'file-list': { variable: 'fl1', label: 'fl1', required: false } },
{ file: { variable: 'f1', label: 'f1', required: false } },
{ json_object: { variable: 'j1', label: 'j1', required: false } },
{
'text-input': {
variable: 't1',
default: 'txt',
max_length: 3,
label: 't1',
required: false,
},
},
],
}
})
it('should map various types properly with max_length truncation when defaults supplied via URL', async () => {
setUrlInputs({
p1: 'toolongparagraph', // truncated to 5
n1: '99',
c1: 'true',
s1: 'B', // Matches options
t1: '1234', // truncated to 3
})
mockStoreState.appParams = mockAppParamsWithInputs as unknown as ChatConfig
webAppState.appParams = appParamsWithInputs
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => {
expect(
result.current.inputsForms.find((form: InputForm) => form.variable === 'n1')?.default,
).toBe(99)
expect(result.current.inputsForms.find((form) => form.variable === 'n1')?.default).toBe(99)
})
const forms = result.current.inputsForms
expect(forms.find((f: InputForm) => f.variable === 'p1')?.default).toBe('toolo')
expect(forms.find((f: InputForm) => f.variable === 'n1')?.default).toBe(99)
expect(forms.find((f: InputForm) => f.variable === 'c1')?.default).toBe(true)
expect(forms.find((f: InputForm) => f.variable === 's1')?.default).toBe('B')
expect(forms.find((f: InputForm) => f.variable === 't1')?.default).toBe('123')
expect(forms.find((f: InputForm) => f.variable === 'fl1')?.type).toBe('file-list')
expect(forms.find((f: InputForm) => f.variable === 'f1')?.type).toBe('file')
expect(forms.find((f: InputForm) => f.variable === 'j1')?.type).toBe('json_object')
expect(forms.find((f) => f.variable === 'p1')?.default).toBe('toolo')
expect(forms.find((f) => f.variable === 'n1')?.default).toBe(99)
expect(forms.find((f) => f.variable === 'c1')?.default).toBe(true)
expect(forms.find((f) => f.variable === 's1')?.default).toBe('B')
expect(forms.find((f) => f.variable === 't1')?.default).toBe('123')
expect(forms.find((f) => f.variable === 'fl1')?.type).toBe('file-list')
expect(forms.find((f) => f.variable === 'f1')?.type).toBe('file')
expect(forms.find((f) => f.variable === 'j1')?.type).toBe('json_object')
})
it('should preserve numeric zero supplied via URL params', async () => {
setUrlInputs({ n1: '0' })
mockStoreState.appParams = {
user_input_form: [{ number: { variable: 'n1', default: 10 } }],
} as unknown as ChatConfig
webAppState.appParams = createChatConfigFixture({
user_input_form: [
{ number: { variable: 'n1', default: 10, label: 'n1', required: false } },
],
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -497,16 +510,16 @@ describe('useEmbeddedChatbot', () => {
})
await waitFor(() => expect(result.current.inputsForms[0].default).toBe(0))
await waitFor(() => {
expect(result.current.newConversationInputsRef.current.n1).toBe(0)
expect(result.current.newConversationInputs.n1).toBe(0)
})
})
})
describe('checkInputsRequired and handleStartChat', () => {
it('should fail checkInputsRequired when required fields are missing', async () => {
mockStoreState.appParams = {
webAppState.appParams = createChatConfigFixture({
user_input_form: [{ 'text-input': { variable: 't1', required: true, label: 'T1' } }],
} as unknown as ChatConfig
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -524,9 +537,9 @@ describe('useEmbeddedChatbot', () => {
})
it('should accept numeric zero for a required number input', async () => {
mockStoreState.appParams = {
webAppState.appParams = createChatConfigFixture({
user_input_form: [{ number: { variable: 'n1', required: true, label: 'N1' } }],
} as unknown as ChatConfig
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -553,16 +566,10 @@ describe('useEmbeddedChatbot', () => {
expect(result.current.clearChatList).toBe(true)
})
it('handleNewConversation sets clearChatList to true for tryApp without complex parsing', async () => {
it('clears the trial conversation when starting a new chat', async () => {
const { result } = await renderWithClient(
() => useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1'),
(client) => {
const key = consoleQuery.trialApps.byAppId.get.queryKey({
input: { params: { app_id: 'app-try-1' } },
})
client.setQueryDefaults(key, { staleTime: Infinity })
client.setQueryData(key, createTryAppDetail('app-try-1', 'Try App'))
},
(client) => seedTryApp(client, 'app-try-1'),
)
await act(async () => {
@@ -573,7 +580,7 @@ describe('useEmbeddedChatbot', () => {
})
it('handleChangeConversation updates current conversation and refetches chat list', async () => {
mockStoreState.embeddedConversationId = null
webAppState.embeddedConversationId = null
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
@@ -601,7 +608,7 @@ describe('useEmbeddedChatbot', () => {
'app-1': { 'embedded-user-1': 'stored-conv-id' },
}),
)
mockStoreState.embeddedConversationId = 'url-conv-id'
webAppState.embeddedConversationId = 'url-conv-id'
setSystemVariables({
user_id: 'embedded-user-1',
conversation_id: 'url-conv-id',
@@ -621,8 +628,8 @@ describe('useEmbeddedChatbot', () => {
'app-1': { 'user-1': 'stored-conv-id' },
}),
)
mockStoreState.embeddedConversationId = null
mockStoreState.embeddedUserId = null
webAppState.embeddedConversationId = null
webAppState.embeddedUserId = null
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -632,7 +639,6 @@ describe('useEmbeddedChatbot', () => {
})
it('handleFeedback invokes updateFeedback service successfully', async () => {
const { updateFeedback } = await import('@/service/share')
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await act(async () => {
@@ -672,13 +678,15 @@ describe('useEmbeddedChatbot', () => {
it('should fall back to app default language', async () => {
setSystemVariables({})
mockStoreState.appInfo = {
webAppState.appInfo = {
app_id: 'app-1',
site: {
title: 'Test App',
default_language: 'ja-JP',
},
} as unknown as AppData
custom_config: null,
end_user_id: 'user-1',
}
const { changeLanguage } = await import('@/i18n/client')
await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -687,37 +695,48 @@ describe('useEmbeddedChatbot', () => {
})
})
describe('Additional Input Form Edges', () => {
it('should handle invalid number inputs and checkbox defaults', async () => {
mockStoreState.appParams = {
it.each([
{
name: 'number',
form: { number: { variable: 'value', label: 'Number', required: false, default: 10 } },
value: 'not-a-number',
expected: 10,
},
{
name: 'select',
form: {
select: {
variable: 'value',
label: 'Choice',
required: false,
options: ['A'],
default: 'A',
},
},
value: 'INVALID',
expected: 'A',
},
])(
'keeps the configured $name default after decoding an invalid URL value',
async ({ form, value, expected }) => {
webAppState.appParams = createChatConfigFixture({
user_input_form: [
{ number: { variable: 'n1', default: 10 } },
{ checkbox: { variable: 'c1', default: false } },
form,
{ 'text-input': { variable: 'marker', label: 'Marker', required: false } },
],
} as unknown as ChatConfig
setUrlInputs({
n1: 'not-a-number',
c1: 'true',
})
setUrlInputs({ value, marker: 'decoded' })
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const forms = result.current.inputsForms
expect(forms.find((f) => f.variable === 'n1')?.default).toBe(10)
expect(forms.find((f) => f.variable === 'c1')?.default).toBe(false)
})
it('should handle select with invalid option and file-list/json types', async () => {
mockStoreState.appParams = {
user_input_form: [{ select: { variable: 's1', options: ['A'], default: 'A' } }],
} as unknown as ChatConfig
setUrlInputs({
s1: 'INVALID',
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
expect(result.current.inputsForms[0].default).toBe('A')
})
})
await waitFor(() =>
expect(result.current.newConversationInputs).toEqual({
value: expected,
marker: 'decoded',
}),
)
},
)
describe('handleConversationIdInfoChange logic', () => {
it('should handle existing appId as string and update it to object', async () => {
@@ -736,7 +755,7 @@ describe('useEmbeddedChatbot', () => {
})
it('should use the site EndUser when embeddedUserId is null', async () => {
mockStoreState.embeddedUserId = null
webAppState.embeddedUserId = null
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
@@ -753,12 +772,9 @@ describe('useEmbeddedChatbot', () => {
describe('allInputsHidden and no required variables', () => {
it('should pass checkInputsRequired immediately when there are no required fields', async () => {
mockStoreState.appParams = {
user_input_form: [
// All optional (not required)
{ 'text-input': { variable: 't1', required: false, label: 'T1' } },
],
} as unknown as ChatConfig
webAppState.appParams = createChatConfigFixture({
user_input_form: [{ 'text-input': { variable: 't1', required: false, label: 'T1' } }],
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -770,12 +786,12 @@ describe('useEmbeddedChatbot', () => {
})
it('should pass checkInputsRequired when all inputs are hidden', async () => {
mockStoreState.appParams = {
webAppState.appParams = createChatConfigFixture({
user_input_form: [
{ 'text-input': { variable: 't1', required: true, label: 'T1', hide: true } },
{ 'text-input': { variable: 't2', required: true, label: 'T2', hide: true } },
],
} as unknown as ChatConfig
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
@@ -789,25 +805,22 @@ describe('useEmbeddedChatbot', () => {
})
})
describe('checkInputsRequired silent mode and multi-file', () => {
describe('Required file inputs', () => {
it('should handle multi-file uploading status', async () => {
mockStoreState.appParams = {
user_input_form: [
{ 'file-list': { variable: 'files', required: true, type: InputVarType.multiFiles } },
],
} as unknown as ChatConfig
webAppState.appParams = createChatConfigFixture({
user_input_form: [{ 'file-list': { variable: 'files', required: true, label: 'files' } }],
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
result.current.handleNewConversationInputsChange({
files: [
{ transferMethod: TransferMethod.local_file, uploadedId: 'ok' },
{ transferMethod: TransferMethod.local_file, uploadedId: null },
createChatFileFixture({ id: 'file-1', uploadedId: 'ok' }),
createChatFileFixture({ id: 'file-2', uploadedId: undefined, progress: 30 }),
],
})
})
// handleStartChat returns void, but we just verify no callback fires (file upload pending)
const onStart = vi.fn()
act(() => {
result.current.handleStartChat(onStart)
@@ -816,8 +829,8 @@ describe('useEmbeddedChatbot', () => {
act(() => {
result.current.handleNewConversationInputsChange({
files: [
{ transferMethod: TransferMethod.local_file, uploadedId: 'ok' },
{ transferMethod: TransferMethod.local_file, uploadedId: 'finished' },
createChatFileFixture({ id: 'file-1', uploadedId: 'ok' }),
createChatFileFixture({ id: 'file-2', uploadedId: 'finished' }),
],
})
result.current.handleStartChat(onStart)
@@ -826,15 +839,14 @@ describe('useEmbeddedChatbot', () => {
})
it('should detect single-file upload still in progress', async () => {
mockStoreState.appParams = {
webAppState.appParams = createChatConfigFixture({
user_input_form: [{ file: { variable: 'f1', required: true, label: 'Attachment' } }],
} as unknown as ChatConfig
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
// Single file (not array) transfer that hasn't finished uploading
result.current.handleNewConversationInputsChange({
f1: { transferMethod: TransferMethod.local_file, uploadedId: null },
f1: createChatFileFixture({ id: 'file-2', uploadedId: undefined, progress: 30 }),
})
})
@@ -848,15 +860,13 @@ describe('useEmbeddedChatbot', () => {
describe('currentConversationItem from pinned list', () => {
it('should find currentConversationItem from pinned list when not in main list', async () => {
mockStoreState.embeddedConversationId = null
webAppState.embeddedConversationId = null
const pinnedData = createConversationData({
data: [createConversationItem({ id: 'pinned-conv', name: 'Pinned' })],
})
mockFetchConversations.mockImplementation(
async (_a: unknown, _b: unknown, _c: unknown, pinned?: boolean) => {
return pinned ? pinnedData : createConversationData({ data: [] })
},
)
mockFetchConversations.mockImplementation(async (_appSourceType, _appId, _lastId, pinned) => {
return pinned ? pinnedData : createConversationData({ data: [] })
})
mockFetchChatList.mockResolvedValue({ data: [] })
localStorage.setItem(
CONVERSATION_ID_INFO,
@@ -865,18 +875,7 @@ describe('useEmbeddedChatbot', () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(
() => {
expect(result.current.pinnedConversationList.length).toBeGreaterThan(0)
},
{ timeout: 3000 },
)
await waitFor(
() => {
expect(result.current.currentConversationItem?.id).toBe('pinned-conv')
},
{ timeout: 3000 },
)
await waitFor(() => expect(result.current.currentConversationItem?.id).toBe('pinned-conv'))
})
})
@@ -907,7 +906,7 @@ describe('useEmbeddedChatbot', () => {
describe('currentConversationLatestInputs', () => {
it('should return inputs from latest chat message when conversation has data', async () => {
mockStoreState.embeddedConversationId = null
webAppState.embeddedConversationId = null
const convId = 'conversation-with-inputs'
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { 'user-1': convId } }))
mockFetchConversations.mockResolvedValue(
@@ -919,15 +918,10 @@ describe('useEmbeddedChatbot', () => {
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => expect(result.current.currentConversationItem?.id).toBe(convId), {
timeout: 3000,
await waitFor(() => {
expect(result.current.currentConversationItem?.id).toBe(convId)
expect(result.current.currentConversationInputs).toEqual({ key1: 'val1' })
})
await waitFor(
() => expect(result.current.currentConversationInputs).toEqual({ key1: 'val1' }),
{
timeout: 3000,
},
)
})
})
})
@@ -25,10 +25,11 @@ describe('checkbox list component', () => {
})
it('filters options by label', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
const input = getSearchInput()
await userEvent.type(input, 'app')
await user.type(input, 'app')
expect(screen.getByText('Apple'))!.toBeInTheDocument()
expect(screen.queryByText('Option 2')).not.toBeInTheDocument()
@@ -36,28 +37,31 @@ describe('checkbox list component', () => {
})
it('selects all options when select-all is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll />)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await userEvent.click(selectAll)
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
})
it('does not select all options when select-all is clicked when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} disabled showSelectAll onChange={onChange} />)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
await userEvent.click(selectAll)
await user.click(selectAll)
expect(onChange).not.toHaveBeenCalled()
})
it('deselects all options when select-all is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
@@ -71,28 +75,31 @@ describe('checkbox list component', () => {
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
expect(selectAll).toBeChecked()
await userEvent.click(selectAll)
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith([])
})
it('hides select-all checkbox when searching', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
await userEvent.type(getSearchInput(), 'app')
await user.type(getSearchInput(), 'app')
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} showSelectAll={false} />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await userEvent.click(selectOption)
await user.click(selectOption)
expect(onChange).toHaveBeenCalledWith(['option1'])
})
it('deselects options when checkbox is clicked when selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
@@ -105,34 +112,37 @@ describe('checkbox list component', () => {
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await userEvent.click(selectOption)
await user.click(selectOption)
expect(onChange).toHaveBeenCalledWith([])
})
it('does not select options when checkbox is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} disabled />)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
await userEvent.click(selectOption)
await user.click(selectOption)
expect(onChange).not.toHaveBeenCalled()
})
it('Reset button works', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<CheckboxList options={options} value={[]} onChange={onChange} />)
const input = getSearchInput()
await userEvent.type(input, 'ban')
await userEvent.click(screen.getByText('common.operation.resetKeywords'))
await user.type(input, 'ban')
await user.click(screen.getByText('common.operation.resetKeywords'))
expect(input).toHaveValue('')
expect(screen.getByRole('checkbox', { name: 'Apple' })).toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
it('does not toggle disabled option when clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const disabledOptions = [
{ label: 'Enabled', value: 'enabled' },
@@ -142,7 +152,7 @@ describe('checkbox list component', () => {
render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />)
const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' })
await userEvent.click(disabledCheckbox)
await user.click(disabledCheckbox)
expect(onChange).not.toHaveBeenCalled()
})
@@ -163,6 +173,7 @@ describe('checkbox list component', () => {
})
it('shows indeterminate state when some options are selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<CheckboxList
@@ -177,35 +188,38 @@ describe('checkbox list component', () => {
expect(selectAll)!.toBeInTheDocument()
expect(selectAll)!.toHaveAttribute('aria-checked', 'mixed')
await userEvent.click(selectAll)
await user.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
})
it('shows no data message when no options match search', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} />)
const input = getSearchInput()
await userEvent.type(input, 'xyz')
await user.type(input, 'xyz')
expect(screen.getByText(/common.operation.noSearchResults/i))!.toBeInTheDocument()
})
it('does not toggle when clicking disabled option row', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const disabledOptions = [{ label: 'Option 1', value: 'option1', disabled: true }]
render(<CheckboxList options={disabledOptions} value={[]} onChange={onChange} />)
await userEvent.click(screen.getByText('Option 1'))
await user.click(screen.getByText('Option 1'))
expect(onChange).not.toHaveBeenCalled()
})
it('shows correct filtered count message when searching', async () => {
const user = userEvent.setup()
render(<CheckboxList options={options} title="Items" />)
const input = getSearchInput()
await userEvent.type(input, 'opt')
await user.type(input, 'opt')
expect(screen.getByText(/operation.searchCount/i)).toHaveTextContent('3')
})
@@ -166,6 +166,7 @@ describe('ErrorBoundary', () => {
})
it('should reset and render children when Try Again is clicked', async () => {
const user = userEvent.setup()
const onReset = vi.fn()
const RecoveryHarness = () => {
@@ -183,13 +184,14 @@ describe('ErrorBoundary', () => {
}
render(<RecoveryHarness />)
await userEvent.setup().click(await screen.findByRole('button', { name: 'Try Again' }))
await user.click(await screen.findByRole('button', { name: 'Try Again' }))
await screen.findByText('Child content rendered')
expect(onReset).toHaveBeenCalledTimes(1)
})
it('should reset after resetKeys change when boundary is in error state', async () => {
const user = userEvent.setup()
const ResetKeysHarness = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
const [boundaryKey, setBoundaryKey] = React.useState(0)
@@ -214,7 +216,7 @@ describe('ErrorBoundary', () => {
render(<ResetKeysHarness />)
await screen.findByText('Something went wrong')
await userEvent.setup().click(screen.getByRole('button', { name: 'Recover with keys' }))
await user.click(screen.getByRole('button', { name: 'Recover with keys' }))
await waitFor(() => {
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
@@ -222,6 +224,7 @@ describe('ErrorBoundary', () => {
})
it('should not reset when resetKeys reference changes but values are identical', async () => {
const user = userEvent.setup()
const onReset = vi.fn()
const StableKeysHarness = () => {
@@ -239,7 +242,7 @@ describe('ErrorBoundary', () => {
render(<StableKeysHarness />)
await screen.findByText('Something went wrong')
await userEvent.setup().click(screen.getByRole('button', { name: 'Update keys same values' }))
await user.click(screen.getByRole('button', { name: 'Update keys same values' }))
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
@@ -248,6 +251,7 @@ describe('ErrorBoundary', () => {
})
it('should reset after children change when resetOnPropsChange is true', async () => {
const user = userEvent.setup()
const ResetOnPropsHarness = () => {
const [shouldThrow, setShouldThrow] = React.useState(true)
const [childLabel, setChildLabel] = React.useState('first child')
@@ -272,7 +276,7 @@ describe('ErrorBoundary', () => {
render(<ResetOnPropsHarness />)
await screen.findByText('Something went wrong')
await userEvent.setup().click(screen.getByRole('button', { name: 'Replace children' }))
await user.click(screen.getByRole('button', { name: 'Replace children' }))
await waitFor(() => {
expect(screen.getByText('second child')).toBeInTheDocument()
@@ -280,6 +284,7 @@ describe('ErrorBoundary', () => {
})
it('should call window.location.reload when Reload Page is clicked', async () => {
const user = userEvent.setup()
const reloadSpy = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadSpy })
@@ -289,7 +294,7 @@ describe('ErrorBoundary', () => {
</ErrorBoundary>,
)
await userEvent.setup().click(await screen.findByRole('button', { name: 'Reload Page' }))
await user.click(await screen.findByRole('button', { name: 'Reload Page' }))
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
@@ -61,6 +61,7 @@ describe('FeatureBar', () => {
})
it('should call onFeatureBarClick when manage button is clicked', async () => {
const user = userEvent.setup()
const onFeatureBarClick = vi.fn()
renderWithProvider(
@@ -69,7 +70,7 @@ describe('FeatureBar', () => {
moreLikeThis: { enabled: true },
},
)
await userEvent.click(screen.getByRole('button', { name: /feature\.bar\.manage/ }))
await user.click(screen.getByRole('button', { name: /feature\.bar\.manage/ }))
expect(onFeatureBarClick).toHaveBeenCalledWith(true)
})
@@ -125,13 +126,14 @@ describe('FeatureBar', () => {
it.each([false, true])(
'prevents opening feature management while disabled (enabled features: %s)',
async (enabled) => {
const user = userEvent.setup()
const onFeatureBarClick = vi.fn()
renderWithProvider({ disabled: true, onFeatureBarClick }, { moreLikeThis: { enabled } })
const action = screen.getByRole('button', {
name: enabled ? /feature\.bar\.manage/ : /feature\.bar\.empty/,
})
expect(action).toBeDisabled()
await userEvent.click(action)
await user.click(action)
expect(onFeatureBarClick).not.toHaveBeenCalled()
},
)
@@ -1,65 +1,76 @@
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { FollowUpSettingsDialog } from '../follow-up-setting-modal'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: {
provider: {
provider: 'openai',
const render = createNuqsTestRenderer((ui) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'gpt-4o-mini',
label: { en_US: 'GPT-4o mini' },
model_properties: { mode: 'chat' },
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'llm' } },
}),
{ data: createDefaultModelFixture({ model: 'gpt-4o-mini' }) },
)
queryClient.setQueryDefaults(
consoleQuery.workspaces.current.modelProviders.byProvider.models.parameterRules.get.key(),
{
initialData: {
data: [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
},
{
name: 'max_tokens',
label: { en_US: 'Max Tokens' },
type: 'int',
default: 256,
min: 0,
max: 4096,
},
],
},
model: 'gpt-4o-mini',
},
}),
}))
)
return renderWithConsoleQuery(ui, { queryClient })
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: ({
provider,
modelId,
completionParams,
onCompletionParamsChange,
}: {
provider: string
modelId: string
completionParams: FormValue
onCompletionParamsChange: (newParams: FormValue) => void
}) => {
const hasMaxTokens = 'max_tokens' in completionParams
return (
<div data-testid="model-parameter-modal">
{`${provider}:${modelId}`}
<button
type="button"
role="switch"
aria-checked={hasMaxTokens}
onClick={() => {
const { max_tokens: _maxTokens, ...remainingParams } = completionParams
onCompletionParamsChange(remainingParams)
}}
>
Max Tokens
</button>
</div>
)
},
}),
)
const renderModal = async (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => {
const renderModal = async (
user: ReturnType<typeof userEvent.setup>,
data: SuggestedQuestionsAfterAnswer = { enabled: true },
) => {
const onSave = vi.fn()
render(<FollowUpSettingsDialog data={data} onSave={onSave} />)
await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.settings' }))
const view = render(<FollowUpSettingsDialog data={data} onSave={onSave} />)
await user.click(screen.getByRole('button', { name: 'common.operation.settings' }))
return {
onSave,
...view,
}
}
@@ -72,7 +83,7 @@ describe('FollowUpSettingModal', () => {
describe('Model Parameters', () => {
it('should keep max tokens disabled after its switch is turned off', async () => {
const user = userEvent.setup()
const { onSave } = await renderModal({
const { onSave, rerender } = await renderModal(user, {
enabled: true,
model: {
provider: 'openai',
@@ -90,17 +101,25 @@ describe('FollowUpSettingModal', () => {
},
})
await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
const maxTokensSwitch = screen.getByRole('switch', { name: 'Max Tokens' })
await user.click(maxTokensSwitch)
expect(maxTokensSwitch).toHaveAttribute('aria-checked', 'false')
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave.mock.lastCall![0].model.completion_params).not.toHaveProperty('max_tokens')
expect(onSave.mock.lastCall![0].model.completion_params.temperature).toBe(0.7)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
rerender(<FollowUpSettingsDialog data={onSave.mock.lastCall![0]} onSave={onSave} />)
await user.click(screen.getByRole('button', { name: 'common.operation.settings' }))
await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
expect(screen.getByRole('switch', { name: 'Max Tokens' })).not.toBeChecked()
})
it('should keep max tokens disabled when saved model parameters omit it', async () => {
await renderModal({
const user = userEvent.setup()
await renderModal(user, {
enabled: true,
model: {
provider: 'openai',
@@ -108,10 +127,11 @@ describe('FollowUpSettingModal', () => {
mode: 'chat',
completion_params: {
temperature: 0.7,
} as CompletionParams,
},
},
})
await user.click(screen.getByRole('button', { name: /modelProvider.modelSettings/ }))
expect(screen.getByRole('switch', { name: 'Max Tokens' })).toHaveAttribute(
'aria-checked',
'false',
@@ -122,7 +142,7 @@ describe('FollowUpSettingModal', () => {
describe('Default Prompt', () => {
it('should show the system default prompt and save without a custom prompt when no custom prompt is configured', async () => {
const user = userEvent.setup()
const { onSave } = await renderModal()
const { onSave } = await renderModal(user)
expect(
screen.getByText(
@@ -152,7 +172,7 @@ describe('FollowUpSettingModal', () => {
describe('Custom Prompt', () => {
it('should enable custom prompt input and save the custom prompt when selected', async () => {
const user = userEvent.setup()
const { onSave } = await renderModal()
const { onSave } = await renderModal(user)
const custom = screen.getByRole('radio', { name: /modal.customPromptOption/ })
expect(custom).toHaveAttribute('aria-checked', 'false')
@@ -1,27 +1,26 @@
import { Button } from '@langgenius/dify-ui/button'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { FeaturesProvider } from '../../context'
import { FollowUp } from '../follow-up'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: undefined }),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div>Model selector</div>,
}),
)
describe('Follow-up settings keyboard access', () => {
it('reveals the persistent trigger with keyboard focus and restores visible focus after closing', async () => {
// Native focus and CSS visibility prove the card remains usable without hovering it.
const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper()
seedModelProviderQueries(queryClient)
const screen = await render(
<FeaturesProvider features={{ suggested: { enabled: true } }}>
<Button>Before feature</Button>
<FollowUp />
</FeaturesProvider>,
<NuqsTestingAdapter>
<QueryProvider>
<FeaturesProvider features={{ suggested: { enabled: true } }}>
<Button>Before feature</Button>
<FollowUp />
</FeaturesProvider>
</QueryProvider>
</NuqsTestingAdapter>,
)
await screen.getByRole('button', { name: 'Before feature' }).click()
await userEvent.tab()
@@ -1,18 +1,17 @@
import type { OnFeaturesChange, SuggestedQuestionsAfterAnswer } from '../../types'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import { FeaturesProvider } from '../../context'
import { FollowUp } from '../follow-up'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: undefined }),
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div>Model selector</div>,
}),
)
const render = createNuqsTestRenderer((ui) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
return renderWithConsoleQuery(ui, { queryClient })
})
function renderWithProvider(
props: {
@@ -1,7 +1,14 @@
import type { Features } from '../../types'
import { screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { FeaturesProvider } from '../../context'
import NewFeaturePanel from '../index'
@@ -21,25 +28,6 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/app/test-app-id/configuration',
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: (type: string) => {
if (type === 'speech2text' || type === 'tts')
return { data: { provider: 'openai', model: 'whisper-1' } }
return { data: null }
},
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: [
{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] },
],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
currentModel: true,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: () => <div data-testid="model-selector">Model Selector</div>,
}))
const defaultFeatures: Features = {
moreLikeThis: { enabled: false },
opening: { enabled: false },
@@ -65,6 +53,23 @@ const renderPanel = (
}> = {},
searchParams = '',
) => {
const queryClient = createConsoleQueryClient()
const models = [
createCatalogModelFixture({ model: 'text-embedding-ada-002', model_type: 'text-embedding' }),
createCatalogModelFixture({ model: 'whisper-1', model_type: 'speech2text' }),
createCatalogModelFixture({ model: 'tts-1', model_type: 'tts' }),
]
seedModelProviderQueries(queryClient, [createModelCatalogFixture({ models })])
for (const model of models) {
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: model.model_type } },
}),
{
data: createDefaultModelFixture({ model: model.model, model_type: model.model_type }),
},
)
}
return renderWithConsoleQuery(
<NuqsTestingAdapter searchParams={searchParams}>
<FeaturesProvider features={defaultFeatures}>
@@ -80,6 +85,7 @@ const renderPanel = (
/>
</FeaturesProvider>
</NuqsTestingAdapter>,
{ queryClient },
)
}
@@ -48,6 +48,7 @@ describe('AnnotationCtrlButton', () => {
})
it('should call onEdit when edit button is clicked', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
render(
<AnnotationCtrlButton
@@ -60,9 +61,7 @@ describe('AnnotationCtrlButton', () => {
/>,
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.edit' }))
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.edit' }))
expect(onEdit).toHaveBeenCalled()
})
@@ -83,6 +82,7 @@ describe('AnnotationCtrlButton', () => {
})
it('should call addAnnotation and onAdded when add button is clicked', async () => {
const user = userEvent.setup()
const onAdded = vi.fn()
render(
<AnnotationCtrlButton
@@ -96,9 +96,7 @@ describe('AnnotationCtrlButton', () => {
/>,
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' }))
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' }))
await waitFor(() => {
expect(mockAddAnnotation).toHaveBeenCalledWith({
@@ -110,6 +108,7 @@ describe('AnnotationCtrlButton', () => {
})
it('should show annotation full modal when annotation limit is reached', async () => {
const user = userEvent.setup()
mockAnnotatedResponseUsage = 100
render(
@@ -123,9 +122,7 @@ describe('AnnotationCtrlButton', () => {
/>,
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' }))
await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.add' }))
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
expect(mockAddAnnotation).not.toHaveBeenCalled()
@@ -1,41 +1,46 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import {
createCatalogModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createNuqsTestRenderer } from '@/test/nuqs-testing'
import ConfigParamModal from '../config-param-modal'
let mockHooksReturn: {
modelList: { provider: { provider: string }; models: { model: string }[] }[]
defaultModel: { provider: { provider: string }; model: string } | undefined
currentModel: boolean | undefined
} = {
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
currentModel: true,
}
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: ({
value,
onValueChange,
}: {
value?: { provider: string; model: string }
onValueChange: (val: { provider: string; model: string }) => void
}) => (
<div data-testid="model-selector" data-provider={value?.provider} data-model={value?.model}>
Model Selector
<button
data-testid="select-model"
onClick={() => onValueChange({ provider: 'cohere', model: 'embed-english' })}
>
Select
</button>
</div>
),
}))
let modelAvailable = true
const render = createNuqsTestRenderer((ui) => {
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(
queryClient,
modelAvailable
? [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'text-embedding-ada-002',
label: { en_US: 'Ada Embedding' },
model_type: 'text-embedding',
}),
],
}),
createModelCatalogFixture({
provider: 'cohere',
models: [
createCatalogModelFixture({
model: 'embed-english',
label: { en_US: 'English Embedding' },
model_type: 'text-embedding',
}),
],
}),
]
: [],
)
return renderWithConsoleQuery(ui, { queryClient })
})
const defaultAnnotationConfig = {
id: 'test-id',
@@ -53,13 +58,7 @@ describe('ConfigParamModal', () => {
beforeEach(() => {
vi.clearAllMocks()
toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
mockHooksReturn = {
modelList: [
{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] },
],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
currentModel: true,
}
modelAvailable = true
})
afterEach(() => vi.restoreAllMocks())
@@ -79,6 +78,7 @@ describe('ConfigParamModal', () => {
})
it('should call onSave with embedding model and score when save is clicked', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
@@ -91,9 +91,7 @@ describe('ConfigParamModal', () => {
)
// Click the confirm/save button
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@@ -104,17 +102,13 @@ describe('ConfigParamModal', () => {
})
it('should show error toast when embedding model is not set', async () => {
const user = userEvent.setup()
const configWithoutModel = {
...defaultAnnotationConfig,
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
embedding_model: { embedding_provider_name: '', embedding_model_name: '' },
}
// Override hooks to return no default model and no valid current model
mockHooksReturn = {
modelList: [],
defaultModel: undefined,
currentModel: undefined,
}
modelAvailable = false
render(
<ConfigParamModal
@@ -126,9 +120,7 @@ describe('ConfigParamModal', () => {
/>,
)
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
expect(toastErrorSpy).toHaveBeenCalledWith(
'modelProvider.modelProvider.embeddingModel.required',
@@ -136,6 +128,7 @@ describe('ConfigParamModal', () => {
})
it('should call onHide when cancel is clicked and not loading', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
render(
<ConfigParamModal
@@ -147,7 +140,7 @@ describe('ConfigParamModal', () => {
/>,
)
await userEvent.setup().click(screen.getByText(/operation\.cancel/))
await user.click(screen.getByText(/operation\.cancel/))
expect(onHide).toHaveBeenCalled()
})
@@ -165,12 +158,12 @@ describe('ConfigParamModal', () => {
)
// Change model
await userEvent.setup().click(screen.getByTestId('select-model'))
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Ada Embedding/ }))
await user.click(await screen.findByRole('button', { name: /English Embedding/ }))
// Save
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@@ -180,50 +173,6 @@ describe('ConfigParamModal', () => {
})
})
it('should use default model when annotation config has no embedding model', () => {
const configWithoutModel = {
...defaultAnnotationConfig,
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
}
render(
<ConfigParamModal
appId="test-app"
isShow={true}
onHide={vi.fn()}
onSave={vi.fn()}
annotationConfig={configWithoutModel}
/>,
)
// Model selector should be initialized with the default model
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
expect(screen.getByTestId('model-selector')).toHaveAttribute(
'data-model',
'text-embedding-ada-002',
)
})
it('saves the displayed default threshold when the configuration omits it', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
appId="test-app"
isShow
onHide={vi.fn()}
onSave={onSave}
annotationConfig={{
...defaultAnnotationConfig,
score_threshold: undefined as unknown as number,
}}
/>,
)
expect(screen.getByRole('slider')).toHaveValue('0.9')
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
expect(onSave).toHaveBeenCalledWith(defaultAnnotationConfig.embedding_model, 0.9)
})
it('leaves the form usable after a failed save', async () => {
const onSave = vi
.fn()
@@ -250,6 +199,7 @@ describe('ConfigParamModal', () => {
})
it('should preserve zero score threshold instead of falling back to default', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue(undefined)
render(
<ConfigParamModal
@@ -266,9 +216,7 @@ describe('ConfigParamModal', () => {
expect(screen.getByRole('slider')).toHaveValue('0')
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@@ -279,6 +227,7 @@ describe('ConfigParamModal', () => {
})
it('should set loading state while saving', async () => {
const user = userEvent.setup()
let resolveOnSave: () => void
const onSave = vi.fn().mockImplementation(
() =>
@@ -299,17 +248,15 @@ describe('ConfigParamModal', () => {
)
// Click save
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
// While loading, clicking cancel should not call onHide
await userEvent.setup().click(screen.getByText(/operation\.cancel/))
await user.click(screen.getByText(/operation\.cancel/))
expect(onHide).not.toHaveBeenCalled()
// Resolve the save
await act(async () => resolveOnSave!())
await userEvent.setup().click(screen.getByRole('button', { name: /operation\.cancel/ }))
await user.click(screen.getByRole('button', { name: /operation\.cancel/ }))
expect(onHide).toHaveBeenCalledOnce()
})
@@ -331,9 +278,7 @@ describe('ConfigParamModal', () => {
await user.keyboard('{ArrowRight}')
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('0.91'))
await userEvent
.setup()
.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await user.click(screen.getByRole('button', { name: /initSetup\.configConfirmBtn/ }))
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@@ -1,8 +1,16 @@
import type { Features, OnFeaturesChange } from '../../../types'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { toast } from '@/app/notifications'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { consoleQuery } from '@/service/console'
import {
createCatalogModelFixture,
createDefaultModelFixture,
createModelCatalogFixture,
seedModelProviderQueries,
} from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
import { FeaturesProvider } from '../../../context'
import AnnotationReply from '../index'
@@ -23,30 +31,6 @@ vi.mock('@/service/console', async (importOriginal) => {
}),
}
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
async (importOriginal) => ({
...(await importOriginal<
typeof import('@/app/components/header/account-setting/model-provider-page/hooks')
>()),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: [],
defaultModel: { provider: { provider: 'openai' }, model: 'embedding-model' },
currentModel: true,
}),
}),
)
// Model selection and billing purchase flows have separate integration suites.
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
ModelSelector: () => null,
}))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div role="dialog" aria-label="Annotation limit">
<button onClick={onHide}>Close limit</button>
</div>
),
}))
const embeddingModel = {
embedding_provider_name: 'openai',
@@ -76,16 +60,39 @@ function renderFeature({
onChange?: OnFeaturesChange
full?: boolean
} = {}) {
return renderWithConsoleQuery(
<FeaturesProvider
features={{
...defaultFeatures,
annotationReply: { enabled, score_threshold: score, embedding_model: embeddingModel },
}}
>
<AnnotationReply disabled={disabled} onChange={onChange} />
</FeaturesProvider>,
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient, [
createModelCatalogFixture({
models: [
createCatalogModelFixture({
model: 'embedding-model',
label: { en_US: 'embedding-model' },
model_type: 'text-embedding',
}),
],
}),
])
queryClient.setQueryData(
consoleQuery.workspaces.current.defaultModel.get.queryKey({
input: { query: { model_type: 'text-embedding' } },
}),
{
data: createDefaultModelFixture({ model: 'embedding-model', model_type: 'text-embedding' }),
},
)
return renderWithConsoleQuery(
<NuqsTestingAdapter>
<FeaturesProvider
features={{
...defaultFeatures,
annotationReply: { enabled, score_threshold: score, embedding_model: embeddingModel },
}}
>
<AnnotationReply disabled={disabled} onChange={onChange} />
</FeaturesProvider>
</NuqsTestingAdapter>,
{
queryClient,
systemFeatures: { deployment_edition: 'CLOUD' },
features: { annotation_quota_limit: { size: full ? 10 : 0, limit: 10 } },
},
@@ -205,18 +212,21 @@ describe('AnnotationReply', () => {
const user = userEvent.setup({ skipHover: true })
renderFeature({ full: true })
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('dialog', { name: 'Annotation limit' })).toBeInTheDocument()
expect(
within(screen.getByRole('dialog')).getByText('billing.annotatedResponse.fullTipLine1'),
).toBeVisible()
expect(screen.queryByRole('dialog', { name: /initSetup.title/ })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close limit' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(updateStatus).not.toHaveBeenCalled()
})
it('does not allow configuration when the feature is disabled by its owner', async () => {
const user = userEvent.setup({ skipHover: true })
renderFeature({ disabled: true })
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-disabled', 'true')
await userEvent.setup({ skipHover: true }).click(toggle)
await user.click(toggle)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(updateStatus).not.toHaveBeenCalled()
})
@@ -1,5 +1,5 @@
import type { OpeningStatement } from '@/app/components/base/features/types'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import { InputVarType } from '@/app/components/workflow/types'
@@ -11,17 +11,12 @@ const defaultData: OpeningStatement = {
opening_statement: 'Hello, how can I help?',
suggested_questions: ['Question 1', 'Question 2'],
}
const getPromptEditor = () => {
const editor = document.querySelector<HTMLElement>('[data-lexical-editor="true"]')
expect(editor).toBeInTheDocument()
return editor!
}
const save = async () =>
userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.save' }))
const dismissVariableCheck = async () =>
userEvent
.setup()
.click(screen.getAllByRole('button', { name: 'common.operation.cancel' }).at(-1)!)
const getPromptEditor = () =>
within(screen.getByTestId('opener-input-section')).getByRole('textbox')
const save = (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const dismissVariableCheck = (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getAllByRole('button', { name: 'common.operation.cancel' }).at(-1)!)
describe('OpeningSettingModal', () => {
it.each(['cancel', 'close', 'Escape'] as const)(
@@ -50,7 +45,7 @@ describe('OpeningSettingModal', () => {
screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder')[0]!,
'Updated question',
)
await user.click(screen.getByTestId('delete-question-Question 2').parentElement!)
await user.click(screen.getByTestId('delete-question-Question 2'))
expect(screen.queryByDisplayValue('Question 2')).not.toBeInTheDocument()
await user.click(screen.getByText('appDebug.variableConfig.addOption'))
const newQuestion = screen.getAllByPlaceholderText(
@@ -58,7 +53,7 @@ describe('OpeningSettingModal', () => {
)[1]!
expect(newQuestion).toHaveFocus()
await user.type(newQuestion, 'New question')
await save()
await save(user)
expect(onSave).toHaveBeenCalledExactlyOnceWith({
...defaultData,
opening_statement: 'Welcome!',
@@ -67,6 +62,7 @@ describe('OpeningSettingModal', () => {
})
it.each(['', ' '])('prevents saving an empty opener (%j)', async (opening_statement) => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<OpeningSettingModal
@@ -76,13 +72,14 @@ describe('OpeningSettingModal', () => {
/>,
)
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
await save()
await save(user)
expect(onSave).not.toHaveBeenCalled()
})
it.each([[], undefined])(
'saves an empty question list when initialized with %j',
async (suggested_questions) => {
const user = userEvent.setup()
const onSave = vi.fn()
render(
<OpeningSettingModal
@@ -91,7 +88,7 @@ describe('OpeningSettingModal', () => {
onCancel={vi.fn()}
/>,
)
await save()
await save(user)
expect(onSave).toHaveBeenCalledExactlyOnceWith({ ...defaultData, suggested_questions: [] })
},
)
@@ -130,7 +127,7 @@ describe('OpeningSettingModal', () => {
onAutoAddPromptVariable={onAutoAddPromptVariable}
/>,
)
await save()
await save(user)
expect(screen.getByText('appDebug.autoAddVar')).toBeInTheDocument()
expect(onSave).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
@@ -142,6 +139,7 @@ describe('OpeningSettingModal', () => {
})
it('allows saving without adding an unknown variable', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const onAutoAddPromptVariable = vi.fn()
const data = { ...defaultData, opening_statement: 'Hello {{unknown}}' }
@@ -156,10 +154,10 @@ describe('OpeningSettingModal', () => {
]}
/>,
)
await save()
await save(user)
expect(screen.getByText('appDebug.autoAddVar')).toBeInTheDocument()
expect(onSave).not.toHaveBeenCalled()
await dismissVariableCheck()
await dismissVariableCheck(user)
expect(onSave).toHaveBeenCalledExactlyOnceWith(data)
expect(onAutoAddPromptVariable).not.toHaveBeenCalled()
})
@@ -167,6 +165,7 @@ describe('OpeningSettingModal', () => {
it.each(['prompt', 'workflow'] as const)(
'saves known %s variables without confirmation',
async (kind) => {
const user = userEvent.setup()
const onSave = vi.fn()
const data = { ...defaultData, opening_statement: 'Hello {{name}}' }
render(
@@ -184,13 +183,14 @@ describe('OpeningSettingModal', () => {
}
/>,
)
await save()
await save(user)
expect(screen.queryByText('appDebug.autoAddVar')).not.toBeInTheDocument()
expect(onSave).toHaveBeenCalledExactlyOnceWith(data)
},
)
it('updates the editor and saved opener when its data changes', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const view = render(
<OpeningSettingModal data={defaultData} onSave={onSave} onCancel={vi.fn()} />,
@@ -203,7 +203,7 @@ describe('OpeningSettingModal', () => {
/>,
)
await waitFor(() => expect(getPromptEditor()).toHaveTextContent('New greeting!'))
await save()
await save(user)
expect(onSave).toHaveBeenCalledExactlyOnceWith({
...defaultData,
opening_statement: 'New greeting!',
@@ -211,6 +211,7 @@ describe('OpeningSettingModal', () => {
})
it('does not save if the opener becomes empty while variable confirmation is open', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const view = render(
<OpeningSettingModal
@@ -219,7 +220,7 @@ describe('OpeningSettingModal', () => {
onCancel={vi.fn()}
/>,
)
await save()
await save(user)
expect(screen.getByText('appDebug.autoAddVar')).toBeInTheDocument()
view.rerender(
<OpeningSettingModal
@@ -228,7 +229,7 @@ describe('OpeningSettingModal', () => {
onCancel={vi.fn()}
/>,
)
await dismissVariableCheck()
await dismissVariableCheck(user)
expect(onSave).not.toHaveBeenCalled()
})
@@ -253,7 +254,7 @@ describe('OpeningSettingModal', () => {
screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder')[0]!,
'Edited after sorting',
)
await save()
await save(user)
expect(onSave).toHaveBeenCalledExactlyOnceWith({
...defaultData,
suggested_questions: ['Edited after sorting', 'Question 1'],
@@ -140,10 +140,11 @@ it('persists enabled state in both file settings and the legacy image flag', asy
})
it('blocks enabling upload when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderWithProvider({ disabled: true, onChange })
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-disabled', 'true')
await userEvent.setup().click(toggle)
await user.click(toggle)
expect(onChange).not.toHaveBeenCalled()
})
@@ -84,8 +84,9 @@ it('discards cancelled edits and saves the next edit to feature state before clo
})
it('keeps settings closed when editing is disabled', async () => {
const user = userEvent.setup()
const onChange = setup(true)
await userEvent.setup().click(screen.getByRole('button', { name: 'Upload Settings' }))
await user.click(screen.getByRole('button', { name: 'Upload Settings' }))
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
expect(onChange).not.toHaveBeenCalled()
})
@@ -64,10 +64,11 @@ describe('Moderation', () => {
})
it('opens initial configuration and enables moderation only after saving', async () => {
const user = userEvent.setup({ skipHover: true })
const onChange = vi.fn()
const { store } = renderModeration(undefined, { onChange })
const toggle = screen.getByRole('switch')
await userEvent.setup({ skipHover: true }).click(toggle)
await user.click(toggle)
expect(getModal().payload).toMatchObject({
enabled: true,
type: 'keywords',
@@ -82,9 +83,10 @@ describe('Moderation', () => {
})
it('keeps moderation disabled when initial configuration is cancelled', async () => {
const user = userEvent.setup({ skipHover: true })
const onChange = vi.fn()
const { store } = renderModeration(undefined, { onChange })
await userEvent.setup({ skipHover: true }).click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch'))
act(() => getModal().onCancelCallback())
expect(screen.getByRole('switch')).not.toBeChecked()
expect(store.getState().features.moderation).toEqual({ enabled: false })
@@ -92,9 +94,10 @@ describe('Moderation', () => {
})
it('re-enables an existing configuration without reopening setup', async () => {
const user = userEvent.setup({ skipHover: true })
const onChange = vi.fn()
const { store } = renderModeration({ ...configured, enabled: false }, { onChange })
await userEvent.setup({ skipHover: true }).click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
expect(store.getState().features.moderation).toEqual(configured)
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
@@ -102,9 +105,10 @@ describe('Moderation', () => {
})
it('disables moderation and publishes the saved feature state', async () => {
const user = userEvent.setup({ skipHover: true })
const onChange = vi.fn()
const { store } = renderModeration(configured, { onChange })
await userEvent.setup({ skipHover: true }).click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).not.toBeChecked()
expect(store.getState().features.moderation).toEqual({ enabled: false })
expect(onChange).toHaveBeenCalledExactlyOnceWith(store.getState().features)
@@ -113,9 +117,10 @@ describe('Moderation', () => {
it.each([false, true])(
'preserves other feature changes made while the modal is open (existing=%s)',
async (existing) => {
const user = userEvent.setup({ skipHover: true })
const { store } = renderModeration(existing ? configured : undefined)
if (existing) await openSettings()
else await userEvent.setup({ skipHover: true }).click(screen.getByRole('switch'))
else await user.click(screen.getByRole('switch'))
act(() =>
store
.getState()
@@ -1,7 +1,8 @@
import type { AppConfigJsonValue } from '@dify/contracts/api/console/apps/types.gen'
import type { CodeBasedExtensionItemResponse } from '@dify/contracts/api/console/code-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ModerationConfig } from '@/models/debug'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { buildConfigurationDatasetConfigs } from '@/app/components/app/configuration/hooks/configuration-lifecycle/dataset'
import { createPublishHandler } from '@/app/components/app/configuration/hooks/configuration-lifecycle/publish'
@@ -26,30 +27,8 @@ vi.mock('@/app/notifications', () => ({
},
}))
let mockCodeBasedExtensions: { data: { data: Array<{ name: string; [key: string]: unknown }> } } = {
data: { data: [] },
}
let mockModelProvidersData: {
data: { data: Record<string, unknown>[] }
isPending: boolean
refetch: ReturnType<typeof vi.fn>
} = {
data: {
data: [
{
provider: 'langgenius/openai/openai',
system_configuration: {
enabled: true,
current_quota_type: 'paid',
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
},
custom_configuration: { status: 'active' },
},
],
},
isPending: false,
refetch: vi.fn(),
}
let extensions: CodeBasedExtensionItemResponse[] = []
let modelProviders: ReturnType<typeof createProviderResponseFixture>[] = []
const defaultData: ModerationConfig = {
enabled: true,
@@ -72,15 +51,12 @@ describe('ModerationSettingModal', () => {
consoleQuery.codeBasedExtension.get.queryKey({
input: { query: { module: 'moderation' } },
}),
{ module: 'moderation', data: mockCodeBasedExtensions.data.data },
{ module: 'moderation', data: extensions },
)
queryClient.setQueryData(
consoleQuery.workspaces.current.modelProviders.get.queryKey({ input: {} }),
{
data: mockModelProvidersData.data.data.map((provider) => ({
...createProviderResponseFixture(),
...provider,
})),
data: modelProviders,
},
)
queryClient.setQueryData(consoleQuery.apiBasedExtension.get.queryKey(), [
@@ -97,57 +73,24 @@ describe('ModerationSettingModal', () => {
}
beforeEach(() => {
vi.clearAllMocks()
mockCodeBasedExtensions = { data: { data: [] } }
mockModelProvidersData = {
data: {
data: [
{
provider: 'langgenius/openai/openai',
system_configuration: {
enabled: true,
current_quota_type: 'paid',
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
},
custom_configuration: { status: 'active' },
},
],
},
isPending: false,
refetch: vi.fn(),
}
})
afterEach(() => {
vi.restoreAllMocks()
extensions = []
modelProviders = [createProviderResponseFixture({ provider: 'langgenius/openai/openai' })]
})
it('should call onCancel when cancel is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
await renderModal(
<ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />,
)
fireEvent.click(screen.getByText(/operation\.cancel/))
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
it('should call onCancel when close icon receives Enter key', async () => {
const onCancel = vi.fn()
await renderModal(
<ModerationSettingModal data={defaultData} onCancel={onCancel} onSave={onSave} />,
)
const user = userEvent.setup()
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
closeButton.focus()
await user.keyboard('{Enter}')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should show error when saving without inputs or outputs enabled', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -158,7 +101,7 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -168,6 +111,7 @@ describe('ModerationSettingModal', () => {
})
it('should show error when keywords type has no keywords', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -178,7 +122,7 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -188,6 +132,7 @@ describe('ModerationSettingModal', () => {
})
it('should save the latest preset response when content textarea changes', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -198,13 +143,12 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.change(
screen.getByRole('textbox', { name: /feature\.moderation\.modal\.content\.preset/ }),
{
target: { value: 'updated blocked response' },
},
)
fireEvent.click(screen.getByText(/operation\.save/))
const preset = screen.getByRole('textbox', {
name: /feature\.moderation\.modal\.content\.preset/,
})
await user.clear(preset)
await user.type(preset, 'updated blocked response')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -218,6 +162,7 @@ describe('ModerationSettingModal', () => {
})
it('should show error when inputs enabled but no preset_response for keywords type', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -228,7 +173,7 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -238,6 +183,7 @@ describe('ModerationSettingModal', () => {
})
it('should show error when api type has no api_based_extension_id', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
enabled: true,
type: 'api',
@@ -248,7 +194,7 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -258,6 +204,7 @@ describe('ModerationSettingModal', () => {
})
it('should save with api_based_extension_id in formatted data for api type', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
enabled: true,
type: 'api',
@@ -270,7 +217,7 @@ describe('ModerationSettingModal', () => {
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
// api type doesn't require preset_response, so save should succeed
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -283,6 +230,7 @@ describe('ModerationSettingModal', () => {
})
it('should show error when outputs enabled but no preset_response for keywords type', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -293,7 +241,7 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -303,6 +251,7 @@ describe('ModerationSettingModal', () => {
})
it('should select api extension via api selector', async () => {
const user = userEvent.setup()
await renderModal(
<ModerationSettingModal
data={{
@@ -315,14 +264,13 @@ describe('ModerationSettingModal', () => {
/>,
)
const user = userEvent.setup()
await user.click(
screen.getByRole('button', { name: 'common.apiBasedExtension.selector.placeholder' }),
)
await user.click(await screen.findByRole('button', { name: /Moderation API/ }))
// Trigger save and confirm the chosen extension id is passed through
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ api_based_extension_id: 'api-ext-1' }),
@@ -331,6 +279,7 @@ describe('ModerationSettingModal', () => {
})
it('should save with openai_moderation type when configured', async () => {
const user = userEvent.setup()
await renderModal(
<ModerationSettingModal
data={{
@@ -346,7 +295,7 @@ describe('ModerationSettingModal', () => {
/>,
)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -356,6 +305,7 @@ describe('ModerationSettingModal', () => {
})
it('should handle keyword truncation to 100 chars per line and 100 lines', async () => {
const user = userEvent.setup()
await renderModal(
<ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />,
)
@@ -367,13 +317,14 @@ describe('ModerationSettingModal', () => {
{ length: 101 },
(_, index) => `${index}`.padStart(3, '0') + 'a'.repeat(150),
)
fireEvent.change(textarea, { target: { value: lines.join('\n') } })
await user.clear(textarea)
await user.paste(lines.join('\n'))
const expectedKeywords = lines
.slice(0, 100)
.map((line) => line.slice(0, 100))
.join('\n')
expect(textarea).toHaveValue(expectedKeywords)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledExactlyOnceWith({
...defaultData,
config: { ...defaultData.config, keywords: expectedKeywords },
@@ -381,6 +332,7 @@ describe('ModerationSettingModal', () => {
})
it('should save with formatted outputs_config when both enabled', async () => {
const user = userEvent.setup()
const data: ModerationConfig = {
...defaultData,
config: {
@@ -391,42 +343,34 @@ describe('ModerationSettingModal', () => {
}
await renderModal(<ModerationSettingModal data={data} onCancel={vi.fn()} onSave={onSave} />)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledExactlyOnceWith(data)
})
it('should handle empty lines in keywords', async () => {
const user = userEvent.setup()
await renderModal(
<ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />,
)
const textarea = screen.getByPlaceholderText(
/feature\.moderation\.modal\.keywords\.placeholder/,
) as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } })
)
await user.clear(textarea)
await user.paste('word1\n\nword2\n\n')
expect(textarea.value).toBe('word1\n\nword2\n')
expect(textarea).toHaveValue('word1\n\nword2\n')
})
it('should open settings modal when provider link is clicked in OpenAI warning', async () => {
mockModelProvidersData = {
data: {
data: [
{
provider: 'langgenius/openai/openai',
system_configuration: {
enabled: false,
current_quota_type: 'free',
quota_configurations: [],
},
custom_configuration: { status: 'no-configure' },
},
],
},
isPending: false,
refetch: vi.fn(),
}
const user = userEvent.setup()
modelProviders = [
createProviderResponseFixture({
provider: 'langgenius/openai/openai',
custom_configuration: { status: 'no-configure' },
}),
]
await renderModal(
<ModerationSettingModal
@@ -442,7 +386,7 @@ describe('ModerationSettingModal', () => {
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
expect(onSave).not.toHaveBeenCalled()
fireEvent.click(screen.getByText(/settings\.provider/))
await user.click(screen.getByText(/settings\.provider/))
await waitFor(() =>
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('provider'),
@@ -450,45 +394,41 @@ describe('ModerationSettingModal', () => {
})
it('should initialize config from form schema when switching to code-based extension', async () => {
mockCodeBasedExtensions = {
data: {
data: [
const user = userEvent.setup()
extensions = [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: 'https://default.com',
placeholder: '',
options: [],
max_length: 200,
},
],
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: 'https://default.com',
placeholder: '',
options: [],
max_length: 200,
},
],
},
}
]
await renderModal(
<ModerationSettingModal data={defaultData} onCancel={vi.fn()} onSave={onSave} />,
)
// Click on the custom extension provider
fireEvent.click(screen.getByText('Custom Extension'))
await user.click(screen.getByRole('button', { name: /Custom Extension/ }))
expect(screen.getByDisplayValue('https://default.com')).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(
screen.getByRole('textbox', { name: /feature\.moderation\.modal\.content\.preset/ }),
{
target: { value: 'Blocked by extension' },
},
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.click(screen.getAllByRole('switch')[0]!)
const preset = screen.getByRole('textbox', {
name: /feature\.moderation\.modal\.content\.preset/,
})
await user.clear(preset)
await user.type(preset, 'Blocked by extension')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledExactlyOnceWith({
enabled: true,
type: 'custom-ext',
@@ -501,28 +441,25 @@ describe('ModerationSettingModal', () => {
})
it('should show error when required form schema field is empty on save', async () => {
mockCodeBasedExtensions = {
data: {
data: [
const user = userEvent.setup()
extensions = [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: '',
placeholder: '',
options: [],
max_length: 200,
},
],
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: '',
placeholder: '',
options: [],
max_length: 200,
},
],
},
}
]
await renderModal(
<ModerationSettingModal
@@ -536,7 +473,7 @@ describe('ModerationSettingModal', () => {
/>,
)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledExactlyOnceWith({
type: 'error',
@@ -546,28 +483,25 @@ describe('ModerationSettingModal', () => {
})
it('should update code-based extension form value and save updated config', async () => {
mockCodeBasedExtensions = {
data: {
data: [
const user = userEvent.setup()
extensions = [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: '',
placeholder: 'Enter URL',
options: [],
max_length: 200,
},
],
variable: 'api_url',
label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' },
type: 'text-input',
required: true,
default: '',
placeholder: 'Enter URL',
options: [],
max_length: 200,
},
],
},
}
]
await renderModal(
<ModerationSettingModal
@@ -584,10 +518,8 @@ describe('ModerationSettingModal', () => {
/>,
)
fireEvent.change(screen.getByPlaceholderText('Enter URL'), {
target: { value: 'https://changed.com' },
})
fireEvent.click(screen.getByText(/operation\.save/))
await user.type(screen.getByPlaceholderText('Enter URL'), 'https://changed.com')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -643,27 +575,23 @@ describe('ModerationSettingModal', () => {
'publishes a saved custom moderation configuration $scenario',
async ({ initialConfig, clear, expectedValue, displayedValue }) => {
const user = userEvent.setup()
mockCodeBasedExtensions = {
data: {
data: [
extensions = [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
name: 'custom-ext',
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
form_schema: [
{
variable: 'optional_token',
label: { 'en-US': 'Optional token', 'zh-Hans': '可选令牌' },
type: 'text-input',
required: false,
default: '',
placeholder: 'Optional token',
options: [],
},
],
variable: 'optional_token',
label: { 'en-US': 'Optional token', 'zh-Hans': '可选令牌' },
type: 'text-input',
required: false,
default: '',
placeholder: 'Optional token',
options: [],
},
],
},
}
]
const featuresStore = createFeaturesStore()
await renderModal(
<ModerationSettingModal
@@ -738,6 +666,7 @@ describe('ModerationSettingModal', () => {
)
it('should fallback missing inputs_config to disabled in formatted save data', async () => {
const user = userEvent.setup()
await renderModal(
<ModerationSettingModal
data={{
@@ -753,7 +682,7 @@ describe('ModerationSettingModal', () => {
/>,
)
fireEvent.click(screen.getByText(/operation\.save/))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
@@ -245,27 +245,30 @@ describe('ParamConfigContent', () => {
)
it('should call onClose when close button receives Enter key', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderWithProvider({ onClose })
const closeButton = screen.getByRole('button', { name: /close/i })
closeButton.focus()
await userEvent.keyboard('{Enter}')
await user.keyboard('{Enter}')
expect(onClose).toHaveBeenCalled()
})
it('should toggle autoPlay switch and call onChange', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderWithProvider({ onChange })
await userEvent.click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
expect(onChange).toHaveBeenCalledOnce()
})
it('should set autoPlay to disabled when toggled off from enabled state', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderWithProvider(
{ onChange },
@@ -282,7 +285,7 @@ describe('ParamConfigContent', () => {
const autoPlaySwitch = screen.getByRole('switch')
expect(autoPlaySwitch)!.toHaveAttribute('aria-checked', 'true')
await userEvent.click(autoPlaySwitch)
await user.click(autoPlaySwitch)
expect(autoPlaySwitch)!.toHaveAttribute('aria-checked', 'false')
expect(onChange).toHaveBeenCalled()
@@ -94,6 +94,7 @@ describe('FileInAttachmentItem', () => {
})
it('retries without opening the attachment preview', async () => {
const user = userEvent.setup()
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const onReUpload = vi.fn()
render(
@@ -104,7 +105,7 @@ describe('FileInAttachmentItem', () => {
previewMode={PreviewMode.NewPage}
/>,
)
await userEvent.setup().click(action('retry'))
await user.click(action('retry'))
expect(onReUpload).toHaveBeenCalledExactlyOnceWith('file-1')
expect(open).not.toHaveBeenCalled()
})
@@ -52,26 +52,29 @@ describe('FileItem (chat-input)', () => {
})
it('should call onRemove when delete button is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
render(<FileItem file={createFile()} canPreview showDeleteAction onRemove={onRemove} />)
const delete_button = screen.getByRole('button', { name: 'common.operation.remove' })
await userEvent.setup().click(delete_button)
await user.click(delete_button)
expect(onRemove).toHaveBeenCalledWith('file-1')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should call onReUpload when replay icon is clicked', async () => {
const user = userEvent.setup()
const onReUpload = vi.fn()
render(<FileItem file={createFile({ progress: -1 })} canPreview onReUpload={onReUpload} />)
const replayIcon = screen.getByRole('button', { name: 'common.operation.retry' })
await userEvent.setup().click(replayIcon!)
await user.click(replayIcon!)
expect(onReUpload).toHaveBeenCalledWith('file-1')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should close audio preview', async () => {
const user = userEvent.setup()
render(
<FileItem
file={createFile({
@@ -83,24 +86,25 @@ describe('FileItem (chat-input)', () => {
/>,
)
await userEvent.setup().click(screen.getByText(/audio\.mp3/i))
await user.click(screen.getByText(/audio\.mp3/i))
expect(document.querySelector('audio source')).toHaveAttribute(
'src',
'https://example.com/audio.mp3',
)
const deleteButton = screen.getByRole('button', { name: 'common.operation.close' })
await userEvent.setup().click(deleteButton)
await user.click(deleteButton)
expect(document.querySelector('audio')).not.toBeInTheDocument()
})
it('should call downloadUrl when download button is clicked', async () => {
const user = userEvent.setup()
const { downloadUrl } = await import('@/utils/download')
render(<FileItem file={createFile()} canPreview showDownloadAction />)
const downloadBtn = screen.getByRole('button', { name: 'common.operation.download' })
await userEvent.setup().click(downloadBtn)
await user.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/document.pdf?as_attachment=true',
@@ -119,6 +123,7 @@ describe('FileItem (chat-input)', () => {
})
it('should not show preview when canPreview is false', async () => {
const user = userEvent.setup()
render(
<FileItem
file={createFile({
@@ -129,12 +134,13 @@ describe('FileItem (chat-input)', () => {
/>,
)
await userEvent.setup().click(screen.getByText(/audio\.mp3/i))
await user.click(screen.getByText(/audio\.mp3/i))
expect(document.querySelector('audio')).not.toBeInTheDocument()
})
it('should close video preview', async () => {
const user = userEvent.setup()
render(
<FileItem
file={createFile({
@@ -146,19 +152,20 @@ describe('FileItem (chat-input)', () => {
/>,
)
await userEvent.setup().click(screen.getByText(/video\.mp4/i))
await user.click(screen.getByText(/video\.mp4/i))
expect(document.querySelector('video source')).toHaveAttribute(
'src',
'https://example.com/video.mp4',
)
const closeBtn = screen.getByRole('button', { name: 'common.operation.close' })
await userEvent.setup().click(closeBtn)
await user.click(closeBtn)
expect(document.querySelector('video')).not.toBeInTheDocument()
})
it('should close pdf preview', async () => {
const user = userEvent.setup()
render(
<FileItem
file={createFile({
@@ -170,13 +177,13 @@ describe('FileItem (chat-input)', () => {
/>,
)
await userEvent.setup().click(screen.getByText(/doc\.pdf/i))
await user.click(screen.getByText(/doc\.pdf/i))
expect(await screen.findByTestId('pdf-preview')).toHaveAttribute(
'data-url',
'https://example.com/doc.pdf',
)
await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.close' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
@@ -215,24 +222,26 @@ describe('FileItem (chat-input)', () => {
it.each(['audio/mpeg', 'video/mp4'])(
'preserves native controls without autoplay for %s',
async (type) => {
const user = userEvent.setup()
const { container } = render(
<FileItem file={createFile({ name: 'media', type })} canPreview />,
)
await userEvent.setup().click(screen.getByRole('button', { name: 'media' }))
await user.click(screen.getByRole('button', { name: 'media' }))
const media = within(screen.getByRole('dialog')).getByTitle('media')
expect(media.querySelector('source')).toHaveAttribute('type', type)
expect(media).toHaveAttribute('controls')
expect(media).toHaveAttribute('preload', 'metadata')
expect(media).not.toHaveAttribute('autoplay')
expect(container.querySelector('audio,video')).not.toBeInTheDocument()
await userEvent.setup().click(media)
await user.click(media)
expect(screen.getByRole('dialog', { name: 'media' })).toBeInTheDocument()
await userEvent.setup().keyboard('{Escape}')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
},
)
it('keeps the open source while upload completes and releases its Blob before the next session', async () => {
const user = userEvent.setup()
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-audio')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
const file = createFile({
@@ -243,22 +252,22 @@ describe('FileItem (chat-input)', () => {
})
const { rerender } = render(<FileItem file={file} canPreview />)
expect(createObjectURL).not.toHaveBeenCalled()
await userEvent.setup().click(screen.getByRole('button', { name: 'audio.mp3' }))
await user.click(screen.getByRole('button', { name: 'audio.mp3' }))
expect(document.querySelector('audio source')).toHaveAttribute('src', 'blob:local-audio')
rerender(<FileItem file={{ ...file, url: 'https://example.com/uploaded.mp3' }} canPreview />)
expect(document.querySelector('audio source')).toHaveAttribute('src', 'blob:local-audio')
expect(createObjectURL).toHaveBeenCalledTimes(1)
expect(revokeObjectURL).not.toHaveBeenCalled()
await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.close' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:local-audio')
await userEvent.setup().click(screen.getByRole('button', { name: 'audio.mp3' }))
await user.click(screen.getByRole('button', { name: 'audio.mp3' }))
expect(document.querySelector('audio source')).toHaveAttribute(
'src',
'https://example.com/uploaded.mp3',
)
expect(createObjectURL).toHaveBeenCalledTimes(1)
await userEvent.setup().keyboard('{Escape}')
await user.keyboard('{Escape}')
expect(revokeObjectURL).toHaveBeenCalledTimes(1)
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
@@ -270,6 +279,7 @@ describe('FileItem (chat-input)', () => {
])(
'borrows the preferred URL without creating or revoking it: $expected',
async ({ url, expected }) => {
const user = userEvent.setup()
const createObjectURL = vi.spyOn(URL, 'createObjectURL')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
render(
@@ -284,9 +294,9 @@ describe('FileItem (chat-input)', () => {
canPreview
/>,
)
await userEvent.setup().click(screen.getByRole('button', { name: 'audio.mp3' }))
await user.click(screen.getByRole('button', { name: 'audio.mp3' }))
expect(document.querySelector('audio source')).toHaveAttribute('src', expected)
await userEvent.setup().keyboard('{Escape}')
await user.keyboard('{Escape}')
expect(createObjectURL).not.toHaveBeenCalled()
expect(revokeObjectURL).not.toHaveBeenCalled()
createObjectURL.mockRestore()
@@ -295,6 +305,7 @@ describe('FileItem (chat-input)', () => {
)
it('ends the resource session if preview permission is removed', async () => {
const user = userEvent.setup()
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-audio')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
const file = createFile({
@@ -304,7 +315,7 @@ describe('FileItem (chat-input)', () => {
originalFile: new File(['audio'], 'audio.mp3'),
})
const { rerender } = render(<FileItem file={file} canPreview />)
await userEvent.setup().click(screen.getByRole('button', { name: 'audio.mp3' }))
await user.click(screen.getByRole('button', { name: 'audio.mp3' }))
rerender(<FileItem file={file} canPreview={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'audio.mp3' })).not.toBeInTheDocument()
@@ -25,7 +25,7 @@ export type EmojiIcon = {
background: string
}
export type ImageIcon = {
type ImageIcon = {
type: 'image'
fileId: string
url: string
@@ -63,3 +63,44 @@ it('keeps the portalled preview fullscreen and owns arrows only while open', asy
expect(onNext).toHaveBeenCalledTimes(2)
expect(onCancel).toHaveBeenCalledOnce()
})
it('zooms and pans without exposing blank edges, then recenters when zoomed out', async () => {
// Native wheel/drag events and actual image bounds catch pan math hidden by mocked rectangles.
await page.viewport(480, 320)
const largeImage = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800"><rect width="1200" height="800" fill="teal" /></svg>')}`
const screen = await render(
<ImagePreview url={largeImage} title="Pan preview" onCancel={vi.fn()} />,
)
const dialog = screen.getByRole('dialog', { name: 'Pan preview' })
const image = dialog.getByRole('img', { name: 'Pan preview' })
await expect.poll(() => image.element().getBoundingClientRect().width).toBeGreaterThan(0)
const initial = image.element().getBoundingClientRect()
await screen.getByRole('button', { name: 'common.operation.zoomIn' }).click()
await expect
.poll(() => image.element().getBoundingClientRect().width)
.toBeCloseTo(initial.width * 1.2)
await dialog.wheel({ direction: 'up', times: 2 })
await expect
.poll(() => image.element().getBoundingClientRect().width)
.toBeCloseTo(initial.width * 1.2 ** 3)
const zoomed = image.element().getBoundingClientRect()
await userEvent.dragAndDrop(image, dialog, {
sourcePosition: { x: zoomed.width / 2, y: zoomed.height / 2 },
targetPosition: { x: 445, y: 280 },
steps: 8,
})
await expect.poll(() => image.element().getBoundingClientRect().left).toBeCloseTo(32)
await expect.poll(() => image.element().getBoundingClientRect().top).toBeCloseTo(32)
const panned = image.element().getBoundingClientRect()
expect(panned.left).toBeLessThanOrEqual(32)
expect(panned.right).toBeGreaterThanOrEqual(448)
await dialog.hover({ position: { x: 240, y: 160 } })
await expect.poll(() => image.element().getBoundingClientRect().left).toBeCloseTo(panned.left)
await dialog.wheel({ direction: 'down', times: 2 })
await expect
.poll(() => image.element().getBoundingClientRect().width)
.toBeCloseTo(initial.width * 1.2)
await screen.getByRole('button', { name: 'common.operation.zoomOut' }).click()
await expect.poll(() => image.element().getBoundingClientRect().left).toBeCloseTo(initial.left)
await expect.poll(() => image.element().getBoundingClientRect().width).toBeCloseTo(initial.width)
})
@@ -1,602 +1,245 @@
import type { ComponentProps } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/notifications'
import { AppToastHost } from '@/app/notifications/host'
import { createDeferred } from '@/test/deferred'
import ImagePreview from '../image-preview'
const mocks = vi.hoisted(() => ({
notify: vi.fn(),
downloadUrl: vi.fn(),
windowOpen: vi.fn<(...args: unknown[]) => Window | null>(),
clipboardWrite: vi.fn<(items: ClipboardItem[]) => Promise<void>>(),
}))
vi.mock('@/app/notifications', () => ({
default: {
notify: (...args: Parameters<typeof mocks.notify>) => mocks.notify(...args),
},
toast: {
success: (message: string) => mocks.notify({ type: 'success', message }),
error: (message: string) => mocks.notify({ type: 'error', message }),
warning: (message: string) => mocks.notify({ type: 'warning', message }),
info: (message: string) => mocks.notify({ type: 'info', message }),
},
}))
vi.mock('@/utils/download', async (importOriginal) => ({
...(await importOriginal<typeof import('@/utils/download')>()),
downloadUrl: (...args: Parameters<typeof mocks.downloadUrl>) => mocks.downloadUrl(...args),
}))
const getOverlay = () => screen.getByTestId('image-preview-container') as HTMLDivElement
const getCloseButton = () =>
screen.getByRole('button', { name: 'common.operation.cancel' }) as HTMLButtonElement
const getCopyButton = () =>
screen.getByRole('button', { name: 'common.operation.copyImage' }) as HTMLButtonElement
const getZoomOutButton = () =>
screen.getByRole('button', { name: 'common.operation.zoomOut' }) as HTMLButtonElement
const getZoomInButton = () =>
screen.getByRole('button', { name: 'common.operation.zoomIn' }) as HTMLButtonElement
const getDownloadButton = () =>
screen.getByRole('button', { name: 'common.operation.download' }) as HTMLButtonElement
const getOpenInTabButton = () =>
screen.getByRole('button', { name: 'common.operation.openInNewTab' }) as HTMLButtonElement
const base64Image = 'aGVsbG8='
const dataImage = `data:image/png;base64,${base64Image}`
const button = (action: string) =>
screen.getByRole('button', { name: `common.operation.${action}` })
describe('ImagePreview', () => {
const originalClipboardItem = globalThis.ClipboardItem
beforeEach(() => {
vi.clearAllMocks()
mocks.windowOpen.mockReset()
mocks.clipboardWrite.mockReset().mockResolvedValue()
vi.stubGlobal(
'fetch',
vi
.fn()
.mockImplementation(async () => new Response(new Blob(['image'], { type: 'image/png' }))),
)
if (!navigator.clipboard) {
Object.defineProperty(globalThis.navigator, 'clipboard', {
value: {
write: vi.fn(),
},
writable: true,
configurable: true,
})
}
const clipboardTarget = navigator.clipboard as {
write: (items: ClipboardItem[]) => Promise<void>
}
// In some test environments `write` lives on the prototype rather than
// the clipboard instance itself; locate the actual owner so vi.spyOn
// patches the right object.
const writeOwner = Object.prototype.hasOwnProperty.call(clipboardTarget, 'write')
? clipboardTarget
: (Object.getPrototypeOf(clipboardTarget) as {
write: (items: ClipboardItem[]) => Promise<void>
})
vi.spyOn(writeOwner, 'write').mockImplementation((items: ClipboardItem[]) => {
return mocks.clipboardWrite(items)
const setup = (props: Partial<ComponentProps<typeof ImagePreview>> = {}) => {
const user = userEvent.setup()
const write = navigator.clipboard.write.bind(navigator.clipboard)
const clipboardWrite = vi
.spyOn(navigator.clipboard, 'write')
.mockImplementation(async (items) => {
// A browser consumes promised image data before reporting a successful clipboard write.
await Promise.all(items.map((item) => item.getType('image/png')))
await write(items)
})
globalThis.ClipboardItem = class {
public readonly data: Record<string, Blob | Promise<Blob>>
constructor(data: Record<string, Blob | Promise<Blob>>) {
this.data = data
}
} as unknown as typeof ClipboardItem
vi.spyOn(window, 'open').mockImplementation((...args: Parameters<Window['open']>) => {
return mocks.windowOpen(...args)
})
})
afterEach(() => {
globalThis.ClipboardItem = originalClipboardItem
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render preview in portal with image from url', () => {
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
expect(overlay).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'Preview Image' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute(
'src',
'https://example.com/image.png',
)
})
it.each(['', ' '])('names the dialog when the image title is %j', (title) => {
render(<ImagePreview url={dataImage} title={title} onCancel={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'workflow.common.preview' })).toBeInTheDocument()
})
it('should convert plain base64 string into data image src', () => {
render(<ImagePreview url={base64Image} title="Preview Image" onCancel={vi.fn()} />)
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', dataImage)
})
})
describe('Hotkeys', () => {
it('navigates images from the focused preview and closes with Escape', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const onPrev = vi.fn()
const onNext = vi.fn()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
onPrev={onPrev}
onNext={onNext}
/>,
)
await user.click(getCopyButton())
await user.keyboard('{ArrowLeft}{ArrowRight}{Escape}')
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onPrev).toHaveBeenCalledTimes(1)
expect(onNext).toHaveBeenCalledTimes(1)
})
it('leaves outside arrows and unavailable image navigation unclaimed', () => {
render(
<ImagePreview url="https://example.com/image.png" title="Preview" onCancel={vi.fn()} />,
)
const outside = new KeyboardEvent('keydown', {
key: 'ArrowUp',
bubbles: true,
cancelable: true,
})
fireEvent(document.body, outside)
expect(outside.defaultPrevented).toBe(false)
const unavailable = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
})
fireEvent(screen.getByRole('dialog'), unavailable)
expect(unavailable.defaultPrevented).toBe(false)
})
it('should zoom in and out from keyboard up/down hotkeys', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const image = screen.getByRole('img', { name: 'Preview Image' })
await user.click(getCopyButton())
await user.keyboard('{ArrowUp}')
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
await user.keyboard('{ArrowDown}')
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})
})
})
describe('User Interactions', () => {
it('should not close when image content is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
/>,
)
await user.click(screen.getByRole('img', { name: 'Preview Image' }))
expect(onCancel).not.toHaveBeenCalled()
})
it('should call onCancel when close button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
/>,
)
const closeButton = getCloseButton()
await user.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should zoom in and out with wheel interactions', async () => {
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
const image = screen.getByRole('img', { name: 'Preview Image' })
act(() => {
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }))
})
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
act(() => {
overlay.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 100 }))
})
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})
})
it('should update position while dragging when zoomed in and stop dragging on mouseup', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const overlay = getOverlay()
const image = screen.getByRole('img', { name: 'Preview Image' }) as HTMLImageElement
const imageParent = image.parentElement
if (!imageParent) throw new Error('Image parent element not found')
vi.spyOn(image, 'getBoundingClientRect').mockReturnValue({
width: 200,
height: 120,
top: 0,
left: 0,
bottom: 120,
right: 200,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
vi.spyOn(imageParent, 'getBoundingClientRect').mockReturnValue({
width: 100,
height: 100,
top: 0,
left: 0,
bottom: 100,
right: 100,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
const zoomInButton = getZoomInButton()
await user.click(zoomInButton)
act(() => {
overlay.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, clientX: 10, clientY: 10 }),
)
})
act(() => {
overlay.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 200, clientY: -100 }),
)
})
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(70px, -22px)' })
})
act(() => {
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
fireEvent.mouseMove(overlay, { clientX: 0, clientY: 0 })
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(70px, -22px)' })
})
})
describe('Action Buttons', () => {
it.each(['https://example.com/image.png', '/image-proxy/github/example'])(
'should open valid url %s in new tab',
async (url) => {
const user = userEvent.setup()
render(<ImagePreview url={url} title="Preview Image" onCancel={vi.fn()} />)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.windowOpen).toHaveBeenCalledWith(url, '_blank')
},
)
it.each([dataImage, base64Image])(
'opens image %s with the title treated as text',
async (url) => {
const user = userEvent.setup()
const popupDocument = document.implementation.createHTMLDocument('')
mocks.windowOpen.mockReturnValue({ document: popupDocument } as Window)
const title = '" /><script>window.injected = true</script>'
render(<ImagePreview url={url} title={title} onCancel={vi.fn()} />)
await user.click(getOpenInTabButton())
expect(mocks.windowOpen).toHaveBeenCalledOnce()
const image = popupDocument.querySelector('img')!
expect(image.src).toBe(dataImage)
expect(image.alt).toBe(title)
expect(popupDocument.querySelector('script')).toBeNull()
},
)
it('should show error toast when opening unsupported url', async () => {
const user = userEvent.setup()
render(<ImagePreview url="file:///tmp/image.png" title="Preview Image" onCancel={vi.fn()} />)
const openInTabButton = getOpenInTabButton()
await user.click(openInTabButton)
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Unable to open image: file:///tmp/image.png',
})
})
it('keeps the preview open and reports clipboard denial without downloading or navigating', async () => {
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mocks.clipboardWrite.mockRejectedValue(new Error('copy failed'))
render(<ImagePreview url={dataImage} title="Preview Image" onCancel={vi.fn()} />)
const copyButton = getCopyButton()
await user.click(copyButton)
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'common.operation.imageCopyFailed',
}),
)
expect(mocks.downloadUrl).not.toHaveBeenCalled()
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('copies a remote PNG and starts the clipboard write before the image fetch resolves', async () => {
const user = userEvent.setup()
const png = new Blob(['png bytes'], { type: 'image/png' })
let resolveFetch!: (response: Response) => void
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve
}),
)
vi.stubGlobal('fetch', fetchMock)
let copiedBlob: Blob | undefined
mocks.clipboardWrite.mockImplementation(async ([item]) => {
copiedBlob = await (item as unknown as { data: Record<string, Promise<Blob>> }).data[
'image/png'
]
})
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1)
expect(copiedBlob).toBeUndefined()
resolveFetch(new Response(png))
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })),
)
expect(copiedBlob?.type).toBe('image/png')
expect(await copiedBlob?.text()).toBe('png bytes')
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
it('converts a JPEG to PNG instead of labelling JPEG bytes as PNG', async () => {
const user = userEvent.setup()
const jpeg = new Blob(['jpeg bytes'], { type: 'image/jpeg' })
const png = new Blob(['encoded png'], { type: 'image/png' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(jpeg)))
const bitmap = { width: 12, height: 8, close: vi.fn() }
vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) =>
callback(png),
)
let copiedBlob: Blob | undefined
mocks.clipboardWrite.mockImplementation(async ([item]) => {
copiedBlob = await (item as unknown as { data: Record<string, Promise<Blob>> }).data[
'image/png'
]
})
render(
<ImagePreview
url="https://example.com/image.jpg"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
await waitFor(() => expect(copiedBlob).toBe(png))
expect(bitmap.close).toHaveBeenCalled()
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
it('reports an image fetch failure without leaving the preview', async () => {
const user = userEvent.setup()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
mocks.clipboardWrite.mockImplementation(async ([item]) => {
await (item as unknown as { data: Record<string, Promise<Blob>> }).data['image/png']
})
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
await user.click(getCopyButton())
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })),
)
expect(mocks.downloadUrl).not.toHaveBeenCalled()
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
})
it('should copy image and show success toast', async () => {
const user = userEvent.setup()
let copiedBlob: Blob | undefined
mocks.clipboardWrite.mockImplementation(async ([item]) => {
copiedBlob = await (item as unknown as { data: Record<string, Promise<Blob>> }).data[
'image/png'
]
})
render(<ImagePreview url={dataImage} title="Preview Image" onCancel={vi.fn()} />)
const copyButton = getCopyButton()
await user.click(copyButton)
await waitFor(() => {
expect(mocks.clipboardWrite).toHaveBeenCalledTimes(1)
})
await waitFor(() => expect(copiedBlob).toBeDefined())
expect(fetch).toHaveBeenCalledWith(dataImage)
expect(await copiedBlob!.text()).toBe('image')
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
it.each([
['https://example.com/image.png', 'Preview Image'],
['/image-proxy/github/example', ''],
[dataImage, 'Screenshot.png'],
])('downloads image bytes without opening a tab for %s', async (url, title) => {
const user = userEvent.setup()
const createObjectURL = vi
.spyOn(window.URL, 'createObjectURL')
.mockReturnValue('blob:download-image')
const revokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
const downloads: { href: string; name: string; target: string }[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
downloads.push({ href: this.href, name: this.download, target: this.target })
})
render(<ImagePreview url={url} title={title} onCancel={vi.fn()} />)
await user.click(getDownloadButton())
await waitFor(() =>
expect(downloads).toEqual([
{
href: 'blob:download-image',
name: title || 'image.png',
target: '',
},
]),
)
const blob = createObjectURL.mock.calls[0]?.[0] as Blob
expect(blob.type).toBe('image/png')
expect(await blob.text()).toBe('image')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:download-image')
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
})
it.each(['network', 'http'])(
'keeps the preview open when a download fails: %s',
async (failure) => {
const user = userEvent.setup()
if (failure === 'network')
vi.mocked(fetch).mockRejectedValue(new TypeError('Failed to fetch'))
else vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 403 }))
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
render(<ImagePreview url="https://example.com/image.png" title="" onCancel={vi.fn()} />)
await user.click(getDownloadButton())
await waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith({
type: 'error',
message: 'common.operation.downloadFailed',
}),
)
expect(click).not.toHaveBeenCalled()
expect(mocks.windowOpen).not.toHaveBeenCalled()
expect(getOverlay()).toBeInTheDocument()
},
)
it('should zoom with dedicated zoom buttons', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={vi.fn()}
/>,
)
const image = screen.getByRole('img', { name: 'Preview Image' })
const zoomInButton = getZoomInButton()
const zoomOutButton = getZoomOutButton()
await user.click(zoomInButton)
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
await user.click(zoomOutButton)
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})
})
})
const windowOpen = vi.spyOn(window, 'open').mockReturnValue(null)
const download = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const onCancel = vi.fn()
render(
<>
<ImagePreview
url="https://example.com/image.png"
title="Preview Image"
onCancel={onCancel}
{...props}
/>
<AppToastHost timeout={0} />
</>,
)
return { user, clipboardWrite, windowOpen, download, onCancel }
}
const readPng = async () => {
const [item] = await navigator.clipboard.read()
expect(item?.types).toEqual(['image/png'])
return item!.getType('image/png')
}
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi
.fn<typeof fetch>()
.mockImplementation(async () => new Response(new Blob(['image'], { type: 'image/png' }))),
)
})
afterEach(() => {
toast.dismiss()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each([
['https://example.com/image.png', 'https://example.com/image.png'],
[base64Image, dataImage],
])('shows the preview source for %s', (url, src) => {
setup({ url })
expect(screen.getByRole('dialog', { name: 'Preview Image' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', src)
})
it.each(['', ' '])('names an untitled preview for %j', (title) => {
setup({ title })
expect(screen.getByRole('dialog', { name: 'workflow.common.preview' })).toBeInTheDocument()
})
it('navigates the preview with arrows and closes with Escape', async () => {
const onPrev = vi.fn()
const onNext = vi.fn()
const { user, onCancel } = setup({ onPrev, onNext })
await user.click(screen.getByRole('img', { name: 'Preview Image' }))
await user.keyboard('{ArrowLeft}{ArrowRight}{Escape}')
expect(onPrev).toHaveBeenCalledOnce()
expect(onNext).toHaveBeenCalledOnce()
expect(onCancel).toHaveBeenCalledOnce()
})
it('leaves outside arrows and unavailable navigation unclaimed', () => {
setup()
const outside = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })
fireEvent(document.body, outside)
expect(outside.defaultPrevented).toBe(false)
const unavailable = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
})
fireEvent(screen.getByRole('dialog', { name: 'Preview Image' }), unavailable)
expect(unavailable.defaultPrevented).toBe(false)
})
it('keeps the image open until the close action is chosen', async () => {
const { user, onCancel } = setup()
await user.click(screen.getByRole('img', { name: 'Preview Image' }))
expect(onCancel).not.toHaveBeenCalled()
await user.click(button('cancel'))
expect(onCancel).toHaveBeenCalledOnce()
})
it.each(['https://example.com/image.png', '/image-proxy/github/example'])(
'opens %s in a new tab',
async (url) => {
const { user, windowOpen } = setup({ url })
await user.click(button('openInNewTab'))
expect(windowOpen).toHaveBeenCalledWith(url, '_blank')
},
)
it.each([dataImage, base64Image])(
'opens inline image %s with its title treated as text',
async (url) => {
render(<iframe title="Image tab" />)
const popup = screen.getByTitle<HTMLIFrameElement>('Image tab').contentWindow
if (!popup) throw new Error('Popup document unavailable')
const title = '\" /><script>window.injected = true</script>'
const { user, windowOpen } = setup({ url, title })
windowOpen.mockReturnValue(popup)
await user.click(button('openInNewTab'))
expect(windowOpen).toHaveBeenCalledOnce()
const image = popup.document.querySelector('img')
expect(image?.src).toBe(dataImage)
expect(image?.alt).toBe(title)
expect(popup.document.querySelector('script')).toBeNull()
},
)
it('reports unsupported URLs without opening a tab', async () => {
const { user, windowOpen } = setup({ url: 'file:///tmp/image.png' })
await user.click(button('openInNewTab'))
expect(await screen.findByText('Unable to open image: file:///tmp/image.png')).toBeInTheDocument()
expect(windowOpen).not.toHaveBeenCalled()
})
it.each(['permission', 'fetch'] as const)(
'reports clipboard %s failure without downloading or navigating',
async (failure) => {
const { user, clipboardWrite, windowOpen, download, onCancel } = setup()
vi.spyOn(console, 'error').mockImplementation(() => {})
if (failure === 'permission')
clipboardWrite.mockRejectedValueOnce(new Error('Clipboard denied'))
else vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'))
await user.click(button('copyImage'))
expect(await screen.findByText('common.operation.imageCopyFailed')).toBeInTheDocument()
expect(download).not.toHaveBeenCalled()
expect(windowOpen).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
expect(screen.getByRole('dialog', { name: 'Preview Image' })).toBeInTheDocument()
},
)
it('starts the clipboard write in the click gesture before the remote PNG arrives', async () => {
const request = createDeferred<Response>()
vi.mocked(fetch).mockReturnValueOnce(request.promise)
const { user, clipboardWrite, download } = setup()
await user.click(button('copyImage'))
expect(clipboardWrite).toHaveBeenCalledOnce()
expect(screen.queryByText('common.operation.imageCopied')).not.toBeInTheDocument()
expect(await navigator.clipboard.read()).toEqual([])
await act(async () =>
request.resolve(new Response(new Blob(['png bytes'], { type: 'image/png' }))),
)
expect(await screen.findByText('common.operation.imageCopied')).toBeInTheDocument()
const png = await readPng()
expect(png.type).toBe('image/png')
expect(await png.text()).toBe('png bytes')
expect(download).not.toHaveBeenCalled()
})
it('converts JPEG bytes to PNG for the clipboard', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(new Blob(['jpeg bytes'], { type: 'image/jpeg' })),
)
const png = new Blob(['encoded png'], { type: 'image/png' })
const bitmap: ImageBitmap = { width: 12, height: 8, close: vi.fn() }
vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap))
const context = document.createElement('canvas').getContext('2d')
if (!context) throw new Error('Canvas context unavailable')
vi.spyOn(context, 'drawImage').mockImplementation(() => {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(png))
const { user } = setup({ url: 'https://example.com/image.jpg' })
await user.click(button('copyImage'))
expect(await screen.findByText('common.operation.imageCopied')).toBeInTheDocument()
const copied = await readPng()
expect(copied.type).toBe('image/png')
expect(await copied.text()).toBe('encoded png')
expect(bitmap.close).toHaveBeenCalledOnce()
})
it('copies an inline image and reports success', async () => {
const { user } = setup({ url: dataImage })
await user.click(button('copyImage'))
expect(await screen.findByText('common.operation.imageCopied')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(dataImage)
expect(await (await readPng()).text()).toBe('image')
})
it.each([
['https://example.com/image.png', 'Preview Image'],
['/image-proxy/github/example', ''],
[dataImage, 'Screenshot.png'],
])('downloads image bytes without opening a tab for %s', async (url, title) => {
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:download-image')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const { user, download, windowOpen, onCancel } = setup({ url, title })
const downloads: { href: string; name: string; target: string }[] = []
download.mockImplementation(function (this: HTMLAnchorElement) {
downloads.push({ href: this.href, name: this.download, target: this.target })
})
await user.click(button('download'))
await waitFor(() =>
expect(downloads).toEqual([
{ href: 'blob:download-image', name: title || 'image.png', target: '' },
]),
)
const blob = createObjectURL.mock.calls[0]?.[0]
if (!(blob instanceof Blob)) throw new Error('Expected downloaded image bytes')
expect(blob.type).toBe('image/png')
expect(await blob.text()).toBe('image')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:download-image')
expect(windowOpen).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it.each(['network', 'http'])(
'keeps the preview open when a download fails: %s',
async (failure) => {
if (failure === 'network')
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch'))
else vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 403 }))
const { user, download, windowOpen, onCancel } = setup()
await user.click(button('download'))
expect(await screen.findByText('common.operation.downloadFailed')).toBeInTheDocument()
expect(download).not.toHaveBeenCalled()
expect(windowOpen).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
},
)
@@ -21,7 +21,7 @@ it('uploads a selected image through the real hook and permits selecting the sam
{() => <span>Upload image</span>}
</Uploader>,
)
const input = screen.getByTestId('local-file-input') as HTMLInputElement
const input = screen.getByLabelText('Upload image') as HTMLInputElement
const file = new File(['image'], 'demo.png', { type: 'image/png' })
await user.upload(input, file)
@@ -57,7 +57,7 @@ it('blocks file selection when disabled', async () => {
{() => <span>Upload image</span>}
</Uploader>,
)
const input = screen.getByTestId('local-file-input') as HTMLInputElement
const input = screen.getByLabelText('Upload image') as HTMLInputElement
expect(input).toBeDisabled()
await user.upload(input, new File(['image'], 'demo.png', { type: 'image/png' }))
expect(upload).not.toHaveBeenCalled()
@@ -1,157 +1,88 @@
import type { TFunction } from 'i18next'
import { ORPCError } from '@orpc/client'
import { waitFor } from '@testing-library/react'
import { upload } from '@/service/base'
import { createHttpError } from '@/service/http-error'
import { withSelectorKey } from '@/test/i18n-mock'
import { createInstance } from 'i18next'
import { getImageUploadErrorMessage, imageUpload } from '../utils'
vi.mock('@/service/base', () => ({
upload: vi.fn(),
const upload = vi.hoisted(() => vi.fn<typeof import('@/service/base').upload>())
vi.mock('@/service/base', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/base')>()),
upload,
}))
describe('image-uploader utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getImageUploadErrorMessage', () => {
it('should return backend message when error code is forbidden', () => {
const translate = vi.fn((key: string, _options?: Record<string, unknown>) => key)
const t = withSelectorKey(translate) as unknown as TFunction<['common']>
const result = getImageUploadErrorMessage(
createHttpError(new Response(null, { status: 400 }), {
code: 'forbidden',
message: 'Forbidden by policy',
}),
'Default error',
t,
)
expect(result).toBe('Forbidden by policy')
expect(translate).not.toHaveBeenCalled()
})
it('should return translated message when error code is file_extension_blocked', () => {
const translate = vi.fn(() => 'common.fileUploader.fileExtensionBlocked')
const t = withSelectorKey(translate) as unknown as TFunction<['common']>
const result = getImageUploadErrorMessage(
createHttpError(new Response(null, { status: 400 }), { code: 'file_extension_blocked' }),
'Default error',
t,
)
expect(result).toBe('common.fileUploader.fileExtensionBlocked')
expect(translate).toHaveBeenCalledWith('fileUploader.fileExtensionBlocked', { ns: 'common' })
})
it('should return default message when error code is unknown', () => {
const translate = vi.fn((key: string, _options?: Record<string, unknown>) => key)
const t = withSelectorKey(translate) as unknown as TFunction<['common']>
const result = getImageUploadErrorMessage(
createHttpError(new Response(null, { status: 400 }), { code: 'unexpected_error' }),
'Default error',
t,
)
expect(result).toBe('Default error')
expect(translate).not.toHaveBeenCalled()
})
it('should return default message when error is missing response code', () => {
const translate = vi.fn((key: string, _options?: Record<string, unknown>) => key)
const t = withSelectorKey(translate) as unknown as TFunction<['common']>
const result = getImageUploadErrorMessage(undefined, 'Default error', t)
expect(result).toBe('Default error')
expect(translate).not.toHaveBeenCalled()
})
})
describe('imageUpload', () => {
const createCallbacks = () => ({
onProgressCallback: vi.fn<(progress: number) => void>(),
onSuccessCallback: vi.fn<(res: { id: string }) => void>(),
onErrorCallback: vi.fn<(error?: unknown) => void>(),
})
it('should upload file and call success callback', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockResolvedValue({ id: 'uploaded-id' })
imageUpload({ file, ...callbacks }, true, '/files/upload')
expect(upload).toHaveBeenCalledTimes(1)
const [options, isPublic, url] = vi.mocked(upload).mock.calls[0]!
expect(isPublic).toBe(true)
expect(url).toBe('/files/upload')
expect(options.xhr).toBeInstanceOf(XMLHttpRequest)
expect(options.data).toBeInstanceOf(FormData)
expect((options.data as FormData).get('file')).toBe(file)
await waitFor(() => {
expect(callbacks.onSuccessCallback).toHaveBeenCalledWith({ id: 'uploaded-id' })
})
expect(callbacks.onErrorCallback).not.toHaveBeenCalled()
})
it('should call error callback when upload fails', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
const error = new Error('Upload failed')
vi.mocked(upload).mockRejectedValue(error)
imageUpload({ file, ...callbacks })
await waitFor(() => {
expect(callbacks.onErrorCallback).toHaveBeenCalledWith(error)
})
expect(callbacks.onSuccessCallback).not.toHaveBeenCalled()
})
it('should report progress percentage when progress is computable', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockImplementation(
(options: { onprogress?: (e: ProgressEvent) => void }) => {
options.onprogress?.(
new ProgressEvent('progress', { lengthComputable: true, loaded: 5, total: 8 }),
)
return Promise.resolve({ id: 'uploaded-id' })
},
)
imageUpload({ file, ...callbacks })
expect(callbacks.onProgressCallback).toHaveBeenCalledWith(62)
await waitFor(() =>
expect(callbacks.onSuccessCallback).toHaveBeenCalledWith({ id: 'uploaded-id' }),
)
})
it('should not report progress when length is not computable', async () => {
const file = new File(['hello'], 'image.png', { type: 'image/png' })
const callbacks = createCallbacks()
vi.mocked(upload).mockImplementation(
(options: { onprogress?: (e: ProgressEvent) => void }) => {
options.onprogress?.(
new ProgressEvent('progress', { lengthComputable: false, loaded: 5, total: 8 }),
)
return Promise.resolve({ id: 'uploaded-id' })
},
)
imageUpload({ file, ...callbacks })
expect(callbacks.onProgressCallback).not.toHaveBeenCalled()
await waitFor(() =>
expect(callbacks.onSuccessCallback).toHaveBeenCalledWith({ id: 'uploaded-id' }),
)
})
const i18n = createInstance()
beforeAll(async () => {
await i18n.init({
lng: 'en',
resources: {
en: { common: { fileUploader: { fileExtensionBlocked: 'This file type is blocked.' } } },
},
})
})
beforeEach(() => {
upload.mockReset().mockResolvedValue({ id: 'uploaded-id' })
})
it.each([
{
error: new ORPCError('forbidden', { message: 'Forbidden by policy' }),
expected: 'Forbidden by policy',
},
{ error: new ORPCError('file_extension_blocked'), expected: 'This file type is blocked.' },
{ error: new ORPCError('unexpected_error'), expected: 'Upload failed' },
{ error: new Error('Network unavailable'), expected: 'Upload failed' },
])('returns the user-facing error for $error.code / $error.message', ({ error, expected }) => {
expect(getImageUploadErrorMessage(error, 'Upload failed', i18n.getFixedT('en', ['common']))).toBe(
expected,
)
})
const file = () => new File(['image'], 'image.png', { type: 'image/png' })
const callbacks = () => ({
onProgressCallback: vi.fn<(progress: number) => void>(),
onSuccessCallback: vi.fn<(res: { id: string }) => void>(),
onErrorCallback: vi.fn<(error?: unknown) => void>(),
})
it('passes the file and upload destination to the service and reports the server result', async () => {
const selected = file()
const events = callbacks()
imageUpload({ file: selected, ...events }, true, '/files/upload')
expect(upload).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ data: expect.any(FormData) }),
true,
'/files/upload',
)
expect(upload.mock.calls[0]![0].data.get('file')).toBe(selected)
await waitFor(() =>
expect(events.onSuccessCallback).toHaveBeenCalledExactlyOnceWith({ id: 'uploaded-id' }),
)
expect(events.onErrorCallback).not.toHaveBeenCalled()
})
it('forwards a failed upload without reporting success', async () => {
const error = new Error('Upload failed')
upload.mockRejectedValue(error)
const events = callbacks()
imageUpload({ file: file(), ...events })
await waitFor(() => expect(events.onErrorCallback).toHaveBeenCalledExactlyOnceWith(error))
expect(events.onSuccessCallback).not.toHaveBeenCalled()
})
it.each([true, false])(
'only reports a progress percentage when the total is known (%s)',
async (lengthComputable) => {
upload.mockImplementation(async (options) => {
options.onprogress?.call(
options.xhr,
new ProgressEvent('progress', { lengthComputable, loaded: 5, total: 8 }),
)
return { id: 'uploaded-id' }
})
const events = callbacks()
imageUpload({ file: file(), ...events })
if (lengthComputable) expect(events.onProgressCallback).toHaveBeenCalledExactlyOnceWith(62)
else expect(events.onProgressCallback).not.toHaveBeenCalled()
await waitFor(() =>
expect(events.onSuccessCallback).toHaveBeenCalledExactlyOnceWith({ id: 'uploaded-id' }),
)
},
)
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff. Show more