@mzzsfy/dsh-maintain 0.9.0 → 0.11.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 +8 -4
- package/package.json +2 -3
- package/src/client.js +18 -14
- package/src/index.js +87 -80
- package/test/parity.test.mjs +12 -19
- package/test/restart-ready.test.mjs +7 -8
- package/test/route.test.mjs +125 -88
- package/test/upgrade.test.mjs +10 -20
- package/src/runtime.mjs +0 -69
- package/test/runtime.test.mjs +0 -67
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, RELEASE_NOTES_CACHE_MAX, collectActiveWork } from '../src/index.js'
|
|
9
|
+
import { apply, Config, DEFAULT_CHANNEL, DEFAULT_POLL_INTERVAL_SEC, DEFAULT_UPGRADE_TEMPLATE, DEFAULT_REGISTRY_BASE, 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
|
// 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
|
|
@@ -35,16 +35,46 @@ function tagsBody(tags) {
|
|
|
35
35
|
|
|
36
36
|
globalThis.fetch = async () => tagsBody(MOCK_TAGS)
|
|
37
37
|
|
|
38
|
-
function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {} } = {}) {
|
|
38
|
+
function makeCtx({ appExit, settingsStore, settingsAvailable = true, legacySettings = false, configEditorAvailable = true, timerAvailable = true, services = {} } = {}) {
|
|
39
39
|
const routes = new Map()
|
|
40
40
|
let tick = null
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// 条目配置桩:settingsStore 对象即 apply 入参 config(0.1.7 形态的宿主解析配置);
|
|
42
|
+
// configEditor 桩经 change 合并后原样写回,模拟 loader 仅 volatile 变化的原地热更。
|
|
43
|
+
// legacy 形态下 settingsStore 别名 settings 命名空间存储,config 为空对象
|
|
44
|
+
const config = legacySettings ? {} : (settingsStore ?? {})
|
|
45
|
+
const legacyStore = legacySettings ? (settingsStore ?? {}) : {}
|
|
46
|
+
const calls = { exits: [], registered: [], disposers: [], editCalls: [], configureCalls: [], registerCalls: [] }
|
|
47
|
+
const settingsService = {
|
|
48
|
+
configure(presentation, owner) {
|
|
49
|
+
calls.configureCalls.push({ presentation, owner })
|
|
50
|
+
return () => {}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
if (legacySettings) {
|
|
54
|
+
// legacy(≤0.1.6)settings 服务方法面:register 声明命名空间,get/update 命名空间语义
|
|
55
|
+
settingsService.register = (ns, schema) => {
|
|
56
|
+
calls.registerCalls.push({ ns, schema })
|
|
57
|
+
}
|
|
58
|
+
settingsService.get = (ns) => legacyStore
|
|
59
|
+
settingsService.update = async (ns, patch) => {
|
|
60
|
+
Object.assign(legacyStore, patch)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const configEditor = {
|
|
64
|
+
async edit(entry, change) {
|
|
65
|
+
calls.editCalls.push(entry)
|
|
66
|
+
const next = change({ ...config }, {})
|
|
67
|
+
for (const [key, value] of Object.entries(next)) config[key] = value
|
|
68
|
+
},
|
|
69
|
+
}
|
|
43
70
|
const ctx = {
|
|
44
71
|
calls,
|
|
72
|
+
// fiber 桩:0.1.7 写路径经 fiber.entry 定位 profile 条目
|
|
73
|
+
fiber: { entry: { options: { id: 'maintain', name: '@mzzsfy/dsh-maintain' } } },
|
|
45
74
|
get(name) {
|
|
46
75
|
if (name === 'appExit') return appExit
|
|
47
|
-
if (name === 'settings') return settingsService
|
|
76
|
+
if (name === 'settings') return settingsAvailable ? settingsService : undefined
|
|
77
|
+
if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
|
|
48
78
|
if (Object.prototype.hasOwnProperty.call(services, name)) return services[name]
|
|
49
79
|
return undefined
|
|
50
80
|
},
|
|
@@ -57,7 +87,11 @@ function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {}
|
|
|
57
87
|
// timer 服务桩:模拟宿主 timer 激活后的 interval(返回 disposer 同官方契约);
|
|
58
88
|
// timerAvailable=false 模拟服务缺失
|
|
59
89
|
fn({
|
|
60
|
-
settings: settingsService,
|
|
90
|
+
settings: settingsAvailable ? settingsService : undefined,
|
|
91
|
+
effect(stubEffect) {
|
|
92
|
+
const disposer = stubEffect()
|
|
93
|
+
if (typeof disposer === 'function') calls.disposers.push(disposer)
|
|
94
|
+
},
|
|
61
95
|
interval: timerAvailable
|
|
62
96
|
? (intervalFn) => {
|
|
63
97
|
tick = intervalFn
|
|
@@ -79,16 +113,8 @@ function makeCtx({ appExit, settingsStore, timerAvailable = true, services = {}
|
|
|
79
113
|
for (const disposer of calls.disposers) disposer()
|
|
80
114
|
},
|
|
81
115
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
get() {
|
|
85
|
-
return store
|
|
86
|
-
},
|
|
87
|
-
async update(ns, patch) {
|
|
88
|
-
Object.assign(store, patch)
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
return { ctx, routes }
|
|
116
|
+
apply(ctx, config)
|
|
117
|
+
return { ctx, routes, config }
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
function makeReq({ method = 'POST', body, headers = {} } = {}) {
|
|
@@ -139,7 +165,6 @@ const realSleep = (ms) => new Promise((resolve) => {
|
|
|
139
165
|
|
|
140
166
|
test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
|
|
141
167
|
const { ctx, routes } = makeCtx()
|
|
142
|
-
apply(ctx)
|
|
143
168
|
assert.equal(routes.size, 9)
|
|
144
169
|
assert.deepEqual(
|
|
145
170
|
[...routes.keys()].sort(),
|
|
@@ -168,9 +193,69 @@ test('挂载:9 条路由注册,启动检查后快照就绪', async () => {
|
|
|
168
193
|
assert.equal(res.payload.pollRunning, true, 'timer 服务激活时自动轮询应武装')
|
|
169
194
|
})
|
|
170
195
|
|
|
196
|
+
test('Config 导出契约:根级 volatile 包装,validate 产出整节单 ref,默认值对拍 DEFAULT_* 常量', () => {
|
|
197
|
+
const resolved = Config['~standard'].validate({})
|
|
198
|
+
assert.equal(resolved.issues, undefined)
|
|
199
|
+
const value = resolved.value
|
|
200
|
+
assert.equal(typeof value?.get, 'function', '根级 volatile 须产出整节单 ref(get 协议)')
|
|
201
|
+
const section = value.get()
|
|
202
|
+
assert.equal(section.channel, DEFAULT_CHANNEL)
|
|
203
|
+
assert.equal(section.pollIntervalSec, DEFAULT_POLL_INTERVAL_SEC)
|
|
204
|
+
assert.equal(section.upgradeCommandTemplate, DEFAULT_UPGRADE_TEMPLATE)
|
|
205
|
+
assert.equal(section.registryBase, DEFAULT_REGISTRY_BASE)
|
|
206
|
+
const provided = Config['~standard'].validate({ channel: 'next' }).value.get()
|
|
207
|
+
assert.equal(provided.channel, 'next')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('0.1.7 形态:configure 关闭原生自动页,owner 为本插件 fiber', () => {
|
|
211
|
+
const { ctx } = makeCtx()
|
|
212
|
+
assert.deepEqual(ctx.calls.configureCalls, [{ presentation: { auto: false }, owner: ctx.fiber }])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('0.1.7 形态:通道切换经 configEditor.edit 定位 maintain 条目并合并写回', async () => {
|
|
216
|
+
const store = {}
|
|
217
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
218
|
+
const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
|
|
219
|
+
assert.equal(switched.status, 200)
|
|
220
|
+
assert.equal(ctx.calls.editCalls.length, 1)
|
|
221
|
+
assert.equal(ctx.calls.editCalls[0].options.id, 'maintain')
|
|
222
|
+
assert.equal(ctx.calls.editCalls[0].options.name, '@mzzsfy/dsh-maintain')
|
|
223
|
+
assert.equal(store.channel, 'next')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('0.1.7 形态:settings 缺席干净降级——挂载照常、自动页策略缺席、写走 configEditor', async () => {
|
|
227
|
+
const store = {}
|
|
228
|
+
const { ctx, routes } = makeCtx({ settingsAvailable: false, settingsStore: store })
|
|
229
|
+
assert.deepEqual(ctx.calls.configureCalls, [])
|
|
230
|
+
const switched = await post(routes, '/api/maintain/channel', { channel: 'next' })
|
|
231
|
+
assert.equal(switched.status, 200)
|
|
232
|
+
assert.equal(store.channel, 'next')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('0.1.7 形态:configEditor 缺失即 500,条目配置不被改动', async () => {
|
|
236
|
+
const store = {}
|
|
237
|
+
const { routes } = makeCtx({ configEditorAvailable: false, settingsStore: store })
|
|
238
|
+
const denied = await post(routes, '/api/maintain/channel', { channel: 'next' })
|
|
239
|
+
assert.equal(denied.status, 500)
|
|
240
|
+
assert.deepEqual(denied.payload, { error: 'configEditor 服务不可用' })
|
|
241
|
+
assert.equal(store.channel, undefined)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('legacy 形态:register 注册 maintain 命名空间,get 读预设,写走 settings.update', async () => {
|
|
245
|
+
const store = { channel: 'next' }
|
|
246
|
+
const { ctx, routes } = makeCtx({ legacySettings: true, settingsStore: store })
|
|
247
|
+
assert.deepEqual(ctx.calls.registerCalls.map((row) => row.ns), ['maintain'])
|
|
248
|
+
assert.equal(ctx.calls.registerCalls[0].schema, Config, 'legacy register 须复用 Config 导出')
|
|
249
|
+
const status = await get(routes, '/api/maintain/status')
|
|
250
|
+
assert.equal(status.payload.channel, 'next', 'legacy 形态读 settings 命名空间')
|
|
251
|
+
const switched = await post(routes, '/api/maintain/channel', { channel: 'latest' })
|
|
252
|
+
assert.equal(switched.status, 200)
|
|
253
|
+
assert.equal(store.channel, 'latest')
|
|
254
|
+
assert.equal(ctx.calls.editCalls.length, 0, 'legacy 形态不得触碰 configEditor')
|
|
255
|
+
})
|
|
256
|
+
|
|
171
257
|
test('timer 服务缺失:自动轮询降级,面板状态照常响应', async () => {
|
|
172
258
|
const { ctx, routes } = makeCtx({ timerAvailable: false })
|
|
173
|
-
apply(ctx)
|
|
174
259
|
const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
175
260
|
assert.equal(res.status, 200)
|
|
176
261
|
assert.equal(res.payload.pollRunning, false)
|
|
@@ -182,7 +267,6 @@ test('timer 服务缺失:自动轮询降级,面板状态照常响应', async ()
|
|
|
182
267
|
|
|
183
268
|
test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval 回归)', async () => {
|
|
184
269
|
const { ctx, routes } = makeCtx()
|
|
185
|
-
apply(ctx)
|
|
186
270
|
assert.equal(ctx.calls.disposers.length > 0, true, 'interval dispose 必须经 ctx.effect 挂回插件 fiber')
|
|
187
271
|
// 等启动检查落定,排除其 checkedAt 变化对断言的干扰
|
|
188
272
|
let baseline = null
|
|
@@ -200,7 +284,6 @@ test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval
|
|
|
200
284
|
|
|
201
285
|
test('方法守卫:全部路由错误方法一律 405', async () => {
|
|
202
286
|
const { ctx, routes } = makeCtx()
|
|
203
|
-
apply(ctx)
|
|
204
287
|
// GET 端点(status/release-notes)以 POST 拒绝,其余以 GET 拒绝
|
|
205
288
|
const readPaths = ['/api/maintain/status', '/api/maintain/release-notes']
|
|
206
289
|
for (const path of routes.keys()) {
|
|
@@ -212,7 +295,6 @@ test('方法守卫:全部路由错误方法一律 405', async () => {
|
|
|
212
295
|
|
|
213
296
|
test('跨源守卫:Origin 与 Host 不符即 403;同源放行;Host 大小写归一', async () => {
|
|
214
297
|
const { ctx, routes } = makeCtx()
|
|
215
|
-
apply(ctx)
|
|
216
298
|
const evil = await post(routes, '/api/maintain/refresh', undefined, { origin: 'https://evil.example', host: 'localhost:3000' })
|
|
217
299
|
assert.equal(evil.status, 403)
|
|
218
300
|
const ok = await post(routes, '/api/maintain/refresh', undefined, { origin: 'http://localhost:3000' })
|
|
@@ -223,7 +305,6 @@ test('跨源守卫:Origin 与 Host 不符即 403;同源放行;Host 大小写归
|
|
|
223
305
|
|
|
224
306
|
test('refresh:触发检查并返回 200 快照', async () => {
|
|
225
307
|
const { ctx, routes } = makeCtx()
|
|
226
|
-
apply(ctx)
|
|
227
308
|
const res = await post(routes, '/api/maintain/refresh')
|
|
228
309
|
assert.equal(res.status, 200)
|
|
229
310
|
assert.ok(res.payload.checkedAt !== null)
|
|
@@ -232,7 +313,6 @@ test('refresh:触发检查并返回 200 快照', async () => {
|
|
|
232
313
|
test('channel:空值 400;非法字符 400;不在 tags 400;合法通道走白名单放行', async () => {
|
|
233
314
|
const store = {}
|
|
234
315
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
235
|
-
apply(ctx)
|
|
236
316
|
|
|
237
317
|
const empty = await post(routes, '/api/maintain/channel', { channel: ' ' })
|
|
238
318
|
assert.equal(empty.status, 400)
|
|
@@ -254,7 +334,6 @@ test('channel:空值 400;非法字符 400;不在 tags 400;合法通道走白名
|
|
|
254
334
|
test('upgrade-template:空值 400;合法值持久化', async () => {
|
|
255
335
|
const store = {}
|
|
256
336
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
257
|
-
apply(ctx)
|
|
258
337
|
const empty = await post(routes, '/api/maintain/upgrade-template', { template: '' })
|
|
259
338
|
assert.equal(empty.status, 400)
|
|
260
339
|
const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
|
|
@@ -268,7 +347,6 @@ test('upgrade-template:空值 400;合法值持久化', async () => {
|
|
|
268
347
|
test('poll-interval:负数 400;合法值持久化', async () => {
|
|
269
348
|
const store = {}
|
|
270
349
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
271
|
-
apply(ctx)
|
|
272
350
|
const bad = await post(routes, '/api/maintain/poll-interval', { seconds: -1 })
|
|
273
351
|
assert.equal(bad.status, 400)
|
|
274
352
|
const wide = await post(routes, '/api/maintain/poll-interval', { seconds: '60' })
|
|
@@ -281,7 +359,6 @@ test('poll-interval:负数 400;合法值持久化', async () => {
|
|
|
281
359
|
test('registry-base:非法 scheme 400;合法值持久化', async () => {
|
|
282
360
|
const store = {}
|
|
283
361
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
284
|
-
apply(ctx)
|
|
285
362
|
const bad = await post(routes, '/api/maintain/registry-base', { base: 'ftp://mirror.example' })
|
|
286
363
|
assert.equal(bad.status, 400)
|
|
287
364
|
const ok = await post(routes, '/api/maintain/registry-base', { base: 'https://mirror.example' })
|
|
@@ -297,7 +374,6 @@ test('upgrade:运行版本已是通道最新放行(重装/回退场景),不再 4
|
|
|
297
374
|
settingsStore: store,
|
|
298
375
|
services: { hostVersionProbe: () => Promise.resolve('5.4.3') },
|
|
299
376
|
})
|
|
300
|
-
apply(ctx)
|
|
301
377
|
// 注入运行版本远高于假目标版本:verdict 应转 up-to-date,安装入口照常放行
|
|
302
378
|
const originalFetch = globalThis.fetch
|
|
303
379
|
globalThis.fetch = async () => tagsBody({ latest: '0.0.1', next: '0.0.2' })
|
|
@@ -331,8 +407,7 @@ test('upgrade:verdict unknown 放行(tags 未就绪不得 409 误拒)', async ()
|
|
|
331
407
|
globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
|
|
332
408
|
try {
|
|
333
409
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
334
|
-
|
|
335
|
-
let snapshot = null
|
|
410
|
+
let snapshot = null
|
|
336
411
|
for (let waited = 0; waited < 5000 && snapshot === null; waited += 25) {
|
|
337
412
|
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
338
413
|
if (poll.checkedAt !== null) snapshot = poll
|
|
@@ -359,7 +434,6 @@ test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => { //
|
|
|
359
434
|
// 门闩语义由"真实挂起命令"用例覆盖,此处锁定保存侧空白拒绝
|
|
360
435
|
const store = {}
|
|
361
436
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
362
|
-
apply(ctx)
|
|
363
437
|
const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
|
|
364
438
|
assert.equal(blank.status, 400)
|
|
365
439
|
assert.equal(store.upgradeCommandTemplate, undefined)
|
|
@@ -368,7 +442,6 @@ test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => { //
|
|
|
368
442
|
test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查', async () => {
|
|
369
443
|
const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
|
|
370
444
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
371
|
-
apply(ctx)
|
|
372
445
|
const baseline = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
373
446
|
const first = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
374
447
|
assert.equal(first.status, 200)
|
|
@@ -402,8 +475,7 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
|
|
|
402
475
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
403
476
|
try {
|
|
404
477
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
405
|
-
|
|
406
|
-
// 等启动检查落定,固定网络请求基线(mock timers 域内以 realSleep 自旋让出事件循环)
|
|
478
|
+
// 等启动检查落定,固定网络请求基线(mock timers 域内以 realSleep 自旋让出事件循环)
|
|
407
479
|
let baseline = null
|
|
408
480
|
for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
|
|
409
481
|
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
@@ -424,10 +496,8 @@ test('upgrade:托管+勾选自动重启,命令成功即调度关机且落定钩
|
|
|
424
496
|
// 关机路径零复读:版本复读属升级后磁盘读取,是明确的故障源,砍掉
|
|
425
497
|
assert.equal(settled.upgrade.last.installedVersion, null, '关机路径禁止复读磁盘版本')
|
|
426
498
|
assert.equal(settled.upgrade.last.stale, null, '关机路径无 stale 判定')
|
|
427
|
-
assert.equal(settled.upgrade.last.requiresManualRestart, undefined)
|
|
428
499
|
assert.equal(settled.autoRestartScheduled, true, '命令成功即调度关机(stale 不再抑制)')
|
|
429
500
|
assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
|
|
430
|
-
assert.ok(typeof settled.runtimeEnv === 'object' && typeof settled.runtimeEnv.kind === 'string', 'runtimeEnv 必须进 status')
|
|
431
501
|
// 落定钩子零网络:观察窗口内 fetch 计数不得增长(runCheck 已随关机路径移除)
|
|
432
502
|
await realSleep(300)
|
|
433
503
|
assert.equal(fetchCalls, baseline, '落定钩子禁止网络请求')
|
|
@@ -445,7 +515,6 @@ test('restart:升级进行中 409 拒绝且不调度退出', async () => {
|
|
|
445
515
|
const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
|
|
446
516
|
const exits = []
|
|
447
517
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
448
|
-
apply(ctx)
|
|
449
518
|
const upgrade = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
450
519
|
assert.equal(upgrade.status, 200)
|
|
451
520
|
const denied = await post(routes, '/api/maintain/restart')
|
|
@@ -465,7 +534,6 @@ test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
|
|
|
465
534
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
466
535
|
const exits = []
|
|
467
536
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
468
|
-
apply(ctx)
|
|
469
537
|
const restart = await post(routes, '/api/maintain/restart')
|
|
470
538
|
assert.equal(restart.status, 200)
|
|
471
539
|
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
@@ -488,8 +556,7 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
|
|
|
488
556
|
try {
|
|
489
557
|
const exits = []
|
|
490
558
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
491
|
-
|
|
492
|
-
let baseline = null
|
|
559
|
+
let baseline = null
|
|
493
560
|
for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
|
|
494
561
|
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
495
562
|
if (poll && poll.checkedAt !== null) baseline = fetchCalls
|
|
@@ -514,7 +581,6 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
|
|
|
514
581
|
|
|
515
582
|
test('status:快照携带 bootAt 实例代际', async () => {
|
|
516
583
|
const { ctx, routes } = makeCtx()
|
|
517
|
-
apply(ctx)
|
|
518
584
|
const status = await get(routes, '/api/maintain/status')
|
|
519
585
|
assert.equal(status.status, 200)
|
|
520
586
|
assert.equal(typeof status.payload.bootAt, 'number')
|
|
@@ -523,7 +589,6 @@ test('status:快照携带 bootAt 实例代际', async () => {
|
|
|
523
589
|
|
|
524
590
|
test('status:运行版本与已装版本双字段,verdict 以运行版本为准', async () => {
|
|
525
591
|
const { ctx, routes } = makeCtx()
|
|
526
|
-
apply(ctx)
|
|
527
592
|
// 等启动检查落定:installedVersion 由检查快照填充
|
|
528
593
|
let ready = null
|
|
529
594
|
for (let waited = 0; waited < 5000 && ready === null; waited += 25) {
|
|
@@ -547,7 +612,6 @@ test('status:运行版本与已装版本双字段,verdict 以运行版本为准'
|
|
|
547
612
|
test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
|
|
548
613
|
const store = {}
|
|
549
614
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
550
|
-
apply(ctx)
|
|
551
615
|
const huge = await post(routes, '/api/maintain/poll-interval', { seconds: 1e308 })
|
|
552
616
|
assert.equal(huge.status, 400)
|
|
553
617
|
assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
|
|
@@ -563,7 +627,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
|
|
|
563
627
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
564
628
|
})
|
|
565
629
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
566
|
-
apply(ctx)
|
|
567
630
|
// 排空启动期微任务链,探针读序此后稳定
|
|
568
631
|
await drainMicrotasks()
|
|
569
632
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
|
|
@@ -581,7 +644,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
|
|
|
581
644
|
assert.equal(settled.upgrade.last.stale, true, '继续运行路径必须复读并标注 stale')
|
|
582
645
|
assert.ok(typeof settled.upgrade.last.reason === 'string' && settled.upgrade.last.reason.length > 0, 'stale 必须带原因')
|
|
583
646
|
assert.equal(settled.autoRestartScheduled, false, '未勾选不得置调度标记')
|
|
584
|
-
assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '托管环境未勾选不标手动指引')
|
|
585
647
|
// 推过完整调度延迟:若误调度,延迟窗口内 exit 必被调用
|
|
586
648
|
t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
|
|
587
649
|
assert.deepEqual(exits, [], '未勾选自动重启时禁止调度任何宿主退出')
|
|
@@ -601,7 +663,6 @@ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参
|
|
|
601
663
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
602
664
|
})
|
|
603
665
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
604
|
-
apply(ctx)
|
|
605
666
|
await drainMicrotasks()
|
|
606
667
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
|
|
607
668
|
assert.equal(trigger.status, 200)
|
|
@@ -633,7 +694,6 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
|
|
|
633
694
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
634
695
|
})
|
|
635
696
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
636
|
-
apply(ctx)
|
|
637
697
|
await drainMicrotasks()
|
|
638
698
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
639
699
|
assert.equal(trigger.status, 200)
|
|
@@ -647,9 +707,8 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
|
|
|
647
707
|
assert.ok(settled, '升级应在假命令退出后落定')
|
|
648
708
|
assert.equal(settled.upgrade.last.ok, true)
|
|
649
709
|
assert.equal(settled.upgrade.last.stale, null, '关机路径零复读,stale 不再判定')
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
assert.equal(scheduled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
|
|
710
|
+
// 调度标记与 running=false 同拍置位,落定快照直接可断言
|
|
711
|
+
assert.equal(settled.autoRestartScheduled, true, '勾选自动重启时升级成功必须调度')
|
|
653
712
|
assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
|
|
654
713
|
t.mock.timers.tick(AUTO_RESTART_DELAY_MS + 1)
|
|
655
714
|
assert.deepEqual(exits, [0], '延迟窗口过后必须调度宿主退出')
|
|
@@ -662,7 +721,6 @@ test('upgrade:autoRestart 缺失或非 boolean 一律 400', async () => {
|
|
|
662
721
|
// 假命令兜底:本用例期待 400,但若校验意外放行,真实默认模板会当场触发 npm install
|
|
663
722
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
664
723
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
665
|
-
apply(ctx)
|
|
666
724
|
const missing = await post(routes, '/api/maintain/upgrade')
|
|
667
725
|
assert.equal(missing.status, 400, '缺失 autoRestart 必须拒绝,防静默翻转关机行为')
|
|
668
726
|
const stringInput = await post(routes, '/api/maintain/upgrade', { autoRestart: 'true' })
|
|
@@ -677,7 +735,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
|
|
|
677
735
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
678
736
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
679
737
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: () => {} })
|
|
680
|
-
apply(ctx)
|
|
681
738
|
await post(routes, '/api/maintain/restart')
|
|
682
739
|
const denied = await post(routes, '/api/maintain/upgrade')
|
|
683
740
|
assert.equal(denied.status, 400, 'body 非法时即使门控(重启已调度)命中也必须 400')
|
|
@@ -686,7 +743,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
|
|
|
686
743
|
test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
687
744
|
const store = {}
|
|
688
745
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
689
|
-
apply(ctx)
|
|
690
746
|
const withQuery = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com?mirror=1' })
|
|
691
747
|
assert.equal(withQuery.status, 400)
|
|
692
748
|
const withHash = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com#frag' })
|
|
@@ -696,14 +752,12 @@ test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
|
696
752
|
|
|
697
753
|
test('restart:缺失 appExit 500;响应立即返回,延迟退出', async (t) => {
|
|
698
754
|
const withoutExit = makeCtx()
|
|
699
|
-
apply(withoutExit.ctx)
|
|
700
755
|
const denied = await post(withoutExit.routes, '/api/maintain/restart')
|
|
701
756
|
assert.equal(denied.status, 500)
|
|
702
757
|
|
|
703
758
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
704
759
|
const exits = []
|
|
705
760
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
706
|
-
apply(ctx)
|
|
707
761
|
const ok = await post(routes, '/api/maintain/restart')
|
|
708
762
|
assert.equal(ok.status, 200)
|
|
709
763
|
assert.equal(ok.payload.restarting, true)
|
|
@@ -717,7 +771,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
|
|
|
717
771
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
718
772
|
const exits = []
|
|
719
773
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
720
|
-
apply(ctx)
|
|
721
774
|
const first = await post(routes, '/api/maintain/restart')
|
|
722
775
|
assert.equal(first.status, 200)
|
|
723
776
|
const second = await post(routes, '/api/maintain/restart')
|
|
@@ -729,7 +782,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
|
|
|
729
782
|
|
|
730
783
|
test('readBody 超限:路由归一 400', async () => {
|
|
731
784
|
const { ctx, routes } = makeCtx()
|
|
732
|
-
apply(ctx)
|
|
733
785
|
const req = makeReq({ method: 'POST' })
|
|
734
786
|
const res = makeRes()
|
|
735
787
|
const done = routes.get('/api/maintain/channel')(req, res)
|
|
@@ -810,7 +862,6 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
|
|
|
810
862
|
const jobs = { list: () => [] }
|
|
811
863
|
const terminals = { list: () => [] }
|
|
812
864
|
const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
|
|
813
|
-
apply(ctx)
|
|
814
865
|
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
815
866
|
assert.equal(denied.status, 409)
|
|
816
867
|
assert.match(denied.payload.error, /活跃工作/)
|
|
@@ -826,7 +877,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
|
|
|
826
877
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
827
878
|
const jobs = { list: () => [] }
|
|
828
879
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code), services: { agents, jobs } })
|
|
829
|
-
apply(ctx)
|
|
830
880
|
const denied = await post(routes, '/api/maintain/restart')
|
|
831
881
|
assert.equal(denied.status, 409)
|
|
832
882
|
assert.match(denied.payload.error, /活跃工作/)
|
|
@@ -839,7 +889,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
|
|
|
839
889
|
|
|
840
890
|
test('status:activeWork 概要进快照,服务缺失标 detectionAvailable=false', async () => {
|
|
841
891
|
const { ctx, routes } = makeCtx()
|
|
842
|
-
apply(ctx)
|
|
843
892
|
const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
844
893
|
assert.equal(status.activeWork.total, 0)
|
|
845
894
|
assert.equal(status.activeWork.detectionAvailable, false)
|
|
@@ -851,30 +900,29 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
|
|
|
851
900
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
852
901
|
const exits = []
|
|
853
902
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
854
|
-
apply(ctx)
|
|
855
903
|
const ok = await post(routes, '/api/maintain/restart', 'null')
|
|
856
904
|
assert.equal(ok.status, 200, 'null 体须按空体处理,不得以内部错误形态 400/500 泄漏')
|
|
857
905
|
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
858
906
|
assert.deepEqual(exits, [0], 'null 体等价空体:正常调度退出')
|
|
859
907
|
})
|
|
860
908
|
|
|
861
|
-
test('
|
|
909
|
+
test('自动重启接线:落定链只取内存输入并直接分流调度(源码形态锁定)', () => {
|
|
862
910
|
const source = readFileSync(new URL('../src/index.js', import.meta.url), 'utf8')
|
|
863
911
|
const settle = source.match(/const decision = judgeAutoRestart\(\{([\s\S]*?)\}\)/)
|
|
864
912
|
assert.ok(settle, '落定链缺少 judgeAutoRestart 判定')
|
|
865
|
-
assert.
|
|
866
|
-
assert.
|
|
913
|
+
assert.doesNotMatch(settle[1], /runtimeKind|runtimeEnv/, '落定判定禁止消费运行环境:升级即调度与启动形态无关')
|
|
914
|
+
assert.doesNotMatch(source, /requiresManualRestart/, '手动直跑指引链必须整体移除,不得残留回写')
|
|
867
915
|
assert.match(source, /if \(decision\.schedule === true\) \{[\s\S]*?last\.autoRestartScheduled = true[\s\S]*?scheduleHostExit\(\{ detail: 'reason=upgrade-ok', autoRestart: true/, '调度链必须置位标记并经统一退出入口关机')
|
|
868
916
|
})
|
|
869
917
|
|
|
870
|
-
test('upgrade
|
|
918
|
+
test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调度关机)', async () => {
|
|
919
|
+
// DSH_MAINTAIN_RUNTIME_ENV 已无消费者:注入历史值证明判定与运行环境彻底解耦
|
|
871
920
|
process.env.DSH_MAINTAIN_RUNTIME_ENV = 'manual'
|
|
872
921
|
try {
|
|
873
922
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
874
923
|
const exits = []
|
|
875
924
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
876
|
-
|
|
877
|
-
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
925
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
878
926
|
let settled = null
|
|
879
927
|
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
880
928
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -882,11 +930,9 @@ test('upgrade:env 注入手动直跑环境,落定链保守分流零退出', asyn
|
|
|
882
930
|
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
883
931
|
}
|
|
884
932
|
assert.ok(settled, '升级应在假命令退出后落定')
|
|
885
|
-
assert.equal(settled.
|
|
886
|
-
assert.equal(settled.
|
|
887
|
-
assert.deepEqual(exits, [], '
|
|
888
|
-
// 手动直跑标环境指引:命令成功即提示手动重启(指引不再被 stale 抑制)
|
|
889
|
-
assert.equal(settled.upgrade.last.requiresManualRestart, true, '手动直跑必须标手动重启指引')
|
|
933
|
+
assert.equal(settled.autoRestartScheduled, true, '升级成功勾选即调度,运行环境不得抑制')
|
|
934
|
+
assert.equal(settled.upgrade.last.requiresManualRestart, undefined, '手动指引链已移除')
|
|
935
|
+
assert.deepEqual(exits, [], '调度延迟窗口内不得提前退出')
|
|
890
936
|
} finally {
|
|
891
937
|
delete process.env.DSH_MAINTAIN_RUNTIME_ENV
|
|
892
938
|
}
|
|
@@ -901,8 +947,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
901
947
|
try {
|
|
902
948
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
903
949
|
const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
|
|
904
|
-
|
|
905
|
-
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
950
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
906
951
|
assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
|
|
907
952
|
await post(routes, '/api/maintain/restart')
|
|
908
953
|
assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
|
|
@@ -916,7 +961,6 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
916
961
|
const plainCtx = makeCtx({ settingsStore: { upgradeCommandTemplate: 'node -e "process.exit(0)"' }, appExit: () => {} })
|
|
917
962
|
console.warn = (text) => plainWarns.push(String(text))
|
|
918
963
|
try {
|
|
919
|
-
apply(plainCtx.ctx)
|
|
920
964
|
await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
921
965
|
for (let i = 0; i < 50; i += 1) {
|
|
922
966
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -984,8 +1028,7 @@ test('release-notes:默认取通道最新版,200 返回发布说明', async () =
|
|
|
984
1028
|
const restore = installDualUpstream({ requestedUrls })
|
|
985
1029
|
try {
|
|
986
1030
|
const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
|
|
987
|
-
|
|
988
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1031
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
989
1032
|
const res = await get(routes, RELEASE_PATH)
|
|
990
1033
|
assert.equal(res.status, 200)
|
|
991
1034
|
assert.deepEqual(res.payload, {
|
|
@@ -1006,8 +1049,7 @@ test('release-notes:缓存生效,切通道后按新通道版本拉取', async ()
|
|
|
1006
1049
|
const restore = installDualUpstream({ requestedUrls })
|
|
1007
1050
|
try {
|
|
1008
1051
|
const { ctx, routes } = makeCtx()
|
|
1009
|
-
|
|
1010
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1052
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1011
1053
|
const first = await get(routes, RELEASE_PATH)
|
|
1012
1054
|
assert.equal(first.status, 200)
|
|
1013
1055
|
assert.equal(first.payload.version, '9.9.9')
|
|
@@ -1033,8 +1075,7 @@ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', asy
|
|
|
1033
1075
|
globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
|
|
1034
1076
|
try {
|
|
1035
1077
|
const { ctx, routes } = makeCtx()
|
|
1036
|
-
|
|
1037
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1078
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1038
1079
|
const res = await get(routes, RELEASE_PATH)
|
|
1039
1080
|
assert.equal(res.status, 400)
|
|
1040
1081
|
assert.match(res.payload.error, /检查更新/)
|
|
@@ -1056,8 +1097,7 @@ test('release-notes:通道标签非 semver,构建上游标签前归一 400', asy
|
|
|
1056
1097
|
}
|
|
1057
1098
|
try {
|
|
1058
1099
|
const { ctx, routes } = makeCtx()
|
|
1059
|
-
|
|
1060
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1100
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1061
1101
|
const res = await get(routes, RELEASE_PATH)
|
|
1062
1102
|
assert.equal(res.status, 400)
|
|
1063
1103
|
assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
|
|
@@ -1073,8 +1113,7 @@ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t)
|
|
|
1073
1113
|
const restore = installDualUpstream({ requestedUrls })
|
|
1074
1114
|
try {
|
|
1075
1115
|
const { ctx, routes } = makeCtx({ appExit: () => {} })
|
|
1076
|
-
|
|
1077
|
-
const restart = await post(routes, '/api/maintain/restart')
|
|
1116
|
+
const restart = await post(routes, '/api/maintain/restart')
|
|
1078
1117
|
assert.equal(restart.status, 200)
|
|
1079
1118
|
const res = await get(routes, RELEASE_PATH)
|
|
1080
1119
|
assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
|
|
@@ -1102,8 +1141,7 @@ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
|
|
|
1102
1141
|
}
|
|
1103
1142
|
try {
|
|
1104
1143
|
const { ctx, routes } = makeCtx()
|
|
1105
|
-
|
|
1106
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1144
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1107
1145
|
for (const [name, version] of Object.entries(channels)) {
|
|
1108
1146
|
const switched = await post(routes, '/api/maintain/channel', { channel: name })
|
|
1109
1147
|
assert.equal(switched.status, 200)
|
|
@@ -1132,8 +1170,7 @@ test('release-notes:上游失败 400 带错误信息', async () => {
|
|
|
1132
1170
|
const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
|
|
1133
1171
|
try {
|
|
1134
1172
|
const { ctx, routes } = makeCtx()
|
|
1135
|
-
|
|
1136
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1173
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1137
1174
|
const res = await get(routes, RELEASE_PATH)
|
|
1138
1175
|
assert.equal(res.status, 400)
|
|
1139
1176
|
assert.match(res.payload.error, /ECONNRESET/)
|
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
|
})
|