1048 lines
52 KiB
JavaScript
1048 lines
52 KiB
JavaScript
/**
|
||
* 插件投放与分库线 ① · **S3 跨机装配**的回归测试。
|
||
*
|
||
* ## 这个文件防的是什么(每一件都对应一条会静默失效的判据)
|
||
*
|
||
* 1. **`RemoteSpawner.applyPlugins` 真的发到"实例所在那台"** —— 与 `restartAndProbe`
|
||
* 同一套 `hostIdFor` 路由。⛔ 若它回落到默认 host,就会把请求发给**另一台机**
|
||
* (那台上没有这个用户的 profile ⇒ 落盘落到一个空目录、或给错人装插件),
|
||
* 而症状只有"插件没生效",平台零日志。
|
||
* 2. **请求体形状与 agent 侧的入参校验对得上** —— `{userId, items:[{id,version,enabled,src}]}`。
|
||
* 两侧字段名漂一个字(如 `src` vs `source`)在 TS 里编译得过(一个 `Record<string,unknown>`),
|
||
* 但运行期是 400 ⇒ "用户点了启用永远失败"。
|
||
* 3. **`Spawner.applyPlugins` 是接口成员**:`LeasedSpawner` / `LocalSpawner` / `RemoteSpawner`
|
||
* 三处都得有 —— 漏一处 TS 会拦(这条由 `tsc` 保证),但**透传语义**(LeasedSpawner ⛔ 不碰
|
||
* 租约)是编译期看不出来的,故在此钉住。
|
||
* 4. **共享层路径换算两台机必须逐字一致**(`sharedDirNameOf`)—— manager 算出
|
||
* `dsh-plugin-mcn-suite` 而 worker 上铺的是别的名字 ⇒ `pnpm install` 报 ENOENT,
|
||
* 症状只是"某个用户装不上插件"。
|
||
* 5. **装配失败要"报错可读"**(S3-E ⑤):指向不存在的版本 ⇒ 抛带 `code` 的错,
|
||
* ⛔ 不静默跳过 —— 静默跳过会让任务显示 `success` 而插件根本没装。
|
||
*
|
||
* 运行:`node --test test/plugin-assembly.test.mjs`(已登记进 `npm test`)。
|
||
*
|
||
* @module test/plugin-assembly
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { test } from 'node:test'
|
||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, lstatSync, readlinkSync, existsSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
|
||
import {
|
||
applyProfileChanges,
|
||
depRefOf,
|
||
profileDirOf,
|
||
readProfileManifest,
|
||
sharedDirNameOf,
|
||
sharedDirOf,
|
||
syncWebProviderPatch,
|
||
wsDirOf,
|
||
WEB_PROVIDER_CLOSE,
|
||
WEB_PROVIDER_OPEN,
|
||
isWorkspaceRoot,
|
||
pnpmInstallArgs,
|
||
workspaceRootFlag,
|
||
pnpmExecError,
|
||
pnpmDiagArgs,
|
||
// 共享层自足性(第 18 棒)+ 回滚残影清理
|
||
ensureSharedScopeLink,
|
||
healSharedLayerSelfSufficiency,
|
||
pruneOrphanDepLinks,
|
||
restoreProfile,
|
||
snapshotProfile,
|
||
rebuildMissingDepLinks,
|
||
linkRefsOf,
|
||
resolveDshPackageRoot,
|
||
resetDshPackageRootCache,
|
||
// 越权软链的临时摘除(第 20 棒)
|
||
quarantineSharedLayerLinks,
|
||
restoreQuarantinedLinks,
|
||
} from '../lib/supervisor/plugin-assembly.js'
|
||
import { RemoteSpawner } from '../lib/supervisor/remote-spawner.js'
|
||
|
||
/* ─────────── 小工具 ─────────── */
|
||
|
||
/** 记下每次请求(url / method / body),回同一个形状的 JSON。 */
|
||
function recordingFetch(payload) {
|
||
const calls = []
|
||
const impl = async (url, init) => {
|
||
calls.push({ url: `${init?.method ?? 'GET'} ${url}`, body: init?.body === undefined ? undefined : JSON.parse(init.body) })
|
||
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
|
||
}
|
||
impl.calls = calls
|
||
return impl
|
||
}
|
||
|
||
/** 106 那台 worker(`via` 缺省 ⇒ 直连,不做 relay 翻译)。 */
|
||
const W106 = {
|
||
hostId: 'w-2',
|
||
agentUrl: 'http://127.0.0.1:19000',
|
||
token: 'tok-106',
|
||
}
|
||
|
||
/* ─────────── ① 跨机路由 ─────────── */
|
||
|
||
test('S3: applyPlugins 落到"该用户实例所在那台",⛔ 不回落到默认 host', async () => {
|
||
const fetchImpl = recordingFetch({ enabled: ['@dsh-local/trpg-kit'], disabled: [], bundles: ['@dsh-local/trpg-kit'] })
|
||
const sp = new RemoteSpawner({
|
||
agentUrl: 'http://127.0.0.1:19100', // 默认 host(w-1)
|
||
token: 'tok-local',
|
||
defaultHostId: 'w-1',
|
||
hostIdFor: async () => 'w-2', // 该用户的实例在 106
|
||
hostsProvider: async () => [W106],
|
||
fetchImpl,
|
||
})
|
||
const out = await sp.applyPlugins('u-1', [{ id: '@dsh-local/trpg-kit', enabled: true }])
|
||
assert.equal(fetchImpl.calls.length, 1)
|
||
// 🔴 打到 w-2(19000)而不是默认 host(19100)—— 这就是"装在 A、实例在 B"的判据。
|
||
assert.match(fetchImpl.calls[0].url, /^POST http:\/\/127\.0\.0\.1:19000\/plugins\/apply$/)
|
||
assert.deepEqual(out.bundles, ['@dsh-local/trpg-kit'])
|
||
})
|
||
|
||
test('S3: applyPlugins 请求体形状 = agent 侧约定(字段名漂一个就是 400)', async () => {
|
||
const fetchImpl = recordingFetch({ enabled: [], disabled: [], bundles: [] })
|
||
const sp = new RemoteSpawner({
|
||
agentUrl: 'http://127.0.0.1:19100',
|
||
token: 'tok',
|
||
fetchImpl,
|
||
})
|
||
await sp.applyPlugins('u-1', [
|
||
{ id: '@dsh-local/trpg-kit', version: '1.2.3', enabled: true },
|
||
{ id: 'mcn-suite', version: null, enabled: false, src: 'file' },
|
||
])
|
||
// ⚠️ v17 起 `essential` 是**协议的一部分**(基础插件"关不掉"的拒绝必须落在落盘那端
|
||
// ⇒ 这个标记必须过网)。本断言钉的就是"两边字段名与形状逐字一致"。
|
||
assert.deepEqual(fetchImpl.calls[0].body, {
|
||
userId: 'u-1',
|
||
items: [
|
||
{ id: '@dsh-local/trpg-kit', version: '1.2.3', enabled: true, src: 'bundled', essential: false },
|
||
{ id: 'mcn-suite', version: null, enabled: false, src: 'file', essential: false },
|
||
],
|
||
})
|
||
})
|
||
|
||
test('S3: applyPlugins 在 hostIdFor 缺失(单机形态)时走默认 host', async () => {
|
||
const fetchImpl = recordingFetch({ enabled: [], disabled: [], bundles: [] })
|
||
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl })
|
||
await sp.applyPlugins('u-1', [])
|
||
assert.match(fetchImpl.calls[0].url, /127\.0\.0\.1:19100\/plugins\/apply/)
|
||
})
|
||
|
||
test('S3: agent 回 4xx ⇒ 原样带上状态码与响应体抛错(⛔ 不被重试吞掉)', async () => {
|
||
let calls = 0
|
||
const impl = async () => {
|
||
calls += 1
|
||
return {
|
||
ok: false,
|
||
status: 409,
|
||
json: async () => ({}),
|
||
text: async () => '{"error":"plugin_bundle_missing"}',
|
||
}
|
||
}
|
||
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl: impl })
|
||
await assert.rejects(
|
||
() => sp.applyPlugins('u-1', [{ id: 'x', enabled: true }]),
|
||
(err) => {
|
||
assert.match(String(err.message), /409/)
|
||
assert.match(String(err.message), /plugin_bundle_missing/)
|
||
return true
|
||
},
|
||
)
|
||
// 4xx = 协议/参数错 ⇒ 重试没意义(与 call() 的既有纪律一致)
|
||
assert.equal(calls, 1)
|
||
})
|
||
|
||
/* ─────────── ② 路径换算:两台机必须逐字一致 ─────────── */
|
||
|
||
test('S3: 共享层目录名 = 扁平化包名(scope 的 `/` 变 `_`,⛔ 不带版本号)', () => {
|
||
assert.equal(sharedDirNameOf('@dsh-local/trpg-kit'), '_dsh-local_trpg-kit')
|
||
assert.equal(sharedDirNameOf('dsh-plugin-mcn-suite'), 'dsh-plugin-mcn-suite')
|
||
assert.equal(sharedDirNameOf('[email protected]'), 'univer-office_1.2.3')
|
||
// ⛔ 版本号不进路径(B 档:同名只留最新一版,写进路径会让老 profile 悬空)
|
||
assert.equal(sharedDirNameOf('dsh-plugin-mcn-suite'), sharedDirNameOf('dsh-plugin-mcn-suite'))
|
||
})
|
||
|
||
test('S3: sharedDirOf / profileDirOf / wsDirOf 的路径数学', () => {
|
||
// ⚠️ 用 `join` 拼期望值:本机是 Windows(`\`),而 47/106 是 Linux(`/`)——
|
||
// 判据要的是**路径数学**(拼哪个子目录),不是分隔符字面。
|
||
assert.equal(
|
||
sharedDirOf('/var/lib/dshs/bundled-plugins', '@dsh-local/trpg-kit'),
|
||
join('/var/lib/dshs/bundled-plugins', '_dsh-local_trpg-kit'),
|
||
)
|
||
assert.equal(profileDirOf('/var/lib/dshs/users/u-1'), join('/var/lib/dshs/users/u-1', 'home', 'profiles', 'web'))
|
||
assert.equal(wsDirOf('/var/lib/dshs/users/u-1'), join('/var/lib/dshs/users/u-1', 'ws'))
|
||
})
|
||
|
||
/* ─────────── ③ profile 读写与 web provider 段 ─────────── */
|
||
|
||
test('S3: readProfileManifest 读不出 ⇒ 空清单(⛔ 不抛)', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
|
||
try {
|
||
assert.deepEqual(readProfileManifest(dir), { bundles: [], deps: {} })
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('S3: syncWebProviderPatch 幂等(跑两次结果逐字相同)', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
|
||
try {
|
||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||
name: 'dsh-profile-web',
|
||
private: true,
|
||
dependencies: { 'anysearch': 'file:/tmp/anysearch' },
|
||
dsh: { profile: { bundles: ['anysearch'] } },
|
||
}, null, 2))
|
||
// 插件自带 `cordis.patch.yml` 声明 searchProvider(`collectWebProviderClaims` 读它)
|
||
mkdirSync(join(dir, 'node_modules', 'anysearch'), { recursive: true })
|
||
writeFileSync(join(dir, 'node_modules', 'anysearch', 'cordis.patch.yml'), [
|
||
'- id: web',
|
||
' config:',
|
||
' searchProvider: anysearch',
|
||
'',
|
||
].join('\n'))
|
||
|
||
syncWebProviderPatch(dir)
|
||
const first = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||
syncWebProviderPatch(dir)
|
||
const second = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||
assert.equal(first, second)
|
||
// 托管段两侧标记必须都在(验证脚本按字面找它们 —— ⛔ 改名会让 verify-*.mjs 静默不判)
|
||
assert.ok(first.includes(WEB_PROVIDER_OPEN))
|
||
assert.ok(first.includes(WEB_PROVIDER_CLOSE))
|
||
assert.match(first, /searchProvider: anysearch/)
|
||
assert.match(first, /fetchProvider: http/)
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('S3: bundles 为空 ⇒ 托管段被整段摘掉(回到"自动选")', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'pa-'))
|
||
try {
|
||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||
name: 'dsh-profile-web',
|
||
private: true,
|
||
dependencies: {},
|
||
dsh: { profile: { bundles: [] } },
|
||
}, null, 2))
|
||
writeFileSync(join(dir, 'cordis.patch.yml'), [
|
||
'other: true',
|
||
'',
|
||
WEB_PROVIDER_OPEN,
|
||
'- id: web',
|
||
' config:',
|
||
' searchProvider: anysearch',
|
||
WEB_PROVIDER_CLOSE,
|
||
'',
|
||
].join('\n'))
|
||
syncWebProviderPatch(dir)
|
||
const text = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||
assert.ok(!text.includes(WEB_PROVIDER_OPEN))
|
||
assert.ok(!text.includes('searchProvider'))
|
||
assert.match(text, /other: true/)
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
/* ─────────── ④ 装配失败必须"报错可读"(S3-E ⑤) ─────────── */
|
||
|
||
test('S3 反证: 共享层里没有该包 ⇒ applyPlugins 抛错(⛔ 不静默跳过)', async () => {
|
||
// 用 RemoteSpawner 模拟:agent 回 409 plugin_bundle_missing
|
||
const impl = async () => ({
|
||
ok: false,
|
||
status: 409,
|
||
json: async () => ({}),
|
||
text: async () => '{"error":"plugin_bundle_missing","message":"共享层里没有该插件的包:@dsh-local/ghost"}',
|
||
})
|
||
const sp = new RemoteSpawner({ agentUrl: 'http://127.0.0.1:19100', token: 'tok', fetchImpl: impl })
|
||
await assert.rejects(
|
||
() => sp.applyPlugins('u-1', [{ id: '@dsh-local/ghost', enabled: true }]),
|
||
/plugin_bundle_missing/,
|
||
)
|
||
})
|
||
|
||
/* ─────────── ⑤ 软链语义(D2:一份实体、多份链接) ─────────── */
|
||
|
||
test('S3: D2 语义 —— node_modules/<pkg> 是指向共享层的软链,⛔ 不是实体副本', () => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-'))
|
||
try {
|
||
const shared = join(root, 'bundled-plugins', '_dsh-local_trpg-kit')
|
||
mkdirSync(shared, { recursive: true })
|
||
writeFileSync(join(shared, 'package.json'), '{"name":"@dsh-local/trpg-kit"}')
|
||
const profile = join(root, 'users', 'u-1', 'home', 'profiles', 'web')
|
||
mkdirSync(join(profile, 'node_modules'), { recursive: true })
|
||
symlinkSync(shared, join(profile, 'node_modules', '@dsh-local_trpg-kit'), 'dir')
|
||
|
||
const link = join(profile, 'node_modules', '@dsh-local_trpg-kit')
|
||
assert.ok(lstatSync(link).isSymbolicLink(), '必须是软链(实体只有共享层那一份)')
|
||
assert.equal(readlinkSync(link), shared, '必须指向共享层的那一份实体')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
/* ─────────── ⑥ pnpm 参数契约(防"裸写无效 flag ⇒ 静默装不上")─────────── */
|
||
|
||
test('S3: pnpm 参数 ⛔ 不含裸写的 ignore-workspace-root-check(pnpm 9 会 Unknown option 直接失败)', () => {
|
||
// 真机实测(106 · pnpm 9.15.9):`pnpm install --ignore-workspace-root-check` 报
|
||
// ERROR Unknown option: 'ignore-workspace-root-check'
|
||
// 且**退出即失败** ⇒ 装配整条链断在 pnpm 这一步,而症状只是"插件没装上"。
|
||
const dir = mkdtempSync(join(tmpdir(), 'pa-args-'))
|
||
try {
|
||
// 有 workspace 文件 ⇒ 就是 workspace 根:`-w` 必须带(放行 workspace root 只能靠它)
|
||
writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - .\n')
|
||
const withWs = pnpmInstallArgs(dir)
|
||
assert.ok(
|
||
!withWs.includes('--ignore-workspace-root-check'),
|
||
'⛔ 不得出现裸写的 --ignore-workspace-root-check(pnpm 9 不认)',
|
||
)
|
||
assert.ok(withWs.includes('-w'), 'profile 是 workspace 根 ⇒ 必须带 -w 显式放行')
|
||
assert.equal(withWs[0], 'install', '子命令必须是 install')
|
||
// ⛔ 命令行不重复传 auto-install-peers:dsh 插件的 @deepseek-ai/dsh-client-* 是 optional peer,
|
||
// 公网 registry 没有 ⇒ 自动装 peer 必失败;该开关由 profile 内 workspace 文件承载。
|
||
assert.ok(
|
||
!withWs.some((a) => String(a).startsWith('--config.auto-install-peers')),
|
||
'⛔ 别在命令行传 auto-install-peers(由 profile 的 workspace 文件承载)',
|
||
)
|
||
|
||
// 🔴 **没有** workspace 文件 ⇒ ⛔ 绝不能带 `-w`:pnpm 9 会报
|
||
// `ERROR --workspace-root may only be used inside a workspace` 并立即退出。
|
||
// 这正是 47 上 admin 实例的真实形态(平台自己铺的 profile 没有 pnpm-workspace.yaml)
|
||
// ⇒ 旧写法(常量里写死 `-w`)让门户「启停插件」整条链断在 pnpm 这一步。
|
||
rmSync(join(dir, 'pnpm-workspace.yaml'))
|
||
assert.equal(isWorkspaceRoot(dir), false, '没有 pnpm-workspace.yaml ⇒ 不是 workspace 根')
|
||
assert.deepEqual(workspaceRootFlag(dir), [], '⛔ 非 workspace 根不得带 -w')
|
||
assert.ok(
|
||
!pnpmInstallArgs(dir).includes('-w'),
|
||
'⛔ 非 workspace 根的 profile 不得带 -w(pnpm 9 直接报错退出)',
|
||
)
|
||
assert.equal(pnpmInstallArgs(dir)[0], 'install', '子命令必须是 install')
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
/* ─────────── ⑪ 静默 reporter 的失败取证(第 20 棒)─────────── */
|
||
|
||
test('S3: 失败取证 —— `--reporter silent` 会被换成 `default` 重跑(否则原因拿不到)', () => {
|
||
// 第 19 棒真机:12 s 内连续 4 次 `exit 255`,而 `--reporter silent` 把原因**全吞掉**
|
||
// (`stderr=- stdout=-`)⇒ 错误对象里只剩命令与退出码。本组把"诊断重跑"的参数形态钉住。
|
||
const src = ['install', '-w', '--reporter', 'silent']
|
||
assert.deepEqual(
|
||
pnpmDiagArgs(src),
|
||
['install', '-w', '--reporter', 'default'],
|
||
'silent ⇒ default,其余参数逐字保留、顺序不变',
|
||
)
|
||
assert.deepEqual(src, ['install', '-w', '--reporter', 'silent'], '⛔ 不许就地改写调用方数组(失败报错仍用原参数)')
|
||
// 原本没写 reporter ⇒ 补一个:不同 pnpm 版本的默认 reporter 不同,不补则证据不可比。
|
||
assert.deepEqual(pnpmDiagArgs(['remove', 'pkg']), ['remove', 'pkg', '--reporter', 'default'])
|
||
// 已写别的 reporter ⇒ 同样换成 default(诊断要的是"最多信息",不是"沿用原档位")
|
||
assert.deepEqual(pnpmDiagArgs(['install', '--reporter', 'append-only']), ['install', '--reporter', 'default'])
|
||
})
|
||
|
||
test('S3: 失败取证 —— 诊断读数进 message(跨机回传后只剩 message 可读)', () => {
|
||
const err = pnpmExecError(
|
||
{ status: 255, stderr: '', stdout: '' },
|
||
['install', '--reporter', 'silent'],
|
||
'/var/lib/dshs/users/x/home/profiles/web',
|
||
114801,
|
||
'诊断重跑(--reporter default)exit=255 stderr=ERR_PNPM_UNEXPECTED_STORE stdout=-',
|
||
)
|
||
assert.ok(err.message.includes('ERR_PNPM_UNEXPECTED_STORE'), '诊断重跑拿到的真原因必须进 message')
|
||
assert.ok(err.message.includes('exit=255'), '退出码必须在 message 里')
|
||
assert.ok(String(err.pnpmDiag).includes('重跑'), '结构化字段 pnpmDiag 同步挂上(同机可直接取)')
|
||
// ⛔ 不改既有语义:不传 diag ⇒ 与旧版逐字相同(旧断言仍绿,这里钉下界)。
|
||
const plain = pnpmExecError({ status: 1, stderr: 'boom' }, ['install'], '/x', 7)
|
||
assert.equal(plain.pnpmDiag, '')
|
||
assert.ok(!plain.message.includes('诊断重跑'), '无诊断时 ⛔ 不许凭空多一句话')
|
||
})
|
||
|
||
/* ─────────── ⑫ 越权软链的临时摘除(2026-09-25 · 第 20 棒 · 真机 exit 255 的真根因) ─────────── */
|
||
//
|
||
// 真机原文(47 · admin profile,复刻平台调用、只换 reporter):
|
||
// `EPERM EPERM: operation not permitted, chmod '…/profiles/web/node_modules/xlsx/bin/xlsx.njs'`
|
||
// `at async linkBin (pnpm.cjs) ← installInContext`
|
||
// 机制 = 顶层那条**未声明**的软链解到共享层,而共享层文件属主是 **root** ⇒ 用户侧 pnpm
|
||
// 在 `linkBin` 里对它无脑 `chmod` ⇒ 非属主 EPERM(是 EPERM 不是 EACCES ⇒ 与 mode 无关)。
|
||
// 本组钉住"三条摘除判据 + finally 放回 + ⛔ 不覆盖"。
|
||
|
||
test('越权软链:只摘「顶层 ∧ 未声明 ∧ 落在共享层」的软链,其余一律不碰', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-quar-'))
|
||
try {
|
||
const shared = join(root, 'bundled-plugins')
|
||
const sharedPkg = join(shared, 'dsh-plugin-mcn-suite')
|
||
mkdirSync(join(sharedPkg, 'node_modules', 'xlsx', 'bin'), { recursive: true })
|
||
writeFileSync(join(sharedPkg, 'node_modules', 'xlsx', 'bin', 'xlsx.njs'), '#!/usr/bin/env node\n')
|
||
|
||
const profile = join(root, 'home', 'profiles', 'web')
|
||
const nm = join(profile, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
// 声明集合(与平台同一份口径:`dependencies` ∪ `dsh.profile.bundles`)
|
||
const declared = new Set(['dsh-plugin-mcn-suite'])
|
||
writeFileSync(
|
||
join(profile, 'package.json'),
|
||
JSON.stringify({
|
||
dependencies: { 'dsh-plugin-mcn-suite': `link:${sharedPkg}` },
|
||
dsh: { profile: { bundles: ['dsh-plugin-mcn-suite'] } },
|
||
}),
|
||
)
|
||
|
||
if (!tryLink(sharedPkg, join(nm, 'dsh-plugin-mcn-suite'))) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
// 真凶:**未声明**、指向共享层,且中间隔一层 fallback(与真机同形)
|
||
const fb = join(profile, '.dsh-module-fallback', 'node_modules')
|
||
mkdirSync(fb, { recursive: true })
|
||
tryLink(join(sharedPkg, 'node_modules', 'xlsx'), join(fb, 'xlsx'))
|
||
tryLink(join(fb, 'xlsx'), join(nm, 'xlsx'))
|
||
// 干扰项:真目录 / 指向 profile 内(不落共享层)的软链 / 悬空软链
|
||
mkdirSync(join(nm, 'realpkg'), { recursive: true })
|
||
mkdirSync(join(profile, 'local-target'), { recursive: true })
|
||
tryLink(join(profile, 'local-target'), join(nm, 'locallink'))
|
||
tryLink(join(root, 'nope'), join(nm, 'dangling'))
|
||
|
||
const q = quarantineSharedLayerLinks(profile, shared, declared)
|
||
assert.deepEqual(q.map((x) => x.name), ['xlsx'], '只摘真凶一条(声明内 / 真目录 / 非共享层 / 悬空链都不摘)')
|
||
assert.ok(!existsSync(join(nm, 'xlsx')), '摘除后原路径必须不在(pnpm 才看不见它)')
|
||
assert.ok(existsSync(join(nm, '.dsh-quarantine-xlsx')), '摘除 = 改名(⛔ 不是删除)')
|
||
assert.ok(lstatSync(join(nm, 'dsh-plugin-mcn-suite')).isSymbolicLink(), '⛔ 声明内的 link: 一律不碰')
|
||
assert.ok(lstatSync(join(nm, 'realpkg')).isDirectory(), '⛔ 真目录一律不碰')
|
||
assert.ok(existsSync(join(nm, 'locallink')), '⛔ 不落共享层的软链不碰')
|
||
assert.ok(lstatSync(join(nm, 'dangling')).isSymbolicLink(), '⛔ 悬空链不碰(解析不了 ⇒ 不猜)')
|
||
|
||
// 放回:目标逐字还原、隐藏项消失、重复调用幂等
|
||
const kept = readlinkSync(join(nm, '.dsh-quarantine-xlsx'))
|
||
assert.deepEqual(restoreQuarantinedLinks(q), [], '放回必须全部成功')
|
||
assert.ok(!existsSync(join(nm, '.dsh-quarantine-xlsx')), '放回后 ⛔ 不许留下隐藏项')
|
||
assert.equal(readlinkSync(join(nm, 'xlsx')), kept, '放回后的链接目标逐字不变')
|
||
|
||
// 可重复:判据没变 ⇒ 再摘仍只摘这一条;再放回仍逐字还原(摘/放是一对,不累积副作用)
|
||
const q2 = quarantineSharedLayerLinks(profile, shared, declared)
|
||
assert.deepEqual(q2.map((x) => x.name), ['xlsx'], '再摘同一份清单(不重复摘、不重复计数)')
|
||
assert.deepEqual(restoreQuarantinedLinks(q2), [], '第二轮放回同样全部成功')
|
||
assert.equal(readlinkSync(join(nm, 'xlsx')), kept, '两轮之后链接仍逐字不变')
|
||
assert.ok(!existsSync(join(nm, '.dsh-quarantine-xlsx')), '⛔ 两轮之后不许留隐藏项')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('越权软链:放回时原名被占 ⇒ 跳过并记名(⛔ 不覆盖 pnpm 刚装的东西,⛔ 不丢隐藏项)', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-quar2-'))
|
||
try {
|
||
const nm = join(root, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
mkdirSync(join(root, 'local-target'), { recursive: true })
|
||
if (!tryLink(join(root, 'local-target'), join(nm, '.dsh-quarantine-xlsx'))) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
// pnpm 跑完后原名位置上出现了别的东西(真目录)⇒ 不许被覆盖
|
||
mkdirSync(join(nm, 'xlsx'), { recursive: true })
|
||
const failed = restoreQuarantinedLinks([{ name: 'xlsx', linkPath: join(nm, 'xlsx'), hidden: join(nm, '.dsh-quarantine-xlsx') }])
|
||
assert.deepEqual(failed, ['xlsx'], '放不回的要**具名**返回(调用方据此落日志)')
|
||
assert.ok(lstatSync(join(nm, 'xlsx')).isDirectory(), '占位项一个字不动')
|
||
assert.ok(existsSync(join(nm, '.dsh-quarantine-xlsx')), '⛔ 隐藏项不许被丢掉(丢掉 = 用户盘上少一个包)')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('越权软链:applyProfileChanges 跑完 pnpm ⇒ 越权软链仍在盘上(finally 放回,⛔ 不留隐藏态)', (t) => {
|
||
process.env.DSHS_TEST_NO_PNPM = '1'
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-quar3-'))
|
||
try {
|
||
const userRoot = root
|
||
const profile = join(userRoot, 'home', 'profiles', 'web')
|
||
const nm = join(profile, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
mkdirSync(join(userRoot, 'ws'), { recursive: true })
|
||
|
||
const sharedRoot = join(root, 'bundled-plugins')
|
||
const declDir = join(sharedRoot, '_dsh-local_decl')
|
||
const mcnDir = join(sharedRoot, 'dsh-plugin-mcn-suite')
|
||
mkdirSync(declDir, { recursive: true })
|
||
mkdirSync(join(mcnDir, 'node_modules', 'xlsx'), { recursive: true })
|
||
writeFileSync(join(declDir, 'package.json'), '{"name":"@dsh-local/decl"}')
|
||
writeFileSync(join(mcnDir, 'package.json'), '{"name":"dsh-plugin-mcn-suite"}')
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(sharedRoot, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
writeFileSync(
|
||
join(profile, 'package.json'),
|
||
JSON.stringify({ dependencies: { 'dsh-plugin-mcn-suite': `link:${mcnDir}` }, dsh: { profile: { bundles: [] } } }),
|
||
)
|
||
tryLink(mcnDir, join(nm, 'dsh-plugin-mcn-suite'))
|
||
// 未声明的越权软链(真凶)
|
||
tryLink(join(mcnDir, 'node_modules', 'xlsx'), join(nm, 'xlsx'))
|
||
|
||
const r = applyProfileChanges([{ id: '@dsh-local/decl', enabled: true, src: 'bundled' }], {
|
||
userRoot,
|
||
bundledPluginDir: sharedRoot,
|
||
resolveBundleDir: (id) => join(sharedRoot, sharedDirNameOf(id)),
|
||
})
|
||
assert.deepEqual(r.enabled, ['@dsh-local/decl'], '启用项要真的落盘(否则本用例没走到 pnpm 那一步)')
|
||
assert.ok(lstatSync(join(nm, 'xlsx')).isSymbolicLink(), '⛔ apply 之后必须把越权软链放回(不许把用户盘留在隐藏态)')
|
||
assert.ok(!existsSync(join(nm, '.dsh-quarantine-xlsx')), '⛔ 不许留隐藏项')
|
||
} finally {
|
||
delete process.env.DSHS_TEST_NO_PNPM
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('S3: pnpm 子进程失败 ⇒ 错误对象带 stdout/stderr 证据(⛔ 不许只剩"安装失败"四个字)', () => {
|
||
// 第 16 棒真机教训:`applyPlugins` 失败时原因**一个字都不落日志**,两次复现都只能看到
|
||
// 用户侧的"安装失败,请重试或联系管理员" ⇒ 根因无从定位。本断言把"带证据"钉成契约。
|
||
const err = pnpmExecError(
|
||
{ status: 1, stderr: Buffer.from('ERROR --workspace-root may only be used inside a workspace\n'), stdout: Buffer.from('') },
|
||
['remove', 'dsh-plugin-mcn-suite', '-w', '--reporter', 'silent'],
|
||
'/tmp/somewhere',
|
||
114801,
|
||
)
|
||
assert.equal(err.code, 'pnpm_failed')
|
||
assert.ok(err.message.includes('--workspace-root may only be used inside a workspace'), '原因必须进 message(跨机后只剩 message 可读)')
|
||
assert.ok(err.message.includes('remove dsh-plugin-mcn-suite'), '命令必须进 message')
|
||
assert.ok(String(err.pnpmStderr).includes('may only be used inside a workspace'), 'stderr 必须挂成结构化字段')
|
||
assert.equal(err.pnpmCwd, '/tmp/somewhere')
|
||
// 超时要单独可辨(上层据此判 timeout 而不是 failed)
|
||
const to = pnpmExecError({ code: 'ETIMEDOUT', killed: true, stderr: '' }, ['install'], '/tmp/x', 1)
|
||
assert.equal(to.code, 'pnpm_timeout')
|
||
})
|
||
|
||
/* ─────────── ⑦ 原子性:缺失包 ⇒ 抛错且 profile 原样不动 ─────────── */
|
||
|
||
test('S3: 有包不在共享层 ⇒ applyProfileChanges 抛 plugin_bundle_missing 且 package.json 未被改动', () => {
|
||
// 这条防的是**半装状态**:若先写依赖引用再跑 pnpm,失败时 package.json 里会留下
|
||
// 悬挂引用而实际未安装 ⇒ 下次实例冷启动直接崩,用户却看到"装过了"。
|
||
// 判据:抛错 + 磁盘上的 package.json 与调用前**逐字节相同**。
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-atomic-'))
|
||
try {
|
||
const userRoot = root
|
||
mkdirSync(join(userRoot, 'home', 'profiles', 'web'), { recursive: true })
|
||
mkdirSync(join(userRoot, 'ws'), { recursive: true })
|
||
const profile = join(userRoot, 'home', 'profiles', 'web')
|
||
const before = {
|
||
name: 'dsh-profile-web',
|
||
private: true,
|
||
dependencies: { 'keep-me': 'file:/somewhere/keep-me' },
|
||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'], patchReload: 'live' } },
|
||
}
|
||
const beforeText = JSON.stringify(before, null, 2) + '\n'
|
||
writeFileSync(join(profile, 'package.json'), beforeText)
|
||
|
||
const sharedRoot = join(root, 'shared')
|
||
const present = join(sharedRoot, 'present-plugin')
|
||
mkdirSync(present, { recursive: true })
|
||
writeFileSync(join(present, 'package.json'), '{"name":"present-plugin","version":"1.0.0"}')
|
||
|
||
assert.throws(
|
||
() => applyProfileChanges(
|
||
[
|
||
{ id: 'present-plugin', enabled: true, src: 'bundled' },
|
||
{ id: 'ghost-plugin', enabled: true, src: 'bundled' },
|
||
],
|
||
{
|
||
userRoot,
|
||
bundledPluginDir: sharedRoot,
|
||
resolveBundleDir: (id) => {
|
||
const d = join(sharedRoot, id)
|
||
return existsSync(join(d, 'package.json')) ? d : undefined
|
||
},
|
||
},
|
||
),
|
||
(e) => e && e.code === 'plugin_bundle_missing',
|
||
'缺失包必须抛带 code=plugin_bundle_missing 的错(⛔ 不静默跳过)',
|
||
)
|
||
|
||
// 🔴 核心断言:一个字节都不能变 —— 连"好的那个包"也不许被写进去
|
||
assert.equal(
|
||
readFileSync(join(profile, 'package.json'), 'utf8'),
|
||
beforeText,
|
||
'⛔ 有任一包缺失时 package.json 必须原样不动(否则留下半装状态)',
|
||
)
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
/* ─────────── ⑧ 装配协议 = `link:`(2026-09-23 拍板,推翻原 D-h 的 `file:`)─────────── */
|
||
|
||
test('S3: 装配协议必须是 link: 且指向共享层 —— ⛔ 不得回到 file:(pnpm 9 会整份复制 6.5 MiB/用户/包)', () => {
|
||
// 真机实测(第 6 棒 · 106 · pnpm 9.15.9):
|
||
// `file:`(目录型)⇒ 插件包被**整份复制**进该用户 `.pnpm/`(6.5 MiB、116 文件、
|
||
// `links=1`、inode 与源不同)⇒ 不满足 D2「⛔ 不给每个用户复制一份」,用户数一涨磁盘线性膨胀。
|
||
// `link:` ⇒ 纯软链直达共享层(≈ 4.0 KiB),且**抗 reconcile**(加依赖 / `remove` 后软链仍在,
|
||
// 即 D-h 当初选 `file:` 的唯一理由依然成立)。
|
||
const target = '/var/lib/dshs/bundled-plugins/_dsh-local_storyforge'
|
||
assert.equal(
|
||
depRefOf(target),
|
||
`link:${target}`,
|
||
'⛔ 协议必须是 link: —— 回到 file: 会让每个用户各复制一份实体(D2 不成立)',
|
||
)
|
||
|
||
// 写进 profile 的**真实**依赖值也必须带 link: 前缀(防"只在工具函数里改了、调用点漏改")
|
||
//
|
||
// ⚠️ `DSHS_TEST_NO_PNPM=1` = 允许跳过 `setpriv`(Linux 专有;本机 Windows 上必 ENOENT)。
|
||
// 但**写 package.json 的那段照常跑** ⇒ 断言测的是真产物,不是桩。
|
||
// ⛔ 在 Linux 真机上该开关**不生效**(`pnpmExecDisabled` 会探测到 setpriv 存在)⇒ 生产不可能被绕过。
|
||
process.env.DSHS_TEST_NO_PNPM = '1'
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-ref-'))
|
||
try {
|
||
const userRoot = root
|
||
const profile = join(userRoot, 'home', 'profiles', 'web')
|
||
mkdirSync(profile, { recursive: true })
|
||
mkdirSync(join(userRoot, 'ws'), { recursive: true })
|
||
writeFileSync(join(profile, 'package.json'), '{"name":"p","private":true}\n')
|
||
|
||
const sharedRoot = join(root, 'bundled-plugins')
|
||
const pkgDir = join(sharedRoot, '_dsh-local_probe')
|
||
mkdirSync(pkgDir, { recursive: true })
|
||
writeFileSync(join(pkgDir, 'package.json'), '{"name":"@dsh-local/probe"}')
|
||
|
||
applyProfileChanges([{ id: '@dsh-local/probe', enabled: true, src: 'bundled' }], {
|
||
userRoot,
|
||
bundledPluginDir: sharedRoot,
|
||
resolveBundleDir: (id) =>
|
||
existsSync(join(sharedRoot, sharedDirNameOf(id), 'package.json'))
|
||
? join(sharedRoot, sharedDirNameOf(id))
|
||
: undefined,
|
||
})
|
||
|
||
const written = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
|
||
assert.equal(
|
||
written.dependencies['@dsh-local/probe'],
|
||
`link:${pkgDir}`,
|
||
'profile 里写死的依赖值必须是 link: 前缀',
|
||
)
|
||
assert.ok(
|
||
!String(written.dependencies['@dsh-local/probe']).startsWith('file:'),
|
||
'⛔ 不得写成 file:(会触发 pnpm 整份复制)',
|
||
)
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
delete process.env.DSHS_TEST_NO_PNPM
|
||
}
|
||
})
|
||
|
||
|
||
/* ─────────── ⑦ 共享层自足性:补 dsh 宿主包软链(2026-09-25 · 第 18 棒) ─────────── */
|
||
//
|
||
// 判据来源:第 16/17 棒真机实证 —— 共享层里零个 `@deepseek-ai` ⇒ 实例加载插件报
|
||
// `ERR_MODULE_NOT_FOUND: @deepseek-ai/dsh-llm` ⇒ crash-restart ⇒ 插件被自动禁用。
|
||
// 本组用例钉住三件会**静默退化**的事:
|
||
// ① 缺链时**真的补上**,且指向**本机** scope 目录(⛔ 不是 Manager 算好发下来的绝对路径
|
||
// —— 47 在 `/usr/local/lib`,106 在 `/usr/lib`,跨机就是悬空链);
|
||
// ② 幂等(对准了不动)+ 悬空/指错**要改回来** + **真目录⛔ 不碰**(删了不可逆);
|
||
// ③ ⛔ 不能把软链放到共享层根(那会让节点对账面永远多出 `extra`、判据"对账收敛"失效)
|
||
// —— 故所有用例都断言链接落在 `<flat>/node_modules/` 之下。
|
||
|
||
/** 建软链;本机不允许(无特权 Windows)⇒ 返回 false,用例自行 skip。 */
|
||
function tryLink(target, path) {
|
||
try {
|
||
symlinkSync(target, path, 'dir')
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
test('共享层自足性:缺链 ⇒ 补上 <flat>/node_modules/@deepseek-ai(指向本机 scope 目录)', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-scope-'))
|
||
try {
|
||
const flat = join(root, 'bundled-plugins', 'dsh-plugin-mcn-suite')
|
||
mkdirSync(join(flat, 'lib'), { recursive: true })
|
||
writeFileSync(join(flat, 'package.json'), '{"name":"dsh-plugin-mcn-suite"}')
|
||
const scope = join(root, 'dsh-root', 'node_modules', '@deepseek-ai')
|
||
mkdirSync(scope, { recursive: true })
|
||
|
||
// 先探一次能不能建链(Windows 无特权会 EPERM)
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(scope, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
const r = ensureSharedScopeLink(flat, scope)
|
||
assert.equal(r.action, 'linked')
|
||
const link = join(flat, 'node_modules', '@deepseek-ai')
|
||
assert.ok(lstatSync(link).isSymbolicLink(), '必须落到 <flat>/node_modules/@deepseek-ai')
|
||
assert.equal(readlinkSync(link), scope, '目标必须是本机 scope 目录(绝对路径)')
|
||
assert.ok(existsSync(link), '链必须能解开(悬空链等于没补)')
|
||
|
||
// 幂等:再跑一次 ⇒ kept,且一个字节都没动
|
||
const before = lstatSync(link).mtimeMs
|
||
const again = ensureSharedScopeLink(flat, scope)
|
||
assert.equal(again.action, 'kept')
|
||
assert.equal(lstatSync(link).mtimeMs, before, 'kept ⇒ ⛔ 不许重建')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('共享层自足性:悬空/指错 ⇒ 换成本机的;真目录占位 ⇒ ⛔ 不碰', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-scope2-'))
|
||
try {
|
||
const flat = join(root, 'bundled-plugins', 'flat-a')
|
||
mkdirSync(join(flat, 'node_modules'), { recursive: true })
|
||
const scope = join(root, 'dsh-root', 'node_modules', '@deepseek-ai')
|
||
mkdirSync(scope, { recursive: true })
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(scope, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
// ① 悬空链(模拟"Manager 生成的绝对目标在本机不存在")
|
||
const link = join(flat, 'node_modules', '@deepseek-ai')
|
||
symlinkSync(join(root, 'nowhere', '@deepseek-ai'), link, 'dir')
|
||
assert.ok(!existsSync(link), '前置:这条链确实是悬空的')
|
||
assert.equal(ensureSharedScopeLink(flat, scope).action, 'relinked')
|
||
assert.equal(readlinkSync(link), scope)
|
||
assert.ok(existsSync(link), '换过之后必须能解开')
|
||
|
||
// ② 真目录占位(包自带 @deepseek-ai)⇒ 不碰
|
||
rmSync(link, { force: true })
|
||
mkdirSync(link, { recursive: true })
|
||
writeFileSync(join(link, 'keep.txt'), 'x')
|
||
assert.equal(ensureSharedScopeLink(flat, scope).action, 'skipped_real_dir')
|
||
assert.ok(existsSync(join(link, 'keep.txt')), '⛔ 真目录一个字节都不许动')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('共享层自足性:解析不到本机 scope ⇒ no_scope,一个文件都不碰', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-scope3-'))
|
||
try {
|
||
const flat = join(root, 'bundled-plugins', 'flat-a')
|
||
mkdirSync(flat, { recursive: true })
|
||
|
||
const r = ensureSharedScopeLink(flat, null)
|
||
assert.equal(r.action, 'no_scope')
|
||
assert.equal(r.target, null)
|
||
assert.ok(!existsSync(join(flat, 'node_modules')), '⛔ 不许留下空 node_modules')
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('共享层自足性:全量补(含早先铺好的包),跳过 . 开头的旁挂物', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-heal-'))
|
||
const saved = { DSH_PACKAGE_DIR: process.env.DSH_PACKAGE_DIR, DSHS_DSH_BIN: process.env.DSHS_DSH_BIN }
|
||
try {
|
||
const sharedRoot = join(root, 'bundled-plugins')
|
||
for (const flat of ['dsh-plugin-mcn-suite', '_dsh-local_storyforge']) {
|
||
mkdirSync(join(sharedRoot, flat), { recursive: true })
|
||
}
|
||
// . 开头的旁挂物(.store / .manifest.json 的目录位)不是包目录 ⇒ 不许被当成包
|
||
mkdirSync(join(sharedRoot, '.store'), { recursive: true })
|
||
const fakeRoot = join(root, 'dsh-root')
|
||
const scope = join(fakeRoot, 'node_modules', '@deepseek-ai')
|
||
mkdirSync(scope, { recursive: true })
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(scope, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
// 通过 env 指定"本机 dsh 包根"⇒ heal 内部现解析(与真机同一条路径)
|
||
process.env.DSH_PACKAGE_DIR = fakeRoot
|
||
resetDshPackageRootCache()
|
||
|
||
const rep = healSharedLayerSelfSufficiency(sharedRoot)
|
||
assert.equal(rep.scope, scope, 'scope 必须是 <DSH_PACKAGE_DIR>/node_modules/@deepseek-ai')
|
||
assert.deepEqual(rep.linked, ['_dsh-local_storyforge', 'dsh-plugin-mcn-suite'], '两个包都要补(不止刚铺的那个)')
|
||
assert.deepEqual(rep.skipped, [], '.store 这类旁挂物⛔ 不许被当成包目录')
|
||
assert.deepEqual(rep.failed, [])
|
||
for (const flat of ['dsh-plugin-mcn-suite', '_dsh-local_storyforge']) {
|
||
assert.ok(existsSync(join(sharedRoot, flat, 'node_modules', '@deepseek-ai')), '链要能解开:' + flat)
|
||
}
|
||
assert.ok(!existsSync(join(sharedRoot, 'node_modules')), '⛔ 链接不许落在共享层根(会让节点对账永远多出 extra)')
|
||
|
||
// 第二轮必须收敛成 kept(幂等)
|
||
const rep2 = healSharedLayerSelfSufficiency(sharedRoot)
|
||
assert.deepEqual(rep2.linked, [])
|
||
assert.deepEqual(rep2.relinked, [])
|
||
assert.equal(rep2.kept.length, 2)
|
||
} finally {
|
||
if (saved.DSH_PACKAGE_DIR === undefined) delete process.env.DSH_PACKAGE_DIR
|
||
else process.env.DSH_PACKAGE_DIR = saved.DSH_PACKAGE_DIR
|
||
if (saved.DSHS_DSH_BIN === undefined) delete process.env.DSHS_DSH_BIN
|
||
else process.env.DSHS_DSH_BIN = saved.DSHS_DSH_BIN
|
||
resetDshPackageRootCache()
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('dsh 安装位置解析:只有一份实现(① 层 dsh-install 与 ③ 层同源、缓存一并清)', async () => {
|
||
const saved = { DSH_PACKAGE_DIR: process.env.DSH_PACKAGE_DIR }
|
||
try {
|
||
const install = await import('../lib/web/dsh-install.js')
|
||
process.env.DSH_PACKAGE_DIR = '/first-root'
|
||
resetDshPackageRootCache()
|
||
install.resetInstallPathsCache()
|
||
assert.equal(resolveDshPackageRoot(), '/first-root')
|
||
assert.equal(install.dshPackageRoot(), '/first-root', '① 层必须复用 ③ 层的结果')
|
||
assert.equal(install.dshScopeDir(), join('/first-root', 'node_modules', '@deepseek-ai'))
|
||
|
||
// 🔴 漂移点:①层的 reset 必须同时清掉 ③ 层的缓存,否则 env 改了不生效(verify-dsh-install 第 4 组用例)
|
||
process.env.DSH_PACKAGE_DIR = '/second-root'
|
||
assert.equal(install.dshPackageRoot(), '/first-root', '前置:不 reset 就该是旧值(缓存生效)')
|
||
install.resetInstallPathsCache()
|
||
assert.equal(install.dshPackageRoot(), '/second-root', 'resetInstallPathsCache 必须连带清 ③ 层缓存')
|
||
} finally {
|
||
if (saved.DSH_PACKAGE_DIR === undefined) delete process.env.DSH_PACKAGE_DIR
|
||
else process.env.DSH_PACKAGE_DIR = saved.DSH_PACKAGE_DIR
|
||
resetDshPackageRootCache()
|
||
}
|
||
})
|
||
|
||
/* ─────────── ⑧ 回滚不留残影(2026-09-25 · 第 18 棒 · P1) ─────────── */
|
||
|
||
test('回滚:restoreProfile 清掉无主软链,⛔ 不动声明内 / 真目录 / bundles / . 旁挂物', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-rb-'))
|
||
try {
|
||
const shared = join(root, 'bundled-plugins')
|
||
const profile = join(root, 'home', 'profiles', 'web')
|
||
const nm = join(profile, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
mkdirSync(shared, { recursive: true }) // 链的目标要真存在(否则 existsSync 会因为悬空而骗人)
|
||
const scope = join(root, 'dsh-root')
|
||
mkdirSync(scope, { recursive: true })
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(scope, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
// 装配前:依赖声明里已有 mcn-suite(+ bundles 里有一个只出现在 bundles 的包)
|
||
writeFileSync(
|
||
join(profile, 'package.json'),
|
||
JSON.stringify({
|
||
dependencies: { 'dsh-plugin-mcn-suite': 'link:/x/dsh-plugin-mcn-suite' },
|
||
dsh: { profile: { bundles: ['@dsh-local/bundle-only'] } },
|
||
}),
|
||
)
|
||
const snap = snapshotProfile(profile)
|
||
assert.ok(snap['package.json'] !== null)
|
||
|
||
// 装配之后盘上的样子(含本次留下的残影)
|
||
symlinkSync(shared, join(nm, 'dsh-plugin-mcn-suite'), 'dir') // 声明内 ⇒ 留
|
||
mkdirSync(join(nm, '@dsh-local'), { recursive: true })
|
||
symlinkSync(shared, join(nm, '@dsh-local', 'bundle-only'), 'dir') // bundles 内 ⇒ 留
|
||
symlinkSync(shared, join(nm, 'dsh-plugin-orphan'), 'dir') // 无主 ⇒ 删
|
||
symlinkSync(shared, join(nm, '@dsh-local', 'ghost'), 'dir') // 无主(scoped)⇒ 删
|
||
mkdirSync(join(nm, 'real-dir-pkg'), { recursive: true }) // 真目录 ⇒ 留
|
||
mkdirSync(join(nm, '.pnpm'), { recursive: true }) // pnpm 内部结构 ⇒ 留
|
||
|
||
const removed = pruneOrphanDepLinks(profile, snap)
|
||
assert.deepEqual(removed.sort(), ['@dsh-local/ghost', 'dsh-plugin-orphan'])
|
||
assert.ok(existsSync(join(nm, 'dsh-plugin-mcn-suite')), '声明内的依赖⛔ 不许删')
|
||
assert.ok(existsSync(join(nm, '@dsh-local', 'bundle-only')), 'bundles 里的包⛔ 不许删')
|
||
assert.ok(existsSync(join(nm, 'real-dir-pkg')), '真目录⛔ 一律不碰')
|
||
assert.ok(existsSync(join(nm, '.pnpm')), '`.` 开头的旁挂物⛔ 不碰')
|
||
|
||
// restoreProfile 是同一个判据的入口(回滚路径只走它)
|
||
symlinkSync(shared, join(nm, 'dsh-plugin-orphan'), 'dir')
|
||
restoreProfile(profile, snap)
|
||
assert.ok(!existsSync(join(nm, 'dsh-plugin-orphan')), 'restoreProfile 必须清掉残影')
|
||
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), JSON.parse(snap['package.json']))
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
/* ─────────── ⑨ 回滚的「缺」方向:重建缺失的 link: 软链(2026-09-25 · 第 19 棒 · P0) ─────────── */
|
||
|
||
/**
|
||
* 第 18 棒真机实证:`off` 撞 pnpm 偶发失败后,`package.json` 的声明**还在**、
|
||
* `node_modules/<pkg>` 软链**已被删** ⇒ 实例崩 `cannot resolve profile bundle` ×5
|
||
* → `crash-loop-circuit-open`。旧 `restoreProfile` 只治"多",治不了这个"缺"。
|
||
*/
|
||
test('回滚:restoreProfile 重建缺失的 link: 软链,⛔ 不碰已存在 / 真目录 / 非 link: / 悬空目标', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-rb2-'))
|
||
try {
|
||
const shared = join(root, 'bundled-plugins')
|
||
const profile = join(root, 'home', 'profiles', 'web')
|
||
const nm = join(profile, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
for (const d of ['dsh-plugin-mcn-suite', '_dsh-local_storyforge', '_dsh-local_im-conversation-tabs']) {
|
||
mkdirSync(join(shared, d), { recursive: true })
|
||
}
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(shared, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
const mcnTarget = join(shared, 'dsh-plugin-mcn-suite')
|
||
const sfTarget = join(shared, '_dsh-local_storyforge')
|
||
const imTarget = join(shared, '_dsh-local_im-conversation-tabs')
|
||
writeFileSync(
|
||
join(profile, 'package.json'),
|
||
JSON.stringify({
|
||
dependencies: {
|
||
'dsh-plugin-mcn-suite': 'link:' + mcnTarget, // 缺 ⇒ 重建
|
||
'@dsh-local/storyforge': 'link:' + sfTarget, // 缺(scoped)⇒ 重建 + 造出 @dsh-local/
|
||
'@dsh-local/im-conversation-tabs': 'link:' + imTarget, // 已在 ⇒ kept
|
||
'dsh-plugin-real-dir': 'link:' + join(shared, 'dsh-plugin-real-dir'), // 盘上是真目录 ⇒ 不碰
|
||
'dsh-plugin-file-ref': 'file:/nowhere/dsh-plugin-file-ref.tgz', // 非 link: ⇒ 一律不动
|
||
'dsh-plugin-dangling': 'link:' + join(root, 'no-such-dir'), // 目标不存在 ⇒ ⛔ 不建悬空链
|
||
},
|
||
dsh: { profile: { bundles: [] } },
|
||
}),
|
||
)
|
||
const snap = snapshotProfile(profile)
|
||
|
||
// 装配之后的盘上样子:「已在」那条留着;真目录也留着(⛔ 分不清是 pnpm 摊平还是用户自己的)
|
||
mkdirSync(join(nm, '@dsh-local'), { recursive: true })
|
||
symlinkSync(imTarget, join(nm, '@dsh-local', 'im-conversation-tabs'), 'dir')
|
||
mkdirSync(join(nm, 'dsh-plugin-real-dir'), { recursive: true })
|
||
|
||
// 判据 ① 缺链重建(含 scoped 自动造父目录)
|
||
const rebuilt = rebuildMissingDepLinks(profile)
|
||
assert.deepEqual(rebuilt.sort(), ['@dsh-local/storyforge', 'dsh-plugin-mcn-suite'])
|
||
assert.equal(readlinkSync(join(nm, 'dsh-plugin-mcn-suite')), mcnTarget, '重建的链必须指向声明里的目标')
|
||
assert.equal(readlinkSync(join(nm, '@dsh-local', 'storyforge')), sfTarget, 'scoped 包要连带造出 @dsh-local/')
|
||
// 判据 ② 已存在 ⇒ 不重建(返回值里没有它)
|
||
assert.ok(!rebuilt.includes('@dsh-local/im-conversation-tabs'))
|
||
// 判据 ③ 真目录 / 非 link: / 悬空目标 ⇒ 一个都不动
|
||
assert.ok(lstatSync(join(nm, 'dsh-plugin-real-dir')).isDirectory(), '真目录⛔ 一律不碰')
|
||
assert.ok(!existsSync(join(nm, 'dsh-plugin-file-ref')), '非 link: 声明⛔ 不许建链')
|
||
assert.ok(!existsSync(join(nm, 'dsh-plugin-dangling')), '目标不存在⇒⛔ 不许建悬空链')
|
||
// 幂等:再跑一次 = 空操作(已正确 ⇒ kept)
|
||
assert.deepEqual(rebuildMissingDepLinks(profile), [])
|
||
|
||
// 判据 ④ 回滚路径的唯一入口 = restoreProfile(两个方向一起收敛)
|
||
rmSync(join(nm, 'dsh-plugin-mcn-suite'), { force: true })
|
||
restoreProfile(profile, snap)
|
||
assert.equal(readlinkSync(join(nm, 'dsh-plugin-mcn-suite')), mcnTarget, 'restoreProfile 必须补回缺的链')
|
||
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), JSON.parse(snap['package.json']))
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('回滚:node_modules 不存在 / 无 link: 声明 ⇒ 直接跳过(⛔ 不造半安装态)', (t) => {
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-rb3-'))
|
||
try {
|
||
const shared = join(root, 'bundled-plugins')
|
||
mkdirSync(join(shared, 'p1'), { recursive: true })
|
||
const profile = join(root, 'home', 'profiles', 'web')
|
||
mkdirSync(profile, { recursive: true })
|
||
|
||
// ① 没有 node_modules ⇒ 跳过(那是"从没装过",该走 pnpm install)
|
||
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { p1: 'link:' + join(shared, 'p1') } }))
|
||
assert.deepEqual(rebuildMissingDepLinks(profile), [])
|
||
assert.ok(!existsSync(join(profile, 'node_modules')), '⛔ 不许凭空造出 node_modules')
|
||
|
||
// ② 有 node_modules 但声明里没有 link: ⇒ 空操作
|
||
mkdirSync(join(profile, 'node_modules'), { recursive: true })
|
||
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { p1: 'file:/x/p1.tgz' } }))
|
||
assert.deepEqual(rebuildMissingDepLinks(profile), [])
|
||
assert.deepEqual([...linkRefsOf(profile).keys()], [])
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
/* ─────────── ⑩ 基础插件「默认开启 + 用户不可关闭」(2026-09-25 · 第 19 棒 · §5-15) ─────────── */
|
||
|
||
/**
|
||
* 口径 = 用户 2026-09-25 09:3x 原话「这些 dsh 自带的基础插件 应该是 默认开启无法关闭的」。
|
||
*
|
||
* 本组钉的是**落盘那端**(真正改盘的地方,集群形态下在 worker 上跑):
|
||
* 路由侧的 409 只管住门户这条路径;把 `essential` 随装配项发过来,这里必须**结构性**拒绝关闭
|
||
* —— 否则"关不掉"只是"按钮藏起来了",绕过 UI 照样能关。
|
||
*/
|
||
test('基础插件:落盘那端拒绝关闭(状态一个字不动);普通插件照旧可关;缺链顺手补回', (t) => {
|
||
process.env.DSHS_TEST_NO_PNPM = '1'
|
||
const root = mkdtempSync(join(tmpdir(), 'pa-ess-'))
|
||
try {
|
||
const userRoot = root
|
||
const profile = join(userRoot, 'home', 'profiles', 'web')
|
||
const nm = join(profile, 'node_modules')
|
||
mkdirSync(nm, { recursive: true })
|
||
mkdirSync(join(userRoot, 'ws'), { recursive: true })
|
||
|
||
const sharedRoot = join(root, 'bundled-plugins')
|
||
const essDir = join(sharedRoot, '_dsh-local_ess')
|
||
const normDir = join(sharedRoot, '_dsh-local_norm')
|
||
mkdirSync(essDir, { recursive: true })
|
||
mkdirSync(normDir, { recursive: true })
|
||
|
||
const canary = join(root, 'canary')
|
||
if (!tryLink(sharedRoot, canary)) {
|
||
t.skip('symlink creation unavailable on this host')
|
||
return
|
||
}
|
||
rmSync(canary, { force: true })
|
||
|
||
// 前置状态:两个插件**都已经装上**(声明在 + bundles 在)
|
||
writeFileSync(
|
||
join(profile, 'package.json'),
|
||
JSON.stringify({
|
||
dependencies: { '@dsh-local/ess': `link:${essDir}`, '@dsh-local/norm': `link:${normDir}` },
|
||
dsh: { profile: { bundles: ['@dsh-local/ess', '@dsh-local/norm'] } },
|
||
}),
|
||
)
|
||
const opts = {
|
||
userRoot,
|
||
bundledPluginDir: sharedRoot,
|
||
resolveBundleDir: (id) => join(sharedRoot, sharedDirNameOf(id)),
|
||
}
|
||
|
||
// ① 基础插件:请求"关" ⇒ **被拒**,且盘上一个字都没动
|
||
const r1 = applyProfileChanges([{ id: '@dsh-local/ess', enabled: false, src: 'bundled', essential: true }], opts)
|
||
assert.deepEqual(r1.refused, [{ id: '@dsh-local/ess', reason: 'essential_plugin' }], '必须回**具名**拒绝理由')
|
||
assert.deepEqual(r1.disabled, [], '被拒的项⛔ 不许出现在 disabled 里(那等于宣称"关掉了")')
|
||
const after1 = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
|
||
assert.equal(after1.dependencies['@dsh-local/ess'], `link:${essDir}`, '被拒 ⇒ 依赖声明⛔ 不许删')
|
||
assert.ok(after1.dsh.profile.bundles.includes('@dsh-local/ess'), '被拒 ⇒ bundles⛔ 不许摘')
|
||
|
||
// ② 普通插件(不在基础插件名单里)⇒ 走的是**"能关"的那条路**(`disabled` 里有它)
|
||
// ⇒ 证明门禁是"点名"的,⛔ 不是一刀切。
|
||
// ⚠️ 这里**不**拿"磁盘上删没删"当判据:本机测试用 `DSHS_TEST_NO_PNPM=1` ⇒ pnpm 不跑,
|
||
// 而 `package.json` 里那一行的删除**是 pnpm remove 自己落盘的**(平台侧只改内存里的
|
||
// `deps` 快照)。磁盘级判据属**真机验收**,⛔ 不在单测里假装。
|
||
const r2 = applyProfileChanges([{ id: '@dsh-local/norm', enabled: false, src: 'bundled' }], opts)
|
||
assert.deepEqual(r2.disabled, ['@dsh-local/norm'], '普通插件的关闭必须被**放行**')
|
||
assert.deepEqual(r2.refused, [], '普通插件⛔ 不许被基础插件门禁拦住')
|
||
|
||
// ③ 第 1 件的"缺"方向也要在装配路径上收敛:ess 声明还在、链此前没建 ⇒ 本次顺手补回
|
||
assert.ok(r1.repaired.includes('@dsh-local/ess'), '装配路径必须顺手补回缺失的 link: 软链')
|
||
assert.equal(readlinkSync(join(nm, '@dsh-local', 'ess')), essDir)
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true })
|
||
delete process.env.DSHS_TEST_NO_PNPM
|
||
}
|
||
})
|
||
|
||
test('基础插件:种子文案与拒绝码**同名不重复**(UI/路由/装配三处引用同一个来源)', async () => {
|
||
const { ESSENTIAL_REFUSED_REASON } = await import('../lib/supervisor/plugin-assembly.js')
|
||
const { ESSENTIAL_DENY_CODE, essentialDenyMessage } = await import('../lib/web/essential-plugins.js')
|
||
assert.equal(ESSENTIAL_DENY_CODE, ESSENTIAL_REFUSED_REASON, '门户 409 的 error 码必须与装配层 reason 逐字一致')
|
||
assert.equal(ESSENTIAL_DENY_CODE, 'essential_plugin')
|
||
const msg = essentialDenyMessage('@softspark/dsh-file-preview')
|
||
assert.ok(msg.includes('@softspark/dsh-file-preview'), '拒绝文案必须**具名**(否则用户不知道是哪个插件)')
|
||
assert.ok(msg.includes('不可关闭'), '拒绝文案必须说清"为什么"')
|
||
})
|