Commit 9c8450da by Finley Ge Committed by GitHub

feat(tool-marketplace): support partial plugin update retries (#7444)

* feat(plugin): support partial batch update retries

* fix: table header font size

* refactor(upload): centralize extension normalization helpers

* feat(marketplace): align batch update tool details

* fix(tool-marketplace): handle plugin update failures and refine status

* fix(i18n): rename ai managed label

* fix(i18n): rename generated input labels

* fix(marketplace): track installed plugin version in detail

* fix(marketplace): make version install action exclusive

* fix(marketplace): sync selected installed version

* fix(marketplace): persist installed version in tool state

* fix(marketplace): persist installed version status

* fix(marketplace): truncate long tool card titles

* fix(marketplace): preserve installed state after tool updates

* fix(marketplace): surface install errors and defer success toast
parent 5c1d1bd2
......@@ -4,7 +4,7 @@ import m from 'multer';
import type { NodeHttpRequest } from '../../types/http';
import path from 'path';
import fs from 'node:fs';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/uploadConstraints';
import { normalizeAllowedExtensions, normalizeFileExtension } from '../s3/utils/extension';
type MulterFileFilterOptions = {
allowedExtensions?: string[];
......
......@@ -11,6 +11,16 @@ import {
resolveMimeType
} from '../utils/mime';
import type { UploadExtensionRule, UploadPolicy } from './type';
import {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
export {
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../utils/extension';
const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectFile',
......@@ -70,25 +80,6 @@ export const officeZipFormats = [
* 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单
* 在预签、上传校验和 metadata 修正阶段出现不同表示。
*/
export const normalizeFileExtension = (extension?: string) => {
if (!extension) return '';
const trimmedExtension = extension.trim().toLowerCase();
if (!trimmedExtension) return '';
return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`;
};
export const normalizeAllowedExtensions = (extensions?: string[]) => {
if (!extensions?.length) return [];
return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))];
};
export const parseAllowedExtensions = (value: string) => {
return normalizeAllowedExtensions(value.split(','));
};
export const decodeFileName = (filename?: string) => {
if (!filename) return '';
try {
......
/**
* 统一扩展名格式。上传策略中所有 extension 都必须小写并带 `.`,避免同一白名单
* 在预签、上传校验和 metadata 修正阶段出现不同表示。
*/
export const normalizeFileExtension = (extension?: string) => {
if (!extension) return '';
const trimmedExtension = extension.trim().toLowerCase();
if (!trimmedExtension) return '';
return trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`;
};
export const normalizeAllowedExtensions = (extensions?: string[]) => {
if (!extensions?.length) return [];
return [...new Set(extensions.map(normalizeFileExtension).filter(Boolean))];
};
export const parseAllowedExtensions = (value: string) => {
return normalizeAllowedExtensions(value.split(','));
};
......@@ -4,13 +4,13 @@ import {
type FileExtensionKeyType
} from '@fastgpt/global/core/app/constants';
import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema';
import type { UploadConstraintsInput } from '../contracts/type';
import type { UploadConstraintsInput, UploadConstraints } from '../contracts/type';
import { createUploadExtensionRulesFromFileSelectConfig } from '../uploadPolicy/utils';
import {
createUploadExtensionRulesFromFileSelectConfig,
normalizeAllowedExtensions,
normalizeFileExtension,
parseAllowedExtensions
} from '../uploadPolicy/utils';
} from './extension';
import { createUploadPolicy } from '../uploadPolicy/service';
import type { UploadPolicy } from '../uploadPolicy/type';
......@@ -22,7 +22,7 @@ const uploadConfigKeys: FileExtensionKeyType[] = [
'canSelectCustomFileExtension'
];
export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions };
export { normalizeAllowedExtensions, normalizeFileExtension, parseAllowedExtensions } from './extension';
export const avatarAllowedExtensions = normalizeAllowedExtensions(['.jpg', '.jpeg', '.png']);
export const datasetAllowedExtensions = parseAllowedExtensions(documentFileType);
......
......@@ -72,12 +72,7 @@ const ToolCard = ({
isMarketplaceVariant && mode === 'admin' && item.installed && !showActionButton;
useEffect(() => {
if (displayTags.length === 0) {
setVisibleTagsCount(0);
return;
}
setVisibleTagsCount(displayTags.length);
if (displayTags.length === 0) return;
const calculate = () => {
const container = tagsContainerRef.current;
......@@ -204,42 +199,6 @@ const ToolCard = ({
}
}}
>
{/* Update badge in top-right corner */}
{item.update && mode === 'admin' && !item.isDebug && (
<Flex
alignItems="center"
position={'absolute'}
top={isMarketplaceVariant ? '17px' : 4}
right={isMarketplaceVariant ? '17px' : 4}
px={2}
py={0.5}
bg={'rgb(255, 247, 237)'}
color={'rgba(234,88,12,1)'}
fontSize={'12px'}
fontWeight={'medium'}
borderRadius={'0.5rem'}
borderColor={'rgba(255,237,213,1)'}
borderWidth={'1px'}
zIndex={1}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path>
</svg>
{t('app:app.modules.has new version')}
</Flex>
)}
<HStack
minW={0}
spacing={isMarketplaceVariant ? 2 : undefined}
......@@ -250,6 +209,7 @@ const ToolCard = ({
borderRadius={'sm'}
w={isMarketplaceVariant ? '24px' : '1.5rem'}
h={isMarketplaceVariant ? '24px' : undefined}
flexShrink={0}
/>
<Box
color={isMarketplaceVariant ? '#111824' : 'myGray.900'}
......@@ -257,7 +217,7 @@ const ToolCard = ({
lineHeight={isMarketplaceVariant ? '24px' : undefined}
fontWeight={'medium'}
minW={0}
flexShrink={1}
flex={1}
className={'textEllipsis'}
>
{parseI18nString(item.name, i18n.language)}
......@@ -279,6 +239,37 @@ const ToolCard = ({
</Box>
)}
{item.isDebug && <DebugToolTag />}
{item.update && mode === 'admin' && !item.isDebug && (
<Flex
alignItems="center"
flexShrink={0}
px={2}
py={0.5}
bg={'rgb(255, 247, 237)'}
color={'rgba(234,88,12,1)'}
fontSize={'12px'}
fontWeight={'medium'}
borderRadius={'0.5rem'}
borderColor={'rgba(255,237,213,1)'}
borderWidth={'1px'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path>
</svg>
{t('app:app.modules.has new version')}
</Flex>
)}
{statusLabel && (
<Flex
flexShrink={0}
......
import React, { useState, type ReactNode } from 'react';
import { Accordion, Box, Flex, VStack } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import MyBox from '../../../../common/MyBox';
import LightRowTabs from '../../../../common/Tabs/LightRowTabs';
import { ParamSection, SubToolAccordionItem } from './components';
import ReadmeBox from './ReadmeBox';
import type { ToolDetailExtendedType } from './types';
type ToolDetailBodyProps = {
parentTool?: Partial<ToolDetailExtendedType>;
isToolSet: boolean;
subTools: ToolDetailExtendedType[];
readmeContent: string;
actions?: ReactNode;
showPoint?: boolean;
systemTitle?: string;
};
/**
* 渲染工具详情 Drawer 的共享正文,操作区由调用方注入以适配不同业务入口。
*/
const ToolDetailBody = ({
parentTool,
isToolSet,
subTools,
readmeContent,
actions,
showPoint = false,
systemTitle
}: ToolDetailBodyProps) => {
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<'guide' | 'params'>('params');
const author = [parentTool?.author, systemTitle].find((value) => value?.trim()) ?? 'FastGPT';
const readmeSource = [readmeContent, parentTool?.userGuide].find((value) => value?.trim()) ?? '';
return (
<MyBox>
<Flex gap={2} flexWrap="wrap">
{parentTool?.tags?.map((tag) => (
<Box
key={tag}
px={2}
py={1}
border={'1px solid'}
borderRadius={'6px'}
borderColor={'myGray.200'}
fontSize={'10px'}
fontWeight={'medium'}
color={'myGray.700'}
>
{tag}
</Box>
))}
</Flex>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{parseI18nString(parentTool?.description ?? '', i18n.language)}
</Box>
<Box fontSize={'12px'} color="myGray.500" mt={3}>
{`by ${author}`}
</Box>
{actions && <Box mt={3}>{actions}</Box>}
{showPoint && (
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_call_points_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{!!parentTool?.currentCost ? parentTool.currentCost : t('app:toolkit_no_call_points')}
</Box>
</Flex>
)}
<Flex mt={4} gap={1.5} alignItems={'center'}>
<Box fontWeight={'medium'} fontSize={'14px'} color={'myGray.900'}>
{t('app:toolkit_activation_label')}
</Box>
<Box fontSize={'12px'} color={'myGray.600'}>
{parentTool?.hasSystemSecret ||
(parentTool?.secrets && parentTool.secrets.length > 0) ||
(parentTool?.secretInputConfig && parentTool.secretInputConfig.length > 0) ||
(parentTool?.inputList && parentTool.inputList.length > 0)
? t('app:toolkit_activation_required')
: t('app:toolkit_activation_not_required')}
</Box>
</Flex>
<Box mt={4}>
<LightRowTabs
list={[
{
label: isToolSet ? t('app:toolkit_tool_list') : t('app:toolkit_params_description'),
value: 'params'
},
...(parentTool?.courseUrl || parentTool?.readme || parentTool?.userGuide
? [{ label: t('app:toolkit_user_guide'), value: 'guide' }]
: [])
]}
value={activeTab}
onChange={(value) => {
if (
value === 'guide' &&
parentTool?.courseUrl &&
!parentTool?.readme &&
!parentTool?.userGuide
) {
window.open(parentTool.courseUrl, '_blank');
} else {
setActiveTab(value as 'guide' | 'params');
}
}}
gap={4}
/>
<Box h={'1px'} w={'full'} bg={'myGray.200'} mt={'-5px'} mx={1} />
</Box>
<Box mt={4}>
{activeTab === 'guide' && (
<VStack align="stretch" spacing={4} flex="1" minH="0">
{(parentTool?.readme || readmeContent || parentTool?.userGuide) && (
<ReadmeBox source={readmeSource} courseUrl={parentTool?.courseUrl} />
)}
</VStack>
)}
{activeTab === 'params' && (
<VStack align="stretch" spacing={4}>
{isToolSet && subTools.length > 0 && (
<Accordion allowMultiple {...(subTools.length === 1 ? { defaultIndex: [0] } : {})}>
{subTools.map((subTool) => (
<SubToolAccordionItem key={subTool.pluginId} tool={subTool} />
))}
</Accordion>
)}
{!isToolSet && (
<>
{parentTool?.versionList?.[0]?.inputs &&
parentTool.versionList[0].inputs.length > 0 && (
<ParamSection
title={t('app:toolkit_inputs')}
params={parentTool.versionList[0].inputs}
/>
)}
{parentTool?.versionList?.[0]?.outputs &&
parentTool.versionList[0].outputs.length > 0 && (
<ParamSection
title={t('app:toolkit_outputs')}
params={parentTool.versionList[0].outputs}
/>
)}
</>
)}
</VStack>
)}
</Box>
</MyBox>
);
};
export default ToolDetailBody;
......@@ -3,3 +3,4 @@ export * from './components';
export * from './hooks';
export * from './styles';
export { default as ReadmeBox } from './ReadmeBox';
export { default as ToolDetailBody } from './ToolDetailBody';
......@@ -3,3 +3,22 @@
*/
export const normalizeToolCardTags = (tags?: string[] | null) =>
tags?.map((tag) => tag.trim()).filter(Boolean) ?? [];
/** 根据工具启用状态和 plugin-server 安装记录判断当前版本是否已安装。 */
export const isToolVersionInstalled = ({
isInstalled,
currentVersion,
installedVersions,
installedVersion
}: {
isInstalled: boolean;
currentVersion?: string;
installedVersions?: string[];
installedVersion?: string;
}) => {
if (!isInstalled) return false;
if (installedVersion === currentVersion) return true;
if (installedVersions) return installedVersions.includes(currentVersion ?? '');
if (installedVersion) return false;
return true;
};
......@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "Plugin Introduction",
"toolkit_plugin_name": "Plugin Name",
"toolkit_plugin_status": "Plugin Status",
"toolkit_plugin_update": "Plugin Update",
"toolkit_plugin_version": "Plugin Version",
"toolkit_promote_tags": "Promote Tags",
"toolkit_promote_tags_tip": "Users with the following tags will see the \"Promoted\" badge",
"toolkit_reinstall": "Reinstall",
"toolkit_retry_update": "Retry update",
"toolkit_search_placeholder": "Search tool names",
"toolkit_select": "Select",
"toolkit_select_app": "Select an existing app",
"toolkit_select_user_tags": "Select user tags",
"toolkit_status": "Status",
......@@ -475,6 +478,11 @@
"toolkit_uninstalled": "Not Installed",
"toolkit_updatable": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins",
"toolkit_update_failed": "Update failed",
"toolkit_update_pending": "Pending",
"toolkit_update_selected": "Update selected ({{count}})",
"toolkit_update_success_count": "Successfully updated {{count}} plugin(s)",
"toolkit_updating": "Updating",
"toolkit_user_guide": "User Guide",
"toolkit_version_info": "Version Information",
"tools": "tool",
......
......@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "Variable Reference",
"core.workflow.inputType.agentGenerated": "Agent generated",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "Parameter value is generated dynamically by AI",
"core.workflow.inputType.agentGeneratedManaged": "AI managed",
"core.workflow.inputType.agentGeneratedManaged": "AI Generate",
"core.workflow.inputType.agentGeneratedSwitchTip": "Controlled by AI",
"core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.",
"core.workflow.inputType.custom": "External Variable",
......
......@@ -440,11 +440,14 @@
"toolkit_plugin_intro": "插件介绍",
"toolkit_plugin_name": "插件名称",
"toolkit_plugin_status": "插件状态",
"toolkit_plugin_update": "插件更新",
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推荐标签",
"toolkit_promote_tags_tip": "拥有以下标签的用户会看到\"推荐\"标识",
"toolkit_reinstall": "重新安装",
"toolkit_retry_update": "重试更新",
"toolkit_search_placeholder": "搜索工具名称",
"toolkit_select": "选择",
"toolkit_select_app": "选择现有应用",
"toolkit_select_user_tags": "选择用户标签",
"toolkit_status": "状态",
......@@ -475,6 +478,11 @@
"toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件",
"toolkit_update_failed": "更新失败",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新选中项 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 个插件",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用说明",
"toolkit_version_info": "版本信息",
"tools": "工具",
......
......@@ -671,7 +671,7 @@
"core.workflow.inputType.Reference": "变量引用",
"core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "参数值由 AI 动态生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 托管",
"core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "开关由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "运行时由模型填写,会进入工具调用参数。",
"core.workflow.inputType.custom": "外部变量",
......
......@@ -430,11 +430,14 @@
"toolkit_plugin_intro": "插件介紹",
"toolkit_plugin_name": "插件名稱",
"toolkit_plugin_status": "插件狀態",
"toolkit_plugin_update": "外掛程式更新",
"toolkit_plugin_version": "插件版本",
"toolkit_promote_tags": "推薦標籤",
"toolkit_promote_tags_tip": "擁有以下標籤的使用者會看到「推薦」標識",
"toolkit_reinstall": "重新安裝",
"toolkit_retry_update": "重試更新",
"toolkit_search_placeholder": "搜索工具名稱",
"toolkit_select": "選擇",
"toolkit_select_app": "選擇現有應用",
"toolkit_select_user_tags": "選擇使用者標籤",
"toolkit_status": "狀態",
......@@ -464,6 +467,11 @@
"toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式",
"toolkit_update_failed": "更新失敗",
"toolkit_update_pending": "待更新",
"toolkit_update_selected": "更新選中項 ({{count}})",
"toolkit_update_success_count": "已成功更新 {{count}} 個外掛程式",
"toolkit_updating": "更新中",
"toolkit_user_guide": "使用說明",
"toolkit_version_info": "版本資訊",
"tools": "工具",
......
......@@ -666,7 +666,7 @@
"core.workflow.inputType.Reference": "變數引用",
"core.workflow.inputType.agentGenerated": "Agent 生成",
"core.workflow.inputType.agentGeneratedInputPlaceholder": "參數值由 AI 動態生成",
"core.workflow.inputType.agentGeneratedManaged": "AI 託管",
"core.workflow.inputType.agentGeneratedManaged": "AI 生成",
"core.workflow.inputType.agentGeneratedSwitchTip": "開關由 AI 控制",
"core.workflow.inputType.agentGeneratedDesc": "執行時由模型填寫,會進入工具呼叫參數。",
"core.workflow.inputType.custom": "外部變數",
......
......@@ -4,6 +4,7 @@ import type {
InstallPluginFromUrlBodyType,
UploadPkgPluginResponseType
} from '@fastgpt/global/openapi/core/plugin/admin/api';
import type { PluginInstallResultType } from '@fastgpt/global/sdk/fastgpt-plugin';
// Pkg plugin
export const uploadPkgPlugin = (formData: FormData) =>
......@@ -13,4 +14,4 @@ export const confirmPkgPluginUpload = (data: ConfirmUploadPkgPluginBodyType) =>
POST(`/core/plugin/admin/pkg/confirm`, data);
export const intallPluginWithUrl = (data: InstallPluginFromUrlBodyType) =>
POST('/core/plugin/admin/installWithUrl', data);
POST<PluginInstallResultType>('/core/plugin/admin/installWithUrl', data);
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type {
PluginInstallFailureType,
PluginInstallResultType
} from '@fastgpt/global/sdk/fastgpt-plugin';
export type BatchUpdateFailure = {
toolId: string;
reason: string;
};
/** 根据安装后的刷新结果生成详情页安装状态。 */
export const getMarketplaceToolStateAfterInstall = ({
targetVersion,
refreshedInstalledVersion
}: {
targetVersion?: string;
refreshedInstalledVersion?: string;
}) => ({
installed: true,
installedVersion: targetVersion ?? refreshedInstalledVersion,
update: false
});
/** 判断离线工具是否可以直接重新启用,版本不同时仍需下载安装所选版本。 */
export const shouldReinstallOfflineMarketplaceTool = ({
isOffline,
installedVersion,
targetVersion
}: {
isOffline: boolean;
installedVersion?: string;
targetVersion?: string;
}) => isOffline && installedVersion === targetVersion;
/**
* 将 plugin-server 按下载地址返回的失败项映射回 marketplace toolId。
* 下载地址与 toolId 按请求数组下标一一对应,未出现在 failed 中的项目视为更新成功。
*/
export const getBatchUpdateFailures = ({
toolIds,
downloadUrls,
installResult,
language
}: {
toolIds: string[];
downloadUrls: string[];
installResult: PluginInstallResultType;
language: string;
}): BatchUpdateFailure[] => {
const failureByUrl = new Map<string, PluginInstallFailureType>(
installResult.failed?.map((failure) => [failure.url, failure]) ?? []
);
return toolIds.flatMap((toolId, index) => {
const failure = failureByUrl.get(downloadUrls[index]);
if (!failure) return [];
return [
{
toolId,
reason: parseI18nString(failure.reason, language)
}
];
});
};
import { describe, expect, it } from 'vitest';
import {
getBatchUpdateFailures,
getMarketplaceToolStateAfterInstall,
shouldReinstallOfflineMarketplaceTool
} from '@/web/core/plugin/marketplace/utils';
import { isToolVersionInstalled } from '@fastgpt/web/components/core/plugin/tool/utils';
describe('shouldReinstallOfflineMarketplaceTool', () => {
it('re-enables an offline tool only when the selected version matches', () => {
expect(
shouldReinstallOfflineMarketplaceTool({
isOffline: true,
installedVersion: '3.0.0',
targetVersion: '3.0.0'
})
).toBe(true);
expect(
shouldReinstallOfflineMarketplaceTool({
isOffline: true,
installedVersion: '3.0.0',
targetVersion: '2.0.0'
})
).toBe(false);
});
});
describe('getMarketplaceToolStateAfterInstall', () => {
it('keeps the detail installed when refresh has not returned the newly installed tool yet', () => {
expect(
getMarketplaceToolStateAfterInstall({
targetVersion: '3.0.0',
refreshedInstalledVersion: undefined
})
).toEqual({
installed: true,
installedVersion: '3.0.0',
update: false
});
});
});
describe('isToolVersionInstalled', () => {
it('recognizes every installed version returned by plugin-server', () => {
expect(
isToolVersionInstalled({
isInstalled: true,
currentVersion: '2.0.0',
installedVersions: ['3.0.0', '2.0.0'],
installedVersion: '3.0.0'
})
).toBe(true);
});
it('uses the updated installed version while the installed version list is stale', () => {
expect(
isToolVersionInstalled({
isInstalled: true,
currentVersion: '3.0.0',
installedVersions: ['2.0.0'],
installedVersion: '3.0.0'
})
).toBe(true);
});
it('treats all versions as unavailable while the tool is offline', () => {
expect(
isToolVersionInstalled({
isInstalled: false,
currentVersion: '2.0.0',
installedVersions: ['3.0.0', '2.0.0']
})
).toBe(false);
});
});
describe('getBatchUpdateFailures', () => {
it('maps partial failures from download URLs back to tool IDs', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1', 'tool-2', 'tool-3'],
downloadUrls: ['url-1', 'url-2', 'url-3'],
installResult: {
failed: [
{
url: 'url-2',
reason: { en: 'Install failed', 'zh-CN': '安装失败' }
}
]
},
language: 'zh-CN'
})
).toEqual([{ toolId: 'tool-2', reason: '安装失败' }]);
});
it('returns an empty list when every plugin succeeds', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {},
language: 'en'
})
).toEqual([]);
});
it('falls back to the English reason for an unsupported locale', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {
failed: [{ url: 'url-1', reason: { en: 'Install failed' } }]
},
language: 'ja'
})
).toEqual([{ toolId: 'tool-1', reason: 'Install failed' }]);
});
it('ignores failure URLs that are not part of this request', () => {
expect(
getBatchUpdateFailures({
toolIds: ['tool-1'],
downloadUrls: ['url-1'],
installResult: {
failed: [{ url: 'other-url', reason: { en: 'Install failed' } }]
},
language: 'en'
})
).toEqual([]);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment