@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.
@@ -197,23 +197,23 @@ test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=p
197
197
  assert.match(callSite[1], /readyStreak:\s*prev\.readyStreak/, 'restartTick 调用点缺少 readyStreak: prev.readyStreak 实参')
198
198
  })
199
199
 
200
- // 升级浮条终态文案:ok 落定后 stale / 手动直跑 / 自动重启三条终态分流,失败与未知不在此列
200
+ // 升级浮条终态文案:ok 落定后 stale / 自动重启两条终态分流,失败与未知不在此列
201
201
  const clientUpgradeFinalText = extractLogic('upgradeFinalText')
202
202
 
203
203
  // 重启确认态文案:armed 且有活跃计数时改「仍要重启」
204
204
  const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
205
205
 
206
- test('upgradeFinalText: stale 优先于两种重启指引,不得引导重启', () => {
207
- const text = clientUpgradeFinalText({ ok: true, stale: true, requiresManualRestart: true, autoRestartScheduled: true })
208
- assert.match(text, /未前进|未达目标/)
206
+ test('upgradeFinalText: stale 优先于重启指引,不得引导重启', () => {
207
+ const text = clientUpgradeFinalText({ ok: true, stale: true, autoRestartScheduled: true })
208
+ // stale 口径泛化:未前进/未达目标/未落到目标/未装到指定版本统称与安装意图不符
209
+ assert.match(text, /意图不符/)
209
210
  assert.doesNotMatch(text, /重启/)
210
211
  })
211
212
 
212
- test('upgradeFinalText: 手动直跑终态指向手动重启,自动重启终态指向页面自恢复', () => {
213
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: true, autoRestartScheduled: false }), /手动重启/)
214
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: true }), /自动重启/)
213
+ test('upgradeFinalText: 自动重启终态指向页面自恢复,默认终态指向手动重启', () => {
214
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: true }), /自动重启/)
215
215
  // 默认成功终态
216
- assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: false }), /重启宿主/)
216
+ assert.match(clientUpgradeFinalText({ ok: true, stale: false, autoRestartScheduled: false }), /重启宿主/)
217
217
  })
218
218
 
