zen-gitsync 2.16.6 → 2.16.8

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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/src/cli/ai/modelSetup.js +594 -594
  3. package/src/cli/ai/modelSetup.test.js +496 -496
  4. package/src/cli/ai/platformGuard.js +200 -200
  5. package/src/cli/ai/platformGuard.test.js +253 -253
  6. package/src/gitCommit.js +484 -484
  7. package/src/ui/public/assets/AgentView-8-xsOmE6.css +1 -0
  8. package/src/ui/public/assets/AgentView-DSmUynN-.js +4 -0
  9. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
  10. package/src/ui/public/assets/AppVersionBadge-CcFagCnq.js +2 -0
  11. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  12. package/src/ui/public/assets/BranchSelector-DcYVBQJh.js +1 -0
  13. package/src/ui/public/assets/CommitForm-CkTI8Xyz.js +6 -0
  14. package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
  15. package/src/ui/public/assets/CommonDialog-BFyhw0Qj.js +1 -0
  16. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
  17. package/src/ui/public/assets/EditorView-BVod-rjn.css +1 -0
  18. package/src/ui/public/assets/EditorView-DuHkBSc3.js +0 -0
  19. package/src/ui/public/assets/FlowExecutionViewer-C063Qjmk.js +1 -0
  20. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  21. package/src/ui/public/assets/FlowOrchestrationWorkspace-BeJRId5G.js +21 -0
  22. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  23. package/src/ui/public/assets/LogList-CeFFytjB.css +1 -0
  24. package/src/ui/public/assets/LogList-GSeER4cv.js +15 -0
  25. package/src/ui/public/assets/MindmapView-BQTqx842.css +1 -0
  26. package/src/ui/public/assets/MindmapView-p7szLCtQ.js +2 -0
  27. package/src/ui/public/assets/MonitorView-Be6KEUCe.js +1 -0
  28. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-F1k5Km2p.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  31. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
  32. package/src/ui/public/assets/RemoteRepoCard-CSMFlWDl.js +5 -0
  33. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
  34. package/src/ui/public/assets/SourceMapView-Dg5NP_gR.js +3 -0
  35. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  36. package/src/ui/public/assets/SvgIcon-CYZGHQAE.js +1 -0
  37. package/src/ui/public/assets/UserInputNode-3OAXiGVh.js +1 -0
  38. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  39. package/src/ui/public/assets/WorkbenchView-8xyxdWAg.js +28 -0
  40. package/src/ui/public/assets/{WorkbenchView-CQfjF0Gr.css → WorkbenchView-DbF4hH5s.css} +1 -1
  41. package/src/ui/public/assets/_plugin-vue_export-helper-DgLDQTWK.js +15 -0
  42. package/src/ui/public/assets/{configStore-CmG77GNj.js → configStore-D5t05s4G.js} +1 -1
  43. package/src/ui/public/assets/element-plus-CyGhXd7X.js +25 -0
  44. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +1 -0
  45. package/src/ui/public/assets/{flow-mindmap-D26cYDAD.js → flow-mindmap-CUvJaYn2.js} +1 -1
  46. package/src/ui/public/assets/index--onqtBRQ.css +1 -0
  47. package/src/ui/public/assets/index-DDGvh1mp.js +68 -0
  48. package/src/ui/public/assets/socket-io-BSgn8Jx2.js +1 -0
  49. package/src/ui/public/assets/vendor-DVvq7qtS.css +1 -0
  50. package/src/ui/public/assets/{vendor-lXqgobGS.js → vendor-jF--I87-.js} +88 -37
  51. package/src/ui/public/assets/vue-flow-CWmfpfPN.js +39 -0
  52. package/src/ui/public/favicon.svg +75 -75
  53. package/src/ui/public/index.html +31 -31
  54. package/src/ui/public/logo.svg +74 -74
  55. package/src/ui/server/middleware/requestLogger.js +53 -53
  56. package/src/ui/server/routes/branchStatus.js +105 -105
  57. package/src/ui/server/routes/fileOpen.js +3 -2
  58. package/src/ui/server/routes/fs.js +791 -791
  59. package/src/ui/server/routes/git/diff.js +426 -426
  60. package/src/ui/server/routes/git/diffUtils.js +149 -149
  61. package/src/ui/server/routes/git/stash.js +550 -550
  62. package/src/ui/server/routes/git/tags.js +176 -176
  63. package/src/ui/server/routes/git.js +181 -181
  64. package/src/ui/server/routes/gitOps.js +1255 -1255
  65. package/src/ui/server/routes/process.js +86 -86
  66. package/src/ui/server/routes/status.js +67 -67
  67. package/src/ui/server/routes/workbench/pdfText.js +145 -145
  68. package/src/ui/server/utils/createSavePortToFile.js +48 -48
  69. package/src/ui/server/utils/instanceRegistry.js +282 -282
  70. package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
  71. package/src/utils/index.js +1173 -1173
  72. package/src/ui/public/assets/AgentView-D3ltcW16.js +0 -4
  73. package/src/ui/public/assets/AgentView-DJBC9U8b.css +0 -1
  74. package/src/ui/public/assets/AppVersionBadge-DEzojWz9.js +0 -2
  75. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
  76. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  77. package/src/ui/public/assets/BranchSelector-YgVQRusR.js +0 -1
  78. package/src/ui/public/assets/CommitForm-B9xoh9cE.js +0 -6
  79. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
  80. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  81. package/src/ui/public/assets/CommonDialog-DB4V4CKn.js +0 -1
  82. package/src/ui/public/assets/EditorView-CRYGVw_r.css +0 -1
  83. package/src/ui/public/assets/EditorView-CsjhdSd_.js +0 -0
  84. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  85. package/src/ui/public/assets/FlowExecutionViewer-Blax8J71.js +0 -1
  86. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  87. package/src/ui/public/assets/FlowOrchestrationWorkspace-Dm85yYLO.js +0 -21
  88. package/src/ui/public/assets/LogList-DJYpDUTZ.css +0 -1
  89. package/src/ui/public/assets/LogList-WxAgsMg0.js +0 -7
  90. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +0 -1
  91. package/src/ui/public/assets/MindmapView-_FKSYPEk.js +0 -1
  92. package/src/ui/public/assets/MonitorView-CynFYBMr.js +0 -1
  93. package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
  94. package/src/ui/public/assets/ProjectStartupButton-B5vk2Lu0.js +0 -1
  95. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  96. package/src/ui/public/assets/RemoteRepoCard-B_iW7EZK.js +0 -1
  97. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
  98. package/src/ui/public/assets/SourceMapView-0fHVZr1r.js +0 -3
  99. package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
  100. package/src/ui/public/assets/SvgIcon-Ch_wHlf9.js +0 -1
  101. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  102. package/src/ui/public/assets/UserInputNode-LbWvYQcA.js +0 -1
  103. package/src/ui/public/assets/WorkbenchView-CYucXDsF.js +0 -12
  104. package/src/ui/public/assets/_plugin-vue_export-helper-7QfI9jF6.js +0 -15
  105. package/src/ui/public/assets/ai-chat-ui-BqgKQovY.js +0 -45
  106. package/src/ui/public/assets/ai-chat-ui-BwlJovCq.css +0 -1
  107. package/src/ui/public/assets/element-plus-CQrjg9us.css +0 -1
  108. package/src/ui/public/assets/element-plus-DjpeTxOl.js +0 -24
  109. package/src/ui/public/assets/index-CmQSuclp.js +0 -38
  110. package/src/ui/public/assets/index-hGb6cPd0.css +0 -1
  111. package/src/ui/public/assets/socket-io-z4n873W1.js +0 -1
  112. package/src/ui/public/assets/vendor-CdOLCu7-.css +0 -1
  113. package/src/ui/public/assets/vue-flow-CIbkTMWF.js +0 -39
  114. package/src/ui/server/.claude/codediff.txt +0 -8
