@lobehub/lobehub 2.0.0-next.292 → 2.0.0-next.294
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release-desktop-beta.yml +6 -6
- package/.github/workflows/release-desktop-stable.yml +22 -11
- package/CHANGELOG.md +52 -0
- package/apps/desktop/electron.vite.config.ts +0 -1
- package/apps/desktop/src/main/controllers/McpCtr.ts +50 -18
- package/apps/desktop/src/main/libs/mcp/client.ts +54 -2
- package/changelog/v1.json +10 -0
- package/package.json +1 -1
- package/packages/const/src/cacheControl.ts +1 -0
- package/src/app/(backend)/api/desktop/latest/route.ts +115 -0
- package/src/app/(backend)/middleware/validate/createValidator.test.ts +61 -0
- package/src/app/(backend)/middleware/validate/createValidator.ts +79 -0
- package/src/app/(backend)/middleware/validate/index.ts +3 -0
- package/src/app/[variants]/(main)/agent/_layout/AgentIdSync.tsx +12 -1
- package/src/app/[variants]/(main)/group/_layout/GroupIdSync.tsx +12 -1
- package/src/features/MCP/MCPInstallProgress/InstallError/ErrorDetails.tsx +61 -83
- package/src/features/PluginDevModal/MCPManifestForm/index.tsx +30 -3
- package/src/libs/mcp/types.ts +31 -0
- package/src/server/services/desktopRelease/index.test.ts +65 -0
- package/src/server/services/desktopRelease/index.ts +208 -0
- package/src/store/tool/slices/mcpStore/action.ts +26 -11
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
export interface ValidatorOptions {
|
|
5
|
+
errorStatus?: number;
|
|
6
|
+
omitNotShapeField?: boolean;
|
|
7
|
+
stopOnFirstError?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type InferInput<TSchema extends z.ZodTypeAny> = z.input<TSchema>;
|
|
11
|
+
type InferOutput<TSchema extends z.ZodTypeAny> = z.output<TSchema>;
|
|
12
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
13
|
+
|
|
14
|
+
const getRequestInput = async (req: Request): Promise<Record<string, unknown>> => {
|
|
15
|
+
const method = req.method?.toUpperCase?.() ?? 'GET';
|
|
16
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
17
|
+
return Object.fromEntries(new URL(req.url).searchParams.entries());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const contentType = req.headers.get('content-type') || '';
|
|
21
|
+
if (contentType.includes('application/json')) {
|
|
22
|
+
try {
|
|
23
|
+
return (await req.json()) as any;
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return (await (req as any).json?.()) as any;
|
|
31
|
+
} catch {
|
|
32
|
+
return Object.fromEntries(new URL(req.url).searchParams.entries());
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const applyOptionsToSchema = <TSchema extends z.ZodTypeAny>(
|
|
37
|
+
schema: TSchema,
|
|
38
|
+
options: ValidatorOptions,
|
|
39
|
+
): z.ZodTypeAny => {
|
|
40
|
+
if (!options.omitNotShapeField) return schema;
|
|
41
|
+
if (schema instanceof z.ZodObject) return schema.strip();
|
|
42
|
+
return schema;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const createValidator =
|
|
46
|
+
(options: ValidatorOptions = {}) =>
|
|
47
|
+
<TSchema extends z.ZodTypeAny>(schema: TSchema) => {
|
|
48
|
+
const errorStatus = options.errorStatus ?? 422;
|
|
49
|
+
const effectiveSchema = applyOptionsToSchema(schema, options) as z.ZodType<
|
|
50
|
+
InferOutput<TSchema>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
return <TReq extends NextRequest, TContext>(
|
|
54
|
+
handler: (
|
|
55
|
+
req: TReq,
|
|
56
|
+
context: TContext,
|
|
57
|
+
data: InferOutput<TSchema>,
|
|
58
|
+
) => MaybePromise<Response>,
|
|
59
|
+
) =>
|
|
60
|
+
async (req: TReq, context?: TContext) => {
|
|
61
|
+
const input = (await getRequestInput(req)) as InferInput<TSchema>;
|
|
62
|
+
const result = effectiveSchema.safeParse(input);
|
|
63
|
+
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
const issues = options.stopOnFirstError
|
|
66
|
+
? result.error.issues.slice(0, 1)
|
|
67
|
+
: result.error.issues;
|
|
68
|
+
return NextResponse.json({ error: 'Invalid request', issues }, { status: errorStatus });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return handler(req, context as TContext, result.data);
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const zodValidator = createValidator({
|
|
76
|
+
errorStatus: 422,
|
|
77
|
+
omitNotShapeField: true,
|
|
78
|
+
stopOnFirstError: true,
|
|
79
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useUnmount } from 'ahooks';
|
|
1
|
+
import { usePrevious, useUnmount } from 'ahooks';
|
|
2
|
+
import { useEffect } from 'react';
|
|
2
3
|
import { useParams } from 'react-router-dom';
|
|
3
4
|
import { createStoreUpdater } from 'zustand-utils';
|
|
4
5
|
|
|
@@ -9,10 +10,20 @@ const AgentIdSync = () => {
|
|
|
9
10
|
const useStoreUpdater = createStoreUpdater(useAgentStore);
|
|
10
11
|
const useChatStoreUpdater = createStoreUpdater(useChatStore);
|
|
11
12
|
const params = useParams<{ aid?: string }>();
|
|
13
|
+
const prevAgentId = usePrevious(params.aid);
|
|
12
14
|
|
|
13
15
|
useStoreUpdater('activeAgentId', params.aid);
|
|
14
16
|
useChatStoreUpdater('activeAgentId', params.aid ?? '');
|
|
15
17
|
|
|
18
|
+
// Reset activeTopicId when switching to a different agent
|
|
19
|
+
// This prevents messages from being saved to the wrong topic bucket
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
// Only reset topic when switching between agents (not on initial mount)
|
|
22
|
+
if (prevAgentId !== undefined && prevAgentId !== params.aid) {
|
|
23
|
+
useChatStore.getState().switchTopic(null, { skipRefreshMessage: true });
|
|
24
|
+
}
|
|
25
|
+
}, [params.aid, prevAgentId]);
|
|
26
|
+
|
|
16
27
|
// Clear activeAgentId when unmounting (leaving chat page)
|
|
17
28
|
useUnmount(() => {
|
|
18
29
|
useAgentStore.setState({ activeAgentId: undefined });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useUnmount } from 'ahooks';
|
|
1
|
+
import { usePrevious, useUnmount } from 'ahooks';
|
|
2
|
+
import { useEffect } from 'react';
|
|
2
3
|
import { useParams } from 'react-router-dom';
|
|
3
4
|
import { createStoreUpdater } from 'zustand-utils';
|
|
4
5
|
|
|
@@ -10,6 +11,7 @@ const GroupIdSync = () => {
|
|
|
10
11
|
const useAgentGroupStoreUpdater = createStoreUpdater(useAgentGroupStore);
|
|
11
12
|
const useChatStoreUpdater = createStoreUpdater(useChatStore);
|
|
12
13
|
const params = useParams<{ gid?: string }>();
|
|
14
|
+
const prevGroupId = usePrevious(params.gid);
|
|
13
15
|
const router = useQueryRoute();
|
|
14
16
|
|
|
15
17
|
// Sync groupId to agentGroupStore and chatStore
|
|
@@ -19,6 +21,15 @@ const GroupIdSync = () => {
|
|
|
19
21
|
// Inject router to agentGroupStore for navigation
|
|
20
22
|
useAgentGroupStoreUpdater('router', router);
|
|
21
23
|
|
|
24
|
+
// Reset activeTopicId when switching to a different group
|
|
25
|
+
// This prevents messages from being saved to the wrong topic bucket
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// Only reset topic when switching between groups (not on initial mount)
|
|
28
|
+
if (prevGroupId !== undefined && prevGroupId !== params.gid) {
|
|
29
|
+
useChatStore.getState().switchTopic(null, { skipRefreshMessage: true });
|
|
30
|
+
}
|
|
31
|
+
}, [params.gid, prevGroupId]);
|
|
32
|
+
|
|
22
33
|
// Clear activeGroupId when unmounting (leaving group page)
|
|
23
34
|
useUnmount(() => {
|
|
24
35
|
useAgentGroupStore.setState({ activeGroupId: undefined, router: undefined });
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Flexbox, Highlighter, Tag } from '@lobehub/ui';
|
|
2
2
|
import { cssVar } from 'antd-style';
|
|
3
|
-
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
4
3
|
import * as motion from 'motion/react-m';
|
|
5
|
-
import { memo
|
|
4
|
+
import { memo } from 'react';
|
|
6
5
|
import { useTranslation } from 'react-i18next';
|
|
7
6
|
|
|
8
7
|
import { type MCPErrorInfoMetadata } from '@/types/plugins';
|
|
@@ -12,94 +11,73 @@ const ErrorDetails = memo<{
|
|
|
12
11
|
errorMessage?: string;
|
|
13
12
|
}>(({ errorInfo, errorMessage }) => {
|
|
14
13
|
const { t } = useTranslation('plugin');
|
|
15
|
-
const [expanded, setExpanded] = useState(false);
|
|
16
14
|
|
|
17
15
|
return (
|
|
18
16
|
<Flexbox gap={8}>
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
size="small"
|
|
24
|
-
style={{
|
|
25
|
-
fontSize: '12px',
|
|
26
|
-
padding: '0 4px',
|
|
27
|
-
}}
|
|
28
|
-
variant="filled"
|
|
17
|
+
<motion.div
|
|
18
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
19
|
+
initial={{ height: 0, opacity: 0 }}
|
|
20
|
+
style={{ overflow: 'hidden' }}
|
|
29
21
|
>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
style={{ overflow: 'hidden' }}
|
|
22
|
+
<Flexbox
|
|
23
|
+
gap={8}
|
|
24
|
+
style={{
|
|
25
|
+
backgroundColor: cssVar.colorFillQuaternary,
|
|
26
|
+
borderRadius: 8,
|
|
27
|
+
fontFamily: 'monospace',
|
|
28
|
+
fontSize: '11px',
|
|
29
|
+
padding: '8px 12px',
|
|
30
|
+
}}
|
|
40
31
|
>
|
|
41
|
-
|
|
42
|
-
gap={
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
</div>
|
|
63
|
-
)}
|
|
64
|
-
{errorInfo.params.args && (
|
|
65
|
-
<div>
|
|
66
|
-
{t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
|
|
67
|
-
</div>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
70
|
-
</Flexbox>
|
|
71
|
-
)}
|
|
72
|
-
|
|
73
|
-
{errorInfo.errorLog && (
|
|
74
|
-
<Flexbox gap={4}>
|
|
75
|
-
<div>
|
|
76
|
-
<Tag color="red" variant={'filled'}>
|
|
77
|
-
{t('mcpInstall.errorDetails.errorOutput')}
|
|
78
|
-
</Tag>
|
|
79
|
-
</div>
|
|
80
|
-
<Highlighter
|
|
81
|
-
language={'log'}
|
|
82
|
-
style={{
|
|
83
|
-
maxHeight: 200,
|
|
84
|
-
overflow: 'auto',
|
|
85
|
-
}}
|
|
86
|
-
>
|
|
87
|
-
{errorInfo.errorLog}
|
|
88
|
-
</Highlighter>
|
|
89
|
-
</Flexbox>
|
|
90
|
-
)}
|
|
32
|
+
{errorInfo.params && (
|
|
33
|
+
<Flexbox gap={4}>
|
|
34
|
+
<div>
|
|
35
|
+
<Tag color="blue" variant={'filled'}>
|
|
36
|
+
{t('mcpInstall.errorDetails.connectionParams')}
|
|
37
|
+
</Tag>
|
|
38
|
+
</div>
|
|
39
|
+
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>
|
|
40
|
+
{errorInfo.params.command && (
|
|
41
|
+
<div>
|
|
42
|
+
{t('mcpInstall.errorDetails.command')}: {errorInfo.params.command}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
{errorInfo.params.args && (
|
|
46
|
+
<div>
|
|
47
|
+
{t('mcpInstall.errorDetails.args')}: {errorInfo.params.args.join(' ')}
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</Flexbox>
|
|
52
|
+
)}
|
|
91
53
|
|
|
92
|
-
|
|
54
|
+
{errorInfo.errorLog && (
|
|
55
|
+
<Flexbox gap={4}>
|
|
93
56
|
<div>
|
|
94
|
-
<Tag color="
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
</div>
|
|
57
|
+
<Tag color="red" variant={'filled'}>
|
|
58
|
+
{t('mcpInstall.errorDetails.errorOutput')}
|
|
59
|
+
</Tag>
|
|
98
60
|
</div>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
61
|
+
<Highlighter
|
|
62
|
+
language={'log'}
|
|
63
|
+
style={{
|
|
64
|
+
maxHeight: 200,
|
|
65
|
+
overflow: 'auto',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{errorInfo.errorLog}
|
|
69
|
+
</Highlighter>
|
|
70
|
+
</Flexbox>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{errorInfo.originalError && errorInfo.originalError !== errorMessage && (
|
|
74
|
+
<div>
|
|
75
|
+
<Tag color="orange">{t('mcpInstall.errorDetails.originalError')}</Tag>
|
|
76
|
+
<div style={{ marginTop: 4, wordBreak: 'break-all' }}>{errorInfo.originalError}</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</Flexbox>
|
|
80
|
+
</motion.div>
|
|
103
81
|
</Flexbox>
|
|
104
82
|
);
|
|
105
83
|
});
|
|
@@ -6,8 +6,10 @@ import { useTranslation } from 'react-i18next';
|
|
|
6
6
|
|
|
7
7
|
import KeyValueEditor from '@/components/KeyValueEditor';
|
|
8
8
|
import MCPStdioCommandInput from '@/components/MCPStdioCommandInput';
|
|
9
|
+
import ErrorDetails from '@/features/MCP/MCPInstallProgress/InstallError/ErrorDetails';
|
|
9
10
|
import { useToolStore } from '@/store/tool';
|
|
10
11
|
import { mcpStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
|
|
12
|
+
import { type MCPErrorInfoMetadata } from '@/types/plugins';
|
|
11
13
|
|
|
12
14
|
import ArgsInput from './ArgsInput';
|
|
13
15
|
import CollapsibleSection from './CollapsibleSection';
|
|
@@ -46,10 +48,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
|
|
|
46
48
|
const testState = useToolStore(mcpStoreSelectors.getMCPConnectionTestState(identifier), isEqual);
|
|
47
49
|
|
|
48
50
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
51
|
+
const [errorMetadata, setErrorMetadata] = useState<MCPErrorInfoMetadata | null>(null);
|
|
49
52
|
|
|
50
53
|
const handleTestConnection = async () => {
|
|
51
54
|
setIsTesting(true);
|
|
52
55
|
setConnectionError(null);
|
|
56
|
+
setErrorMetadata(null);
|
|
53
57
|
|
|
54
58
|
// Manually trigger validation for fields needed for the test
|
|
55
59
|
let isValid = false;
|
|
@@ -97,12 +101,29 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
|
|
|
97
101
|
// Be careful about overwriting user input if not desired
|
|
98
102
|
form.setFieldsValue({ manifest: result.manifest });
|
|
99
103
|
setConnectionError(null); // 清除本地错误状态
|
|
104
|
+
setErrorMetadata(null);
|
|
100
105
|
} else if (result.error) {
|
|
101
106
|
// Store 已经处理了错误状态,这里可以选择显示额外的用户友好提示
|
|
102
107
|
const errorMessage = t('error.testConnectionFailed', {
|
|
103
108
|
error: result.error,
|
|
104
109
|
});
|
|
105
110
|
setConnectionError(errorMessage);
|
|
111
|
+
|
|
112
|
+
// Build error metadata for detailed display
|
|
113
|
+
if (result.errorLog || mcpType === 'stdio') {
|
|
114
|
+
setErrorMetadata({
|
|
115
|
+
errorLog: result.errorLog,
|
|
116
|
+
params:
|
|
117
|
+
mcpType === 'stdio'
|
|
118
|
+
? {
|
|
119
|
+
args: mcp?.args,
|
|
120
|
+
command: mcp?.command,
|
|
121
|
+
type: 'stdio',
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
106
127
|
}
|
|
107
128
|
} catch (error) {
|
|
108
129
|
// Handle unexpected errors
|
|
@@ -121,7 +142,10 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
|
|
|
121
142
|
<QuickImportSection
|
|
122
143
|
form={form}
|
|
123
144
|
isEditMode={isEditMode}
|
|
124
|
-
onClearConnectionError={() =>
|
|
145
|
+
onClearConnectionError={() => {
|
|
146
|
+
setConnectionError(null);
|
|
147
|
+
setErrorMetadata(null);
|
|
148
|
+
}}
|
|
125
149
|
/>
|
|
126
150
|
<Form form={form} layout={'vertical'}>
|
|
127
151
|
<Flexbox>
|
|
@@ -270,9 +294,12 @@ const MCPManifestForm = ({ form, isEditMode }: MCPManifestFormProps) => {
|
|
|
270
294
|
{(connectionError || testState.error) && (
|
|
271
295
|
<Alert
|
|
272
296
|
closable
|
|
273
|
-
|
|
297
|
+
extra={errorMetadata ? <ErrorDetails errorInfo={errorMetadata} /> : undefined}
|
|
298
|
+
onClose={() => {
|
|
299
|
+
setConnectionError(null);
|
|
300
|
+
setErrorMetadata(null);
|
|
301
|
+
}}
|
|
274
302
|
showIcon
|
|
275
|
-
style={{ marginBottom: 16 }}
|
|
276
303
|
title={connectionError || testState.error}
|
|
277
304
|
type="error"
|
|
278
305
|
/>
|
package/src/libs/mcp/types.ts
CHANGED
|
@@ -240,3 +240,34 @@ export function createMCPError(
|
|
|
240
240
|
|
|
241
241
|
return error;
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* STDIO Process Output separator used in enhanced error messages
|
|
246
|
+
*/
|
|
247
|
+
const STDIO_OUTPUT_SEPARATOR = '--- STDIO Process Output ---';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Parse error message to extract STDIO process output logs
|
|
251
|
+
* The enhanced error format from desktop is:
|
|
252
|
+
* "Original message\n\n--- STDIO Process Output ---\nlogs..."
|
|
253
|
+
*/
|
|
254
|
+
export interface ParsedStdioError {
|
|
255
|
+
errorLog?: string;
|
|
256
|
+
originalMessage: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function parseStdioErrorMessage(errorMessage: string): ParsedStdioError {
|
|
260
|
+
const separatorIndex = errorMessage.indexOf(STDIO_OUTPUT_SEPARATOR);
|
|
261
|
+
|
|
262
|
+
if (separatorIndex === -1) {
|
|
263
|
+
return { originalMessage: errorMessage };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const originalMessage = errorMessage.slice(0, separatorIndex).trim();
|
|
267
|
+
const errorLog = errorMessage.slice(separatorIndex + STDIO_OUTPUT_SEPARATOR.length).trim();
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
errorLog: errorLog || undefined,
|
|
271
|
+
originalMessage: originalMessage || errorMessage,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type DesktopDownloadType,
|
|
5
|
+
resolveDesktopDownload,
|
|
6
|
+
resolveDesktopDownloadFromUrls,
|
|
7
|
+
} from './index';
|
|
8
|
+
|
|
9
|
+
const mockRelease = {
|
|
10
|
+
assets: [
|
|
11
|
+
{
|
|
12
|
+
browser_download_url: 'https://example.com/LobeHub-2.0.0-arm64.dmg',
|
|
13
|
+
name: 'LobeHub-2.0.0-arm64.dmg',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
browser_download_url: 'https://example.com/LobeHub-2.0.0-x64.dmg',
|
|
17
|
+
name: 'LobeHub-2.0.0-x64.dmg',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
browser_download_url: 'https://example.com/LobeHub-2.0.0-setup.exe',
|
|
21
|
+
name: 'LobeHub-2.0.0-setup.exe',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
browser_download_url: 'https://example.com/LobeHub-2.0.0.AppImage',
|
|
25
|
+
name: 'LobeHub-2.0.0.AppImage',
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
published_at: '2026-01-01T00:00:00.000Z',
|
|
29
|
+
tag_name: 'v2.0.0',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('desktopRelease', () => {
|
|
33
|
+
it.each([
|
|
34
|
+
['mac-arm', 'LobeHub-2.0.0-arm64.dmg'],
|
|
35
|
+
['mac-intel', 'LobeHub-2.0.0-x64.dmg'],
|
|
36
|
+
['windows', 'LobeHub-2.0.0-setup.exe'],
|
|
37
|
+
['linux', 'LobeHub-2.0.0.AppImage'],
|
|
38
|
+
] as Array<[DesktopDownloadType, string]>)(
|
|
39
|
+
'resolveDesktopDownload(%s)',
|
|
40
|
+
(type, expectedAssetName) => {
|
|
41
|
+
const resolved = resolveDesktopDownload(mockRelease as any, type);
|
|
42
|
+
expect(resolved?.assetName).toBe(expectedAssetName);
|
|
43
|
+
expect(resolved?.version).toBe('2.0.0');
|
|
44
|
+
expect(resolved?.tag).toBe('v2.0.0');
|
|
45
|
+
expect(resolved?.type).toBe(type);
|
|
46
|
+
expect(resolved?.url).toContain(expectedAssetName);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it('resolveDesktopDownloadFromUrls should match basename', () => {
|
|
51
|
+
const resolved = resolveDesktopDownloadFromUrls({
|
|
52
|
+
publishedAt: '2026-01-01T00:00:00.000Z',
|
|
53
|
+
tag: 'v2.0.0',
|
|
54
|
+
type: 'windows',
|
|
55
|
+
urls: [
|
|
56
|
+
'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-setup.exe?download=1',
|
|
57
|
+
'https://releases.example.com/stable/2.0.0/LobeHub-2.0.0-x64.dmg',
|
|
58
|
+
],
|
|
59
|
+
version: '2.0.0',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(resolved?.assetName).toBe('LobeHub-2.0.0-setup.exe');
|
|
63
|
+
expect(resolved?.url).toContain('setup.exe');
|
|
64
|
+
});
|
|
65
|
+
});
|