Files
dsh_shenxian/test/im-plugin-bridge.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

314 lines
16 KiB
JavaScript
Raw 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.
// 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('<html>oops</html>', { 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
}
})