@mzzsfy/dsh-maintain 0.8.0 → 0.10.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 +9 -4
- package/package.json +1 -1
- package/src/client.js +188 -39
- package/src/core.mjs +140 -20
- package/src/index.js +53 -37
- package/test/client-id.test.mjs +17 -0
- package/test/core.test.mjs +51 -0
- package/test/parity.test.mjs +137 -16
- package/test/release-notes.test.mjs +165 -0
- package/test/restart-ready.test.mjs +9 -9
- package/test/route.test.mjs +281 -37
- package/test/upgrade.test.mjs +10 -20
- package/src/runtime.mjs +0 -69
- package/test/runtime.test.mjs +0 -67
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,10 +24,8 @@ 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
|
-
import { detectRuntimeEnv, RUNTIME_KINDS } from './runtime.mjs'
|
|
29
29
|
|
|
30
30
|
export const name = 'dsh-maintain'
|
|
31
31
|
|
|
@@ -36,6 +36,9 @@ export const inject = ['webServer']
|
|
|
36
36
|
const NAMESPACE = 'maintain'
|
|
37
37
|
|
|
38
38
|
const CHECK_TIMEOUT_MS = 20 * 1000
|
|
39
|
+
// 版本更新内容缓存上限:长期运行中通道切换持续产生新键,无上限则缓存无界;
|
|
40
|
+
// 超限删最旧(Map 迭代序即插入序,命中不重插);导出仅供测试
|
|
41
|
+
export const RELEASE_NOTES_CACHE_MAX = 20
|
|
39
42
|
// 升级命令超时:client 浮条观察上限与此对拍(parity 锁定),强杀宽限另计
|
|
40
43
|
export const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000
|
|
41
44
|
// 响应发出到执行退出的延迟:保证浏览器收到 200 并进入重启等待态,进程才离场;
|
|
@@ -204,16 +207,15 @@ export async function runUpgradeWithRetry({ command, timeoutMs = UPGRADE_TIMEOUT
|
|
|
204
207
|
return { attempts, ok: false, kind: null, stillRunning: false, stdoutTail: '', stderrTail: '', error: null }
|
|
205
208
|
}
|
|
206
209
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
+
// 升级后自动重启守卫:三条件缺一不可——成功、本次勾选 enabled、appExit 可用。
|
|
211
|
+
// 宿主退出动作与面板"确认重启"完全同一调度入口,拉起责任归启动方式:
|
|
212
|
+
// 托管环境由进程管理器拉起,手动直跑等同点了重启按钮后自行再启动
|
|
210
213
|
// 无 stale 条件:版本复读属升级后磁盘读取,关机路径禁止;版本是否前进由重启后的启动检查呈现
|
|
211
|
-
export function judgeAutoRestart({ ok,
|
|
212
|
-
if (ok !== true) return { schedule: false
|
|
213
|
-
if (
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
return { schedule: true, requiresManualRestart: false }
|
|
214
|
+
export function judgeAutoRestart({ ok, hasExit, enabled }) {
|
|
215
|
+
if (ok !== true) return { schedule: false }
|
|
216
|
+
if (enabled !== true) return { schedule: false }
|
|
217
|
+
if (hasExit !== true) return { schedule: false }
|
|
218
|
+
return { schedule: true }
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
// 浏览器半区调用的 API 路径清单:client.js 同名常量与之对拍(parity),防单侧改路径生产 404
|
|
@@ -226,6 +228,7 @@ export const API_PATHS = Object.freeze({
|
|
|
226
228
|
REGISTRY_BASE: '/api/maintain/registry-base',
|
|
227
229
|
UPGRADE: '/api/maintain/upgrade',
|
|
228
230
|
RESTART: '/api/maintain/restart',
|
|
231
|
+
RELEASE_NOTES: '/api/maintain/release-notes',
|
|
229
232
|
})
|
|
230
233
|
|
|
231
234
|
// 宿主进程启动时刻:重启探测的第三代际信号(容器内 pid 恒 1 且零失联时 pid 信号失效)
|
|
@@ -381,21 +384,13 @@ export function apply(ctx) {
|
|
|
381
384
|
const runningVersionReady = resolveCurrentHostVersion()
|
|
382
385
|
.then((version) => { runningVersion = version })
|
|
383
386
|
.catch(() => {})
|
|
384
|
-
|
|
385
|
-
// 运行环境:apply 时一次检测;探测异步,以 ready 链收口,异常回退 unknown(维持自动重启)
|
|
386
|
-
let runtimeEnv = { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
|
|
387
|
-
const runtimeEnvReady = detectRuntimeEnv({
|
|
388
|
-
env: process.env,
|
|
389
|
-
platform: process.platform,
|
|
390
|
-
isTTY: { stdin: process.stdin.isTTY === true, stdout: process.stdout.isTTY === true },
|
|
391
|
-
})
|
|
392
|
-
.then((env) => { runtimeEnv = env })
|
|
393
|
-
.catch(() => {})
|
|
394
387
|
let upgrade = { running: false, last: null }
|
|
395
388
|
let checkInFlight = null
|
|
396
389
|
let nextDueAt = null
|
|
397
390
|
let restartScheduled = false
|
|
398
391
|
let autoRestartScheduled = false
|
|
392
|
+
// 版本更新内容缓存:键为通道最新版原始值(未归一),值为 {url,publishedAt,body};仅成功结果入缓存
|
|
393
|
+
const releaseNotesCache = new Map()
|
|
399
394
|
|
|
400
395
|
function runCheck() {
|
|
401
396
|
if (checkInFlight) return checkInFlight
|
|
@@ -453,8 +448,8 @@ export function apply(ctx) {
|
|
|
453
448
|
}
|
|
454
449
|
|
|
455
450
|
async function currentStatus() {
|
|
456
|
-
//
|
|
457
|
-
await
|
|
451
|
+
// 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
|
|
452
|
+
await runningVersionReady
|
|
458
453
|
const config = readSettings(ctx)
|
|
459
454
|
const judged = judgeNow()
|
|
460
455
|
// running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
|
|
@@ -479,7 +474,6 @@ export function apply(ctx) {
|
|
|
479
474
|
checkedAt: snapshot.checkedAt,
|
|
480
475
|
checkError: snapshot.error,
|
|
481
476
|
upgrade,
|
|
482
|
-
runtimeEnv,
|
|
483
477
|
autoRestartScheduled,
|
|
484
478
|
activeWork: collectActiveWork(ctx),
|
|
485
479
|
canRestart: typeof exit === 'function',
|
|
@@ -490,8 +484,12 @@ export function apply(ctx) {
|
|
|
490
484
|
const config = readSettings(ctx)
|
|
491
485
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
492
486
|
const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
|
|
487
|
+
// 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
|
|
488
|
+
const pinnedVersion = extractPinnedVersion(command)
|
|
493
489
|
const last = {
|
|
494
490
|
command,
|
|
491
|
+
// 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
|
|
492
|
+
pinnedVersion: pinnedVersion === config.channel ? null : pinnedVersion,
|
|
495
493
|
startedAt: Date.now(),
|
|
496
494
|
ok: false,
|
|
497
495
|
finishedAt: null,
|
|
@@ -581,11 +579,9 @@ export function apply(ctx) {
|
|
|
581
579
|
+ (last.stale === true ? ' stale=' + singleLine(last.reason) : ''))
|
|
582
580
|
audit('upgrade', last.ok === true ? 'ok' : 'failed',
|
|
583
581
|
'durationMs=' + (last.finishedAt - last.startedAt) + ' code=' + last.code + ' kind=' + singleLine(last.kind))
|
|
584
|
-
// 关机前置判定只取内存输入(
|
|
582
|
+
// 关机前置判定只取内存输入(本次会话勾选 + 启动器退出能力):升级命令刚
|
|
585
583
|
// 替换过宿主磁盘文件,落定钩子发起网络请求或读取磁盘都是半写状态下的故障源
|
|
586
|
-
|
|
587
|
-
const decision = judgeAutoRestart({ ok: last.ok === true, runtimeKind: runtimeEnv.kind, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
588
|
-
if (decision.requiresManualRestart === true) last.requiresManualRestart = true
|
|
584
|
+
const decision = judgeAutoRestart({ ok: last.ok === true, hasExit: typeof exit === 'function', enabled: autoRestart === true })
|
|
589
585
|
if (decision.schedule === true) {
|
|
590
586
|
// 关机路径零收尾:与确认重启同一内部动作,直接进入延迟退出
|
|
591
587
|
// last 镜像调度标记:浮条终态文案按其分流(与 status.autoRestartScheduled 同值)
|
|
@@ -593,7 +589,7 @@ export function apply(ctx) {
|
|
|
593
589
|
scheduleHostExit({ detail: 'reason=upgrade-ok', autoRestart: true, delayMs: AUTO_RESTART_DELAY_MS })
|
|
594
590
|
return
|
|
595
591
|
}
|
|
596
|
-
// 继续运行路径(
|
|
592
|
+
// 继续运行路径(失败/本次未勾选自动重启/appExit 缺失):复读磁盘版本标 stale,版本未前进提示保留
|
|
597
593
|
if (settle !== null && settle.ok) {
|
|
598
594
|
// 复读属磁盘消费点,拒绝不得以 unhandledRejection 形式逃逸(void 触发即崩宿主):
|
|
599
595
|
// 失败时放弃 stale 判定,保留已装版本 null 由启动检查兜底呈现
|
|
@@ -604,6 +600,7 @@ export function apply(ctx) {
|
|
|
604
600
|
previousVersion: last.previousVersion,
|
|
605
601
|
installedVersion: last.installedVersion,
|
|
606
602
|
channelLatest: tags !== null && Object.prototype.hasOwnProperty.call(tags, channel) ? tags[channel] : null,
|
|
603
|
+
pinnedVersion: last.pinnedVersion,
|
|
607
604
|
})
|
|
608
605
|
last.stale = freshness.stale
|
|
609
606
|
last.reason = freshness.reason
|
|
@@ -756,14 +753,6 @@ export function apply(ctx) {
|
|
|
756
753
|
sendJson(res, 409, { error: '存在未过期的升级锁(可能有残留升级子进程),等待锁过期或删除 ' + UPGRADE_LOCK_PATH })
|
|
757
754
|
return
|
|
758
755
|
}
|
|
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
756
|
// 活跃工作门控:升级不可越(agent 会话/job 在跑时强行升级会被中断)
|
|
768
757
|
const activeWork = collectActiveWork(ctx)
|
|
769
758
|
if (activeWork.total > 0) {
|
|
@@ -820,6 +809,33 @@ export function apply(ctx) {
|
|
|
820
809
|
scheduleHostExit({ detail: 'forced=' + (requestBody.force === true), delayMs: RESTART_DELAY_MS })
|
|
821
810
|
}),
|
|
822
811
|
},
|
|
812
|
+
{
|
|
813
|
+
path: API_PATHS.RELEASE_NOTES,
|
|
814
|
+
handler: route('GET', {}, async (req, res) => {
|
|
815
|
+
// 关机窗口内不发上游请求:退出延迟 2 秒小于上游超时,与 refresh 同纪律
|
|
816
|
+
if (restartScheduled) {
|
|
817
|
+
sendJson(res, 409, { error: '重启已调度,禁止查询更新内容' })
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
// 缺省恒取追踪通道最新版;上游标签值非 semver 时 buildReleaseTag 抛错归一 400
|
|
821
|
+
const version = judgeNow().channelLatest
|
|
822
|
+
if (!version) {
|
|
823
|
+
sendJson(res, 400, { error: '通道最新版未知,请先检查更新' })
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
const cached = releaseNotesCache.get(version)
|
|
827
|
+
if (cached) {
|
|
828
|
+
sendJson(res, 200, { version, url: cached.url, publishedAt: cached.publishedAt, body: cached.body })
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
const notes = await fetchReleaseNotes({ version, timeoutMs: CHECK_TIMEOUT_MS })
|
|
832
|
+
releaseNotesCache.set(version, notes)
|
|
833
|
+
while (releaseNotesCache.size > RELEASE_NOTES_CACHE_MAX) {
|
|
834
|
+
releaseNotesCache.delete(releaseNotesCache.keys().next().value)
|
|
835
|
+
}
|
|
836
|
+
sendJson(res, 200, { version, url: notes.url, publishedAt: notes.publishedAt, body: notes.body })
|
|
837
|
+
}),
|
|
838
|
+
},
|
|
823
839
|
]
|
|
824
840
|
|
|
825
841
|
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
|
+
'buildInstallChangeLine', 'buildInstallVersionHint',
|
|
142
|
+
'return (' + match[0] + ')')(
|
|
143
|
+
h, useState, Switch, 'npm install -g @deepseek-ai/dsh@{tag}', 3,
|
|
144
|
+
(status, command) => '变更:' + command, (status) => '提示:' + status.runningVersion)
|
|
145
|
+
const status = { channel: 'latest', upgradeTemplate: '', runningVersion: '0.1.5', installedVersion: '0.1.5' }
|
|
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 缺失时的退化路径
|
|
@@ -92,13 +200,6 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
|
|
|
92
200
|
assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
|
|
93
201
|
})
|
|
94
202
|
|
|
95
|
-
// host 侧锚点直接 import runtime.mjs 实现,防测试内手抄字面量漂移假绿
|
|
96
|
-
import { RUNTIME_KINDS } from '../src/runtime.mjs'
|
|
97
|
-
|
|
98
|
-
test('parity: 手动直跑运行环境常量 client 与 host 一致', () => {
|
|
99
|
-
assert.equal(extractConst('RUNTIME_KIND_MANUAL'), RUNTIME_KINDS.MANUAL_START)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
203
|
test('parity: 弹窗文案自动重启延迟秒与 host AUTO_RESTART_DELAY_MS 换算一致', () => {
|
|
103
204
|
assert.equal(extractNumberConst('AUTO_RESTART_DELAY_SEC'), AUTO_RESTART_DELAY_MS / 1000)
|
|
104
205
|
})
|
|
@@ -140,18 +241,20 @@ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
|
|
|
140
241
|
REGISTRY_BASE: 'REGISTRY_BASE_URL',
|
|
141
242
|
UPGRADE: 'UPGRADE_URL',
|
|
142
243
|
RESTART: 'RESTART_URL',
|
|
244
|
+
RELEASE_NOTES: 'RELEASE_NOTES_URL',
|
|
143
245
|
}
|
|
144
246
|
for (const [hostKey, hostPath] of Object.entries(API_PATHS)) {
|
|
145
247
|
assert.equal(extractConst(CLIENT_KEY_BY_HOST_KEY[hostKey]), hostPath, 'API 路径漂移: ' + hostKey)
|
|
146
248
|
}
|
|
147
249
|
})
|
|
148
250
|
|
|
149
|
-
//
|
|
251
|
+
// 窗口关系对拍:重启等待总时长必须大于任一退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
|
|
150
252
|
import { RESTART_DELAY_MS } from '../src/index.js'
|
|
151
253
|
|
|
152
|
-
test('parity: 重启等待总时长大于宿主退出延迟', () => {
|
|
254
|
+
test('parity: 重启等待总时长大于宿主退出延迟(按钮与自动重启取大)', () => {
|
|
153
255
|
const restartTimeoutMs = extractNumberConst('RESTART_TIMEOUT_MS')
|
|
154
|
-
assert.ok(restartTimeoutMs > RESTART_DELAY_MS,
|
|
256
|
+
assert.ok(restartTimeoutMs > Math.max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS),
|
|
257
|
+
'RESTART_TIMEOUT_MS 必须大于 max(RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS)')
|
|
155
258
|
})
|
|
156
259
|
|
|
157
260
|
test('parity: 升级观察上限覆盖宿主重试链上限(防抢跑转状态未知)', () => {
|
|
@@ -170,13 +273,19 @@ test('parity: npm 版本页链接与追踪包名同源', () => {
|
|
|
170
273
|
assert.ok(extractConst('NPM_VERSIONS_URL').includes(TARGET_PACKAGE), 'NPM_VERSIONS_URL 应包含 TARGET_PACKAGE 字面量')
|
|
171
274
|
})
|
|
172
275
|
|
|
173
|
-
|
|
174
|
-
|
|
276
|
+
// release 更新内容事实源在 GitHub Releases:client 兜底链接与 host 拉取仓库必须同源
|
|
277
|
+
import { RELEASE_REPO } from '../src/core.mjs'
|
|
278
|
+
|
|
279
|
+
test('parity: GitHub releases 兜底链接与 host 拉取仓库同源', () => {
|
|
280
|
+
assert.equal(extractConst('RELEASES_PAGE_URL'), 'https://github.com/' + RELEASE_REPO + '/releases')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('parity: 落定补查宽限不小于观察轮询拍间隔', () => {
|
|
284
|
+
// 关机路径调度标记与 running=false 同拍置位,宽限不再竞态调度延迟;现职责是
|
|
285
|
+
// 给继续运行路径的 stale 异步复读留余量,下限锁定为不小于轮询拍间隔(补查不早于一拍)
|
|
175
286
|
const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
|
|
176
|
-
assert.ok(
|
|
177
|
-
|
|
178
|
-
'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于 AUTO_RESTART_DELAY_MS(' + AUTO_RESTART_DELAY_MS + ') 加观察裕量',
|
|
179
|
-
)
|
|
287
|
+
assert.ok(graceMs >= 2 * 1000,
|
|
288
|
+
'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于观察轮询拍间隔(2s)')
|
|
180
289
|
})
|
|
181
290
|
|
|
182
291
|
test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
|
|
@@ -185,3 +294,15 @@ test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重
|
|
|
185
294
|
assert.ok(CLIENT_SOURCE.includes('AbortSignal.timeout(UPGRADE_POLL_TIMEOUT_MS)'), '升级观察拍必须带请求超时')
|
|
186
295
|
assert.ok(CLIENT_SOURCE.includes('upgradeWatch.generation !== generation'), '升级观察拍 settle 后必须验代际')
|
|
187
296
|
})
|
|
297
|
+
|
|
298
|
+
test('源码契约: 安装入口全版本状态可点,verdict 不得回归为禁用门', () => {
|
|
299
|
+
// 重装修复与回退是面板显式意图:按钮与弹窗派生门控不得含 verdict 比较;
|
|
300
|
+
// VERDICT_UP_TO_DATE 仅允许出现两次(常量声明 + 结论徽章展示)
|
|
301
|
+
const occurrences = CLIENT_SOURCE.split('VERDICT_UP_TO_DATE').length - 1
|
|
302
|
+
assert.equal(occurrences, 2, 'VERDICT_UP_TO_DATE 只允许声明与徽章展示两处,禁用门回归即超限')
|
|
303
|
+
assert.ok(CLIENT_SOURCE.includes("}, '安装'),"), '升级按钮文案必须为「安装」')
|
|
304
|
+
assert.ok(CLIENT_SOURCE.includes("'确认安装'"), '弹窗标题必须为「确认安装」')
|
|
305
|
+
assert.ok(CLIENT_SOURCE.includes("'开始安装'"), '弹窗确认按钮必须为「开始安装」')
|
|
306
|
+
assert.ok(CLIENT_SOURCE.includes('重装'), '弹窗必须说明重装(修复)场景')
|
|
307
|
+
assert.ok(CLIENT_SOURCE.includes('回退'), '弹窗必须说明回退场景')
|
|
308
|
+
})
|
|
@@ -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
|
+
})
|