dsh-router-core 0.4.7 → 0.4.9

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 (62) hide show
  1. package/README.md +14 -70
  2. package/cordis.patch.yml +2 -7
  3. package/docs/ext.md +3 -17
  4. package/docs/suppliers.md +1 -1
  5. package/lib/client-registry.js +137 -590
  6. package/lib/client.js +137 -590
  7. package/lib/ext-prompt/index.js +20 -15
  8. package/lib/index.js +0 -419
  9. package/lib/types/ext/contract.d.ts +1 -1
  10. package/lib/types/ext-prompt/content.d.ts +11 -0
  11. package/lib/types/ext-prompt/index.d.ts +7 -9
  12. package/lib/types/ext-prompt/mount.d.ts +2 -2
  13. package/lib/types/ext-prompt/plugin.d.ts +3 -5
  14. package/lib/types/router/types.d.ts +11 -0
  15. package/lib/types/shared.d.ts +2 -0
  16. package/lib/types/suppliers/registry.d.ts +0 -1
  17. package/package.json +1 -10
  18. package/src/client/CombosTab.tsx +29 -8
  19. package/src/client/ExtDetail.tsx +1 -13
  20. package/src/client/SupplierDetail.tsx +11 -5
  21. package/src/client/group-controls.test.ts +1 -1
  22. package/src/client/group-controls.ts +1 -1
  23. package/src/client/index.tsx +0 -17
  24. package/src/client/model-label.test.ts +33 -0
  25. package/src/client/model-label.ts +48 -0
  26. package/src/client/router-components.test.ts +1 -1
  27. package/src/client/router.css +24 -412
  28. package/src/ext/contract.ts +1 -1
  29. package/src/ext-prompt/content.ts +17 -12
  30. package/src/ext-prompt/ext-prompt.test.ts +3 -3
  31. package/src/ext-prompt/index.ts +7 -9
  32. package/src/ext-prompt/mount.ts +2 -2
  33. package/src/ext-prompt/plugin.ts +3 -5
  34. package/src/router/index.ts +2 -3
  35. package/src/router/models-cache.test.ts +21 -6
  36. package/src/router/supplier-switch.test.ts +1 -1
  37. package/src/router/types.ts +11 -0
  38. package/src/shared.ts +2 -0
  39. package/src/suppliers/registry.ts +0 -71
  40. package/lib/contract-D4BWZ7Xe.js +0 -13
  41. package/lib/ext-test/index.js +0 -36
  42. package/lib/ext-test/locale/en.json +0 -6
  43. package/lib/ext-test/locale/zh.json +0 -6
  44. package/lib/ext-test/package.json +0 -5
  45. package/lib/types/ext-test/index.d.ts +0 -21
  46. package/lib/types/ext-test/plugin.d.ts +0 -23
  47. package/lib/types/suppliers/probe.d.ts +0 -160
  48. package/src/client/ExtTestPanel.tsx +0 -392
  49. package/src/client/comp-button-scope.test.ts +0 -58
  50. package/src/client/comp-panel-layout.test.ts +0 -58
  51. package/src/client/ext-panels.test.ts +0 -44
  52. package/src/client/ext-panels.ts +0 -49
  53. package/src/client/model-choice.test.ts +0 -41
  54. package/src/client/model-choice.ts +0 -37
  55. package/src/ext-test/ext-test.test.ts +0 -60
  56. package/src/ext-test/index.ts +0 -35
  57. package/src/ext-test/locale/en.json +0 -6
  58. package/src/ext-test/locale/zh.json +0 -6
  59. package/src/ext-test/plugin.ts +0 -34
  60. package/src/ext-test/row.json +0 -5
  61. package/src/suppliers/probe.test.ts +0 -266
  62. package/src/suppliers/probe.ts +0 -475
