From 3abf97176eb745ceed5d0febf5d80e17e79832ef Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Fri, 6 Nov 2026 10:00:05 +0000 Subject: [PATCH] test(web): migrate New RAG setup specs to Browser Mode (#45722) --- ...ge-model-readiness-banner.browser.spec.tsx | 142 ++++++++++++ .../knowledge-model-readiness-banner.spec.tsx | 153 ------------- ...datasource-parameter-form.browser.spec.tsx | 189 ++++++++++++++-- .../datasource-parameter-form.spec.tsx | 211 ------------------ .../setup/__tests__/fields.browser.spec.tsx | 65 ++++++ .../sources/setup/__tests__/fields.spec.tsx | 72 ------ .../api-access-dialog.browser.spec.tsx | 121 ++++++++++ .../__tests__/api-access-dialog.spec.tsx | 125 ----------- 8 files changed, 495 insertions(+), 583 deletions(-) create mode 100644 web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.browser.spec.tsx delete mode 100644 web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx delete mode 100644 web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.spec.tsx create mode 100644 web/features/new-rag/sources/setup/__tests__/fields.browser.spec.tsx delete mode 100644 web/features/new-rag/sources/setup/__tests__/fields.spec.tsx create mode 100644 web/features/new-rag/space/__tests__/api-access-dialog.browser.spec.tsx delete mode 100644 web/features/new-rag/space/__tests__/api-access-dialog.spec.tsx diff --git a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.browser.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.browser.spec.tsx new file mode 100644 index 00000000000..6c5e5a77f98 --- /dev/null +++ b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.browser.spec.tsx @@ -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>()) + +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() + 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 => ({ + 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( + + + + , + ) +} + +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) + }) +}) diff --git a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx b/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx deleted file mode 100644 index b50d5d266d5..00000000000 --- a/web/features/new-rag/components/__tests__/knowledge-model-readiness-banner.spec.tsx +++ /dev/null @@ -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>()) - -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() - 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 => ({ - 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( - , - ) - - 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( - , - ) - - 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( - , - ) - - 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() - 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) - }) -}) diff --git a/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.browser.spec.tsx b/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.browser.spec.tsx index 20ffc1e02cc..568a5da3e0b 100644 --- a/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.browser.spec.tsx +++ b/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.browser.spec.tsx @@ -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({}) + return ( + + ) +} + +function WebsiteParameterForm({ customized = false }: { customized?: boolean }) { + const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never) + const [parameters, setParameters] = useState(() => ({ + ...datasourceParameterDefaults(schemas), + ...(customized ? { limit: 99 } : {}), + })) + return ( + + ) +} + +function LegacyWebsiteParameterForm() { + const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never) + const [parameters, setParameters] = useState(() => + datasourceParameterDefaults(schemas), + ) + return ( + + ) +} + +const scrollSchema: DatasourceParameterSchema[] = [ { label: { en_US: 'Include subpages' }, name: 'include_subpages', @@ -16,7 +83,6 @@ const schemas: DatasourceParameterSchema[] = [ function ScrollableCrawlOptions() { const [parameters, setParameters] = useState({ include_subpages: true }) - return (
@@ -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() - 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() + 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() + 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() + 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() + 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() + 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() + + 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() + 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) + }) }) diff --git a/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.spec.tsx b/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.spec.tsx deleted file mode 100644 index 1147cd0ee68..00000000000 --- a/web/features/new-rag/sources/setup/__tests__/datasource-parameter-form.spec.tsx +++ /dev/null @@ -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({}) - return ( - - ) -} - -function UrlParameterForm() { - const [parameters, setParameters] = useState({}) - return ( - - ) -} - -function DecimalParameterForm() { - const [parameters, setParameters] = useState({}) - return ( - - ) -} - -function LegacyWebsiteParameterForm() { - const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never) - const [parameters, setParameters] = useState(() => - datasourceParameterDefaults(schemas), - ) - return ( - - ) -} - -function GroupedWebsiteParameterForm() { - const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never) - const [parameters, setParameters] = useState(() => - datasourceParameterDefaults(schemas), - ) - return ( - - ) -} - -function CustomizedGroupedWebsiteParameterForm() { - const schemas = websiteDatasourceParameterSchemas({ parameters: [] } as never) - const [parameters, setParameters] = useState(() => ({ - ...datasourceParameterDefaults(schemas), - limit: 99, - })) - return ( - - ) -} - -describe('DatasourceParameterForm', () => { - it('keeps an out-of-range numeric value visible and reports it as invalid', async () => { - const user = userEvent.setup() - render() - - 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() - - 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() - - 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() - - 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() - - 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() - - 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) - }) -}) diff --git a/web/features/new-rag/sources/setup/__tests__/fields.browser.spec.tsx b/web/features/new-rag/sources/setup/__tests__/fields.browser.spec.tsx new file mode 100644 index 00000000000..6a9d470c9f8 --- /dev/null +++ b/web/features/new-rag/sources/setup/__tests__/fields.browser.spec.tsx @@ -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 ( + <> + + + + + ) +} + +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() + 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) + }) +}) diff --git a/web/features/new-rag/sources/setup/__tests__/fields.spec.tsx b/web/features/new-rag/sources/setup/__tests__/fields.spec.tsx deleted file mode 100644 index 857e8967389..00000000000 --- a/web/features/new-rag/sources/setup/__tests__/fields.spec.tsx +++ /dev/null @@ -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( - , - ) - fireEvent.error(screen.getByRole('presentation', { hidden: true })) - expect(screen.queryByRole('presentation', { hidden: true })).not.toBeInTheDocument() - expect(container.firstElementChild).toBeVisible() - - rerender() - expect(screen.getByRole('presentation', { hidden: true })).toHaveAttribute('src', '/second.svg') - rerender() - 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() - 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', - ) - }) -}) diff --git a/web/features/new-rag/space/__tests__/api-access-dialog.browser.spec.tsx b/web/features/new-rag/space/__tests__/api-access-dialog.browser.spec.tsx new file mode 100644 index 00000000000..7473f4a8859 --- /dev/null +++ b/web/features/new-rag/space/__tests__/api-access-dialog.browser.spec.tsx @@ -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() + 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, 'onOpenChange' | 'open'>, + workspacePermissionKeys: string[] = ['dataset.api_key.manage'], +) { + function Viewer() { + const [open, setOpen] = useState(false) + return ( + <> + + + + ) + } + const { wrapper: QueryProvider } = createConsoleQueryWrapper({ workspacePermissionKeys }) + return render( + + + , + ) +} + +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() + }) +}) diff --git a/web/features/new-rag/space/__tests__/api-access-dialog.spec.tsx b/web/features/new-rag/space/__tests__/api-access-dialog.spec.tsx deleted file mode 100644 index bbdeb2cff9c..00000000000 --- a/web/features/new-rag/space/__tests__/api-access-dialog.spec.tsx +++ /dev/null @@ -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() - 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, - workspacePermissionKeys: string[] = ['dataset.api_key.manage'], -) { - return renderWithConsoleQuery(, { - 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 ( - { - onOpenChange(next) - setOpen(next) - }} - /> - ) - } - renderWithConsoleQuery(, { 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() - }) -})