219
219
  test('restartConfirmLabel: armed 且活跃计数>0 才显示仍要重启文案', () => {
@@ -239,7 +239,7 @@ test('client 自动重启接管:单一守卫函数,订阅回调与初始加载
239
239
 
240
240
  test('client 落定补查:未见自动重启标记时延迟宽限补查一拍再终判', () => {
241
241
  const source = clientSource()
242
- // 落定拍 running 翻转与宿主置调度标记之间存在 await 窗口:未见标记不得立即终判
242
+ // 落定拍可见时继续运行路径的 stale 复读可能尚未完成:未见调度标记不得立即终判
243
243
  assert.match(source, /setTimeout\(\(\)\s*=>\s*\{[\s\S]*?recheckUpgradeSettle\(/, '落定拍未见标记必须延迟补查')
244
244
  assert.match(source, /async function recheckUpgradeSettle\([\s\S]*?broadcastUpgradeStatus\(final\)/, '补查必须广播以驱动接管守卫')
245
245
  assert.match(source, /upgradeWatch\.generation !== null\) return[\s\S]*?recheckUpgradeSettle|recheckUpgradeSettle\([\s\S]*?upgradeWatch\.generation !== null\) return/, '补查前后必须验让位新观察')
@@ -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
 
@@ -420,10 +424,8 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
420
424
  // 关机路径零复读:版本复读属升级后磁盘读取,是明确的故障源,砍掉
421
425
  assert.equal(settled.upgrade.last.installedVersion, null, '关机路径禁止复读磁盘版本')
422
426
  assert.equal(settled.upgrade.last.stale, null, '关机路径无 stale 判定')
423
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
424
427
  assert.equal(settled.autoRestartScheduled, true, '命令成功即调度关机(stale 不再抑制)')
425
428
  assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
426
- assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
427
429
  // 落定钩子零网络:观察窗口内 fetch 计数不得增长(runCheck 已随关机路径移除)
428
430
  await realSleep(300)
429
431
  assert.equal(fetchCalls, baseline, '落定钩子禁止网络请求')
@@ -577,7 +579,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
577
579
  assert.equal(settled.upgrade.last.stale, true, '继续运行路径必须复读并标注 stale')
578
580
  assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0, 'stale 必须带原因')
579
581
  assert.equal(settled.autoRestartScheduled, false, '未勾选不得置调度标记')
580
- assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '托管环境未勾选不标手动指引')
581
582
  // 推过完整调度延迟:若误调度,延迟窗口内 exit 必被调用
582
583
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
583
584
  assert.deepEqual(exits, [], '未勾选自动重启时禁止调度任何宿主退出')
@@ -586,6 +587,39 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
586
587
  }
587
588
  })
588
589
 
590
+ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参与对拍)', async (t) => {
591
+ // 假命令携带钉定 token(参数位),语义等同 `npm i -g pkg@1.0.0` 但不真装
592
+ const store = { upgradeCommandTemplate: 'node -e "process.exit(0)" @deepseek-ai/dsh@1.0.0' }
593
+ const exits = []
594
+ const { ctx, routes } = makeCtx({
595
+ settingsStore: store,
596
+ appExit: (code) => exits.push(code),
597
+ // 探针恒读 1.0.0:钉定 1.0.0 时"磁盘未变"即达成,不得按通道目标误报 stale
598
+ services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
599
+ })
600
+ t.mock.timers.enable({ apis: ['setTimeout'] })
601
+ apply(ctx)
602
+ await drainMicrotasks()
603
+ const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
604
+ assert.equal(trigger.status, 200)
605
+ let settled = null
606
+ try {
607
+ for (let i = 0; i < 50 && settled === null; i += 1) {
608
+ await realSleep(30)
609
+ const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
610
+ if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
611
+ }
612
+ assert.ok(settled, '升级应在假命令退出后落定')
613
+ assert.equal(settled.upgrade.last.ok, true)
614
+ assert.equal(settled.upgrade.last.stale, false, '钉定版本装到位即达成')
615
+ assert.equal(settled.upgrade.last.reason, null)
616
+ t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
617
+ assert.deepEqual(exits, [])
618
+ } finally {
619
+ rmSync(UPGRADE_LOCK_PATH, { force: true })
620
+ }
621
+ })
622
+
589
623
  test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', async (t) => {
590
624
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
591
625
  const exits = []
@@ -610,9 +644,8 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
610
644
  assert.ok(settled, '升级应在假命令退出后落定')
611
645
  assert.equal(settled.upgrade.last.ok, true)
612
646
  assert.equal(settled.upgrade.last.stale, null, '关机路径零复读,stale 不再判定')
613
- // 落定可见与调度置位之间隔 runtimeEnvReady 的 await:await 一次 status 让微任务链走完再断言
614
- const scheduled = await get(routes, '/api/maintain/status').then((r) => r.payload)
615
- assert.equal(scheduled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
647
+ // 调度标记与 running=false 同拍置位,落定快照直接可断言
648
+ assert.equal(settled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
616
649
  assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
617
650
  t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
618
651
  assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
@@ -821,16 +854,17 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
821
854
  assert.deepEqual(exits, [0], 'null 体等价空体:正常调度退出')
822
855
  })
823
856
 
