Files
dsh_shenxian/test/shared-layer-sync.test.mjs
T

1045 lines
50 KiB
JavaScript
Raw Normal View History

// 跨节点内容分发 · 回归测试(本线 §5-2 / §5-8)
//
// 形态(2026-09-24 09:0x 用户拍板):「**最好用拉取,主节点核对告知差异**」
// ⇒ 节点主动拨出去问 ⇒ Manager 核对后把差异告知回来 ⇒ 节点按差异拉产物。
//
// 本文件钉住的判据(每一条都对应一处会**静默出错**的地方):
// ① 两侧的 `hashTree` **必须算出同一个 hex** —— 这是全部完整性判据的地基。
// Manager 侧在 `src/web/routes/business-plugins.ts`、节点侧在 `src/worker/agent.ts`,
// 因分层 R1 物理上不能共用一份实现 ⇒ 靠本文件的第 1 组用例钉住漂移。
// ② 指纹**不符**或传输**损坏** ⇒ ⛔ **不换掉现役目录**(最坏结果只能是"没更新")。
// ⛔ 绝不能出现"半包":那会让该机上所有引用该插件的实例起不来。
// ③ 通信失败 ⇒ ⛔ **一个本地文件都不许动**(不许退化成"那就当没有差异")。
// ④ 节点上**多出来**的目录 ⇒ **只上报、⛔ 平台绝不删**(删错不可逆)。
// ⑤ 对账判据里「节点给不出指纹」**必须**算 `stale`(⇒ 重拉自愈),⛔ 不算"一致"。
//
// 跑法:`npm test`(先 build 再跑 `lib/`)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative } from 'node:path'
import { effectiveDownloadTimeout, hashTreeAt, syncSharedLayerOnce } from '../lib/worker/agent.js'
import {
archiveLiveVersion,
availableVersionsOf,
hashTree,
historyEntryOf,
isSafeVersion,
resolveSharedTarget,
sharedLayerDiff,
sharedStatusMatrix,
packageVersionAt,
packSharedTree,
reconcileSharedManifest,
rollbackAssetPath,
versionsDirOf,
} from '../lib/web/routes/business-plugins.js'
const FLAT = '_dsh-local_probe'
const SHA_A = 'a'.repeat(64)
const tmp = () => mkdtempSync(join(tmpdir(), 'dshs-shared-sync-'))
/** 造一份"已解包的产物树"(内容刻意含子目录与多个文件,覆盖 walk 的分支)。 */
function makeTree(root) {
mkdirSync(join(root, 'dist'), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: '@dsh-local/probe', version: '1.0.0' }))
writeFileSync(join(root, 'dist', 'index.js'), 'export const a = 1\n')
writeFileSync(join(root, 'README.md'), '# probe\n')
return root
}
/** 用**真 tar** 打一份 `flat/…` 的产物包(与 Manager 侧 `spawn('tar', […-C root flat])` 同形)。 */
function packDir(parent, flat) {
const tgz = join(parent, 'pack.tgz')
// ⚠️ 只给 tar **相对**参数 + `cwd`:本机(Windows/MSYS)下把 `E:/…` 这类盘符路径
// 当 argv 传给 tar 有已知坑,绕开它(被 `-C` 的 cwd 语义覆盖)。
execFileSync('tar', ['-czf', 'pack.tgz', flat], { cwd: parent, stdio: 'pipe' })
return readFileSync(tgz)
}
/**
* 假 Manager:只实现节点用到的三个口,逻辑尽量贴真路由(差异判据直接复用真函数)。
*
* `versions[flat][ver]` = **历史版本归档**的字节(B-1)—— 真路由的判据是
* 「带了 `?version=` ⇒ 按它给;解析不到就 404 `not_here`(⛔ 不悄悄回落现役)」,
* 这里照抄同一条(⛔ 别让假实现比真实现"更宽容":那会把真实现里的坑藏起来)。
*/
function fakeManager({ entries, packs, versions = {}, liveVersions = {}, syncStatus = 200 }) {
const calls = []
const fetchImpl = async (url, init = {}) => {
const u = String(url)
calls.push({ url: u, init })
if (u.endsWith('/api/plugins/shared/node/sync')) {
if (syncStatus !== 200) return new Response('nope', { status: syncStatus })
const sent = JSON.parse(init.body)
const local = new Map(sent.inventory.map((i) => [i.flat, i.treeSha256]))
const diff = sharedLayerDiff(entries, local)
return Response.json({
ok: true,
entries,
diff,
pull: [...diff.missing, ...diff.stale],
})
}
if (u.includes('/api/plugins/shared/node/pack/')) {
const parsed = new URL(u)
const flat = decodeURIComponent(parsed.pathname.split('/pack/')[1])
const want = parsed.searchParams.get('version')
// 真路由的判据:`?version=` == **现役**版本 ⇒ 现打现役目录;否则只能从归档里拿,拿不到 404。
const liveVer = liveVersions[flat] ?? entries.find((e) => e.flat === flat)?.version
const body = want !== null && want !== liveVer ? versions[flat]?.[want] : packs[flat]
if (body === undefined) return new Response('x', { status: 404 })
return new Response(body, { status: 200, headers: { 'content-type': 'application/gzip' } })
}
if (u.endsWith('/api/plugins/shared/node/result')) return Response.json({ ok: true })
return new Response('x', { status: 404 })
}
return { fetchImpl, calls }
}
/**
* 一个**尊重 abort 信号**的慢响应(真 `fetch` 就是这么表现的)。
*
* ⚠️ 不能只写 `await sleep(300)`:那样 `AbortController.abort()` 对它毫无作用
* ⇒ "downloadTimeoutMs 生效了吗"这条用例会**假绿**(测出来永远成功)。
*/
async function slowWithSignal(ms, init, makeResponse) {
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms)
const sig = init?.signal
if (sig === undefined || sig === null) return
const onAbort = () => {
clearTimeout(t)
reject(sig.reason instanceof Error ? sig.reason : new Error('aborted'))
}
if (sig.aborted) onAbort()
else sig.addEventListener('abort', onAbort, { once: true })
})
return makeResponse()
}
// ── ① 两侧同算法(漂移钉)────────────────────────────────────────────────
test('跨实现一致性: worker 的 hashTreeAt 与 Manager 的 hashTree 必须逐字相等', () => {
const d = tmp()
try {
makeTree(join(d, FLAT))
const a = hashTree(join(d, FLAT))
const b = hashTreeAt(join(d, FLAT))
assert.equal(b.hash, a.hash, '两侧算法漂移了 —— 节点会把完好产物判成损坏并无限重拉')
assert.equal(b.fileCount, a.fileCount)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('hashTreeAt: 内容是判据 —— 改一个字节 / 改相对路径 / 增删文件都要变', () => {
const d = tmp()
try {
makeTree(join(d, FLAT))
const base = hashTreeAt(join(d, FLAT)).hash
assert.equal(hashTreeAt(join(d, FLAT)).hash, base, '同一棵树两次必须一样(顺序无关)')
writeFileSync(join(d, FLAT, 'dist', 'index.js'), 'export const a = 2\n')
const changed = hashTreeAt(join(d, FLAT)).hash
assert.notEqual(changed, base, '内容变了指纹必须变')
writeFileSync(join(d, FLAT, 'extra.txt'), 'x')
assert.notEqual(hashTreeAt(join(d, FLAT)).hash, changed, '多一个文件指纹必须变')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
// ── ⑤ 对账判据(真函数 `sharedLayerDiff`)──────────────────────────────────
test('对账判据: 缺的 / 指纹不同的 / 给不出指纹的 / 多出来的,四类各归各位', () => {
const auth = [
{ flat: 'have-same', treeSha256: SHA_A },
{ flat: 'have-diff', treeSha256: SHA_A },
{ flat: 'have-unknown', treeSha256: SHA_A },
{ flat: 'absent-on-node', treeSha256: SHA_A },
]
const local = new Map([
['have-same', SHA_A],
['have-diff', 'b'.repeat(64)],
['have-unknown', null], // 节点有目录但算不出指纹 = "不知道"
['node-only', SHA_A],
])
const { missing, stale, extra } = sharedLayerDiff(auth, local)
assert.deepEqual(missing, ['absent-on-node'])
assert.deepEqual(stale.sort(), ['have-diff', 'have-unknown'])
assert.deepEqual(extra, ['node-only'])
// 🔴 「给不出指纹」必须落在 stale(⇒ 重拉自愈),⛔ 绝不可当"一致"
assert.ok(!stale.includes('have-same') && !missing.includes('have-unknown'))
})
test('对账判据: `extra` 的判据是"权威清单里没有",与指纹无关(⛔ 不看版本号)', () => {
const { extra } = sharedLayerDiff([{ flat: 'a', treeSha256: SHA_A }], new Map([['b', null], ['a', SHA_A]]))
assert.deepEqual(extra, ['b'], '节点上报 null 指纹不影响它"多出来"这个事实')
})
// ── ②③④ 端到端一轮拉取(真 tar / 真文件系统 / 假 Manager)──────────────────
test('正常轮: 本机记录与权威一致 ⇒ 什么都不拉(⛔ 不做无谓重传)', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
const before = hashTreeAt(join(root, FLAT)).hash
// 预置"我上次拉下来的指纹" ⇒ 这才是"没有差异"的前提
writeFileSync(
join(root, '.node-pull.json'),
JSON.stringify({ entries: { [FLAT]: { treeSha256: before, pulledAt: 1 } }, lastSyncAt: 1 }),
)
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: before, fileCount: 3 }]
const m = fakeManager({ entries, packs: {} })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test/', // 尾斜杠要能被吃掉
hostId: 'w-1',
token: 'tok',
sharedRoot: root,
fetchImpl: m.fetchImpl,
})
assert.equal(out.ok, true, out.reason ?? '')
assert.deepEqual(out.pulled, [])
assert.deepEqual(out.missing, [])
assert.deepEqual(out.stale, [])
// ⛔ 没差异就不该去取产物(只该有一次对账请求 + 无产物请求)
assert.equal(m.calls.filter((c) => c.url.includes('/pack/')).length, 0)
assert.equal(hashTreeAt(join(root, FLAT)).hash, before, '正常轮⛔ 不该动产物')
// 凭据头必须带上(节点身份 = 这台机)
assert.equal(m.calls[0].init.headers['x-dsh-agent-token'], 'tok')
assert.equal(m.calls[0].init.headers['x-dsh-host-id'], 'w-1')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('自愈: 首次没有拉取记录 ⇒ 指纹报 `null`("不知道")⇒ 被判 stale 并重拉一次', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const want = hashTreeAt(src).hash
makeTree(join(root, FLAT)) // 本机也有这个目录(内容相同),但**没有**拉取记录
// 事后要清掉:`packDir` 把 tgz 写进了自己的 parent,与共享根无关
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test',
hostId: 'w-1',
token: 'tok',
sharedRoot: root,
fetchImpl: m.fetchImpl,
})
// 🔴 关键:**"不知道"必须导致重拉**,⛔ 绝不能被当成"一致"("把未知当已知"的老病)
assert.equal(JSON.parse(m.calls[0].init.body).inventory[0].treeSha256, null)
assert.deepEqual(out.stale, [FLAT])
assert.deepEqual(out.pulled, [FLAT])
assert.equal(out.ok, true, out.reason ?? '')
// 记录已落 ⇒ 下一轮就不必再拉
const state = JSON.parse(readFileSync(join(root, '.node-pull.json'), 'utf8'))
assert.equal(state.entries[FLAT].treeSha256, want)
assert.equal(hashTreeAt(join(root, FLAT)).hash, want)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('正常轮: 缺包 ⇒ 拉下来换上,内容与权威逐字一致,并记下指纹(第二轮无差异)', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const want = hashTreeAt(src).hash
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const opts = { baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl }
const out = await syncSharedLayerOnce(opts)
assert.equal(out.ok, true, out.reason ?? '')
assert.deepEqual(out.pulled, [FLAT])
assert.equal(hashTreeAt(join(root, FLAT)).hash, want, '换上去的内容必须与权威指纹一致')
assert.equal(readFileSync(join(root, FLAT, 'README.md'), 'utf8'), '# probe\n')
// stage 必须清净(⛔ 不留 `.pull-stage-*` 残渣)
const { readdirSync } = await import('node:fs')
assert.ok(!readdirSync(root).some((n) => n.startsWith('.pull-stage-')), 'stage 目录必须清干净')
// 第二轮:本机已记录指纹 ⇒ 与权威一致 ⇒ 无差异(证明第二轮不会白拉)
const m2 = fakeManager({ entries, packs: { [FLAT]: tgz } })
const out2 = await syncSharedLayerOnce({ ...opts, fetchImpl: m2.fetchImpl })
assert.equal(out2.ok, true, out2.reason ?? '')
assert.deepEqual(out2.pulled, [])
assert.deepEqual(out2.missing, [])
assert.deepEqual(out2.stale, [])
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('🔴 反证(最要紧): 指纹不符 ⇒ 报失败,且**现役目录一个字都不动**', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
writeFileSync(join(root, FLAT, 'MARKER.txt'), 'OLD\n')
const oldHash = hashTreeAt(join(root, FLAT)).hash
// 权威宣称的是"另一个内容" ⇒ 解包后必然对不上
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: 'c'.repeat(64), fileCount: 3 }]
void src
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.ok, false)
assert.deepEqual(out.pulled, [])
assert.match(out.failed[0].error, /tree_sha256_mismatch/)
assert.equal(hashTreeAt(join(root, FLAT)).hash, oldHash, '校验不过⛔ 绝不能换掉现役目录(半包会让实例起不来)')
assert.equal(readFileSync(join(root, FLAT, 'MARKER.txt'), 'utf8'), 'OLD\n')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('反证: 传输不是 gzip(截断/错误页)⇒ 报失败且不动本地', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
const before = hashTreeAt(join(root, FLAT)).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
const m = fakeManager({ entries, packs: { [FLAT]: Buffer.from('<html>500</html>') } })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.ok, false)
assert.equal(out.failed[0].error, 'pack_not_gzip')
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('反证: 产物口 404 ⇒ 报 `not_here`(⛔ 不是"包是空的"),不动本地', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
const before = hashTreeAt(join(root, FLAT)).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
const m = fakeManager({ entries, packs: {} })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.failed[0].error, 'not_here')
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('🔴 反证: 对账口通信失败 ⇒ 一个本地文件都不许动(⛔ 不许退化成"当没有差异")', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
const before = hashTreeAt(join(root, FLAT)).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
const m500 = fakeManager({ entries, packs: {}, syncStatus: 500 })
const out500 = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m500.fetchImpl,
})
assert.equal(out500.ok, false)
assert.equal(out500.reason, 'sync_http_500')
assert.deepEqual(out500.pulled, [])
const boom = async () => {
throw new Error('ECONNREFUSED')
}
const outNet = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: boom,
})
assert.equal(outNet.ok, false)
assert.match(outNet.reason, /sync_unreachable/)
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
// ⛔ 连产物口都不该被请求(没有清单就没有要拉的东西)
assert.ok(!existsSync(join(root, '.node-pull.json')), '通信失败时⛔ 连记录都别写')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('🔴 反证: 归档里的成员越界(绝对路径 / `../`)⇒ 拒收,且**没写到 stage 之外**', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
// 造一个"恶意"包:成员名字面是 `../escaped.txt`(tar 能装进去)
const evil = join(d, 'evil')
mkdirSync(evil, { recursive: true })
writeFileSync(join(evil, 'escaped.txt'), 'PWNED\n')
execFileSync('tar', ['-czf', 'evil.tgz', '--transform', 's|escaped.txt|../escaped.txt|', 'escaped.txt'], {
cwd: evil,
stdio: 'pipe',
})
const evilTgz = readFileSync(join(evil, 'evil.tgz'))
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 1 }]
const m = fakeManager({ entries, packs: { [FLAT]: evilTgz } })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.ok, false)
assert.equal(out.failed[0].error, 'pack_member_out_of_scope')
assert.ok(!existsSync(join(root, FLAT)), '越界包⛔ 不许落盘')
assert.ok(!existsSync(join(root, 'escaped.txt')), '越界内容⛔ 不许逃逸出 stage')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('🔴 反证: 节点上"多出来"的目录 ⇒ 只上报,⛔ 平台绝不删', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(join(root, 'node-own-dir'), { recursive: true })
writeFileSync(join(root, 'node-own-dir', 'keep.txt'), 'DO NOT DELETE\n')
const entries = [] // 权威清单为空 ⇒ 节点上那个目录就是 `extra`
const m = fakeManager({ entries, packs: {} })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.deepEqual(out.extra, ['node-own-dir'])
assert.ok(existsSync(join(root, 'node-own-dir', 'keep.txt')), 'extra 只告知,⛔ 删错不可逆')
assert.equal(readFileSync(join(root, 'node-own-dir', 'keep.txt'), 'utf8'), 'DO NOT DELETE\n')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('失败项会回报给 Manager(对账面用),⛔ 但不影响"本轮不换盘"的结论', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
makeTree(join(root, FLAT))
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
const m = fakeManager({ entries, packs: {} }) // 产物口 404 ⇒ 必失败
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.failed.length, 1)
const reported = m.calls.find((c) => c.url.endsWith('/node/result'))
assert.ok(reported !== undefined, '失败项必须回报一次(否则对账面上看不到"拉失败")')
assert.deepEqual(JSON.parse(reported.init.body).failed, out.failed)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('未配置(共享根不存在)⇒ 直接返回原因,⛔ 不发任何请求', async () => {
let hit = 0
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test',
hostId: 'w-1',
token: 'tok',
sharedRoot: join(tmpdir(), 'dshs-definitely-not-here-' + Date.now()),
fetchImpl: async () => {
hit += 1
return new Response('{}')
},
})
assert.equal(out.ok, false)
assert.equal(out.reason, 'shared_root_missing')
assert.equal(hit, 0)
})
test('旁挂文件/目录不参与对账(`.manifest.json` / `.store` / 本记录自身)', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(join(root, '.store'), { recursive: true })
writeFileSync(join(root, '.manifest.json'), '{}')
makeTree(join(root, FLAT))
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: hashTreeAt(join(root, FLAT)).hash, fileCount: 3 }]
const m = fakeManager({ entries, packs: {} })
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
const sent = JSON.parse(m.calls[0].init.body)
assert.deepEqual(sent.inventory.map((i) => i.flat), [FLAT], '⛔ 别把 .store / .manifest.json 报成"节点有"')
assert.deepEqual(out.extra, [])
} finally {
rmSync(d, { recursive: true, force: true })
}
})
// ── ③ 台账两条语义缺陷(第 21 棒 · 本线 §5-10 ㈠㈡)────────────────────────────
//
// 病根同源:**该写进台账的值,此前来自"别处的副本",而不是盘上那份实体**。
// ㈠ `version` 取自候选池行 ⇒ 库与实体一漂移,清单就在描述"另一份东西";
// ㈡ "回滚素材"落的是新包自拷贝 ⇒ 名字与内容都新,**回不到上一版**。
// ⇒ 本组单测钉住一条口径:**真值一律从盘上现读**(⛔ 不做"看起来对"的近似)。
test('台账口径(㈠): `version` 取**盘上实体现读**,⛔ 不信上游传来的池行字段', () => {
const d = tmp()
try {
const pkg = join(d, 'tree')
makeTree(pkg) // 包内 package.json 写的是 1.0.0
assert.equal(packageVersionAt(pkg), '1.0.0', '真值 = 包内 package.json 的 version')
// 最浅优先(与 findPackageJson 同口径)⇒ 嵌套依赖里的版本不算数
mkdirSync(join(pkg, 'node_modules', 'dep'), { recursive: true })
writeFileSync(join(pkg, 'node_modules', 'dep', 'package.json'), JSON.stringify({ name: 'dep', version: '0.0.1' }))
assert.equal(packageVersionAt(pkg), '1.0.0')
// 读不到 ⇒ null:⛔ 不编造、也⛔ 不向上抛(调用点在装配路径上)
assert.equal(packageVersionAt(join(d, 'definitely-not-here')), null)
const bare = join(d, 'bare')
mkdirSync(bare, { recursive: true })
writeFileSync(join(bare, 'package.json'), JSON.stringify({ name: 'y' }))
assert.equal(packageVersionAt(bare), null, '没有 version 字段 ⇒ null')
writeFileSync(join(bare, 'package.json'), '{ 不是 json')
assert.equal(packageVersionAt(bare), null, '解析不了 ⇒ null(⛔ 不抛)')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('台账自愈(㈠): 清单与磁盘脱钩 ⇒ 以盘上真值回写三件(version / treeSha256 / fileCount)', () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
makeTree(join(root, FLAT))
const real = hashTree(join(root, FLAT))
const stale = {
[FLAT]: {
id: '@dsh-local/probe',
version: '0.3.9', // ← 与盘上 1.0.0 脱钩(㈠ 的真实现象)
tgzSha256: SHA_A,
treeSha256: 'b'.repeat(64),
fileCount: 999,
backupTgz: '/b/probe.tgz',
rollbackTgz: '/b/probe-prev.tgz',
installedAt: 1,
updatedAt: 1,
},
}
const { manifest, report } = reconcileSharedManifest(root, stale)
assert.equal(report.checked, 1)
assert.equal(report.fixed.length, 1, '脱钩必须被认出来')
assert.deepEqual(report.fixed[0].version, ['0.3.9', '1.0.0'], 'version 取盘上真值')
assert.deepEqual(report.fixed[0].treeSha256, ['b'.repeat(64), real.hash], 'treeSha256 取现算值')
assert.deepEqual(report.fixed[0].fileCount, [999, real.fileCount])
// ⛔ 只动"描述盘上那份东西"的三个字段 —— 其余描述的是**别的事实**,原样保留
assert.equal(manifest[FLAT].tgzSha256, SHA_A)
assert.equal(manifest[FLAT].backupTgz, '/b/probe.tgz')
assert.equal(manifest[FLAT].rollbackTgz, '/b/probe-prev.tgz')
assert.equal(manifest[FLAT].installedAt, 1)
// 幂等:拿修好的清单再跑 ⇒ 一处不动(⛔ 不制造无谓写盘 ⇒ 不抖 mtime)
const again = reconcileSharedManifest(root, manifest)
assert.deepEqual(again.report.fixed, [])
assert.equal(again.report.checked, 1)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('台账自愈: 清单有盘上无 ⇒ 只报 missingOnDisk;盘上有清单无 ⇒ 只报 extrasOnDisk(⛔ 双向都不增删)', () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
makeTree(join(root, FLAT))
makeTree(join(root, '_dsh-local_extra')) // 盘上有、清单里没有
mkdirSync(join(root, '.store'), { recursive: true }) // 旁挂 ⇒ ⛔ 不算 extra
const m = {
[FLAT]: {
id: 'probe', version: '1.0.0', tgzSha256: SHA_A,
treeSha256: hashTree(join(root, FLAT)).hash, fileCount: 3,
backupTgz: '', installedAt: 1, updatedAt: 1,
},
'_dsh-local_ghost': {
id: 'ghost', version: '9', tgzSha256: SHA_A, treeSha256: 'c'.repeat(64), fileCount: 1,
backupTgz: '', installedAt: 1, updatedAt: 1,
},
}
const { manifest, report } = reconcileSharedManifest(root, m)
assert.deepEqual(report.missingOnDisk, ['_dsh-local_ghost'])
assert.deepEqual(report.fixed, [])
assert.deepEqual([...report.extrasOnDisk].sort(), ['_dsh-local_extra'], '旁挂文件不算 extra')
assert.ok(manifest['_dsh-local_ghost'] !== undefined, '⛔ 不许因"盘上没有"就删条目')
assert.equal(manifest['_dsh-local_extra'], undefined, '⛔ 不许因"盘上有"就新增条目')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('回滚素材(㈡): 命名 = **上一版版本号**,内容 = 旧实体,且不含 node_modules', () => {
const d = tmp()
try {
const old = join(d, 'bundled-plugins', FLAT)
makeTree(old) // 旧实体:包内 version 1.0.0
// pnpm 铺装的副产品 ⇒ ⛔ 不进素材(否则素材膨胀、回滚重放还带垃圾)
mkdirSync(join(old, 'node_modules', 'big'), { recursive: true })
writeFileSync(join(old, 'node_modules', 'big', 'package.json'), JSON.stringify({ name: 'big', version: '9.9.9' }))
writeFileSync(join(old, 'node_modules', 'big', 'blob.js'), 'x'.repeat(4096))
const ver = packageVersionAt(old)
assert.equal(ver, '1.0.0', '素材名必须来自**旧实体自己**的版本')
const asset = rollbackAssetPath(join(d, 'backups'), FLAT, ver)
assert.equal(asset, join(d, 'backups', 'plugins', FLAT, '1.0.0.tgz'))
mkdirSync(dirname(asset), { recursive: true })
packSharedTree(old, asset)
assert.ok(existsSync(asset) && statSync(asset).size > 0)
// 解包验证:① 名实相符(包内 version ≡ 文件名)② node_modules 不在归档里
const out = join(d, 'unpack')
mkdirSync(out, { recursive: true })
execFileSync('tar', ['-xzf', relative(out, asset), '-C', '.'], { cwd: out, stdio: 'pipe' })
assert.equal(packageVersionAt(out), ver, '素材内容 = 上一版(名与内容自洽)')
assert.ok(existsSync(join(out, 'dist', 'index.js')))
assert.ok(!existsSync(join(out, 'node_modules')), '⛔ node_modules 不进回滚素材')
assert.equal(hashTree(out).fileCount, 3, '归档内容 ≡ 旧实体减去 node_modules')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('反证: 回滚素材⛔ 不许落在被归档的目录内(否则每次打包都把自己包进去)', () => {
const d = tmp()
try {
const old = join(d, 'bundled-plugins', FLAT)
makeTree(old)
assert.throws(() => packSharedTree(old, join(old, 'self.tgz')), /pack_into_self/, '树内落点必须被拒')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
// ══════════════════════════════════════════════════════════════════════════
// 层 A(止血)· 2026-09-26「版本管理与包更新机制」
//
// 真机事故(D1):`syncSharedLayerOnce` 里**对账与产物下载共用一个 20 s 常量**
// ⇒ 2.0 MB 包跨云(47↔106 经 relay)拉不完 ⇒ 106 每轮上报
// `failed:[{flat:'dsh-plugin-mcn-suite', error:'The operation was aborted due to timeout'}]`
// ⇒ **节点永远停在旧版**。本组用例钉住:两个超时**必须分开**、且都能外置注入。
// ══════════════════════════════════════════════════════════════════════════
test('A-1(最要紧): 下载**慢于**对账超时也必须成功 —— 两者共用一个超时就是本事故的病根', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const want = hashTreeAt(src).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const base = m.fetchImpl
/** 产物口故意慢:比对账超时(40 ms)慢一个数量级。 */
const slow = async (url, init) => {
if (String(url).includes('/pack/')) await new Promise((r) => setTimeout(r, 150))
return base(url, init)
}
// ⚠️ 用**老名字** `timeoutMs` 传 40 ms:这正是改动前"对账与下载共用一个常量"的形状。
// ⇒ 若两个超时没有真正分家,这里必然 abort(用例会红)。
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test',
hostId: 'w-1',
token: 'tok',
sharedRoot: root,
fetchImpl: slow,
timeoutMs: 40,
retries: 0,
})
assert.equal(out.ok, true, out.reason ?? '')
assert.deepEqual(out.pulled, [FLAT], '下载慢于对账超时 ⛔ 不该被掐断')
assert.equal(hashTreeAt(join(root, FLAT)).hash, want)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('A-1: `downloadTimeoutMs` **可注入且真的生效** —— 掐断时记成可重试的失败', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const want = hashTreeAt(src).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const base = m.fetchImpl
/** 产物口慢 300 ms,但**尊重 abort**(真 fetch 就是这样)。 */
const slow = async (url, init) => {
if (String(url).includes('/pack/')) return slowWithSignal(300, init, () => new Response(tgz, { status: 200 }))
return base(url, init)
}
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test',
hostId: 'w-1',
token: 'tok',
sharedRoot: root,
fetchImpl: slow,
downloadTimeoutMs: 30, // 刻意小到必超时
retries: 0,
})
assert.equal(out.ok, false)
assert.equal(out.failed.length, 1)
assert.match(out.failed[0].error, /download_timeout/)
assert.ok(!existsSync(join(root, FLAT)), '超时 ⛔ 不许留半包')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('A-2: `effectiveDownloadTimeout` —— 无提示走基准 / 有提示按最低速率放宽 / 封顶', () => {
const o = { downloadTimeoutMs: 300_000, maxDownloadTimeoutMs: 900_000, minRateBps: 64 * 1024 }
// 无提示(NaN / 0 / 负数)⇒ **原样**基准(⛔ 不许凭空放大:那会让真卡住的连接一直占着这一轮)
assert.equal(effectiveDownloadTimeout(Number.NaN, o), 300_000)
assert.equal(effectiveDownloadTimeout(0, o), 300_000)
assert.equal(effectiveDownloadTimeout(-1, o), 300_000)
// 小包 ⇒ 基准就够(2 MB 在 64 KiB/s 下 ≈ 32 s × 1.5 = 48 s < 300 s)
assert.equal(effectiveDownloadTimeout(2 * 1024 * 1024, o), 300_000)
// 大包 ⇒ 按最低速率放宽(100 MB ≈ 1600 s × 1.5 = 2400 s ⇒ 撞上封顶 900 s)
assert.equal(effectiveDownloadTimeout(100 * 1024 * 1024, o), 900_000)
// 基准已高于上限 ⇒ 基准优先(⛔ 不许"注入的上限把基准压小")
assert.equal(
effectiveDownloadTimeout(1, { downloadTimeoutMs: 1_200_000, maxDownloadTimeoutMs: 900_000, minRateBps: 1 }),
1_200_000,
)
})
test('A-3: 瞬态失败会重试;**永久性**错误(404)一次也不重试', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const src = makeTree(join(d, 'src-copy', FLAT))
const tgz = packDir(join(d, 'src-copy'), FLAT)
const want = hashTreeAt(src).hash
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
// ① 瞬态:第一次抛(模拟网络抖动 / abort),第二次成功。
{
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
const base = m.fetchImpl
let packHits = 0
const flaky = async (url, init) => {
if (String(url).includes('/pack/')) {
packHits++
if (packHits === 1) throw new Error('ECONNRESET')
}
return base(url, init)
}
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
fetchImpl: flaky, retries: 2, backoffMs: 0,
})
assert.equal(out.ok, true, out.reason ?? '')
assert.deepEqual(out.pulled, [FLAT], '抖动应当自愈(A-3)')
assert.deepEqual(out.failed, [], '🔴 重试成功后 ⛔ 不许**同时**出现在 failed 里(自相矛盾的读数)')
assert.equal(packHits, 2, '恰好重试一次')
}
/** 每个子用例都从**空节点**起(⛔ 别复用上一段的成果):上一段拉成功后节点侧已记下指纹
* ⇒ 对账就没有差异、连产物口都不会被请求 ⇒ 用例会"假绿"。 */
const resetRoot = () => {
rmSync(join(root, FLAT), { recursive: true, force: true })
rmSync(join(root, '.node-pull.json'), { force: true })
}
// ② 永久:产物口 404 ⇒ ⛔ 一次也不许多试(重试只会同样失败,白等退避)
{
resetRoot()
const m = fakeManager({ entries, packs: {} }) // packs 空 ⇒ 404 not_here
let packHits = 0
const base = m.fetchImpl
const counting = async (url, init) => {
if (String(url).includes('/pack/')) packHits++
return base(url, init)
}
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
fetchImpl: counting, retries: 5, backoffMs: 0,
})
assert.equal(out.failed[0].error, 'not_here')
assert.equal(packHits, 1, '404 是永久性错误 ⇒ ⛔ 不重试')
}
// ③ 指纹不符同样是永久性错误(重拉同一份包只会得到同一个 hex)
{
resetRoot()
const m = fakeManager({ entries: [{ ...entries[0], treeSha256: 'c'.repeat(64) }], packs: { [FLAT]: tgz } })
let packHits = 0
const base = m.fetchImpl
const counting = async (url, init) => {
if (String(url).includes('/pack/')) packHits++
return base(url, init)
}
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
fetchImpl: counting, retries: 5, backoffMs: 0,
})
assert.match(out.failed[0].error, /tree_sha256_mismatch/)
assert.equal(packHits, 1, '指纹不符 ⇒ ⛔ 不重试')
}
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('A-4: 失败留痕 —— 尝试次数 + 首/末失败时刻(答得出"连续失败几次")', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
const m = fakeManager({ entries, packs: {} })
const base = m.fetchImpl
const alwaysTransient = async (url, init) => {
if (String(url).includes('/pack/')) throw new Error('The operation was aborted due to timeout')
return base(url, init)
}
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
fetchImpl: alwaysTransient, retries: 2, backoffMs: 0,
})
assert.equal(out.failed.length, 1)
const f = out.failed[0]
assert.match(f.error, /aborted/)
assert.equal(f.attempts, 3, '首次 + 2 次重试')
assert.ok(typeof f.firstFailedAt === 'number' && typeof f.lastFailedAt === 'number')
assert.ok(f.lastFailedAt >= f.firstFailedAt)
// 对账面照旧拿得到(⛔ 形状没被破坏:`error` 仍是原来的字符串)
const reported = m.calls.find((c) => c.url.endsWith('/node/result'))
assert.ok(reported !== undefined)
assert.deepEqual(JSON.parse(reported.init.body).failed, out.failed)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
// ══════════════════════════════════════════════════════════════════════════
// 层 B-1/B-2/B-3 · 版本目录化 / 通道 / 定向钉版
// ══════════════════════════════════════════════════════════════════════════
test('B-1(最要紧): 归档解开后的指纹必须**逐字等于**当初现役树的指纹(否则节点无限重拉)', () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
const live = makeTree(join(root, FLAT))
// 现役树带上 `node_modules`(`materializeShared` 的副产品)—— 归档**必须包含**它,
// 因为产物口现打 tar 时也不排除 ⇒ 只有口径一致,解开后的 hex 才等于当初记的那个。
mkdirSync(join(live, 'node_modules', 'dep'), { recursive: true })
writeFileSync(join(live, 'node_modules', 'dep', 'index.js'), 'export const d = 1\n')
const want = hashTree(live).hash
const ref = archiveLiveVersion(root, FLAT, live, '1.0.0')
assert.ok(ref !== null, '归档必须生成')
assert.equal(ref.treeSha256, want, '归档记的指纹 = 现役现算指纹')
assert.equal(ref.fileCount, 4, 'package.json + dist/index.js + README.md + node_modules/dep/index.js')
assert.equal(ref.version, '1.0.0')
assert.ok(ref.bytes > 0)
assert.equal(ref.file, join(versionsDirOf(root, FLAT), '1.0.0.tgz'))
// 真解开:内容与现役逐字一致 ⇒ 节点侧 `hashTreeAt` 会算出同一个 hex
const out = join(d, 'unpack')
mkdirSync(out, { recursive: true })
execFileSync('tar', ['-xzf', relative(out, ref.file)], { cwd: out, stdio: 'pipe' })
assert.equal(hashTreeAt(join(out, FLAT)).hash, want, '解开后指纹必须逐字相同')
assert.ok(existsSync(join(out, FLAT, 'node_modules', 'dep', 'index.js')), '归档含 node_modules(与产物口同口径)')
// 幂等:已有归档 ⇒ 不重做(不制造无谓写盘)
const ref2 = archiveLiveVersion(root, FLAT, live, '1.0.0')
assert.equal(ref2.tgzSha256, ref.tgzSha256)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('B-1: 版本号要进文件名 ⇒ 不合法一律**拒绝归档**(⛔ 不让它变成路径逃逸)', () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
const live = makeTree(join(root, FLAT))
for (const bad of ['../evil', 'a/b', '', '.', '..', 'x'.repeat(80), '-lead']) {
assert.equal(isSafeVersion(bad), false, `${bad} 不该通过`)
assert.equal(archiveLiveVersion(root, FLAT, live, bad), null)
}
for (const good of ['0.5.1', '1.0.0-rc.1', 'v2', '1.2.3+build.5']) {
assert.equal(isSafeVersion(good), true, `${good} 该通过`)
}
assert.ok(!existsSync(join(root, '..', 'evil.tgz')))
assert.ok(!existsSync(join(root, 'evil.tgz')))
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('B-1 反证: 归档口径与「回滚素材」**不同** —— 素材排除 node_modules,故两者不可互相顶替', () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
const live = makeTree(join(root, FLAT))
mkdirSync(join(live, 'node_modules', 'big'), { recursive: true })
writeFileSync(join(live, 'node_modules', 'big', 'blob.js'), 'x'.repeat(2048))
const asset = rollbackAssetPath(join(d, 'backups'), FLAT, '1.0.0')
mkdirSync(dirname(asset), { recursive: true })
packSharedTree(live, asset)
const out = join(d, 'unpack')
mkdirSync(out, { recursive: true })
execFileSync('tar', ['-xzf', relative(out, asset)], { cwd: out, stdio: 'pipe' })
assert.ok(!existsSync(join(out, 'node_modules')), '素材含 node_modules ⇒ 就不是素材了')
assert.notEqual(hashTree(out).hash, hashTree(live).hash, '两者指纹本就不同 ⇒ ⛔ 不可拿素材当归档下发')
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('B-1/B-2 端到端: 节点被钉在**历史版本** ⇒ 按 `?version=` 取归档,校验通过并记下版本号', async () => {
const d = tmp()
try {
const root = join(d, 'bundled-plugins')
mkdirSync(root, { recursive: true })
const srcOld = makeTree(join(d, 'src-old', FLAT))
const tgzOld = packDir(join(d, 'src-old'), FLAT)
const oldHash = hashTreeAt(srcOld).hash
const srcNew = makeTree(join(d, 'src-new', FLAT))
writeFileSync(join(srcNew, 'README.md'), '# probe v2\n')
const tgzNew = packDir(join(d, 'src-new'), FLAT)
const newHash = hashTreeAt(srcNew).hash
assert.notEqual(oldHash, newHash)
// Manager 给这台 host 的**目标形态**:版本 0.5.0(旧版)+ 旧版指纹 ⇒ 与"钉版"同形。
const entries = [{ flat: FLAT, id: 'probe', version: '0.5.0', treeSha256: oldHash, fileCount: 3 }]
const m = fakeManager({
entries,
packs: { [FLAT]: tgzNew }, // 现役口给的是新版
liveVersions: { [FLAT]: '0.5.1' }, // 现役 = 0.5.1
versions: { [FLAT]: { '0.5.0': tgzOld } }, // 历史归档口给的是旧版
})
const out = await syncSharedLayerOnce({
baseUrl: 'https://manager.test', hostId: 'w-106', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
})
assert.equal(out.ok, true, out.reason ?? '')
assert.deepEqual(out.pulled, [FLAT])
assert.equal(hashTreeAt(join(root, FLAT)).hash, oldHash, '拿到的必须是**旧版**内容(不是现役的新版)')
const packCall = m.calls.find((c) => c.url.includes('/pack/'))
assert.match(packCall.url, /\?version=0\.5\.0$/, '必须带 ?version= 去取,否则拿到的会是现役')
// 节点把"我拉的是哪个版本"记下来(可观测;下一轮据此判断要不要换)
const state = JSON.parse(readFileSync(join(root, '.node-pull.json'), 'utf8'))
assert.equal(state.entries[FLAT].version, '0.5.0')
assert.equal(state.entries[FLAT].treeSha256, oldHash)
} finally {
rmSync(d, { recursive: true, force: true })
}
})
test('B-2/B-3: 目标版本解析 —— 钉版 > 通道 > 现役;取不到就**回落并如实标注**', () => {
const OLD = 'b'.repeat(64)
const e = {
version: '0.5.1',
treeSha256: SHA_A,
fileCount: 3,
history: [
{ version: '0.5.0', treeSha256: OLD, fileCount: 2, tgzSha256: 'c'.repeat(64), file: '/x/0.5.0.tgz', bytes: 10, archivedAt: 1 },
],
channel: { canary: '0.5.0', stable: null },
pinnedHosts: { 'w-106': '0.5.0' },
}
// ① 钉版优先于通道
const t1 = resolveSharedTarget(e, 'w-106', 'stable')
assert.equal(t1.version, '0.5.0')
assert.equal(t1.by, 'pin')
assert.equal(t1.source, 'archive')
assert.equal(t1.treeSha256, OLD, '必须给**归档那个版本**的指纹,⛔ 不是现役的')
// ② 通道:canary 走旧版
const t2 = resolveSharedTarget(e, 'w-47', 'canary')
assert.equal(t2.version, '0.5.0')
assert.equal(t2.by, 'channel')
// ③ stable = null ⇒ 现役
const t3 = resolveSharedTarget(e, 'w-47', 'stable')
assert.equal(t3.version, '0.5.1')
assert.equal(t3.source, 'live')
// ④ 不认识的通道 ⇒ 现役(⛔ 不报错、也不假装命中)
const t4 = resolveSharedTarget(e, 'w-47', 'beta')
assert.equal(t4.version, '0.5.1')
assert.equal(t4.by, 'live')
// ⑤ 钉到一个**没有归档**的版本 ⇒ 回落现役 + **如实标注**(⛔ 不许假装解析成功)
const t5 = resolveSharedTarget({ ...e, pinnedHosts: { 'w-x': '9.9.9' } }, 'w-x', 'stable')
assert.equal(t5.source, 'live')
assert.equal(t5.fellBack, 'version_not_archived')
// ⑥ 钉到**现役版本** ⇒ 走现役(不必走归档)
const t6 = resolveSharedTarget({ ...e, pinnedHosts: { 'w-y': '0.5.1' } }, 'w-y', 'stable')
assert.equal(t6.source, 'live')
assert.equal(t6.by, 'pin')
assert.equal(t6.fellBack, undefined)
// ⑦ 可选版本清单 = 现役 ∪ 归档(去重、现役在前)
assert.deepEqual(availableVersionsOf(e), ['0.5.1', '0.5.0'])
assert.deepEqual(availableVersionsOf({ version: '1.0.0', treeSha256: SHA_A, fileCount: 0, history: [] }), ['1.0.0'])
// ⑧ 无声明(存量清单)⇒ 恒现役 —— 行为与加这两个字段之前**完全一致**
assert.deepEqual(resolveSharedTarget({ version: '1.0.0', treeSha256: SHA_A, fileCount: 0 }, 'h', 'stable'), {
version: '1.0.0', treeSha256: SHA_A, fileCount: 0, source: 'live', by: 'live',
})
assert.equal(historyEntryOf(e, '0.5.0').treeSha256, OLD)
assert.equal(historyEntryOf(e, '0.5.1'), undefined)
assert.equal(historyEntryOf({}, '0.5.0'), undefined)
})
test('B-5: 全局矩阵 —— 目标/实报/漂移三类;老快照(无 inventory)⇒ `unknown`,⛔ 不折算成 current', () => {
const OLD = 'b'.repeat(64)
const manifest = {
[FLAT]: {
id: '@dsh-local/probe',
version: '0.5.1',
tgzSha256: 'd'.repeat(64),
treeSha256: SHA_A,
fileCount: 3,
backupTgz: '',
installedAt: 1,
updatedAt: 2,
channel: { canary: '0.5.0' },
pinnedHosts: { 'w-106': '0.5.0' },
history: [
{ version: '0.5.0', treeSha256: OLD, fileCount: 2, tgzSha256: 'c'.repeat(64), file: '/x/a.tgz', bytes: 9, archivedAt: 1 },
],
},
}
const reports = [
// ① 新节点:自报清单有 ⇒ 能算漂移(它按钉版拿了旧版 ⇒ current)
{ hostId: 'w-106', at: 111, authoritative: 1, missing: [], stale: [], extra: [], failed: [], channel: 'stable', inventory: [{ flat: FLAT, treeSha256: OLD }] },
// ② 新节点但实报与目标不符 ⇒ stale
{ hostId: 'w-47', at: 222, authoritative: 1, missing: [], stale: [FLAT], extra: [], failed: [{ flat: FLAT, error: 'x' }], channel: 'canary', inventory: [{ flat: FLAT, treeSha256: SHA_A }] },
// ③ 老节点(改造前的快照):**没有 inventory** ⇒ ⛔ 一律 unknown
{ hostId: 'w-old', at: 333, authoritative: 1, missing: [], stale: [], extra: [], failed: [], channel: undefined },
]
const { hosts, flats } = sharedStatusMatrix(manifest, reports)
assert.deepEqual(hosts.map((h) => h.hostId), ['w-106', 'w-47', 'w-old'])
assert.equal(hosts.find((h) => h.hostId === 'w-old').hasInventory, false)
assert.equal(hosts.find((h) => h.hostId === 'w-47').clean, false, '有 failed ⇒ 不干净')
assert.equal(hosts.find((h) => h.hostId === 'w-106').clean, true)
assert.equal(flats.length, 1)
const f = flats[0]
assert.equal(f.flat, FLAT)
assert.deepEqual(f.versions, ['0.5.1', '0.5.0'])
assert.equal(f.live.version, '0.5.1')
const byHost = new Map(f.hosts.map((h) => [h.hostId, h]))
assert.equal(byHost.get('w-106').target.version, '0.5.0', '按钉版解析')
assert.equal(byHost.get('w-106').drift, 'current')
assert.equal(byHost.get('w-47').target.version, '0.5.0', '按 canary 通道解析')
assert.equal(byHost.get('w-47').drift, 'stale', '实报的是现役内容 ⇒ 与目标(旧版)不符')
assert.equal(byHost.get('w-old').drift, 'unknown', '⛔ 老快照不许折算成 current')
// 旁挂目录(`.` 开头)不进矩阵
const withDot = sharedStatusMatrix({ ...manifest, '.versions': manifest[FLAT] }, [])
assert.deepEqual(withDot.flats.map((x) => x.flat), [FLAT])
})