Files
dsh_shenxian/test/plugin-assembly.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

404 lines
18 KiB
JavaScript
Raw Permalink 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.
/**
* 插件投放与分库线 ① · **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,
PNPM_INSTALL_ARGS,
} 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' },
])
assert.deepEqual(fetchImpl.calls[0].body, {
userId: 'u-1',
items: [
{ id: '@dsh-local/trpg-kit', version: '1.2.3', enabled: true, src: 'bundled' },
{ id: 'mcn-suite', version: null, enabled: false, src: 'file' },
],
})
})
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 install 参数 ⛔ 不含裸写的 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 这一步,而症状只是"插件没装上"。
assert.ok(
!PNPM_INSTALL_ARGS.includes('--ignore-workspace-root-check'),
'⛔ 不得出现裸写的 --ignore-workspace-root-check(pnpm 9 不认)',
)
// 放行 workspace root 只能靠 `-w`(与 scripts/ensure-biz-plugins.cjs 的既有姿势逐字一致)
assert.ok(PNPM_INSTALL_ARGS.includes('-w'), '必须带 -w:profile 里有 pnpm-workspace.yaml ⇒ 需显式放行 workspace root')
assert.equal(PNPM_INSTALL_ARGS[0], 'install', '子命令必须是 install')
// ⛔ 命令行不重复传 auto-install-peers:dsh 插件的 @deepseek-ai/dsh-client-* 是 optional peer,
// 公网 registry 没有 ⇒ 自动装 peer 必失败;该开关由 profile 内 workspace 文件承载。
assert.ok(
!PNPM_INSTALL_ARGS.some((a) => String(a).startsWith('--config.auto-install-peers')),
'⛔ 别在命令行传 auto-install-peers(由 profile 的 workspace 文件承载)',
)
})
/* ─────────── ⑦ 原子性:缺失包 ⇒ 抛错且 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
}
})