// 跨节点内容分发 · 回归测试(本线 §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('500') } }) 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]) })