Files

403 lines
18 KiB
JavaScript
Raw Permalink Normal View History

/**
* 插件投放与分库线 ① · **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
}
})