dify/web/features/new-rag/__tests__/add-source-page.spec.tsx
Stephen Zhou eac9f69ad1
feat(dataset): add crawl source selection (#39325)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-24 07:11:48 +00:00

1036 lines
38 KiB
TypeScript

import type {
GetKnowledgeSpacesByIdSourceConnectionsResponse,
GetSourceProvidersResponse,
} from '@dify/contracts/knowledge-fs/types.gen'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { StrictMode } from 'react'
import { render } from '@/test/console/render'
import { AddSourcePage } from '../add-source-page'
import { newKnowledgeSourceDraftStorageKey } from '../routes'
const routerMock = vi.hoisted(() => ({
push: vi.fn(),
replace: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({ useRouter: () => routerMock }))
const toastInfoMock = vi.hoisted(() => vi.fn())
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: { info: toastInfoMock },
}))
type ConnectionsInfiniteData = {
pages: GetKnowledgeSpacesByIdSourceConnectionsResponse[]
}
type ConnectionsInfiniteOptions = {
enabled?: boolean
getNextPageParam: (
lastPage: GetKnowledgeSpacesByIdSourceConnectionsResponse,
) => string | undefined
input: (pageParam: string | null) => unknown
initialPageParam: string | null
}
const queryState = vi.hoisted(() => ({
connections: {
data: { pages: [{ items: [] }] } as ConnectionsInfiniteData | undefined,
error: null as unknown,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
refetch: vi.fn(),
},
providers: {
data: undefined as GetSourceProvidersResponse | undefined,
error: null as unknown,
isPending: false,
refetch: vi.fn(),
},
}))
const clientMock = vi.hoisted(() => ({
createConnection: vi.fn(),
refreshConnection: vi.fn(),
}))
const queryClientMock = vi.hoisted(() => ({
invalidateQueries: vi.fn(),
}))
const providerQueryOptionsMock = vi.hoisted(() =>
vi.fn((options: { enabled?: boolean }) => ({
enabled: options.enabled,
queryKey: ['source-providers'],
})),
)
const connectionInfiniteOptionsMock = vi.hoisted(() =>
vi.fn((options: ConnectionsInfiniteOptions) => ({
enabled: options.enabled,
queryKey: ['source-connections'],
})),
)
const providerHookOptionsMock = vi.hoisted(() => vi.fn())
const connectionHookOptionsMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useInfiniteQuery: (options: unknown) => {
connectionHookOptionsMock(options)
return queryState.connections
},
useQuery: (options: unknown) => {
providerHookOptionsMock(options)
return queryState.providers
},
useQueryClient: () => queryClientMock,
}
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
postKnowledgeSpacesByIdSourceConnections: clientMock.createConnection,
postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh: clientMock.refreshConnection,
},
},
consoleQuery: {
knowledgeFs: {
getSourceProviders: {
queryOptions: providerQueryOptionsMock,
},
getKnowledgeSpacesByIdSourceConnections: {
infiniteOptions: connectionInfiniteOptionsMock,
key: vi.fn(() => ['source-connections']),
},
},
},
}))
const firecrawlProvider: GetSourceProvidersResponse['items'][number] = {
authKinds: ['api-key', 'endpoint'],
available: true,
capabilities: ['website-crawl'],
configuration: [
{
description: 'Plugin identifier',
name: 'pluginId',
required: true,
secret: false,
type: 'string',
},
{
description: 'Plugin provider',
name: 'provider',
required: true,
secret: false,
type: 'string',
},
{
description: 'Plugin datasource',
name: 'datasource',
required: true,
secret: false,
type: 'string',
},
{
description: 'Firecrawl API key',
format: 'password',
name: 'apiKey',
required: true,
secret: true,
type: 'string',
},
{
description: 'Self-hosted endpoint',
format: 'uri',
name: 'endpoint',
required: false,
secret: false,
type: 'string',
},
],
displayName: 'Plugin daemon website crawl',
id: 'plugin-daemon-website',
}
const connection = (
status: 'provisioning' | 'active' | 'expired' | 'error' | 'revoked',
version = 2,
) => ({
authKind: 'api-key' as const,
configuration: {
datasource: 'crawl',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
},
createdAt: '2026-07-20T10:00:00Z',
id: 'connection-1',
knowledgeSpaceId: 'space-1',
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
scopes: [],
status,
updatedAt: '2026-07-20T10:00:00Z',
version,
})
describe('AddSourcePage', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.sessionStorage.clear()
clientMock.createConnection.mockReset()
clientMock.refreshConnection.mockReset()
queryState.connections.refetch.mockReset()
queryState.providers.refetch.mockReset()
queryState.providers.data = { items: [firecrawlProvider] }
queryState.providers.error = null
queryState.providers.isPending = false
queryState.connections.data = { pages: [{ items: [] }] }
queryState.connections.error = null
queryState.connections.hasNextPage = false
queryState.connections.isFetchNextPageError = false
queryState.connections.isFetchingNextPage = false
queryState.connections.isPending = false
})
afterEach(() => {
globalThis.sessionStorage.clear()
})
it('loads the provider catalog and every scoped connection cursor page', () => {
queryState.providers.isPending = true
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(providerQueryOptionsMock).toHaveBeenCalledWith({
context: { silent: true },
enabled: true,
input: {},
retry: false,
})
const options = connectionInfiniteOptionsMock.mock.lastCall?.[0]
expect(options).toBeDefined()
if (!options) throw new Error('Expected connection infinite query options')
expect(options.input(null)).toEqual({ params: { id: 'space-1' }, query: { limit: 200 } })
expect(options.input('next')).toEqual({
params: { id: 'space-1' },
query: { cursor: 'next', limit: 200 },
})
expect(options.getNextPageParam({ items: [], nextCursor: 'next' })).toBe('next')
expect(options.initialPageParam).toBeNull()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('continues loading connection pages automatically', async () => {
queryState.connections.hasNextPage = true
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await waitFor(() => expect(queryState.connections.fetchNextPage).toHaveBeenCalledOnce())
})
it('stops website connection pagination after switching source type', async () => {
const user = userEvent.setup()
render(<AddSourcePage knowledgeSpaceId="space-1" />)
queryState.connections.hasNextPage = true
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
expect(queryState.connections.fetchNextPage).not.toHaveBeenCalled()
})
it.each(['onlineDocuments', 'onlineDrive'])(
'does not request website provider data for the %s state',
(initialSourceType) => {
queryState.providers.isPending = true
queryState.connections.isPending = true
render(<AddSourcePage initialSourceType={initialSourceType} knowledgeSpaceId="space-1" />)
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: false })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: false })
expect(screen.queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.providerUnavailable')).toBeInTheDocument()
},
)
it('stops automatic connection pagination after a cursor error', () => {
queryState.connections.error = new Error('next page failed')
queryState.connections.hasNextPage = true
queryState.connections.isFetchNextPageError = true
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(queryState.connections.fetchNextPage).not.toHaveBeenCalled()
expect(screen.getByText('dataset.newKnowledge.providerLoadFailed')).toBeInTheDocument()
})
it('finds the provider connection on a later loaded page', () => {
queryState.connections.data = { pages: [{ items: [] }, { items: [connection('active')] }] }
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toBeEnabled()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }),
).toBeDisabled()
})
it('offers replacement setup when the saved provider connection is revoked', () => {
queryState.connections.data = { pages: [{ items: [connection('revoked')] }] }
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
).toBeEnabled()
expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument()
})
it('restores a website draft handed off by the creation flow', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
render(
<AddSourcePage
initialSourceDraft={{
includeSubpages: false,
maxPages: 25,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}}
knowledgeSpaceId="space-1"
/>,
)
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toHaveValue(
'https://docs.dify.ai',
)
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ })).toHaveValue(
'Dify docs',
)
await user.click(screen.getByRole('button', { name: /dataset\.newKnowledge\.crawlOptions/ }))
expect(
screen.getByRole('checkbox', { name: 'dataset.newKnowledge.includeSubpages' }),
).not.toBeChecked()
expect(screen.getByRole('spinbutton', { name: 'dataset.newKnowledge.maxPages' })).toHaveValue(
25,
)
})
it('keeps the exact website provider selected and lets the user switch to Firecrawl', async () => {
const user = userEvent.setup()
render(
<AddSourcePage
initialSourceDraft={{
includeSubpages: true,
maxPages: 100,
provider: 'Jina Reader',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'manual',
}}
knowledgeSpaceId="space-1"
/>,
)
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeChecked()
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: false })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: false })
expect(screen.getByText('dataset.newKnowledge.providerUnavailable')).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'Firecrawl' }))
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
expect(providerHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
expect(connectionHookOptionsMock.mock.lastCall?.[0]).toMatchObject({ enabled: true })
})
it('clears a website dependency alert when the provider changes', async () => {
const user = userEvent.setup()
render(
<AddSourcePage
initialSourceDraft={{
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}}
knowledgeSpaceId="space-1"
/>,
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.crawlAndPreview' }))
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: 'Jina Reader' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('restores online document configuration and reaches an honest backend boundary', async () => {
const user = userEvent.setup()
render(
<AddSourcePage
initialSourceDraft={{
provider: 'Google Docs',
sourceName: 'Shared product docs',
sourceType: 'onlineDocuments',
syncPolicy: 'daily',
}}
knowledgeSpaceId="space-1"
/>,
)
expect(screen.getByRole('radio', { name: 'Google Docs' })).toBeChecked()
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toHaveValue(
'Shared product docs',
)
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toHaveValue(
'daily',
)
const addSource = screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })
expect(addSource).toBeEnabled()
await user.click(addSource)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.sourceSetupBackendDependency',
)
})
it('restores a website draft from session storage', async () => {
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
const storageKey = newKnowledgeSourceDraftStorageKey('draft-1')
globalThis.sessionStorage.setItem(
storageKey,
JSON.stringify({
includeSubpages: false,
maxPages: 25,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}),
)
render(<AddSourcePage knowledgeSpaceId="space-1" sourceDraftKey="draft-1" />)
expect(
await screen.findByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
).toHaveValue('https://docs.dify.ai')
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ })).toHaveValue(
'Dify docs',
)
expect(globalThis.sessionStorage.getItem(storageKey)).not.toBeNull()
})
it('retains the draft through Strict Mode, delayed loading, and a real remount', async () => {
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
queryState.connections.isPending = true
const storageKey = newKnowledgeSourceDraftStorageKey('strict-draft')
globalThis.sessionStorage.setItem(
storageKey,
JSON.stringify({
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai/strict',
sourceName: 'Strict docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}),
)
const view = render(
<StrictMode>
<AddSourcePage knowledgeSpaceId="space-1" sourceDraftKey="strict-draft" />
</StrictMode>,
)
await waitFor(() => expect(globalThis.sessionStorage.getItem(storageKey)).not.toBeNull())
queryState.connections.isPending = false
view.rerender(
<StrictMode>
<AddSourcePage knowledgeSpaceId="space-1" sourceDraftKey="strict-draft" />
</StrictMode>,
)
expect(
await screen.findByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
).toHaveValue('https://docs.dify.ai/strict')
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ })).toHaveValue(
'Strict docs',
)
view.unmount()
queryState.connections.isPending = false
render(
<StrictMode>
<AddSourcePage knowledgeSpaceId="space-1" sourceDraftKey="strict-draft" />
</StrictMode>,
)
expect(
await screen.findByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ }),
).toHaveValue('https://docs.dify.ai/strict')
})
it('clears the stored draft when source setup is canceled', async () => {
const user = userEvent.setup()
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
const storageKey = newKnowledgeSourceDraftStorageKey('cancel-draft')
globalThis.sessionStorage.setItem(
storageKey,
JSON.stringify({
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}),
)
render(<AddSourcePage knowledgeSpaceId="space-1" sourceDraftKey="cancel-draft" />)
await user.click(
await screen.findByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }),
)
const confirmation = await screen.findByRole('alertdialog', {
name: 'dataset.newKnowledge.discardSourceDraftTitle',
})
expect(globalThis.sessionStorage.getItem(storageKey)).not.toBeNull()
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.discardDraftConfirm' }),
)
expect(globalThis.sessionStorage.getItem(storageKey)).toBeNull()
expect(historyBack).toHaveBeenCalledOnce()
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(routerMock.replace).toHaveBeenCalledWith('/datasets/new/space-1/sources')
expect(confirmation).not.toBeInTheDocument()
})
it('creates the exact Firecrawl provider connection without leaking credentials', async () => {
const user = userEvent.setup()
clientMock.createConnection.mockResolvedValue(connection('active'))
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
body: {
authKind: 'api-key',
configuration: {
datasource: 'crawl',
endpoint: 'https://crawl.example.com',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
},
credentials: { apiKey: 'secret-value' },
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
},
params: { id: 'space-1' },
}),
)
await screen.findByRole('status', { name: 'appApi.loading' })
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['source-connections'],
})
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
expect(screen.queryByDisplayValue('secret-value')).not.toBeInTheDocument()
})
it('releases the parent history guard before the crawl preview owns navigation', async () => {
const user = userEvent.setup()
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
clientMock.createConnection.mockResolvedValue(connection('active'))
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await waitFor(() => expect(historyBack).toHaveBeenCalledOnce())
expect(screen.queryByText(/dataset\.newKnowledge\.providerConnected/)).not.toBeInTheDocument()
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
await screen.findByText(/dataset\.newKnowledge\.providerConnected/)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.cancelAddSource' }))
expect(historyBack).toHaveBeenCalledOnce()
expect(routerMock.push).toHaveBeenCalledWith('/datasets/new/space-1/sources')
})
it('keeps the website setup interactive until it reaches the backend boundary', async () => {
const user = userEvent.setup()
render(
<AddSourcePage
initialSourceDraft={{
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl',
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl',
syncPolicy: 'provider',
}}
knowledgeSpaceId="space-1"
/>,
)
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.rootUrl' })).toHaveValue(
'https://docs.dify.ai',
)
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toHaveValue(
'Dify docs',
)
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.rootUrl' })).toBeEnabled()
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled()
const crawlAndPreview = screen.getByRole('button', {
name: 'dataset.newKnowledge.crawlAndPreview',
})
expect(crawlAndPreview).toBeEnabled()
await user.click(crawlAndPreview)
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.sourceSetupBackendDependency',
)
})
it('keeps pending website edits when the provider connection becomes active', async () => {
const user = userEvent.setup()
const initialSourceDraft = {
includeSubpages: true,
maxPages: 100,
provider: 'Firecrawl' as const,
rootUrl: 'https://docs.dify.ai',
sourceName: 'Dify docs',
sourceType: 'websiteCrawl' as const,
syncPolicy: 'provider' as const,
}
const view = render(
<AddSourcePage initialSourceDraft={initialSourceDraft} knowledgeSpaceId="space-1" />,
)
const rootUrl = screen.getByRole('textbox', { name: 'dataset.newKnowledge.rootUrl' })
const sourceName = screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })
await user.clear(rootUrl)
await user.type(rootUrl, 'https://docs.dify.ai/edited')
await user.clear(sourceName)
await user.type(sourceName, 'Edited docs')
queryState.connections.data = { pages: [{ items: [connection('active')] }] }
view.rerender(
<AddSourcePage initialSourceDraft={initialSourceDraft} knowledgeSpaceId="space-1" />,
)
await screen.findByRole('status', { name: 'appApi.loading' })
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.rootUrl/ })).toHaveValue(
'https://docs.dify.ai/edited',
)
expect(screen.getByRole('textbox', { name: /dataset\.newKnowledge\.sourceName/ })).toHaveValue(
'Edited docs',
)
})
it('does not select a lookalike provider by fuzzy display name', () => {
queryState.providers.data = {
items: [{ ...firecrawlProvider, displayName: 'Firecrawl impostor', id: 'impostor' }],
}
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByText('dataset.newKnowledge.firecrawlUnavailable')).toBeInTheDocument()
})
it('clears sensitive input but retains non-sensitive input after a connection error', async () => {
const user = userEvent.setup()
clientMock.createConnection.mockRejectedValue(new Error('provider unavailable'))
queryState.connections.refetch.mockResolvedValue({ data: queryState.connections.data })
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'do-not-retain')
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
expect(await screen.findByText('dataset.newKnowledge.connectionFailed')).toBeInTheDocument()
expect(screen.getByLabelText(/Api Key/)).toHaveValue('')
expect(screen.getByLabelText('Endpoint')).toHaveValue('https://crawl.example.com')
})
it('reconciles a response-lost create before showing an error', async () => {
const user = userEvent.setup()
clientMock.createConnection.mockRejectedValue(new Error('response lost'))
queryState.connections.refetch.mockResolvedValue({
data: { pages: [{ items: [connection('active')] }] },
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await waitFor(() => expect(clientMock.createConnection).toHaveBeenCalledOnce())
await screen.findByRole('status', { name: 'appApi.loading' })
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(await screen.findByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
expect(screen.queryByText('dataset.newKnowledge.connectionFailed')).not.toBeInTheDocument()
})
it('clears an API key when authentication modes are changed and changed back', async () => {
const user = userEvent.setup()
clientMock.createConnection.mockResolvedValue(connection('active'))
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'must-not-return')
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.authKind.endpoint' }))
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.authKind.api-key' }))
expect(screen.getByLabelText(/Api Key/)).toHaveValue('')
})
it('supports an endpoint descriptor without sending a hidden secret field', async () => {
const user = userEvent.setup()
clientMock.createConnection.mockResolvedValue({
...connection('active'),
authKind: 'endpoint',
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
await user.type(screen.getByLabelText(/Api Key/), 'must-not-be-sent')
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.authKind.endpoint' }))
await user.type(screen.getByLabelText('Endpoint'), 'https://crawl.example.com')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.connectProvider' }))
await waitFor(() =>
expect(clientMock.createConnection).toHaveBeenCalledWith({
body: {
authKind: 'endpoint',
configuration: {
datasource: 'crawl',
endpoint: 'https://crawl.example.com',
pluginId: 'langgenius/firecrawl_datasource',
provider: 'firecrawl',
},
credentials: {},
name: 'Firecrawl',
providerId: 'plugin-daemon-website',
},
params: { id: 'space-1' },
}),
)
})
it('associates provider field descriptions with their controls', async () => {
const user = userEvent.setup()
const booleanProvider = {
...firecrawlProvider,
configuration: [
...firecrawlProvider.configuration,
{
description: 'Use stealth mode',
name: 'stealth',
required: false,
secret: false,
type: 'boolean' as const,
},
],
}
queryState.providers.data = { items: [booleanProvider] }
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
)
expect(screen.getByLabelText(/Api Key/)).toHaveAccessibleDescription('Firecrawl API key')
expect(screen.getByLabelText('Stealth')).toHaveAccessibleDescription('Use stealth mode')
})
it('refreshes an errored connection using its current version', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('error')] }] }
clientMock.refreshConnection.mockResolvedValue(connection('active'))
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenCalledWith({
body: { expectedVersion: 2 },
params: { connectionId: 'connection-1', id: 'space-1' },
}),
)
expect(queryClientMock.invalidateQueries).toHaveBeenCalled()
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
})
it('reconciles a refresh version race and retries with the server version', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('error')] }] }
clientMock.refreshConnection
.mockRejectedValueOnce(new Error('version conflict'))
.mockResolvedValueOnce(connection('active', 4))
queryState.connections.refetch.mockResolvedValue({
data: {
pages: [
{
items: [
connection('error', 3),
{
...connection('error', 8),
id: 'connection-2',
updatedAt: '2026-07-20T11:00:00Z',
},
],
},
],
},
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() => expect(queryState.connections.refetch).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenLastCalledWith({
body: { expectedVersion: 3 },
params: { connectionId: 'connection-1', id: 'space-1' },
}),
)
})
it('does not regress the current version when reconciliation reads a stale replica', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('error', 3)] }] }
clientMock.refreshConnection
.mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValueOnce(connection('active', 4))
queryState.connections.refetch.mockResolvedValue({
data: { pages: [{ items: [connection('error', 2)] }] },
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() => expect(queryState.connections.refetch).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() =>
expect(clientMock.refreshConnection).toHaveBeenLastCalledWith({
body: { expectedVersion: 3 },
params: { connectionId: 'connection-1', id: 'space-1' },
}),
)
})
it('lets a newer remote connection version replace a local mutation response', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('error')] }] }
clientMock.refreshConnection.mockResolvedValue(connection('provisioning', 3))
const view = render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(
await screen.findByText('dataset.newKnowledge.connectionProvisioning'),
).toBeInTheDocument()
queryState.connections.data = {
pages: [
{
items: [
connection('active', 3),
{
...connection('active', 10),
id: 'connection-2',
updatedAt: '2026-07-20T11:00:00Z',
},
],
},
],
}
view.rerender(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
})
it('reconciles a provisioning connection with the refreshed server state', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('provisioning')] }] }
queryState.connections.refetch.mockResolvedValue({
data: { pages: [{ items: [connection('active')] }] },
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.refreshConnectionStatus' }),
)
expect(await screen.findByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
})
it('shows a retryable error when provisioning reconciliation fails', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('provisioning')] }] }
queryState.connections.refetch.mockResolvedValue({
data: queryState.connections.data,
error: new Error('temporary failure'),
})
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.refreshConnectionStatus' }),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.connectionRefreshFailed',
)
})
it('shows a retryable error when provisioning reconciliation finds no connection', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('provisioning')] }] }
queryState.connections.refetch.mockImplementation(async () => {
queryState.connections.data = { pages: [{ items: [] }] }
return { data: queryState.connections.data }
})
const view = render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(
screen.getByRole('button', { name: 'dataset.newKnowledge.refreshConnectionStatus' }),
)
view.rerender(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.connectionRefreshFailed',
)
expect(
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
).not.toBeInTheDocument()
})
it('keeps source types selectable and shows an honest dependency state', async () => {
const user = userEvent.setup()
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.websiteCrawl' })).toBeChecked()
const onlineDocuments = screen.getByRole('radio', {
name: 'dataset.newKnowledge.onlineDocuments',
})
expect(onlineDocuments).toBeEnabled()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toBeEnabled()
expect(screen.getByRole('radio', { name: 'Jina Reader' })).toBeEnabled()
expect(
screen.getByRole('group', { name: 'datasetCreation.stepOne.website.chooseProvider' }),
).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Firecrawl' })).toBeChecked()
await user.click(onlineDocuments)
expect(onlineDocuments).toBeChecked()
expect(
screen.getByRole('group', { name: 'datasetCreation.stepOne.website.chooseProvider' }),
).toBeVisible()
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled()
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.syncPolicy' })).toBeEnabled()
expect(screen.getByRole('status')).toHaveTextContent('dataset.newKnowledge.providerUnavailable')
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' }),
'Product docs',
)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' }))
expect(screen.getByRole('radio', { name: 'Google Drive' })).toBeChecked()
await user.click(onlineDocuments)
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toHaveValue(
'Product docs',
)
})
it('restores the selected source type from the create flow', () => {
render(<AddSourcePage initialSourceType="onlineDrive" knowledgeSpaceId="space-1" />)
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDrive' })).toBeChecked()
expect(
screen.getByRole('region', { name: 'dataset.newKnowledge.selectFilesAndFolders' }),
).toBeVisible()
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.sourceName' })).toBeEnabled()
expect(screen.getByRole('status')).toHaveTextContent('dataset.newKnowledge.providerUnavailable')
})
it('disables the final Add source action while its backend dependency is missing', async () => {
const user = userEvent.setup()
render(<AddSourcePage knowledgeSpaceId="space-1" />)
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.onlineDocuments' }))
const addSource = screen.getByRole('button', { name: 'dataset.newKnowledge.addSource' })
expect(addSource).toBeDisabled()
expect(toastInfoMock).not.toHaveBeenCalled()
})
it('shows catalog unavailability instead of offering a fake connection', () => {
queryState.providers.data = {
items: [{ ...firecrawlProvider, available: false, unavailableReason: 'Disabled by admin' }],
}
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByText('Disabled by admin')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
).not.toBeInTheDocument()
})
it('does not offer unsupported OAuth or undocumented direct connection paths', () => {
queryState.providers.data = {
items: [
{
...firecrawlProvider,
authKinds: ['oauth2'],
configuration: firecrawlProvider.configuration.slice(0, 3),
},
],
}
render(<AddSourcePage knowledgeSpaceId="space-1" />)
expect(screen.getByText('dataset.newKnowledge.providerUnavailable')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
).not.toBeInTheDocument()
})
})