mirror of
https://github.com/langgenius/dify.git
synced 2026-07-26 22:28:32 +08:00
1036 lines
38 KiB
TypeScript
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()
|
|
})
|
|
})
|