@mzzsfy/dsh-maintain 0.5.3 → 0.6.1
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 +13 -10
- package/package.json +1 -1
- package/src/client.js +127 -23
- package/src/core.mjs +58 -0
- package/src/index.js +369 -48
- package/src/runtime.mjs +69 -0
- package/src/upgrade.mjs +3 -2
- package/test/client-scope.test.mjs +54 -0
- package/test/core.test.mjs +135 -0
- package/test/parity.test.mjs +30 -3
- package/test/restart-ready.test.mjs +65 -9
- package/test/route.test.mjs +314 -23
- package/test/runtime.test.mjs +67 -0
- package/test/upgrade.test.mjs +172 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
|
|
2
|
+
// 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
|
|
3
|
+
// 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
|
|
4
|
+
// 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import vm from 'node:vm'
|
|
11
|
+
|
|
12
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
|
|
13
|
+
const DECLARATION_NAMES = [
|
|
14
|
+
...new Set(
|
|
15
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
16
|
+
),
|
|
17
|
+
]
|
|
18
|
+
const BUNDLE_ID = '@mzzsfy/dsh-maintain'
|
|
19
|
+
|
|
20
|
+
test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
|
|
21
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
22
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
23
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
24
|
+
assert.equal(vm.runInContext('CSS', ctx), 1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
|
|
28
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
29
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
30
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
|
|
34
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
35
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
36
|
+
const leaked = DECLARATION_NAMES.filter((name) => {
|
|
37
|
+
try {
|
|
38
|
+
vm.runInContext(`const ${name} = null`, ctx)
|
|
39
|
+
return false
|
|
40
|
+
} catch {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
assert.deepEqual(leaked, [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
|
|
48
|
+
const registrations = []
|
|
49
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
|
|
50
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
51
|
+
assert.equal(registrations.length, 1)
|
|
52
|
+
assert.equal(registrations[0].id, BUNDLE_ID)
|
|
53
|
+
assert.equal(typeof registrations[0].factory, 'function')
|
|
54
|
+
})
|
package/test/core.test.mjs
CHANGED
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
parseSemver,
|
|
6
6
|
gtSemver,
|
|
7
7
|
judgeVersion,
|
|
8
|
+
judgeUpgradeFreshness,
|
|
9
|
+
isVersionPendingRestart,
|
|
10
|
+
classifyUpgradeFailure,
|
|
8
11
|
buildUpgradeCommand,
|
|
9
12
|
isValidChannelName,
|
|
10
13
|
isValidRegistryBase,
|
|
@@ -12,6 +15,11 @@ import {
|
|
|
12
15
|
VERDICT_OUTDATED,
|
|
13
16
|
VERDICT_UP_TO_DATE,
|
|
14
17
|
VERDICT_UNKNOWN,
|
|
18
|
+
UPGRADE_FAIL_TRANSIENT_NETWORK,
|
|
19
|
+
UPGRADE_FAIL_FILE_LOCKED,
|
|
20
|
+
UPGRADE_FAIL_NPM_MISSING,
|
|
21
|
+
UPGRADE_FAIL_TIMEOUT,
|
|
22
|
+
UPGRADE_FAIL_UNKNOWN,
|
|
15
23
|
} from '../src/core.mjs'
|
|
16
24
|
|
|
17
25
|
const CURRENT = '0.1.1-rc.2'
|
|
@@ -174,3 +182,130 @@ test('场景:未失联且实例标识未变不刷新', () => {
|
|
|
174
182
|
// bootAt 单侧出现不构成证据(防旧宿主快照缺字段误判)
|
|
175
183
|
assert.equal(shouldReloadAfterRestart({ lost: false, pid: 100, bootAt: null }, { lost: false, pid: 100, bootAt: 200 }), false)
|
|
176
184
|
})
|
|
185
|
+
|
|
186
|
+
// 升级失败分类:npm 输出特征取自 npm 10/11 真实错误行形态(npm error code/syscall),
|
|
187
|
+
// 特征未命中一律宽松归 unknown,不可重试类绝不重试。
|
|
188
|
+
test('分类:Windows 文件锁形态归 file-locked 且可重试', () => {
|
|
189
|
+
const samples = [
|
|
190
|
+
'npm error code EBUSY\nnpm error syscall rename\nnpm error path C:\\nvm\\node_modules\\@deepseek-ai\\dsh\\package.json',
|
|
191
|
+
'npm error code EPERM\nnpm error syscall unlink\nnpm error path C:\\nvm\\node_modules\\.bin\\dsh.cmd',
|
|
192
|
+
'npm error code ENOENT\nnpm error syscall rename\nnpm error path C:\\nvm\\node_modules\\@deepseek-ai\\dsh',
|
|
193
|
+
'C:\\nvm\\node_modules\\@deepseek-ai\\dsh\\lib\\index.js is being used by another process',
|
|
194
|
+
'EBUSY: resource busy or locked, unlink C:\\nvm\\node_modules\\@deepseek-ai\\dsh\\lib\\index.js',
|
|
195
|
+
]
|
|
196
|
+
for (const stderrTail of samples) {
|
|
197
|
+
const result = classifyUpgradeFailure({ code: 1, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail })
|
|
198
|
+
assert.equal(result.kind, UPGRADE_FAIL_FILE_LOCKED, stderrTail)
|
|
199
|
+
assert.equal(result.retryable, true, stderrTail)
|
|
200
|
+
assert.ok(typeof result.reason === 'string' && result.reason.length > 0)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('分类:网络瞬断形态归 transient-network 且可重试', () => {
|
|
205
|
+
const samples = [
|
|
206
|
+
'npm error network request to https://registry.npmjs.org/@deepseek-ai%2fdsh failed, reason: socket hang up',
|
|
207
|
+
'npm error code ECONNRESET\nnpm error errno ECONNRESET\nnpm error network This is a problem related to network connectivity.',
|
|
208
|
+
'npm error code EAI_AGAIN\nnpm error syscall getaddrinfo',
|
|
209
|
+
'npm error code ECONNREFUSED',
|
|
210
|
+
'fetch failed',
|
|
211
|
+
'npm error code E503\nnpm error 503 Service Unavailable - GET https://registry.npmjs.org/dsh',
|
|
212
|
+
]
|
|
213
|
+
for (const stderrTail of samples) {
|
|
214
|
+
const result = classifyUpgradeFailure({ code: 1, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail })
|
|
215
|
+
assert.equal(result.kind, UPGRADE_FAIL_TRANSIENT_NETWORK, stderrTail)
|
|
216
|
+
assert.equal(result.retryable, true, stderrTail)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('分类:命令未找到形态归 npm-missing 且不可重试', () => {
|
|
221
|
+
const samples = [
|
|
222
|
+
"'npmm' 不是内部或外部命令,也不是可运行的程序或批处理文件。",
|
|
223
|
+
'/bin/sh: 1: npmm: command not found',
|
|
224
|
+
'/bin/sh: 1: npmm: not found',
|
|
225
|
+
'spawn npmm ENOENT',
|
|
226
|
+
]
|
|
227
|
+
for (const stderrTail of samples) {
|
|
228
|
+
const result = classifyUpgradeFailure({ code: 1, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail })
|
|
229
|
+
assert.equal(result.kind, UPGRADE_FAIL_NPM_MISSING, stderrTail)
|
|
230
|
+
assert.equal(result.retryable, false, stderrTail)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('分类:超时强杀优先归 timeout,尾流含文件锁特征也不重试', () => {
|
|
235
|
+
const result = classifyUpgradeFailure({ code: null, timedOut: true, stillRunning: false, stdoutTail: '', stderrTail: 'npm error code EBUSY' })
|
|
236
|
+
assert.equal(result.kind, UPGRADE_FAIL_TIMEOUT)
|
|
237
|
+
assert.equal(result.retryable, false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('分类:未识别输出归 unknown 兜底且不可重试', () => {
|
|
241
|
+
for (const input of [
|
|
242
|
+
{ code: 3, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail: 'boom-fail' },
|
|
243
|
+
{ code: 1, timedOut: false, stillRunning: false, stdoutTail: '', stderrTail: '' },
|
|
244
|
+
{ code: null, timedOut: false, stillRunning: true, stdoutTail: '', stderrTail: '' },
|
|
245
|
+
{ code: 1, timedOut: false, stillRunning: false, stdoutTail: 'npm warn deprecated x', stderrTail: 'exit 1' },
|
|
246
|
+
]) {
|
|
247
|
+
const result = classifyUpgradeFailure(input)
|
|
248
|
+
assert.equal(result.kind, UPGRADE_FAIL_UNKNOWN, JSON.stringify(input))
|
|
249
|
+
assert.equal(result.retryable, false, JSON.stringify(input))
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('分类:stdout 尾流特征同样参与匹配', () => {
|
|
254
|
+
const result = classifyUpgradeFailure({ code: 1, timedOut: false, stillRunning: false, stdoutTail: 'npm error code EBUSY', stderrTail: '' })
|
|
255
|
+
assert.equal(result.kind, UPGRADE_FAIL_FILE_LOCKED)
|
|
256
|
+
assert.equal(result.retryable, true)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('分类:输入字段缺省容忍不抛错', () => {
|
|
260
|
+
const result = classifyUpgradeFailure({})
|
|
261
|
+
assert.equal(result.kind, UPGRADE_FAIL_UNKNOWN)
|
|
262
|
+
assert.equal(result.retryable, false)
|
|
263
|
+
assert.ok(typeof result.reason === 'string')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// 升级后磁盘版本复读判定:stale=版本未前进或未达通道目标;信息缺失宽松不误报。
|
|
267
|
+
test('复读:版本前进且达通道目标判 fresh', () => {
|
|
268
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '2.0.0', channelLatest: '2.0.0' }), { stale: false, reason: null })
|
|
269
|
+
// prerelease 目标达成也算 fresh;prerelease 低于正式版属未达目标(见下例)
|
|
270
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '2.0.0', installedVersion: '2.1.0-rc.1', channelLatest: '2.1.0-rc.1' }), { stale: false, reason: null })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('复读:磁盘版本未前进或回退判 stale', () => {
|
|
274
|
+
const same = judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '1.0.0', channelLatest: '2.0.0' })
|
|
275
|
+
assert.equal(same.stale, true)
|
|
276
|
+
assert.match(same.reason, /未前进/)
|
|
277
|
+
const rollback = judgeUpgradeFreshness({ previousVersion: '2.0.0', installedVersion: '1.9.0', channelLatest: '2.0.0' })
|
|
278
|
+
assert.equal(rollback.stale, true)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('复读:前进但未达通道目标判 stale', () => {
|
|
282
|
+
const result = judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '1.5.0', channelLatest: '2.0.0' })
|
|
283
|
+
assert.equal(result.stale, true)
|
|
284
|
+
assert.match(result.reason, /未达/)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// 运行/已装版本区分:磁盘版本领先运行版本即待重启生效;任一侧不可解析一律 false 不误报。
|
|
288
|
+
test('重启待生效:仅已装版本严格领先运行版本时为真', () => {
|
|
289
|
+
assert.equal(isVersionPendingRestart({ runningVersion: '1.0.0', installedVersion: '2.0.0' }), true)
|
|
290
|
+
assert.equal(isVersionPendingRestart({ runningVersion: '2.1.0-rc.1', installedVersion: '2.1.0' }), true)
|
|
291
|
+
assert.equal(isVersionPendingRestart({ runningVersion: '2.0.0', installedVersion: '2.0.0' }), false)
|
|
292
|
+
assert.equal(isVersionPendingRestart({ runningVersion: '3.0.0', installedVersion: '2.0.0' }), false)
|
|
293
|
+
assert.equal(isVersionPendingRestart({ runningVersion: null, installedVersion: '2.0.0' }), false)
|
|
294
|
+
assert.equal(isVersionPendingRestart({ runningVersion: '2.0.0', installedVersion: null }), false)
|
|
295
|
+
assert.equal(isVersionPendingRestart({ runningVersion: 'dev-main', installedVersion: '2.0.0' }), false)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('复读:installed 解析失败判 stale,previous 缺失不豁免未达目标', () => {
|
|
299
|
+
const broken = judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: null, channelLatest: '2.0.0' })
|
|
300
|
+
assert.equal(broken.stale, true)
|
|
301
|
+
assert.match(broken.reason, /解析失败|读取/)
|
|
302
|
+
const garbage = judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: 'dev-main', channelLatest: '2.0.0' })
|
|
303
|
+
assert.equal(garbage.stale, true)
|
|
304
|
+
// 旧版本未知只豁免"未前进"分支:磁盘低于通道目标仍判 stale
|
|
305
|
+
const unknownPrevious = judgeUpgradeFreshness({ previousVersion: null, installedVersion: '1.5.0', channelLatest: '2.0.0' })
|
|
306
|
+
assert.equal(unknownPrevious.stale, true)
|
|
307
|
+
assert.match(unknownPrevious.reason, /未达/)
|
|
308
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: null, installedVersion: '2.0.0', channelLatest: '2.0.0' }), { stale: false, reason: null })
|
|
309
|
+
// 通道目标缺失时无法证明未达标,宽松判 fresh
|
|
310
|
+
assert.deepEqual(judgeUpgradeFreshness({ previousVersion: '1.0.0', installedVersion: '2.0.0', channelLatest: null }), { stale: false, reason: null })
|
|
311
|
+
})
|
package/test/parity.test.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
TARGET_PACKAGE,
|
|
17
17
|
} from '../src/core.mjs'
|
|
18
18
|
import { clientSource, extractLogic } from './logic-extract.mjs'
|
|
19
|
+
import { KILL_GRACE_MS } from '../src/upgrade.mjs'
|
|
19
20
|
|
|
20
21
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
22
|
const CLIENT_SOURCE = clientSource()
|
|
@@ -92,7 +93,17 @@ test('parity: VERDICT 三常量 client 与 core 一致', () => {
|
|
|
92
93
|
})
|
|
93
94
|
|
|
94
95
|
// host 侧锚点直接 import index.js 实现,防测试内手抄字面量漂移假绿
|
|
95
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
DEFAULT_UPGRADE_TEMPLATE,
|
|
98
|
+
DEFAULT_POLL_INTERVAL_SEC,
|
|
99
|
+
DEFAULT_REGISTRY_BASE,
|
|
100
|
+
TICK_MS,
|
|
101
|
+
API_PATHS,
|
|
102
|
+
UPGRADE_TIMEOUT_MS,
|
|
103
|
+
UPGRADE_MAX_ATTEMPTS,
|
|
104
|
+
UPGRADE_RETRY_BACKOFF_MS,
|
|
105
|
+
AUTO_RESTART_DELAY_MS,
|
|
106
|
+
} from '../src/index.js'
|
|
96
107
|
|
|
97
108
|
test('parity: 默认升级命令模板 client 字面量与 host 实现一致', () => {
|
|
98
109
|
assert.equal(extractConst('DEFAULT_UPGRADE_TEMPLATE'), DEFAULT_UPGRADE_TEMPLATE)
|
|
@@ -132,15 +143,31 @@ test('parity: 重启等待总时长大于宿主退出延迟', () => {
|
|
|
132
143
|
assert.ok(restartTimeoutMs > RESTART_DELAY_MS, 'RESTART_TIMEOUT_MS 必须大于 RESTART_DELAY_MS')
|
|
133
144
|
})
|
|
134
145
|
|
|
135
|
-
test('parity:
|
|
146
|
+
test('parity: 升级观察上限覆盖宿主重试链上限(防抢跑转状态未知)', () => {
|
|
147
|
+
// 重试链上限 = 尝试次数×单次超时 + 最大退避累计 + 强杀宽限;超时强杀只会终止链,不叠加
|
|
148
|
+
const maxBackoffTotal = Object.values(UPGRADE_RETRY_BACKOFF_MS)
|
|
149
|
+
.reduce((max, seq) => Math.max(max, seq.reduce((sum, ms) => sum + ms, 0)), 0)
|
|
150
|
+
const chainUpperBound = UPGRADE_MAX_ATTEMPTS * UPGRADE_TIMEOUT_MS + maxBackoffTotal + KILL_GRACE_MS
|
|
136
151
|
const watchMaxMs = extractNumberConst('UPGRADE_WATCH_MAX_MS')
|
|
137
|
-
assert.ok(
|
|
152
|
+
assert.ok(
|
|
153
|
+
watchMaxMs >= chainUpperBound,
|
|
154
|
+
'UPGRADE_WATCH_MAX_MS(' + watchMaxMs + ') 必须不小于重试链上限(' + chainUpperBound + ')',
|
|
155
|
+
)
|
|
138
156
|
})
|
|
139
157
|
|
|
140
158
|
test('parity: npm 版本页链接与追踪包名同源', () => {
|
|
141
159
|
assert.ok(extractConst('NPM_VERSIONS_URL').includes(TARGET_PACKAGE), 'NPM_VERSIONS_URL 应包含 TARGET_PACKAGE 字面量')
|
|
142
160
|
})
|
|
143
161
|
|
|
162
|
+
test('parity: 落定补查宽限覆盖宿主自动重启调度延迟', () => {
|
|
163
|
+
// 落定拍与调度置位之间存在宿主侧 await 窗口:client 补查宽限必须不小于调度延迟 + 观察裕量
|
|
164
|
+
const graceMs = extractNumberConst('UPGRADE_AUTO_RESTART_GRACE_MS')
|
|
165
|
+
assert.ok(
|
|
166
|
+
graceMs >= AUTO_RESTART_DELAY_MS + 1 * 1000,
|
|
167
|
+
'UPGRADE_AUTO_RESTART_GRACE_MS(' + graceMs + ') 必须不小于 AUTO_RESTART_DELAY_MS(' + AUTO_RESTART_DELAY_MS + ') 加观察裕量',
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
144
171
|
test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
|
|
145
172
|
// 顺序循环 + 代际令牌是 R4 修复形态;setInterval 回归即重叠拍竞态回归
|
|
146
173
|
assert.ok(!/setInterval\(/.test(CLIENT_SOURCE), 'client.js 禁止 setInterval(拍自调度取代)')
|
|
@@ -188,15 +188,6 @@ test('restartPostLost: 无应答失败(网络/中止)属于失联', () => {
|
|
|
188
188
|
assert.equal(clientRestartPostLost('boom'), true)
|
|
189
189
|
})
|
|
190
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
|
-
})
|
|
199
|
-
|
|
200
191
|
test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=prev.readyStreak', () => {
|
|
201
192
|
// 回归:f58f47b 给 restartTick 加 readyStreak 参数时调用点漏传,ready=true 分支
|
|
202
193
|
// undefined+1=NaN,NaN>=门槛恒 false,宿主恢复后页面永不自动刷新。
|
|
@@ -205,3 +196,68 @@ test('restartTick 调用点实参完整性:轮询 effect 必须传 readyStreak=p
|
|
|
205
196
|
assert.ok(callSite, 'client.js 找不到 effect 内 restartTick 调用点')
|
|
206
197
|
assert.match(callSite[1], /readyStreak:\s*prev\.readyStreak/, 'restartTick 调用点缺少 readyStreak: prev.readyStreak 实参')
|
|
207
198
|
})
|
|
199
|
+
|
|
200
|
+
// 升级浮条终态文案:ok 落定后 stale / 手动直跑 / 自动重启三条终态分流,失败与未知不在此列
|
|
201
|
+
const clientUpgradeFinalText = extractLogic('upgradeFinalText')
|
|
202
|
+
|
|
203
|
+
// 重启确认态文案:armed 且有活跃计数时改「仍要重启」
|
|
204
|
+
const clientRestartConfirmLabel = extractLogic('restartConfirmLabel')
|
|
205
|
+
|
|
206
|
+
test('upgradeFinalText: stale 优先于两种重启指引,不得引导重启', () => {
|
|
207
|
+
const text = clientUpgradeFinalText({ ok: true, stale: true, requiresManualRestart: true, autoRestartScheduled: true })
|
|
208
|
+
assert.match(text, /未前进|未达目标/)
|
|
209
|
+
assert.doesNotMatch(text, /重启/)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('upgradeFinalText: 手动直跑终态指向手动重启,自动重启终态指向页面自恢复', () => {
|
|
213
|
+
assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: true, autoRestartScheduled: false }), /手动重启/)
|
|
214
|
+
assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: true }), /自动重启/)
|
|
215
|
+
// 默认成功终态
|
|
216
|
+
assert.match(clientUpgradeFinalText({ ok: true, stale: false, requiresManualRestart: false, autoRestartScheduled: false }), /重启宿主/)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('restartConfirmLabel: armed 且活跃计数>0 才显示仍要重启文案', () => {
|
|
220
|
+
assert.equal(clientRestartConfirmLabel(false, 0), '重启宿主')
|
|
221
|
+
assert.equal(clientRestartConfirmLabel(true, 0), '确认重启')
|
|
222
|
+
assert.equal(clientRestartConfirmLabel(false, 3), '重启宿主', '未进入确认态不得显示仍要重启')
|
|
223
|
+
assert.equal(clientRestartConfirmLabel(true, 3), '仍要重启(3 项活跃工作)')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('client 自动重启接管:单一守卫函数,订阅回调与初始加载两路同走', () => {
|
|
227
|
+
const source = clientSource()
|
|
228
|
+
const guardFn = source.match(/function takeOverAutoRestart\(snapshot\)\s*\{([\s\S]*?)\n \}/)
|
|
229
|
+
assert.ok(guardFn, 'client.js 缺少 takeOverAutoRestart 接管守卫')
|
|
230
|
+
assert.match(guardFn[1], /autoRestartScheduled !== true/, '守卫必须以 autoRestartScheduled 触发')
|
|
231
|
+
assert.match(guardFn[1], /restartPendingRef\.current === true/, '重启 POST 在途不得接管')
|
|
232
|
+
assert.match(guardFn[1], /autoRestartTakenRef\.current === true/, '不得重复接管')
|
|
233
|
+
assert.match(guardFn[1], /pid:\s*snapshot\.pid/, '接管基线必须取快照 pid')
|
|
234
|
+
assert.match(guardFn[1], /bootAt:\s*snapshot\.bootAt/, '接管基线必须取快照 bootAt')
|
|
235
|
+
// 两路调用:观察器订阅回调(升级落定拍)+ 初始 load 快照(页面刷新落在调度窗口)
|
|
236
|
+
assert.match(source, /subscribeUpgradeStatus\(\(snapshot\)\s*=>\s*\{[\s\S]*?takeOverAutoRestart\(snapshot\)/, '订阅回调必须走接管守卫')
|
|
237
|
+
assert.match(source, /void load\(\)\.then\(\(next\)\s*=>\s*\{[\s\S]*?takeOverAutoRestart\(next\)/, '初始快照必须走接管守卫')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('client 落定补查:未见自动重启标记时延迟宽限补查一拍再终判', () => {
|
|
241
|
+
const source = clientSource()
|
|
242
|
+
// 落定拍 running 翻转与宿主置调度标记之间存在 await 窗口:未见标记不得立即终判
|
|
243
|
+
assert.match(source, /setTimeout\(\(\)\s*=>\s*\{[\s\S]*?recheckUpgradeSettle\(/, '落定拍未见标记必须延迟补查')
|
|
244
|
+
assert.match(source, /async function recheckUpgradeSettle\([\s\S]*?broadcastUpgradeStatus\(final\)/, '补查必须广播以驱动接管守卫')
|
|
245
|
+
assert.match(source, /upgradeWatch\.generation !== null\) return[\s\S]*?recheckUpgradeSettle|recheckUpgradeSettle\([\s\S]*?upgradeWatch\.generation !== null\) return/, '补查前后必须验让位新观察')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('force 发送条件锁定:仅活跃计数>0 的重启确认带 force', () => {
|
|
249
|
+
const callSite = clientSource().match(/post\(RESTART_URL,\s*([^)]+)\)/)
|
|
250
|
+
assert.ok(callSite, 'client.js 找不到重启 POST 调用点')
|
|
251
|
+
assert.match(callSite[1], /activeWorkTotal > 0 \? \{ force: true \} : undefined/, 'force 必须仅在活跃计数>0 时携带')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('apiError: 携带 status 与完整 payload,解析失败回退 HTTP 码', () => {
|
|
255
|
+
const withBody = clientApiError({ status: 409 }, { error: '升级进行中,禁止重启;等待升级完成后重试', items: { total: 2 } })
|
|
256
|
+
assert.equal(withBody.status, 409)
|
|
257
|
+
assert.equal(withBody.message, '升级进行中,禁止重启;等待升级完成后重试')
|
|
258
|
+
assert.deepEqual(withBody.payload, { error: '升级进行中,禁止重启;等待升级完成后重试', items: { total: 2 } }, 'payload 必须挂在错误对象上供回写计数')
|
|
259
|
+
const withoutBody = clientApiError({ status: 500 }, {})
|
|
260
|
+
assert.equal(withoutBody.status, 500)
|
|
261
|
+
assert.equal(withoutBody.message, 'HTTP 500')
|
|
262
|
+
assert.deepEqual(withoutBody.payload, {})
|
|
263
|
+
})
|