@@ -1,496 +1,496 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- // src/cli/ai/modelSetup.js 的单元测试。
16
- // 覆盖纯函数:getBuiltinModels / findProviderByUrl / validateEndpoint /
17
- // buildModelConfig / testModelConnection(注入 mock fetch)。
18
- import { test } from 'node:test'
19
- import assert from 'node:assert/strict'
20
- import {
21
- getBuiltinModels,
22
- findProviderByUrl,
23
- validateEndpoint,
24
- buildModelConfig,
25
- testModelConnection,
26
- collectModelInput,
27
- PROVIDERS,
28
- BUILTIN_MODELS,
29
- } from './modelSetup.js'
30
-
31
- // ── getBuiltinModels ──────────────────────────
32
-
33
- test('getBuiltinModels: 已知 baseURL 返回模型列表', () => {
34
- const models = getBuiltinModels('https://api.deepseek.com/v1')
35
- assert.ok(Array.isArray(models))
36
- assert.ok(models.length > 0)
37
- assert.ok(models.includes('deepseek-chat'))
38
- })
39
-
40
- test('getBuiltinModels: 尾斜杠的 baseURL 也能匹配', () => {
41
- const a = getBuiltinModels('https://api.deepseek.com/v1')
42
- const b = getBuiltinModels('https://api.deepseek.com/v1/')
43
- assert.deepEqual(a, b)
44
- })
45
-
46
- test('getBuiltinModels: 未知 baseURL 返回空数组', () => {
47
- assert.deepEqual(getBuiltinModels('https://example.com/v1'), [])
48
- })
49
-
50
- test('getBuiltinModels: null / undefined / 空字符串 返回空数组', () => {
51
- assert.deepEqual(getBuiltinModels(null), [])
52
- assert.deepEqual(getBuiltinModels(undefined), [])
53
- assert.deepEqual(getBuiltinModels(''), [])
54
- assert.deepEqual(getBuiltinModels(123), [])
55
- })
56
-
57
- // ── findProviderByUrl ─────────────────────────
58
-
59
- test('findProviderByUrl: 已知 URL 返回 provider 对象', () => {
60
- const p = findProviderByUrl('https://api.openai.com/v1')
61
- assert.ok(p)
62
- assert.equal(p.id, 'openai')
63
- assert.equal(p.label, 'OpenAI')
64
- })
65
-
66
- test('findProviderByUrl: 尾斜杠的 URL 也能匹配', () => {
67
- const p = findProviderByUrl('https://api.openai.com/v1/')
68
- assert.ok(p)
69
- assert.equal(p.id, 'openai')
70
- })
71
-
72
- test('findProviderByUrl: 未知 URL 返回 null', () => {
73
- assert.equal(findProviderByUrl('https://example.com/v1'), null)
74
- })
75
-
76
- test('findProviderByUrl: null / undefined 返回 null', () => {
77
- assert.equal(findProviderByUrl(null), null)
78
- assert.equal(findProviderByUrl(undefined), null)
79
- assert.equal(findProviderByUrl(''), null)
80
- })
81
-
82
- test('findProviderByUrl: 每个预设都能被找到', () => {
83
- for (const p of PROVIDERS) {
84
- const found = findProviderByUrl(p.url)
85
- assert.ok(found, `应能找到 provider: ${p.id}`)
86
- assert.equal(found.id, p.id)
87
- }
88
- })
89
-
90
- // ── validateEndpoint ──────────────────────────
91
-
92
- test('validateEndpoint: 合法 https URL', () => {
93
- assert.equal(validateEndpoint('https://api.openai.com/v1'), true)
94
- assert.equal(validateEndpoint('https://example.com'), true)
95
- })
96
-
97
- test('validateEndpoint: 合法 http URL', () => {
98
- assert.equal(validateEndpoint('http://localhost:11434/v1'), true)
99
- assert.equal(validateEndpoint('http://192.168.1.1:8080'), true)
100
- })
101
-
102
- test('validateEndpoint: 非法协议(ftp)返回 false', () => {
103
- assert.equal(validateEndpoint('ftp://example.com'), false)
104
- })
105
-
106
- test('validateEndpoint: 无协议返回 false', () => {
107
- assert.equal(validateEndpoint('example.com'), false)
108
- assert.equal(validateEndpoint('api.openai.com/v1'), false)
109
- })
110
-
111
- test('validateEndpoint: null / undefined / 空字符串返回 false', () => {
112
- assert.equal(validateEndpoint(null), false)
113
- assert.equal(validateEndpoint(undefined), false)
114
- assert.equal(validateEndpoint(''), false)
115
- })
116
-
117
- // ── buildModelConfig ──────────────────────────
118
-
119
- test('buildModelConfig: 基本构建', () => {
120
- const m = buildModelConfig({
121
- baseURL: 'https://api.deepseek.com/v1',
122
- model: 'deepseek-chat',
123
- apiKey: 'sk-test',
124
- name: 'My DeepSeek',
125
- isDefault: true,
126
- })
127
- assert.equal(m.model, 'deepseek-chat')
128
- assert.equal(m.name, 'My DeepSeek')
129
- assert.equal(m.baseURL, 'https://api.deepseek.com/v1')
130
- assert.equal(m.apiKey, 'sk-test')
131
- assert.equal(m.isDefault, true)
132
- assert.ok(m.id, 'id 不应为空')
133
- assert.match(m.id, /^model-/, 'id 应以 model- 开头')
134
- })
135
-
136
- test('buildModelConfig: 不传 name 时默认用 model 名', () => {
137
- const m = buildModelConfig({
138
- baseURL: 'https://api.openai.com/v1',
139
- model: 'gpt-4o',
140
- })
141
- assert.equal(m.name, 'gpt-4o')
142
- })
143
-
144
- test('buildModelConfig: name 为空白时默认用 model 名', () => {
145
- const m = buildModelConfig({
146
- baseURL: 'https://api.openai.com/v1',
147
- model: 'gpt-4o',
148
- name: ' ',
149
- })
150
- assert.equal(m.name, 'gpt-4o')
151
- })
152
-
153
- test('buildModelConfig: baseURL 去尾斜杠', () => {
154
- const m = buildModelConfig({
155
- baseURL: 'https://api.openai.com/v1/',
156
- model: 'gpt-4o',
157
- })
158
- assert.equal(m.baseURL, 'https://api.openai.com/v1')
159
- })
160
-
161
- test('buildModelConfig: isDefault 默认 false', () => {
162
- const m = buildModelConfig({
163
- baseURL: 'https://api.openai.com/v1',
164
- model: 'gpt-4o',
165
- })
166
- assert.equal(m.isDefault, false)
167
- })
168
-
169
- test('buildModelConfig: apiKey 为空时存空字符串', () => {
170
- const m = buildModelConfig({
171
- baseURL: 'http://localhost:11434/v1',
172
- model: 'llama3',
173
- })
174
- assert.equal(m.apiKey, '')
175
- })
176
-
177
- test('buildModelConfig: id 包含 name 的 slug 化形式', () => {
178
- const m = buildModelConfig({
179
- baseURL: 'https://api.openai.com/v1',
180
- model: 'gpt-4o',
181
- name: 'GPT 4o Mini!',
182
- })
183
- // name 中的非 a-z0-9 字符被替换为 -
184
- assert.match(m.id, /^model-gpt-4o-mini-/)
185
- })
186
-
187
- // ── testModelConnection ───────────────────────
188
- // 注入 mock fetch 避免真实网络请求
189
-
190
- function mockFetch({ status = 200, ok = true, body = {} } = {}) {
191
- return async (url, opts) => ({
192
- ok,
193
- status,
194
- json: async () => body,
195
- text: async () => JSON.stringify(body),
196
- })
197
- }
198
-
199
- test('testModelConnection: 200 成功', async () => {
200
- const result = await testModelConnection({
201
- baseURL: 'https://api.deepseek.com/v1',
202
- model: 'deepseek-chat',
203
- apiKey: 'sk-test',
204
- fetchFn: mockFetch({ status: 200, ok: true }),
205
- })
206
- assert.equal(result.ok, true)
207
- assert.equal(result.status, 200)
208
- })
209
-
210
- test('testModelConnection: 400 也视为成功(连接通的)', async () => {
211
- const result = await testModelConnection({
212
- baseURL: 'https://api.deepseek.com/v1',
213
- model: 'deepseek-chat',
214
- fetchFn: mockFetch({ status: 400, ok: false }),
215
- })
216
- assert.equal(result.ok, true)
217
- assert.equal(result.status, 400)
218
- })
219
-
220
- test('testModelConnection: 401 API Key 无效', async () => {
221
- const result = await testModelConnection({
222
- baseURL: 'https://api.deepseek.com/v1',
223
- model: 'deepseek-chat',
224
- apiKey: 'sk-bad',
225
- fetchFn: mockFetch({ status: 401, ok: false }),
226
- })
227
- assert.equal(result.ok, false)
228
- assert.equal(result.status, 401)
229
- assert.match(result.message, /401/)
230
- })
231
-
232
- test('testModelConnection: 404 模型不存在', async () => {
233
- const result = await testModelConnection({
234
- baseURL: 'https://api.deepseek.com/v1',
235
- model: 'nonexistent',
236
- fetchFn: mockFetch({ status: 404, ok: false }),
237
- })
238
- assert.equal(result.ok, false)
239
- assert.equal(result.status, 404)
240
- assert.match(result.message, /404/)
241
- })
242
-
243
- test('testModelConnection: 500 服务器错误', async () => {
244
- const result = await testModelConnection({
245
- baseURL: 'https://api.deepseek.com/v1',
246
- model: 'deepseek-chat',
247
- fetchFn: mockFetch({ status: 500, ok: false }),
248
- })
249
- assert.equal(result.ok, false)
250
- assert.equal(result.status, 500)
251
- assert.match(result.message, /500/)
252
- })
253
-
254
- test('testModelConnection: AbortError 返回超时消息', async () => {
255
- const abortFetch = async () => {
256
- const err = new Error('aborted')
257
- err.name = 'AbortError'
258
- throw err
259
- }
260
- const result = await testModelConnection({
261
- baseURL: 'https://api.deepseek.com/v1',
262
- model: 'deepseek-chat',
263
- fetchFn: abortFetch,
264
- timeoutMs: 100,
265
- })
266
- assert.equal(result.ok, false)
267
- assert.match(result.message, /超时|timeout/i)
268
- })
269
-
270
- test('testModelConnection: 网络错误返回失败消息', async () => {
271
- const failFetch = async () => {
272
- throw new Error('ECONNREFUSED')
273
- }
274
- const result = await testModelConnection({
275
- baseURL: 'https://api.deepseek.com/v1',
276
- model: 'deepseek-chat',
277
- fetchFn: failFetch,
278
- })
279
- assert.equal(result.ok, false)
280
- assert.match(result.message, /ECONNREFUSED|连接失败/)
281
- })
282
-
283
- test('testModelConnection: baseURL 尾斜杠被正确处理', async () => {
284
- let calledUrl = ''
285
- const spyFetch = async (url) => {
286
- calledUrl = url
287
- return { ok: true, status: 200, json: async () => ({}) }
288
- }
289
- await testModelConnection({
290
- baseURL: 'https://api.deepseek.com/v1/',
291
- model: 'deepseek-chat',
292
- fetchFn: spyFetch,
293
- })
294
- // 尾斜杠被去掉,不产生双斜杠
295
- assert.equal(calledUrl, 'https://api.deepseek.com/v1/chat/completions')
296
- })
297
-
298
- test('testModelConnection: 无 apiKey 时不发 Authorization 头', async () => {
299
- let calledHeaders = {}
300
- const spyFetch = async (url, opts) => {
301
- calledHeaders = opts.headers
302
- return { ok: true, status: 200, json: async () => ({}) }
303
- }
304
- await testModelConnection({
305
- baseURL: 'https://api.deepseek.com/v1',
306
- model: 'deepseek-chat',
307
- apiKey: '',
308
- fetchFn: spyFetch,
309
- })
310
- assert.ok(!calledHeaders['Authorization'], '无 apiKey 时不应有 Authorization 头')
311
- assert.equal(calledHeaders['Content-Type'], 'application/json')
312
- })
313
-
314
- test('testModelConnection: 有 apiKey 时发 Bearer token', async () => {
315
- let calledHeaders = {}
316
- const spyFetch = async (url, opts) => {
317
- calledHeaders = opts.headers
318
- return { ok: true, status: 200, json: async () => ({}) }
319
- }
320
- await testModelConnection({
321
- baseURL: 'https://api.deepseek.com/v1',
322
- model: 'deepseek-chat',
323
- apiKey: 'sk-abc123',
324
- fetchFn: spyFetch,
325
- })
326
- assert.equal(calledHeaders['Authorization'], 'Bearer sk-abc123')
327
- })
328
-
329
- // ── 数据完整性 ────────────────────────────────
330
-
331
- test('PROVIDERS: 至少有 16 个预设', () => {
332
- assert.ok(PROVIDERS.length >= 16)
333
- })
334
-
335
- test('PROVIDERS: 每个预设有 id / label / url', () => {
336
- for (const p of PROVIDERS) {
337
- assert.ok(p.id, `provider 缺少 id: ${JSON.stringify(p)}`)
338
- assert.ok(p.label, `provider 缺少 label: ${JSON.stringify(p)}`)
339
- assert.ok(p.url, `provider 缺少 url: ${JSON.stringify(p)}`)
340
- assert.ok(validateEndpoint(p.url), `provider url 不合法: ${p.url}`)
341
- }
342
- })
343
-
344
- test('BUILTIN_MODELS: 每个 baseURL 都对应一个非空数组', () => {
345
- for (const [url, models] of Object.entries(BUILTIN_MODELS)) {
346
- assert.ok(Array.isArray(models), `BUILTIN_MODELS[${url}] 应为数组`)
347
- assert.ok(models.length > 0, `BUILTIN_MODELS[${url}] 不应为空`)
348
- for (const m of models) {
349
- assert.equal(typeof m, 'string', `模型名应为字符串: ${m}`)
350
- assert.ok(m.length > 0, `模型名不应为空`)
351
- }
352
- }
353
- })
354
-
355
- test('BUILTIN_MODELS: 每个 key 都能在 PROVIDERS 中找到', () => {
356
- const providerUrls = new Set(PROVIDERS.map(p => p.url))
357
- for (const url of Object.keys(BUILTIN_MODELS)) {
358
- assert.ok(providerUrls.has(url), `BUILTIN_MODELS 的 key "${url}" 不在 PROVIDERS 中`)
359
- }
360
- })
361
-
362
- // ── collectModelInput:交互式收集(注入 mock rl + mock fetch) ──
363
- //
364
- // collectModelInput 是 runModelSetup 和 agent.js /addmodel 共用的核心交互逻辑。
365
- // 通过 mock readline 逐条返回预设答案,mock fetch 控制 /chat/completions 测试结果,
366
- // 覆盖:预设服务商、自定义服务商、测试成功/失败/取消 四条主路径。
367
-
368
- /**
369
- * 创建 mock readline,按队列依次返回预设答案给 rl.question()。
370
- * @param {string[]} answers - 预设答案队列(每次 question 消费一条)
371
- * @param {object} [opts]
372
- * @param {number} [opts.closeOnQuestion=-1] - 在第 N 次 question 时触发 close 事件(模拟取消),-1=不触发
373
- */
374
- function createMockRl(answers, { closeOnQuestion = -1 } = {}) {
375
- const queue = [...answers]
376
- const closeHandlers = []
377
- let qNum = 0
378
- return {
379
- question(_prompt, cb) {
380
- qNum++
381
- if (qNum === closeOnQuestion) {
382
- // 先让 once('close') 注册,再在 nextTick 触发(模拟用户 Ctrl+D)
383
- process.nextTick(() => { for (const h of closeHandlers) h() })
384
- return
385
- }
386
- const answer = queue.shift() ?? ''
387
- process.nextTick(() => cb(answer))
388
- },
389
- once(event, cb) {
390
- if (event === 'close') closeHandlers.push(cb)
391
- },
392
- close() {},
393
- }
394
- }
395
-
396
- /** 静默 console.log/error/warn,避免交互式测试刷屏;返回 restore 函数 */
397
- function silenceConsole() {
398
- const orig = { log: console.log, error: console.error, warn: console.warn }
399
- console.log = () => {}
400
- console.error = () => {}
401
- console.warn = () => {}
402
- return () => {
403
- console.log = orig.log
404
- console.error = orig.error
405
- console.warn = orig.warn
406
- }
407
- }
408
-
409
- test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
410
- const restore = silenceConsole()
411
- try {
412
- // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) → apiKey → displayName(空=默认)
413
- const rl = createMockRl(['1', '', '1', 'sk-test', ''])
414
- const result = await collectModelInput({
415
- locale: 'zh-CN',
416
- rl,
417
- fetchFn: mockFetch({ status: 200, ok: true }),
418
- })
419
- assert.ok(result, '应返回收集结果')
420
- assert.equal(result.baseURL, 'https://api.openai.com/v1')
421
- assert.equal(result.model, 'gpt-5.5')
422
- assert.equal(result.apiKey, 'sk-test')
423
- assert.equal(result.displayName, 'gpt-5.5')
424
- } finally {
425
- restore()
426
- }
427
- })
428
-
429
- test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
430
- const restore = silenceConsole()
431
- try {
432
- // 选自定义(0) → 输入 baseURL → 确认(空=默认) → 手动输入模型名 → apiKey(空) → displayName(空=默认)
433
- const rl = createMockRl(['0', 'https://my.custom.api/v1', '', 'my-model', '', ''])
434
- const result = await collectModelInput({
435
- locale: 'zh-CN',
436
- rl,
437
- fetchFn: mockFetch({ status: 200, ok: true }),
438
- })
439
- assert.ok(result)
440
- assert.equal(result.baseURL, 'https://my.custom.api/v1')
441
- assert.equal(result.model, 'my-model')
442
- assert.equal(result.apiKey, '')
443
- assert.equal(result.displayName, 'my-model')
444
- } finally {
445
- restore()
446
- }
447
- })
448
-
449
- test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
450
- const restore = silenceConsole()
451
- try {
452
- // 选 DeepSeek(3) → 确认 → 选第一个模型(1) → apiKey → displayName → 测试401失败 → 不保存(n)
453
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'n'])
454
- const result = await collectModelInput({
455
- locale: 'zh-CN',
456
- rl,
457
- fetchFn: mockFetch({ status: 401, ok: false }),
458
- })
459
- assert.equal(result, null)
460
- } finally {
461
- restore()
462
- }
463
- })
464
-
465
- test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
466
- const restore = silenceConsole()
467
- try {
468
- const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'y'])
469
- const result = await collectModelInput({
470
- locale: 'zh-CN',
471
- rl,
472
- fetchFn: mockFetch({ status: 500, ok: false }),
473
- })
474
- assert.ok(result)
475
- assert.equal(result.model, 'deepseek-v4-pro')
476
- } finally {
477
- restore()
478
- }
479
- })
480
-
481
- test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () => {
482
- const restore = silenceConsole()
483
- try {
484
- // 在第 1 次 question(选服务商)时触发 close,模拟用户取消
485
- const rl = createMockRl([], { closeOnQuestion: 1 })
486
- const result = await collectModelInput({
487
- locale: 'zh-CN',
488
- rl,
489
- fetchFn: mockFetch({ status: 200, ok: true }),
490
- cancelMessage: '自定义取消文案',
491
- })
492
- assert.equal(result, null)
493
- } finally {
494
- restore()
495
- }
496
- })
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // src/cli/ai/modelSetup.js 的单元测试。
16
+ // 覆盖纯函数:getBuiltinModels / findProviderByUrl / validateEndpoint /
17
+ // buildModelConfig / testModelConnection(注入 mock fetch)。
18
+ import { test } from 'node:test'
19
+ import assert from 'node:assert/strict'
20
+ import {
21
+ getBuiltinModels,
22
+ findProviderByUrl,
23
+ validateEndpoint,
24
+ buildModelConfig,
25
+ testModelConnection,
26
+ collectModelInput,
27
+ PROVIDERS,
28
+ BUILTIN_MODELS,
29
+ } from './modelSetup.js'
30
+
31
+ // ── getBuiltinModels ──────────────────────────
32
+
33
+ test('getBuiltinModels: 已知 baseURL 返回模型列表', () => {
34
+ const models = getBuiltinModels('https://api.deepseek.com/v1')
35
+ assert.ok(Array.isArray(models))
36
+ assert.ok(models.length > 0)
37
+ assert.ok(models.includes('deepseek-chat'))
38
+ })
39
+
40
+ test('getBuiltinModels: 尾斜杠的 baseURL 也能匹配', () => {
41
+ const a = getBuiltinModels('https://api.deepseek.com/v1')
42
+ const b = getBuiltinModels('https://api.deepseek.com/v1/')
43
+ assert.deepEqual(a, b)
44
+ })
45
+
46
+ test('getBuiltinModels: 未知 baseURL 返回空数组', () => {
47
+ assert.deepEqual(getBuiltinModels('https://example.com/v1'), [])
48
+ })
49
+
50
+ test('getBuiltinModels: null / undefined / 空字符串 返回空数组', () => {
51
+ assert.deepEqual(getBuiltinModels(null), [])
52
+ assert.deepEqual(getBuiltinModels(undefined), [])
53
+ assert.deepEqual(getBuiltinModels(''), [])
54
+ assert.deepEqual(getBuiltinModels(123), [])
55
+ })
56
+
57
+ // ── findProviderByUrl ─────────────────────────
58
+
59
+ test('findProviderByUrl: 已知 URL 返回 provider 对象', () => {
60
+ const p = findProviderByUrl('https://api.openai.com/v1')
61
+ assert.ok(p)
62
+ assert.equal(p.id, 'openai')
63
+ assert.equal(p.label, 'OpenAI')
64
+ })
65
+
66
+ test('findProviderByUrl: 尾斜杠的 URL 也能匹配', () => {
67
+ const p = findProviderByUrl('https://api.openai.com/v1/')
68
+ assert.ok(p)
69
+ assert.equal(p.id, 'openai')
70
+ })
71
+
72
+ test('findProviderByUrl: 未知 URL 返回 null', () => {
73
+ assert.equal(findProviderByUrl('https://example.com/v1'), null)
74
+ })
75
+
76
+ test('findProviderByUrl: null / undefined 返回 null', () => {
77
+ assert.equal(findProviderByUrl(null), null)
78
+ assert.equal(findProviderByUrl(undefined), null)
79
+ assert.equal(findProviderByUrl(''), null)
80
+ })
81
+
82
+ test('findProviderByUrl: 每个预设都能被找到', () => {
83
+ for (const p of PROVIDERS) {
84
+ const found = findProviderByUrl(p.url)
85
+ assert.ok(found, `应能找到 provider: ${p.id}`)
86
+ assert.equal(found.id, p.id)
87
+ }
88
+ })
89
+
90
+ // ── validateEndpoint ──────────────────────────
91
+
92
+ test('validateEndpoint: 合法 https URL', () => {
93
+ assert.equal(validateEndpoint('https://api.openai.com/v1'), true)
94
+ assert.equal(validateEndpoint('https://example.com'), true)
95
+ })
96
+
97
+ test('validateEndpoint: 合法 http URL', () => {
98
+ assert.equal(validateEndpoint('http://localhost:11434/v1'), true)
99
+ assert.equal(validateEndpoint('http://192.168.1.1:8080'), true)
100
+ })
101
+
102
+ test('validateEndpoint: 非法协议(ftp)返回 false', () => {
103
+ assert.equal(validateEndpoint('ftp://example.com'), false)
104
+ })
105
+
106
+ test('validateEndpoint: 无协议返回 false', () => {
107
+ assert.equal(validateEndpoint('example.com'), false)
108
+ assert.equal(validateEndpoint('api.openai.com/v1'), false)
109
+ })
110
+
111
+ test('validateEndpoint: null / undefined / 空字符串返回 false', () => {
112
+ assert.equal(validateEndpoint(null), false)
113
+ assert.equal(validateEndpoint(undefined), false)
114
+ assert.equal(validateEndpoint(''), false)
115
+ })
116
+
117
+ // ── buildModelConfig ──────────────────────────
118
+
119
+ test('buildModelConfig: 基本构建', () => {
120
+ const m = buildModelConfig({
121
+ baseURL: 'https://api.deepseek.com/v1',
122
+ model: 'deepseek-chat',
123
+ apiKey: 'sk-test',
124
+ name: 'My DeepSeek',
125
+ isDefault: true,
126
+ })
127
+ assert.equal(m.model, 'deepseek-chat')
128
+ assert.equal(m.name, 'My DeepSeek')
129
+ assert.equal(m.baseURL, 'https://api.deepseek.com/v1')
130
+ assert.equal(m.apiKey, 'sk-test')
131
+ assert.equal(m.isDefault, true)
132
+ assert.ok(m.id, 'id 不应为空')
133
+ assert.match(m.id, /^model-/, 'id 应以 model- 开头')
134
+ })
135
+
136
+ test('buildModelConfig: 不传 name 时默认用 model 名', () => {
137
+ const m = buildModelConfig({
138
+ baseURL: 'https://api.openai.com/v1',
139
+ model: 'gpt-4o',
140
+ })
141
+ assert.equal(m.name, 'gpt-4o')
142
+ })
143
+
144
+ test('buildModelConfig: name 为空白时默认用 model 名', () => {
145
+ const m = buildModelConfig({
146
+ baseURL: 'https://api.openai.com/v1',
147
+ model: 'gpt-4o',
148
+ name: ' ',
149
+ })
150
+ assert.equal(m.name, 'gpt-4o')
151
+ })
152
+
153
+ test('buildModelConfig: baseURL 去尾斜杠', () => {
154
+ const m = buildModelConfig({
155
+ baseURL: 'https://api.openai.com/v1/',
156
+ model: 'gpt-4o',
157
+ })
158
+ assert.equal(m.baseURL, 'https://api.openai.com/v1')
159
+ })
160
+
161
+ test('buildModelConfig: isDefault 默认 false', () => {
162
+ const m = buildModelConfig({
163
+ baseURL: 'https://api.openai.com/v1',
164
+ model: 'gpt-4o',
165
+ })
166
+ assert.equal(m.isDefault, false)
167
+ })
168
+
169
+ test('buildModelConfig: apiKey 为空时存空字符串', () => {
170
+ const m = buildModelConfig({
171
+ baseURL: 'http://localhost:11434/v1',
172
+ model: 'llama3',
173
+ })
174
+ assert.equal(m.apiKey, '')
175
+ })
176
+
177
+ test('buildModelConfig: id 包含 name 的 slug 化形式', () => {
178
+ const m = buildModelConfig({
179
+ baseURL: 'https://api.openai.com/v1',
180
+ model: 'gpt-4o',
181
+ name: 'GPT 4o Mini!',
182
+ })
183
+ // name 中的非 a-z0-9 字符被替换为 -
184
+ assert.match(m.id, /^model-gpt-4o-mini-/)
185
+ })
186
+
187
+ // ── testModelConnection ───────────────────────
188
+ // 注入 mock fetch 避免真实网络请求
189
+
190
+ function mockFetch({ status = 200, ok = true, body = {} } = {}) {
191
+ return async (url, opts) => ({
192
+ ok,
193
+ status,
194
+ json: async () => body,
195
+ text: async () => JSON.stringify(body),
196
+ })
197
+ }
198
+
199
+ test('testModelConnection: 200 成功', async () => {
200
+ const result = await testModelConnection({
201
+ baseURL: 'https://api.deepseek.com/v1',
202
+ model: 'deepseek-chat',
203
+ apiKey: 'sk-test',
204
+ fetchFn: mockFetch({ status: 200, ok: true }),
205
+ })
206
+ assert.equal(result.ok, true)
207
+ assert.equal(result.status, 200)
208
+ })
209
+
210
+ test('testModelConnection: 400 也视为成功(连接通的)', async () => {
211
+ const result = await testModelConnection({
212
+ baseURL: 'https://api.deepseek.com/v1',
213
+ model: 'deepseek-chat',
214
+ fetchFn: mockFetch({ status: 400, ok: false }),
215
+ })
216
+ assert.equal(result.ok, true)
217
+ assert.equal(result.status, 400)
218
+ })
219
+
220
+ test('testModelConnection: 401 API Key 无效', async () => {
221
+ const result = await testModelConnection({
222
+ baseURL: 'https://api.deepseek.com/v1',
223
+ model: 'deepseek-chat',
224
+ apiKey: 'sk-bad',
225
+ fetchFn: mockFetch({ status: 401, ok: false }),
226
+ })
227
+ assert.equal(result.ok, false)
228
+ assert.equal(result.status, 401)
229
+ assert.match(result.message, /401/)
230
+ })
231
+
232
+ test('testModelConnection: 404 模型不存在', async () => {
233
+ const result = await testModelConnection({
234
+ baseURL: 'https://api.deepseek.com/v1',
235
+ model: 'nonexistent',
236
+ fetchFn: mockFetch({ status: 404, ok: false }),
237
+ })
238
+ assert.equal(result.ok, false)
239
+ assert.equal(result.status, 404)
240
+ assert.match(result.message, /404/)
241
+ })
242
+
243
+ test('testModelConnection: 500 服务器错误', async () => {
244
+ const result = await testModelConnection({
245
+ baseURL: 'https://api.deepseek.com/v1',
246
+ model: 'deepseek-chat',
247
+ fetchFn: mockFetch({ status: 500, ok: false }),
248
+ })
249
+ assert.equal(result.ok, false)
250
+ assert.equal(result.status, 500)
251
+ assert.match(result.message, /500/)
252
+ })
253
+
254
+ test('testModelConnection: AbortError 返回超时消息', async () => {
255
+ const abortFetch = async () => {
256
+ const err = new Error('aborted')
257
+ err.name = 'AbortError'
258
+ throw err
259
+ }
260
+ const result = await testModelConnection({
261
+ baseURL: 'https://api.deepseek.com/v1',
262
+ model: 'deepseek-chat',
263
+ fetchFn: abortFetch,
264
+ timeoutMs: 100,
265
+ })
266
+ assert.equal(result.ok, false)
267
+ assert.match(result.message, /超时|timeout/i)
268
+ })
269
+
270
+ test('testModelConnection: 网络错误返回失败消息', async () => {
271
+ const failFetch = async () => {
272
+ throw new Error('ECONNREFUSED')
273
+ }
274
+ const result = await testModelConnection({
275
+ baseURL: 'https://api.deepseek.com/v1',
276
+ model: 'deepseek-chat',
277
+ fetchFn: failFetch,
278
+ })
279
+ assert.equal(result.ok, false)
280
+ assert.match(result.message, /ECONNREFUSED|连接失败/)
281
+ })
282
+
283
+ test('testModelConnection: baseURL 尾斜杠被正确处理', async () => {
284
+ let calledUrl = ''
285
+ const spyFetch = async (url) => {
286
+ calledUrl = url
287
+ return { ok: true, status: 200, json: async () => ({}) }
288
+ }
289
+ await testModelConnection({
290
+ baseURL: 'https://api.deepseek.com/v1/',
291
+ model: 'deepseek-chat',
292
+ fetchFn: spyFetch,
293
+ })
294
+ // 尾斜杠被去掉,不产生双斜杠
295
+ assert.equal(calledUrl, 'https://api.deepseek.com/v1/chat/completions')
296
+ })
297
+
298
+ test('testModelConnection: 无 apiKey 时不发 Authorization 头', async () => {
299
+ let calledHeaders = {}
300
+ const spyFetch = async (url, opts) => {
301
+ calledHeaders = opts.headers
302
+ return { ok: true, status: 200, json: async () => ({}) }
303
+ }
304
+ await testModelConnection({
305
+ baseURL: 'https://api.deepseek.com/v1',
306
+ model: 'deepseek-chat',
307
+ apiKey: '',
308
+ fetchFn: spyFetch,
309
+ })
310
+ assert.ok(!calledHeaders['Authorization'], '无 apiKey 时不应有 Authorization 头')
311
+ assert.equal(calledHeaders['Content-Type'], 'application/json')
312
+ })
313
+
314
+ test('testModelConnection: 有 apiKey 时发 Bearer token', async () => {
315
+ let calledHeaders = {}
316
+ const spyFetch = async (url, opts) => {
317
+ calledHeaders = opts.headers
318
+ return { ok: true, status: 200, json: async () => ({}) }
319
+ }
320
+ await testModelConnection({
321
+ baseURL: 'https://api.deepseek.com/v1',
322
+ model: 'deepseek-chat',
323
+ apiKey: 'sk-abc123',
324
+ fetchFn: spyFetch,
325
+ })
326
+ assert.equal(calledHeaders['Authorization'], 'Bearer sk-abc123')
327
+ })
328
+
329
+ // ── 数据完整性 ────────────────────────────────
330
+
331
+ test('PROVIDERS: 至少有 16 个预设', () => {
332
+ assert.ok(PROVIDERS.length >= 16)
333
+ })
334
+
335
+ test('PROVIDERS: 每个预设有 id / label / url', () => {
336
+ for (const p of PROVIDERS) {
337
+ assert.ok(p.id, `provider 缺少 id: ${JSON.stringify(p)}`)
338
+ assert.ok(p.label, `provider 缺少 label: ${JSON.stringify(p)}`)
339
+ assert.ok(p.url, `provider 缺少 url: ${JSON.stringify(p)}`)
340
+ assert.ok(validateEndpoint(p.url), `provider url 不合法: ${p.url}`)
341
+ }
342
+ })
343
+
344
+ test('BUILTIN_MODELS: 每个 baseURL 都对应一个非空数组', () => {
345
+ for (const [url, models] of Object.entries(BUILTIN_MODELS)) {
346
+ assert.ok(Array.isArray(models), `BUILTIN_MODELS[${url}] 应为数组`)
347
+ assert.ok(models.length > 0, `BUILTIN_MODELS[${url}] 不应为空`)
348
+ for (const m of models) {
349
+ assert.equal(typeof m, 'string', `模型名应为字符串: ${m}`)
350
+ assert.ok(m.length > 0, `模型名不应为空`)
351
+ }
352
+ }
353
+ })
354
+
355
+ test('BUILTIN_MODELS: 每个 key 都能在 PROVIDERS 中找到', () => {
356
+ const providerUrls = new Set(PROVIDERS.map(p => p.url))
357
+ for (const url of Object.keys(BUILTIN_MODELS)) {
358
+ assert.ok(providerUrls.has(url), `BUILTIN_MODELS 的 key "${url}" 不在 PROVIDERS 中`)
359
+ }
360
+ })
361
+
362
+ // ── collectModelInput:交互式收集(注入 mock rl + mock fetch) ──
363
+ //
364
+ // collectModelInput 是 runModelSetup 和 agent.js /addmodel 共用的核心交互逻辑。
365
+ // 通过 mock readline 逐条返回预设答案,mock fetch 控制 /chat/completions 测试结果,
366
+ // 覆盖:预设服务商、自定义服务商、测试成功/失败/取消 四条主路径。
367
+
368
+ /**
369
+ * 创建 mock readline,按队列依次返回预设答案给 rl.question()。
370
+ * @param {string[]} answers - 预设答案队列(每次 question 消费一条)
371
+ * @param {object} [opts]
372
+ * @param {number} [opts.closeOnQuestion=-1] - 在第 N 次 question 时触发 close 事件(模拟取消),-1=不触发
373
+ */
374
+ function createMockRl(answers, { closeOnQuestion = -1 } = {}) {
375
+ const queue = [...answers]
376
+ const closeHandlers = []
377
+ let qNum = 0
378
+ return {
379
+ question(_prompt, cb) {
380
+ qNum++
381
+ if (qNum === closeOnQuestion) {
382
+ // 先让 once('close') 注册,再在 nextTick 触发(模拟用户 Ctrl+D)
383
+ process.nextTick(() => { for (const h of closeHandlers) h() })
384
+ return
385
+ }
386
+ const answer = queue.shift() ?? ''
387
+ process.nextTick(() => cb(answer))
388
+ },
389
+ once(event, cb) {
390
+ if (event === 'close') closeHandlers.push(cb)
391
+ },
392
+ close() {},
393
+ }
394
+ }
395
+
396
+ /** 静默 console.log/error/warn,避免交互式测试刷屏;返回 restore 函数 */
397
+ function silenceConsole() {
398
+ const orig = { log: console.log, error: console.error, warn: console.warn }
399
+ console.log = () => {}
400
+ console.error = () => {}
401
+ console.warn = () => {}
402
+ return () => {
403
+ console.log = orig.log
404
+ console.error = orig.error
405
+ console.warn = orig.warn
406
+ }
407
+ }
408
+
409
+ test('collectModelInput: 预设服务商 + 内置模型 + 测试成功 → 返回收集的字段', async () => {
410
+ const restore = silenceConsole()
411
+ try {
412
+ // 答案:选 OpenAI(1) → 确认 baseURL(空=默认) → 选第一个模型(1) → apiKey → displayName(空=默认)
413
+ const rl = createMockRl(['1', '', '1', 'sk-test', ''])
414
+ const result = await collectModelInput({
415
+ locale: 'zh-CN',
416
+ rl,
417
+ fetchFn: mockFetch({ status: 200, ok: true }),
418
+ })
419
+ assert.ok(result, '应返回收集结果')
420
+ assert.equal(result.baseURL, 'https://api.openai.com/v1')
421
+ assert.equal(result.model, 'gpt-5.5')
422
+ assert.equal(result.apiKey, 'sk-test')
423
+ assert.equal(result.displayName, 'gpt-5.5')
424
+ } finally {
425
+ restore()
426
+ }
427
+ })
428
+
429
+ test('collectModelInput: 自定义服务商 + 手动输入模型名 → 返回收集的字段', async () => {
430
+ const restore = silenceConsole()
431
+ try {
432
+ // 选自定义(0) → 输入 baseURL → 确认(空=默认) → 手动输入模型名 → apiKey(空) → displayName(空=默认)
433
+ const rl = createMockRl(['0', 'https://my.custom.api/v1', '', 'my-model', '', ''])
434
+ const result = await collectModelInput({
435
+ locale: 'zh-CN',
436
+ rl,
437
+ fetchFn: mockFetch({ status: 200, ok: true }),
438
+ })
439
+ assert.ok(result)
440
+ assert.equal(result.baseURL, 'https://my.custom.api/v1')
441
+ assert.equal(result.model, 'my-model')
442
+ assert.equal(result.apiKey, '')
443
+ assert.equal(result.displayName, 'my-model')
444
+ } finally {
445
+ restore()
446
+ }
447
+ })
448
+
449
+ test('collectModelInput: 测试失败 + 用户选择不保存 → 返回 null', async () => {
450
+ const restore = silenceConsole()
451
+ try {
452
+ // 选 DeepSeek(3) → 确认 → 选第一个模型(1) → apiKey → displayName → 测试401失败 → 不保存(n)
453
+ const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'n'])
454
+ const result = await collectModelInput({
455
+ locale: 'zh-CN',
456
+ rl,
457
+ fetchFn: mockFetch({ status: 401, ok: false }),
458
+ })
459
+ assert.equal(result, null)
460
+ } finally {
461
+ restore()
462
+ }
463
+ })
464
+
465
+ test('collectModelInput: 测试失败 + 用户选择继续 → 返回收集的字段', async () => {
466
+ const restore = silenceConsole()
467
+ try {
468
+ const rl = createMockRl(['3', '', '1', 'sk-bad', '', 'y'])
469
+ const result = await collectModelInput({
470
+ locale: 'zh-CN',
471
+ rl,
472
+ fetchFn: mockFetch({ status: 500, ok: false }),
473
+ })
474
+ assert.ok(result)
475
+ assert.equal(result.model, 'deepseek-v4-pro')
476
+ } finally {
477
+ restore()
478
+ }
479
+ })
480
+
481
+ test('collectModelInput: 用户取消(触发 close)→ 返回 null', async () => {
482
+ const restore = silenceConsole()
483
+ try {
484
+ // 在第 1 次 question(选服务商)时触发 close,模拟用户取消
485
+ const rl = createMockRl([], { closeOnQuestion: 1 })
486
+ const result = await collectModelInput({
487
+ locale: 'zh-CN',
488
+ rl,
489
+ fetchFn: mockFetch({ status: 200, ok: true }),
490
+ cancelMessage: '自定义取消文案',
491
+ })
492
+ assert.equal(result, null)
493
+ } finally {
494
+ restore()
495
+ }
496
+ })