@mzzsfy/dsh-maintain 0.10.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 +3 -3
- package/package.json +2 -3
- package/src/index.js +74 -52
- package/test/route.test.mjs +115 -73
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ node scripts/dev-link.mjs dsh-maintain # 仓库根执行:归一 profile 依赖
|
|
|
33
33
|
|
|
34
34
|
工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。勿用 `link:` 或裸相对路径手工挂载——realpath 后 peer 从仓库目录解析会失败(早期"必须手工拷贝部署"结论的成因,对 dev-link junction 不成立,junction 内 peer 沿 profile 解析)。
|
|
35
35
|
|
|
36
|
-
## 设置(
|
|
36
|
+
## 设置(配置节,热加载)
|
|
37
37
|
|
|
38
38
|
```yaml
|
|
39
39
|
maintain:
|
|
@@ -43,7 +43,7 @@ maintain:
|
|
|
43
43
|
registryBase: https://registry.npmjs.org # 官方源不可达时可改为镜像地址
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
配置落在 profile 条目 `maintain` 的 config 节(即上例 settings.yaml 的 `maintain:` 块,经 dsh 配置迁移后为 profile patch 内该条目的 config 键):dsh 0.1.7+ 以插件静态 `Config` 导出生成节表单,四个字段均为 volatile,面板保存经 configEditor 写回、仅 volatile 变化时 loader 原地热更(不重启插件);legacy 宿主(≤0.1.6)经 settings 服务 `register/get/update` 命名空间语义等价存取。升级命令、轮询间隔、镜像地址三项亦可在设置面板"版本与运维"页直接编辑保存(等效配置节,热生效);升级后自动重启不在设置中,由升级确认弹窗内的勾选项逐次决定。
|
|
47
47
|
|
|
48
48
|
## 重启须知
|
|
49
49
|
|
|
@@ -60,4 +60,4 @@ cd packages/dsh-maintain && npm test
|
|
|
60
60
|
|
|
61
61
|
## dsh 版本兼容
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)全部通过:版本与运维页控件完整、检查/升级/重启入口可用、激活 live。配置面双形态特性检测(照 dsh-llm-pi-gateway,不做版本硬编码):0.1.7-rc.1 走静态 Config 导出 + configEditor 写回 + volatile 原地热更,legacy(0.1.2–0.1.6)走 settings 命名空间 register/get/update;宿主 settings/configEditor 方法面缺失时对应能力守卫降级(面板提示),不阻塞页面。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-maintain",
|
|
3
3
|
"description": "DSH 自身的版本管理:追踪 npm dist-tag 新版本、一键升级、安全重启",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -36,8 +36,7 @@
|
|
|
36
36
|
"@mzzsfy/dsh-toast": "^0.1.2"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@deepseek-ai/
|
|
40
|
-
"@deepseek-ai/schemastery": ">=3.18.0",
|
|
39
|
+
"@deepseek-ai/schemastery": ">=3.18.4",
|
|
41
40
|
"react": "^18.2.0"
|
|
42
41
|
},
|
|
43
42
|
"files": [
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-maintain Host 半区:版本监测 + 一键升级 + 安全重启。
|
|
2
2
|
// 双端模式照 dsh-usage-panel:webServer 具名路由供浏览器半区调用;
|
|
3
|
-
//
|
|
3
|
+
// 配置持久化双形态:legacy 走 settings 命名空间 maintain,0.1.7+ 走 profile
|
|
4
|
+
// 条目 config 节(Config 导出 + volatile 热更),检查结果仅存内存。
|
|
4
5
|
|
|
5
6
|
import { readFile } from 'node:fs/promises'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
@@ -253,19 +254,26 @@ function upgradeLockStale(lock) {
|
|
|
253
254
|
return lock === null || Date.now() - lock.startedAt > UPGRADE_LOCK_STALE_MS
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
const DEFAULT_CHANNEL = 'latest'
|
|
257
257
|
// 默认值导出仅供 parity 测试作 host 侧锚点;行为入口全部经 readSettings 回落
|
|
258
|
+
export const DEFAULT_CHANNEL = 'latest'
|
|
258
259
|
export const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
|
|
259
260
|
export const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g ' + TARGET_PACKAGE + '@' + TAG_PLACEHOLDER
|
|
260
261
|
export const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
|
|
261
262
|
|
|
262
263
|
// 注册即声明 GUI 设置表单,schema 默认值即生效默认值(rs-workflow-config 先例)。
|
|
263
|
-
|
|
264
|
+
// 0.1.7 宿主以静态 Config 导出生成节表单;legacy 宿主经 settings.register 注册同名命名空间。
|
|
265
|
+
// volatile 根包装(照 dsh-llm-pi-gateway volatileWrap 同构):0.1.7 下整节为 live 表单,
|
|
266
|
+
// 节写经 loader volatile 快速道原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
267
|
+
export const Config = volatileWrap(z.object({
|
|
264
268
|
channel: z.string().default(DEFAULT_CHANNEL).description('追踪通道:npm dist-tag 名(latest/next/alpha 等,以检查返回的通道列表为准)'),
|
|
265
269
|
pollIntervalSec: z.number().default(DEFAULT_POLL_INTERVAL_SEC).description('轮询间隔秒数,仅正数启用周期检查'),
|
|
266
270
|
upgradeCommandTemplate: z.string().default(DEFAULT_UPGRADE_TEMPLATE).description('升级命令模板,{tag} 执行时替换为追踪通道,可整体自改为任意命令'),
|
|
267
271
|
registryBase: z.string().default(DEFAULT_REGISTRY_BASE).description('npm registry 基地址,官方源不可达时改为镜像地址'),
|
|
268
|
-
})
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
function volatileWrap(schema) {
|
|
275
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
276
|
+
}
|
|
269
277
|
|
|
270
278
|
function sendJson(res, status, payload) {
|
|
271
279
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -326,10 +334,17 @@ function readBody(req) {
|
|
|
326
334
|
})
|
|
327
335
|
}
|
|
328
336
|
|
|
329
|
-
|
|
337
|
+
// volatile ref 动态解包(get 协议,官方 plainOptions 同构);legacy 宿主 config 为普通对象原样透传。
|
|
338
|
+
// Config 为根级 volatile 包装,解析产物是整节单 ref,解包一次即得字段明值
|
|
339
|
+
function unwrapVolatile(value) {
|
|
340
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function readSettings(ctx, config) {
|
|
330
344
|
const settings = ctx.get('settings')
|
|
331
|
-
//
|
|
332
|
-
|
|
345
|
+
// 双形态读(照 dsh-llm-pi-gateway,特性检测不硬编码版本):
|
|
346
|
+
// legacy:settings 命名空间 get;0.1.7+:apply 入参 config 整节解包
|
|
347
|
+
const value = typeof settings?.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
|
|
333
348
|
return {
|
|
334
349
|
channel: value && typeof value.channel === 'string' && value.channel.trim().length > 0 ? value.channel.trim() : DEFAULT_CHANNEL,
|
|
335
350
|
pollIntervalSec:
|
|
@@ -348,7 +363,32 @@ function readSettings(ctx) {
|
|
|
348
363
|
}
|
|
349
364
|
|
|
350
365
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
351
|
-
export function apply(ctx) {
|
|
366
|
+
export function apply(ctx, config) {
|
|
367
|
+
// 配置面双形态(照 dsh-llm-pi-gateway,特性检测不硬编码版本),判定一律延迟到使用点:
|
|
368
|
+
// settings 服务挂载时序无保证,apply 时刻探测会误判形态。
|
|
369
|
+
// legacy(≤0.1.6):settings 服务 register/get/update 命名空间语义;
|
|
370
|
+
// 0.1.7+:静态 Config 导出生成节表单,读经 volatile ref 解包,写经 configEditor 落 profile 条目
|
|
371
|
+
const legacySettingsFace = () => {
|
|
372
|
+
const settings = ctx.get('settings')
|
|
373
|
+
return typeof settings?.register === 'function' && typeof settings?.update === 'function'
|
|
374
|
+
}
|
|
375
|
+
// 写路径分发。0.1.7:configEditor.edit 的 change 回调返回完整 raw config,
|
|
376
|
+
// 仅 volatile 字段变化时 loader 原地热更不重启插件,apply 无需重入。
|
|
377
|
+
// 服务面缺失发 500 后返回 false,业务异常抛出由路由统一归一 400
|
|
378
|
+
async function persistPatch(res, patch) {
|
|
379
|
+
if (legacySettingsFace()) {
|
|
380
|
+
await ctx.get('settings').update(NAMESPACE, patch)
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
const editor = ctx.get('configEditor')
|
|
384
|
+
const entry = ctx.fiber?.entry
|
|
385
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) {
|
|
386
|
+
sendJson(res, 500, { error: 'configEditor 服务不可用' })
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
352
392
|
// 启动器在挂载前提供 appExit(有界退出,5 秒兜底强制);缺失时重启能力关闭
|
|
353
393
|
const exit = ctx.get('appExit')
|
|
354
394
|
if (typeof exit !== 'function') {
|
|
@@ -395,9 +435,9 @@ export function apply(ctx) {
|
|
|
395
435
|
function runCheck() {
|
|
396
436
|
if (checkInFlight) return checkInFlight
|
|
397
437
|
checkInFlight = (async () => {
|
|
398
|
-
const
|
|
438
|
+
const cfg = readSettings(ctx, config)
|
|
399
439
|
try {
|
|
400
|
-
snapshot.tags = await fetchDistTags({ registryBase:
|
|
440
|
+
snapshot.tags = await fetchDistTags({ registryBase: cfg.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
|
|
401
441
|
snapshot.error = null
|
|
402
442
|
} catch (error) {
|
|
403
443
|
// registry 不可达:保留上次 tags,仅记录错误
|
|
@@ -420,7 +460,7 @@ export function apply(ctx) {
|
|
|
420
460
|
}
|
|
421
461
|
|
|
422
462
|
function scheduleNext() {
|
|
423
|
-
const intervalSec = readSettings(ctx).pollIntervalSec
|
|
463
|
+
const intervalSec = readSettings(ctx, config).pollIntervalSec
|
|
424
464
|
nextDueAt = intervalSec > 0 && intervalSec <= POLL_INTERVAL_MAX_SEC ? Date.now() + intervalSec * 1000 : null
|
|
425
465
|
}
|
|
426
466
|
|
|
@@ -444,13 +484,13 @@ export function apply(ctx) {
|
|
|
444
484
|
|
|
445
485
|
function judgeNow() {
|
|
446
486
|
// verdict 以运行版本判定:用户关心"跑的是不是最新";已装版本供升级复读校验
|
|
447
|
-
return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx).channel })
|
|
487
|
+
return judgeVersion({ currentVersion: runningVersion, tags: snapshot.tags, channel: readSettings(ctx, config).channel })
|
|
448
488
|
}
|
|
449
489
|
|
|
450
490
|
async function currentStatus() {
|
|
451
491
|
// 等运行版本首读落定:apply 即发起,此处仅吸收启动窗口的微小延迟
|
|
452
492
|
await runningVersionReady
|
|
453
|
-
const
|
|
493
|
+
const cfg = readSettings(ctx, config)
|
|
454
494
|
const judged = judgeNow()
|
|
455
495
|
// running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
|
|
456
496
|
const lock = upgrade.running === true ? { startedAt: Date.now() } : readUpgradeLock()
|
|
@@ -463,10 +503,10 @@ export function apply(ctx) {
|
|
|
463
503
|
runningVersion,
|
|
464
504
|
installedVersion: snapshot.installedVersion,
|
|
465
505
|
restartPending: isVersionPendingRestart({ runningVersion, installedVersion: snapshot.installedVersion }),
|
|
466
|
-
channel:
|
|
467
|
-
upgradeTemplate:
|
|
468
|
-
pollIntervalSec:
|
|
469
|
-
registryBase:
|
|
506
|
+
channel: cfg.channel,
|
|
507
|
+
upgradeTemplate: cfg.upgradeCommandTemplate,
|
|
508
|
+
pollIntervalSec: cfg.pollIntervalSec,
|
|
509
|
+
registryBase: cfg.registryBase,
|
|
470
510
|
tags: snapshot.tags,
|
|
471
511
|
channelLatest: judged.channelLatest,
|
|
472
512
|
verdict: judged.verdict,
|
|
@@ -481,15 +521,15 @@ export function apply(ctx) {
|
|
|
481
521
|
}
|
|
482
522
|
|
|
483
523
|
function triggerUpgrade(autoRestart) {
|
|
484
|
-
const
|
|
524
|
+
const cfg = readSettings(ctx, config)
|
|
485
525
|
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
486
|
-
const command = buildUpgradeCommand({ template:
|
|
526
|
+
const command = buildUpgradeCommand({ template: cfg.upgradeCommandTemplate, tag: cfg.channel })
|
|
487
527
|
// 通道名恰为 semver 形态时 {tag} 展开产物会被误读为钉定:与通道名重合即非钉定
|
|
488
528
|
const pinnedVersion = extractPinnedVersion(command)
|
|
489
529
|
const last = {
|
|
490
530
|
command,
|
|
491
531
|
// 钉定版本随命令定格:落定复读按安装意图(钉定/通道)判定 stale
|
|
492
|
-
pinnedVersion: pinnedVersion ===
|
|
532
|
+
pinnedVersion: pinnedVersion === cfg.channel ? null : pinnedVersion,
|
|
493
533
|
startedAt: Date.now(),
|
|
494
534
|
ok: false,
|
|
495
535
|
finishedAt: null,
|
|
@@ -510,7 +550,7 @@ export function apply(ctx) {
|
|
|
510
550
|
upgrade = { running: true, last }
|
|
511
551
|
writeUpgradeLock(last.startedAt)
|
|
512
552
|
audit('upgrade', 'triggered', 'command=' + singleLine(command) + ' autoRestart=' + (autoRestart === true))
|
|
513
|
-
void performUpgrade(command, last,
|
|
553
|
+
void performUpgrade(command, last, cfg.channel, autoRestart === true)
|
|
514
554
|
}
|
|
515
555
|
|
|
516
556
|
// 升级锁文件只在此写入:首次与每次重试覆写,startedAt 取当前尝试开始时刻,
|
|
@@ -650,12 +690,7 @@ export function apply(ctx) {
|
|
|
650
690
|
sendJson(res, 400, { error: '通道 ' + channel + ' 不在当前 dist-tags 中' })
|
|
651
691
|
return
|
|
652
692
|
}
|
|
653
|
-
|
|
654
|
-
if (!settings) {
|
|
655
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
656
|
-
return
|
|
657
|
-
}
|
|
658
|
-
await settings.update(NAMESPACE, { channel })
|
|
693
|
+
if (!(await persistPatch(res, { channel }))) return
|
|
659
694
|
// 切通道无需重查:dist-tags 与 channel 无关,白名单已用现有 snapshot 校验
|
|
660
695
|
scheduleNext()
|
|
661
696
|
sendJson(res, 200, await currentStatus())
|
|
@@ -670,12 +705,7 @@ export function apply(ctx) {
|
|
|
670
705
|
sendJson(res, 400, { error: '升级命令不能为空' })
|
|
671
706
|
return
|
|
672
707
|
}
|
|
673
|
-
|
|
674
|
-
if (!settings) {
|
|
675
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
676
|
-
return
|
|
677
|
-
}
|
|
678
|
-
await settings.update(NAMESPACE, { upgradeCommandTemplate: template })
|
|
708
|
+
if (!(await persistPatch(res, { upgradeCommandTemplate: template }))) return
|
|
679
709
|
sendJson(res, 200, await currentStatus())
|
|
680
710
|
}),
|
|
681
711
|
},
|
|
@@ -694,12 +724,7 @@ export function apply(ctx) {
|
|
|
694
724
|
sendJson(res, 400, { error: '轮询间隔不能超过 ' + POLL_INTERVAL_MAX_SEC + ' 秒' })
|
|
695
725
|
return
|
|
696
726
|
}
|
|
697
|
-
|
|
698
|
-
if (!settings) {
|
|
699
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
700
|
-
return
|
|
701
|
-
}
|
|
702
|
-
await settings.update(NAMESPACE, { pollIntervalSec: seconds })
|
|
727
|
+
if (!(await persistPatch(res, { pollIntervalSec: seconds }))) return
|
|
703
728
|
scheduleNext()
|
|
704
729
|
sendJson(res, 200, await currentStatus())
|
|
705
730
|
}),
|
|
@@ -713,12 +738,7 @@ export function apply(ctx) {
|
|
|
713
738
|
sendJson(res, 400, { error: 'registry 基地址须为 http(s) 地址且不带查询串或锚点' })
|
|
714
739
|
return
|
|
715
740
|
}
|
|
716
|
-
|
|
717
|
-
if (!settings) {
|
|
718
|
-
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
719
|
-
return
|
|
720
|
-
}
|
|
721
|
-
await settings.update(NAMESPACE, { registryBase: base })
|
|
741
|
+
if (!(await persistPatch(res, { registryBase: base }))) return
|
|
722
742
|
if (restartScheduled) {
|
|
723
743
|
// 保存生效但不发起检查:关机窗口内网络请求与磁盘读取都是半写状态风险
|
|
724
744
|
sendJson(res, 200, await currentStatus())
|
|
@@ -843,14 +863,16 @@ export function apply(ctx) {
|
|
|
843
863
|
}
|
|
844
864
|
|
|
845
865
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
846
|
-
// 方法面防御对齐 timer
|
|
847
|
-
//
|
|
848
|
-
if (
|
|
849
|
-
|
|
850
|
-
|
|
866
|
+
// 方法面防御对齐 timer 软依赖:settings 服务缺 register 面(0.1.7 已移除)即走
|
|
867
|
+
// 新形态分支,不在此路径抛错拖垮启动检查
|
|
868
|
+
if (legacySettingsFace()) {
|
|
869
|
+
settingsCtx.settings.register(NAMESPACE, Config)
|
|
870
|
+
} else if (typeof settingsCtx.settings?.configure === 'function') {
|
|
871
|
+
// 0.1.7 形态:原生自动设置页与本包自定义面板重复,特性检测关闭
|
|
872
|
+
// (官方 dsh-agent-default-model 同构)
|
|
873
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
851
874
|
}
|
|
852
|
-
|
|
853
|
-
// 启动检查放在 settings 注册之后:命名空间未注册时 readSettings 只能拿默认值,
|
|
875
|
+
// 启动检查在 settings 注册之后:legacy 命名空间未注册时 readSettings 只能拿默认值,
|
|
854
876
|
// 配置了镜像地址的部署会确定性检查失败
|
|
855
877
|
runCheck().then(scheduleNext, scheduleNext)
|
|
856
878
|
})
|
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)
|
|
@@ -443,7 +515,6 @@ test('restart:升级进行中 409 拒绝且不调度退出', async () => {
|
|
|
443
515
|
const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
|
|
444
516
|
const exits = []
|
|
445
517
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
446
|
-
apply(ctx)
|
|
447
518
|
const upgrade = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
448
519
|
assert.equal(upgrade.status, 200)
|
|
449
520
|
const denied = await post(routes, '/api/maintain/restart')
|
|
@@ -463,7 +534,6 @@ test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
|
|
|
463
534
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
464
535
|
const exits = []
|
|
465
536
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
466
|
-
apply(ctx)
|
|
467
537
|
const restart = await post(routes, '/api/maintain/restart')
|
|
468
538
|
assert.equal(restart.status, 200)
|
|
469
539
|
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
@@ -486,8 +556,7 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
|
|
|
486
556
|
try {
|
|
487
557
|
const exits = []
|
|
488
558
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
489
|
-
|
|
490
|
-
let baseline = null
|
|
559
|
+
let baseline = null
|
|
491
560
|
for (let waited = 0; waited < 5000 && baseline === null; waited += 25) {
|
|
492
561
|
const poll = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
493
562
|
if (poll && poll.checkedAt !== null) baseline = fetchCalls
|
|
@@ -512,7 +581,6 @@ test('upgrade:重启调度窗口内 refresh 409,registry-base 保存但不发起
|
|
|
512
581
|
|
|
513
582
|
test('status:快照携带 bootAt 实例代际', async () => {
|
|
514
583
|
const { ctx, routes } = makeCtx()
|
|
515
|
-
apply(ctx)
|
|
516
584
|
const status = await get(routes, '/api/maintain/status')
|
|
517
585
|
assert.equal(status.status, 200)
|
|
518
586
|
assert.equal(typeof status.payload.bootAt, 'number')
|
|
@@ -521,7 +589,6 @@ test('status:快照携带 bootAt 实例代际', async () => {
|
|
|
521
589
|
|
|
522
590
|
test('status:运行版本与已装版本双字段,verdict 以运行版本为准', async () => {
|
|
523
591
|
const { ctx, routes } = makeCtx()
|
|
524
|
-
apply(ctx)
|
|
525
592
|
// 等启动检查落定:installedVersion 由检查快照填充
|
|
526
593
|
let ready = null
|
|
527
594
|
for (let waited = 0; waited < 5000 && ready === null; waited += 25) {
|
|
@@ -545,7 +612,6 @@ test('status:运行版本与已装版本双字段,verdict 以运行版本为准'
|
|
|
545
612
|
test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
|
|
546
613
|
const store = {}
|
|
547
614
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
548
|
-
apply(ctx)
|
|
549
615
|
const huge = await post(routes, '/api/maintain/poll-interval', { seconds: 1e308 })
|
|
550
616
|
assert.equal(huge.status, 400)
|
|
551
617
|
assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
|
|
@@ -561,7 +627,6 @@ test('upgrade:未勾选自动重启,升级成功落定继续运行并标 stale',
|
|
|
561
627
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
562
628
|
})
|
|
563
629
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
564
|
-
apply(ctx)
|
|
565
630
|
// 排空启动期微任务链,探针读序此后稳定
|
|
566
631
|
await drainMicrotasks()
|
|
567
632
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
|
|
@@ -598,7 +663,6 @@ test('upgrade:模板钉定版本时按钉定意图判 fresh(通道目标不参
|
|
|
598
663
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
599
664
|
})
|
|
600
665
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
601
|
-
apply(ctx)
|
|
602
666
|
await drainMicrotasks()
|
|
603
667
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: false })
|
|
604
668
|
assert.equal(trigger.status, 200)
|
|
@@ -630,7 +694,6 @@ test('upgrade:勾选自动重启+落定,延迟窗口后调度宿主退出', asyn
|
|
|
630
694
|
services: { hostVersionProbe: () => Promise.resolve('1.0.0') },
|
|
631
695
|
})
|
|
632
696
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
633
|
-
apply(ctx)
|
|
634
697
|
await drainMicrotasks()
|
|
635
698
|
const trigger = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
636
699
|
assert.equal(trigger.status, 200)
|
|
@@ -658,7 +721,6 @@ test('upgrade:autoRestart 缺失或非 boolean 一律 400', async () => {
|
|
|
658
721
|
// 假命令兜底:本用例期待 400,但若校验意外放行,真实默认模板会当场触发 npm install
|
|
659
722
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
660
723
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
661
|
-
apply(ctx)
|
|
662
724
|
const missing = await post(routes, '/api/maintain/upgrade')
|
|
663
725
|
assert.equal(missing.status, 400, '缺失 autoRestart 必须拒绝,防静默翻转关机行为')
|
|
664
726
|
const stringInput = await post(routes, '/api/maintain/upgrade', { autoRestart: 'true' })
|
|
@@ -673,7 +735,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
|
|
|
673
735
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
674
736
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
675
737
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: () => {} })
|
|
676
|
-
apply(ctx)
|
|
677
738
|
await post(routes, '/api/maintain/restart')
|
|
678
739
|
const denied = await post(routes, '/api/maintain/upgrade')
|
|
679
740
|
assert.equal(denied.status, 400, 'body 非法时即使门控(重启已调度)命中也必须 400')
|
|
@@ -682,7 +743,6 @@ test('upgrade:body 校验先于门控,门控命中时非法 body 仍 400', async
|
|
|
682
743
|
test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
683
744
|
const store = {}
|
|
684
745
|
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
685
|
-
apply(ctx)
|
|
686
746
|
const withQuery = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com?mirror=1' })
|
|
687
747
|
assert.equal(withQuery.status, 400)
|
|
688
748
|
const withHash = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com#frag' })
|
|
@@ -692,14 +752,12 @@ test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
|
692
752
|
|
|
693
753
|
test('restart:缺失 appExit 500;响应立即返回,延迟退出', async (t) => {
|
|
694
754
|
const withoutExit = makeCtx()
|
|
695
|
-
apply(withoutExit.ctx)
|
|
696
755
|
const denied = await post(withoutExit.routes, '/api/maintain/restart')
|
|
697
756
|
assert.equal(denied.status, 500)
|
|
698
757
|
|
|
699
758
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
700
759
|
const exits = []
|
|
701
760
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
702
|
-
apply(ctx)
|
|
703
761
|
const ok = await post(routes, '/api/maintain/restart')
|
|
704
762
|
assert.equal(ok.status, 200)
|
|
705
763
|
assert.equal(ok.payload.restarting, true)
|
|
@@ -713,7 +771,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
|
|
|
713
771
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
714
772
|
const exits = []
|
|
715
773
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
716
|
-
apply(ctx)
|
|
717
774
|
const first = await post(routes, '/api/maintain/restart')
|
|
718
775
|
assert.equal(first.status, 200)
|
|
719
776
|
const second = await post(routes, '/api/maintain/restart')
|
|
@@ -725,7 +782,6 @@ test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t)
|
|
|
725
782
|
|
|
726
783
|
test('readBody 超限:路由归一 400', async () => {
|
|
727
784
|
const { ctx, routes } = makeCtx()
|
|
728
|
-
apply(ctx)
|
|
729
785
|
const req = makeReq({ method: 'POST' })
|
|
730
786
|
const res = makeRes()
|
|
731
787
|
const done = routes.get('/api/maintain/channel')(req, res)
|
|
@@ -806,7 +862,6 @@ test('upgrade:存在活跃工作 409 拒绝,不可越', async () => {
|
|
|
806
862
|
const jobs = { list: () => [] }
|
|
807
863
|
const terminals = { list: () => [] }
|
|
808
864
|
const { ctx, routes } = makeCtx({ settingsStore: store, services: { agents, jobs, terminals } })
|
|
809
|
-
apply(ctx)
|
|
810
865
|
const denied = await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
811
866
|
assert.equal(denied.status, 409)
|
|
812
867
|
assert.match(denied.payload.error, /活跃工作/)
|
|
@@ -822,7 +877,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
|
|
|
822
877
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
823
878
|
const jobs = { list: () => [] }
|
|
824
879
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code), services: { agents, jobs } })
|
|
825
|
-
apply(ctx)
|
|
826
880
|
const denied = await post(routes, '/api/maintain/restart')
|
|
827
881
|
assert.equal(denied.status, 409)
|
|
828
882
|
assert.match(denied.payload.error, /活跃工作/)
|
|
@@ -835,7 +889,6 @@ test('restart:活跃工作 409,force 越过', async (t) => {
|
|
|
835
889
|
|
|
836
890
|
test('status:activeWork 概要进快照,服务缺失标 detectionAvailable=false', async () => {
|
|
837
891
|
const { ctx, routes } = makeCtx()
|
|
838
|
-
apply(ctx)
|
|
839
892
|
const status = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
840
893
|
assert.equal(status.activeWork.total, 0)
|
|
841
894
|
assert.equal(status.activeWork.detectionAvailable, false)
|
|
@@ -847,7 +900,6 @@ test('restart:null 体按空体处理,内部形态不泄漏', async (t) => {
|
|
|
847
900
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
848
901
|
const exits = []
|
|
849
902
|
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
850
|
-
apply(ctx)
|
|
851
903
|
const ok = await post(routes, '/api/maintain/restart', 'null')
|
|
852
904
|
assert.equal(ok.status, 200, 'null 体须按空体处理,不得以内部错误形态 400/500 泄漏')
|
|
853
905
|
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
@@ -870,8 +922,7 @@ test('upgrade:运行环境不参与自动重启判定(env 注入 manual 仍调
|
|
|
870
922
|
const store = { upgradeCommandTemplate: 'node -e "process.exit(0)"' }
|
|
871
923
|
const exits = []
|
|
872
924
|
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
873
|
-
|
|
874
|
-
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
925
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
875
926
|
let settled = null
|
|
876
927
|
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
877
928
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -896,8 +947,7 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
896
947
|
try {
|
|
897
948
|
const agents = { list: () => [{ id: 'a1', status: 'running' }] }
|
|
898
949
|
const { ctx, routes } = makeCtx({ services: { agents }, appExit: () => {} })
|
|
899
|
-
|
|
900
|
-
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
950
|
+
await post(routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
901
951
|
assert.equal(warns.some((text) => text.includes('audit endpoint=upgrade outcome=rejected reason=active-work')), true, '门控拒绝须留审计行')
|
|
902
952
|
await post(routes, '/api/maintain/restart')
|
|
903
953
|
assert.equal(warns.some((text) => text.includes('audit endpoint=restart outcome=rejected reason=active-work')), true, '重启门控拒绝须留审计行')
|
|
@@ -911,7 +961,6 @@ test('audit:触发/落定/拒绝各留一行结构化日志', async () => {
|
|
|
911
961
|
const plainCtx = makeCtx({ settingsStore: { upgradeCommandTemplate: 'node -e "process.exit(0)"' }, appExit: () => {} })
|
|
912
962
|
console.warn = (text) => plainWarns.push(String(text))
|
|
913
963
|
try {
|
|
914
|
-
apply(plainCtx.ctx)
|
|
915
964
|
await post(plainCtx.routes, '/api/maintain/upgrade', { autoRestart: true })
|
|
916
965
|
for (let i = 0; i < 50; i += 1) {
|
|
917
966
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
@@ -979,8 +1028,7 @@ test('release-notes:默认取通道最新版,200 返回发布说明', async () =
|
|
|
979
1028
|
const restore = installDualUpstream({ requestedUrls })
|
|
980
1029
|
try {
|
|
981
1030
|
const { ctx, routes } = makeCtx({ services: { hostVersionProbe: () => Promise.resolve('5.4.3') } })
|
|
982
|
-
|
|
983
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1031
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
984
1032
|
const res = await get(routes, RELEASE_PATH)
|
|
985
1033
|
assert.equal(res.status, 200)
|
|
986
1034
|
assert.deepEqual(res.payload, {
|
|
@@ -1001,8 +1049,7 @@ test('release-notes:缓存生效,切通道后按新通道版本拉取', async ()
|
|
|
1001
1049
|
const restore = installDualUpstream({ requestedUrls })
|
|
1002
1050
|
try {
|
|
1003
1051
|
const { ctx, routes } = makeCtx()
|
|
1004
|
-
|
|
1005
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1052
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1006
1053
|
const first = await get(routes, RELEASE_PATH)
|
|
1007
1054
|
assert.equal(first.status, 200)
|
|
1008
1055
|
assert.equal(first.payload.version, '9.9.9')
|
|
@@ -1028,8 +1075,7 @@ test('release-notes:tags 未就绪且无 version 400 提示先检查更新', asy
|
|
|
1028
1075
|
globalThis.fetch = async () => { throw new Error('ECONNREFUSED') }
|
|
1029
1076
|
try {
|
|
1030
1077
|
const { ctx, routes } = makeCtx()
|
|
1031
|
-
|
|
1032
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1078
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1033
1079
|
const res = await get(routes, RELEASE_PATH)
|
|
1034
1080
|
assert.equal(res.status, 400)
|
|
1035
1081
|
assert.match(res.payload.error, /检查更新/)
|
|
@@ -1051,8 +1097,7 @@ test('release-notes:通道标签非 semver,构建上游标签前归一 400', asy
|
|
|
1051
1097
|
}
|
|
1052
1098
|
try {
|
|
1053
1099
|
const { ctx, routes } = makeCtx()
|
|
1054
|
-
|
|
1055
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1100
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1056
1101
|
const res = await get(routes, RELEASE_PATH)
|
|
1057
1102
|
assert.equal(res.status, 400)
|
|
1058
1103
|
assert.match(res.payload.error, /semver/, '病理标签必须在触达上游前被 semver 白名单拦截')
|
|
@@ -1068,8 +1113,7 @@ test('release-notes:重启调度窗口内 409,不发起上游请求', async (t)
|
|
|
1068
1113
|
const restore = installDualUpstream({ requestedUrls })
|
|
1069
1114
|
try {
|
|
1070
1115
|
const { ctx, routes } = makeCtx({ appExit: () => {} })
|
|
1071
|
-
|
|
1072
|
-
const restart = await post(routes, '/api/maintain/restart')
|
|
1116
|
+
const restart = await post(routes, '/api/maintain/restart')
|
|
1073
1117
|
assert.equal(restart.status, 200)
|
|
1074
1118
|
const res = await get(routes, RELEASE_PATH)
|
|
1075
1119
|
assert.equal(res.status, 409, '关机窗口内查询更新内容必须拒绝')
|
|
@@ -1097,8 +1141,7 @@ test('release-notes:缓存超限 FIFO 淘汰,最早键被逐出', async () => {
|
|
|
1097
1141
|
}
|
|
1098
1142
|
try {
|
|
1099
1143
|
const { ctx, routes } = makeCtx()
|
|
1100
|
-
|
|
1101
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1144
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1102
1145
|
for (const [name, version] of Object.entries(channels)) {
|
|
1103
1146
|
const switched = await post(routes, '/api/maintain/channel', { channel: name })
|
|
1104
1147
|
assert.equal(switched.status, 200)
|
|
@@ -1127,8 +1170,7 @@ test('release-notes:上游失败 400 带错误信息', async () => {
|
|
|
1127
1170
|
const restore = installDualUpstream({ requestedUrls, releaseThrows: 'ECONNRESET' })
|
|
1128
1171
|
try {
|
|
1129
1172
|
const { ctx, routes } = makeCtx()
|
|
1130
|
-
|
|
1131
|
-
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1173
|
+
assert.ok(await waitSnapshotReady(routes), '启动检查 5 秒内未完成')
|
|
1132
1174
|
const res = await get(routes, RELEASE_PATH)
|
|
1133
1175
|
assert.equal(res.status, 400)
|
|
1134
1176
|
assert.match(res.payload.error, /ECONNRESET/)
|