824
- test('自动重启接线:落定链消费运行环境并分流调度与手动指引(源码形态锁定)', () => {
857
+ test('自动重启接线:落定链只取内存输入并直接分流调度(源码形态锁定)', () => {
825
858
  const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
826
859
  const settle = source.match(/const decision = judgeAutoRestart\(\{([\s\S]*?)\}\)/)
827
860
  assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
828
- assert.match(settle[1], /runtimeKind: runtimeEnv\.kind/, '落定判定必须消费运行环境检测结果')
829
- assert.match(source, /if \(decision\.requiresManualRestart === true\) last\.requiresManualRestart = true/, '手动直跑指引必须回写 last')
861
+ assert.doesNotMatch(settle[1], /runtimeKind|runtimeEnv/, '落定判定禁止消费运行环境:升级即调度与启动形态无关')
862
+ assert.doesNotMatch(source, /requiresManualRestart/, '手动直跑指引链必须整体移除,不得残留回写')
830
863
  assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
831
864
  })
832
865
 
833
- test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', async () => {
866
+ test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调度关机)', async () => {
867
+ // DSH_MAINTAIN_RUNTIME_ENV 已无消费者:注入历史值证明判定与运行环境彻底解耦
834
868
  process.env.DSH_MAINTAIN_RUNTIME_ENV = 'manual'
835
869
  try {
836
870
  const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
@@ -845,11 +879,9 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
845
879
  if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
846
880
  }
847
881
  assert.ok(settled, '升级应在假命令退出后落定')
848
- assert.equal(settled.runtimeEnv.kind, 'manual-start-likely', 'env 注入必须经 apply 检测进 status')
849
- assert.equal(settled.autoRestartScheduled, false, '手动直跑禁止调度自动重启')
850
- assert.deepEqual(exits, [], '手动直跑禁止任何宿主退出')
851
- // 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
852
- assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
882
+ assert.equal(settled.autoRestartScheduled, true, '升级成功勾选即调度,运行环境不得抑制')
883
+ assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '手动指引链已移除')
884
+ assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
853
885
  } finally {
854
886
  delete process.env.DSH_MAINTAIN_RUNTIME_ENV
855
887
  }
@@ -891,4 +923,216 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
891
923
  }
892
924
  assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=triggered/.test(text)), true, '升级触发须留审计行')
893
925
  assert.equal(plainWarns.some((text) => /audit endpoint=upgrade outcome=(ok|failed) durationMs=[0-9]+ code=/.test(text)), true, '升级落定须留审计行')
