@mzzsfy/dsh-maintain 0.7.1 → 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.
@@ -6,7 +6,7 @@ import { test } from 'node:test'
6
6
  import assert from 'node:assert/strict'
7
7
  import { EventEmitter } from 'node:events'
8
8
 
9
- import { apply, RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS, UPGRADE_LOCK_PATH, collectActiveWork } from '../src/index.js'
9
+ import { apply, RESTART_DELAY_MS, AUTO_RESTART_DELAY_MS, UPGRADE_LOCK_PATH, RELEASE_NOTES_CACHE_MAX, collectActiveWork } from '../src/index.js'
10
10
  import { rmSync, readFileSync } from 'node:fs'
11
11
 
12
12
  // 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
@@ -137,10 +137,10 @@ const realSleep = (ms) => new Promise((resolve) => {
137
137
  setImmediate(spin)
138
138
  })
139
139
 
140
- test('挂载:8 条路由注册,启动检查后快照就绪', async () => {
140
+ test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
141
141
  const { ctx, routes } = makeCtx()
142
142
  apply(ctx)
143
- assert.equal(routes.size, 8)
143
+ assert.equal(routes.size, 9)
144
144
  assert.deepEqual(
145
145
  [...routes.keys()].sort(),
146
146
  [
@@ -148,6 +148,7 @@ test('挂载:8 条路由注册,启动检查后快照就绪', async () => {
148
148
  '/api/maintain/poll-interval',
149
149
  '/api/maintain/refresh',
150
150
  '/api/maintain/registry-base',
151
+ '/api/maintain/release-notes',
151
152
  '/api/maintain/restart',
152
153
  '/api/maintain/status',
153
154
  '/api/maintain/upgrade',
@@ -156,16 +157,7 @@ test('挂载:8 条路由注册,启动检查后快照就绪', async () => {
156
157
  )
157
158
  // 启动检查是异步链(mock fetch 微任务 + resolveHostVersion 真实文件读),
158
159
  // 轮询等快照落定后再断言
159
- let snapshotReady = false
160
- for (let waited = 0; waited < 5000; waited += 25) {
161
- const poll = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
162
- if (poll.payload.checkedAt !== null) {
163
- snapshotReady = true
164
- break
165
- }
166
- await new Promise((resolve) => setTimeout(resolve, 25))
167
- }
168
- assert.ok(snapshotReady, '启动检查 5 秒内未完成')
160
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
169
161
  const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
170
162
  assert.equal(res.status, 200)
171
163
  assert.equal(res.payload.packageName, '@deepseek-ai/dsh')
@@ -209,8 +201,10 @@ test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval
209
201
  test('方法守卫:全部路由错误方法一律 405', async () => {
210
202
  const { ctx, routes } = makeCtx()
211
203
  apply(ctx)
204
+ // GET 端点(status/release-notes)以 POST 拒绝,其余以 GET 拒绝
205
+ const readPaths = ['/api/maintain/status', '/api/maintain/release-notes']
212
206
  for (const path of routes.keys()) {
213
- const expected = path === '/api/maintain/status' ? 'POST' : 'GET'
207
+ const expected = readPaths.indexOf(path) >= 0 ? 'POST' : 'GET'
214
208
  const res = await call(routes, path, makeReq({ method: expected }))
215
209
  assert.equal(res.status, 405, path + ' 应拒绝 ' + expected)
216
210
  }
@@ -295,28 +289,38 @@ test('registry-base:非法 scheme 400;合法值持久化', async () => {
295
289
  assert.equal(store.registryBase, 'https://mirror.example')
296
290
  })
297
291
 
298
- test('upgrade:运行版本已是通道最新 409 拒绝(防降级),unknown 放行', async () => {
292
+ test('upgrade:运行版本已是通道最新放行(重装/回退场景),不再 409 拒绝', async () => {
299
293
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
300
294
  // 版本探测注入受控值:CI 无 dsh 本体,真实盘读回 null 会把 verdict 打成 unknown,
301
- // 防降级门控(核心断言)在 CI 恒不触发
295
+ // 重装放行断言(本用例核心)在 CI 恒不触发
302
296
  const { ctx, routes } = makeCtx({
303
297
  settingsStore: store,
304
298
  services: { hostVersionProbe: () => Promise.resolve('5.4.3') },
305
299
  })
306
300
  apply(ctx)
307
- // 注入运行版本远低于假目标版本:verdict 应转 up-to-date,升级入口拒绝
301
+ // 注入运行版本远高于假目标版本:verdict 应转 up-to-date,安装入口照常放行
308
302
  const originalFetch = globalThis.fetch
309
303
  globalThis.fetch = async () => tagsBody({ latest: '0.0.1', next: '0.0.2' })
304
+ const warns = []
305
+ const originalWarn = console.warn
306
+ console.warn = (text) => warns.push(String(text))
310
307
  try {
311
308
  const refreshed = await post(routes, '/api/maintain/refresh')
312
309
  assert.equal(refreshed.status, 200)
313
310
  assert.equal(refreshed.payload.verdict, 'up-to-date', '前置:注入运行版本应高于 0.0.1 假目标')
314
- const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
315
- assert.equal(denied.status, 409)
316
- assert.match(denied.payload.error, /已是通道最新版/)
317
- assert.equal(store.upgradeCommandTemplate, 'node -e "process.exit(0)"', '拒绝路径不得触发升级')
311
+ const accepted = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
312
+ assert.equal(accepted.status, 200, '已是通道最新必须放行:重装修复与回退是显式用户意图')
313
+ assert.equal(accepted.payload.upgrade.running, true)
314
+ for (let i = 0; i < 50; i += 1) {
315
+ await new Promise((resolve) => setTimeout(resolve, 100))
316
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
317
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) break
318
+ }
319
+ assert.equal(store.upgradeCommandTemplate, 'node -e "process.exit(0)"', '放行路径命令模板不得被改动')
320
+ assert.ok(warns.some((text) => /audit endpoint=upgrade outcome=triggered/.test(text)), '放行安装须留审计行')
318
321
  } finally {
319
322
  globalThis.fetch = originalFetch
323
+ console.warn = originalWarn
320
324
  }
321
325
  })
322
326
 
@@ -586,6 +590,39 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
586
590
  }
587
591
  })
588
592
 
593
+ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参与对拍)', async (t) => {
594
+ // 假命令携带钉定 token(参数位),语义等同 `npm i -g pkg@1.0.0` 但不真装
595
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)" @deepseek-ai/dsh@1.0.0' }
596
+ const exits = []
597
+ const { ctx, routes } = makeCtx({
598
+ settingsStore: store,
599
+ appExit: (code) => exits.push(code),
600
+ // 探针恒读 1.0.0:钉定 1.0.0 时"磁盘未变"即达成,不得按通道目标误报 stale
601
+ services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
602
+ })
603
+ t.mock.timers.enable({ apis: ['setTimeout'] })
604
+ apply(ctx)
605
+ await drainMicrotasks()
606
+ const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
607
+ assert.equal(trigger.status, 200)
608
+ let settled = null
609
+ try {
610
+ for (let i = 0; i < 50 && settled === null; i += 1) {
611
+ await realSleep(30)
612
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
613
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
614
+ }
615
+ assert.ok(settled, '升级应在假命令退出后落定')
616
+ assert.equal(settled.upgrade.last.ok, true)
617
+ assert.equal(settled.upgrade.last.stale, false, '钉定版本装到位即达成')
618
+ assert.equal(settled.upgrade.last.reason, null)
619
+ t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
620
+ assert.deepEqual(exits, [])
621
+ } finally {
622
+ rmSync(UPGRADE_LOCK_PATH, { force: true })
623
+ }
624
+ })
625
+
589
626
  test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', async (t) => {
590
627
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
591
628
  const exits = []
@@ -891,4 +928,216 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
891
928
  }
892
929
  assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=triggered/.test(text)), true, '升级触发须留审计行')
