@mzzsfy/dsh-maintain 0.8.0 → 0.9.0
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/README.md +4 -3
- package/package.json +1 -1
- package/src/client.js +172 -27
- package/src/core.mjs +140 -20
- package/src/index.js +40 -9
- package/test/client-id.test.mjs +17 -0
- package/test/core.test.mjs +51 -0
- package/test/parity.test.mjs +128 -0
- package/test/release-notes.test.mjs +165 -0
- package/test/restart-ready.test.mjs +2 -1
- package/test/route.test.mjs +271 -22
package/src/index.js
CHANGED
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
TARGET_PACKAGE,
|
|
13
13
|
TAG_PLACEHOLDER,
|
|
14
14
|
buildUpgradeCommand,
|
|
15
|
+
extractPinnedVersion,
|
|
15
16
|
classifyUpgradeFailure,
|
|
16
17
|
fetchDistTags,
|
|
18
|
+
fetchReleaseNotes,
|
|
17
19
|
isValidChannelName,
|
|
18
20
|
isValidRegistryBase,
|
|
19
21
|
isVersionPendingRestart,
|
|
@@ -22,7 +24,6 @@ import {
|
|
|
22
24
|
resolveHostVersion,
|
|
23
25
|
UPGRADE_FAIL_FILE_LOCKED,
|
|
24
26
|
UPGRADE_FAIL_TRANSIENT_NETWORK,
|
|
25
|
-
VERDICT_UP_TO_DATE,
|
|
26
27
|
} from './core.mjs'
|
|
27
28
|
import { runUpgrade } from './upgrade.mjs'
|
|
28
29
|
import { detectRuntimeEnv, RUNTIME_KINDS } from './runtime.mjs'
|
|
@@ -36,6 +37,9 @@ export const inject = ['webServer']
|
|
|
36
37
|
const NAMESPACE = 'maintain'
|
|
37
38
|
|
|
38
39
|
const CHECK_TIMEOUT_MS = 20 * 1000
|
|
40
|
+
// 版本更新内容缓存上限:长期运行中通道切换持续产生新键,无上限则缓存无界;
|
|
41
|
+
// 超限删最旧(Map 迭代序即插入序,命中不重插);导出仅供测试
|
|
42
|
+
export const RELEASE_NOTES_CACHE_MAX = 20
|
|
39
43
|
// 升级命令超时:client 浮条观察上限与此对拍(parity 锁定),强杀宽限另计
|
|
40
44
|
export const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000
|
|
41
45
|
// 响应发出到执行退出的延迟:保证浏览器收到 200 并进入重启等待态,进程才离场;
|
|
@@ -226,6 +230,7 @@ export const API_PATHS = Object.freeze({
|
|
|
226
230
|
REGISTRY_BASE: '/api/maintain/registry-base',
|
|
227
231
|
UPGRADE: '/api/maintain/upgrade',
|
|
228
232
|
RESTART: '/api/maintain/restart',
|
|
233
|
+
RELEASE_NOTES: '/api/maintain/release-notes',
|
|
229
234
|
})
|
|
230
235
|
|
|
231
236
|
// 宿主进程启动时刻:重启探测的第三代际信号(容器内 pid 恒 1 且零失联时 pid 信号失效)
|
|
@@ -396,6 +401,8 @@ export function apply(ctx) {
|
|
|
396
401
|
let nextDueAt = null
|
|
397
402
|
let restartScheduled = false
|
|
398
403
|
let autoRestartScheduled = false
|
|
404
|
+
// 版本更新内容缓存:键为通道最新版原始值(未归一),值为 {url,publishedAt,body};仅成功结果入缓存
|
|
405
|
+
const releaseNotesCache = new Map()
|
|
399
406
|
|
|
400
407
|
function runCheck() {
|
|
401
408
|
if (checkInFlight) return checkInFlight
|
|
@@ -490,8 +497,12 @@ export function apply(ctx) {
|
|
|
490
497
|
const config = readSettings(ctx)
|
|
491
498
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
492
499
|
const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
|
|
500
|
+
// 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
|
|
501
|
+
const pinnedVersion = extractPinnedVersion(command)
|
|
493
502
|
const last = {
|
|
494
503
|
command,
|
|
504
|
+
// 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
|
|
505
|
+
pinnedVersion: pinnedVersion === config.channel ? null : pinnedVersion,
|
|
495
506
|
startedAt: Date.now(),
|
|
496
507
|
ok: false,
|
|
497
508
|
finishedAt: null,
|
|
@@ -604,6 +615,7 @@ export function apply(ctx) {
|
|
|
604
615
|
previousVersion: last.previousVersion,
|
|
605
616
|
installedVersion: last.installedVersion,
|
|
606
617
|
channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
|
|
618
|
+
pinnedVersion: last.pinnedVersion,
|
|
607
619
|
})
|
|
608
620
|
last.stale = freshness.stale
|
|
609
621
|
last.reason = freshness.reason
|
|
@@ -756,14 +768,6 @@ export function apply(ctx) {
|
|
|
756
768
|
sendJson(res, 409, { error: '存在未过期的升级锁(可能有残留升级子进程),等待锁过期或删除 ' + UPGRADE_LOCK_PATH })
|
|
757
769
|
return
|
|
758
770
|
}
|
|
759
|
-
// 防降级:运行版本已达通道最新时拒绝(重装旧版即降级,对齐 no-newer-version);
|
|
760
|
-
// 信息不足(verdict unknown)放行,宽松不误拒
|
|
761
|
-
const judged = judgeNow()
|
|
762
|
-
if (judged.verdict === VERDICT_UP_TO_DATE) {
|
|
763
|
-
audit('upgrade', 'rejected', 'reason=up-to-date')
|
|
764
|
-
sendJson(res, 409, { error: '当前已是通道最新版,无需升级;重装请走命令行' })
|
|
765
|
-
return
|
|
766
|
-
}
|
|
767
771
|
// 活跃工作门控:升级不可越(agent 会话/job 在跑时强行升级会被中断)
|
|
768
772
|
const activeWork = collectActiveWork(ctx)
|
|
769
773
|
if (activeWork.total > 0) {
|
|
@@ -820,6 +824,33 @@ export function apply(ctx) {
|
|
|
820
824
|
scheduleHostExit({ detail: 'forced=' + (requestBody.force === true), delayMs: RESTART_DELAY_MS })
|
|
821
825
|
}),
|
|
822
826
|
},
|
|
827
|
+
{
|
|
828
|
+
path: API_PATHS.RELEASE_NOTES,
|
|
829
|
+
handler: route('GET', {}, async (req, res) => {
|
|
830
|
+
// 关机窗口内不发上游请求:退出延迟 2 秒小于上游超时,与 refresh 同纪律
|
|
831
|
+
if (restartScheduled) {
|
|
832
|
+
sendJson(res, 409, { error: '重启已调度,禁止查询更新内容' })
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
// 缺省恒取追踪通道最新版;上游标签值非 semver 时 buildReleaseTag 抛错归一 400
|
|
836
|
+
const version = judgeNow().channelLatest
|
|
837
|
+
if (!version) {
|
|
838
|
+
sendJson(res, 400, { error: '通道最新版未知,请先检查更新' })
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
const cached = releaseNotesCache.get(version)
|
|
842
|
+
if (cached) {
|
|
843
|
+
sendJson(res, 200, { version, url: cached.url, publishedAt: cached.publishedAt, body: cached.body })
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
const notes = await fetchReleaseNotes({ version, timeoutMs: CHECK_TIMEOUT_MS })
|
|
847
|
+
releaseNotesCache.set(version, notes)
|
|
848
|
+
while (releaseNotesCache.size > RELEASE_NOTES_CACHE_MAX) {
|
|
849
|
+
releaseNotesCache.delete(releaseNotesCache.keys().next().value)
|
|
850
|
+
}
|
|
851
|
+
sendJson(res, 200, { version, url: notes.url, publishedAt: notes.publishedAt, body: notes.body })
|
|
852
|
+
}),
|
|
853
|
+
},
|
|
823
854
|
]
|
|
824
855
|
|
|
825
856
|
for (const route of routes) {
|
package/test/client-id.test.mjs
CHANGED
|
@@ -13,4 +13,21 @@ test('client.js 注册 id 为完整包名', () => {
|
|
|
13
13
|
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
14
14
|
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
15
|
assert.equal(match[1], name)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('client.js 样式注入自带 data-plugin', () => {
|
|
19
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
20
|
+
const injections = source.split("createElement('style')").length - 1
|
|
21
|
+
assert.ok(injections > 0, 'client.js 无样式注入点')
|
|
22
|
+
const marked = source.split(`setAttribute('data-plugin', '${name}')`).length - 1
|
|
23
|
+
assert.equal(marked, injections, '每个样式注入点都必须自带 data-plugin(值=注册包名),防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('client.js 样式渲染自带 data-plugin', () => {
|
|
27
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
28
|
+
// 渲染点计数用 ('style', { 精确命中 React 形态,排除 DOM 形态的 createElement('style')
|
|
29
|
+
const renders = source.split("('style', {").length - 1
|
|
30
|
+
assert.ok(renders > 0, 'client.js 无样式渲染点')
|
|
31
|
+
const marked = source.split("('style', { 'data-plugin'").length - 1
|
|
32
|
+
assert.equal(marked, renders, '每个样式渲染点都必须携带 data-plugin(值=注册包名),防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
|
|
16
33
|
})
|
package/test/core.test.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
gtSemver,
|
|
7
7
|
judgeVersion,
|
|
8
8
|
judgeUpgradeFreshness,
|
|
9
|
+
extractPinnedVersion,
|
|
9
10
|
isVersionPendingRestart,
|
|
10
11
|
classifyUpgradeFailure,
|
|
11
12
|
buildUpgradeCommand,
|
|
@@ -263,6 +264,25 @@ test('分类:输入字段缺省容忍不抛错', () => {
|
|
|
263
264
|
assert.ok(typeof result.reason === 'string')
|
|
264
265
|
})
|
|
265
266
|
|
|
267
|
+
// 钉定版本提取:取命令中最后一个合法 semver token(包说明符通常在尾部);
|
|
268
|
+
// 通道名/@latest/无版本形态天然不命中;shell 粘连(&&、;、>)在 token 边界截断
|
|
269
|
+
test('钉定版本:提取命令中的 @x.y.z token,取最后合法者', () => {
|
|
270
|
+
assert.equal(extractPinnedVersion('npm install -g @deepseek-ai/dsh@0.1.4'), '0.1.4')
|
|
271
|
+
assert.equal(extractPinnedVersion('npm install -g @deepseek-ai/dsh@0.1.4 --silent'), '0.1.4')
|
|
272
|
+
assert.equal(extractPinnedVersion('npm i -g @deepseek-ai/dsh@0.1.4 && echo ok'), '0.1.4')
|
|
273
|
+
assert.equal(extractPinnedVersion('@scope/pkg@1.2.3-rc.1'), '1.2.3-rc.1')
|
|
274
|
+
assert.equal(extractPinnedVersion('pkg@1.2.3+build.7'), '1.2.3+build.7')
|
|
275
|
+
assert.equal(extractPinnedVersion('a@1.0.0 b@2.0.0'), '2.0.0')
|
|
276
|
+
assert.equal(extractPinnedVersion('npm install -g @deepseek-ai/dsh@{tag}'), null)
|
|
277
|
+
assert.equal(extractPinnedVersion('npm install -g @deepseek-ai/dsh@latest'), null)
|
|
278
|
+
assert.equal(extractPinnedVersion('node -e "process.exit(0)"'), null)
|
|
279
|
+
assert.equal(extractPinnedVersion('pkg@0.1.4beta'), null)
|
|
280
|
+
assert.equal(extractPinnedVersion('pkg@01.2.3'), null)
|
|
281
|
+
assert.equal(extractPinnedVersion('@@1.0.0'), '1.0.0')
|
|
282
|
+
assert.equal(extractPinnedVersion(''), null)
|
|
283
|
+
assert.equal(extractPinnedVersion(null), null)
|
|
284
|
+
})
|
|
285
|
+
|
|
266
286
|
// 升级后磁盘版本复读判定:stale=版本未前进或未达通道目标;信息缺失宽松不误报。
|
|
267
287
|
test('复读:版本前进且达通道目标判 fresh', () => {
|
|
268
288
|
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '2.0.0', channelLatest: '2.0.0' }), { stale: false, reason: null })
|
|
@@ -276,6 +296,37 @@ test('复读:磁盘版本未前进或回退判 stale', () => {
|
|
|
276
296
|
assert.match(same.reason, /未前进/)
|
|
277
297
|
const rollback = judgeUpgradeFreshness({ previousVersion: '2.0.0', installedVersion: '1.9.0', channelLatest: '2.0.0' })
|
|
278
298
|
assert.equal(rollback.stale, true)
|
|
299
|
+
// 目标==来版属重装/回退意图:失败原因按"未落到通道目标"口径,不得走"未前进"文案
|
|
300
|
+
assert.match(rollback.reason, /通道目标/)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// 重装场景(通道目标与来版相同):版本不前进是预期结果,不得误报 stale
|
|
304
|
+
test('复读:重装(目标==来版)版本不变判 fresh', () => {
|
|
305
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '1.0.0', channelLatest: '1.0.0' }), { stale: false, reason: null })
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('复读:重装场景装出更低版本判 stale(未落到通道目标)', () => {
|
|
309
|
+
const result = judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '0.9.0', channelLatest: '1.0.0' })
|
|
310
|
+
assert.equal(result.stale, true)
|
|
311
|
+
assert.match(result.reason, /通道目标/)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// 切通道回退(目标<来版):恰落到通道目标即达成;静默未动(磁盘仍是来版)必须拦下
|
|
315
|
+
test('复读:回退落到通道目标判 fresh,静默未动判 stale', () => {
|
|
316
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '0.2.0-rc.1', installedVersion: '0.1.5', channelLatest: '0.1.5' }), { stale: false, reason: null })
|
|
317
|
+
const silent = judgeUpgradeFreshness({ previousVersion: '0.2.0-rc.1', installedVersion: '0.2.0-rc.1', channelLatest: '0.1.5' })
|
|
318
|
+
assert.equal(silent.stale, true)
|
|
319
|
+
assert.match(silent.reason, /通道目标/)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// 钉定版本(命令含 @x.y.z):意图=精确版本,装到位即达成,通道目标不参与对拍
|
|
323
|
+
test('复读:钉定版本装到位判 fresh,装偏判 stale', () => {
|
|
324
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '0.1.5', installedVersion: '0.1.4', channelLatest: '0.1.5', pinnedVersion: '0.1.4' }), { stale: false, reason: null })
|
|
325
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '1.0.0', channelLatest: '2.0.0', pinnedVersion: '1.0.0' }), { stale: false, reason: null })
|
|
326
|
+
const missed = judgeUpgradeFreshness({ previousVersion: '0.1.5', installedVersion: '0.1.5', channelLatest: '0.1.5', pinnedVersion: '0.1.4' })
|
|
327
|
+
assert.equal(missed.stale, true)
|
|
328
|
+
assert.match(missed.reason, /指定版本/)
|
|
329
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '1.2.3-rc.1', channelLatest: '2.0.0', pinnedVersion: '1.2.3-rc.1' }), { stale: false, reason: null })
|
|
279
330
|
})
|
|
280
331
|
|
|
281
332
|
test('复读:前进但未达通道目标判 stale', () => {
|
package/test/parity.test.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
10
10
|
import {
|
|
11
11
|
shouldReloadAfterRestart as coreShouldReload,
|
|
12
12
|
isValidRegistryBase as coreIsValidRegistryBase,
|
|
13
|
+
extractPinnedVersion as coreExtractPinnedVersion,
|
|
13
14
|
VERDICT_OUTDATED,
|
|
14
15
|
VERDICT_UP_TO_DATE,
|
|
15
16
|
VERDICT_UNKNOWN,
|
|
@@ -40,6 +41,113 @@ function extractNumberConst(name) {
|
|
|
40
41
|
|
|
41
42
|
const clientShouldReload = extractLogic('shouldReloadAfterRestart')
|
|
42
43
|
const clientIsValidRegistryBase = extractLogic('isValidRegistryBase')
|
|
44
|
+
const clientExtractPinnedVersion = extractLogic('extractPinnedVersion')
|
|
45
|
+
// 弹窗文案分支依赖钉定检测:直接注 core 实现,顺带证明两侧检测可互换
|
|
46
|
+
const clientBuildInstallChangeLine = extractLogic('buildInstallChangeLine', {
|
|
47
|
+
extractPinnedVersion: coreExtractPinnedVersion,
|
|
48
|
+
VERDICT_OUTDATED,
|
|
49
|
+
VERDICT_UNKNOWN,
|
|
50
|
+
})
|
|
51
|
+
const clientBuildInstallVersionHint = extractLogic('buildInstallVersionHint')
|
|
52
|
+
|
|
53
|
+
// 命令钉定 token 提取:双实现对拍 + 语义锚(尾部取最后合法者/通道名与 @latest 不命中/shell 粘连截断)
|
|
54
|
+
const PINNED_CASES = [
|
|
55
|
+
{ command: 'npm install -g @deepseek-ai/dsh@0.1.4', expected: '0.1.4' },
|
|
56
|
+
{ command: 'npm install -g @deepseek-ai/dsh@0.1.4 --silent', expected: '0.1.4' },
|
|
57
|
+
{ command: 'npm i -g @deepseek-ai/dsh@0.1.4 && echo ok', expected: '0.1.4' },
|
|
58
|
+
{ command: '@scope/pkg@1.2.3-rc.1', expected: '1.2.3-rc.1' },
|
|
59
|
+
{ command: 'pkg@1.2.3+build.7', expected: '1.2.3+build.7' },
|
|
60
|
+
{ command: 'a@1.0.0 b@2.0.0', expected: '2.0.0' },
|
|
61
|
+
{ command: 'npm install -g @deepseek-ai/dsh@{tag}', expected: null },
|
|
62
|
+
{ command: 'npm install -g @deepseek-ai/dsh@latest', expected: null },
|
|
63
|
+
{ command: 'node -e "process.exit(0)"', expected: null },
|
|
64
|
+
{ command: 'pkg@0.1.4beta', expected: null },
|
|
65
|
+
{ command: 'pkg@01.2.3', expected: null },
|
|
66
|
+
{ command: '@@1.0.0', expected: '1.0.0' },
|
|
67
|
+
{ command: '', expected: null },
|
|
68
|
+
{ command: null, expected: null },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
test('parity: extractPinnedVersion 双实现全场景一致', () => {
|
|
72
|
+
for (const { command, expected } of PINNED_CASES) {
|
|
73
|
+
assert.equal(coreExtractPinnedVersion(command), expected, 'core ' + JSON.stringify(command))
|
|
74
|
+
assert.equal(clientExtractPinnedVersion(command), expected, 'client ' + JSON.stringify(command))
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// 弹窗版本变更行:钉定优先(含改回 {tag} 指引),通道语义按 未知/重装/升级/无法判定/回退 分支
|
|
79
|
+
test('parity: buildInstallChangeLine 分支行为', () => {
|
|
80
|
+
const base = { channel: 'latest', channelLatest: null, runningVersion: null, installedVersion: null, verdict: VERDICT_UNKNOWN }
|
|
81
|
+
// 钉定:模板含 @x.y.z 即按指定版本表述,并提示改回 {tag}
|
|
82
|
+
const pinned = clientBuildInstallChangeLine(
|
|
83
|
+
{ ...base, channelLatest: '0.1.5', runningVersion: '0.1.5' },
|
|
84
|
+
'npm install -g @deepseek-ai/dsh@0.1.4')
|
|
85
|
+
assert.ok(pinned.includes('0.1.4'), pinned)
|
|
86
|
+
assert.ok(pinned.includes('指定版本'), pinned)
|
|
87
|
+
assert.ok(pinned.includes('{tag}'), pinned)
|
|
88
|
+
// 通道目标未知
|
|
89
|
+
assert.ok(clientBuildInstallChangeLine({ ...base }, 'npm install -g @deepseek-ai/dsh@latest').includes('未知'))
|
|
90
|
+
// 重装:目标==运行;已装领先(待生效)时提示将被替换
|
|
91
|
+
const reinstall = clientBuildInstallChangeLine(
|
|
92
|
+
{ ...base, channelLatest: '0.1.5', runningVersion: '0.1.5', installedVersion: '0.2.0-rc.1', verdict: VERDICT_UP_TO_DATE },
|
|
93
|
+
'npm install -g @deepseek-ai/dsh@latest')
|
|
94
|
+
assert.ok(reinstall.includes('重装'), reinstall)
|
|
95
|
+
assert.ok(reinstall.includes('0.2.0-rc.1'), reinstall)
|
|
96
|
+
const reinstallClean = clientBuildInstallChangeLine(
|
|
97
|
+
{ ...base, channelLatest: '0.1.5', runningVersion: '0.1.5', installedVersion: '0.1.5', verdict: VERDICT_UP_TO_DATE },
|
|
98
|
+
'npm install -g @deepseek-ai/dsh@latest')
|
|
99
|
+
assert.ok(!reinstallClean.includes('替换'), reinstallClean)
|
|
100
|
+
// 升级
|
|
101
|
+
const upgrade = clientBuildInstallChangeLine(
|
|
102
|
+
{ ...base, channelLatest: '0.2.0', runningVersion: '0.1.5', verdict: VERDICT_OUTDATED },
|
|
103
|
+
'npm install -g @deepseek-ai/dsh@latest')
|
|
104
|
+
assert.ok(upgrade.includes('升级') && upgrade.includes('0.2.0'), upgrade)
|
|
105
|
+
// 无法判定:unknown 且目标非空,归因中性(目标非法/运行非法两种来源)
|
|
106
|
+
const undetermined = clientBuildInstallChangeLine(
|
|
107
|
+
{ ...base, channelLatest: '0.2.0', runningVersion: 'dev-main', verdict: VERDICT_UNKNOWN },
|
|
108
|
+
'npm install -g @deepseek-ai/dsh@latest')
|
|
109
|
+
assert.ok(undetermined.includes('无法判定'), undetermined)
|
|
110
|
+
// 回退:up-to-date 且目标!=运行,表述不断言方向(semver 等值字面不同时同样成立)
|
|
111
|
+
const rollback = clientBuildInstallChangeLine(
|
|
112
|
+
{ ...base, channelLatest: '0.1.5', runningVersion: '0.2.0-rc.1', verdict: VERDICT_UP_TO_DATE },
|
|
113
|
+
'npm install -g @deepseek-ai/dsh@latest')
|
|
114
|
+
assert.ok(rollback.includes('不高于'), rollback)
|
|
115
|
+
assert.ok(rollback.includes('0.1.5'), rollback)
|
|
116
|
+
// semver 形态通道名:命令中的 @1.0.0 是 {tag} 展开产物,按通道语义(重装)而非钉定表述
|
|
117
|
+
const semverChannel = clientBuildInstallChangeLine(
|
|
118
|
+
{ ...base, channel: '1.0.0', channelLatest: '1.0.0', runningVersion: '1.0.0', installedVersion: '1.0.0', verdict: VERDICT_UP_TO_DATE },
|
|
119
|
+
'npm install -g @deepseek-ai/dsh@1.0.0')
|
|
120
|
+
assert.ok(!semverChannel.includes('指定版本'), semverChannel)
|
|
121
|
+
assert.ok(semverChannel.includes('重装'), semverChannel)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// 弹窗当前版本行:缺失回退"未知"(渲染级防回归——该行曾在组件内联,作用域变量删除后渲染即崩)
|
|
125
|
+
test('parity: buildInstallVersionHint 缺失回退未知', () => {
|
|
126
|
+
assert.equal(clientBuildInstallVersionHint({ runningVersion: '0.1.5', installedVersion: '0.1.5' }), '版本变更:当前 运行 0.1.5 / 已装 0.1.5')
|
|
127
|
+
assert.equal(clientBuildInstallVersionHint({ installedVersion: '0.1.5' }), '版本变更:当前 运行 未知 / 已装 0.1.5')
|
|
128
|
+
assert.equal(clientBuildInstallVersionHint({}), '版本变更:当前 运行 未知 / 已装 未知')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// 弹窗渲染冒烟:整函数工厂化执行,自由变量全桩注入,断言不抛错且关键行在树中。
|
|
132
|
+
// 动态文案(版本行/变更行)已抽 LOGIC 段,此测试兜住组件体对桩外符号的引用回归
|
|
133
|
+
test('渲染冒烟: UpgradeDialog 工厂化执行不抛错且含版本变更行', () => {
|
|
134
|
+
const match = CLIENT_SOURCE.match(/function UpgradeDialog\(props\) \{[\s\S]*?\n\}/)
|
|
135
|
+
assert.ok(match, 'client.js 缺少 UpgradeDialog')
|
|
136
|
+
const calls = []
|
|
137
|
+
const h = (type, propsArg, ...children) => { calls.push(String(type)); return { type, propsArg, children } }
|
|
138
|
+
const useState = (init) => [init, () => {}]
|
|
139
|
+
const Switch = (propsArg) => h('label', propsArg)
|
|
140
|
+
const dialog = new Function('h', 'useState', 'Switch', 'DEFAULT_UPGRADE_TEMPLATE', 'AUTO_RESTART_DELAY_SEC',
|
|
141
|
+
'RUNTIME_KIND_MANUAL', 'buildInstallChangeLine', 'buildInstallVersionHint',
|
|
142
|
+
'return (' + match[0] + ')')(
|
|
143
|
+
h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3, 'manual-start-likely',
|
|
144
|
+
(status, command) => '变更:' + command, (status) => '提示:' + status.runningVersion)
|
|
145
|
+
const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5', runtimeEnv: null }
|
|
146
|
+
const tree = dialog({ status, onCancel: () => {}, onConfirm: () => {} })
|
|
147
|
+
assert.equal(tree.type, 'div', '弹窗根为遮罩 div')
|
|
148
|
+
assert.ok(calls.some((type) => type === 'pre'), '命令 pre 节点应在树中')
|
|
149
|
+
assert.deepEqual(dialog({ status, onCancel: () => {}, onConfirm: () => {} }).type, 'div', '二次渲染稳定')
|
|
150
|
+
})
|
|
43
151
|
|
|
44
152
|
// prev/next 为 {lost,pid,bootAt} 快照;lost 强信号优先;bootAt 双侧齐备时以其为唯一
|
|
45
153
|
// 实例证据(自洽数据:不同进程 bootAt 必不同),pid 比对是 bootAt 缺失时的退化路径
|
|
@@ -140,6 +248,7 @@ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
|
|
|
140
248
|
REGISTRY_BASE: 'REGISTRY_BASE_URL',
|
|
141
249
|
UPGRADE: 'UPGRADE_URL',
|
|
142
250
|
RESTART: 'RESTART_URL',
|
|
251
|
+
RELEASE_NOTES: 'RELEASE_NOTES_URL',
|
|
143
252
|
}
|
|
144
253
|
for (const [hostKey, hostPath] of Object.entries(API_PATHS)) {
|
|
145
254
|
assert.equal(extractConst(CLIENT_KEY_BY_HOST_KEY[hostKey]), hostPath, 'API 路径漂移: ' + hostKey)
|
|
@@ -170,6 +279,13 @@ test('parity: npm 版本页链接与追踪包名同源', () => {
|
|
|
170
279
|
assert.ok(extractConst('NPM_VERSIONS_URL').includes(TARGET_PACKAGE), 'NPM_VERSIONS_URL 应包含 TARGET_PACKAGE 字面量')
|
|
171
280
|
})
|
|
172
281
|
|
|
282
|
+
// release 更新内容事实源在 GitHub Releases:client 兜底链接与 host 拉取仓库必须同源
|
|
283
|
+
import { RELEASE_REPO } from '../src/core.mjs'
|
|
284
|
+
|
|
285
|
+
test('parity: GitHub releases 兜底链接与 host 拉取仓库同源', () => {
|
|
286
|
+
assert.equal(extractConst('RELEASES_PAGE_URL'), 'https://github.com/' + RELEASE_REPO + '/releases')
|
|
287
|
+
})
|
|
288
|
+
|
|
173
289
|
test('parity: 落定补查宽限覆盖宿主自动重启调度延迟', () => {
|
|
174
290
|
// 落定拍与调度置位之间存在宿主侧 await 窗口:client 补查宽限必须不小于调度延迟 + 观察裕量
|
|
175
291
|
const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
|
|
@@ -185,3 +301,15 @@ test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重
|
|
|
185
301
|
assert.ok(CLIENT_SOURCE.includes('AbortSignal.timeout(UPGRADE_POLL_TIMEOUT_MS)'), '升级观察拍必须带请求超时')
|
|
186
302
|
assert.ok(CLIENT_SOURCE.includes('upgradeWatch.generation !== generation'), '升级观察拍 settle 后必须验代际')
|
|
187
303
|
})
|
|
304
|
+
|
|
305
|
+
test('源码契约: 安装入口全版本状态可点,verdict 不得回归为禁用门', () => {
|
|
306
|
+
// 重装修复与回退是面板显式意图:按钮与弹窗派生门控不得含 verdict 比较;
|
|
307
|
+
// VERDICT_UP_TO_DATE 仅允许出现两次(常量声明 + 结论徽章展示)
|
|
308
|
+
const occurrences = CLIENT_SOURCE.split('VERDICT_UP_TO_DATE').length - 1
|
|
309
|
+
assert.equal(occurrences, 2, 'VERDICT_UP_TO_DATE 只允许声明与徽章展示两处,禁用门回归即超限')
|
|
310
|
+
assert.ok(CLIENT_SOURCE.includes("}, '安装'),"), '升级按钮文案必须为「安装」')
|
|
311
|
+
assert.ok(CLIENT_SOURCE.includes("'确认安装'"), '弹窗标题必须为「确认安装」')
|
|
312
|
+
assert.ok(CLIENT_SOURCE.includes("'开始安装'"), '弹窗确认按钮必须为「开始安装」')
|
|
313
|
+
assert.ok(CLIENT_SOURCE.includes('重装'), '弹窗必须说明重装(修复)场景')
|
|
314
|
+
assert.ok(CLIENT_SOURCE.includes('回退'), '弹窗必须说明回退场景')
|
|
315
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// release notes 纯逻辑测试:标签构建、发布页地址构建、页面拉取与正文提取。
|
|
2
|
+
// 数据源:https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v<版本>(页面正文,规避 api.github.com 未认证限流)
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
buildReleaseTag,
|
|
9
|
+
buildReleaseNotesUrl,
|
|
10
|
+
fetchReleaseNotes,
|
|
11
|
+
extractReleaseNotesFromHtml,
|
|
12
|
+
RELEASE_REPO,
|
|
13
|
+
} from '../src/core.mjs'
|
|
14
|
+
|
|
15
|
+
// fetchReleaseNotes 走 response.body.getReader() 流式读取;mock 按单块提供全部字节
|
|
16
|
+
function streamBody(text) {
|
|
17
|
+
const chunks = [new TextEncoder().encode(text)]
|
|
18
|
+
return {
|
|
19
|
+
getReader: () => ({
|
|
20
|
+
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
|
|
21
|
+
cancel: async () => {},
|
|
22
|
+
}),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fakeFetch(body, { ok = true, status = 200 } = {}) {
|
|
27
|
+
return async (url, options) => {
|
|
28
|
+
const text = typeof body === 'function' ? body(url) : typeof body === 'string' ? body : JSON.stringify(body)
|
|
29
|
+
return { ok, status, body: streamBody(text) }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeReleaseHtml(inner, { datetime = '2026-09-10T03:09:00Z' } = {}) {
|
|
34
|
+
return '<!doctype html><html><body>'
|
|
35
|
+
+ (datetime ? '<relative-time datetime="' + datetime + '"></relative-time>' : '')
|
|
36
|
+
+ '<div data-test-selector="body-content" class="markdown-body">' + inner + '</div>'
|
|
37
|
+
+ '</body></html>'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const RELEASE_INNER = '<h3 id="user-content-note">更新内容</h3>'
|
|
41
|
+
+ '<ul>'
|
|
42
|
+
+ '<li>新增 A 功能 & B 修复</li>'
|
|
43
|
+
+ '<li>支持 <code>fast</code> 模式 '引号' 与 <标签></li>'
|
|
44
|
+
+ '</ul>'
|
|
45
|
+
+ '<div class="markdown-body-inner"><p>嵌套段落保持在外层容器内</p></div>'
|
|
46
|
+
+ '<p>详情见发布页。</p>'
|
|
47
|
+
const RELEASE_BODY = [
|
|
48
|
+
'### 更新内容',
|
|
49
|
+
'- 新增 A 功能 & B 修复',
|
|
50
|
+
'- 支持 fast 模式 \'引号\' 与 <标签>',
|
|
51
|
+
'',
|
|
52
|
+
'嵌套段落保持在外层容器内',
|
|
53
|
+
'',
|
|
54
|
+
'详情见发布页。',
|
|
55
|
+
].join('\n')
|
|
56
|
+
const RELEASE_HTML = makeReleaseHtml(RELEASE_INNER)
|
|
57
|
+
|
|
58
|
+
test('场景:合法版本构建发布标签与发布页地址', () => {
|
|
59
|
+
assert.equal(buildReleaseTag('0.1.5-rc.1'), 'dsh-v0.1.5-rc.1')
|
|
60
|
+
assert.equal(
|
|
61
|
+
buildReleaseNotesUrl('0.1.5-rc.1'),
|
|
62
|
+
'https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.5-rc.1',
|
|
63
|
+
)
|
|
64
|
+
assert.equal(RELEASE_REPO, 'deepseek-ai/deepseek-harness')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('场景:非法版本拒绝构建标签,空白版本归一后放行', () => {
|
|
68
|
+
assert.throws(() => buildReleaseTag(null), /semver/)
|
|
69
|
+
assert.throws(() => buildReleaseTag('not-semver'), /semver/)
|
|
70
|
+
assert.throws(() => buildReleaseTag('1;rm -rf /'), /semver/)
|
|
71
|
+
assert.equal(buildReleaseTag(' 0.1.5-rc.1 '), 'dsh-v0.1.5-rc.1', '首尾空白归一,防拼接出残缺标签')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('场景:拉取成功提取正文与发布时间,请求形态与上游纪律一致', async () => {
|
|
75
|
+
let requestedUrl = null
|
|
76
|
+
let capturedOptions = null
|
|
77
|
+
const notes = await fetchReleaseNotes({
|
|
78
|
+
version: '0.1.5-rc.1',
|
|
79
|
+
fetchImpl: async (url, options) => {
|
|
80
|
+
requestedUrl = url
|
|
81
|
+
capturedOptions = options
|
|
82
|
+
return { ok: true, status: 200, body: streamBody(RELEASE_HTML) }
|
|
83
|
+
},
|
|
84
|
+
timeoutMs: 1000,
|
|
85
|
+
})
|
|
86
|
+
assert.deepEqual(notes, {
|
|
87
|
+
url: buildReleaseNotesUrl('0.1.5-rc.1'),
|
|
88
|
+
publishedAt: '2026-09-10T03:09:00Z',
|
|
89
|
+
body: RELEASE_BODY,
|
|
90
|
+
})
|
|
91
|
+
assert.equal(requestedUrl, buildReleaseNotesUrl('0.1.5-rc.1'))
|
|
92
|
+
// 选项透传断言:redirect:'error' 的实际拒跟行为由平台 fetch 保证,mock 只验证参数到达
|
|
93
|
+
assert.equal(capturedOptions.redirect, 'error')
|
|
94
|
+
assert.ok(capturedOptions.signal instanceof AbortSignal)
|
|
95
|
+
assert.match(String(capturedOptions.headers.accept), /html/)
|
|
96
|
+
assert.ok(capturedOptions.headers['user-agent'], '页面拉取须带显式 User-Agent')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('场景:HTTP 404 报未找到发布说明,其余非 2xx 报状态码', async () => {
|
|
100
|
+
await assert.rejects(
|
|
101
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: fakeFetch('<html></html>', { ok: false, status: 404 }), timeoutMs: 1000 }),
|
|
102
|
+
/未找到/,
|
|
103
|
+
)
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: fakeFetch('<html></html>', { ok: false, status: 502 }), timeoutMs: 1000 }),
|
|
106
|
+
/502/,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('场景:页面缺 markdown-body 锚点或容器未闭合报结构解析失败', () => {
|
|
111
|
+
assert.throws(() => extractReleaseNotesFromHtml('<html><body><p>没有正文容器</p></body></html>'), /结构无法解析/)
|
|
112
|
+
assert.throws(() => extractReleaseNotesFromHtml('<div class="markdown-body"><p>容器未闭合</p>'), /结构无法解析/)
|
|
113
|
+
assert.throws(() => extractReleaseNotesFromHtml(null), /结构无法解析/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('场景:空正文容器返回空说明,缺发布时间为 null', async () => {
|
|
117
|
+
const notes = await fetchReleaseNotes({
|
|
118
|
+
version: '9.9.9',
|
|
119
|
+
fetchImpl: fakeFetch(makeReleaseHtml(' ', { datetime: '' })),
|
|
120
|
+
timeoutMs: 1000,
|
|
121
|
+
})
|
|
122
|
+
assert.deepEqual(notes, {
|
|
123
|
+
url: buildReleaseNotesUrl('9.9.9'),
|
|
124
|
+
publishedAt: null,
|
|
125
|
+
body: '',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('场景:上游网络异常原样抛出由调用方归一', async () => {
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: async () => { throw new Error('ECONNRESET') }, timeoutMs: 1000 }),
|
|
132
|
+
/ECONNRESET/,
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('场景:超限响应体中途断开,不整量入内存', async () => {
|
|
137
|
+
const chunk = 'x'.repeat(128 * 1024)
|
|
138
|
+
let cancelled = false
|
|
139
|
+
const oversized = {
|
|
140
|
+
ok: true,
|
|
141
|
+
status: 200,
|
|
142
|
+
body: {
|
|
143
|
+
getReader: () => ({
|
|
144
|
+
read: async () => ({ done: false, value: new TextEncoder().encode(chunk) }),
|
|
145
|
+
cancel: async () => { cancelled = true },
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: async () => oversized, timeoutMs: 1000 }),
|
|
151
|
+
/上限/,
|
|
152
|
+
)
|
|
153
|
+
assert.equal(cancelled, true, '流必须被取消')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('场景:timeoutMs 缺失或非正数拒绝', async () => {
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: fakeFetch(RELEASE_HTML) }),
|
|
159
|
+
/timeoutMs/,
|
|
160
|
+
)
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
() => fetchReleaseNotes({ version: '9.9.9', fetchImpl: fakeFetch(RELEASE_HTML), timeoutMs: 0 }),
|
|
163
|
+
/timeoutMs/,
|
|
164
|
+
)
|
|
165
|
+
})
|
|
@@ -205,7 +205,8 @@ const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
|
|
|
205
205
|
|
|
206
206
|
test('upgradeFinalText: stale 优先于两种重启指引,不得引导重启', () => {
|
|
207
207
|
const text = clientUpgradeFinalText({ ok: true, stale: true, requiresManualRestart: true, autoRestartScheduled: true })
|
|
208
|
-
|
|
208
|
+
// stale 口径泛化:未前进/未达目标/未落到目标/未装到指定版本统称与安装意图不符
|
|
209
|
+
assert.match(text, /意图不符/)
|
|
209
210
|
assert.doesNotMatch(text, /重启/)
|
|
210
211
|
})
|
|
211
212
|
|