894
- })
926
+ })
927
+
928
+ // ---- release notes 路由 ----
929
+
930
+ const RELEASE_PATH = '/api/maintain/release-notes'
931
+ // 发布页 fixture:与真实页面同构(relative-time + markdown-body 正文容器),正文映射由 core 提取器完成
932
+ const RELEASE_HTML = '<!doctype html><html><body>'
933
+ + '<relative-time datetime="2026-09-10T03:09:00Z"></relative-time>'
934
+ + '<div data-test-selector="body-content" class="markdown-body">'
935
+ + '<h3>更新内容</h3>'
936
+ + '<ul><li>新功能</li><li>问题修复</li></ul>'
937
+ + '<p>详情见发布页。</p>'
938
+ + '</div></body></html>'
939
+ const RELEASE_URL = 'https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v9.9.9'
940
+ const RELEASE_BODY = ['### 更新内容', '- 新功能', '- 问题修复', '', '详情见发布页。'].join('\n')
941
+
942
+ function releaseHtmlBody() {
943
+ const chunks = [new TextEncoder().encode(RELEASE_HTML)]
944
+ return {
945
+ getReader: () => ({
946
+ read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
947
+ cancel: async () => {},
948
+ }),
949
+ }
950
+ }
951
+
952
+ // dist-tags 与 GitHub 发布页双上游分流:registry 路径回 tags 形态,GitHub 路径回页面形态
953
+ function installDualUpstream({ requestedUrls, releaseThrows }) {
954
+ const originalFetch = globalThis.fetch
955
+ globalThis.fetch = async (url, options) => {
956
+ const text = String(url)
957
+ if (text.includes('/releases/tag/')) {
958
+ requestedUrls.push(text)
959
+ if (releaseThrows) throw new Error(releaseThrows)
960
+ return { ok: true, status: 200, body: releaseHtmlBody() }
961
+ }
962
+ void options
963
+ return tagsBody(MOCK_TAGS)
964
+ }
965
+ return () => { globalThis.fetch = originalFetch }
966
+ }
967
+
968
+ async function waitSnapshotReady(routes) {
969
+ for (let waited = 0; waited < 5000; waited += 25) {
970
+ const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
971
+ if (poll && poll.checkedAt !== null) return poll
972
+ await new Promise((resolve) => setTimeout(resolve, 25))
973
+ }
974
+ return null
975
+ }
976
+
977
+ test('release-notes:默认取通道最新版,200 返回发布说明', async () => {
978
+ const requestedUrls = []
979
+ const restore = installDualUpstream({ requestedUrls })
980
+ try {
981
+ const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
982
+ apply(ctx)
983
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
984
+ const res = await get(routes, RELEASE_PATH)
985
+ assert.equal(res.status, 200)
986
+ assert.deepEqual(res.payload, {
987
+ version: '9.9.9',
988
+ url: RELEASE_URL,
989
+ publishedAt: '2026-09-10T03:09:00Z',
990
+ body: RELEASE_BODY,
991
+ })
992
+ assert.equal(requestedUrls.length, 1)
993
+ assert.ok(requestedUrls[0].endsWith('/releases/tag/dsh-v9.9.9'), '默认版本必须取通道 latest 的目标')
994
+ } finally {
995
+ restore()
996
+ }
997
+ })
998
+
999
+ test('release-notes:缓存生效,切通道后按新通道版本拉取', async () => {
1000
+ const requestedUrls = []
1001
+ const restore = installDualUpstream({ requestedUrls })
1002
+ try {
1003
+ const { ctx, routes } = makeCtx()
1004
+ apply(ctx)
1005
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1006
+ const first = await get(routes, RELEASE_PATH)
1007
+ assert.equal(first.status, 200)
1008
+ assert.equal(first.payload.version, '9.9.9')
1009
+ assert.ok(requestedUrls[0].endsWith('/releases/tag/dsh-v9.9.9'))
1010
+ const again = await get(routes, RELEASE_PATH)
1011
+ assert.equal(again.status, 200)
1012
+ assert.equal(requestedUrls.length, 1, '同版本两次调用必须命中缓存,上游仅一次')
1013
+ const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
1014
+ assert.equal(switched.status, 200)
1015
+ assert.equal(switched.payload.channelLatest, '10.0.0')
1016
+ const after = await get(routes, RELEASE_PATH)
1017
+ assert.equal(after.status, 200)
1018
+ assert.equal(after.payload.version, '10.0.0')
1019
+ assert.equal(requestedUrls.length, 2, '通道版本变化必须重新拉取')
1020
+ assert.ok(requestedUrls[1].endsWith('/releases/tag/dsh-v10.0.0'))
1021
+ } finally {
1022
+ restore()
1023
+ }
1024
+ })
1025
+
1026
+ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', async () => {
1027
+ const originalFetch = globalThis.fetch
1028
+ globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
1029
+ try {
1030
+ const { ctx, routes } = makeCtx()
1031
+ apply(ctx)
1032
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1033
+ const res = await get(routes, RELEASE_PATH)
1034
+ assert.equal(res.status, 400)
1035
+ assert.match(res.payload.error, /检查更新/)
1036
+ } finally {
1037
+ globalThis.fetch = originalFetch
1038
+ }
1039
+ })
1040
+
1041
+ test('release-notes:通道标签非 semver,构建上游标签前归一 400', async () => {
1042
+ const requestedUrls = []
1043
+ const originalFetch = globalThis.fetch
1044
+ globalThis.fetch = async (url, options) => {
1045
+ const text = String(url)
1046
+ if (text.includes('/releases/tag/')) {
1047
+ requestedUrls.push(text)
1048
+ return { ok: true, status: 200, body: releaseHtmlBody() }
1049
+ }
1050
+ return tagsBody({ latest: 'banana' })
1051
+ }
1052
+ try {
1053
+ const { ctx, routes } = makeCtx()
1054
+ apply(ctx)
1055
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1056
+ const res = await get(routes, RELEASE_PATH)
1057
+ assert.equal(res.status, 400)
1058
+ assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
1059
+ assert.equal(requestedUrls.length, 0, '拒绝路径不得触达上游')
1060
+ } finally {
1061
+ globalThis.fetch = originalFetch
1062
+ }
1063
+ })
1064
+
1065
+ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t) => {
1066
+ t.mock.timers.enable({ apis: ['setTimeout'] })
1067
+ const requestedUrls = []
1068
+ const restore = installDualUpstream({ requestedUrls })
1069
+ try {
1070
+ const { ctx, routes } = makeCtx({ appExit: () => {} })
1071
+ apply(ctx)
1072
+ const restart = await post(routes, '/api/maintain/restart')
1073
+ assert.equal(restart.status, 200)
1074
+ const res = await get(routes, RELEASE_PATH)
1075
+ assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
1076
+ assert.match(res.payload.error, /重启已调度/)
1077
+ assert.equal(requestedUrls.length, 0, '关机窗口内不得发起上游请求')
1078
+ t.mock.timers.tick(RESTART_DELAY_MS + 1)
1079
+ } finally {
1080
+ restore()
1081
+ }
1082
+ })
1083
+
1084
+ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
1085
+ // N+1 个通道各配独立版本:逐个切换并查看,第 N+1 键入缓存时第 1 键必被淘汰;
1086
+ // 回访第 1 通道触发重新拉取,回访末位通道命中缓存不再拉取
1087
+ const channels = {}
1088
+ for (let i = 1; i <= RELEASE_NOTES_CACHE_MAX + 1; i += 1) channels['c' + i] = '1.0.' + i
1089
+ const requestedUrls = []
1090
+ const originalFetch = globalThis.fetch
1091
+ globalThis.fetch = async (url) => {
1092
+ if (String(url).includes('/releases/tag/')) {
1093
+ requestedUrls.push(String(url))
1094
+ return { ok: true, status: 200, body: releaseHtmlBody() }
1095
+ }
1096
+ return tagsBody(channels)
1097
+ }
1098
+ try {
1099
+ const { ctx, routes } = makeCtx()
1100
+ apply(ctx)
1101
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1102
+ for (const [name, version] of Object.entries(channels)) {
1103
+ const switched = await post(routes, '/api/maintain/channel', { channel: name })
1104
+ assert.equal(switched.status, 200)
1105
+ const res = await get(routes, RELEASE_PATH)
1106
+ assert.equal(res.status, 200)
1107
+ assert.equal(res.payload.version, version)
1108
+ }
1109
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 1, '每通道版本独立,各拉取一次')
1110
+ const first = Object.keys(channels)[0]
1111
+ const last = Object.keys(channels)[RELEASE_NOTES_CACHE_MAX]
1112
+ const backToFirst = await post(routes, '/api/maintain/channel', { channel: first })
1113
+ assert.equal(backToFirst.status, 200)
1114
+ await get(routes, RELEASE_PATH)
1115
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 2, '最早键已被淘汰,回访必须重新拉取')
1116
+ const backToLast = await post(routes, '/api/maintain/channel', { channel: last })
1117
+ assert.equal(backToLast.status, 200)
1118
+ await get(routes, RELEASE_PATH)
1119
+ assert.equal(requestedUrls.length, RELEASE_NOTES_CACHE_MAX + 2, '未淘汰键回访必须命中缓存')
1120
+ } finally {
1121
+ globalThis.fetch = originalFetch
1122
+ }
1123
+ })
1124
+
1125
+ test('release-notes:上游失败 400 带错误信息', async () => {
1126
+ const requestedUrls = []
1127
+ const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
1128
+ try {
1129
+ const { ctx, routes } = makeCtx()
1130
+ apply(ctx)
1131
+ assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
1132
+ const res = await get(routes, RELEASE_PATH)
1133
+ assert.equal(res.status, 400)
1134
+ assert.match(res.payload.error, /ECONNRESET/)
1135
+ } finally {
1136
+ restore()
1137
+ }
1138
+ })
@@ -12,7 +12,6 @@ import {
12
12
  UPGRADE_FAIL_NPM_MISSING,
13
13
  UPGRADE_FAIL_TIMEOUT,
14
14
  } from '../src/core.mjs'
