@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
|
@@ -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,
|
|
208
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
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/, '补查前后必须验让位新观察')
|
package/test/route.test.mjs
CHANGED
|
@@ -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('挂载:
|
|
140
|
+
test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
|
|
141
141
|
const { ctx, routes } = makeCtx()
|
|
142
142
|
apply(ctx)
|
|
143
|
-
assert.equal(routes.size,
|
|
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
|
-
|
|
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
|
|
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
|
|
292
|
+
test('upgrade:运行版本已是通道最新放行(重装/回退场景),不再 409 拒绝', async () => {
|
|
299
293
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
300
294
|
// 版本探测注入受控值:CI 无 dsh 本体,真实盘读回 null 会把 verdict 打成 unknown,
|
|
301
|
-
//
|
|
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
|
-
//
|
|
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
|
|
315
|
-
assert.equal(
|
|
316
|
-
assert.
|
|
317
|
-
|
|
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
|
-
//
|
|
614
|
-
|
|
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.
|
|
829
|
-
assert.
|
|
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
|
|
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.
|
|
849
|
-
assert.equal(settled.
|
|
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
|
+
})
|
package/test/upgrade.test.mjs
CHANGED
|
@@ -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('
|
|
223
|
-
//
|
|
224
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
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,
|
|
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,
|
|
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
|
|
239
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
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,
|
|
245
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
246
|
-
assert.deepEqual(judgeAutoRestart({ ok: true,
|
|
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
|
-
}
|