// IM 插件跨进程桥回归测试(node:test)—— 2026-09-25「插件接入面 第 2 棒」。 // // 跑的是**构建产物** `lib/`(`npm test` 先 build);插件侧 SDK 直接 import 源文件(纯 ESM)。 // // 覆盖面:① 注册表按实例隔离 ② room_type 全局唯一(跨实例冲突具名拒)③ 出向(节流 / fail-closed / // 身份不可借 / bot 成员校验)④ 插件侧 SDK(剔函数 / 登记 / 出向头 / 未配置 / 增量订阅)。 // // ⚠️ 本文件**不碰真实网络**(SDK 侧用 mock fetch;注册表侧用内存桩)⇒ 判据可复现。 import { test } from 'node:test' import assert from 'node:assert/strict' import { ImPluginBridgeRegistry } from '../lib/im/sdk/registry.js' import { createImPluginHost, sanitizeManifest, HOST_SDK_VERSION } from '../sdk/im-plugin-host/index.mjs' const CIRCUIT = { threshold: 3, cooldownMs: 1000 } /** 一个最小可登记清单(声明面:⛔ 不含函数)。 */ function decl(extra = {}) { return { pluginId: 'im_plugin_demo', label: '示例', packageName: '@dsh-local/im-plugin-demo', version: '0.1.0', roomTypes: [{ type: 'demo', label: '示例房', transport: 'chat', timeoutMs: 50, circuit: CIRCUIT }], bots: [{ botId: 'bot_demo', label: 'demo', timeoutMs: 50, circuit: CIRCUIT }], outbounds: [{ outboundId: 'demo_out', label: '出向', timeoutMs: 50, circuit: CIRCUIT }], ...extra, } } /** 内存出口桩:记下每次帧 / 落库,供断言。 */ function makeSink() { const frames = [] const writes = [] return { frames, writes, sink: { frame: ({ roomId, text }) => { frames.push({ roomId, text }) return { delivered: 2, skipped: 0 } }, message: async (input) => { writes.push(input) return { id: `m_${writes.length}`, seq: writes.length, inserted: true } }, }, } } /* ── ① 注册表:按实例隔离 + 房间类型唯一 ────────────────────────────────── */ test('桥 registry 按实例隔离:各自一份宿主,互相看不到对方的插件', () => { const reg = new ImPluginBridgeRegistry() const a = reg.register('u:alice', decl()) assert.equal(a.ok, true) assert.deepEqual(a.value.claimedRoomTypes, ['demo']) assert.equal(reg.hostFor('u:alice')?.has('im_plugin_demo'), true) // 另一个实例**什么都没登记** ⇒ 拿不到宿主 assert.equal(reg.hostFor('u:bob'), undefined) assert.equal(reg.pluginsOf('u:bob').length, 0) assert.deepEqual(reg.snapshot(), { instances: 1, plugins: 1, roomTypes: ['demo'] }) }) test('桥 registry room_type 全局唯一:别的实例占用同名 ⇒ 该条具名拒,其余条目照常生效', () => { const reg = new ImPluginBridgeRegistry() assert.equal(reg.register('u:alice', decl()).ok, true) // bob 用同一个 room_type(跨实例)⇒ 那一条被拒;但他的插件本身仍登记上(⛔ 不整单回滚) const bob = reg.register('u:bob', decl()) assert.equal(bob.ok, true) assert.equal(bob.value.rejected.length, 1) assert.match(bob.value.rejected[0].reason, /已被实例「u:alice」/) assert.deepEqual(bob.value.claimedRoomTypes, []) // 归属不变(仍是 alice 的) assert.deepEqual(reg.ownerOfRoomType('demo'), { instanceKey: 'u:alice', pluginId: 'im_plugin_demo' }) // 同实例同插件重复登记 ⇒ 幂等(自己占自己的,不算冲突) const again = reg.register('u:alice', decl()) assert.equal(again.value.rejected.length, 0) assert.deepEqual(again.value.claimedRoomTypes, ['demo']) }) test('桥 registry 形状闸:pluginId 与包名不自洽 ⇒ 在**占用房间类型之前**就失败(不留残登记)', () => { const reg = new ImPluginBridgeRegistry() const bad = reg.register('u:alice', { ...decl(), pluginId: 'someone_else' }) assert.equal(bad.ok, false) assert.equal(bad.reason, 'bad-args') // 🔴 关键:没有留下任何占用(否则"半登记"会让后来者永远占不到) assert.equal(reg.ownerOfRoomType('demo'), undefined) assert.equal(reg.hostFor('u:alice'), undefined) assert.deepEqual(reg.snapshot(), { instances: 0, plugins: 0, roomTypes: [] }) }) test('桥 registry 注销实例:清掉它的插件与房间类型占用,别实例不受影响', () => { const reg = new ImPluginBridgeRegistry() reg.register('u:alice', decl()) reg.register('u:bob', decl({ pluginId: 'im_plugin_b', packageName: '@dsh-local/im-plugin-b', roomTypes: [{ type: 'other', label: 'x', timeoutMs: 50, circuit: CIRCUIT }] })) const removed = reg.unregisterInstance('u:alice') assert.deepEqual(removed, ['im_plugin_demo']) assert.equal(reg.hostFor('u:alice'), undefined) assert.equal(reg.ownerOfRoomType('demo'), undefined) // bob 不受影响 assert.equal(reg.ownerOfRoomType('other')?.instanceKey, 'u:bob') assert.deepEqual(reg.snapshot(), { instances: 1, plugins: 1, roomTypes: ['other'] }) }) /* ── ② 出向:fail-closed / 节流 / 身份不可借 ────────────────────────────── */ test('桥 出向 未登记插件 ⇒ plugin-unavailable(⛔ 不返回"永远失败"的对象骗插件)', () => { const reg = new ImPluginBridgeRegistry() assert.equal(reg.outboundPortFor('u:alice', 'im_plugin_demo'), undefined) reg.register('u:alice', decl()) // 登记了但问**别的**插件 id ⇒ 同样 undefined(按实例 + pluginId 双键隔离) assert.equal(reg.outboundPortFor('u:alice', 'im_plugin_other'), undefined) assert.ok(reg.outboundPortFor('u:alice', 'im_plugin_demo')) }) test('桥 出向帧:走注入的扇出端口;归属判不了 ⇒ 默认 fail-closed 拒(声明了 roomTypes 时)', async () => { const { frames, sink } = makeSink() // ① 不注入 findRoom ⇒ 声明了 roomTypes 的出口拒 const strict = new ImPluginBridgeRegistry({ outbound: sink }) strict.register('u:alice', decl({ outbounds: [{ outboundId: 'o', label: 'x', roomTypes: ['demo'], timeoutMs: 50, circuit: CIRCUIT }] })) const denied = await strict.outboundPortFor('u:alice', 'im_plugin_demo').frame({ roomId: 'imr_1', channel: 'doc', data: {} }) assert.equal(denied.reason, 'outbound-denied') assert.equal(frames.length, 0) // ② 注入 findRoom 且类型对得上 ⇒ 放行 const okReg = new ImPluginBridgeRegistry({ outbound: sink, findRoom: async () => ({ roomType: 'demo' }) }) okReg.register('u:alice', decl()) const sent = await okReg.outboundPortFor('u:alice', 'im_plugin_demo').frame({ roomId: 'imr_1', channel: 'doc', data: { v: 1 } }) assert.equal(sent.ok, true) assert.equal(sent.value.delivered, 2) assert.equal(frames.length, 1) assert.match(frames[0].text, /"op":"frame"/) }) test('桥 出向帧节流:内核给硬上限,窗口内第二帧 throttled(合并语义归插件)', async () => { let t = 1000 const { frames, sink } = makeSink() const reg = new ImPluginBridgeRegistry({ outbound: sink, findRoom: async () => ({ roomType: 'demo' }), now: () => t, frameMinIntervalMs: 100 }) reg.register('u:alice', decl()) const port = reg.outboundPortFor('u:alice', 'im_plugin_demo') assert.equal((await port.frame({ roomId: 'imr_1', channel: 'c', data: {} })).ok, true) assert.equal((await port.frame({ roomId: 'imr_1', channel: 'c', data: {} })).reason, 'throttled') assert.equal(frames.length, 1) t += 100 assert.equal((await port.frame({ roomId: 'imr_1', channel: 'c', data: {} })).ok, true) assert.equal(frames.length, 2) }) test('桥 出向发言:bot **必须是该房成员**(fail-closed);身份不可借(别人的 bot 拒)', async () => { const { writes, sink } = makeSink() const reg = new ImPluginBridgeRegistry({ outbound: sink, findRoom: async () => ({ roomType: 'demo' }), isMember: async (_roomId, memberId) => memberId === 'bot_demo', }) reg.register('u:alice', decl()) const port = reg.outboundPortFor('u:alice', 'im_plugin_demo') // ① bot 不在房里 ⇒ 拒(越权写出) assert.equal((await reg.authorizeOutbound('u:alice', 'imr_1', 'bot_outsider')).reason, 'outbound-denied') // ② 本插件的 bot 且是成员 ⇒ 放行 assert.equal((await reg.authorizeOutbound('u:alice', 'imr_1', 'bot_demo')).ok, true) // ③ 借别人的 bot 身份 ⇒ 宿主侧拒(身份不可借) const stolen = await port.message({ roomId: 'imr_1', botId: 'bot_other', payload: { kind: 'text' } }) assert.equal(stolen.reason, 'outbound-denied') assert.equal(writes.length, 0) // ④ 正常发言 const own = await port.message({ roomId: 'imr_1', botId: 'bot_demo', payload: { kind: 'text' } }) assert.equal(own.ok, true) assert.equal(writes[0].authorKind, 'bot') assert.equal(writes[0].via, 'api') }) test('桥 出向发言:**未注入成员判定** ⇒ fail-closed 拒(⛔ 不"判不了就放行")', async () => { const reg = new ImPluginBridgeRegistry({ outbound: makeSink().sink, findRoom: async () => ({ roomType: 'demo' }) }) reg.register('u:alice', decl()) const res = await reg.authorizeOutbound('u:alice', 'imr_1', 'bot_demo') assert.equal(res.ok, false) assert.equal(res.reason, 'outbound-denied') }) /* ── ③ 插件侧 SDK ──────────────────────────────────────────────────────── */ test('SDK sanitizeManifest:剔除**函数**字段,其余声明原样保留(⛔ 不整条丢)', () => { const raw = { pluginId: 'im_plugin_demo', packageName: '@dsh-local/im-plugin-demo', version: '0.1.0', payloads: [{ kind: 'ping', validate: () => true, requires: ['text'], timeoutMs: 50, circuit: CIRCUIT }], panels: [{ panelId: 'p', label: 'x', slot: 's', render: () => ({}), onAction: () => ({}), timeoutMs: 50, circuit: CIRCUIT }], events: [{ events: ['message.created'], onEvent: () => {}, timeoutMs: 50, circuit: CIRCUIT }], speakRules: [{ rule: 'free', check: () => ({ ok: true, reason: 'ok' }), timeoutMs: 50, circuit: CIRCUIT }], } const clean = sanitizeManifest(raw) assert.equal(clean.payloads[0].validate, undefined) assert.deepEqual(clean.payloads[0].requires, ['text']) assert.equal(clean.panels[0].render, undefined) assert.equal(clean.panels[0].onAction, undefined) assert.equal(clean.panels[0].panelId, 'p') assert.equal(clean.events[0].onEvent, undefined) assert.deepEqual(clean.events[0].events, ['message.created']) assert.equal(clean.speakRules[0].check, undefined) assert.equal(clean.speakRules[0].rule, 'free') // 序列化后必须**不含函数**(跨进程的前提) assert.doesNotMatch(JSON.stringify(clean), /function/) }) test('SDK 未配置环境 ⇒ 具名 not-configured(⛔ 不抛、不静默)', async () => { const host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: decl(), baseUrl: '', token: '' }) const res = await host.ready() assert.equal(res.ok, false) assert.equal(res.reason, 'not-configured') assert.equal(host.registered, false) // ⚠️ 版本随 SDK 能力演进:1.1.0 = 新增跨进程反向回调(onSpeakRule / onEvent)。 // 向后兼容判据 = 不注册回调时行为与 1.0.0 逐字相同(见 im-plugin-bridge-callback.test.mjs ⑨)。 assert.equal(HOST_SDK_VERSION, '1.1.0') }) test('SDK 登记 + 出向:带实例凭据与插件身份头;登记失败 ⇒ registered=false 但仍可调(回具名结论)', async () => { const calls = [] const realFetch = globalThis.fetch globalThis.fetch = async (url, init) => { const body = init?.body === undefined ? undefined : JSON.parse(init.body) calls.push({ url: String(url), headers: init.headers, method: init.method, body }) if (String(url).endsWith('/api/im/plugins/register')) { return new Response(JSON.stringify({ ok: true, reason: 'ok', result: { instanceKey: 'u:alice' } }), { status: 200 }) } if (String(url).endsWith('/out/frame')) { return new Response(JSON.stringify({ ok: true, reason: 'ok', result: { delivered: 3, throttled: false } }), { status: 200 }) } return new Response(JSON.stringify({ ok: false, reason: 'plugin-unavailable' }), { status: 200 }) } try { const host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: decl(), baseUrl: 'http://127.0.0.1:3080/', token: 'tok_abc', }) const reg = await host.ready() assert.equal(reg.ok, true) assert.equal(host.registered, true) assert.equal(calls[0].url, 'http://127.0.0.1:3080/api/im/plugins/register') assert.equal(calls[0].headers['x-dsh-im-plugin-id'], '@dsh-local/im-plugin-demo') assert.equal(calls[0].headers['x-dsh-im-instance-token'], 'tok_abc') // manifest 里若混了函数,SDK 必须先剔掉(⛔ 不发出去) assert.equal(calls[0].body.speakRules, undefined) const fr = await host.frame({ roomId: 'imr_1', channel: 'doc', data: { v: 1 } }) assert.equal(fr.ok, true) assert.equal(fr.result.delivered, 3) // 出向失败(未登记出口)⇒ 原样透传具名结论 const msg = await host.say({ roomId: 'imr_1', botId: 'bot_demo', payload: {} }) assert.equal(msg.reason, 'plugin-unavailable') assert.equal(calls.length, 3) } finally { globalThis.fetch = realFetch } }) test('SDK 增量订阅:只把**新增**交给 handler(游标推进),停表后不再拉', async () => { const realFetch = globalThis.fetch let n = 0 globalThis.fetch = async (url) => { const u = String(url) n += 1 const since = new URL(u).searchParams.get('since') if (u.includes('/messages')) { const list = since === null ? [{ id: 'm1', seq: 1 }, { id: 'm2', seq: 2 }] : [{ id: `m${n}`, seq: 3 + n }] return new Response(JSON.stringify({ messages: list, count: list.length }), { status: 200 }) } return new Response(JSON.stringify({ ok: true }), { status: 200 }) } try { const host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: {}, baseUrl: 'http://x', token: 't' }) const got = [] const stop = host.subscribe('imr_1', (m) => got.push(m.id), { intervalMs: 300 }) await new Promise((r) => setTimeout(r, 60)) assert.deepEqual(got.slice(0, 2), ['m1', 'm2']) stop() const seen = got.length await new Promise((r) => setTimeout(r, 60)) assert.equal(got.length, seen, '停表后不该再拉(定时器必须真被清掉)') } finally { globalThis.fetch = realFetch } }) test('SDK 请求失败可分辨:网络错 / 非 JSON / HTTP 非 2xx 各自**具名**(⛔ 不混成一个失败)', async () => { const realFetch = globalThis.fetch try { globalThis.fetch = async () => { throw new Error('connect ECONNREFUSED') } let host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: {}, baseUrl: 'http://x', token: 't' }) assert.equal((await host.ready()).reason, 'network') globalThis.fetch = async () => new Response('oops', { status: 200, headers: { 'content-type': 'text/html' } }) host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: {}, baseUrl: 'http://x', token: 't' }) assert.equal((await host.ready()).reason, 'bad-response') globalThis.fetch = async () => new Response(JSON.stringify({ error: 'instance-token-rejected', detail: '过期' }), { status: 401 }) host = await createImPluginHost({ pluginId: '@dsh-local/im-plugin-demo', manifest: {}, baseUrl: 'http://x', token: 't' }) const r = await host.ready() assert.equal(r.reason, 'instance-token-rejected') assert.equal(r.detail, '过期') } finally { globalThis.fetch = realFetch } })