15
- import { RUNTIME_KINDS } from '../src/runtime.mjs'
16
15
 
17
16
  // 脚本体一律单引号:Windows shell 化 spawn 经 cmd.exe,双层双引号会被截断。
18
17
  const NODE = 'node'
@@ -219,29 +218,20 @@ test('重试:假命令真实进程 文件锁失败后重试成功', async () =>
219
218
  }
220
219
  })
221
220
 
222
- test('自动重启:四条件守卫(成功/非手动直跑/enabled/appExit 可用)', () => {
223
- // 成功+托管(含 unknown)+enabled=true → 调度
224
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
225
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
226
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.PM2, hasExit: true, enabled: true }), { schedule: true, requiresManualRestart: false })
221
+ test('自动重启:三条件守卫(成功/enabled/appExit 可用),运行环境不参与判定', () => {
222
+ // 成功+enabled → 调度:宿主退出动作与重启按钮同一入口,拉起责任在启动方式
223
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: true }), { schedule: true })
227
224
  // 失败不调度
228
- assert.deepEqual(judgeAutoRestart({ ok: false, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: false })
229
- // 手动直跑 → 手动指引,不调度
230
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: true }), { schedule: false, requiresManualRestart: true })
225
+ assert.deepEqual(judgeAutoRestart({ ok: false, hasExit: true, enabled: true }), { schedule: false })
231
226
  // appExit 缺失不调度
