test(web): migrate New RAG setup specs to Browser Mode (#45722)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-06 10:00:05 +00:00
1 parent f8c99ece94
commit 3abf97176e
8 files changed
+495 -583

No files matched your search

@@ -0,0 +1,142 @@
import type { KnowledgeFsSettingsResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import { KnowledgeModelReadinessBanner } from '../knowledge-model-readiness-banner'
const getSettings = vi.hoisted(() => vi.fn<() => Promise<KnowledgeFsSettingsResponse>>())
vi.mock('@/next/navigation', () => ({
usePathname: () => '/datasets/new/space-1/documents',
useSearchParams: () => new URLSearchParams('status=failed'),
}))
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, {
'knowledgeFs.spaces.byControlSpaceId.settings.get': getSettings,
}),
}
})
const allCapabilities = {
deep: true,
index: true,
ingest: true,
query: true,
research: true,
source_sync: true,
}
const readiness = (
overrides: Partial<KnowledgeFsSettingsResponse> = {},
): KnowledgeFsSettingsResponse => ({
active_profile_available: true,
active_profile_revisions: { embedding: 1, retrieval: 1 },
capabilities: allCapabilities,
configuration_state: 'active',
embedding: null,
issues: [],
retrieval: null,
revision: 1,
...overrides,
})
async function renderBanner(capability?: 'index') {
const { wrapper: QueryProvider } = createConsoleQueryWrapper()
return render(
<QueryProvider>
<KnowledgeModelReadinessBanner capability={capability} knowledgeSpaceId="space-1" />
<button type="button">After readiness</button>
</QueryProvider>,
)
}
describe('KnowledgeModelReadinessBanner', () => {
beforeEach(async () => {
await page.viewport(800, 600)
onTestFinished(guardUnexpectedFetch())
})
it.each([
['the active profile is healthy', readiness()],
[
'validation is waiting for the first document',
readiness({
configuration_state: 'pending-validation',
issues: [{ code: 'missing', field: 'embedding', retryable: true }],
}),
],
])('stays hidden after settings settle when %s', async (_, response) => {
getSettings.mockResolvedValue(response)
const screen = await renderBanner('index')
await vi.waitFor(() => expect(getSettings).toHaveBeenCalledOnce())
await expect.element(screen.getByRole('status')).not.toBeInTheDocument()
await expect.element(screen.getByRole('alert')).not.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'After readiness' })).toBeVisible()
})
it('shows configuration guidance when no model profile can serve the capability', async () => {
getSettings.mockResolvedValue(
readiness({
active_profile_available: false,
active_profile_revisions: { embedding: null, retrieval: null },
capabilities: Object.fromEntries(
Object.keys(allCapabilities).map((key) => [key, false]),
) as KnowledgeFsSettingsResponse['capabilities'],
configuration_state: 'setup-required',
issues: [{ code: 'missing', field: 'embedding', retryable: false }],
}),
)
const screen = await renderBanner('index')
await expect
.element(screen.getByRole('status'))
.toMatchTextContent('knowledgeSpace.overview.attention.modelReadiness.profilesMissing')
})
it('offers the current page as the return target and exposes the action to the keyboard', async () => {
getSettings.mockResolvedValue(
readiness({
capabilities: { ...allCapabilities, index: false },
configuration_state: 'validation-failed',
issues: [{ code: 'validation_failed', field: 'embedding', retryable: true }],
}),
)
const user = userEvent
const screen = await renderBanner('index')
const alert = screen.getByRole('alert')
await expect.element(alert).toBeVisible()
await expect.element(alert).toMatchTextContent('common.api.actionFailed')
const configure = alert.getByRole('link', {
name: 'knowledgeSpace.overview.attention.action.configureModels',
})
await expect
.element(configure)
.toHaveAttribute(
'href',
'/datasets/new/space-1/settings?returnTo=%2Fdatasets%2Fnew%2Fspace-1%2Fdocuments%3Fstatus%3Dfailed&capability=index',
)
await user.tab()
await expect.element(configure).toHaveFocus()
})
it('removes a transient error after the user retries successfully', async () => {
getSettings.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue(readiness())
const screen = await renderBanner()
const retry = screen.getByRole('button', { name: 'common.operation.retry' })
await expect.element(retry).toBeVisible()
await retry.click()
await expect.element(retry).not.toBeInTheDocument()
await expect.element(screen.getByRole('alert')).not.toBeInTheDocument()
expect(getSettings).toHaveBeenCalledTimes(2)
})
})
@@ -1,153 +0,0 @@
import type { KnowledgeFsSettingsResponse } from '@dify/contracts/api/console/knowledge-fs/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { KnowledgeModelReadinessBanner } from '../knowledge-model-readiness-banner'
const getSettings = vi.hoisted(() => vi.fn<() => Promise<KnowledgeFsSettingsResponse>>())
vi.mock('@/next/navigation', () => ({
usePathname: () => '/datasets/new/space-1/documents',
useSearchParams: () => new URLSearchParams('status=failed'),
}))
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, {
'knowledgeFs.spaces.byControlSpaceId.settings.get': getSettings,
}),
}
})
const allCapabilities = {
deep: true,
index: true,
ingest: true,
query: true,
research: true,
source_sync: true,
}
const readiness = (
overrides: Partial<KnowledgeFsSettingsResponse> = {},
): KnowledgeFsSettingsResponse => ({
active_profile_available: true,
active_profile_revisions: { embedding: 1, retrieval: 1 },
capabilities: allCapabilities,
configuration_state: 'active',
embedding: null,
issues: [],
retrieval: null,
revision: 1,
...overrides,
})
const configureModelsLink = () =>
screen.queryByRole('link', { name: 'knowledgeSpace.overview.attention.action.configureModels' })
describe('KnowledgeModelReadinessBanner', () => {
it.each([
['the active profile is healthy', readiness()],
[
'configuration is complete but the first profile is not active yet',
readiness({
active_profile_available: false,
active_profile_revisions: { embedding: null, retrieval: null },
capabilities: {
...allCapabilities,
deep: false,
index: false,
query: false,
research: false,
},
}),
],
[
'configuration is complete despite an unrelated runtime issue',
readiness({
capabilities: { ...allCapabilities, research: false },
issues: [{ code: 'unavailable', field: 'publication', retryable: true }],
}),
],
[
'validation is waiting for the first document',
readiness({
configuration_state: 'pending-validation',
issues: [{ code: 'missing', field: 'embedding', retryable: true }],
}),
],
])('stays hidden when %s', async (_, response) => {
getSettings.mockResolvedValue(response)
renderWithConsoleQuery(
<KnowledgeModelReadinessBanner capability="index" knowledgeSpaceId="space-1" />,
)
await waitFor(() => expect(getSettings).toHaveBeenCalled())
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows configuration guidance when model setup is required', async () => {
getSettings.mockResolvedValue(
readiness({
active_profile_available: false,
active_profile_revisions: { embedding: null, retrieval: null },
capabilities: Object.fromEntries(
Object.keys(allCapabilities).map((key) => [key, false]),
) as KnowledgeFsSettingsResponse['capabilities'],
configuration_state: 'setup-required',
issues: [{ code: 'missing', field: 'embedding', retryable: false }],
}),
)
renderWithConsoleQuery(
<KnowledgeModelReadinessBanner capability="index" knowledgeSpaceId="space-1" />,
)
expect(await screen.findByRole('status')).toHaveTextContent(
'knowledgeSpace.overview.attention.modelReadiness.profilesMissing',
)
})
it('links validation failures to settings and preserves the current page', async () => {
getSettings.mockResolvedValue(
readiness({
capabilities: { ...allCapabilities, index: false },
configuration_state: 'validation-failed',
issues: [{ code: 'validation_failed', field: 'embedding', retryable: true }],
}),
)
renderWithConsoleQuery(
<KnowledgeModelReadinessBanner capability="index" knowledgeSpaceId="space-1" />,
)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('common.api.actionFailed')
expect(alert).toHaveTextContent('knowledgeSpace.overview.attention.modelReadiness.description')
expect(configureModelsLink()).toHaveAttribute(
'href',
'/datasets/new/space-1/settings?returnTo=%2Fdatasets%2Fnew%2Fspace-1%2Fdocuments%3Fstatus%3Dfailed&capability=index',
)
})
it('keeps a readiness fetch failure separate and retryable', async () => {
const user = userEvent.setup()
getSettings.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue(readiness())
renderWithConsoleQuery(<KnowledgeModelReadinessBanner knowledgeSpaceId="space-1" />)
await user.click(await screen.findByRole('button', { name: 'common.operation.retry' }))
expect(configureModelsLink()).not.toBeInTheDocument()
await waitFor(() =>
expect(
screen.queryByRole('button', { name: 'common.operation.retry' }),
).not.toBeInTheDocument(),
)
expect(getSettings).toHaveBeenCalledTimes(2)
})
})
@@ -1,10 +1,77 @@
import type { DatasourceParameters, DatasourceParameterSchema } from '../datasource-parameter-model'
import { useState } from 'react'
import { page } from 'vite-plus/test/browser'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { WebsiteDatasourceParameterForm } from '../datasource-parameter-form'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import {
DatasourceParameterForm,
WebsiteDatasourceParameterForm,
} from '../datasource-parameter-form'
import {
datasourceParameterDefaults,
websiteDatasourceParameterSchemas,
} from '../datasource-parameter-model'
const schemas: DatasourceParameterSchema[] = [
const limitSchema: DatasourceParameterSchema = {
label: { en_US: 'Limit' },
max: 200,
min: 100,
name: 'limit',
options: [],
required: true,
type: 'number',
}
const urlSchema: DatasourceParameterSchema = {
label: { en_US: 'Root URL' },
name: 'url',
options: [],
required: true,
type: 'string',
}
const decimalSchema: DatasourceParameterSchema = {
label: { en_US: 'Threshold' },
name: 'threshold',
options: [],
precision: 2,
required: false,
type: 'number',
}
function ParameterForm({ schema }: { schema: DatasourceParameterSchema }) {
const [parameters, setParameters] = useState<DatasourceParameters>({})
return (
<DatasourceParameterForm parameters={parameters} schemas={[schema]} onChange={setParameters} />
)
}
function WebsiteParameterForm({ customized = false }: { customized?: boolean }) {
const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never)
const [parameters, setParameters] = useState<DatasourceParameters>(() => ({
...datasourceParameterDefaults(schemas),
...(customized ? { limit: 99 } : {}),
}))
return (
<WebsiteDatasourceParameterForm
parameters={parameters}
schemas={schemas}
onChange={setParameters}
/>
)
}
function LegacyWebsiteParameterForm() {
const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never)
const [parameters, setParameters] = useState<DatasourceParameters>(() =>
datasourceParameterDefaults(schemas),
)
return (
<DatasourceParameterForm parameters={parameters} schemas={schemas} onChange={setParameters} />
)
}
const scrollSchema: DatasourceParameterSchema[] = [
{
label: { en_US: 'Include subpages' },
name: 'include_subpages',
@@ -16,7 +83,6 @@ const schemas: DatasourceParameterSchema[] = [
function ScrollableCrawlOptions() {
const [parameters, setParameters] = useState<DatasourceParameters>({ include_subpages: true })
return (
<section
aria-label="Source setup"
@@ -27,7 +93,7 @@ function ScrollableCrawlOptions() {
<div style={{ height: 500 }} />
<WebsiteDatasourceParameterForm
parameters={parameters}
schemas={schemas}
schemas={scrollSchema}
onChange={setParameters}
/>
<div style={{ height: 500 }} />
@@ -37,23 +103,102 @@ function ScrollableCrawlOptions() {
)
}
it('keeps the surrounding panel in place when clicking a scrolled crawl option label', async () => {
await page.viewport(900, 600)
const screen = await render(<ScrollableCrawlOptions />)
await screen.getByRole('button', { name: 'knowledgeSources.crawlOptions' }).click()
const label = screen.getByText('Include subpages', { exact: true })
const checkbox = screen.getByRole('checkbox', { name: 'Include subpages' })
const panel = screen.getByRole('region', { name: 'Source setup' }).element()
const heading = screen.getByRole('heading', { name: 'Connect a source' }).element()
const headingTop = heading.getBoundingClientRect().top
describe('DatasourceParameterForm', () => {
beforeEach(async () => {
await page.viewport(900, 600)
onTestFinished(guardUnexpectedFetch())
})
await label.click()
await expect.element(checkbox).not.toBeChecked()
expect(panel.scrollTop).toBe(0)
expect(heading.getBoundingClientRect().top).toBe(headingTop)
it('keeps an out-of-range number visible and exposes native and accessible invalid state', async () => {
const screen = await render(<ParameterForm schema={limitSchema} />)
const input = screen.getByRole('spinbutton', { name: 'Limit' })
await label.click()
await expect.element(checkbox).toBeChecked()
expect(panel.scrollTop).toBe(0)
expect(heading.getBoundingClientRect().top).toBe(headingTop)
await input.fill('15')
await userEvent.tab()
await expect.element(input).toHaveValue(15)
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
expect((input.element() as HTMLInputElement).validity.rangeUnderflow).toBe(true)
})
it('associates the actionable URL error after native blur validation', async () => {
const screen = await render(<ParameterForm schema={urlSchema} />)
const input = screen.getByRole('textbox', { name: 'Root URL' })
await input.fill('ftp://example.com')
await userEvent.tab()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAccessibleDescription('dataset.validation.invalidHttpUrl')
await expect.element(screen.getByText('dataset.validation.invalidHttpUrl')).toBeVisible()
await expect.element(input).toHaveValue('ftp://example.com')
})
it('reveals website options, applies edits, and resets them to provider defaults', async () => {
const screen = await render(<WebsiteParameterForm />)
const crawlOptions = screen.getByRole('button', { name: 'knowledgeSources.crawlOptions' })
await expect.element(crawlOptions).toHaveAttribute('aria-expanded', 'false')
await expect.element(crawlOptions).toMatchTextContent('knowledgeSources.usingDefaults')
await crawlOptions.click()
const includeSubpages = screen.getByRole('checkbox', {
name: 'knowledgeSources.includeSubpages',
})
const reset = screen.getByRole('button', { name: 'knowledgeSources.resetToDefaults' })
await expect.element(includeSubpages).toBeChecked()
await includeSubpages.click()
await expect.element(includeSubpages).not.toBeChecked()
await reset.click()
await expect.element(includeSubpages).toBeChecked()
})
it('does not describe customized collapsed website options as defaults', async () => {
const customized = await render(<WebsiteParameterForm customized />)
const trigger = customized.getByRole('button', { name: 'knowledgeSources.crawlOptions' })
await expect.element(trigger).toHaveTextContent('knowledgeSources.crawlOptions')
})
it('preserves declaration precision through the native decimal input', async () => {
const decimal = await render(<ParameterForm schema={decimalSchema} />)
const input = decimal.getByRole('spinbutton', { name: 'Threshold' })
await expect.element(input).toHaveAttribute('inputmode', 'decimal')
await expect.element(input).toHaveAttribute('step', '0.01')
await input.fill('0.25')
await expect.element(input).toHaveValue(0.25)
expect((input.element() as HTMLInputElement).validity.valid).toBe(true)
})
it('resolves the legacy website controls to their public labels and native values', async () => {
const screen = await render(<LegacyWebsiteParameterForm />)
await expect
.element(screen.getByRole('textbox', { name: 'knowledgeSources.rootUrl' }))
.toHaveAttribute('placeholder', 'knowledgeSources.rootUrlPlaceholder')
await expect
.element(screen.getByRole('switch', { name: 'knowledgeSources.includeSubpages' }))
.toBeChecked()
await expect
.element(screen.getByRole('spinbutton', { name: 'knowledgeSources.maxPages' }))
.toHaveValue(100)
})
it('keeps the surrounding panel in place when clicking a scrolled crawl option label', async () => {
const screen = await render(<ScrollableCrawlOptions />)
await screen.getByRole('button', { name: 'knowledgeSources.crawlOptions' }).click()
const label = screen.getByText('Include subpages', { exact: true })
const checkbox = screen.getByRole('checkbox', { name: 'Include subpages' })
const panel = screen.getByRole('region', { name: 'Source setup' }).element()
const heading = screen.getByRole('heading', { name: 'Connect a source' }).element()
const headingTop = heading.getBoundingClientRect().top
await label.click()
await expect.element(checkbox).not.toBeChecked()
expect(panel.scrollTop).toBe(0)
expect(heading.getBoundingClientRect().top).toBe(headingTop)
await label.click()
await expect.element(checkbox).toBeChecked()
expect(panel.scrollTop).toBe(0)
expect(heading.getBoundingClientRect().top).toBe(headingTop)
})
})
@@ -1,211 +0,0 @@
import type { DatasourceParameters, DatasourceParameterSchema } from '../datasource-parameter-model'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { render } from '@/test/console/render'
import {
DatasourceParameterForm,
WebsiteDatasourceParameterForm,
} from '../datasource-parameter-form'
import {
datasourceParameterDefaults,
websiteDatasourceParameterSchemas,
} from '../datasource-parameter-model'
const limitSchema: DatasourceParameterSchema = {
label: { en_US: 'Limit' },
max: 200,
min: 100,
name: 'limit',
options: [],
required: true,
type: 'number',
}
const urlSchema: DatasourceParameterSchema = {
label: { en_US: 'Root URL' },
name: 'url',
options: [],
required: true,
type: 'string',
}
const decimalSchema: DatasourceParameterSchema = {
label: { en_US: 'Threshold' },
name: 'threshold',
options: [],
precision: 2,
required: false,
type: 'number',
}
function NumberParameterForm() {
const [parameters, setParameters] = useState<DatasourceParameters>({})
return (
<DatasourceParameterForm
parameters={parameters}
schemas={[limitSchema]}
onChange={setParameters}
/>
)
}
function UrlParameterForm() {
const [parameters, setParameters] = useState<DatasourceParameters>({})
return (
<DatasourceParameterForm
parameters={parameters}
schemas={[urlSchema]}
onChange={setParameters}
/>
)
}
function DecimalParameterForm() {
const [parameters, setParameters] = useState<DatasourceParameters>({})
return (
<DatasourceParameterForm
parameters={parameters}
schemas={[decimalSchema]}
onChange={setParameters}
/>
)
}
function LegacyWebsiteParameterForm() {
const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never)
const [parameters, setParameters] = useState<DatasourceParameters>(() =>
datasourceParameterDefaults(schemas),
)
return (
<DatasourceParameterForm parameters={parameters} schemas={schemas} onChange={setParameters} />
)
}
function GroupedWebsiteParameterForm() {
const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never)
const [parameters, setParameters] = useState<DatasourceParameters>(() =>
datasourceParameterDefaults(schemas),
)
return (
<WebsiteDatasourceParameterForm
parameters={parameters}
schemas={schemas}
onChange={setParameters}
/>
)
}
function CustomizedGroupedWebsiteParameterForm() {
const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never)
const [parameters, setParameters] = useState<DatasourceParameters>(() => ({
...datasourceParameterDefaults(schemas),
limit: 99,
}))
return (
<WebsiteDatasourceParameterForm
parameters={parameters}
schemas={schemas}
onChange={setParameters}
/>
)
}
describe('DatasourceParameterForm', () => {
it('keeps an out-of-range numeric value visible and reports it as invalid', async () => {
const user = userEvent.setup()
render(<NumberParameterForm />)
const input = screen.getByRole('spinbutton', { name: 'Limit' })
await user.type(input, '15')
expect(input).toHaveValue(15)
await user.tab()
expect(input).toHaveValue(15)
expect(input).toHaveAttribute('aria-invalid', 'true')
})
it('associates an actionable error with an invalid URL field', async () => {
const user = userEvent.setup()
render(<UrlParameterForm />)
const input = screen.getByRole('textbox', { name: 'Root URL' })
expect(input).not.toHaveAttribute('aria-invalid', 'true')
expect(screen.queryByText('dataset.validation.invalidHttpUrl')).not.toBeInTheDocument()
await user.type(input, 'ftp://example.com')
await user.tab()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveAccessibleDescription('dataset.validation.invalidHttpUrl')
expect(screen.getByText('dataset.validation.invalidHttpUrl')).toBeInTheDocument()
})
it('groups website options and resets edits to provider defaults', async () => {
const user = userEvent.setup()
render(<GroupedWebsiteParameterForm />)
expect(screen.getByRole('textbox', { name: 'knowledgeSources.rootUrl' })).toBeVisible()
const crawlOptions = screen.getByRole('button', {
name: 'knowledgeSources.crawlOptions',
})
expect(crawlOptions).toHaveAttribute('aria-expanded', 'false')
expect(crawlOptions).toHaveTextContent('knowledgeSources.usingDefaults')
expect(
screen.queryByRole('checkbox', { name: 'knowledgeSources.includeSubpages' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'knowledgeSources.resetToDefaults' }),
).not.toBeInTheDocument()
await user.click(crawlOptions)
const includeSubpages = screen.getByRole('checkbox', {
name: 'knowledgeSources.includeSubpages',
})
const resetButton = screen.getByRole('button', {
name: 'knowledgeSources.resetToDefaults',
})
expect(includeSubpages).toBeChecked()
expect(resetButton).toBeEnabled()
expect(screen.getByRole('spinbutton', { name: 'knowledgeSources.maxPages' })).toBeVisible()
await user.click(includeSubpages)
expect(includeSubpages).not.toBeChecked()
expect(resetButton).toBeEnabled()
await user.click(resetButton)
expect(includeSubpages).toBeChecked()
})
it('does not describe customized collapsed website options as defaults', () => {
render(<CustomizedGroupedWebsiteParameterForm />)
expect(
screen.getByRole('button', { name: 'knowledgeSources.crawlOptions' }),
).not.toHaveTextContent('knowledgeSources.usingDefaults')
})
it('allows decimal input using the declaration precision', async () => {
const user = userEvent.setup()
render(<DecimalParameterForm />)
const input = screen.getByRole('spinbutton', { name: 'Threshold' })
expect(input).toHaveAttribute('inputmode', 'decimal')
expect(input).toHaveAttribute('step', '0.01')
await user.type(input, '0.25')
expect(input).toHaveValue(0.25)
expect(input).not.toHaveAttribute('aria-invalid', 'true')
})
it('resolves translation keys for legacy website parameter labels', () => {
render(<LegacyWebsiteParameterForm />)
expect(screen.getByRole('textbox', { name: 'knowledgeSources.rootUrl' })).toHaveAttribute(
'placeholder',
'knowledgeSources.rootUrlPlaceholder',
)
expect(screen.getByRole('switch', { name: 'knowledgeSources.includeSubpages' })).toBeChecked()
expect(screen.getByRole('spinbutton', { name: 'knowledgeSources.maxPages' })).toHaveValue(100)
})
})
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import { SourceProviderIcon } from '../fields'
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
currentWorkspace: { id: 'workspace-123' },
}))
})
const validIcon =
'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22%3E%3Crect width=%2216%22 height=%2216%22 fill=%22%234B7BEC%22/%3E%3C/svg%3E'
const brokenIcon = 'data:image/png;base64,broken-image'
function IconSession() {
const [icon, setIcon] = useState(brokenIcon)
return (
<>
<SourceProviderIcon icon={icon} fallbackIcon="i-ri-file-text-line" />
<button type="button" onClick={() => setIcon(validIcon)}>
Use valid icon
</button>
<button type="button" onClick={() => setIcon(brokenIcon)}>
Use broken icon
</button>
</>
)
}
describe('SourceProviderIcon', () => {
beforeEach(async () => {
await page.viewport(480, 320)
onTestFinished(guardUnexpectedFetch())
})
it('recovers from a native decode failure for each new image session', async () => {
const screen = await render(<IconSession />)
const image = () => screen.getByAltText('')
await expect.element(image()).not.toBeInTheDocument()
await screen.getByRole('button', { name: 'Use valid icon' }).click()
await expect.element(image()).toHaveAttribute('src', validIcon)
await expect
.poll(() => {
const element = image().element() as HTMLImageElement
return element.complete && element.naturalWidth
})
.toBeGreaterThan(0)
await screen.getByRole('button', { name: 'Use broken icon' }).click()
await expect.element(image()).not.toBeInTheDocument()
await screen.getByRole('button', { name: 'Use valid icon' }).click()
await expect
.poll(() => {
const element = image().element() as HTMLImageElement
return element.complete && element.naturalWidth
})
.toBeGreaterThan(0)
})
})
@@ -1,72 +0,0 @@
import type { SourceProviderOption } from '../provider-options'
import { fireEvent, screen, within } from '@testing-library/react'
import { render } from '@/test/console/render'
import { SourceProviderIcon, SourceProviderSelector } from '../fields'
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
currentWorkspace: { id: 'workspace-123' },
}))
})
describe('source provider icons', () => {
it('replaces a broken image and retries after switching images, including switching back', () => {
const { rerender, container } = render(
<SourceProviderIcon icon="/first.svg" fallbackIcon="i-ri-file-text-line" />,
)
fireEvent.error(screen.getByRole('presentation', { hidden: true }))
expect(screen.queryByRole('presentation', { hidden: true })).not.toBeInTheDocument()
expect(container.firstElementChild).toBeVisible()
rerender(<SourceProviderIcon icon="/second.svg" fallbackIcon="i-ri-file-text-line" />)
expect(screen.getByRole('presentation', { hidden: true })).toHaveAttribute('src', '/second.svg')
rerender(<SourceProviderIcon icon="/first.svg" fallbackIcon="i-ri-file-text-line" />)
expect(screen.getByRole('presentation', { hidden: true })).toHaveAttribute('src', '/first.svg')
})
it('uses the plugin image when the datasource image is empty', () => {
const label = { en_US: 'GitHub Repository', zh_Hans: 'GitHub Repository' }
const datasource = {
description: label,
identity: { author: 'langgenius', icon: '', label, name: 'github', provider: 'github' },
parameters: [],
}
const option: SourceProviderOption = {
datasource,
fallbackIcon: 'i-ri-file-text-line',
key: 'github',
label: 'GitHub Repository',
packageId: 'langgenius/github',
providerType: 'online_document',
sourceType: 'onlineDocuments',
plugin: {
plugin_id: 'langgenius/github',
plugin_unique_identifier: 'langgenius/github:1.0.0@local',
provider: 'github',
is_authorized: false,
declaration: {
credentials_schema: [],
provider_type: 'online_document',
datasources: [datasource],
identity: {
author: 'langgenius',
description: label,
icon: 'github.svg',
label,
name: 'github',
tags: [],
},
},
},
}
render(<SourceProviderSelector options={[option]} providerKey="github" onChange={vi.fn()} />)
const image = within(screen.getByRole('radio', { name: 'GitHub Repository' })).getByRole(
'presentation',
{ hidden: true },
)
expect(image.getAttribute('src')).toContain(
'/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=github.svg',
)
})
})
@@ -0,0 +1,121 @@
import type { ComponentProps } from 'react'
import { useState } from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { guardUnexpectedFetch } from '@/test/unexpected-fetch'
import { KnowledgeFsApiAccessDialog } from '../api-access-dialog'
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.apiKeys.get': async () => ({
data: [
{
id: 'workspace-key',
token: 'dataset-existing-secret',
created_at: 1710000000,
last_used_at: null,
type: 'dataset',
dataset_ids: [],
knowledge_space_ids: [],
},
],
}),
'datasets.apiBaseInfo.get': async () => ({ api_base_url: 'https://api.example.com/v1/' }),
}),
}
})
async function renderDialog(
props: Omit<ComponentProps<typeof KnowledgeFsApiAccessDialog>, 'onOpenChange' | 'open'>,
workspacePermissionKeys: string[] = ['dataset.api_key.manage'],
) {
function Viewer() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open API access
</button>
<KnowledgeFsApiAccessDialog {...props} open={open} onOpenChange={setOpen} />
</>
)
}
const { wrapper: QueryProvider } = createConsoleQueryWrapper({ workspacePermissionKeys })
return render(
<QueryProvider>
<Viewer />
</QueryProvider>,
)
}
describe('KnowledgeFsApiAccessDialog', () => {
beforeEach(async () => {
await page.viewport(800, 700)
onTestFinished(guardUnexpectedFetch())
})
it('moves from Service API details to workspace keys and reopens a fresh dialog', async () => {
const user = userEvent
const screen = await renderDialog({ status: 'active', knowledgeSpaceId: 'space-1' })
const opener = screen.getByRole('button', { name: 'Open API access' })
await opener.click()
let serviceDialog = screen.getByRole('dialog', { name: 'knowledgeSpace.apiAgentAccess' })
await expect.element(serviceDialog).toBeVisible()
expect(serviceDialog.element().contains(document.activeElement)).toBe(true)
await expect
.element(serviceDialog.getByText('https://api.example.com/v1/knowledge-fs/spaces/space-1'))
.toBeVisible()
await user.click(serviceDialog.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
await expect.element(serviceDialog).not.toBeInTheDocument()
const keyDialog = screen.getByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' })
await expect.element(keyDialog).toBeVisible()
expect(keyDialog.element().contains(document.activeElement)).toBe(true)
await expect.element(keyDialog.getByText('datas...cret')).toBeVisible()
await expect
.element(keyDialog.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }))
.toBeEnabled()
await user.keyboard('{Escape}')
await expect.element(keyDialog).not.toBeInTheDocument()
await expect.element(opener).toHaveFocus()
await user.keyboard('{Enter}')
serviceDialog = screen.getByRole('dialog', { name: 'knowledgeSpace.apiAgentAccess' })
await expect.element(serviceDialog).toBeVisible()
await expect
.element(serviceDialog.getByText('https://api.example.com/v1/knowledge-fs/spaces/space-1'))
.toBeVisible()
})
it('shows inactive access without exposing workspace key management', async () => {
const screen = await renderDialog({ status: 'inactive', knowledgeSpaceId: 'space-1' })
await screen.getByRole('button', { name: 'Open API access' }).click()
const dialog = screen.getByRole('dialog', { name: 'knowledgeSpace.apiAgentAccess' })
await expect.element(dialog.getByText('knowledgeSpace.apiAccessInactive')).toBeVisible()
await expect
.element(dialog.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
.not.toBeInTheDocument()
})
it('keeps the endpoint visible but makes key management read-only without permission', async () => {
const screen = await renderDialog({ status: 'active', knowledgeSpaceId: 'space-1' }, [])
await screen.getByRole('button', { name: 'Open API access' }).click()
const dialog = screen.getByRole('dialog', { name: 'knowledgeSpace.apiAgentAccess' })
await expect.element(dialog.getByText('knowledgeSpace.settings.viewOnly')).toBeVisible()
await expect
.element(dialog.getByText('https://api.example.com/v1/knowledge-fs/spaces/space-1'))
.toBeVisible()
await expect
.element(dialog.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
.not.toBeInTheDocument()
})
})
@@ -1,125 +0,0 @@
import type { ComponentProps } from 'react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { KnowledgeFsApiAccessDialog } from '../api-access-dialog'
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.apiKeys.get': async () => ({
data: [
{
id: 'workspace-key',
token: 'dataset-existing-secret',
created_at: 1710000000,
last_used_at: null,
type: 'dataset',
dataset_ids: [],
knowledge_space_ids: [],
},
],
}),
'datasets.apiBaseInfo.get': async () => ({ api_base_url: 'https://api.example.com/v1/' }),
}),
}
})
function renderDialog(
props: ComponentProps<typeof KnowledgeFsApiAccessDialog>,
workspacePermissionKeys: string[] = ['dataset.api_key.manage'],
) {
return renderWithConsoleQuery(<KnowledgeFsApiAccessDialog {...props} />, {
workspacePermissionKeys,
})
}
describe('KnowledgeFsApiAccessDialog', () => {
it('shows the generated KnowledgeFS Service API endpoint', async () => {
renderDialog({
status: 'active',
knowledgeSpaceId: 'space-1',
open: true,
onOpenChange: vi.fn(),
})
expect(
await screen.findByText('https://api.example.com/v1/knowledge-fs/spaces/space-1'),
).toBeInTheDocument()
expect(screen.getByText('common.appMenus.apiAccessTip')).toBeInTheDocument()
})
it('opens the shared workspace Dataset API key manager', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
function Viewer() {
const [open, setOpen] = useState(true)
return (
<KnowledgeFsApiAccessDialog
status="active"
knowledgeSpaceId="space-1"
open={open}
onOpenChange={(next) => {
onOpenChange(next)
setOpen(next)
}}
/>
)
}
renderWithConsoleQuery(<Viewer />, { workspacePermissionKeys: ['dataset.api_key.manage'] })
await user.click(screen.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(
await screen.findByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' }),
).toBeInTheDocument()
expect(await screen.findByText('datas...cret')).toBeInTheDocument()
await waitFor(() =>
expect(screen.queryByLabelText('knowledgeSpace.apiAgentAccess')).not.toBeInTheDocument(),
)
expect(
screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }),
).toBeEnabled()
})
it('does not expose API key management while Service API access is disabled', async () => {
renderDialog({
status: 'inactive',
knowledgeSpaceId: 'space-1',
open: true,
onOpenChange: vi.fn(),
})
expect(screen.getByText('knowledgeSpace.apiAccessInactive')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.serviceApi.card.apiKey' }),
).not.toBeInTheDocument()
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' }),
).not.toBeInTheDocument(),
)
})
it('uses the workspace API key permission for the read-only state', () => {
renderDialog(
{
status: 'active',
knowledgeSpaceId: 'space-1',
open: true,
onOpenChange: vi.fn(),
},
[],
)
expect(screen.getByText('knowledgeSpace.settings.viewOnly')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.serviceApi.card.apiKey' }),
).not.toBeInTheDocument()
})
})