@mzzsfy/dsh-maintain 0.5.2
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 +55 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +781 -0
- package/src/core.mjs +215 -0
- package/src/index.js +483 -0
- package/src/upgrade.mjs +128 -0
- package/test/client-id.test.mjs +16 -0
- package/test/core.test.mjs +176 -0
- package/test/dist-tags.test.mjs +220 -0
- package/test/logic-extract.mjs +21 -0
- package/test/parity.test.mjs +149 -0
- package/test/restart-ready.test.mjs +198 -0
- package/test/route.test.mjs +425 -0
- package/test/upgrade.test.mjs +65 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 重启等待拍决策行为测试:client.js 的 restartTick / restartPostLost / apiError LOGIC 段
|
|
2
|
+
// 经共享提取器(test/logic-extract.mjs)工厂化后覆盖全场景。
|
|
3
|
+
// 核心回归:宿主重启确认(pid / bootAt 变化 / 失联恢复)后页面服务未就绪(SPA fallback
|
|
4
|
+
// 未注册,`/` 返回裸 404)时必须等待重探,不得立即整页刷新;
|
|
5
|
+
// 主文档须连续就绪达到门槛才放行刷新(reload action),单次就绪只累积计数;
|
|
6
|
+
// 重启 POST 收到明确 HTTP 回绝(409/500)时不得进入等待轮询凭活宿主误判已重启。
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import { extractLogic, clientSource } from './logic-extract.mjs'
|
|
12
|
+
|
|
13
|
+
// 就绪门槛常量直接取自 client 源:注入值与实现同源,client 调整门槛时用例自动跟随
|
|
14
|
+
const REQUIRED_MATCH = clientSource().match(/^const RESTART_READY_REQUIRED = ([0-9 *]+)$/m)
|
|
15
|
+
assert.ok(REQUIRED_MATCH, 'client.js 缺少常量 RESTART_READY_REQUIRED')
|
|
16
|
+
const RESTART_READY_REQUIRED = new Function('return (' + REQUIRED_MATCH[1] + ')')()
|
|
17
|
+
|
|
18
|
+
const clientShouldReload = extractLogic('shouldReloadAfterRestart')
|
|
19
|
+
const clientPageReady = extractLogic('pageReady')
|
|
20
|
+
const clientRestartTick = extractLogic('restartTick', {
|
|
21
|
+
shouldReloadAfterRestart: clientShouldReload,
|
|
22
|
+
pageReady: clientPageReady,
|
|
23
|
+
RESTART_READY_REQUIRED,
|
|
24
|
+
})
|
|
25
|
+
const clientRestartPostLost = extractLogic('restartPostLost')
|
|
26
|
+
const clientApiError = extractLogic('apiError')
|
|
27
|
+
|
|
28
|
+
const ALIVE_MS = 10 * 1000
|
|
29
|
+
|
|
30
|
+
const statusOk = (snapshot) => () => Promise.resolve(snapshot)
|
|
31
|
+
const statusFail = () => () => Promise.reject(new Error('connection refused'))
|
|
32
|
+
const pageHtmlOk = () => Promise.resolve(new Response('<html></html>', { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } }))
|
|
33
|
+
const pageMiss = () => Promise.resolve(new Response('', { status: 404 }))
|
|
34
|
+
const pageUnauthorized = () => Promise.resolve(new Response('', { status: 401 }))
|
|
35
|
+
const pageNoType = () => Promise.resolve(new Response('<html></html>', { status: 200 }))
|
|
36
|
+
const pageNetFail = () => Promise.reject(new Error('connection refused'))
|
|
37
|
+
|
|
38
|
+
// prev 快照构造:{lost,pid,bootAt} 宿主侧 + readyStreak 客户侧连续就绪计数,默认与 pid 7 / bootAt 100 / 计数 0 对齐
|
|
39
|
+
function tick({ now = 0, deadlineAt = ALIVE_MS, lost = false, pid = 7, bootAt = 100, readyStreak = 0, status, page }) {
|
|
40
|
+
return clientRestartTick({
|
|
41
|
+
now,
|
|
42
|
+
deadlineAt,
|
|
43
|
+
prev: { lost, pid, bootAt },
|
|
44
|
+
readyStreak,
|
|
45
|
+
statusFetch: status,
|
|
46
|
+
pageFetch: page,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('restartTick: 等待中 pid 与 bootAt 均未变,宿主未重启,继续等待', async () => {
|
|
51
|
+
// Given 未失联基线(pid 7/bootAt 100);When status 成功返回同值;Then wait 且 lost 保持 false
|
|
52
|
+
const result = await tick({ status: statusOk({ pid: 7, bootAt: 100 }), page: pageHtmlOk })
|
|
53
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('restartTick: status 拉取失败记为失联并等待,就绪计数归零', async () => {
|
|
57
|
+
// Given 已连续就绪一次;When status 请求连接失败;Then wait 且 lost 置 true 计数清零
|
|
58
|
+
const result = await tick({ readyStreak: 1, status: statusFail(), page: pageHtmlOk })
|
|
59
|
+
assert.deepEqual(result, { action: 'wait', lost: true, readyStreak: 0 })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('restartTick: bootAt 变化即判定重启(容器 pid 恒 1 场景)', async () => {
|
|
63
|
+
// Given pid 基线 1/bootAt 100 且已就绪一次;When status 返回 pid 1(复用)而 bootAt 200 且页面就绪;Then reload
|
|
64
|
+
const result = await tick({ pid: 1, bootAt: 100, readyStreak: 1, status: statusOk({ pid: 1, bootAt: 200 }), page: pageHtmlOk })
|
|
65
|
+
assert.deepEqual(result, { action: 'reload', lost: false, readyStreak: 2 })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('restartTick: 宿主已重启但页面 404,等待页面就绪且计数归零', async () => {
|
|
69
|
+
// Given 已就绪一次;When status 返回新 bootAt(已重启)而主文档 404(fallback 未注册);Then wait,不得整页刷新
|
|
70
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: pageMiss })
|
|
71
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('restartTick: 首次就绪只累积计数,不刷新', async () => {
|
|
75
|
+
// Given 宿主已重启且计数为零;When 主文档就绪;Then wait 且计数为一(连续两次才放行)
|
|
76
|
+
const result = await tick({ status: statusOk({ pid: 8, bootAt: 200 }), page: pageHtmlOk })
|
|
77
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 1 })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('restartTick: 连续第二次就绪达到门槛,放行刷新', async () => {
|
|
81
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: pageHtmlOk })
|
|
82
|
+
assert.deepEqual(result, { action: 'reload', lost: false, readyStreak: 2 })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('restartTick: 失联后恢复且页面就绪(已就绪一次),放行刷新', async () => {
|
|
86
|
+
// Given 曾失联且已就绪一次;When status 恢复 200 且主文档 200;Then reload
|
|
87
|
+
const result = await tick({ lost: true, readyStreak: 1, status: statusOk({ pid: 7, bootAt: 100 }), page: pageHtmlOk })
|
|
88
|
+
assert.deepEqual(result, { action: 'reload', lost: true, readyStreak: 2 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('restartTick: 失联恢复但页面未就绪,保持失联标记且计数归零', async () => {
|
|
92
|
+
const result = await tick({ lost: true, readyStreak: 1, status: statusOk({ pid: 7, bootAt: 100 }), page: pageMiss })
|
|
93
|
+
assert.deepEqual(result, { action: 'wait', lost: true, readyStreak: 0 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('restartTick: 超时拍直接转人工收尾,不再探测', async () => {
|
|
97
|
+
// Given now 已到 deadline;When 拍触发;Then timeout
|
|
98
|
+
const result = await tick({ now: ALIVE_MS, deadlineAt: ALIVE_MS, readyStreak: 3, status: statusOk({ pid: 8, bootAt: 200 }), page: pageHtmlOk })
|
|
99
|
+
assert.deepEqual(result, { action: 'timeout', lost: false, readyStreak: 3 })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('restartTick: 页面探测网络失败按未就绪处理且计数归零', async () => {
|
|
103
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: pageNetFail })
|
|
104
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('restartTick: 页面 200 但缺失 content-type 按未就绪处理', async () => {
|
|
108
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: pageNoType })
|
|
109
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('restartTick: 页面 401 按未就绪处理', async () => {
|
|
113
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: pageUnauthorized })
|
|
114
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('restartTick: status 响应缺 pid 与 bootAt 时不判定重启', async () => {
|
|
118
|
+
// Given pid/bootAt 基线 7/100;When status 响应无任一实例标识;Then wait 且 lost 保持 false
|
|
119
|
+
const result = await tick({ readyStreak: 1, status: statusOk({}), page: pageHtmlOk })
|
|
120
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('restartTick: 旧宿主缺 bootAt 时退化 pid 比对,不误判', async () => {
|
|
124
|
+
// Given prev 无 bootAt(旧宿主快照);When next bootAt 出现但 pid 相同;Then wait(bootAt 缺失单侧不构成证据)
|
|
125
|
+
const result = await clientRestartTick({
|
|
126
|
+
now: 0,
|
|
127
|
+
deadlineAt: ALIVE_MS,
|
|
128
|
+
prev: { lost: false, pid: 7, bootAt: null },
|
|
129
|
+
readyStreak: 1,
|
|
130
|
+
statusFetch: statusOk({ pid: 7, bootAt: 200 }),
|
|
131
|
+
pageFetch: pageHtmlOk,
|
|
132
|
+
})
|
|
133
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('restartTick: 无重启证据时不发起页面探测', async () => {
|
|
137
|
+
// Given 基线为空且未失联;When status 成功;Then wait 且页面探测未被调用
|
|
138
|
+
let pageCalls = 0
|
|
139
|
+
const result = await clientRestartTick({
|
|
140
|
+
now: 0,
|
|
141
|
+
deadlineAt: ALIVE_MS,
|
|
142
|
+
prev: { lost: false, pid: null, bootAt: null },
|
|
143
|
+
readyStreak: 0,
|
|
144
|
+
statusFetch: statusOk({ pid: 7, bootAt: 100 }),
|
|
145
|
+
pageFetch: () => { pageCalls += 1; return pageHtmlOk() },
|
|
146
|
+
})
|
|
147
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
148
|
+
assert.equal(pageCalls, 0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('restartTick: 页面 204 无正文按未就绪处理', async () => {
|
|
152
|
+
const result = await tick({ readyStreak: 1, status: statusOk({ pid: 8, bootAt: 200 }), page: () => Promise.resolve(new Response(null, { status: 204 })) })
|
|
153
|
+
assert.deepEqual(result, { action: 'wait', lost: false, readyStreak: 0 })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('restartTick: content-type 大写仍按就绪处理', async () => {
|
|
157
|
+
const result = await tick({
|
|
158
|
+
readyStreak: 1,
|
|
159
|
+
status: statusOk({ pid: 8, bootAt: 200 }),
|
|
160
|
+
page: () => Promise.resolve(new Response('<html></html>', { status: 200, headers: { 'content-type': 'TEXT/HTML; charset=utf-8' } })),
|
|
161
|
+
})
|
|
162
|
+
assert.deepEqual(result, { action: 'reload', lost: false, readyStreak: 2 })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('restartPostLost: 网关 5xx(宿主退出窗口)视为失联,宿主应答回绝不算', () => {
|
|
166
|
+
// 502/503/504 出现在宿主退出窗口:网关可达而宿主不在,必须继续等待
|
|
167
|
+
for (const status of [502, 503, 504]) {
|
|
168
|
+
const gatewayError = new Error('HTTP ' + status)
|
|
169
|
+
gatewayError.status = status
|
|
170
|
+
assert.equal(clientRestartPostLost(gatewayError), true, 'status=' + status)
|
|
171
|
+
}
|
|
172
|
+
// 409 升级互斥/500 appExit 缺失为宿主自身应答:活宿主明确回绝,不得等待
|
|
173
|
+
const conflictError = new Error('升级进行中,禁止重启;等待升级完成后重试')
|
|
174
|
+
conflictError.status = 409
|
|
175
|
+
assert.equal(clientRestartPostLost(conflictError), false)
|
|
176
|
+
const capabilityError = new Error('启动器未提供 appExit,无法重启')
|
|
177
|
+
capabilityError.status = 500
|
|
178
|
+
assert.equal(clientRestartPostLost(capabilityError), false)
|
|
179
|
+
const okLikeError = new Error('HTTP 400')
|
|
180
|
+
okLikeError.status = 400
|
|
181
|
+
assert.equal(clientRestartPostLost(okLikeError), false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('restartPostLost: 无应答失败(网络/中止)属于失联', () => {
|
|
185
|
+
assert.equal(clientRestartPostLost(new TypeError('Failed to fetch')), true)
|
|
186
|
+
assert.equal(clientRestartPostLost(new DOMException('The operation was aborted due to timeout', 'TimeoutError')), true)
|
|
187
|
+
assert.equal(clientRestartPostLost(null), true)
|
|
188
|
+
assert.equal(clientRestartPostLost('boom'), true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('apiError: 携带 status 与 payload.error,解析失败回退 HTTP 码', () => {
|
|
192
|
+
const withBody = clientApiError({ status: 409 }, { error: '升级进行中,禁止重启;等待升级完成后重试' })
|
|
193
|
+
assert.equal(withBody.status, 409)
|
|
194
|
+
assert.equal(withBody.message, '升级进行中,禁止重启;等待升级完成后重试')
|
|
195
|
+
const withoutBody = clientApiError({ status: 500 }, {})
|
|
196
|
+
assert.equal(withoutBody.status, 500)
|
|
197
|
+
assert.equal(withoutBody.message, 'HTTP 500')
|
|
198
|
+
})
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// host 路由集成测试:stub 宿主 ctx 后经 apply 挂载,直接调用捕获的 handler。
|
|
2
|
+
// 覆盖:方法守卫 405、跨源守卫 403、各路由业务分支(400/409/500)、
|
|
3
|
+
// restart 依赖 appExit、readBody 超限。
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
|
|
9
|
+
import { apply, RESTART_DELAY_MS, UPGRADE_LOCK_PATH } from '../src/index.js'
|
|
10
|
+
import { rmSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
// 锁文件是固定共享路径:测试进程中断可能残留幽灵锁毒化后续运行,用例前预热清理
|
|
13
|
+
rmSync(UPGRADE_LOCK_PATH, { force: true })
|
|
14
|
+
|
|
15
|
+
// 全局 fetch 拦截:core.fetchDistTags 默认绑定全局 fetch,测试期返回与 dist-tags.test
|
|
16
|
+
// 同形的流式响应(tags 就绪),防止启动检查/refresh 触发真实网络请求
|
|
17
|
+
const MOCK_TAGS = { latest: '9.9.9', next: '10.0.0' }
|
|
18
|
+
globalThis.fetch = async () => ({
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
body: {
|
|
22
|
+
getReader: () => {
|
|
23
|
+
const chunks = [new TextEncoder().encode(JSON.stringify(MOCK_TAGS))]
|
|
24
|
+
return {
|
|
25
|
+
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
|
|
26
|
+
cancel: async () => {},
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function makeCtx({ appExit, settingsStore, timerAvailable = true } = {}) {
|
|
33
|
+
const routes = new Map()
|
|
34
|
+
let tick = null
|
|
35
|
+
const store = settingsStore ?? {}
|
|
36
|
+
const calls = { exits: [], registered: [], disposers: [] }
|
|
37
|
+
const ctx = {
|
|
38
|
+
calls,
|
|
39
|
+
get(name) {
|
|
40
|
+
if (name === 'appExit') return appExit
|
|
41
|
+
if (name === 'settings') return settingsService
|
|
42
|
+
return undefined
|
|
43
|
+
},
|
|
44
|
+
// effect 桩:执行装配函数并捕获其返回的 disposer,供测试模拟 fiber 停用
|
|
45
|
+
effect(fn) {
|
|
46
|
+
const disposer = fn()
|
|
47
|
+
if (typeof disposer === 'function') calls.disposers.push(disposer)
|
|
48
|
+
},
|
|
49
|
+
inject(deps, fn) {
|
|
50
|
+
// timer 服务桩:模拟宿主 timer 激活后的 interval(返回 disposer 同官方契约);
|
|
51
|
+
// timerAvailable=false 模拟服务缺失
|
|
52
|
+
fn({
|
|
53
|
+
settings: settingsService,
|
|
54
|
+
interval: timerAvailable
|
|
55
|
+
? (intervalFn) => {
|
|
56
|
+
tick = intervalFn
|
|
57
|
+
return () => { if (tick === intervalFn) tick = null }
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
webServer: {
|
|
63
|
+
register(route) {
|
|
64
|
+
routes.set(route.path, route.handler)
|
|
65
|
+
calls.registered.push(route.path)
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
fireTick() {
|
|
69
|
+
if (tick) tick()
|
|
70
|
+
},
|
|
71
|
+
disposeEffects() {
|
|
72
|
+
for (const disposer of calls.disposers) disposer()
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
const settingsService = {
|
|
76
|
+
register() {},
|
|
77
|
+
get() {
|
|
78
|
+
return store
|
|
79
|
+
},
|
|
80
|
+
async update(ns, patch) {
|
|
81
|
+
Object.assign(store, patch)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return { ctx, routes }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeReq({ method = 'POST', body, headers = {} } = {}) {
|
|
88
|
+
const req = new EventEmitter()
|
|
89
|
+
req.method = method
|
|
90
|
+
req.headers = { host: 'localhost:3000', ...headers }
|
|
91
|
+
req.destroy = () => {
|
|
92
|
+
req.destroyed = true
|
|
93
|
+
}
|
|
94
|
+
if (body !== undefined) {
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
req.emit('data', Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)))
|
|
97
|
+
req.emit('end')
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
return req
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeRes() {
|
|
104
|
+
const res = { status: null, payload: null }
|
|
105
|
+
res.writeHead = (status) => {
|
|
106
|
+
res.status = status
|
|
107
|
+
}
|
|
108
|
+
res.end = (text) => {
|
|
109
|
+
res.payload = text ? JSON.parse(text) : null
|
|
110
|
+
}
|
|
111
|
+
return res
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function call(routes, path, req) {
|
|
115
|
+
const handler = routes.get(path)
|
|
116
|
+
assert.ok(handler, '路由未注册: ' + path)
|
|
117
|
+
const res = makeRes()
|
|
118
|
+
await handler(req, res)
|
|
119
|
+
return res
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const post = (routes, path, body, headers) => call(routes, path, makeReq({ method: 'POST', body, headers }))
|
|
123
|
+
const get = (routes, path) => call(routes, path, makeReq({ method: 'GET' }))
|
|
124
|
+
|
|
125
|
+
test('挂载:8 条路由注册,启动检查后快照就绪', async () => {
|
|
126
|
+
const { ctx, routes } = makeCtx()
|
|
127
|
+
apply(ctx)
|
|
128
|
+
assert.equal(routes.size, 8)
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
[...routes.keys()].sort(),
|
|
131
|
+
[
|
|
132
|
+
'/api/maintain/channel',
|
|
133
|
+
'/api/maintain/poll-interval',
|
|
134
|
+
'/api/maintain/refresh',
|
|
135
|
+
'/api/maintain/registry-base',
|
|
136
|
+
'/api/maintain/restart',
|
|
137
|
+
'/api/maintain/status',
|
|
138
|
+
'/api/maintain/upgrade',
|
|
139
|
+
'/api/maintain/upgrade-template',
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
// 启动检查是异步链(mock fetch 微任务 + resolveHostVersion 真实文件读),
|
|
143
|
+
// 轮询等快照落定后再断言
|
|
144
|
+
let snapshotReady = false
|
|
145
|
+
for (let waited = 0; waited < 5000; waited += 25) {
|
|
146
|
+
const poll = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
147
|
+
if (poll.payload.checkedAt !== null) {
|
|
148
|
+
snapshotReady = true
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
152
|
+
}
|
|
153
|
+
assert.ok(snapshotReady, '启动检查 5 秒内未完成')
|
|
154
|
+
const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
155
|
+
assert.equal(res.status, 200)
|
|
156
|
+
assert.equal(res.payload.packageName, '@deepseek-ai/dsh')
|
|
157
|
+
assert.equal(res.payload.channel, 'latest')
|
|
158
|
+
assert.deepEqual(res.payload.tags, MOCK_TAGS, '启动检查后 dist-tags 必须就绪')
|
|
159
|
+
assert.equal(res.payload.checkError, null)
|
|
160
|
+
assert.ok(res.payload.checkedAt !== null)
|
|
161
|
+
assert.equal(res.payload.pollRunning, true, 'timer 服务激活时自动轮询应武装')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('timer 服务缺失:自动轮询降级,面板状态照常响应', async () => {
|
|
165
|
+
const { ctx, routes } = makeCtx({ timerAvailable: false })
|
|
166
|
+
apply(ctx)
|
|
167
|
+
const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
168
|
+
assert.equal(res.status, 200)
|
|
169
|
+
assert.equal(res.payload.pollRunning, false)
|
|
170
|
+
// 手动检查通道不受影响:refresh 仍可拉取 dist-tags
|
|
171
|
+
const refreshed = await call(routes, '/api/maintain/refresh', makeReq({ body: {} }))
|
|
172
|
+
assert.equal(refreshed.status, 200)
|
|
173
|
+
assert.ok(refreshed.payload.checkedAt !== null)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('interval dispose 回归:fiber 停用后轮询 tick 失效(防双 interval 回归)', async () => {
|
|
177
|
+
const { ctx, routes } = makeCtx()
|
|
178
|
+
apply(ctx)
|
|
179
|
+
assert.equal(ctx.calls.disposers.length > 0, true, 'interval dispose 必须经 ctx.effect 挂回插件 fiber')
|
|
180
|
+
// 等启动检查落定,排除其 checkedAt 变化对断言的干扰
|
|
181
|
+
let baseline = null
|
|
182
|
+
for (let waited = 0; waited < 5000; waited += 25) {
|
|
183
|
+
const poll = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
184
|
+
if (poll.payload.checkedAt !== null) { baseline = poll.payload.checkedAt; break }
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
186
|
+
}
|
|
187
|
+
assert.ok(baseline !== null, '启动检查 5 秒内未完成')
|
|
188
|
+
ctx.disposeEffects()
|
|
189
|
+
ctx.fireTick()
|
|
190
|
+
const res = await call(routes, '/api/maintain/status', makeReq({ method: 'GET' }))
|
|
191
|
+
assert.equal(res.payload.checkedAt, baseline, 'dispose 后 fireTick 不得触发新一轮检查')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('方法守卫:全部路由错误方法一律 405', async () => {
|
|
195
|
+
const { ctx, routes } = makeCtx()
|
|
196
|
+
apply(ctx)
|
|
197
|
+
for (const path of routes.keys()) {
|
|
198
|
+
const expected = path === '/api/maintain/status' ? 'POST' : 'GET'
|
|
199
|
+
const res = await call(routes, path, makeReq({ method: expected }))
|
|
200
|
+
assert.equal(res.status, 405, path + ' 应拒绝 ' + expected)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('跨源守卫:Origin 与 Host 不符即 403;同源放行;Host 大小写归一', async () => {
|
|
205
|
+
const { ctx, routes } = makeCtx()
|
|
206
|
+
apply(ctx)
|
|
207
|
+
const evil = await post(routes, '/api/maintain/refresh', undefined, { origin: 'https://evil.example', host: 'localhost:3000' })
|
|
208
|
+
assert.equal(evil.status, 403)
|
|
209
|
+
const ok = await post(routes, '/api/maintain/refresh', undefined, { origin: 'http://localhost:3000' })
|
|
210
|
+
assert.equal(ok.status, 200)
|
|
211
|
+
const upper = await post(routes, '/api/maintain/refresh', undefined, { origin: 'http://LOCALHOST:3000', host: 'LOCALHOST:3000' })
|
|
212
|
+
assert.equal(upper.status, 200, 'Host 头大小写不影响同源判定')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('refresh:触发检查并返回 200 快照', async () => {
|
|
216
|
+
const { ctx, routes } = makeCtx()
|
|
217
|
+
apply(ctx)
|
|
218
|
+
const res = await post(routes, '/api/maintain/refresh')
|
|
219
|
+
assert.equal(res.status, 200)
|
|
220
|
+
assert.ok(res.payload.checkedAt !== null)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('channel:空值 400;非法字符 400;不在 tags 400;合法通道走白名单放行', async () => {
|
|
224
|
+
const store = {}
|
|
225
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
226
|
+
apply(ctx)
|
|
227
|
+
|
|
228
|
+
const empty = await post(routes, '/api/maintain/channel', { channel: ' ' })
|
|
229
|
+
assert.equal(empty.status, 400)
|
|
230
|
+
|
|
231
|
+
const malicious = await post(routes, '/api/maintain/channel', { channel: 'latest; rm -rf /' })
|
|
232
|
+
assert.equal(malicious.status, 400)
|
|
233
|
+
assert.match(malicious.payload.error, /非法字符/, '白名单与不在 tags 两条拒绝路径文案可区分')
|
|
234
|
+
assert.equal(store.channel, undefined, '非法通道不得落盘')
|
|
235
|
+
|
|
236
|
+
const ghost = await post(routes, '/api/maintain/channel', { channel: 'ghosttag' })
|
|
237
|
+
assert.equal(ghost.status, 400)
|
|
238
|
+
assert.match(ghost.payload.error, /不在当前 dist-tags/, 'tags 就绪时白名单分支生效')
|
|
239
|
+
|
|
240
|
+
const ok = await post(routes, '/api/maintain/channel', { channel: 'next' })
|
|
241
|
+
assert.equal(ok.status, 200)
|
|
242
|
+
assert.equal(store.channel, 'next')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('upgrade-template:空值 400;合法值持久化', async () => {
|
|
246
|
+
const store = {}
|
|
247
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
248
|
+
apply(ctx)
|
|
249
|
+
const empty = await post(routes, '/api/maintain/upgrade-template', { template: '' })
|
|
250
|
+
assert.equal(empty.status, 400)
|
|
251
|
+
const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
|
|
252
|
+
assert.equal(blank.status, 400, '空白模板与空值同判,不得落盘')
|
|
253
|
+
assert.equal(store.upgradeCommandTemplate, undefined, '空白模板不得经路由落盘')
|
|
254
|
+
const ok = await post(routes, '/api/maintain/upgrade-template', { template: 'npm i -g pkg@{tag}' })
|
|
255
|
+
assert.equal(ok.status, 200)
|
|
256
|
+
assert.equal(store.upgradeCommandTemplate, 'npm i -g pkg@{tag}')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('poll-interval:负数 400;合法值持久化', async () => {
|
|
260
|
+
const store = {}
|
|
261
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
262
|
+
apply(ctx)
|
|
263
|
+
const bad = await post(routes, '/api/maintain/poll-interval', { seconds: -1 })
|
|
264
|
+
assert.equal(bad.status, 400)
|
|
265
|
+
const wide = await post(routes, '/api/maintain/poll-interval', { seconds: '60' })
|
|
266
|
+
assert.equal(wide.status, 400, '字符串宽转必须拒绝')
|
|
267
|
+
const ok = await post(routes, '/api/maintain/poll-interval', { seconds: 0 })
|
|
268
|
+
assert.equal(ok.status, 200)
|
|
269
|
+
assert.equal(store.pollIntervalSec, 0)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('registry-base:非法 scheme 400;合法值持久化', async () => {
|
|
273
|
+
const store = {}
|
|
274
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
275
|
+
apply(ctx)
|
|
276
|
+
const bad = await post(routes, '/api/maintain/registry-base', { base: 'ftp://mirror.example' })
|
|
277
|
+
assert.equal(bad.status, 400)
|
|
278
|
+
const ok = await post(routes, '/api/maintain/registry-base', { base: 'https://mirror.example' })
|
|
279
|
+
assert.equal(ok.status, 200)
|
|
280
|
+
assert.equal(store.registryBase, 'https://mirror.example')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('upgrade:空白模板经 upgrade-template 路由拒绝', async () => {
|
|
284
|
+
// 默认模板是真实 npm install 命令,POST upgrade 的默认路径禁止在测试中触发;
|
|
285
|
+
// 门闩语义由"真实挂起命令"用例覆盖,此处锁定保存侧空白拒绝
|
|
286
|
+
const store = {}
|
|
287
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
288
|
+
apply(ctx)
|
|
289
|
+
const blank = await post(routes, '/api/maintain/upgrade-template', { template: ' ' })
|
|
290
|
+
assert.equal(blank.status, 400)
|
|
291
|
+
assert.equal(store.upgradeCommandTemplate, undefined)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('upgrade:真实挂起命令触达门闩,二次 409,结束后自动重查', async () => {
|
|
295
|
+
const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
|
|
296
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
297
|
+
apply(ctx)
|
|
298
|
+
const baseline = await get(routes, '/api/maintain/status').then((r) => r.payload)
|
|
299
|
+
const first = await post(routes, '/api/maintain/upgrade')
|
|
300
|
+
assert.equal(first.status, 200)
|
|
301
|
+
assert.equal(first.payload.upgrade.running, true)
|
|
302
|
+
assert.equal(first.payload.upgradeLockHeld, true, '升级期间锁文件持有效力')
|
|
303
|
+
const second = await post(routes, '/api/maintain/upgrade')
|
|
304
|
+
assert.equal(second.status, 409)
|
|
305
|
+
// 等挂起命令自然退出(轮询而非固定 sleep,兼做落定状态显式断言)
|
|
306
|
+
let settled = null
|
|
307
|
+
for (let i = 0; i < 50 && settled === null; i += 1) {
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
309
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
310
|
+
if (status && status.upgrade && status.upgrade.running === false && status.upgrade.last !== null) settled = status
|
|
311
|
+
}
|
|
312
|
+
assert.ok(settled, '升级应在挂起命令退出后落定')
|
|
313
|
+
assert.equal(settled.upgrade.last.ok, true)
|
|
314
|
+
assert.equal(settled.upgradeLockHeld, false, '升级结束后锁文件应删除')
|
|
315
|
+
assert.notEqual(settled.checkedAt, baseline.checkedAt, '升级落定后自动重查链应已刷新 checkedAt')
|
|
316
|
+
assert.ok(settled.checkedAt !== null)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('restart:升级进行中 409 拒绝且不调度退出', async () => {
|
|
320
|
+
const store = { upgradeCommandTemplate: 'node -e "setTimeout(() => {}, 2000)"' }
|
|
321
|
+
const exits = []
|
|
322
|
+
const { ctx, routes } = makeCtx({ settingsStore: store, appExit: (code) => exits.push(code) })
|
|
323
|
+
apply(ctx)
|
|
324
|
+
const upgrade = await post(routes, '/api/maintain/upgrade')
|
|
325
|
+
assert.equal(upgrade.status, 200)
|
|
326
|
+
const denied = await post(routes, '/api/maintain/restart')
|
|
327
|
+
assert.equal(denied.status, 409)
|
|
328
|
+
assert.match(denied.payload.error, /升级进行中/)
|
|
329
|
+
// 等挂起命令退出后确认 exit 未被调度(轮询上限 5s)
|
|
330
|
+
let running = true
|
|
331
|
+
for (let i = 0; i < 50 && running; i += 1) {
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
333
|
+
const status = await get(routes, '/api/maintain/status').then((r) => r.payload).catch(() => null)
|
|
334
|
+
running = Boolean(status && status.upgrade && status.upgrade.running)
|
|
335
|
+
}
|
|
336
|
+
assert.deepEqual(exits, [], '409 拒绝路径不得调度 exit')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('upgrade:重启调度后触发升级 409(双向互斥)', async (t) => {
|
|
340
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
341
|
+
const exits = []
|
|
342
|
+
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
343
|
+
apply(ctx)
|
|
344
|
+
const restart = await post(routes, '/api/maintain/restart')
|
|
345
|
+
assert.equal(restart.status, 200)
|
|
346
|
+
const denied = await post(routes, '/api/maintain/upgrade')
|
|
347
|
+
assert.equal(denied.status, 409)
|
|
348
|
+
assert.match(denied.payload.error, /重启已调度/)
|
|
349
|
+
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
350
|
+
assert.deepEqual(exits, [0])
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('status:快照携带 bootAt 实例代际', async () => {
|
|
354
|
+
const { ctx, routes } = makeCtx()
|
|
355
|
+
apply(ctx)
|
|
356
|
+
const status = await get(routes, '/api/maintain/status')
|
|
357
|
+
assert.equal(status.status, 200)
|
|
358
|
+
assert.equal(typeof status.payload.bootAt, 'number')
|
|
359
|
+
assert.ok(Number.isFinite(status.payload.bootAt) && status.payload.bootAt > 0)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('poll-interval:超上界 400(秒转毫秒溢出防护)', async () => {
|
|
363
|
+
const store = {}
|
|
364
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
365
|
+
apply(ctx)
|
|
366
|
+
const huge = await post(routes, '/api/maintain/poll-interval', { seconds: 1e308 })
|
|
367
|
+
assert.equal(huge.status, 400)
|
|
368
|
+
assert.equal(store.pollIntervalSec, undefined, '超上界值不得落盘')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('registry-base:带 query 或 hash 的输入 400', async () => {
|
|
372
|
+
const store = {}
|
|
373
|
+
const { ctx, routes } = makeCtx({ settingsStore: store })
|
|
374
|
+
apply(ctx)
|
|
375
|
+
const withQuery = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com?mirror=1' })
|
|
376
|
+
assert.equal(withQuery.status, 400)
|
|
377
|
+
const withHash = await post(routes, '/api/maintain/registry-base', { base: 'https://example.com#frag' })
|
|
378
|
+
assert.equal(withHash.status, 400)
|
|
379
|
+
assert.equal(store.registryBase, undefined)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('restart:缺失 appExit 500;响应立即返回,延迟退出', async (t) => {
|
|
383
|
+
const withoutExit = makeCtx()
|
|
384
|
+
apply(withoutExit.ctx)
|
|
385
|
+
const denied = await post(withoutExit.routes, '/api/maintain/restart')
|
|
386
|
+
assert.equal(denied.status, 500)
|
|
387
|
+
|
|
388
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
389
|
+
const exits = []
|
|
390
|
+
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
391
|
+
apply(ctx)
|
|
392
|
+
const ok = await post(routes, '/api/maintain/restart')
|
|
393
|
+
assert.equal(ok.status, 200)
|
|
394
|
+
assert.equal(ok.payload.restarting, true)
|
|
395
|
+
// 响应交付瞬间宿主必须仍在:exit 只能在延迟窗口之后执行
|
|
396
|
+
assert.deepEqual(exits, [], '响应返回时 exit 不得已触发')
|
|
397
|
+
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
398
|
+
assert.deepEqual(exits, [0])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('restart:延迟窗口内重复请求幂等,exit 仅调度一次', async (t) => {
|
|
402
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
403
|
+
const exits = []
|
|
404
|
+
const { ctx, routes } = makeCtx({ appExit: (code) => exits.push(code) })
|
|
405
|
+
apply(ctx)
|
|
406
|
+
const first = await post(routes, '/api/maintain/restart')
|
|
407
|
+
assert.equal(first.status, 200)
|
|
408
|
+
const second = await post(routes, '/api/maintain/restart')
|
|
409
|
+
assert.equal(second.status, 200)
|
|
410
|
+
assert.equal(second.payload.restarting, true)
|
|
411
|
+
t.mock.timers.tick(RESTART_DELAY_MS + 1)
|
|
412
|
+
assert.deepEqual(exits, [0], '重复请求不得叠加调度 exit')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('readBody 超限:路由归一 400', async () => {
|
|
416
|
+
const { ctx, routes } = makeCtx()
|
|
417
|
+
apply(ctx)
|
|
418
|
+
const req = makeReq({ method: 'POST' })
|
|
419
|
+
const res = makeRes()
|
|
420
|
+
const done = routes.get('/api/maintain/channel')(req, res)
|
|
421
|
+
req.emit('data', Buffer.alloc(64 * 1024 + 1, 'x'))
|
|
422
|
+
await done
|
|
423
|
+
assert.equal(res.status, 400)
|
|
424
|
+
assert.match(res.payload.error, /上限/)
|
|
425
|
+
})
|