232
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: false })
233
- // 手动直跑与 appExit 缺失并存 → 仍以手动指引标记(指引面板,与退出能力无关)
234
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: false, enabled: true }), { schedule: false, requiresManualRestart: true })
227
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: false, enabled: true }), { schedule: false })
235
228
  })
236
229
 
237
230
  test('自动重启:enabled 严格 boolean,仅 true 调度', () => {
238
- // 显式 false:托管(含 unknown)不调度、不标手动指引
239
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
240
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.DECLARED_MANAGED, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: false })
241
- // 手动直跑指引是环境约束事实,不受勾选影响
242
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.MANUAL_START, hasExit: true, enabled: false }), { schedule: false, requiresManualRestart: true })
231
+ // 显式 false 不调度
232
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: false }), { schedule: false })
243
233
  // 非 true 一律不调度:调用点经升级路由严格 boolean 校验,此处锁定纯函数自身契约
244
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: undefined }), { schedule: false, requiresManualRestart: false })
245
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: null }), { schedule: false, requiresManualRestart: false })
246
- assert.deepEqual(judgeAutoRestart({ ok: true, runtimeKind: RUNTIME_KINDS.UNKNOWN, hasExit: true, enabled: 'true' }), { schedule: false, requiresManualRestart: false })
234
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: undefined }), { schedule: false })
235
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: null }), { schedule: false })
236
+ assert.deepEqual(judgeAutoRestart({ ok: true, hasExit: true, enabled: 'true' }), { schedule: false })
247
237
  })
