test(web): replace AP1/AP3/AP32 list and output doubles (#45313)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-11-03 02:04:20 +00:00
1 parent 53ea596fc7
commit 3dc973ac34
3 files changed
+210 -131

No files matched your search

@@ -1,66 +1,99 @@
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import type { MarketplaceCollection } from '@dify/contracts/marketplace'
import type { ReactElement } from 'react'
import { screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vite-plus/test'
import {
createPluginInstallPermissionStore,
PluginInstallPermissionContext,
} from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createMarketplacePluginFixture, createPluginEntity } from '@/test/fixtures/plugin'
import List from '../index'
vi.mock(
'@/app/components/plugins/install-plugin/components/plugin-install-permission-provider',
() => ({
PluginInstallPermissionProviderGuard: ({ children }: { children: React.ReactNode }) => children,
}),
)
vi.mock('@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission', () => ({
useOptionalPluginInstallPermission: () => ({ canInstallPlugin: true }),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({ installedInfo: { installed: {} } }),
}))
vi.mock('../list-with-collection', () => ({
default: () => <div>Collections</div>,
}))
vi.mock('../card-wrapper', () => ({
default: ({ plugin, isInstalled }: { plugin: Plugin; isInstalled: boolean }) => (
<div>
{plugin.name}:{isInstalled ? 'installed' : 'available'}
</div>
),
}))
vi.mock('../../empty', () => ({
default: () => <div>No plugins</div>,
}))
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, {
'workspaces.current.plugin.list.installations.ids.post': async () => ({
plugins: [
createPluginEntity({
id: 'installed-plugin-id',
plugin_id: 'dify/installed-plugin',
plugin_unique_identifier: 'dify/installed-plugin:1.0.0@test',
}),
],
}),
}),
}
})
const collection: MarketplaceCollection = {
name: 'featured',
label: { en_US: 'Featured plugins' },
description: { en_US: 'A curated collection' },
rule: 'featured',
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
searchable: false,
search_params: {},
}
const featuredPlugin = createMarketplacePluginFixture({
plugin_id: 'dify/featured-plugin',
name: 'featured-plugin',
label: { en_US: 'Featured plugin' },
})
const baseProps = {
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
marketplaceCollections: [collection],
marketplaceCollectionPluginsMap: { featured: [featuredPlugin] },
}
function renderList(ui: ReactElement) {
const permissionStore = createPluginInstallPermissionStore({ canInstallPlugin: true })
return renderWithConsoleQuery(
<PluginInstallPermissionContext value={permissionStore}>
<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>
</PluginInstallPermissionContext>,
{ systemFeatures: { deployment_edition: 'CLOUD' } },
)
}
describe('List', () => {
it('renders collections when search results are absent', () => {
render(<List {...baseProps} />)
it('renders a real collection and its plugin card when search results are absent', () => {
renderList(<List {...baseProps} />)
expect(screen.getByText('Collections')).toBeInTheDocument()
expect(screen.getByText('Featured plugins')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Featured plugin' })).toBeInTheDocument()
})
it('renders the empty state for an empty search result', () => {
render(<List {...baseProps} plugins={[]} />)
it('renders the marketplace empty state for an empty search result', () => {
renderList(<List {...baseProps} plugins={[]} />)
expect(screen.getByText('No plugins')).toBeInTheDocument()
expect(screen.getByText('plugin.marketplace.noPluginFound')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Featured plugin' })).not.toBeInTheDocument()
})
it('renders search results with installed state', () => {
const plugins = [
{ plugin_id: 'installed', name: 'Installed plugin', org: 'dify' },
{ plugin_id: 'available', name: 'Available plugin', org: 'dify' },
] as Plugin[]
it('distinguishes an installed plugin from one that can be installed', async () => {
const installedPlugin = createMarketplacePluginFixture({
plugin_id: 'dify/installed-plugin',
name: 'installed-plugin',
label: { en_US: 'Installed plugin' },
})
const availablePlugin = createMarketplacePluginFixture({
plugin_id: 'dify/available-plugin',
name: 'available-plugin',
label: { en_US: 'Available plugin' },
})
render(<List {...baseProps} plugins={plugins} showInstallButton />)
renderList(
<List {...baseProps} plugins={[installedPlugin, availablePlugin]} showInstallButton />,
)
expect(screen.getByText('Installed plugin:installed')).toBeInTheDocument()
expect(screen.getByText('Available plugin:available')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'plugin.task.installed' })).toBeDisabled()
expect(
screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }),
).toBeEnabled()
})
})
@@ -1,28 +1,69 @@
import type { ReactElement } from 'react'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vite-plus/test'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/console'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryWrapper, seedAppDslVersion } from '@/test/console/query-data'
import { seedToolProviders } from '@/test/console/tool-providers'
import { ToolSettingsPanel } from '../tool-settings-panel'
vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({
default: ({ schema }: { schema: Array<{ name: string }> }) => (
<div data-testid="tool-form">{schema.map((item) => item.name).join(',')}</div>
),
}))
const createSchema = (overrides: Partial<ToolFormSchema> = {}): ToolFormSchema => ({
name: 'api_key',
variable: 'api_key',
label: { en_US: 'API key', zh_Hans: 'API 密钥' },
type: FormTypeEnum.textInput,
_type: 'string',
form: 'form',
required: false,
default: '',
tooltip: { en_US: 'Credential used by the tool', zh_Hans: '工具使用的凭据' },
show_on: [],
options: [],
placeholder: { en_US: 'Enter API key', zh_Hans: '输入 API 密钥' },
...overrides,
})
vi.mock('../reasoning-config-form', () => ({
ReasoningConfigForm: ({ schemas }: { schemas: Array<{ name: string }> }) => (
<div data-testid="reasoning-config-form">{schemas.map((item) => item.name).join(',')}</div>
),
}))
const settingsSchema = createSchema()
const paramsSchema = createSchema({
name: 'temperature',
variable: 'temperature',
label: { en_US: 'Temperature', zh_Hans: '温度' },
type: FormTypeEnum.textNumber,
_type: 'number',
form: 'llm',
default: '0.5',
tooltip: { en_US: 'Controls randomness', zh_Hans: '控制随机性' },
placeholder: { en_US: 'Enter temperature', zh_Hans: '输入温度' },
})
const baseProps = {
nodeId: 'node-1',
currType: 'settings' as const,
settingsFormSchemas: [{ name: 'api_key' }] as never[],
paramsFormSchemas: [{ name: 'temperature' }] as never[],
settingsValue: {},
settingsFormSchemas: [settingsSchema],
paramsFormSchemas: [paramsSchema],
settingsValue: {
api_key: { type: VarKindType.mixed, value: 'saved-key' },
},
value: {
provider_name: 'test-provider',
tool_name: 'test-tool',
tool_label: 'Test tool',
parameters: {
temperature: {
auto: 0 as const,
value: { type: VarKindType.constant, value: '0.7' },
},
},
},
showTabSlider: true,
userSettingsOnly: false,
reasoningConfigOnly: false,
@@ -36,20 +77,42 @@ const baseProps = {
} as ToolWithProvider,
}
function renderPanel(ui: ReactElement) {
const { queryClient } = createConsoleQueryWrapper()
seedAppDslVersion(queryClient)
seedModelProviderQueries(queryClient, [])
seedToolProviders(queryClient, {
builtInTools: [],
customTools: [],
workflowTools: [],
mcpTools: [],
})
queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), [])
return renderWorkflowFlowComponent(ui, {
queryClient,
nodes: [createNode({ id: 'node-1', data: { type: BlockEnum.Tool, title: 'Tool' } })],
edges: [],
})
}
describe('ToolSettingsPanel', () => {
it('returns null when the provider is not team-authorized or has no forms', () => {
const { container, rerender } = render(
const { rerender } = renderPanel(
<ToolSettingsPanel
{...baseProps}
currentProvider={{ is_team_authorization: false } as ToolWithProvider}
/>,
)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
rerender(<ToolSettingsPanel {...baseProps} settingsFormSchemas={[]} paramsFormSchemas={[]} />)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
it('switches the controlled settings and params panels with the keyboard', async () => {
@@ -58,28 +121,22 @@ describe('ToolSettingsPanel', () => {
const [currType, setCurrType] = useState<'settings' | 'params'>('settings')
return <ToolSettingsPanel {...baseProps} currType={currType} onCurrTypeChange={setCurrType} />
}
render(<ControlledPanel />)
renderPanel(<ControlledPanel />)
expect(
screen.getByRole('tabpanel', { name: 'plugin.detailPanel.toolSelector.settings' }),
).toHaveTextContent('api_key')
expect(screen.getByRole('textbox')).toHaveTextContent('saved-key')
await user.click(screen.getByRole('tab', { name: 'plugin.detailPanel.toolSelector.settings' }))
await user.keyboard('{ArrowRight}{Enter}')
expect(
screen.getByRole('tab', { name: 'plugin.detailPanel.toolSelector.params' }),
).toHaveFocus()
expect(
screen.getByRole('tabpanel', { name: 'plugin.detailPanel.toolSelector.params' }),
).toHaveTextContent('temperature')
expect(screen.queryByTestId('tool-form')).not.toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'Temperature' })).toHaveValue(0.7)
expect(screen.queryByText('saved-key')).not.toBeInTheDocument()
await user.keyboard('{ArrowLeft}{Enter}')
expect(
screen.getByRole('tabpanel', { name: 'plugin.detailPanel.toolSelector.settings' }),
).toHaveTextContent('api_key')
expect(screen.getByRole('textbox')).toHaveTextContent('saved-key')
})
it('renders a settings-only form without an orphaned tab panel', () => {
render(
it('renders a settings-only real form without an orphaned tab panel', () => {
renderPanel(
<ToolSettingsPanel
{...baseProps}
paramsFormSchemas={[]}
@@ -87,13 +144,15 @@ describe('ToolSettingsPanel', () => {
userSettingsOnly
/>,
)
expect(screen.getByTestId('tool-form')).toHaveTextContent('api_key')
const input = screen.getByRole('textbox')
expect(input).toHaveTextContent('saved-key')
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument()
})
it('renders params tips and the reasoning config form for params-only views', () => {
render(
it('renders params tips and a real reasoning control without orphaned panels', () => {
renderPanel(
<ToolSettingsPanel
{...baseProps}
currType="params"
@@ -105,8 +164,10 @@ describe('ToolSettingsPanel', () => {
)
expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip1')).toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip2')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'Temperature' })).toHaveValue(0.7)
expect(screen.getByRole('switch')).not.toBeChecked()
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument()
expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('temperature')
})
})
@@ -1,4 +1,3 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -7,35 +6,6 @@ import OutputPanel from '../output-panel'
type FileOutput = FileResponse & { dify_model_identity: '__dify__file__' }
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list">{files.map((file) => file.name).join(', ')}</div>
),
}))
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({
language,
value,
height,
title,
}: {
language: string
value: string
height?: number
title?: React.ReactNode
}) => (
<div data-height={height} data-language={language} data-testid="code-editor" data-value={value}>
<div>{title}</div>
{value}
</div>
),
}))
const createFileOutput = (overrides: Partial<FileOutput> = {}): FileOutput => ({
dify_model_identity: '__dify__file__',
related_id: 'file-1',
@@ -52,11 +22,11 @@ const createFileOutput = (overrides: Partial<FileOutput> = {}): FileOutput => ({
})
describe('OutputPanel', () => {
it('shows no-output only after execution finishes without a result', () => {
it('shows no-output only after execution finishes without a result', async () => {
const { rerender } = render(<OutputPanel isRunning />)
expect(screen.queryByText('No Output')).not.toBeInTheDocument()
rerender(<OutputPanel isRunning={false} />)
expect(screen.getByText('No Output')).toBeVisible()
expect(await screen.findByText('No Output')).toBeVisible()
})
it('shows and copies a failed execution message', async () => {
@@ -70,22 +40,35 @@ describe('OutputPanel', () => {
expect(screen.getByText('Execution failed')).toBeInTheDocument()
})
it('renders a plain text output as markdown', () => {
render(<OutputPanel outputs={{ answer: 'Hello Dify' }} />)
it('renders a plain text output as semantic markdown', async () => {
render(
<OutputPanel
outputs={{ answer: '# Run summary\n\n[Review output](https://example.com/review)' }}
/>,
)
expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify')
expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Review output' })).toHaveAttribute(
'href',
'https://example.com/review',
)
})
it('renders array text outputs as joined markdown content', () => {
render(<OutputPanel outputs={{ answer: ['Line 1', 'Line 2'] }} />)
it('renders array text outputs as joined semantic markdown content', async () => {
render(<OutputPanel outputs={{ answer: ['## First result', '## Second result'] }} />)
expect(screen.getByTestId('markdown').textContent).toMatch(/Line 1\s+Line 2/)
expect(await screen.findByRole('heading', { name: 'First result' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Second result' })).toBeInTheDocument()
})
it('renders a file list for a single file output', () => {
render(<OutputPanel outputs={{ attachment: createFileOutput() }} />)
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
expect(screen.getByRole('button', { name: 'report.pdf' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.download' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.operation.remove' }),
).not.toBeInTheDocument()
})
it('renders a file list for an array of file outputs', () => {
@@ -108,25 +91,27 @@ describe('OutputPanel', () => {
/>,
)
expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md')
expect(screen.getByRole('button', { name: 'report.pdf' })).toBeInTheDocument()
expect(screen.getByText('summary.md')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'common.operation.download' })).toHaveLength(2)
expect(
screen.queryByRole('button', { name: 'common.operation.remove' }),
).not.toBeInTheDocument()
})
it('renders structured outputs inside the code editor when height is available', () => {
render(<OutputPanel height={200} outputs={{ answer: 'hello', score: 1 }} />)
expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json')
expect(screen.getByTestId('code-editor')).toHaveAttribute(
'data-value',
`{
"answer": "hello",
"score": 1
}`,
)
const editor = screen.getByTestId<HTMLInputElement>('monaco-editor')
expect(editor).toHaveAttribute('readonly')
const parsedValue: unknown = JSON.parse(editor.value)
const semanticValue = typeof parsedValue === 'string' ? JSON.parse(parsedValue) : parsedValue
expect(semanticValue).toEqual({ answer: 'hello', score: 1 })
})
it('skips the code editor when structured outputs have no positive height', () => {
render(<OutputPanel height={0} outputs={{ answer: 'hello', score: 1 }} />)
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument()
})
})