313 lines
16 KiB
JavaScript
313 lines
16 KiB
JavaScript
// 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
|
|||
|
|
}
|
|||
|
|
})
|