test(web): exercise external API dialogs and chat input owners (#44721)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-25 03:25:45 +00:00
1 parent 0b06322423
commit 4208034b07
4 files changed
+502 -751

No files matched your search

@@ -1,429 +1,264 @@
import type { ChatWithHistoryContextValue } from '../../context'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { render, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useRef, useState } from 'react'
import { fileUpload } from '@/app/components/base/file-uploader/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { TransferMethod } from '@/types/app'
import { ChatWithHistoryContext } from '../../context'
import { ChatWithHistoryContext, useChatWithHistoryContext } from '../../context'
import InputsFormContent from '../content'
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({
onChange,
value,
placeholder,
}: {
onChange: (v: string) => void
value: string
placeholder?: React.ReactNode
}) => (
<div>
<textarea
data-testid="mock-code-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{!!placeholder && (
<div data-testid="mock-code-editor-placeholder">
{React.isValidElement<{ children?: React.ReactNode }>(placeholder)
? placeholder.props.children
: ''}
</div>
)}
</div>
),
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({}),
usePathname: () => '/chat',
}))
const uploadedFile: FileEntity = {
id: 'uploaded-file-1',
uploadedId: 'server-file-1',
vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/components/base/file-uploader/utils')>()),
fileUpload: vi.fn(),
}))
const uploadedFile = createChatFileFixture({
name: 'report.pdf',
size: 100,
type: 'application/pdf',
supportFileType: 'document',
progress: 100,
transferMethod: TransferMethod.local_file,
}
// MOCK: file-uploader (stable, deterministic for unit tests)
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
value,
}: {
onChange: (files: FileEntity[]) => void
value?: FileEntity[]
}) => (
<button
type="button"
data-testid="mock-file-uploader"
onClick={() => onChange([...(value ?? []), uploadedFile])}
data-value-count={value?.length ?? 0}
/>
),
}))
const mockSetCurrentConversationInputs = vi.fn()
const mockHandleNewConversationInputsChange = vi.fn()
const defaultSystemParameters = {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
}
const createMockContext = (
overrides: Partial<ChatWithHistoryContextValue> = {},
): ChatWithHistoryContextValue => {
const base: ChatWithHistoryContextValue = {
appParams: {
system_parameters: defaultSystemParameters,
} as unknown as ChatWithHistoryContextValue['appParams'],
inputsForms: [
{ variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true },
],
currentConversationId: '123',
currentConversationInputs: { text_var: 'current-value' },
newConversationInputs: { text_var: 'new-value' },
newConversationInputsRef: { current: { text_var: 'ref-value' } } as React.RefObject<
Record<string, unknown>
>,
setCurrentConversationInputs: mockSetCurrentConversationInputs,
handleNewConversationInputsChange: mockHandleNewConversationInputsChange,
allInputsHidden: false,
appPrevChatTree: [],
pinnedConversationList: [],
conversationList: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handlePinConversation: vi.fn(),
handleUnpinConversation: vi.fn(),
handleDeleteConversation: vi.fn(),
conversationRenaming: false,
handleRenameConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
chatShouldReloadKey: '',
isMobile: false,
isInstalledApp: false,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } } as React.RefObject<{
handleStop: () => void
}>,
sidebarCollapseState: false,
handleSidebarCollapse: vi.fn(),
setClearChatList: vi.fn(),
setIsResponding: vi.fn(),
...overrides,
uploadedId: 'server-file-1',
})
type Options = Partial<
Pick<
ChatWithHistoryContextValue,
'currentConversationId' | 'currentConversationInputs' | 'newConversationInputs' | 'inputsForms'
>
>
function setup(options: Options = {}, showTip = false) {
function Caller() {
const defaults = useChatWithHistoryContext()
const [currentInputs, setCurrentInputs] = useState(
options.currentConversationInputs ?? { text_var: 'current-value' },
)
const [newInputs, setNewInputs] = useState(
options.newConversationInputs ?? { text_var: 'new-value' },
)
const newInputsRef = useRef(newInputs)
return (
<ChatWithHistoryContext.Provider
value={{
...defaults,
...options,
appParams: createChatConfigFixture({
system_parameters: createFileUploadConfigFixture(),
}),
inputsForms: options.inputsForms ?? [
{
variable: 'text_var',
type: InputVarType.textInput,
label: 'Text Label',
required: true,
},
],
currentConversationId: options.currentConversationId ?? '123',
currentConversationInputs: currentInputs,
newConversationInputs: newInputs,
newConversationInputsRef: newInputsRef,
setCurrentConversationInputs: setCurrentInputs,
handleNewConversationInputsChange: (value) => {
newInputsRef.current = value
setNewInputs(value)
},
}}
>
<InputsFormContent showTip={showTip} />
<output aria-label="Current conversation inputs">{JSON.stringify(currentInputs)}</output>
<output aria-label="New conversation inputs">{JSON.stringify(newInputs)}</output>
</ChatWithHistoryContext.Provider>
)
}
return base
return renderWithConsoleQuery(<Caller />)
}
const currentInputs = () =>
JSON.parse(screen.getByLabelText('Current conversation inputs').textContent!)
const newInputs = () => JSON.parse(screen.getByLabelText('New conversation inputs').textContent!)
const MockContextProvider = ({
children,
value,
}: {
children: React.ReactNode
value: ChatWithHistoryContextValue
}) => {
// We need to manage state locally to support controlled components
const [currentInputs, setCurrentInputs] = React.useState(value.currentConversationInputs)
const [newInputs, setNewInputs] = React.useState(value.newConversationInputs)
beforeEach(() => {
vi.mocked(fileUpload)
.mockReset()
.mockImplementation(({ file, onSuccessCallback }) => {
onSuccessCallback({
id: 'server-file-1',
name: file.name,
mime_type: file.type,
extension: 'pdf',
size: file.size,
created_at: 0,
created_by: 'user-1',
source_url: '',
preview_url: null,
})
})
})
const newInputsRef = React.useRef(newInputs)
const contextValue: ChatWithHistoryContextValue = {
...value,
currentConversationInputs: currentInputs,
newConversationInputs: newInputs,
newConversationInputsRef: newInputsRef as React.RefObject<Record<string, unknown>>,
setCurrentConversationInputs: (v: Record<string, unknown>) => {
setCurrentInputs(v)
value.setCurrentConversationInputs(v)
},
handleNewConversationInputsChange: (v: Record<string, unknown>) => {
newInputsRef.current = v
setNewInputs(v)
value.handleNewConversationInputsChange(v)
},
}
return (
<ChatWithHistoryContext.Provider value={contextValue}>
{children}
</ChatWithHistoryContext.Provider>
)
}
describe('InputsFormContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderWithContext = (
component: React.ReactNode,
contextValue: ChatWithHistoryContextValue,
) => {
return render(<MockContextProvider value={contextValue}>{component}</MockContextProvider>)
}
it('renders only visible forms and ignores hidden ones', () => {
const context = createMockContext({
it('renders only visible inputs and includes the footer tip when requested', () => {
setup(
{
inputsForms: [
{ variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true },
{ variable: 'hidden_var', type: InputVarType.textInput, label: 'Hidden', hide: true },
],
})
},
true,
)
expect(screen.getByRole('textbox', { name: 'Text Label' })).toBeVisible()
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
expect(screen.getByText('share.chat.chatFormTip')).toBeVisible()
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByText('Text Label')).toBeInTheDocument()
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
})
it('uses currentConversationInputs when currentConversationId is present', () => {
const context = createMockContext()
renderWithContext(<InputsFormContent />, context)
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
expect(input.value).toBe('current-value')
})
it('falls back to newConversationInputs when currentConversationId is empty', () => {
const context = createMockContext({
currentConversationId: '',
newConversationInputs: { text_var: 'new-value' },
})
renderWithContext(<InputsFormContent />, context)
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
expect(input.value).toBe('new-value')
})
it('updates both current and new inputs when form content changes', async () => {
it.each([
{ currentConversationId: '123', value: 'current-value' },
{ currentConversationId: '', value: 'new-value' },
])(
'edits the active input source and preserves both conversation drafts (%s)',
async ({ currentConversationId, value }) => {
const user = userEvent.setup()
const context = createMockContext()
renderWithContext(<InputsFormContent />, context)
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
setup({ currentConversationId })
const input = screen.getByRole('textbox', { name: 'Text Label' })
expect(input).toHaveValue(value)
expect(screen.queryByText('share.chat.chatFormTip')).not.toBeInTheDocument()
await user.clear(input)
await user.type(input, 'updated')
expect(input).toHaveValue('updated')
expect(currentInputs()).toEqual({ text_var: 'updated' })
expect(newInputs()).toEqual({ text_var: 'updated' })
},
)
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
expect.objectContaining({ text_var: 'updated' }),
)
expect(mockHandleNewConversationInputsChange).toHaveBeenLastCalledWith(
expect.objectContaining({ text_var: 'updated' }),
)
})
it('renders and handles number input updates', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [{ variable: 'num', type: InputVarType.number, label: 'Num' }],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
const input = screen.getByRole('spinbutton', { name: 'Num' }) as HTMLInputElement
expect(input).toHaveAttribute('type', 'number')
await user.type(input, '123')
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
expect.objectContaining({ num: '123' }),
)
})
it('renders numeric zero as zero', () => {
const context = createMockContext({
inputsForms: [{ variable: 'num', type: InputVarType.number, label: 'Num' }],
currentConversationInputs: { num: 0 },
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByRole('spinbutton', { name: 'Num' })).toHaveValue(0)
})
it('renders and handles paragraph input updates', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [{ variable: 'para', type: InputVarType.paragraph, label: 'Para' }],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
const textarea = screen.getByRole('textbox', { name: 'Para' }) as HTMLTextAreaElement
await user.type(textarea, 'hello')
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
expect.objectContaining({ para: 'hello' }),
)
})
it('updates boolean inputs through the real checkbox', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [{ variable: 'bool', type: InputVarType.checkbox, label: 'Bool' }],
})
renderWithContext(<InputsFormContent />, context)
const boolNode = screen.getByRole('checkbox', { name: /Bool/ })
await user.click(boolNode)
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
expect.objectContaining({ bool: true }),
)
expect(boolNode).toBeChecked()
})
it('handles select input with default value and updates', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [
{
variable: 'sel',
type: InputVarType.select,
label: 'Sel',
options: ['A', 'B'],
default: 'B',
},
],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
// Click Select to open
await user.click(screen.getByText('B'))
// Now option A should be available
const optionA = screen.getByText('A')
await user.click(optionA)
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
expect.objectContaining({ sel: 'A' }),
)
})
it('preserves a saved selection even after the option is removed', () => {
const context = createMockContext({
inputsForms: [
{
variable: 'sel',
type: InputVarType.select,
label: 'Sel',
options: ['A'],
default: undefined,
},
],
currentConversationInputs: { sel: 'existing' },
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByText('existing')).toBeInTheDocument()
})
it('renders and handles JSON object updates (uses mocked CodeEditor)', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [
{
variable: 'json',
type: InputVarType.jsonObject,
label: 'Json',
json_schema: '{ "a": 1 }',
},
],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByTestId('mock-code-editor-placeholder').textContent).toContain('{ "a": 1 }')
const jsonEditor = screen.getByTestId('mock-code-editor') as HTMLTextAreaElement
await user.clear(jsonEditor)
await user.paste('{"a":2}')
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
expect.objectContaining({ json: '{"a":2}' }),
)
})
it('handles single file uploader with existing value (using mocked uploader)', () => {
const context = createMockContext({
inputsForms: [
{
variable: 'single',
type: InputVarType.singleFile,
label: 'Single',
allowed_file_types: [],
allowed_file_extensions: [],
allowed_file_upload_methods: [],
},
],
currentConversationInputs: { single: uploadedFile },
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByTestId('mock-file-uploader')).toHaveAttribute('data-value-count', '1')
})
it('handles single file uploader with no value and updates (using mocked uploader)', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [
{
variable: 'single',
type: InputVarType.singleFile,
label: 'Single',
allowed_file_types: [],
allowed_file_extensions: [],
allowed_file_upload_methods: [],
},
],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
expect(screen.getByTestId('mock-file-uploader')).toHaveAttribute('data-value-count', '0')
const uploader = screen.getByTestId('mock-file-uploader')
await user.click(uploader)
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
expect.objectContaining({ single: uploadedFile }),
)
})
it('renders and handles multi files uploader updates (using mocked uploader)', async () => {
const user = userEvent.setup()
const context = createMockContext({
inputsForms: [
{ variable: 'multi', type: InputVarType.multiFiles, label: 'Multi', max_length: 3 },
],
currentConversationInputs: {},
})
renderWithContext(<InputsFormContent />, context)
const uploader = screen.getByTestId('mock-file-uploader')
await user.click(uploader)
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
expect.objectContaining({ multi: [uploadedFile] }),
)
})
it('renders footer tip only when showTip prop is true', () => {
const context = createMockContext()
const { rerender } = renderWithContext(<InputsFormContent showTip={false} />, context)
expect(screen.queryByText('share.chat.chatFormTip')).not.toBeInTheDocument()
rerender(
<MockContextProvider value={context}>
<InputsFormContent showTip={true} />
</MockContextProvider>,
)
expect(screen.getByText('share.chat.chatFormTip')).toBeInTheDocument()
it('updates numeric and paragraph fields without losing the other input', async () => {
const user = userEvent.setup()
setup({
inputsForms: [
{ variable: 'num', type: InputVarType.number, label: 'Num' },
{ variable: 'para', type: InputVarType.paragraph, label: 'Para' },
],
currentConversationInputs: { num: 0 },
newConversationInputs: { num: 0 },
})
const number = screen.getByRole('spinbutton', { name: 'Num' })
expect(number).toHaveValue(0)
await user.clear(number)
await user.type(number, '123')
await user.type(screen.getByRole('textbox', { name: 'Para' }), 'hello')
expect(number).toHaveValue(123)
expect(screen.getByRole('textbox', { name: 'Para' })).toHaveValue('hello')
expect(currentInputs()).toEqual({ num: '123', para: 'hello' })
expect(newInputs()).toEqual({ num: '123', para: 'hello' })
})
it('updates boolean inputs through the real checkbox', async () => {
const user = userEvent.setup()
setup({ inputsForms: [{ variable: 'bool', type: InputVarType.checkbox, label: 'Bool' }] })
const checkbox = screen.getByRole('checkbox', { name: /Bool/ })
await user.click(checkbox)
expect(checkbox).toBeChecked()
expect(currentInputs()).toEqual({ text_var: 'current-value', bool: true })
expect(newInputs()).toEqual({ text_var: 'new-value', bool: true })
})
it('replaces the select default with the chosen value', async () => {
const user = userEvent.setup()
setup({
inputsForms: [
{
variable: 'sel',
type: InputVarType.select,
label: 'Sel',
options: ['A', 'B'],
default: 'B',
},
],
currentConversationInputs: {},
})
const select = screen.getByRole('combobox', { name: 'Sel' })
expect(select).toHaveTextContent('B')
await user.click(select)
await user.click(screen.getByRole('option', { name: 'A' }))
expect(select).toHaveTextContent('A')
expect(currentInputs()).toEqual({ sel: 'A' })
expect(newInputs()).toEqual({ text_var: 'new-value', sel: 'A' })
})
it('preserves a saved selection after its option is removed', () => {
setup({
inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A'] }],
currentConversationInputs: { sel: 'existing' },
})
expect(screen.getByRole('combobox', { name: 'Sel' })).toHaveTextContent('existing')
})
it('edits JSON through the real code-editor wrapper', async () => {
const user = userEvent.setup()
setup({
inputsForms: [
{ variable: 'json', type: InputVarType.jsonObject, label: 'Json', json_schema: '{ "a": 1 }' },
],
currentConversationInputs: {},
})
expect(screen.getByText('{ "a": 1 }')).toBeVisible()
// Monaco itself is an external editor boundary supplied by vitest.setup.ts.
const editor = screen.getByTestId('monaco-editor')
await user.click(editor)
await user.paste('{"a":2}')
expect(editor).toHaveValue('{"a":2}')
expect(currentInputs()).toEqual({ json: '{"a":2}' })
expect(newInputs()).toEqual({ text_var: 'new-value', json: '{"a":2}' })
})
const fileForm = (type: typeof InputVarType.singleFile | typeof InputVarType.multiFiles) => ({
variable: 'attachment',
type,
label: 'Attachment',
max_length: 3,
allowed_file_types: ['document'],
allowed_file_extensions: [],
allowed_file_upload_methods: [TransferMethod.local_file],
})
it('shows an existing single-file value', () => {
setup({
inputsForms: [fileForm(InputVarType.singleFile)],
currentConversationInputs: { attachment: uploadedFile },
})
expect(screen.getByText('report.pdf')).toBeVisible()
expect(
screen.getByRole('button', { name: 'common.fileUploader.uploadFromComputer' }),
).toBeDisabled()
})
it.each([InputVarType.singleFile, InputVarType.multiFiles])(
'uploads %s through the real file owner and stores its value shape',
async (type) => {
const user = userEvent.setup()
const { container } = setup({
inputsForms: [fileForm(type)],
currentConversationInputs: {},
newConversationInputs: {},
})
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument()
// Native file inputs have no useful role/name in the current uploader.
await user.upload(
container.querySelector<HTMLInputElement>('input[type="file"]')!,
new File(['report'], 'report.pdf', { type: 'application/pdf' }),
)
const file = expect.objectContaining({
name: 'report.pdf',
uploadedId: 'server-file-1',
progress: 100,
transferMethod: TransferMethod.local_file,
})
const attachment = type === InputVarType.singleFile ? file : [file]
await waitFor(() => expect(currentInputs()).toEqual({ attachment }))
expect(newInputs()).toEqual({ attachment })
expect(screen.getByText('report.pdf')).toBeVisible()
},
)
@@ -1,13 +1,15 @@
import type { ExternalKnowledgeApiListResponse } from '@dify/contracts/api/console/datasets/types.gen'
import { act, screen } from '@testing-library/react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createExternalAPI } from '@/service/datasets'
import { renderWithConfigurationModals as render } from '@/test/configuration-modals'
import ExternalAPIPanel from '../index'
const { getApis, openModal } = vi.hoisted(() => ({ getApis: vi.fn(), openModal: vi.fn() }))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ setShowExternalKnowledgeAPIModal: openModal }),
const { getApis } = vi.hoisted(() => ({ getApis: vi.fn() }))
vi.mock('@/service/datasets', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/datasets')>()),
createExternalAPI: vi.fn(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
@@ -40,7 +42,10 @@ const response: ExternalKnowledgeApiListResponse = {
}
describe('ExternalAPIPanel', () => {
beforeEach(() => getApis.mockResolvedValue(response))
beforeEach(() => {
getApis.mockResolvedValue(response)
vi.mocked(createExternalAPI).mockResolvedValue(response.data[0]!)
})
it('replaces loading with API cards and allows the panel to close', async () => {
const user = userEvent.setup()
@@ -78,14 +83,21 @@ describe('ExternalAPIPanel', () => {
getApis.mockResolvedValueOnce({ ...response, data: [], total: 0 })
render(<ExternalAPIPanel canManageExternalKnowledgeApi onClose={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'dataset.createExternalAPI' }))
expect(openModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: { name: '', settings: { endpoint: '', api_key: '' } },
datasetBindings: [],
isEditMode: false,
}),
)
await act(async () => openModal.mock.calls[0]![0].onSaveCallback())
const dialog = await screen.findByRole('dialog', { name: 'dataset.createExternalAPI' })
expect(within(dialog).getByLabelText(/Name/)).toHaveValue('')
expect(within(dialog).getByLabelText(/API Endpoint/)).toHaveValue('')
expect(within(dialog).getByLabelText(/API Key/)).toHaveValue('')
await user.type(within(dialog).getByLabelText(/Name/), 'Product knowledge')
await user.type(within(dialog).getByLabelText(/API Endpoint/), 'https://knowledge.example.com')
await user.type(within(dialog).getByLabelText(/API Key/), 'secret-key')
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Product knowledge',
settings: { endpoint: 'https://knowledge.example.com', api_key: 'secret-key' },
},
})
expect(await screen.findByText('Product knowledge')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /common.operation.edit Product knowledge/ }),
@@ -1,337 +1,224 @@
import type { ExternalKnowledgeApiResponse } from '@dify/contracts/api/console/datasets/types.gen'
import { screen, waitFor } from '@testing-library/react'
import { useQuery } from '@tanstack/react-query'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
// Import mocked services
import {
checkUsageExternalAPI,
deleteExternalAPI,
fetchExternalAPI,
updateExternalAPI,
} from '@/service/datasets'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { renderWithConfigurationModals } from '@/test/configuration-modals'
import { createConsoleQueryClient } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
import ExternalKnowledgeAPICard from '../index'
// Mock API services
vi.mock('@/service/datasets', () => ({
vi.mock('@/service/datasets', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/datasets')>()),
fetchExternalAPI: vi.fn(),
updateExternalAPI: vi.fn(),
deleteExternalAPI: vi.fn(),
checkUsageExternalAPI: vi.fn(),
}))
const { getApis } = vi.hoisted(() => ({ getApis: vi.fn() }))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'datasets.externalKnowledgeApi.get': getApis,
}),
}
})
// Mock contexts
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
const externalKnowledgeApiQueryKey = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({
input: {},
}).queryKey
let queryClient: ReturnType<typeof createConsoleQueryClient>
const render = (ui: React.ReactElement) => {
queryClient = createConsoleQueryClient()
queryClient.setQueryData(externalKnowledgeApiQueryKey, {
data: [],
has_more: false,
page: 1,
limit: 20,
total: 0,
})
return renderWithConsoleQuery(ui, { queryClient })
const api = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: { endpoint: 'https://api.example.com/knowledge', api_key: 'secret-key-123' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
} satisfies ExternalKnowledgeApiResponse
const listResponse = (data: ExternalKnowledgeApiResponse[]) => ({
data,
has_more: false,
page: 1,
limit: 20,
total: data.length,
})
const listOptions = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
function setup({ apis = [api], canManage = true } = {}) {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(listOptions.queryKey, listResponse(apis))
function ApiCards() {
const { data } = useQuery(listOptions)
return data?.data.map((item, index) => (
<ExternalKnowledgeAPICard
key={item.id}
api={item}
position={index + 1}
canManageExternalKnowledgeApi={canManage}
/>
))
}
return renderWithConfigurationModals(<ApiCards />, { queryClient })
}
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
}),
}))
describe('ExternalKnowledgeAPICard', () => {
const mockApi: ExternalKnowledgeApiResponse = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
const defaultProps = {
api: mockApi,
canManageExternalKnowledgeApi: true,
position: 1,
}
const editButtonName =
'common.operation.edit Test External API https://api.example.com/knowledge 1'
const deleteButtonName =
'common.operation.delete Test External API https://api.example.com/knowledge 1'
beforeEach(() => {
vi.clearAllMocks()
const edit = () =>
screen.getByRole('button', {
name: `common.operation.edit ${api.name} ${api.settings.endpoint} 1`,
})
const remove = () =>
screen.getByRole('button', {
name: `common.operation.delete ${api.name} ${api.settings.endpoint} 1`,
})
describe('Rendering', () => {
it('targets the correct API when cards have duplicate names and endpoints', async () => {
const user = userEvent.setup()
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
const duplicateApi = {
...mockApi,
id: 'api-456',
}
beforeEach(() => {
vi.mocked(fetchExternalAPI).mockReset().mockResolvedValue(api)
vi.mocked(updateExternalAPI).mockReset().mockResolvedValue(api)
vi.mocked(deleteExternalAPI).mockReset().mockResolvedValue({ result: 'success' })
vi.mocked(checkUsageExternalAPI).mockReset().mockResolvedValue({ is_using: false, count: 0 })
getApis.mockReset().mockResolvedValue(listResponse([api]))
})
render(
<>
<ExternalKnowledgeAPICard {...defaultProps} />
<ExternalKnowledgeAPICard
api={duplicateApi}
canManageExternalKnowledgeApi={true}
position={2}
/>
</>,
)
it('targets the correct API when cards have duplicate names and endpoints', async () => {
const user = userEvent.setup()
setup({ apis: [api, { ...api, id: 'api-456' }] })
await user.click(
screen.getByRole('button', {
name: `common.operation.delete ${api.name} ${api.settings.endpoint} 2`,
}),
)
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-456' })
expect(await screen.findByRole('alertdialog')).toBeVisible()
})
await user.click(
screen.getByRole('button', {
name: 'common.operation.delete Test External API https://api.example.com/knowledge 2',
}),
)
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-456' })
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
})
it('hides management actions without permission', () => {
setup({ canManage: false })
expect(screen.getByText(api.name)).toBeVisible()
expect(
screen.queryByRole('button', { name: /common.operation.(edit|delete)/ }),
).not.toBeInTheDocument()
})
it('should hide edit and delete buttons when external knowledge API management is unavailable', () => {
render(<ExternalKnowledgeAPICard {...defaultProps} canManageExternalKnowledgeApi={false} />)
expect(screen.queryByRole('button', { name: editButtonName })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: deleteButtonName })).not.toBeInTheDocument()
})
it('loads API details and bindings into the real edit dialog', async () => {
const user = userEvent.setup()
vi.mocked(fetchExternalAPI).mockResolvedValue({
...api,
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
})
setup()
await user.click(edit())
const dialog = await screen.findByRole('dialog', { name: 'dataset.editExternalAPIFormTitle' })
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: api.id })
expect(within(dialog).getByLabelText(/Name/)).toHaveValue(api.name)
expect(within(dialog).getByLabelText(/API Endpoint/)).toHaveValue(api.settings.endpoint)
expect(within(dialog).getByLabelText(/API Key/)).toHaveValue(api.settings.api_key)
expect(dialog).toHaveTextContent('1 dataset.editExternalAPIFormWarning.end')
})
describe('User Interactions - Edit', () => {
it('should fetch API details and open modal when edit button is clicked', async () => {
const user = userEvent.setup()
const mockResponse: ExternalKnowledgeApiResponse = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
it('saves edits through the modal and refreshes the displayed card', async () => {
const user = userEvent.setup()
const updated = {
...api,
name: 'Updated External API',
settings: { endpoint: 'https://updated.example.com/knowledge', api_key: 'updated-secret-key' },
}
getApis.mockResolvedValue(listResponse([updated]))
setup()
await user.click(edit())
const dialog = await screen.findByRole('dialog', { name: 'dataset.editExternalAPIFormTitle' })
for (const [label, value] of [
[/Name/, updated.name],
[/API Endpoint/, updated.settings.endpoint],
[/API Key/, updated.settings.api_key],
] as const) {
await user.clear(within(dialog).getByLabelText(label))
await user.type(within(dialog).getByLabelText(label), value)
}
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(updateExternalAPI).toHaveBeenCalledWith({
apiTemplateId: api.id,
body: { name: updated.name, settings: updated.settings },
})
expect(await screen.findByText(updated.name)).toBeVisible()
expect(screen.getByText(updated.settings.endpoint)).toBeVisible()
expect(screen.queryByText(api.name)).not.toBeInTheDocument()
})
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = screen.getByRole('button', { name: editButtonName })
it('shows the usage count before deleting a connected API', async () => {
const user = userEvent.setup()
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
setup()
await user.click(remove())
expect(await screen.findByRole('alertdialog')).toHaveTextContent('3')
expect(deleteExternalAPI).not.toHaveBeenCalled()
})
await user.click(editButton!)
it('deletes a confirmed API and removes its card after refreshing the list', async () => {
const user = userEvent.setup()
getApis.mockResolvedValue(listResponse([]))
setup()
await user.click(remove())
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'common.operation.confirm',
}),
)
await waitFor(() => expect(screen.queryByText(api.name)).not.toBeInTheDocument())
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: api.id })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
await waitFor(() => {
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: 'Test External API',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
},
isEditMode: true,
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
it('cancels deletion without changing the list', async () => {
const user = userEvent.setup()
setup()
await user.click(remove())
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'common.operation.cancel',
}),
)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByText(api.name)).toBeVisible()
expect(deleteExternalAPI).not.toHaveBeenCalled()
expect(getApis).not.toHaveBeenCalled()
})
it.each(['fetch', 'usage', 'delete'] as const)(
'preserves the card when the %s request fails',
async (operation) => {
const user = userEvent.setup()
const failure = new Error('Request failed')
const service =
operation === 'fetch'
? fetchExternalAPI
: operation === 'usage'
? checkUsageExternalAPI
: deleteExternalAPI
const request = createDeferred<never>()
vi.mocked(service).mockReturnValueOnce(request.promise)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
setup()
await user.click(operation === 'fetch' ? edit() : remove())
if (operation === 'delete')
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'common.operation.confirm',
}),
)
})
})
it('should handle fetch error gracefully', async () => {
const user = userEvent.setup()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = screen.getByRole('button', { name: editButtonName })
await user.click(editButton!)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching external knowledge API data:',
expect.any(Error),
)
})
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
consoleSpy.mockRestore()
})
it('should invalidate the generated list query after editing', async () => {
const user = userEvent.setup()
const mockResponse: ExternalKnowledgeApiResponse = {
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test External API',
description: 'Test API description',
settings: {
endpoint: 'https://api.example.com/knowledge',
api_key: 'secret-key-123',
},
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const editButton = screen.getByRole('button', { name: editButtonName })
await user.click(editButton!)
await waitFor(() => {
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
})
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
await modalCall.onEditCallback({
name: 'Updated External API',
settings: {
endpoint: 'https://updated.example.com/knowledge',
api_key: 'updated-secret-key',
},
})
expect(updateExternalAPI).toHaveBeenCalledWith({
apiTemplateId: 'api-123',
body: expect.objectContaining({
name: 'Updated External API',
settings: {
endpoint: 'https://updated.example.com/knowledge',
api_key: 'updated-secret-key',
},
}),
})
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(true)
})
})
describe('User Interactions - Delete', () => {
it('should show usage count in confirm dialog when API is in use', async () => {
const user = userEvent.setup()
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
await user.click(deleteButton!)
await waitFor(() => {
expect(screen.getByText(/3/))!.toBeInTheDocument()
})
})
it('should delete API and refresh list when confirmed', async () => {
const user = userEvent.setup()
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
await user.click(deleteButton!)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i }))!.toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
await user.click(confirmButton)
await waitFor(() => {
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(true)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const user = userEvent.setup()
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
await user.click(deleteButton!)
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i }))!.toBeInTheDocument()
})
const cancelButton = screen.getByRole('button', { name: /cancel/i })
await user.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
expect(deleteExternalAPI).not.toHaveBeenCalled()
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
})
})
it('should handle delete error gracefully', async () => {
const user = userEvent.setup()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
await user.click(deleteButton!)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i }))!.toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
await user.click(confirmButton)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error deleting external knowledge API:',
expect.any(Error),
)
})
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
consoleSpy.mockRestore()
})
it('should handle check usage error gracefully', async () => {
const user = userEvent.setup()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
render(<ExternalKnowledgeAPICard {...defaultProps} />)
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
await user.click(deleteButton!)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error checking external API usage:',
expect.any(Error),
)
})
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
consoleSpy.mockRestore()
})
})
})
await act(async () => request.reject(failure))
expect(screen.getByText(api.name)).toBeVisible()
expect(getApis).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
if (operation !== 'delete') expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
} finally {
consoleError.mockRestore()
}
},
)
@@ -1,14 +1,16 @@
import type { ExternalKnowledgeApiListResponse } from '@dify/contracts/api/console/datasets/types.gen'
import { act, screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createExternalAPI } from '@/service/datasets'
import { renderWithConfigurationModals as render } from '@/test/configuration-modals'
import ExternalApiSelection from '../ExternalApiSelection'
const mocks = vi.hoisted(() => ({ getApis: vi.fn(), refresh: vi.fn(), openModal: vi.fn() }))
const mocks = vi.hoisted(() => ({ getApis: vi.fn(), refresh: vi.fn() }))
vi.mock('@/next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ setShowExternalKnowledgeAPIModal: mocks.openModal }),
vi.mock('@/service/datasets', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/datasets')>()),
createExternalAPI: vi.fn(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
@@ -38,25 +40,29 @@ const response: ExternalKnowledgeApiListResponse = {
total: 2,
}
function Harness({ onChange = vi.fn() }: { onChange?: (value: object) => void }) {
function Harness() {
const [value, setValue] = useState({ external_knowledge_api_id: '', external_knowledge_id: '' })
return (
<ExternalApiSelection
{...value}
onChange={(next) => {
setValue((current) => ({ ...current, ...next }))
onChange(next)
}}
/>
<>
<ExternalApiSelection
{...value}
onChange={(next) => {
setValue((current) => ({ ...current, ...next }))
}}
/>
<output aria-label="Knowledge connection">{JSON.stringify(value)}</output>
</>
)
}
beforeEach(() => mocks.getApis.mockResolvedValue(response))
beforeEach(() => {
mocks.getApis.mockResolvedValue(response)
vi.mocked(createExternalAPI).mockResolvedValue(response.data[0]!)
})
it('defaults to the first API and keeps the chosen API while entering the knowledge ID', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Harness onChange={onChange} />)
render(<Harness />)
const selector = await screen.findByRole('button', { name: 'dataset.externalAPIPanelTitle' })
await waitFor(() => expect(selector).toHaveTextContent('API One'))
await user.click(selector)
@@ -64,7 +70,7 @@ it('defaults to the first API and keeps the chosen API while entering the knowle
expect(selector).toHaveTextContent('API Two')
expect(screen.queryByText('https://api2.com')).not.toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: 'dataset.externalKnowledgeId' }), 'kb-123')
expect(onChange).toHaveBeenLastCalledWith({
expect(JSON.parse(screen.getByLabelText('Knowledge connection').textContent!)).toEqual({
external_knowledge_api_id: 'api-2',
external_knowledge_id: 'kb-123',
})
@@ -74,8 +80,11 @@ it.each(['empty list', 'selector'] as const)(
'refreshes APIs after creation from the %s',
async (entry) => {
const user = userEvent.setup()
if (entry === 'empty list')
mocks.getApis.mockResolvedValueOnce({ ...response, data: [], total: 0 })
mocks.getApis.mockResolvedValueOnce({
...response,
data: entry === 'empty list' ? [] : [response.data[1]!],
total: entry === 'empty list' ? 0 : 1,
})
render(<Harness />)
if (entry === 'selector') {
await user.click(await screen.findByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
@@ -83,14 +92,22 @@ it.each(['empty list', 'selector'] as const)(
} else {
await user.click(await screen.findByRole('button', { name: 'dataset.noExternalKnowledge' }))
}
expect(mocks.openModal).toHaveBeenCalledWith(expect.objectContaining({ isEditMode: false }))
await act(async () => mocks.openModal.mock.calls[0]![0].onSaveCallback())
const dialog = await screen.findByRole('dialog', { name: 'dataset.createExternalAPI' })
await user.type(within(dialog).getByLabelText(/Name/), 'API One')
await user.type(within(dialog).getByLabelText(/API Endpoint/), 'https://api1.com')
await user.type(within(dialog).getByLabelText(/API Key/), 'secret-key')
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(createExternalAPI).toHaveBeenCalledWith({
body: { name: 'API One', settings: { endpoint: 'https://api1.com', api_key: 'secret-key' } },
})
await waitFor(() => expect(mocks.getApis).toHaveBeenCalledTimes(2))
expect(mocks.refresh).toHaveBeenCalledOnce()
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }),
).toHaveTextContent('API One'),
)
const selector = screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' })
if (entry === 'empty list') await waitFor(() => expect(selector).toHaveTextContent('API One'))
else {
expect(selector).toHaveTextContent('API Two')
expect(await screen.findByRole('button', { name: 'API One https://api1.com' })).toBeVisible()
}
},
)