package/src/runtime.mjs DELETED
@@ -1,69 +0,0 @@
1
- // 运行环境检测:判定宿主进程是否处于托管启动形态,决定升级后可否自动重启。
2
- // 纯函数,探测点经参数注入;顺序:env 显式覆盖 > 进程环境表 > POSIX 容器探测 > 双 TTY > unknown。
3
- import { stat, readFile } from 'node:fs/promises'
4
-
5
- export const RUNTIME_KINDS = Object.freeze({
6
- DECLARED_MANAGED: 'declared-managed',
7
- PM2: 'pm2',
8
- SYSTEMD: 'systemd',
9
- SUPERVISORD: 'supervisord',
10
- KUBERNETES: 'kubernetes',
11
- DOCKER: 'docker',
12
- CONTAINER: 'container',
13
- MANUAL_START: 'manual-start-likely',
14
- UNKNOWN: 'unknown',
15
- })
16
-
17
- // 环境覆盖键:managed|manual,测试与用户显式纠偏用
18
- const RUNTIME_ENV_OVERRIDE = 'DSH_MAINTAIN_RUNTIME_ENV'
19
- const OVERRIDE_MANAGED = 'managed'
20
- const OVERRIDE_MANUAL = 'manual'
21
-
22
- // 各托管形态的特征环境变量表:命中任一即声明
23
- const MANAGED_ENV_KEYS = Object.freeze([
24
- [RUNTIME_KINDS.PM2, Object.freeze(['pm_id', 'PM2_HOME', 'pm_uptime'])],
25
- [RUNTIME_KINDS.SYSTEMD, Object.freeze(['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET'])],
26
- [RUNTIME_KINDS.SUPERVISORD, Object.freeze(['SUPERVISOR_ENABLED', 'SUPERVISOR_PROCESS_NAME'])],
27
- [RUNTIME_KINDS.KUBERNETES, Object.freeze(['KUBERNETES_SERVICE_HOST'])],
28
- ])
29
-
30
- const DOCKERENV_PATH = '/.dockerenv'
31
- const CGROUP_PATH = '/proc/1/cgroup'
32
- // cgroup 归类:/docker/ 与 dockerenv 同为 docker;kubepods 即 k8s;其余容器运行时归 container
33
- const CGROUP_DOCKER_PATTERN = /\/docker\//
34
- const CGROUP_K8S_PATTERN = /kubepods/
35
- const CGROUP_CONTAINER_PATTERN = /containerd|lxc|podman/
36
-
37
- const fallbackExists = async (path) => {
38
- try {
39
- await stat(path)
40
- return true
41
- } catch {
42
- return false
43
- }
44
- }
45
-
46
- const fallbackRead = async (path) => readFile(path, 'utf8')
47
-
48
- export async function detectRuntimeEnv({ env, platform, isTTY, existsImpl = fallbackExists, readFileImpl = fallbackRead }) {
49
- const override = env[RUNTIME_ENV_OVERRIDE]
50
- if (override === OVERRIDE_MANAGED) return { kind: RUNTIME_KINDS.DECLARED_MANAGED, declared: true }
51
- if (override === OVERRIDE_MANUAL) return { kind: RUNTIME_KINDS.MANUAL_START, declared: false }
52
-
53
- for (const [kind, keys] of MANAGED_ENV_KEYS) {
54
- if (keys.some((key) => env[key])) return { kind, declared: true }
55
- }
56
-
57
- if (platform !== 'win32') {
58
- try {
59
- if (await existsImpl(DOCKERENV_PATH)) return { kind: RUNTIME_KINDS.DOCKER, declared: true }
60
- const cgroup = await readFileImpl(CGROUP_PATH)
61
- if (CGROUP_DOCKER_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.DOCKER, declared: true }
62
- if (CGROUP_K8S_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.KUBERNETES, declared: true }
63
- if (CGROUP_CONTAINER_PATTERN.test(cgroup)) return { kind: RUNTIME_KINDS.CONTAINER, declared: true }
64
- } catch { /* 探测失败按未声明继续 */ }
65
- }
66
-
67
- if (isTTY.stdin === true && isTTY.stdout === true) return { kind: RUNTIME_KINDS.MANUAL_START, declared: false }
68
- return { kind: RUNTIME_KINDS.UNKNOWN, declared: false }
69
- }