@@ -1,392 +0,0 @@
1
- /**
2
- * 插件自检面板 —— 扩展「插件自检」(`dsh-router-ext-test` / 扩展 id `test`) 的详情内容。
3
- *
4
- * **入口只有一处**:设置 → 路由 → 扩展 → 点开「插件自检」那张卡片(`ExtDetail` 查
5
- * `ext-panels.ts` 的注册表拿到本组件)。
6
- *
7
- * 刻意**不给插件页那一行也挂一个详情页**:那一行的宿主行开关已经能开/关它,再点进去
8
- * 看同一块面板就是同一个东西有两个入口 —— 与「内置扩展不在自绘节里重复列」同理。
9
- * 插件页那一行只负责开关。
10
- *
11
- * 视觉上照着宿主的插件页写(`--dsw-alias-*` 那套 token),**不复用设置页的
12
- * `dshr-*` 组件样式**:那些样式依赖 `--rs-*` 变量,而那套变量只定义在
13
- * `.dshr-settings` / `.dshr-shell` 下,插件页不在其祖先里(未定义的 `var()` 会让
14
- * 整条声明失效)。按钮直接用宿主的 `Button` 原语 —— 配色由原语自己的 token 家族
15
- * 决定,不必在这里猜「主色底上该配什么字色」。
16
- */
17
- import { useEffect, useState } from 'react'
18
- import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
19
- import { ROUTER_API_BASE, type RouterAccount, type RouterHealthResponse } from '../shared.ts'
20
- import { modelChoice, type ModelRow } from './model-choice.ts'
21
- import type { ProbeReport } from '../suppliers/probe.ts'
22
-
23
- /** 一次测试的结果。`ok` 为 false 时 `error` 是核心给的真实原因(上游响应 / chatOnce message)。 */
24
- interface TestResult {
25
- ok: boolean
26
- error?: string
27
- /** 实际测的是哪个连接(空串 = 池内任选,与面板「测试」按钮同义)。 */
28
- uid: string
29
- /** 测的时候选的是哪三样,回显出来 —— 一条「通过」必须可归因。 */
30
- supplier: string
31
- model: string
32
- }
33
-
34
- /** 结果区的一个状态。`network` 是请求本身失败,与「上游拒了」分开。 */
35
- type Notice =
36
- | { tone: 'ok'; result: TestResult }
37
- | { tone: 'fail'; result: TestResult }
38
- | { tone: 'network'; text: string }
39
-
40
- /** 每个契约成员状态的字形(颜色由 data-state 给)。 */
41
- /** 模型启用状态那一行属于核心侧,没在 members 里,状态由「拿不拿得到」决定。 */
42
- function coreModelsState(report: ProbeReport): ProbeReport['members'][number]['state'] {
43
- return report.core.models.total === null ? 'unverified' : 'ok'
44
- }
45
-
46
- const MEMBER_MARK: Record<ProbeReport['members'][number]['state'], string> = {
47
- ok: '✓',
48
- fail: '✕',
49
- absent: '–',
50
- unverified: '⊘',
51
- }
52
-
53
- const TONE_ICON: Record<Notice['tone'], string> = {
54
- ok: 'M20 6L9 17l-5-5',
55
- fail: 'M18 6L6 18M6 6l12 12',
56
- network: 'M12 9v4m0 4h.01M10.3 3.9L2 18a2 2 0 003 2h16a2 2 0 002-2L13.7 3.9a2 2 0 00-3.4 0z',
57
- }
58
-
59
- function Glyph({ d }: { d: string }): JSX.Element {
60
- return (
61
- <svg className="dshr-compNoticeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
62
- strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
63
- <path d={d} />
64
- </svg>
65
- )
66
- }
67
-
68
- /** 三个下拉里的一栏:标题行(可带右侧小注)+ 控件。`emptyHint` 在没得选时说明「为什么没有」。 */
69
- function Picker(props: {
70
- label: string
71
- /** 标题右侧的小注(这一栏的用法要点,比塞进顶部那段长文字更容易被读到)。 */
72
- note?: string
73
- value: string
74
- options: Array<{ value: string; label: string }>
75
- emptyHint: string
76
- disabled?: boolean
77
- onChange: (value: string) => void
78
- }): JSX.Element {
79
- const empty = props.options.length === 0
80
- return (
81
- <div className="dshr-compField">
82
- <div className="dshr-compFieldHead">
83
- <label className="dshr-compFieldLabel">{props.label}</label>
84
- {props.note !== undefined && <span className="dshr-compFieldNote">{props.note}</span>}
85
- </div>
86
- <select
87
- className="dshr-compSelect"
88
- value={props.value}
89
- disabled={props.disabled === true || empty}
90
- aria-label={props.label}
91
- onChange={(e) => { props.onChange(e.target.value) }}
92
- >
93
- {empty
94
- ? <option value="">{props.emptyHint}</option>
95
- : <>
96
- <option value="">请选择</option>
97
- {props.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
98
- </>}
99
- </select>
100
- </div>
101
- )
102
- }
103
-
104
- export function ExtTestPanel(): JSX.Element {
105
- const [suppliers, setSuppliers] = useState<NonNullable<RouterHealthResponse['suppliers']>>([])
106
- const [accounts, setAccounts] = useState<RouterAccount[]>([])
107
- const [models, setModels] = useState<{ ids: string[]; empty?: 'none' | 'all-disabled' }>({ ids: [] })
108
- const [supplierId, setSupplierId] = useState('')
109
- const [modelId, setModelId] = useState('')
110
- const [uid, setUid] = useState('')
111
- const [busy, setBusy] = useState(false)
112
- const [notice, setNotice] = useState<Notice | null>(null)
113
- const [loaded, setLoaded] = useState(false)
114
- // 契约体检:只针对**外部供应商插件**(内置供应商随核心分发,没有独立插件可体检)
115
- const [pluginId, setPluginId] = useState('')
116
- const [probing, setProbing] = useState(false)
117
- const [report, setReport] = useState<ProbeReport | null>(null)
118
- const [probeError, setProbeError] = useState('')
119
-
120
- // 供应商与连接各读一次:/health 给供应商(开着的那批,与面板一致),
121
- // /status 给账号(uid + 昵称 + 冷却状态)。
122
- useEffect(() => {
123
- let live = true
124
- void (async () => {
125
- try {
126
- const [health, status] = await Promise.all([
127
- fetch(`${ROUTER_API_BASE}/health`, { cache: 'no-store' }).then((r) => r.json() as Promise<RouterHealthResponse>),
128
- fetch(`${ROUTER_API_BASE}/status`, { cache: 'no-store' }).then((r) => r.json() as Promise<{ ok: boolean; accounts?: RouterAccount[] }>),
129
- ])
130
- if (!live) return
131
- setSuppliers((health.suppliers ?? []).filter((s) => s.enabled !== false))
132
- setAccounts(status.ok ? (status.accounts ?? []) : [])
133
- } catch (err) {
134
- if (live) setNotice({ tone: 'network', text: (err as Error).message })
135
- } finally {
136
- if (live) setLoaded(true)
137
- }
138
- })()
139
- return () => { live = false }
140
- }, [])
141
-
142
- // 换供应商 → 重拉模型、清掉模型/连接选择与旧结果(旧结果对新选择没有意义,
143
- // 留着会被误读成「刚测过这个」)。
144
- useEffect(() => {
145
- setModelId('')
146
- setUid('')
147
- setNotice(null)
148
- if (supplierId === '') { setModels({ ids: [] }); return }
149
- let live = true
150
- void (async () => {
151
- try {
152
- const data = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(supplierId)}/models`, { cache: 'no-store' })
153
- .then((r) => r.json() as Promise<{ ok: boolean; models?: ModelRow[] }>)
154
- // 端点给的是**全部**模型(含用户停用的),这里只取可用的 —— 见 model-choice.ts
155
- if (live) setModels(data.ok ? modelChoice(data.models ?? []) : { ids: [] })
156
- } catch {
157
- if (live) setModels({ ids: [] })
158
- }
159
- })()
160
- return () => { live = false }
161
- }, [supplierId])
162
-
163
- // 外部供应商插件 = source 为 external 的那些(内置随核心分发,不进这个下拉)
164
- const pluginSuppliers = suppliers.filter((x) => x.source === 'external')
165
-
166
- const runProbe = async (): Promise<void> => {
167
- if (pluginId === '' || probing) return
168
- setProbing(true)
169
- setProbeError('')
170
- setReport(null)
171
- try {
172
- const response = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(pluginId)}/probe`, {
173
- method: 'POST',
174
- headers: { 'Content-Type': 'application/json' },
175
- body: JSON.stringify({}),
176
- cache: 'no-store',
177
- })
178
- const data = await response.json() as { ok: boolean; error?: string; report?: ProbeReport }
179
- if (data.ok && data.report !== undefined) setReport(data.report)
180
- else setProbeError(data.error ?? '体检失败')
181
- } catch (err) {
182
- setProbeError((err as Error).message)
183
- } finally {
184
- setProbing(false)
185
- }
186
- }
187
-
188
- const links = accounts.filter((a) => a.supplier === supplierId)
189
- const runnable = supplierId !== '' && modelId !== ''
190
- const supplierName = suppliers.find((s) => s.id === supplierId)?.name ?? supplierId
191
-
192
- const run = async (): Promise<void> => {
193
- if (!runnable || busy) return
194
- setBusy(true)
195
- setNotice(null)
196
- try {
197
- const response = await fetch(`${ROUTER_API_BASE}/suppliers/${encodeURIComponent(supplierId)}/models/test`, {
198
- method: 'POST',
199
- headers: { 'Content-Type': 'application/json' },
200
- // uid 留空 = 池内任选可用号(与面板「测试」按钮同义);选了 = 只测它。
201
- body: JSON.stringify({ id: modelId, uid }),
202
- cache: 'no-store',
203
- })
204
- const data = await response.json() as { ok: boolean; error?: string }
205
- const result: TestResult = {
206
- ok: data.ok,
207
- uid,
208
- supplier: supplierName,
209
- model: modelId,
210
- ...(data.error === undefined ? {} : { error: data.error }),
211
- }
212
- setNotice({ tone: data.ok ? 'ok' : 'fail', result })
213
- } catch (err) {
214
- setNotice({ tone: 'network', text: (err as Error).message })
215
- } finally {
216
- setBusy(false)
217
- }
218
- }
219
-
220
- return (
221
- <div className="dshr-tabBody dshr-comp">
222
- {/* 两节各自有标题:两个按钮都叫「测试」,没有标题就分不清点的是哪一个 */}
223
- <div className="dshr-compHead">
224
- <h4 className="dshr-compSectionTitle">连接测试</h4>
225
- <span className="dshr-compSectionNote">选供应商 / 模型 / 连接</span>
226
- </div>
227
-
228
- <div className="dshr-compForm">
229
- <Picker
230
- label="供应商"
231
- value={supplierId}
232
- options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
233
- emptyHint={loaded ? '没有已启用的供应商' : '加载中…'}
234
- onChange={setSupplierId}
235
- />
236
- <Picker
237
- label="模型"
238
- note="仅可用"
239
- value={modelId}
240
- options={models.ids.map((id) => ({ value: id, label: id }))}
241
- emptyHint={supplierId === ''
242
- ? '先选供应商'
243
- : models.empty === 'all-disabled'
244
- ? '模型都被停用了(在供应商详情里开启)'
245
- : models.empty === 'none' ? '没有模型(可先在供应商详情里拉取)' : '请选择'}
246
- disabled={supplierId === ''}
247
- onChange={(v) => { setModelId(v); setNotice(null) }}
248
- />
249
- <Picker
250
- label="连接"
251
- note="留空 = 池内任选"
252
- value={uid}
253
- options={links.map((a) => ({
254
- value: a.uid,
255
- label: `${a.nickname ?? a.uid}${a.cooling ? '(冷却中)' : ''}`,
256
- }))}
257
- emptyHint={supplierId === '' ? '先选供应商' : '没有连接(留空则不指定)'}
258
- disabled={supplierId === ''}
259
- onChange={(v) => { setUid(v); setNotice(null) }}
260
- />
261
- </div>
262
-
263
- <div className="dshr-compActions dshr-compBtnRow">
264
- <Button
265
- variant="primary"
266
- size="md"
267
- disabled={!runnable || busy}
268
- onClick={() => { void run() }}
269
- >
270
- {busy ? '测试中…' : '测试'}
271
- </Button>
272
- {!runnable && <span className="dshr-compHint">先选供应商与模型</span>}
273
- </div>
274
-
275
- {notice !== null && (
276
- <div className="dshr-compNotice" data-tone={notice.tone} role="status">
277
- <Glyph d={TONE_ICON[notice.tone]} />
278
- <div className="dshr-compNoticeBody">
279
- {notice.tone === 'network' ? (
280
- <>
281
- <div className="dshr-compNoticeTitle">请求失败</div>
282
- <div className="dshr-compNoticeText">{notice.text}</div>
283
- </>
284
- ) : (
285
- <>
286
- <div className="dshr-compNoticeTitle">
287
- {notice.tone === 'ok' ? '测试通过' : '测试失败'}
288
- </div>
289
- <div className="dshr-compNoticeText">
290
- {notice.tone === 'ok'
291
- ? '这个连接能正常访问该模型。'
292
- : (notice.result.error ?? '上游没有给出原因')}
293
- </div>
294
- {/* 回显测的是哪三样:一条「通过」如果不带对象,就是没法归因的一串绿字。 */}
295
- <div className="dshr-compNoticeMeta">
296
- {notice.result.supplier} · {notice.result.model} · {notice.result.uid === '' ? '池内任选' : notice.result.uid}
297
- </div>
298
- </>
299
- )}
300
- </div>
301
- </div>
302
- )}
303
-
304
- {/* ---- 契约体检:外部供应商插件的每个契约成员 ---- */}
305
- <div className="dshr-compSection">
306
- <div className="dshr-compHead">
307
- <h4 className="dshr-compSectionTitle">插件测试</h4>
308
- <span className="dshr-compSectionNote">外部供应商插件</span>
309
- </div>
310
- <p className="dshr-compIntro">前提:连接池里先有一个可用 token,剩下交给它。</p>
311
- <div className="dshr-compProbeRow">
312
- <Picker
313
- label="供应商插件"
314
- value={pluginId}
315
- options={pluginSuppliers.map((x) => ({ value: x.id, label: x.name }))}
316
- emptyHint={pluginSuppliers.length === 0 ? '没有装外部供应商插件' : '请选择'}
317
- onChange={(v) => { setPluginId(v); setReport(null); setProbeError('') }}
318
- />
319
- <div className="dshr-compProbeBtn dshr-compBtnRow">
320
- <Button variant="primary" size="md" disabled={pluginId === '' || probing} onClick={() => { void runProbe() }}>
321
- {probing ? '测试中…' : '测试'}
322
- </Button>
323
- </div>
324
- </div>
325
-
326
- {probeError !== '' && (
327
- <div className="dshr-compNotice" data-tone="network" role="status">
328
- <Glyph d={TONE_ICON.network} />
329
- <div className="dshr-compNoticeBody">
330
- <div className="dshr-compNoticeTitle">体检失败</div>
331
- <div className="dshr-compNoticeText">{probeError}</div>
332
- </div>
333
- </div>
334
- )}
335
-
336
- {report !== null && (
337
- <div className="dshr-compReport">
338
- <div className="dshr-compReportHead">
339
- <span className="dshr-compReportName">{report.name}</span>
340
- {/* 只报数字,不下判词。合格与否是判断,应当由看报告的人自己做 ——
341
- 工具给数字和逐条事实就够,替人定性既越位,也会在判据本身有偏差
342
- 时把话说死。 */}
343
- <span className="dshr-compReportSum">
344
- 共 {report.summary.total} 个 · 实现 {report.summary.implemented} · 实跑 {report.summary.ran} · 通过 {report.summary.ok} · 失败 {report.summary.fail} · 未验 {report.summary.unverified} · 未实现 {report.summary.absent}
345
- {report.summary.bulk === 'fail' ? ' · 全部启用/禁用有问题' : report.summary.bulk === 'ok' ? ' · 全部启用/禁用通过' : ''}
346
- </span>
347
- </div>
348
- <ul className="dshr-compMembers">
349
- {/* 核心侧那两项与契约成员**同构**(同一个列表、同一种行),只是它们不属于
350
- 插件契约(是核心代劳)。原先给它们单画了个带框的区块,反而让它们看着
351
- 像是另一个模块、比真问题还显眼。 */}
352
- <li className="dshr-compMember" data-state={coreModelsState(report)}>
353
- <span className="dshr-compMemberState" aria-hidden="true">{MEMBER_MARK[coreModelsState(report)]}</span>
354
- <span className="dshr-compMemberName">
355
- 模型启用状态
356
- <code className="dshr-compMemberKey">core</code>
357
- </span>
358
- <span className="dshr-compMemberDetail">
359
- {report.core.models.total === null
360
- ? (report.core.models.note ?? '拿不到')
361
- : `共 ${report.core.models.total} 个 · 启用 ${report.core.models.enabled} · 停用 ${report.core.models.disabled}`}
362
- </span>
363
- </li>
364
- {report.core.operations.map((o) => (
365
- <li key={o.key} className="dshr-compMember" data-state={o.ran === false ? 'unverified' : o.ok === true ? 'ok' : 'fail'}>
366
- <span className="dshr-compMemberState" aria-hidden="true">
367
- {MEMBER_MARK[o.ran === false ? 'unverified' : o.ok === true ? 'ok' : 'fail']}
368
- </span>
369
- <span className="dshr-compMemberName">
370
- {o.label}
371
- <code className="dshr-compMemberKey">core</code>
372
- </span>
373
- <span className="dshr-compMemberDetail">{o.detail}</span>
374
- </li>
375
- ))}
376
- {report.members.map((m) => (
377
- <li key={m.key} className="dshr-compMember" data-state={m.state}>
378
- <span className="dshr-compMemberState" aria-hidden="true">{MEMBER_MARK[m.state]}</span>
379
- <span className="dshr-compMemberName">
380
- {m.label}
381
- <code className="dshr-compMemberKey">{m.key}</code>
382
- </span>
383
- <span className="dshr-compMemberDetail">{m.detail ?? ''}</span>
384
- </li>
385
- ))}
386
- </ul>
387
- </div>
388
- )}
389
- </div>
390
- </div>
391
- )
392
- }
@@ -1,58 +0,0 @@
1
- /**
2
- * 面板按钮的字色判据 —— 锁住「每个按钮都在那条抬特异性的规则覆盖范围内」。
3
- *
4
- * ## 为什么需要这条
5
- *
6
- * 宿主的 `Button` 原语在 `primary` 变体里要把字色设成
7
- * `--dsw-alias-label-primary-foreground`,但 `router.css` 顶部那条兜底
8
- * `.dshr-settings button { color: inherit; font: inherit }` 特异性 (1,1) **高于**
9
- * 原语的 (1,0),赢的是它 —— 于是按钮变「#0f1115 底 + 继承来的 #0f1115 字」,
10
- * **黑底黑字**。这个插件已经因此翻车两次:第一次是访问测试按钮,第二次是新增的
11
- * 契约体检按钮(上一版修复只挂在 `.dshr-compActions` 上,新按钮在另一个容器里,
12
- * 规则没盖到)。症状完全一样、且不报任何错。
13
- *
14
- * 修法是给每个动作行挂 `dshr-compBtnRow`,由一条 (2,1) 的规则统一还原字色。
15
- * (这个类名特意**不是** `dshr-compActions` 的前缀:否则下面按子串计数会把它
16
- * 一起算进去,判据自己就成了摆设。)
17
- * 那么「新增按钮忘了挂这个类」就会退化成同样的黑底黑字 —— 这条判据就是拦它的。
18
- */
19
- import test from 'node:test'
20
- import assert from 'node:assert/strict'
21
- import { readFileSync } from 'node:fs'
22
- import { fileURLToPath } from 'node:url'
23
-
24
- const panel = readFileSync(fileURLToPath(new URL('./ExtTestPanel.tsx', import.meta.url)), 'utf8')
25
- const css = readFileSync(fileURLToPath(new URL('./router.css', import.meta.url)), 'utf8')
26
-
27
- test('面板里每个 Button 都在一个带 dshr-compBtnRow 的动作行里', () => {
28
- // 一个动作行可以放多个按钮(「只读体检」+「深度体检」并排),所以判据是
29
- // **每个 Button 在源码里都处于某个 dshr-compBtnRow 容器之内** —— 早期版本
30
- // 用「按钮数 == 行数」来近似,被并排两个按钮的布局直接顶红了(这正是它的价值:
31
- // 假设一旦不成立就会暴露,而不是继续绿)。
32
- const opens = [...panel.matchAll(/<div className="([^"]*)">/g)].map((m) => m[1])
33
- const rows = opens.filter((c) => (c ?? '').includes('dshr-compBtnRow')).length
34
- assert.ok(rows > 0, '面板里应当有动作行(这条判据才有意义)')
35
- const buttons = [...panel.matchAll(/<Button\b/g)]
36
- assert.ok(buttons.length > 0, '面板里应当有 Button')
37
- for (const b of buttons) {
38
- const before = panel.slice(0, b.index)
39
- // 取该 Button 之前最近一个开着的 <div>,看它是不是动作行
40
- const nearest = [...before.matchAll(/<div className="([^"]*)">/g)].pop()
41
- assert.ok((nearest?.[1] ?? '').includes('dshr-compBtnRow'),
42
- `有一个 Button 不在任何 dshr-compBtnRow 动作行里(最近的容器:${nearest?.[1] ?? '无'})—— 会变回黑底黑字`)
43
- }
44
- })
45
-
46
- test('那条字色规则挂在共用类上(不能再写死某个容器)', () => {
47
- assert.match(css, /\.dshr-comp \.dshr-compBtnRow button\s*\{/,
48
- '字色规则必须挂在 .dshr-compBtnRow 上:写死某个容器的话,下次新增按钮换个容器就漏')
49
- assert.match(css, /\.dshr-comp \.dshr-compBtnRow button\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary-foreground\)/,
50
- '字色仍由原语那个 token 决定,不自己另挑一个颜色')
51
- })
52
-
53
- test('规则特异性高于那条兜底(不依赖源码顺序)', () => {
54
- // 兜底 = `.dshr-settings button` (1,1);本规则 = `.dshr-comp .dshr-compBtnRow button` (2,1)
55
- const rule = css.match(/(\.[\w-]+)\s+(\.[\w-]+)\s+button\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary-foreground\)/)
56
- assert.ok(rule !== null, '找不到那条字色规则')
57
- assert.notEqual(rule[1], '.dshr-settings', '不能和兜底规则同容器 —— 同特异性就变成靠源码顺序了')
58
- })
@@ -1,58 +0,0 @@
1
- /**
2
- * 插件自检面板的**版面**判据 —— 不是像素级的样式测试,是「结构上别退回去」。
3
- *
4
- * 背景:核心侧那两项(模型启用状态 / 全部启用禁用)原先单独画了个带框的区块
5
- * (`.dshr-compCore`,加粗、垫底色、单独一栏)。看着比真问题还显眼,而它们本来就是
6
- * 体检结果的一部分,用户要的是「一眼扫完」。所以并进契约成员那一份列表,同一种行。
7
- */
8
- import test from 'node:test'
9
- import assert from 'node:assert/strict'
10
- import { readFileSync } from 'node:fs'
11
- import { fileURLToPath } from 'node:url'
12
-
13
- const panel = readFileSync(fileURLToPath(new URL('./ExtTestPanel.tsx', import.meta.url)), 'utf8')
14
- const css = readFileSync(fileURLToPath(new URL('./router.css', import.meta.url)), 'utf8')
15
-
16
- test('核心侧那两项和契约成员同构:不另起区块', () => {
17
- assert.equal(/className="dshr-compCore"/.test(panel), false,
18
- '核心侧不该有自己的区块 —— 要和契约成员用同一份列表、同一种行')
19
- assert.equal(/\.dshr-compCore\s*\{/.test(css), false, '核心侧区块的样式也该删掉')
20
- })
21
-
22
- test('「模型启用状态」和「全部启用/禁用」都渲染成 dshr-compMember 行', () => {
23
- const rowCount = (panel.match(/className="dshr-compMember"/g) ?? []).length
24
- assert.ok(rowCount >= 3, '至少要有:模型启用状态、全部启用禁用、契约成员各自的行')
25
- assert.match(panel, /模型启用状态/, '模型启用状态要在面板上出现')
26
- assert.match(panel, /report\.core\.operations\.map/, '全部启用/禁用由 core.operations 渲染')
27
- })
28
-
29
- test('面板顶部说明是一句话(不是写给维护者的长篇)', () => {
30
- const intro = panel.match(/<p className="dshr-compIntro">([\s\S]*?)<\/p>/g) ?? []
31
- for (const block of intro) {
32
- const text = block.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
33
- assert.ok(text.length <= 60, `说明要一句话(现在 ${text.length} 字):${text}`)
34
- }
35
- })
36
-
37
- test('两节各有一个标题,两个按钮都叫「测试」(否则分不清点的是哪一个)', () => {
38
- // 两个按钮同名是有意的(用户就是要一个统一的动词),代价是两节必须有标题来区分
39
- assert.match(panel, /<h4 className="dshr-compSectionTitle">连接测试<\/h4>/, '第一节要有标题')
40
- assert.match(panel, /<h4 className="dshr-compSectionTitle">插件测试<\/h4>/, '第二节叫「插件测试」')
41
- assert.equal((panel.match(/'测试'|'测试中…'/g) ?? []).length, 4,
42
- '两节各一对(常态 + 进行中)')
43
- assert.equal(panel.includes('跑一次访问测试'), false, '旧的按钮长称别回来')
44
- assert.equal(panel.includes('跑一次出厂体检'), false, '旧的按钮长称别回来')
45
- assert.equal(panel.includes('插件契约体检'), false, '旧的节名别回来')
46
- })
47
-
48
- test('报告头只给数字,不给判词(合格/不合格该由看报告的人自己下)', () => {
49
- for (const word of ['可以出厂', '不合格', '有未验项', '结论']) {
50
- assert.equal(panel.includes(word), false, `判词「${word}」不该出现在面板上`)
51
- }
52
- assert.equal(panel.includes('dshr-compVerdict'), false, '徽标整块删掉')
53
- assert.equal(/\.dshr-compVerdict/.test(css), false, '徽标样式也删掉')
54
- // 数字要够:实跑数与「未验」都不能少 —— 没条件验的项混进「通过」就是撒谎
55
- for (const field of ['total', 'implemented', 'ran', 'ok', 'fail', 'unverified', 'absent', 'bulk']) {
56
- assert.match(panel, new RegExp(`summary\\.${field}`), `汇总里要能看到 ${field}`)
57
- }
58
- })
@@ -1,44 +0,0 @@
1
- /**
2
- * 扩展开放详情面板的注册表测试。
3
- *
4
- * 为什么要测:注册表错了**不报任何错** —— 详情页只是安静地退回通用只读页,
5
- * 用户看到的是「面板没出来」,没有任何线索指向「面板没被登记」。所以「登记 →
6
- * 取用 → 清理」这条链要钉住。
7
- */
8
- import test from 'node:test'
9
- import assert from 'node:assert/strict'
10
- import { registerExtPanel, hasExtPanel, extPanel } from './ext-panels.ts'
11
-
12
- const A = (): null => null
13
- const B = (): null => null
14
-
15
- test('登记后能按扩展 id 取到同一个组件', () => {
16
- const dispose = registerExtPanel('t-a', A)
17
- assert.equal(hasExtPanel('t-a'), true)
18
- assert.equal(extPanel('t-a'), A, '取到的必须就是登记进去的那个(两处详情页共用同一份)')
19
- dispose()
20
- })
21
-
22
- test('没登记的扩展取不到(调用方据此走通用只读页)', () => {
23
- assert.equal(hasExtPanel('t-missing'), false)
24
- assert.equal(extPanel('t-missing'), undefined)
25
- })
26
-
27
- test('重复登记同 id:后者顶掉前者(apply 可能跑多次)', () => {
28
- const first = registerExtPanel('t-b', A)
29
- const second = registerExtPanel('t-b', B)
30
- assert.equal(extPanel('t-b'), B)
31
- first()
32
- assert.equal(extPanel('t-b'), B, '清理旧的那次不该把新登记的也删掉')
33
- second()
34
- assert.equal(extPanel('t-b'), undefined)
35
- })
36
-
37
- test('清理只删自己那次登记', () => {
38
- const first = registerExtPanel('t-c', A)
39
- const disposeB = registerExtPanel('t-c', B)
40
- first()
41
- assert.equal(extPanel('t-c'), B, '旧登记被顶掉后,它的清理不该误删新登记')
42
- disposeB()
43
- assert.equal(hasExtPanel('t-c'), false)
44
- })
@@ -1,49 +0,0 @@
1
- /**
2
- * 扩展自带详情面板的注册表 —— 客户端,按扩展 id 索引。
3
- *
4
- * 为什么要有这个:`router.ext` 契约里只有 `id / name / description / icon /
5
- * getState`,**没有「自带详情内容」的口子**。于是扩展详情页(设置 → 路由 → 扩展 →
6
- * 点开卡片)只能是一张写死的只读页:名字、id、状态、一段说明,扩展自己想放的
7
- * 东西(要选的模型、要跑的测试、要看的诊断)**没地方放**。
8
- *
9
- * 本表就是那个口子:扩展(按扩展 id)登记一个组件,详情页优先渲染它。
10
- *
11
- * 两处详情页共用它,两处都以扩展 id 为键,所以**同一个面板注册一次**:
12
- * - 设置 → 路由 → 扩展:卡片点开的详情页(`ExtDetail`)
13
- * - 设置 → 插件 → dsh-router-core:「包含的组件」里那一行的详情页
14
- * (宿主的 `plugins.row.config` 座位,key 是 `<包名>#<行 id>`,与扩展 id 不同,
15
- * 所以那一处由注册处用一个薄适配器转接,不在这里登记第二份)
16
- *
17
- * 这是**客户端**注册表(不是 cordis service):扩展的服务端对象活在宿主进程里,
18
- * 面板是浏览器里的组件,两边不在一个地址空间。模块级 Map 够用 —— 面板在单个
19
- * client bundle 里,模块只有一份实例。
20
- */
21
- import type { FC } from 'react'
22
-
23
- /** 扩展 id → 它的详情面板。 */
24
- const panels = new Map<string, FC>()
25
-
26
- /**
27
- * 给某个扩展登记详情面板。同 id 后登记的**顶掉**先登记的(热重载/重复 apply
28
- * 时的常见情形);返回清理函数。
29
- *
30
- * @param extId - 扩展 id(`router.ext` 表里的键)
31
- * @param panel - 详情面板组件。**不收 props**:面板的渲染点只有详情页一处
32
- * (`ExtDetail`),没有第二种调用形状。
33
- */
34
- export function registerExtPanel(extId: string, panel: FC): () => void {
35
- panels.set(extId, panel)
36
- return () => {
37
- if (panels.get(extId) === panel) panels.delete(extId)
38
- }
39
- }
40
-
41
- /** 某个扩展是否自带详情面板。 */
42
- export function hasExtPanel(extId: string): boolean {
43
- return panels.has(extId)
44
- }
45
-
46
- /** 取某个扩展的详情面板;没登记返回 undefined(调用方据此走通用只读页)。 */
47
- export function extPanel(extId: string): FC | undefined {
48
- return panels.get(extId)
49
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * 「只列可用模型」这条判据的测试。
3
- *
4
- * 为什么要有:错了不会抛异常、不会白屏,只是下拉里多了几个用户明明关掉的模型 ——
5
- * 挑中一个去测,测通了也不代表配置是对的,而用户看不出自己挑错了。
6
- */
7
- import test from 'node:test'
8
- import assert from 'node:assert/strict'
9
- import { modelChoice } from './model-choice.ts'
10
-
11
- test('只列用户没停用的模型(端点给的是全部模型)', () => {
12
- const c = modelChoice([
13
- { id: 'a', enabled: true },
14
- { id: 'b', enabled: false },
15
- { id: 'c', enabled: true },
16
- { id: 'd', enabled: false },
17
- ])
18
- assert.deepEqual(c.ids, ['a', 'c'])
19
- assert.equal(c.empty, undefined)
20
- })
21
-
22
- test('保持端点给的顺序(端点已按优先级排好,重排会毁掉它)', () => {
23
- const c = modelChoice([{ id: 'z', enabled: true }, { id: 'a', enabled: true }])
24
- assert.deepEqual(c.ids, ['z', 'a'])
25
- })
26
-
27
- test('缺 enabled 按「未停用」读 —— 不凭空把模型藏起来', () => {
28
- const c = modelChoice([{ id: 'a' }, { id: 'b', enabled: false }])
29
- assert.deepEqual(c.ids, ['a'])
30
- })
31
-
32
- test('一个模型都没有 vs 全被停用:两种空得指去不同的地方', () => {
33
- assert.equal(modelChoice([]).empty, 'none', '没模型 → 该去拉取')
34
- assert.equal(modelChoice([{ id: 'a', enabled: false }]).empty, 'all-disabled', '有模型但全关 → 该去开启')
35
- })
36
-
37
- test('全停用时 ids 为空,不会把停用的模型漏进下拉', () => {
38
- const c = modelChoice([{ id: 'a', enabled: false }, { id: 'b', enabled: false }])
39
- assert.deepEqual(c.ids, [])
40
- assert.equal(c.empty, 'all-disabled')
41
- })