/** * 插件投放与分库线 ① · **S3 跨机装配**的回归测试。 * * ## 这个文件防的是什么(每一件都对应一条会静默失效的判据) * * 1. **`RemoteSpawner.applyPlugins` 真的发到"实例所在那台"** —— 与 `restartAndProbe` * 同一套 `hostIdFor` 路由。⛔ 若它回落到默认 host,就会把请求发给**另一台机** * (那台上没有这个用户的 profile ⇒ 落盘落到一个空目录、或给错人装插件), * 而症状只有"插件没生效",平台零日志。 * 2. **请求体形状与 agent 侧的入参校验对得上** —— `{userId, items:[{id,version,enabled,src}]}`。 * 两侧字段名漂一个字(如 `src` vs `source`)在 TS 里编译得过(一个 `Record`), * 但运行期是 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('univer-office@1.2.3'), '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/ 是指向共享层的软链,⛔ 不是实体副本', () => { 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 } })