@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.
@@ -0,0 +1,176 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ parseSemver,
6
+ gtSemver,
7
+ judgeVersion,
8
+ buildUpgradeCommand,
9
+ isValidChannelName,
10
+ isValidRegistryBase,
11
+ shouldReloadAfterRestart,
12
+ VERDICT_OUTDATED,
13
+ VERDICT_UP_TO_DATE,
14
+ VERDICT_UNKNOWN,
15
+ } from '../src/core.mjs'
16
+
17
+ const CURRENT = '0.1.1-rc.2'
18
+
19
+ test('semver 解析:标准版本含 prerelease 与 build', () => {
20
+ const parsed = parseSemver('1.2.3-rc.2+build.7')
21
+ assert.deepEqual(
22
+ { major: parsed.major, minor: parsed.minor, patch: parsed.patch, prerelease: parsed.prerelease },
23
+ { major: 1, minor: 2, patch: 3, prerelease: ['rc', 2] },
24
+ )
25
+ })
26
+
27
+ test('semver 解析:非 semver 字符串返回 null', () => {
28
+ assert.equal(parseSemver('latest'), null)
29
+ assert.equal(parseSemver(''), null)
30
+ assert.equal(parseSemver(null), null)
31
+ assert.equal(parseSemver('1.2'), null)
32
+ assert.equal(parseSemver('01.2.3'), null)
33
+ })
34
+
35
+ test('semver 比较:prerelease 数值序 rc.2 低于 rc.10', () => {
36
+ assert.equal(gtSemver('0.1.1-rc.10', '0.1.1-rc.2'), true)
37
+ assert.equal(gtSemver('0.1.1-rc.2', '0.1.1-rc.10'), false)
38
+ })
39
+
40
+ test('semver 比较:无 prerelease 高于有 prerelease', () => {
41
+ assert.equal(gtSemver('0.1.1', '0.1.1-rc.2'), true)
42
+ assert.equal(gtSemver('0.1.1-rc.2', '0.1.1'), false)
43
+ })
44
+
45
+ test('semver 比较:spec 官方示例链严格升序', () => {
46
+ const chain = ['1.0.0-alpha', '1.0.0-alpha.1', '1.0.0-alpha.beta', '1.0.0-beta', '1.0.0-beta.2', '1.0.0-beta.11', '1.0.0-rc.1', '1.0.0']
47
+ for (let i = 1; i < chain.length; i++) {
48
+ assert.equal(gtSemver(chain[i], chain[i - 1]), true, chain[i] + ' 应高于 ' + chain[i - 1])
49
+ assert.equal(gtSemver(chain[i - 1], chain[i]), false, chain[i - 1] + ' 不应高于 ' + chain[i])
50
+ }
51
+ })
52
+
53
+ test('semver 比较:build 元数据不参与比较', () => {
54
+ assert.equal(gtSemver('1.0.0+build.1', '1.0.0+build.2'), false)
55
+ assert.equal(gtSemver('1.0.0', '1.0.0+build.2'), false)
56
+ })
57
+
58
+ test('semver 比较:含非法版本返回 false', () => {
59
+ assert.equal(gtSemver('not-a-version', '1.0.0'), false)
60
+ assert.equal(gtSemver('1.0.0', null), false)
61
+ })
62
+
63
+ test('场景:通道落后判定', () => {
64
+ const result = judgeVersion({ currentVersion: CURRENT, tags: { latest: '0.1.2-alpha.3', next: CURRENT }, channel: 'latest' })
65
+ assert.equal(result.verdict, VERDICT_OUTDATED)
66
+ assert.equal(result.channelLatest, '0.1.2-alpha.3')
67
+ assert.equal(result.reason, null)
68
+ })
69
+
70
+ test('场景:通道切换后判定跟随', () => {
71
+ const tags = { latest: '0.1.2-alpha.3', next: CURRENT, alpha: '0.1.2-alpha.3' }
72
+ assert.equal(judgeVersion({ currentVersion: CURRENT, tags, channel: 'alpha' }).verdict, VERDICT_OUTDATED)
73
+ assert.equal(judgeVersion({ currentVersion: CURRENT, tags, channel: 'next' }).verdict, VERDICT_UP_TO_DATE)
74
+ })
75
+
76
+ test('场景:已是最新判定', () => {
77
+ const result = judgeVersion({ currentVersion: CURRENT, tags: { latest: CURRENT }, channel: 'latest' })
78
+ assert.equal(result.verdict, VERDICT_UP_TO_DATE)
79
+ })
80
+
81
+ test('场景:通道不在 dist-tags 判未知', () => {
82
+ const result = judgeVersion({ currentVersion: CURRENT, tags: { latest: CURRENT }, channel: 'beta' })
83
+ assert.equal(result.verdict, VERDICT_UNKNOWN)
84
+ assert.equal(result.channelLatest, null)
85
+ assert.match(result.reason, /beta/)
86
+ })
87
+
88
+ test('场景:当前版本或 tags 缺失判未知', () => {
89
+ assert.equal(judgeVersion({ currentVersion: null, tags: { latest: '1.0.0' }, channel: 'latest' }).verdict, VERDICT_UNKNOWN)
90
+ assert.equal(judgeVersion({ currentVersion: CURRENT, tags: null, channel: 'latest' }).verdict, VERDICT_UNKNOWN)
91
+ })
92
+
93
+ test('场景:版本字符串非法判未知', () => {
94
+ const result = judgeVersion({ currentVersion: 'dev-main', tags: { latest: '1.0.0' }, channel: 'latest' })
95
+ assert.equal(result.verdict, VERDICT_UNKNOWN)
96
+ assert.match(result.reason, /dev-main/)
97
+ })
98
+
99
+ test('场景:升级命令占位符替换', () => {
100
+ const command = buildUpgradeCommand({ template: 'npm install -g @deepseek-ai/dsh@{tag}', tag: 'next' })
101
+ assert.equal(command, 'npm install -g @deepseek-ai/dsh@next')
102
+ })
103
+
104
+ test('场景:升级命令模板可整体自改为无占位符命令', () => {
105
+ const command = buildUpgradeCommand({ template: 'pnpm add -g @deepseek-ai/dsh', tag: 'alpha' })
106
+ assert.equal(command, 'pnpm add -g @deepseek-ai/dsh')
107
+ })
108
+
109
+ test('场景:升级命令占位符多次出现全部替换', () => {
110
+ const command = buildUpgradeCommand({ template: 'echo {tag} {tag}', tag: 'latest' })
111
+ assert.equal(command, 'echo latest latest')
112
+ })
113
+
114
+ test('场景:升级命令模板为空拒绝执行', () => {
115
+ assert.throws(() => buildUpgradeCommand({ template: ' ', tag: 'latest' }), /模板/)
116
+ assert.throws(() => buildUpgradeCommand({ template: null, tag: 'latest' }), /模板/)
117
+ })
118
+
119
+ test('场景:tag 含 shell 元字符拒绝执行(远端数据回流成命令的拦截点)', () => {
120
+ for (const tag of ['latest; rm -rf /', 'x && calc', 'a|b', '$(whoami)', '`id`', 'a b', '']) {
121
+ assert.throws(() => buildUpgradeCommand({ template: 'npm install -g pkg@{tag}', tag }), /非法字符/, tag)
122
+ }
123
+ for (const tag of ['latest', 'next', 'beta-1.2', 'canary_ignored', 'v1.0.0-rc.1']) {
124
+ assert.doesNotThrow(() => buildUpgradeCommand({ template: 'npm install -g pkg@{tag}', tag }), tag)
125
+ }
126
+ })
127
+
128
+ test('场景:tag 白名单形态对齐 npm dist-tag 规则(首尾字母数字,上限 214)', () => {
129
+ assert.equal(isValidChannelName('..'), false)
130
+ assert.equal(isValidChannelName('.a'), false)
131
+ assert.equal(isValidChannelName('a.'), false)
132
+ assert.equal(isValidChannelName('-a'), false)
133
+ assert.equal(isValidChannelName('a'), true)
134
+ assert.equal(isValidChannelName('a' + 'b'.repeat(213)), true, '恰 214 字符放行')
135
+ assert.equal(isValidChannelName('a' + 'b'.repeat(214)), false, '超 214 拒绝')
136
+ })
137
+
138
+ test('场景:registry 基地址合法判定', () => {
139
+ assert.equal(isValidRegistryBase('https://registry.npmmirror.com'), true)
140
+ assert.equal(isValidRegistryBase('http://127.0.0.1:4873'), true)
141
+ assert.equal(isValidRegistryBase(' https://example.com '), true)
142
+ assert.equal(isValidRegistryBase('HTTPS://example.com'), true)
143
+ })
144
+
145
+ test('场景:registry 基地址非法判定', () => {
146
+ assert.equal(isValidRegistryBase('registry.npmmirror.com'), false)
147
+ assert.equal(isValidRegistryBase('ftp://example.com'), false)
148
+ assert.equal(isValidRegistryBase(''), false)
149
+ assert.equal(isValidRegistryBase(' '), false)
150
+ assert.equal(isValidRegistryBase(null), false)
151
+ assert.equal(isValidRegistryBase(undefined), false)
152
+ })
153
+
154
+ test('场景:重启失联后恢复触发刷新', () => {
155
+ assert.equal(shouldReloadAfterRestart({ lost: true, pid: 100, bootAt: 1 }, { lost: false, pid: 100, bootAt: 1 }), true)
156
+ assert.equal(shouldReloadAfterRestart({ lost: true, pid: null, bootAt: null }, { lost: false, pid: null, bootAt: null }), true)
157
+ })
158
+
159
+ test('场景:快速重启零失联凭 pid 变化触发刷新', () => {
160
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: 100, bootAt: 1 }, { lost: false, pid: 200, bootAt: 2 }), true)
161
+ })
162
+
163
+ test('场景:容器 pid 恒 1 零失联凭 bootAt 变化触发刷新', () => {
164
+ // Docker entrypoint 常驻 pid 1 + 停机时长小于轮询间隔:pid 信号结构性失效,
165
+ // bootAt(宿主进程启动时刻)变化是唯一可靠代际信号
166
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: 1, bootAt: 100 }, { lost: false, pid: 1, bootAt: 200 }), true)
167
+ })
168
+
169
+ test('场景:未失联且实例标识未变不刷新', () => {
170
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: 100, bootAt: 1 }, { lost: false, pid: 100, bootAt: 1 }), false)
171
+ // 单侧缺失退化为 pid 比对;pid/bootAt 双双缺失不构成重启证据
172
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: null, bootAt: null }, { lost: false, pid: 200, bootAt: 2 }), false)
173
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: 100, bootAt: 1 }, { lost: false, pid: undefined, bootAt: undefined }), false)
174
+ // bootAt 单侧出现不构成证据(防旧宿主快照缺字段误判)
175
+ assert.equal(shouldReloadAfterRestart({ lost: false, pid: 100, bootAt: null }, { lost: false, pid: 100, bootAt: 200 }), false)
176
+ })
@@ -0,0 +1,220 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { fetchDistTags, resolveHostVersion, hostPackageCandidates, TARGET_PACKAGE } from '../src/core.mjs'
5
+
6
+ const REGISTRY = 'https://registry.npmmirror.com'
7
+
8
+ // fetchDistTags 走 response.body.getReader() 流式读取;mock 按单块提供全部字节
9
+ function streamBody(text) {
10
+ const chunks = [new TextEncoder().encode(text)]
11
+ return {
12
+ getReader: () => ({
13
+ read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true, value: undefined }),
14
+ cancel: async () => {},
15
+ }),
16
+ }
17
+ }
18
+
19
+ function fakeFetch(body, { ok = true, status = 200 } = {}) {
20
+ return async (url, options) => {
21
+ const text = typeof body === 'function' ? body(url) : typeof body === 'string' ? body : JSON.stringify(body)
22
+ return { ok, status, body: streamBody(text) }
23
+ }
24
+ }
25
+
26
+ test('场景:dist-tags 拉取走轻量端点且透传 redirect/signal 选项', async () => {
27
+ let requestedUrl = null
28
+ let capturedOptions = null
29
+ const tags = await fetchDistTags({
30
+ registryBase: REGISTRY,
31
+ fetchImpl: async (url, options) => {
32
+ requestedUrl = url
33
+ capturedOptions = options
34
+ return { ok: true, status: 200, body: streamBody(JSON.stringify({ latest: '0.1.1-rc.2' })) }
35
+ },
36
+ timeoutMs: 1000,
37
+ })
38
+ assert.deepEqual(tags, { latest: '0.1.1-rc.2' })
39
+ assert.equal(requestedUrl, REGISTRY + '/-/package/' + encodeURIComponent(TARGET_PACKAGE) + '/dist-tags')
40
+ // 选项透传断言:redirect:'error' 的实际拒跟行为由平台 fetch 保证,mock 只验证参数到达
41
+ assert.equal(capturedOptions.redirect, 'error')
42
+ assert.ok(capturedOptions.signal instanceof AbortSignal)
43
+ })
44
+
45
+ test('场景:registry 基地址尾部斜杠容忍', async () => {
46
+ const tags = await fetchDistTags({
47
+ registryBase: REGISTRY + '/',
48
+ fetchImpl: fakeFetch({ latest: '1.0.0' }),
49
+ timeoutMs: 1000,
50
+ })
51
+ assert.deepEqual(tags, { latest: '1.0.0' })
52
+ })
53
+
54
+ test('场景:registry 地址非法拒绝', async () => {
55
+ await assert.rejects(
56
+ () => fetchDistTags({ registryBase: 'ftp://x', fetchImpl: fakeFetch({}), timeoutMs: 1000 }),
57
+ /registry/,
58
+ )
59
+ })
60
+
61
+ test('场景:registry 不可达时抛错由调用方保留上次结果', async () => {
62
+ await assert.rejects(
63
+ () => fetchDistTags({ registryBase: REGISTRY, fetchImpl: async () => { throw new Error('ECONNREFUSED') }, timeoutMs: 1000 }),
64
+ /ECONNREFUSED/,
65
+ )
66
+ })
67
+
68
+ test('场景:HTTP 非 2xx 抛错', async () => {
69
+ await assert.rejects(
70
+ () => fetchDistTags({ registryBase: REGISTRY, fetchImpl: fakeFetch({}, { ok: false, status: 502 }), timeoutMs: 1000 }),
71
+ /502/,
72
+ )
73
+ })
74
+
75
+ test('场景:响应不是对象抛错', async () => {
76
+ await assert.rejects(
77
+ () => fetchDistTags({ registryBase: REGISTRY, fetchImpl: fakeFetch('not-json'), timeoutMs: 1000 }),
78
+ /JSON/,
79
+ )
80
+ await assert.rejects(
81
+ () => fetchDistTags({ registryBase: REGISTRY, fetchImpl: fakeFetch([]), timeoutMs: 1000 }),
82
+ /dist-tags/,
83
+ )
84
+ })
85
+
86
+ test('场景:响应体超限时流式读取中途断开', async () => {
87
+ const huge = JSON.stringify({ latest: '1.0.0', pad: 'x'.repeat(80 * 1024) })
88
+ let cancelled = false
89
+ const bytes = new TextEncoder().encode(huge)
90
+ await assert.rejects(
91
+ () => fetchDistTags({
92
+ registryBase: REGISTRY,
93
+ fetchImpl: async () => ({
94
+ ok: true,
95
+ status: 200,
96
+ body: {
97
+ getReader() {
98
+ let sent = 0
99
+ return {
100
+ read: async () => {
101
+ if (sent >= bytes.byteLength) return { done: true, value: undefined }
102
+ const chunk = bytes.subarray(sent, sent + 8 * 1024)
103
+ sent += chunk.byteLength
104
+ return { done: false, value: chunk }
105
+ },
106
+ cancel: async () => {
107
+ cancelled = true
108
+ },
109
+ }
110
+ },
111
+ },
112
+ }),
113
+ timeoutMs: 1000,
114
+ }),
115
+ /超过上限/,
116
+ )
117
+ assert.equal(cancelled, true, '超限后必须 cancel 断开上游,不得读完全量')
118
+ })
119
+
120
+ test('场景:timeoutMs 缺省快速失败不发起请求', async () => {
121
+ let called = false
122
+ await assert.rejects(
123
+ () => fetchDistTags({
124
+ registryBase: REGISTRY,
125
+ fetchImpl: async () => { called = true; return { ok: true, text: async () => '{}' } },
126
+ }),
127
+ /timeoutMs/,
128
+ )
129
+ assert.equal(called, false)
130
+ })
131
+
132
+ test('场景:非字符串值被过滤,空表抛错', async () => {
133
+ await assert.rejects(
134
+ () => fetchDistTags({ registryBase: REGISTRY, fetchImpl: fakeFetch({ latest: 3 }), timeoutMs: 1000 }),
135
+ /dist-tags/,
136
+ )
137
+ })
138
+
139
+ test('场景:宿主版本定位 win32 全局布局', async () => {
140
+ const files = {
141
+ 'C:\\nvm\\v24.14.1\\node_modules\\@deepseek-ai\\dsh\\package.json': JSON.stringify({ version: '0.1.1-rc.2' }),
142
+ }
143
+ const version = await resolveHostVersion({
144
+ execPath: 'C:\\nvm\\v24.14.1\\node.exe',
145
+ platform: 'win32',
146
+ readFileImpl: async (path) => {
147
+ if (!Object.prototype.hasOwnProperty.call(files, path)) throw new Error('ENOENT')
148
+ return files[path]
149
+ },
150
+ resolveImpl: () => { throw new Error('MODULE_NOT_FOUND') },
151
+ })
152
+ assert.equal(version, '0.1.1-rc.2')
153
+ })
154
+
155
+ test('场景:宿主版本定位 posix 全局布局', async () => {
156
+ const files = {
157
+ '/home/u/.nvm/versions/node/v24/lib/node_modules/@deepseek-ai/dsh/package.json': JSON.stringify({ version: '1.2.3' }),
158
+ }
159
+ const version = await resolveHostVersion({
160
+ execPath: '/home/u/.nvm/versions/node/v24/bin/node',
161
+ platform: 'linux',
162
+ readFileImpl: async (path) => {
163
+ if (!Object.prototype.hasOwnProperty.call(files, path)) throw new Error('ENOENT')
164
+ return files[path]
165
+ },
166
+ resolveImpl: () => { throw new Error('MODULE_NOT_FOUND') },
167
+ })
168
+ assert.equal(version, '1.2.3')
169
+ })
170
+
171
+ test('场景:候选布局失败时走 require 解析兜底', async () => {
172
+ const version = await resolveHostVersion({
173
+ execPath: '/usr/bin/node',
174
+ platform: 'linux',
175
+ readFileImpl: async (path) => {
176
+ if (path === '/opt/dsh/package.json') return JSON.stringify({ version: '2.0.0' })
177
+ throw new Error('ENOENT')
178
+ },
179
+ resolveImpl: () => '/opt/dsh/package.json',
180
+ })
181
+ assert.equal(version, '2.0.0')
182
+ })
183
+
184
+ test('场景:全部途径失败返回 null 不抛错', async () => {
185
+ const version = await resolveHostVersion({
186
+ execPath: '/usr/bin/node',
187
+ platform: 'linux',
188
+ readFileImpl: async () => { throw new Error('ENOENT') },
189
+ resolveImpl: () => { throw new Error('MODULE_NOT_FOUND') },
190
+ })
191
+ assert.equal(version, null)
192
+ })
193
+
194
+ test('场景:候选文件可读但 version 非法时静默换下一候选', async () => {
195
+ const files = {
196
+ '/prefix/lib/node_modules/@deepseek-ai/dsh/package.json': JSON.stringify({ name: 'x' }),
197
+ '/opt/dsh/package.json': JSON.stringify({ version: '2.0.0' }),
198
+ }
199
+ const version = await resolveHostVersion({
200
+ execPath: '/prefix/bin/node',
201
+ platform: 'linux',
202
+ readFileImpl: async (path) => {
203
+ if (!Object.prototype.hasOwnProperty.call(files, path)) throw new Error('ENOENT')
204
+ return files[path]
205
+ },
206
+ resolveImpl: () => '/opt/dsh/package.json',
207
+ })
208
+ assert.equal(version, '2.0.0')
209
+ })
210
+
211
+ test('候选路径推导:win32 与 posix 布局', () => {
212
+ assert.deepEqual(
213
+ hostPackageCandidates({ execPath: 'C:\\nvm\\v24\\node.exe', platform: 'win32' }),
214
+ ['C:\\nvm\\v24\\node_modules\\@deepseek-ai\\dsh\\package.json'],
215
+ )
216
+ assert.deepEqual(
217
+ hostPackageCandidates({ execPath: '/prefix/bin/node', platform: 'linux' }),
218
+ ['/prefix/lib/node_modules/@deepseek-ai/dsh/package.json'],
219
+ )
220
+ })
@@ -0,0 +1,21 @@
1
+ // 测试内部共享的 LOGIC 段提取器:从 client.js 源码提取
2
+ // // LOGIC-BEGIN <name> ... // LOGIC-END <name> 标记段并工厂化,
3
+ // deps 为段内自由变量。单份实现防多份提取器漂移(此前 restart-ready 与 parity 各持一份)。
4
+ import { readFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const CLIENT_SOURCE = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
10
+
11
+ export function clientSource() {
12
+ return CLIENT_SOURCE
13
+ }
14
+
15
+ export function extractLogic(name, deps = {}) {
16
+ const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
17
+ const match = CLIENT_SOURCE.match(pattern)
18
+ if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
19
+ const keys = Object.keys(deps)
20
+ return new Function(...keys, 'return (' + match[1].trim() + ')')(...keys.map((key) => deps[key]))
21
+ }
@@ -0,0 +1,149 @@
1
+ // parity 测试:client.js 的 LOGIC 标记段与 core.mjs 同源函数对拍,
2
+ // 数据镜像常量(默认值/VERDICT/API 路径/超时窗口)与 host 侧组合值对照。
3
+ // client 半区无法 import ESM,按 LOGIC 标记提取源码文本后工厂化执行。
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { dirname, join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import {
11
+ shouldReloadAfterRestart as coreShouldReload,
12
+ isValidRegistryBase as coreIsValidRegistryBase,
13
+ VERDICT_OUTDATED,
14
+ VERDICT_UP_TO_DATE,
15
+ VERDICT_UNKNOWN,
16
+ TARGET_PACKAGE,
17
+ } from '../src/core.mjs'
18
+ import { clientSource, extractLogic } from './logic-extract.mjs'
19
+
20
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
21
+ const CLIENT_SOURCE = clientSource()
22
+
23
+ function extractConst(name) {
24
+ // 行首锚定 + 全局扫描唯一性:防注释/示例代码中的同名字样静默错抓
25
+ const pattern = new RegExp('^const ' + name + " = '([^']*)'", 'm')
26
+ const all = CLIENT_SOURCE.match(new RegExp(pattern.source, 'gm'))
27
+ assert.ok(all && all.length >= 1, 'client.js 缺少常量: ' + name)
28
+ assert.equal(all.length, 1, 'client.js 常量声明不唯一: ' + name)
29
+ return all[0].match(pattern)[1]
30
+ }
31
+
32
+ function extractNumberConst(name) {
33
+ const pattern = new RegExp('^const ' + name + ' = ([0-9 *]+)$', 'm')
34
+ const all = CLIENT_SOURCE.match(new RegExp(pattern.source, 'gm'))
35
+ assert.ok(all && all.length >= 1, 'client.js 缺少常量: ' + name)
36
+ assert.equal(all.length, 1, 'client.js 常量声明不唯一: ' + name)
37
+ return eval(all[0].match(pattern)[1])
38
+ }
39
+
40
+ const clientShouldReload = extractLogic('shouldReloadAfterRestart')
41
+ const clientIsValidRegistryBase = extractLogic('isValidRegistryBase')
42
+
43
+ // prev/next 为 {lost,pid,bootAt} 快照;lost 强信号优先;bootAt 双侧齐备时以其为唯一
44
+ // 实例证据(自洽数据:不同进程 bootAt 必不同),pid 比对是 bootAt 缺失时的退化路径
45
+ const RELOAD_CASES = [
46
+ { prev: { lost: true, pid: 1, bootAt: 1 }, next: { lost: false, pid: 1, bootAt: 1 }, expected: true },
47
+ { prev: { lost: false, pid: 7, bootAt: 100 }, next: { lost: false, pid: 8, bootAt: 200 }, expected: true },
48
+ { prev: { lost: false, pid: 7, bootAt: 100 }, next: { lost: false, pid: 7, bootAt: 100 }, expected: false },
49
+ { prev: { lost: false, pid: 1, bootAt: 100 }, next: { lost: false, pid: 1, bootAt: 200 }, expected: true, note: '容器 pid 恒 1 靠 bootAt' },
50
+ { prev: { lost: false, pid: 7, bootAt: null }, next: { lost: false, pid: 8, bootAt: 200 }, expected: true, note: 'bootAt 缺失退化 pid 比对' },
51
+ { prev: { lost: false, pid: 7, bootAt: null }, next: { lost: false, pid: 7, bootAt: 200 }, expected: false, note: 'bootAt 单侧缺失且 pid 未变' },
52
+ { prev: { lost: false, pid: null, bootAt: null }, next: { lost: false, pid: 8, bootAt: 200 }, expected: false },
53
+ { prev: { lost: false, pid: 7, bootAt: 100 }, next: { lost: false, pid: null, bootAt: null }, expected: false },
54
+ { prev: { lost: false, pid: undefined, bootAt: undefined }, next: { lost: false, pid: undefined, bootAt: undefined }, expected: false },
55
+ { prev: { lost: true, pid: null, bootAt: null }, next: { lost: false, pid: null, bootAt: null }, expected: true },
56
+ ]
57
+
58
+ const REGISTRY_CASES = [
59
+ { value: 'https://registry.npmjs.org', expected: true },
60
+ { value: 'http://localhost:4873', expected: true },
61
+ { value: 'HTTPS://MIRROR.EXAMPLE', expected: true },
62
+ { value: ' https://padded.example ', expected: true },
63
+ { value: 'ftp://registry.example', expected: false },
64
+ { value: 'registry.npmjs.org', expected: false },
65
+ { value: '', expected: false },
66
+ { value: null, expected: false },
67
+ { value: undefined, expected: false },
68
+ { value: 123, expected: false },
69
+ // query/hash 会在拼接 dist-tags 路径时吞掉 API 路径,收紧后一律拒绝
70
+ { value: 'https://example.com?mirror=1', expected: false },
71
+ { value: 'https://example.com#frag', expected: false },
72
+ ]
73
+
74
+ test('parity: shouldReloadAfterRestart 双实现全场景一致', () => {
75
+ for (const { prev, next, expected, note } of RELOAD_CASES) {
76
+ assert.equal(coreShouldReload(prev, next), expected, 'core ' + JSON.stringify({ prev, next }) + (note ? ' ' + note : ''))
77
+ assert.equal(clientShouldReload(prev, next), expected, 'client ' + JSON.stringify({ prev, next }) + (note ? ' ' + note : ''))
78
+ }
79
+ })
80
+
81
+ test('parity: isValidRegistryBase 双实现全场景一致', () => {
82
+ for (const { value, expected } of REGISTRY_CASES) {
83
+ assert.equal(coreIsValidRegistryBase(value), expected, 'core ' + JSON.stringify(value))
84
+ assert.equal(clientIsValidRegistryBase(value), expected, 'client ' + JSON.stringify(value))
85
+ }
86
+ })
87
+
88
+ test('parity: VERDICT 三常量 client 与 core 一致', () => {
89
+ assert.equal(extractConst('VERDICT_OUTDATED'), VERDICT_OUTDATED)
90
+ assert.equal(extractConst('VERDICT_UP_TO_DATE'), VERDICT_UP_TO_DATE)
91
+ assert.equal(extractConst('VERDICT_UNKNOWN'), VERDICT_UNKNOWN)
92
+ })
93
+
94
+ // host 侧锚点直接 import index.js 实现,防测试内手抄字面量漂移假绿
95
+ import { DEFAULT_UPGRADE_TEMPLATE, DEFAULT_POLL_INTERVAL_SEC, DEFAULT_REGISTRY_BASE, TICK_MS, API_PATHS, UPGRADE_TIMEOUT_MS } from '../src/index.js'
96
+
97
+ test('parity: 默认升级命令模板 client 字面量与 host 实现一致', () => {
98
+ assert.equal(extractConst('DEFAULT_UPGRADE_TEMPLATE'), DEFAULT_UPGRADE_TEMPLATE)
99
+ })
100
+
101
+ test('parity: 默认轮询间隔与镜像地址 client 与 host 实现一致', () => {
102
+ assert.equal(extractNumberConst('DEFAULT_POLL_INTERVAL_SEC'), DEFAULT_POLL_INTERVAL_SEC)
103
+ assert.equal(extractConst('DEFAULT_REGISTRY_BASE'), DEFAULT_REGISTRY_BASE)
104
+ })
105
+
106
+ test('parity: client 轮询粒度提示与 host TICK_MS 换算一致', () => {
107
+ assert.equal(extractNumberConst('POLL_MIN_TICK_SECONDS'), TICK_MS / 1000)
108
+ })
109
+
110
+ test('parity: client API 路径常量与 host 路由清单逐条一致', () => {
111
+ // client 侧常量名带 _URL 后缀,host 侧 API_PATHS 键名即语义段
112
+ const CLIENT_KEY_BY_HOST_KEY = {
113
+ STATUS: 'STATUS_URL',
114
+ REFRESH: 'REFRESH_URL',
115
+ CHANNEL: 'CHANNEL_URL',
116
+ UPGRADE_TEMPLATE: 'TEMPLATE_URL',
117
+ POLL_INTERVAL: 'POLL_INTERVAL_URL',
118
+ REGISTRY_BASE: 'REGISTRY_BASE_URL',
119
+ UPGRADE: 'UPGRADE_URL',
120
+ RESTART: 'RESTART_URL',
121
+ }
122
+ for (const [hostKey, hostPath] of Object.entries(API_PATHS)) {
123
+ assert.equal(extractConst(CLIENT_KEY_BY_HOST_KEY[hostKey]), hostPath, 'API 路径漂移: ' + hostKey)
124
+ }
125
+ })
126
+
127
+ // 窗口关系对拍:重启等待总时长必须大于宿主退出延迟,否则宿主还在延迟退出窗口内客户端已报超时
128
+ import { RESTART_DELAY_MS } from '../src/index.js'
129
+
130
+ test('parity: 重启等待总时长大于宿主退出延迟', () => {
131
+ const restartTimeoutMs = extractNumberConst('RESTART_TIMEOUT_MS')
132
+ assert.ok(restartTimeoutMs > RESTART_DELAY_MS, 'RESTART_TIMEOUT_MS 必须大于 RESTART_DELAY_MS')
133
+ })
134
+
135
+ test('parity: 升级观察上限不早于宿主升级超时(防抢跑转状态未知)', () => {
136
+ const watchMaxMs = extractNumberConst('UPGRADE_WATCH_MAX_MS')
137
+ assert.ok(watchMaxMs >= UPGRADE_TIMEOUT_MS, 'UPGRADE_WATCH_MAX_MS(' + watchMaxMs + ') 必须不小于宿主 UPGRADE_TIMEOUT_MS(' + UPGRADE_TIMEOUT_MS + ')')
138
+ })
139
+
140
+ test('parity: npm 版本页链接与追踪包名同源', () => {
141
+ assert.ok(extractConst('NPM_VERSIONS_URL').includes(TARGET_PACKAGE), 'NPM_VERSIONS_URL 应包含 TARGET_PACKAGE 字面量')
142
+ })
143
+
144
+ test('源码契约: 重启轮询与升级观察器不得回退 setInterval 重叠拍形态', () => {
145
+ // 顺序循环 + 代际令牌是 R4 修复形态;setInterval 回归即重叠拍竞态回归
146
+ assert.ok(!/setInterval\(/.test(CLIENT_SOURCE), 'client.js 禁止 setInterval(拍自调度取代)')
147
+ assert.ok(CLIENT_SOURCE.includes('AbortSignal.timeout(UPGRADE_POLL_TIMEOUT_MS)'), '升级观察拍必须带请求超时')
148
+ assert.ok(CLIENT_SOURCE.includes('upgradeWatch.generation !== generation'), '升级观察拍 settle 后必须验代际')
149
+ })