mirror of
https://github.com/langgenius/dify.git
synced 2026-03-20 22:17:58 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
This commit is contained in:
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
1021
web/app/components/app/configuration/debug/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,26 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Trigger from './trigger'
|
||||
|
||||
const mockUseCredentialPanelState = vi.fn()
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
|
||||
modelProviders: [{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: () => mockUseCredentialPanelState(),
|
||||
}))
|
||||
|
||||
vi.mock('../model-icon', () => ({
|
||||
default: () => <div data-testid="model-icon">Icon</div>,
|
||||
}))
|
||||
@ -22,119 +30,195 @@ vi.mock('../model-name', () => ({
|
||||
}))
|
||||
|
||||
describe('Trigger', () => {
|
||||
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
||||
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
|
||||
const currentProvider = {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
} as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
||||
|
||||
const currentModel = {
|
||||
model: 'gpt-4',
|
||||
status: 'active',
|
||||
} as unknown as ComponentProps<typeof Trigger>['currentModel']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render initialized state', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
describe('Rendering', () => {
|
||||
it('should render initialized state when provider and model are available', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback model id when current model is missing', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="openai"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow styles when workflow mode is enabled', () => {
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
isInWorkflow
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
|
||||
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
|
||||
})
|
||||
|
||||
it('should render workflow empty state when no provider or model is selected', () => {
|
||||
const { container } = render(<Trigger isInWorkflow />)
|
||||
|
||||
expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('border-text-warning')
|
||||
expect(container.firstChild).toHaveClass('bg-state-warning-hover')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fallback model id when current model is missing', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="openai"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
describe('Status badges', () => {
|
||||
it('should render credits exhausted split layout in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-exhausted',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should resolve provider from context when currentProvider is missing in split layout', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-exhausted',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable split layout in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-unavailable',
|
||||
supportsCredits: true,
|
||||
isCreditsExhausted: false,
|
||||
priority: 'apiKey',
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: true,
|
||||
credentialName: 'Primary Key',
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when deprecated model is disabled', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
disabled
|
||||
hasDeprecated
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render warning icon when model status is disabled but not deprecated', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'no-configure' } as typeof currentModel}
|
||||
disabled
|
||||
modelDisabled
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.text-\\[\\#F79009\\]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
|
||||
it('should render workflow styles when isInWorkflow is true', () => {
|
||||
// Act
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when providerName does not match any provider', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="unknown-provider"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
|
||||
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
|
||||
expect(container.querySelectorAll('svg').length).toBe(2)
|
||||
})
|
||||
|
||||
// disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip
|
||||
it('should show deprecated warning when disabled with hasDeprecated', () => {
|
||||
// Act
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
disabled
|
||||
hasDeprecated
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - AlertTriangle renders with warning color
|
||||
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||
expect(warningIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// disabled=true + modelDisabled=true: status text tooltip
|
||||
it('should show model status tooltip when disabled with modelDisabled', () => {
|
||||
// Act
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'no-configure' } as unknown as typeof currentModel}
|
||||
disabled
|
||||
modelDisabled
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - AlertTriangle warning icon should be present
|
||||
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||
expect(warningIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
disabled
|
||||
hasDeprecated={false}
|
||||
modelDisabled={false}
|
||||
/>,
|
||||
)
|
||||
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||
expect(warningIcon).toBeInTheDocument()
|
||||
const trigger = container.querySelector('[data-state]')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
await user.hover(trigger as HTMLElement)
|
||||
const tooltip = screen.queryByRole('tooltip')
|
||||
if (tooltip)
|
||||
expect(tooltip).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// providerName not matching any provider: find() returns undefined
|
||||
it('should render without crashing when providerName does not match any provider', () => {
|
||||
// Act
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="unknown-provider"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -10,9 +10,13 @@ import {
|
||||
import ModelSelectorTrigger from './model-selector-trigger'
|
||||
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockUseCredentialPanelState,
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
@ -48,6 +52,16 @@ describe('ModelSelectorTrigger', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [createModel()],
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-active',
|
||||
priority: 'credits',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 100,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -132,6 +146,28 @@ describe('ModelSelectorTrigger', () => {
|
||||
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply credits exhausted badge style when model quota is exceeded', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'credits-exhausted',
|
||||
priority: 'credits',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
currentProvider={createModel()}
|
||||
currentModel={createModelItem()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm')
|
||||
})
|
||||
|
||||
it('should not show status badge when selected model is readonly', () => {
|
||||
render(
|
||||
<ModelSelectorTrigger
|
||||
|
||||
@ -11,6 +11,7 @@ import { cn } from '@/utils/classnames'
|
||||
import { ModelStatusEnum } from '../declarations'
|
||||
import ModelIcon from '../model-icon'
|
||||
import ModelName from '../model-name'
|
||||
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
|
||||
const STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
|
||||
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
|
||||
@ -47,10 +48,22 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
const isSelected = !!currentProvider && !!currentModel
|
||||
const isDeprecated = !isSelected && !!defaultModel
|
||||
const isEmpty = !isSelected && !defaultModel
|
||||
const selectedProvider = isSelected
|
||||
? modelProviders.find(provider => provider.provider === currentProvider.provider)
|
||||
: undefined
|
||||
const selectedProviderState = useCredentialPanelState(selectedProvider)
|
||||
const shouldShowCreditsExhausted = isSelected
|
||||
&& selectedProviderState.priority === 'credits'
|
||||
&& selectedProviderState.supportsCredits
|
||||
&& selectedProviderState.isCreditsExhausted
|
||||
const effectiveStatus = shouldShowCreditsExhausted
|
||||
? ModelStatusEnum.quotaExceeded
|
||||
: currentModel?.status
|
||||
|
||||
const isActive = isSelected && currentModel.status === ModelStatusEnum.active
|
||||
const isActive = isSelected && effectiveStatus === ModelStatusEnum.active
|
||||
const isDisabled = isDeprecated || (isSelected && !isActive)
|
||||
const statusI18nKey = isSelected ? STATUS_I18N_KEY[currentModel.status] : undefined
|
||||
const statusI18nKey = isSelected && effectiveStatus ? STATUS_I18N_KEY[effectiveStatus] : undefined
|
||||
const isCreditsExhausted = isSelected && effectiveStatus === ModelStatusEnum.quotaExceeded
|
||||
|
||||
const deprecatedProvider = isDeprecated
|
||||
? modelProviders.find(p => p.provider === defaultModel.provider)
|
||||
@ -107,9 +120,14 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
{isSelected && !readonly && !isActive && statusI18nKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={currentModel.status !== ModelStatusEnum.noPermission}
|
||||
disabled={effectiveStatus !== ModelStatusEnum.noPermission}
|
||||
render={(
|
||||
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5',
|
||||
isCreditsExhausted && 'min-w-[20px] justify-center bg-components-badge-bg-dimm',
|
||||
)}
|
||||
>
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
|
||||
|
||||
@ -212,7 +212,7 @@ describe('CredentialPanel', () => {
|
||||
expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show red indicator and "Unavailable" for api-unavailable (exhausted + named unauthorized key)', () => {
|
||||
it('should show red indicator and credential name for api-unavailable (exhausted + named unauthorized key)', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
@ -224,7 +224,6 @@ describe('CredentialPanel', () => {
|
||||
},
|
||||
}))
|
||||
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Bad Key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -303,6 +302,27 @@ describe('CredentialPanel', () => {
|
||||
const { container } = renderWithQueryClient(createProvider())
|
||||
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should use destructive text color for api-unavailable credential name', () => {
|
||||
mockTrialCredits.isExhausted = true
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_id: undefined,
|
||||
current_credential_name: 'Bad Key',
|
||||
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
|
||||
},
|
||||
}))
|
||||
expect(screen.getByText('Bad Key')).toHaveClass('text-text-destructive')
|
||||
})
|
||||
|
||||
it('should use secondary text color for api-active credential name', () => {
|
||||
renderWithQueryClient(createProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
}))
|
||||
expect(screen.getByText('test-credential')).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Priority change', () => {
|
||||
|
||||
@ -82,26 +82,21 @@ function StatusLabel({ variant, credentialName }: {
|
||||
variant: CardVariant
|
||||
credentialName: string | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const dotColor = variant === 'api-unavailable' ? 'red' : 'green'
|
||||
const isDestructive = isDestructiveVariant(variant)
|
||||
const dotColor = isDestructive ? 'red' : 'green'
|
||||
const showWarning = variant === 'api-fallback'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Indicator className="shrink-0" color={dotColor} />
|
||||
<span
|
||||
className="truncate text-text-secondary"
|
||||
className={`truncate ${isDestructive ? 'text-text-destructive' : 'text-text-secondary'}`}
|
||||
title={credentialName}
|
||||
>
|
||||
{credentialName}
|
||||
</span>
|
||||
{showWarning && (
|
||||
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
|
||||
)}
|
||||
{variant === 'api-unavailable' && (
|
||||
<span className="shrink-0 text-text-destructive system-2xs-medium">
|
||||
{t('modelProvider.card.unavailable', { ns: 'common' })}
|
||||
</span>
|
||||
<Warning className="ml-auto h-3 w-3 shrink-0 text-text-warning" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -156,7 +156,7 @@ describe('ModelAuthDropdown', () => {
|
||||
})
|
||||
|
||||
describe('Button variant styling', () => {
|
||||
it('should use secondary-accent for api-required-add', () => {
|
||||
it('should use primary for api-required-add', () => {
|
||||
const { container } = render(
|
||||
<ModelAuthDropdown
|
||||
provider={createProvider()}
|
||||
@ -166,7 +166,7 @@ describe('ModelAuthDropdown', () => {
|
||||
/>,
|
||||
)
|
||||
const button = container.querySelector('button')
|
||||
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
|
||||
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/primary/)
|
||||
})
|
||||
|
||||
it('should use secondary-accent for api-required-configure', () => {
|
||||
|
||||
@ -17,17 +17,17 @@ type ModelAuthDropdownProps = {
|
||||
onChangePriority: (key: PreferredProviderTypeEnum) => void
|
||||
}
|
||||
|
||||
const ACCENT_VARIANTS = new Set<CardVariant>([
|
||||
'api-required-add',
|
||||
'api-required-configure',
|
||||
])
|
||||
|
||||
function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record<string, string>) => string) {
|
||||
if (ACCENT_VARIANTS.has(variant)) {
|
||||
if (variant === 'api-required-add') {
|
||||
return {
|
||||
text: variant === 'api-required-add'
|
||||
? t('modelProvider.auth.addApiKey', { ns: 'common' })
|
||||
: t('operation.config', { ns: 'common' }),
|
||||
text: t('modelProvider.auth.addApiKey', { ns: 'common' }),
|
||||
variant: 'primary' as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'api-required-configure') {
|
||||
return {
|
||||
text: t('operation.config', { ns: 'common' }),
|
||||
variant: 'secondary-accent' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginDetail } from '../../types'
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
@ -180,7 +176,7 @@ const DetailHeader = ({
|
||||
text={(
|
||||
<>
|
||||
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
|
||||
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
{isFromMarketplace && !isReadmeView && <span aria-hidden className="i-ri-arrow-left-right-line ml-1 h-3 w-3 text-text-tertiary" />}
|
||||
</>
|
||||
)}
|
||||
hasRedCornerMark={hasNewVersion}
|
||||
@ -255,7 +251,7 @@ const DetailHeader = ({
|
||||
detailUrl={detailUrl}
|
||||
/>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -701,8 +701,8 @@ describe('update-plugin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when task status is failed', async () => {
|
||||
// Arrange - covers lines 99-100
|
||||
it('should reset loading state when task status check fails', async () => {
|
||||
// Arrange
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
|
||||
|
||||
@ -739,6 +739,53 @@ describe('update-plugin', () => {
|
||||
})
|
||||
// onSave should NOT be called when task fails
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop loading when upgrade API returns failed task directly', async () => {
|
||||
// Arrange
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
|
||||
|
||||
mockUpdateFromMarketPlace.mockResolvedValue({
|
||||
task: {
|
||||
status: TaskStatus.failed,
|
||||
plugins: [{
|
||||
plugin_unique_identifier: 'test-target-id',
|
||||
status: TaskStatus.failed,
|
||||
message: 'failed to init environment',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
const payload = createMockMarketPlacePayload()
|
||||
|
||||
// Act
|
||||
renderWithQueryClient(
|
||||
<UpdateFromMarketplace
|
||||
payload={payload}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'failed to init environment',
|
||||
})
|
||||
})
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -33,6 +33,16 @@ type Props = {
|
||||
isShowDowngradeWarningModal?: boolean
|
||||
}
|
||||
|
||||
type FailedUpgradeResponse = {
|
||||
task?: {
|
||||
status?: TaskStatus
|
||||
plugins?: Array<{
|
||||
plugin_unique_identifier: string
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
enum UploadStep {
|
||||
notStarted = 'notStarted',
|
||||
upgrading = 'upgrading',
|
||||
@ -83,13 +93,20 @@ const UpdatePluginModal: FC<Props> = ({
|
||||
if (uploadStep === UploadStep.notStarted) {
|
||||
setUploadStep(UploadStep.upgrading)
|
||||
try {
|
||||
const response = await updateFromMarketPlace({
|
||||
original_plugin_unique_identifier: originalPackageInfo.id,
|
||||
new_plugin_unique_identifier: targetPackageInfo.id,
|
||||
}) as Awaited<ReturnType<typeof updateFromMarketPlace>> & FailedUpgradeResponse
|
||||
|
||||
if (response.task?.status === TaskStatus.failed) {
|
||||
setUploadStep(UploadStep.notStarted)
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
all_installed: isInstalled,
|
||||
task_id: taskId,
|
||||
} = await updateFromMarketPlace({
|
||||
original_plugin_unique_identifier: originalPackageInfo.id,
|
||||
new_plugin_unique_identifier: targetPackageInfo.id,
|
||||
})
|
||||
} = response
|
||||
|
||||
if (isInstalled) {
|
||||
onSave()
|
||||
@ -102,6 +119,7 @@ const UpdatePluginModal: FC<Props> = ({
|
||||
})
|
||||
if (status === TaskStatus.failed) {
|
||||
Toast.notify({ type: 'error', message: error! })
|
||||
setUploadStep(UploadStep.notStarted)
|
||||
return
|
||||
}
|
||||
onSave()
|
||||
|
||||
Reference in New Issue
Block a user