mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
test(web): consolidate suites around real feature behavior (#44274)
This commit is contained in:
1 parent
7c7f93bd53
commit
ed5da3d11a
516 files changed
+39267
-74639
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
-5
@@ -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' }),
|
||||
|
||||
@@ -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'] }),
|
||||
}))
|
||||
|
||||
-5
@@ -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 },
|
||||
})
|
||||
})
|
||||
+27
-62
@@ -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()
|
||||
})
|
||||
})
|
||||
+4
-3
@@ -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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
+165
-246
@@ -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(),
|
||||
)
|
||||
})
|
||||
+56
-38
@@ -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()
|
||||
|
||||
+83
-94
@@ -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}
|
||||
|
||||
+84
-103
@@ -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)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
+9
-54
@@ -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(() => {
|
||||
|
||||
+14
-70
@@ -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()
|
||||
})
|
||||
|
||||
+25
-52
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
+5
-35
@@ -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
-525
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+354
-392
@@ -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()
|
||||
})
|
||||
+138
-313
@@ -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()
|
||||
})
|
||||
})
|
||||
+296
-635
@@ -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(),
|
||||
|
||||
+69
-104
@@ -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',
|
||||
)
|
||||
|
||||
+1
-11
@@ -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
@@ -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
@@ -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
+40
-38
@@ -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()
|
||||
},
|
||||
)
|
||||
+75
-55
@@ -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')
|
||||
|
||||
+13
-14
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-9
@@ -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()
|
||||
|
||||
+59
-114
@@ -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(
|
||||
|
||||
+48
-38
@@ -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()
|
||||
})
|
||||
|
||||
+25
-24
@@ -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'],
|
||||
|
||||
+2
-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()
|
||||
})
|
||||
+2
-1
@@ -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()
|
||||
})
|
||||
+10
-5
@@ -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()
|
||||
|
||||
+123
-194
@@ -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({
|
||||
|
||||
+6
-3
@@ -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()
|
||||
|
||||
+2
-1
@@ -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()
|
||||
})
|
||||
|
||||
+31
-20
@@ -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
Reference in new issue
Block a user