Files
dsh_shenxian/test/shared-layer-sync.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

1046 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 跨节点内容分发 · 回归测试(本线 §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])
})