893
930
  assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=(ok|failed) durationMs=[0-9]+ code=/.test(text)), true, '升级落定须留审计行')
894
- })
931
+ })
932
+
933
+ // ---- release notes 路由 ----
934
+
935
+ const RELEASE_PATH = '/api/maintain/release-notes'
936
+ // 发布页 fixture:与真实页面同构(relative-time + markdown-body 正文容器),正文映射由 core 提取器完成
937
+ const RELEASE_HTML = '<!doctype html><html><body>'
938
+ + '<relative-time datetime="2026-09-10T03:09:00Z"></relative-time>'
939
+ + '<div data-test-selector="body-content" class="markdown-body">'
940
+ + '<h3>更新内容</h3>'
941
+ + '<ul><li>新功能</li><li>问题修复</li></ul>'
942
+ + '<p>详情见发布页。</p>'
943
+ + '</div></body></html>'
944
+ const RELEASE_URL = 'https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v9.9.9'
945
+ const RELEASE_BODY = ['### 更新内容', '- 新功能', '- 问题修复', '', '详情见发布页。'].join('\n')
946
+
947
+ function releaseHtmlBody() {
948
+ const chunks = [new TextEncoder().encode(RELEASE_HTML)]
949
+ return {
950
+ getReader: () => ({
951
+ read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
952
+ cancel: async () => {},
953
+ }),
954
+ }
955
+ }
956
+
957
+ // dist-tags 与 GitHub 发布页双上游分流:registry 路径回 tags 形态,GitHub 路径回页面形态
958
+ function installDualUpstream({ requestedUrls, releaseThrows }) {
959
+ const originalFetch = globalThis.fetch
960
+ globalThis.fetch = async (url, options) => {
961
+ const text = String(url)
962
+ if (text.includes('/releases/tag/')) {
963
+ requestedUrls.push(text)
964
+ if (releaseThrows) throw new Error(releaseThrows)
965
+ return { ok: true, status: 200, body: releaseHtmlBody() }
966
+ }
967
+ void options
968
+ return tagsBody(MOCK_TAGS)
969
+ }
970
+ return () => { globalThis.fetch = originalFetch }
971
+ }
972
+
973
+ async function waitSnapshotReady(routes) {
974
+ for (let waited = 0; waited < 5000; waited += 25) {
975
+ const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
976
+ if (poll && poll.checkedAt !== null) return poll
977
+ await new Promise((resolve) => setTimeout(resolve, 25))
978
+ }
979
+ return null
980
+ }
981
+
982
+ test('release-notes:默认取通道最新版,200 返回发布说明', async () => {
983
+ const requestedUrls = []
984
+ const restore = installDualUpstream({ requestedUrls })
985
+ try {
986
+ const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
987
+ apply(ctx)
988
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
989
+ const res = await get(routes, RELEASE_PATH)
990
+ assert.equal(res.status, 200)
991
+ assert.deepEqual(res.payload, {
992
+ version: '9.9.9',
993
+ url: RELEASE_URL,
994
+ publishedAt: '2026-09-10T03:09:00Z',
995
+ body: RELEASE_BODY,
996
+ })
997
+ assert.equal(requestedUrls.length, 1)
998
+ assert.ok(requestedUrls[0].endsWith('/releases/tag/dsh-v9.9.9'), '默认版本必须取通道 latest 的目标')
999
+ } finally {
1000
+ restore()
1001
+ }
1002
+ })
1003
+
1004
+ test('release-notes:缓存生效,切通道后按新通道版本拉取', async () => {
1005
+ const requestedUrls = []
1006
+ const restore = installDualUpstream({ requestedUrls })
1007
+ try {
1008
+ const { ctx, routes } = makeCtx()
1009
+ apply(ctx)
1010
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1011
+ const first = await get(routes, RELEASE_PATH)
1012
+ assert.equal(first.status, 200)
1013
+ assert.equal(first.payload.version, '9.9.9')
1014
+ assert.ok(requestedUrls[0].endsWith('/releases/tag/dsh-v9.9.9'))
1015
+ const again = await get(routes, RELEASE_PATH)
1016
+ assert.equal(again.status, 200)
1017
+ assert.equal(requestedUrls.length, 1, '同版本两次调用必须命中缓存,上游仅一次')
1018
+ const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
1019
+ assert.equal(switched.status, 200)
1020
+ assert.equal(switched.payload.channelLatest, '10.0.0')
1021
+ const after = await get(routes, RELEASE_PATH)
1022
+ assert.equal(after.status, 200)
1023
+ assert.equal(after.payload.version, '10.0.0')
1024
+ assert.equal(requestedUrls.length, 2, '通道版本变化必须重新拉取')
1025
+ assert.ok(requestedUrls[1].endsWith('/releases/tag/dsh-v10.0.0'))
1026
+ } finally {
1027
+ restore()
1028
+ }
1029
+ })
1030
+
1031
+ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', async () => {
1032
+ const originalFetch = globalThis.fetch
1033
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
1034
+ try {
1035
+ const { ctx, routes } = makeCtx()
1036
+ apply(ctx)
1037
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1038
+ const res = await get(routes, RELEASE_PATH)
1039
+ assert.equal(res.status, 400)
1040
+ assert.match(res.payload.error, /检查更新/)
1041
+ } finally {
1042
+ globalThis.fetch = originalFetch
1043
+ }
1044
+ })
1045
+
1046
+ test('release-notes:通道标签非 semver,构建上游标签前归一 400', async () => {
1047
+ const requestedUrls = []
1048
+ const originalFetch = globalThis.fetch
1049
+ globalThis.fetch = async (url, options) => {
1050
+ const text = String(url)
1051
+ if (text.includes('/releases/tag/')) {
1052
+ requestedUrls.push(text)
1053
+ return { ok: true, status: 200, body: releaseHtmlBody() }
1054
+ }
1055
+ return tagsBody({ latest: 'banana' })
1056
+ }
1057
+ try {
1058
+ const { ctx, routes } = makeCtx()
1059
+ apply(ctx)
1060
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1061
+ const res = await get(routes, RELEASE_PATH)
1062
+ assert.equal(res.status, 400)
1063
+ assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
1064
+ assert.equal(requestedUrls.length, 0, '拒绝路径不得触达上游')
1065
+ } finally {
1066
+ globalThis.fetch = originalFetch
1067
+ }
1068
+ })
1069
+
1070
+ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t) => {
1071
+ t.mock.timers.enable({ apis: ['setTimeout'] })
1072
+ const requestedUrls = []
1073
+ const restore = installDualUpstream({ requestedUrls })
1074
+ try {
1075
+ const { ctx, routes } = makeCtx({ appExit: () => {} })
1076
+ apply(ctx)
1077
+ const restart = await post(routes, '/api/maintain/restart')
1078
+ assert.equal(restart.status, 200)
1079
+ const res = await get(routes, RELEASE_PATH)
1080
+ assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
1081
+ assert.match(res.payload.error, /重启已调度/)
1082
+ assert.equal(requestedUrls.length, 0, '关机窗口内不得发起上游请求')
1083
+ t.mock.timers.tick(RESTART_DELAY_MS + 1)
1084
+ } finally {
1085
+ restore()
1086
+ }
1087
+ })
1088
+
1089
+ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
1090
+ // N+1 个通道各配独立版本:逐个切换并查看,第 N+1 键入缓存时第 1 键必被淘汰;
1091
+ // 回访第 1 通道触发重新拉取,回访末位通道命中缓存不再拉取
1092
+ const channels = {}
1093
+ for (let i = 1; i <= RELEASE_NOTES_CACHE_MAX + 1; i += 1) channels['c' + i] = '1.0.' + i
1094
+ const requestedUrls = []
1095
+ const originalFetch = globalThis.fetch
1096
+ globalThis.fetch = async (url) => {
1097
+ if (String(url).includes('/releases/tag/')) {
1098
+ requestedUrls.push(String(url))
1099
+ return { ok: true, status: 200, body: releaseHtmlBody() }
1100
+ }
1101
+ return tagsBody(channels)
1102
+ }
1103
+ try {
1104
+ const { ctx, routes } = makeCtx()
1105
+ apply(ctx)
1106
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1107
+ for (const [name, version] of Object.entries(channels)) {
1108
+ const switched = await post(routes, '/api/maintain/channel', { channel: name })
1109
+ assert.equal(switched.status, 200)
1110
+ const res = await get(routes, RELEASE_PATH)
1111
+ assert.equal(res.status, 200)
1112
+ assert.equal(res.payload.version, version)
1113
+ }
1114
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 1, '每通道版本独立,各拉取一次')
1115
+ const first = Object.keys(channels)[0]
1116
+ const last = Object.keys(channels)[RELEASE_NOTES_CACHE_MAX]
1117
+ const backToFirst = await post(routes, '/api/maintain/channel', { channel: first })
1118
+ assert.equal(backToFirst.status, 200)
1119
+ await get(routes, RELEASE_PATH)
1120
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 2, '最早键已被淘汰,回访必须重新拉取')
1121
+ const backToLast = await post(routes, '/api/maintain/channel', { channel: last })
1122
+ assert.equal(backToLast.status, 200)
1123
+ await get(routes, RELEASE_PATH)
1124
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 2, '未淘汰键回访必须命中缓存')
1125
+ } finally {
1126
+ globalThis.fetch = originalFetch
1127
+ }
1128
+ })
1129
+
1130
+ test('release-notes:上游失败 400 带错误信息', async () => {
1131
+ const requestedUrls = []
1132
+ const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
1133
+ try {
1134
+ const { ctx, routes } = makeCtx()
1135
+ apply(ctx)
1136
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1137
+ const res = await get(routes, RELEASE_PATH)
1138
+ assert.equal(res.status, 400)
1139
+ assert.match(res.payload.error, /ECONNRESET/)
1140
+ } finally {
1141
+ restore()
1142
+ }
1143
+ })