// IM 反向通道(平台 → 插件)回调总线回归测试(node:test)—— 2026-09-26「IM 反向通道棒」。 // // 跑的是**构建产物** `lib/`(`npm test` 先 build);插件侧 SDK 直接 import 源文件(纯 ESM)。 // // 覆盖面(接续包 §4-3 点名的五项 + 三条具名拒绝): // ① 命中:插件回 deny ⇒ 写路径被拒(经 `attachImBridge` 复合生效) // ② 超时 ⇒ 按 onTimeout 放行 + `bridgeTimeouts` +1 // ③ `onTimeout:'deny'` ⇒ 超时即拒(回合制/限速型的落点) // ④ 队列满 ⇒ 丢最旧并计 `bridgeDropped`(⛔ 不无界增长) // ⑤ 计数(poll / settle / awaiting) // ⑥ 幂等:重复回投 ⇒ `unknown-call-id`;冒领 ⇒ `plugin-id-mismatch` // ⑦ 异步事件:afterAppend ⇒ 入队 ⇒ poll 取到 ⇒(私聊不派发) // ⑧ 复合后守卫不漏:只有实例内插件登记 ⇒ `hasPlugins()` 亦为真 // // ⚠️ 本文件**不碰真实网络**(总线是内存桩;注册表只做归属判定)⇒ 判据可复现。 // 真 HTTP 端到端在 `tmp/im-bridge-e2e-20260926.mjs`(那份才是"决定性的那一项")。 import { test } from 'node:test' import assert from 'node:assert/strict' import { createImPluginBinding } from '../lib/im/sdk/binding.js' import { ImPluginBridgeRegistry } from '../lib/im/sdk/registry.js' import { attachImBridge, bridgeEventTargets, bridgeSpeakTargets, ImBridgeCallbackBus, } from '../lib/im/sdk/callback-bus.js' 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 }], ...extra, } } /** 装一套"接线器 + 注册表 + 总线"(与 `routes/im.ts` 的装配顺序**逐字同序**)。 */ function makeWire({ busOptions = {}, manifest = decl(), instanceKey = 'u:1' } = {}) { const binding = createImPluginBinding() const registry = new ImPluginBridgeRegistry({ findRoom: () => ({ roomType: 'demo' }) }) const bus = new ImBridgeCallbackBus({ maxSpeakWaitMs: 300, ...busOptions }) attachImBridge(binding, { registry, bus }) const reg = registry.register(instanceKey, manifest) assert.equal(reg.ok, true, '前置:登记必须成功') bus.remember(instanceKey, manifest) return { binding, registry, bus, instanceKey } } /** 一条待判定的发言(形状与同进程 `SpeakRuleInput` 一致)。 */ function speakInput(text = 'hello') { return { roomId: 'imr_1', roomType: 'demo', config: {}, authorId: 'u_1', authorKind: 'human', envelope: { payload: { kind: 'text', text }, visibility: 'room', via: 'web' }, at: 1, } } function envelope(id = 'm1', visibility = 'room') { return { id, seq: 1, roomId: 'imr_1', authorId: 'u_1', authorKind: 'human', via: 'web', visibility, payload: { kind: 'text', text: 'hi' }, ts: 1, } } /** 模拟"实例内的长轮询 + 回投":取一批工作项,按 `decide` 决定怎么回。 */ async function serveOnce(instanceKey, bus, decide, { waitMs = 200, pluginId = 'im_plugin_demo' } = {}) { const res = await bus.poll(instanceKey, { waitMs }) const handled = [] for (const item of res.items) { const verdict = await decide(item) const settled = bus.settle(instanceKey, pluginId, { callId: item.callId, ok: verdict.ok, ...(verdict.reason === undefined ? {} : { reason: verdict.reason }), ...(verdict.detail === undefined ? {} : { detail: verdict.detail }), }) handled.push({ item, settled }) } return handled } test('① 命中:插件回 deny ⇒ 写路径被拒,且原因透传', async () => { const { binding, bus, instanceKey } = makeWire({ manifest: decl({ speakRules: [{ rule: 'turn-based', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }], }), }) const serving = serveOnce(instanceKey, bus, () => ({ ok: false, reason: 'outbound-denied', detail: '还没轮到你' })) const verdict = await binding.beforeWrite(speakInput()) await serving assert.equal(verdict.ok, false, '插件拒绝 ⇒ 写路径必须拒') assert.equal(verdict.detail, '还没轮到你', 'detail 必须原样透传给调用方(用户要看得懂)') assert.equal(verdict.reason, 'outbound-denied') }) test('② 超时 ⇒ 按 onTimeout 缺省 allow 放行,且 bridgeTimeouts +1(⛔ 不静默)', async () => { const { binding, bus } = makeWire({ manifest: decl({ speakRules: [{ rule: 'turn-based', roomTypes: ['demo'], timeoutMs: 30, circuit: CIRCUIT }], }), }) // 故意**没人**长轮询(= 实例不在线) const verdict = await binding.beforeWrite(speakInput()) assert.equal(verdict.ok, true, '实例不可达 ⇒ 放行(与同进程"插件故障 ⇒ 放行"逐字一致)') assert.equal(bus.stats().bridgeTimeouts, 1, '超时必须被计数') assert.equal(bus.stats().awaiting, 0, '超时后不许留下挂起的等待') }) test('③ onTimeout=deny ⇒ 超时即拒(回合制/限速型的落点)', async () => { const { binding, bus } = makeWire({ manifest: decl({ speakRules: [{ rule: 'rate-limited', roomTypes: ['demo'], timeoutMs: 30, circuit: CIRCUIT, onTimeout: 'deny' }], }), }) const verdict = await binding.beforeWrite(speakInput()) assert.equal(verdict.ok, false, 'onTimeout=deny ⇒ 超时必须拒(否则超时即破规则)') assert.equal(verdict.reason, 'timeout') assert.equal(bus.stats().bridgeTimeouts, 1) }) test('④ 队列满 ⇒ 丢最旧事件并计 bridgeDropped(有界,⛔ 不无界增长)', async () => { const { registry, bus, instanceKey } = makeWire({ busOptions: { eventQueueMax: 2 }, manifest: decl({ events: [{ events: ['message.created'], roomTypes: ['demo'], timeoutMs: 50, circuit: CIRCUIT }], }), }) const event = { name: 'message.created', roomId: 'imr_1', roomType: 'demo', at: 1, data: { seq: 1 } } for (const target of bridgeEventTargets(registry, bus, event)) { bus.deliverEvent(target, event) bus.deliverEvent(target, event) bus.deliverEvent(target, event) } assert.equal(bus.stats().bridgeDropped, 1, '第 3 条入队时队列已满 ⇒ 丢最旧并计数') const pulled = await bus.poll(instanceKey, { waitMs: 0 }) assert.equal(pulled.items.length, 2, '队列恒不超过上限') }) test('⑤ 计数:poll / settle / awaiting 都上观测面', async () => { const { binding, bus, instanceKey } = makeWire({ manifest: decl({ speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }], }), }) const serving = serveOnce(instanceKey, bus, () => ({ ok: true })) const verdict = await binding.beforeWrite(speakInput()) assert.equal(verdict.ok, true) const handled = await serving assert.equal(handled[0].settled.accepted, true, '回投必须被接受') const stats = bus.stats() assert.equal(stats.polls, 1) assert.equal(stats.settled, 1) assert.equal(stats.awaiting, 0) assert.equal(stats.bridgeTimeouts, 0) }) test('⑥ 幂等与冒领:重复回投 ⇒ unknown-call-id;别的插件 ⇒ plugin-id-mismatch', async () => { const { binding, bus, instanceKey } = makeWire({ manifest: decl({ speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }], }), }) const pending = binding.beforeWrite(speakInput()) const pulled = await bus.poll(instanceKey, { waitMs: 200 }) assert.equal(pulled.items.length, 1, '前置:同步项已入队') const callId = pulled.items[0].callId // 同实例、**别的**插件冒领 ⇒ 拒(防同实例内 A 插件替 B 插件作答) const stolen = bus.settle(instanceKey, 'im_plugin_other', { callId, ok: false, reason: 'outbound-denied' }) assert.equal(stolen.accepted, false) assert.equal(stolen.reason, 'plugin-id-mismatch') // 正主回投 ⇒ 接受 const good = bus.settle(instanceKey, 'im_plugin_demo', { callId, ok: true }) assert.equal(good.accepted, true) await pending // 已结算过的 callId 再投 ⇒ 幂等拒绝(⛔ 不重复 resolve) const again = bus.settle(instanceKey, 'im_plugin_demo', { callId, ok: true }) assert.equal(again.accepted, false) assert.equal(again.reason, 'unknown-call-id') }) test('⑦ 异步事件:afterAppend ⇒ 入队 ⇒ poll 取到;私聊不派发', async () => { const { binding, bus, instanceKey, registry } = makeWire({ manifest: decl({ events: [{ events: ['message.created'], roomTypes: ['demo'], timeoutMs: 50, circuit: CIRCUIT }], }), }) // 房间消息 ⇒ 派发 binding.afterAppend({ envelope: envelope('m1', 'room'), roomType: 'demo' }) const pulled = await bus.poll(instanceKey, { waitMs: 0 }) assert.equal(pulled.items.length, 1) assert.equal(pulled.items[0].kind, 'event.deliver') assert.equal(pulled.items[0].event.name, 'message.created') assert.equal(pulled.items[0].event.data.payload.text, 'hi') // 私聊 ⇒ **不出门**(越权内容不派发,与 binding.ts 纪律 ② 同源) binding.afterAppend({ envelope: envelope('m2', 'private'), roomType: 'demo' }) const none = await bus.poll(instanceKey, { waitMs: 0 }) assert.equal(none.items.length, 0, 'private 消息不许跨进程派发') assert.equal(bridgeEventTargets(registry, bus, pulled.items[0].event).length, 1) }) test('⑧ 复合后守卫不漏:只有实例内插件登记,hasPlugins() 亦为真', async () => { const { binding, registry, bus, instanceKey } = makeWire({ manifest: decl({ speakRules: [{ rule: 'free', roomTypes: ['demo'], timeoutMs: 200, circuit: CIRCUIT }], }), }) assert.equal(binding.host.hasPlugins(), true, '写路径的守卫必须被跨进程登记点亮,否则 speak.check 永不触发') assert.equal(bridgeSpeakTargets(registry, bus, 'demo').length, 1) assert.equal(bridgeSpeakTargets(registry, bus, 'nobody').length, 0, '没被占用的房型不许路由') // 注销 ⇒ 收摊(队列与登记副本一起清) registry.unregisterInstance(instanceKey) bus.forget(instanceKey) assert.equal(bridgeSpeakTargets(registry, bus, 'demo').length, 0) assert.equal(bus.stats().instances, 0) }) test('⑨ 未接线(无任何登记)⇒ 行为与改造前逐字相同:放行、无额外等待', async () => { const binding = createImPluginBinding() const registry = new ImPluginBridgeRegistry({}) const bus = new ImBridgeCallbackBus() attachImBridge(binding, { registry, bus }) assert.equal(binding.host.hasPlugins(), false, '零登记 ⇒ 守卫恒假(存量路径零开销)') const started = Date.now() const verdict = await binding.beforeWrite(speakInput()) assert.equal(verdict.ok, true, '无插件 ⇒ 自由并发') assert.ok(Date.now() - started < 50, '无插件时不许引入等待') assert.equal(bus.stats().polls, 0) assert.equal(bus.stats().bridgeTimeouts, 0) })