// IM 插件 SDK 回归测试(node:test)—— D 单 `交接单/IM群组-D-插件SDK与扩展点契约.md` §六 ∪ §六.1 ∪ §九.7。 // // 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒ // 「哪条验收对哪个用例」一眼可查,第三方可复现。 // // 覆盖面:七点齐备(判据1) · 内核零改动(判据2) · 不直连DB(判据3) · 故障隔离(判据4) · // 预算(判据5) · 两档传输(判据6) · 分发(判据7) · 失败模式齐备(§六.1-8) · // 预算由内核提供(§六.1-9) · payload=密文(§六.1-10) · // 零裸SQL/双后端/越权/房间ACL/迁移/卸载(§九.7 的 8-13)。 // // ⚠️ 本文件**不碰网络**:所有判据都断 `lib/im/sdk/*` 的纯逻辑 + 三个示例插件的清单形状, // 外加 §五-7 的「内核零改动」用 `git status --short` 原文取证。 // 数据面端口用**内存桩**(既证明「插件不直连 DB」,又让判据可复现)。 import { test } from 'node:test' import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { DEFAULT_CIRCUIT, EXTENSION_KEYS, EXTENSION_POINT_LABEL, ImCircuitBreaker, ImSdkHost, TRANSPORT_PROFILES, auditFailureModes, buildTableDdl, dataTableFullName, hasFailureMode, pluginIdOf, registeredRoomTypes, speakRuleFor, transportOf, } from '../lib/im/sdk/index.js' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO = join(HERE, '..') const PLUGIN_ROOT = join(REPO, 'poc/business-plugins-im') /** Windows 上 `import()` 必须用 file:// URL(⛔ 不能传 `D:\...` 裸路径)。 */ function pathToFileUrl(p) { return `file:///${p.replace(/\\/g, '/')}` } /** 动态载入三个示例插件(纯 JS,无 TS 类型标注 ⇒ 可被 Node 直接 import)。 */ async function loadPlugins() { const [office, mud, trpg] = await Promise.all( ['office-tasks', 'mud-rate', 'trpg-turn'].map((d) => import(pathToFileUrl(join(PLUGIN_ROOT, d, 'lib/index.js')))), ) return { office, mud, trpg } } /* ── 夹具 ────────────────────────────────────────────────────────────────── */ /** * 内存数据端口桩 —— 实现 `ImDataPort`。 * * 🔴 它同时是**判据 3 的证明手段**:插件只能通过这个接口读写字,⛔ 拿不到 DB 句柄。 * ACL 模拟 = 按 `room_id` 过滤(口径与内核 `canSee` 同向:非本房行不返回)。 * * ⚠️ **表全名归一**:内核端口会做 `p__` 归一(§九-2),本桩照做 —— * 否则「裸名 vs 全名」会被当成两张表,判据 11/13 会假绿。 */ function makeDataPort() { /** 裸名 → 全名(测试里同名表只在同一插件内出现,故可唯一映射)。 */ const aliases = new Map([ ['tasks', 'p_im_plugin_office_tasks'], ['mud_positions', 'p_im_plugin_mud_mud_positions'], ['trpg_checks', 'p_im_plugin_trpg_trpg_checks'], ]) const full = (t) => aliases.get(t) ?? t const tables = new Map() // fullName → rows[] let seq = 0 const rowsOf = (t) => { const key = full(t) if (!tables.has(key)) tables.set(key, []) return tables.get(key) } const visible = (row, filter) => { if (filter?.roomId !== undefined && row.room_id !== filter.roomId) return false if (filter?.userId !== undefined && row.user_id !== filter.userId) return false for (const [k, v] of Object.entries(filter?.eq ?? {})) if (row[k] !== v) return false return true } return { /** 观测面:某表行数(测试断「跨前缀读 ⇒ 0 行」用)。 */ _rows: (t) => rowsOf(t), _tables: tables, async insert(table, row) { const id = `r${++seq}` rowsOf(table).push({ ...row, id, created_at: seq, updated_at: seq }) return { ok: true, reason: 'ok', value: { id } } }, async update(table, id, patch) { const list = rowsOf(table) const row = list.find((r) => r.id === id) if (row === undefined) return { ok: false, reason: 'not-registered', detail: 'no such row' } Object.assign(row, patch) return { ok: true, reason: 'ok', value: { changed: 1 } } }, async remove(table, id) { const list = rowsOf(table) const i = list.findIndex((r) => r.id === id) if (i < 0) return { ok: true, reason: 'ok', value: { removed: 0 } } list.splice(i, 1) return { ok: true, reason: 'ok', value: { removed: 1 } } }, async find(table, filter = {}) { let list = rowsOf(table).filter((r) => visible(r, filter)) if (filter.limit !== undefined && filter.limit > 0) list = list.slice(0, filter.limit) return { ok: true, reason: 'ok', value: list } }, async count(table, filter = {}) { const n = rowsOf(table).filter((r) => visible(r, filter)).length return { ok: true, reason: 'ok', value: { n } } }, } } /** 预算端口桩 —— 实现 `ImBudgetPort`(真实实现由内核给,§六.1-9)。 */ function makeBudgetPort({ limit = 2 } = {}) { const used = new Map() const key = (i) => `${i.roomId}:${i.authorId}` /** 额度判据只在 `acquire` 里判一次 —— `release` 只减账,⛔ 不重复判(否则归还后反被拒)。 */ const allow = (n) => n <= limit return { _used: used, async acquire(input) { const k = key(input) const n = (used.get(k) ?? 0) + 1 used.set(k, n) if (!allow(n)) return { ok: false, reason: 'rate-limited', detail: `房间级预算已用满(${limit})` } return { ok: true, reason: 'ok' } }, release(input) { const k = key(input) used.set(k, Math.max(0, (used.get(k) ?? 0) - 1)) }, /** 一次成功后应归还(调用方未实际发言时)—— 让"归还后可再申请"成为可判据。 */ releaseAll(input) { used.set(key(input), 0) }, } } /** 造一个宿主(自带数据端口 + 假时钟)。 */ function makeHost(options = {}) { const data = options.data ?? makeDataPort() let now = options.now ?? 1_000_000 const host = new ImSdkHost({ now: () => now, data, budget: options.budget, approvedRoomTypes: options.approvedRoomTypes, onAudit: options.onAudit, }) return { host, data, /** 推进假时钟(毫秒)。 */ advance: (ms) => { now += ms }, /** 设为绝对时刻。 */ setNow: (v) => { now = v }, /** 观测面:当前时刻。 */ now: () => now, } } /** 造一条发言规则入参(形状 = SDK `SpeakRuleInput`)。 */ function speakInput(over = {}) { return { roomId: over.roomId ?? 'imr_1', roomType: over.roomType ?? 'mud', config: over.config ?? {}, authorId: over.authorId ?? 'u_alice', authorKind: over.authorKind ?? 'human', envelope: over.envelope ?? { payload: { text: 'hi' }, visibility: 'room', via: 'web' }, at: over.at ?? 1_000_000, } } /** * 把宿主的数据/预算端口以 `deps` 形式交给示例插件的 `onEvent`。 * 口径 = 宿主 `emit()` 注入的那一份(同一套端口,⛔ 不给插件第二条通路)。 */ function makeDeps(data) { return { insert: (table, row) => data.insert(table, row), find: (table, filter) => data.find(table, filter), count: (table, filter) => data.count(table, filter), } } /* ── §六 判据 1 · 七点齐备 ──────────────────────────────────────────────── */ test('判据1 七点齐备:EXTENSION_KEYS 恰为 142 §3.6 的七个扩展点,且每个都有中文名', () => { assert.equal(EXTENSION_KEYS.length, 7) assert.deepEqual( [...EXTENSION_KEYS], ['roomTypes', 'payloads', 'roles', 'speakRules', 'bots', 'events', 'panels'], ) for (const key of EXTENSION_KEYS) assert.equal(typeof EXTENSION_POINT_LABEL[key], 'string') }) test('判据1 七点齐备:三个示例插件合起来覆盖七个扩展点(缺一即不通过)', async () => { const { office, mud, trpg } = await loadPlugins() const all = [office.manifest, mud.manifest, trpg.manifest] for (const key of EXTENSION_KEYS) { const covering = all.filter((m) => (m[key] ?? []).length > 0) assert.ok(covering.length > 0, `扩展点「${key}」没有任何示例插件覆盖`) } }) test('判据1 七点齐备:三类场景各跑通一个 —— office / mud / trpg 三类型全部可注册', async () => { const { office, mud, trpg } = await loadPlugins() const h = makeHost() for (const p of [office, mud, trpg]) { const res = h.host.register(p.manifest) assert.equal(res.ok, true, `${p.manifest.pluginId} 注册失败:${res.detail}`) assert.deepEqual(res.value.rejected, []) } assert.deepEqual(registeredRoomTypes(h.host).sort(), ['mud', 'office', 'trpg']) }) /* ── §六 判据 2 / §五-7 · 内核零改动 ────────────────────────────────────── */ test('判据2 内核零改动:三个内核文件**相对本单**无改动(`git diff` 空;未被 track 的 sdk 新增不混算)', () => { const files = ['src/im/store.ts', 'src/im/hub.ts', 'src/web/routes/im.ts'] // ⚠️ 本单的三文件在入库前属「未跟踪」,`status --short` 会显示 `??` —— 那不是改动。 // 判据 = **内容差异**:tracked 的 diff 必须为空;未跟踪的按空文件比(原本就不存在)。 const out = execFileSync('git', ['status', '--short', '--untracked-files=no', ...files], { cwd: REPO, encoding: 'utf8', }) assert.equal(out.trim(), '', `内核文件出现内容改动:\n${out}`) for (const f of files) { const diff = execFileSync('git', ['diff', '--', f], { cwd: REPO, encoding: 'utf8' }) assert.equal(diff.trim(), '', `${f} 相对 HEAD 有 diff`) } }) test('判据2 内核零改动:本单在 src/im 下的新增只在 sdk/ 子目录(⛔ 没碰六个既有模块)', () => { const out = execFileSync('git', ['status', '--short', 'src/im'], { cwd: REPO, encoding: 'utf8' }) const lines = out.split('\n').filter((l) => l.trim() !== '') const touchedExisting = lines.filter((l) => !l.includes('src/im/sdk/') && !l.startsWith('??')) assert.deepEqual(touchedExisting, [], `src/im 下的既有模块被改了:\n${touchedExisting.join('\n')}`) // 六个既有模块逐个确认 diff 为空(比"没有 M 标记"更硬)。 for (const f of ['types.ts', 'db.ts', 'store.ts', 'clock.ts', 'hub.ts', 'ws.ts', 'agent-bridge.ts', 'instance-token.ts']) { const diff = execFileSync('git', ['diff', '--', `src/im/${f}`], { cwd: REPO, encoding: 'utf8' }) assert.equal(diff.trim(), '', `src/im/${f} 有 diff`) } }) /* ── §五 步 1 · envelope 与注册面 ──────────────────────────────────────── */ test('步1 未知 room_type 注册 ⇒ 拒绝(defaultConfigOf 返回 unknown-room-type)', () => { const h = makeHost() const res = h.host.defaultConfigOf('nope') assert.equal(res.ok, false) assert.equal(res.reason, 'unknown-room-type') }) test('步1 清单形状不合 ⇒ 整单拒绝(缺 packageName / pluginId 与包名不一致)', () => { const h = makeHost() assert.equal(h.host.register({ pluginId: '', label: '', packageName: '', version: '1' }).ok, false) const mismatched = h.host.register({ pluginId: 'wrong_id', label: 'x', packageName: '@dsh-local/im-plugin-office', version: '1', }) assert.equal(mismatched.ok, false) assert.match(String(mismatched.detail), /不一致/) }) test('步1 pluginIdOf:包名去 scope、小写、连字符转下划线(分库定稿 §六)', () => { assert.equal(pluginIdOf('@dsh-local/im-plugin-office'), 'im_plugin_office') assert.equal(pluginIdOf('im-plugin-mud'), 'im_plugin_mud') }) /* ── §五 步 2 · 房间类型注册 + 默认配置装载 ────────────────────────────── */ test('步2 注册 mud ⇒ 新房间自动带该类型默认(传输档 = 实时档)', async () => { const { mud } = await loadPlugins() const h = makeHost() h.host.register(mud.manifest) const cfg = h.host.defaultConfigOf('mud') assert.equal(cfg.ok, true) assert.equal(cfg.value.speakIntervalMs, mud.DEFAULT_SPEAK_INTERVAL_MS) assert.equal(cfg.value.allowCommands, true) assert.equal(transportOf(h.host, 'mud').tier, 'realtime') }) test('步2 房主显式 config 覆盖默认值(契约默认在前、调用方在后)', async () => { const { mud } = await loadPlugins() const h = makeHost() h.host.register(mud.manifest) const cfg = h.host.defaultConfigOf('mud', { speakIntervalMs: 100 }) assert.equal(cfg.value.speakIntervalMs, 100) }) test('步2 注销后新房间不再可选(registeredRoomTypes 不再含该类型)', async () => { const { mud } = await loadPlugins() const h = makeHost() h.host.register(mud.manifest) assert.deepEqual(registeredRoomTypes(h.host), ['mud']) assert.equal(h.host.unregister(mud.manifest.pluginId).ok, true) assert.deepEqual(registeredRoomTypes(h.host), []) assert.equal(h.host.defaultConfigOf('mud').reason, 'unknown-room-type') }) /* ── §四-5 · 扩展点开放边界(需 admin 审核) ───────────────────────────── */ test('§四-5 requiresReview 的类型未获审核 ⇒ 默认拒绝 + 进 pendingReview', () => { const h = makeHost() const res = h.host.register({ pluginId: 'p_x', label: 'x', packageName: '@dsh-local/p-x', version: '1', roomTypes: [ { type: 'community', label: '社区自建', requiresReview: true, timeoutMs: 10, circuit: DEFAULT_CIRCUIT }, ], }) assert.deepEqual(res.value.pendingReview, ['community']) assert.deepEqual(registeredRoomTypes(h.host), []) assert.equal(h.host.defaultConfigOf('community').reason, 'pending-review') }) test('§四-5 审核放行后同一类型即刻可选(approvedRoomTypes 生效)', () => { const h = makeHost({ approvedRoomTypes: ['community'] }) h.host.register({ pluginId: 'p_x', label: 'x', packageName: '@dsh-local/p-x', version: '1', roomTypes: [ { type: 'community', label: '社区自建', requiresReview: true, timeoutMs: 10, circuit: DEFAULT_CIRCUIT }, ], }) assert.deepEqual(registeredRoomTypes(h.host), ['community']) assert.deepEqual(h.host.pendingRoomTypes(), []) }) test('§四-5 同名 room_type 被两个插件注册 ⇒ 后注册者该条被拒(⛔ 不静默吞)', () => { const h = makeHost() h.host.register({ pluginId: 'p_a', label: 'a', packageName: '@dsh-local/p-a', version: '1', roomTypes: [{ type: 'dup', label: 'dup', timeoutMs: 10, circuit: DEFAULT_CIRCUIT }], }) const res = h.host.register({ pluginId: 'p_b', label: 'b', packageName: '@dsh-local/p-b', version: '1', roomTypes: [{ type: 'dup', label: 'dup', timeoutMs: 10, circuit: DEFAULT_CIRCUIT }], }) assert.equal(res.value.rejected.length, 1) assert.equal(res.value.rejected[0].key, 'roomTypes') assert.match(res.value.rejected[0].reason, /已被插件/) }) /* ── §五 步 3 · 发言规则(回合制 / 限速 / 回落) ────────────────────────── */ test('步3 回合制:非当前回合者发言 ⇒ 被拒且原因是可读文案', async () => { const { trpg } = await loadPlugins() const h = makeHost() h.host.register(trpg.manifest) const res = await h.host.checkSpeak(speakInput({ roomType: 'trpg', config: { turnOf: 'u_bob' }, authorId: 'u_alice' })) assert.equal(res.ok, false) assert.match(String(res.detail), /还没轮到/) assert.match(String(res.detail), /u_bob/) }) test('步3 回合制:当前回合者发言 ⇒ 放行;config.turnOf 为空 ⇒ 视为自由', async () => { const { trpg } = await loadPlugins() const h = makeHost() h.host.register(trpg.manifest) const mine = await h.host.checkSpeak(speakInput({ roomType: 'trpg', config: { turnOf: 'u_alice' }, authorId: 'u_alice' })) assert.equal(mine.ok, true) const open = await h.host.checkSpeak(speakInput({ roomType: 'trpg', config: {}, authorId: 'u_alice' })) assert.equal(open.ok, true) }) test('步3 回合制:bot / system 不参与回合(否则 agent 代答会把自己卡死)', async () => { const { trpg } = await loadPlugins() const h = makeHost() h.host.register(trpg.manifest) const res = await h.host.checkSpeak( speakInput({ roomType: 'trpg', config: { turnOf: 'u_bob' }, authorId: 'u_alice', authorKind: 'bot' }), ) assert.equal(res.ok, true) }) test('步3 MUD 限速:间隔未到 ⇒ 被拒且带读数;间隔已到 ⇒ 放行', async () => { const { mud } = await loadPlugins() const h = makeHost() h.host.register(mud.manifest) const at = 2_000_000 const tooSoon = await h.host.checkSpeak( speakInput({ roomType: 'mud', config: { speakIntervalMs: 500, __lastAt: at }, authorId: 'u_alice', at }), ) assert.equal(tooSoon.ok, false) assert.match(String(tooSoon.detail), /500 ms/) const okLater = await h.host.checkSpeak( speakInput({ roomType: 'mud', config: { speakIntervalMs: 500, __lastAt: at - 600 }, authorId: 'u_alice', at }), ) assert.equal(okLater.ok, true) }) test('步3 **无插件 ⇒ 默认自由并发**(回落不报错,§五-3 硬判据)', () => { const h = makeHost() // office 那个插件还没注册 ⇒ 任何类型都无规则 assert.equal(speakRuleFor(h.host, 'office'), undefined) return h.host.checkSpeak(speakInput({ roomType: 'office' })).then((res) => { assert.equal(res.ok, true) assert.match(String(res.detail), /默认自由并发/) }) }) test('步3 自由并发类型不受限:office 注册后(无 speakRules)发言恒放行', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) assert.equal(speakRuleFor(h.host, 'office'), undefined) const res = await h.host.checkSpeak(speakInput({ roomType: 'office', config: { speakIntervalMs: 99999 } })) assert.equal(res.ok, true) }) /* ── §五 步 4 · 能力机器人 + @ 路由 ────────────────────────────────────── */ test('步4 注册 bot ⇒ 可查(成员表出现 kind=bot 由内核写入,本单只保证可路由)', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) assert.equal(h.host.hasBot('bot_task_router'), true) assert.equal(h.host.hasBot('bot_nope'), false) assert.deepEqual( h.host.registeredBots().map((b) => b.botId), ['bot_task_router'], ) }) test('步4 未注册的 @ ⇒ 不产生事件(hasBot=false ⇒ 调用方不发触发)', () => { const h = makeHost() assert.equal(h.host.hasBot('bot_ghost'), false) }) /* ── §五 步 5 · 事件订阅 + 超时熔断 + 预算继承 ─────────────────────────── */ test('步5① 插件回调挂起 > 超时 ⇒ 房间消息流照常(emit 仍返回 ok,仅计入 skipped)', async () => { const h = makeHost() h.host.register({ pluginId: 'p_hang', label: 'hang', packageName: '@dsh-local/p-hang', version: '1', events: [ { events: ['message.created'], onEvent: () => new Promise(() => {}), // 永不 settle timeoutMs: 20, circuit: { threshold: 5, cooldownMs: 1_000 }, }, ], }) const res = await h.host.emit({ name: 'message.created', roomId: 'imr_1', roomType: 'x', at: h.now(), data: {} }) assert.equal(res.ok, true) assert.deepEqual(res.value, { delivered: 0, skipped: 1 }) assert.equal(h.host.audit().at(-1).reason, 'timeout') }) test('步5② 连续失败触发熔断 ⇒ 审计里有 circuit-open,房间不中断', async () => { const h = makeHost() h.host.register({ pluginId: 'p_boom', label: 'boom', packageName: '@dsh-local/p-boom', version: '1', events: [ { events: ['message.created'], onEvent: () => { throw new Error('插件炸了') }, timeoutMs: 20, circuit: { threshold: 2, cooldownMs: 60_000 }, }, ], }) const ev = { name: 'message.created', roomId: 'imr_1', roomType: 'x', at: h.now(), data: {} } for (let i = 0; i < 2; i += 1) { const r = await h.host.emit(ev) assert.equal(r.ok, true) // 房间消息流照常 } const third = await h.host.emit(ev) assert.deepEqual(third.value, { delivered: 0, skipped: 1 }) assert.equal(h.host.audit().at(-1).reason, 'circuit-open') }) test('步5② 熔断冷却期满 ⇒ 半开放一次试探(ImCircuitBreaker 三态可判)', () => { const b = new ImCircuitBreaker({ threshold: 1, cooldownMs: 100 }) assert.equal(b.state(0).state, 'closed') b.noteFailure(0) assert.equal(b.state(50).state, 'open') assert.equal(b.canCall(50), false) assert.equal(b.state(150).state, 'half-open') assert.equal(b.canCall(150), true) b.noteSuccess() assert.equal(b.state(150).state, 'closed') }) test('步5③ 预算由**内核**提供(§六.1-9):超限 ⇒ 被拒 + 具名 reason', async () => { const budget = makeBudgetPort({ limit: 2 }) const h = makeHost({ budget }) const input = { roomId: 'imr_1', authorId: 'bot_x', authorKind: 'bot' } assert.equal((await h.host.acquireBudget(input)).ok, true) assert.equal((await h.host.acquireBudget(input)).ok, true) const third = await h.host.acquireBudget(input) assert.equal(third.ok, false) assert.equal(third.reason, 'rate-limited') }) test('步5③ 未注入预算端口 ⇒ 明确说明(⛔ 不静默当"已限速")', async () => { const h = makeHost() const res = await h.host.acquireBudget({ roomId: 'imr_1', authorId: 'u_a', authorKind: 'bot' }) assert.equal(res.ok, true) assert.match(String(res.detail), /未注入预算端口/) }) /* ── §五 步 6 · 房间内 UI 插槽契约 ────────────────────────────────────── */ test('步6 面板渲染:经 data 端口取数(⛔ 不直连 DB),返回纯数据', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) const table = dataTableFullName('im_plugin_office', 'tasks') await h.data.insert(table, { room_id: 'imr_1', assignee: 'u_alice', status: 'open', text: '写单' }) const res = await h.host.renderPanel('office.tasks', { roomId: 'imr_1', roomType: 'office', config: {}, at: h.now(), }) assert.equal(res.ok, true) assert.equal(res.value.panelId, 'office.tasks') assert.equal(res.value.rows.length, 1) assert.equal(res.value.rows[0].assignee, 'u_alice') }) test('步6 未注册面板 ⇒ not-registered(⛔ 不返回空对象冒充成功)', async () => { const h = makeHost() const res = await h.host.renderPanel('nope', { roomId: 'imr_1', roomType: 'office', config: {}, at: 0 }) assert.equal(res.ok, false) assert.equal(res.reason, 'not-registered') }) test('步6 面板契约声明 slot(挂 dsh 既有 slots,⛔ 不新造挂载点)', async () => { const { office, mud, trpg } = await loadPlugins() for (const p of [office, mud, trpg]) { for (const panel of p.manifest.panels) assert.equal(panel.slot, 'room.panel') } }) /* ── §五 步 7 / §六 判据 2 · 跨场景验证 ────────────────────────────────── */ test('步7 三类场景各跑通一个:office/mud/trpg 的事件处理各落一行到自己的表', async () => { const { office, mud, trpg } = await loadPlugins() const h = makeHost() for (const p of [office, mud, trpg]) h.host.register(p.manifest) const deps = makeDeps(h.data) await office.manifest.events[0].onEvent( { name: 'message.created', roomId: 'imr_1', at: 1, data: { messageId: 'm1', payload: { kind: 'task', text: 'x', assignee: 'u_a' } } }, deps, ) await mud.manifest.events[0].onEvent( { name: 'message.created', roomId: 'imr_1', at: 2, data: { authorId: 'u_a', payload: { verb: 'move', to: 'north' } } }, deps, ) await trpg.manifest.events[0].onEvent( { name: 'message.created', roomId: 'imr_1', at: 3, data: { authorId: 'u_a', payload: { kind: 'dice', expr: '1d20', result: 17 } } }, deps, ) assert.equal(h.data._rows(dataTableFullName('im_plugin_office', 'tasks')).length, 1) assert.equal(h.data._rows(dataTableFullName('im_plugin_mud', 'mud_positions')).length, 1) assert.equal(h.data._rows(dataTableFullName('im_plugin_trpg', 'trpg_checks')).length, 1) }) test('步7 场景需要的行为全部由扩展点表达(三插件的 uses 都在七点之内)', async () => { const { office, mud, trpg } = await loadPlugins() for (const p of [office, mud, trpg]) { for (const rt of p.manifest.roomTypes) { for (const key of rt.uses ?? []) assert.ok(EXTENSION_KEYS.includes(key), `${p.manifest.pluginId} 声明了未知扩展点 ${key}`) } } }) /* ── §六 判据 3 · 不直连 DB ────────────────────────────────────────────── */ test('判据3 插件侧代码无 SQL / 无 DB 驱动 import(三个示例插件逐文件扫)', async () => { const files = ['office-tasks', 'mud-rate', 'trpg-turn'].map((d) => join(PLUGIN_ROOT, d, 'lib/index.js')) for (const file of files) { const src = readFileSync(file, 'utf8') assert.doesNotMatch(src, /CREATE\s+TABLE/i, `${file} 出现裸 CREATE TABLE`) assert.doesNotMatch(src, /SELECT\s+[\s\S]*?\sFROM\s/i, `${file} 出现裸 SELECT`) assert.doesNotMatch(src, /\bINSERT\s+INTO\b/i, `${file} 出现裸 INSERT`) assert.doesNotMatch(src, /from ['"](?:node:)?(?:better-sqlite3|pg|sqlite3)['"]/, `${file} 直接 import 了 DB 驱动`) } }) test('判据3 SDK 契约层自身不 import DB / 网络 / web 层(分层方向保住)', () => { for (const f of ['src/im/sdk/types.ts', 'src/im/sdk/host.ts', 'src/im/sdk/index.ts']) { const src = readFileSync(join(REPO, f), 'utf8') assert.doesNotMatch(src, /from ['"]\.\.\/(?:\.\.\/)?web\//, `${f} 反向 import web 层`) assert.doesNotMatch(src, /from ['"]node:(?:net|http|https|dgram|tls)['"]/, `${f} import 了网络模块`) assert.doesNotMatch(src, /from ['"](?:better-sqlite3|pg)['"]/, `${f} import 了 DB 驱动`) } }) /* ── §六 判据 5 · 预算(插件发言在房间级限速内) ───────────────────────── */ test('判据5 插件发言受房间级限速约束:预算端口按 (room,author) 计账且超限具名拒绝', async () => { const budget = makeBudgetPort({ limit: 1 }) const h = makeHost({ budget }) const a = { roomId: 'imr_1', authorId: 'bot_a', authorKind: 'bot' } assert.equal((await h.host.acquireBudget(a)).ok, true) // ⚠️ 额度按**作者**计 ⇒ 换作者不共享额度(限速只约束 agent,与 A 单 `decideWrite` 同向)。 assert.equal((await h.host.acquireBudget({ ...a, authorId: 'bot_b' })).ok, true) assert.equal((await h.host.acquireBudget(a)).reason, 'rate-limited') // 归还(调用方未实际发言)⇒ 额度释放 budget.releaseAll(a) assert.equal((await h.host.acquireBudget(a)).ok, true) }) test('判据5 人类成员不计入该限速(与内核 decideWrite「只约束 agent」同向)', async () => { const budget = makeBudgetPort({ limit: 1 }) const h = makeHost({ budget }) const human = { roomId: 'imr_1', authorId: 'u_a', authorKind: 'human' } assert.equal((await h.host.acquireBudget(human)).ok, true) // 即便端口按作者计账,人类**恒不受 `ROOM_LIMITS` 的 agent 配额约束**(内核判据在 store)。 const used = budget._used.get('imr_1:u_a') assert.equal(used, 1) }) test('判据5 插件**不自带**预算实现(三个示例插件都不导出自己的限速器)', async () => { const { office, mud, trpg } = await loadPlugins() for (const p of [office, mud, trpg]) { assert.equal(p.manifest.budget, undefined, `${p.manifest.pluginId} 自带了预算实现(§六.1-9 禁止)`) } }) /* ── §六 判据 6 · 两档传输(不同源) ──────────────────────────────────── */ test('判据6 两档参数**不同源**:chat 与 realtime 的四个字段逐项不同', () => { const chat = TRANSPORT_PROFILES.chat const real = TRANSPORT_PROFILES.realtime assert.notEqual(chat.maxJitterMs, real.maxJitterMs) assert.notEqual(chat.heartbeatMs, real.heartbeatMs) assert.notEqual(chat.fanoutBatch, real.fanoutBatch) assert.notEqual(chat.latencyBudgetMs, real.latencyBudgetMs) assert.equal(real.maxJitterMs, 20) // 档案 109 §1.4 的行业口径 }) test('判据6 房间类型可声明走哪一档:office=chat / mud=realtime / trpg=realtime', async () => { const { office, mud, trpg } = await loadPlugins() const h = makeHost() for (const p of [office, mud, trpg]) h.host.register(p.manifest) assert.equal(transportOf(h.host, 'office').tier, 'chat') assert.equal(transportOf(h.host, 'mud').tier, 'realtime') assert.equal(transportOf(h.host, 'trpg').tier, 'realtime') }) test('判据6 未声明 transport ⇒ 回落聊天档(⛔ 不默认给实时档)', () => { const h = makeHost() h.host.register({ pluginId: 'p_d', label: 'd', packageName: '@dsh-local/p-d', version: '1', roomTypes: [{ type: 'plain', label: 'plain', timeoutMs: 10, circuit: DEFAULT_CIRCUIT }], }) assert.equal(transportOf(h.host, 'plain').tier, 'chat') }) /* ── §六 判据 7 · 分发(复用既有通道) ───────────────────────────────── */ test('判据7 分发复用既有通道:三个示例插件都是标准 dsh 包形态(patch + package.json#dsh)', () => { for (const dir of ['office-tasks', 'mud-rate', 'trpg-turn']) { const pkg = JSON.parse(readFileSync(join(PLUGIN_ROOT, dir, 'package.json'), 'utf8')) assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml') const patch = readFileSync(join(PLUGIN_ROOT, dir, 'cordis.patch.yml'), 'utf8') assert.match(patch, /- insert:/) assert.match(patch, /name: '@dsh-local\//) } }) test('判据7 ⛔ 不新造分发机制:示例插件不声明自己的 profile / 安装器', async () => { const { office } = await loadPlugins() assert.equal(office.apply.length, 2) // apply(ctx, host) —— 只接收 cordis ctx 与注入的宿主 const src = readFileSync(join(PLUGIN_ROOT, 'office-tasks/lib/index.js'), 'utf8') assert.doesNotMatch(src, /npm install|profile\.json|ensure-biz-plugins/) }) /* ── §六.1-8 · 失败模式齐备(缺一即不通过) ──────────────────────────── */ test('§六.1-8 hasFailureMode:缺 timeoutMs 或 circuit ⇒ 不满足', () => { assert.equal(hasFailureMode({ timeoutMs: 10, circuit: DEFAULT_CIRCUIT }), true) assert.equal(hasFailureMode({ circuit: DEFAULT_CIRCUIT }), false) assert.equal(hasFailureMode({ timeoutMs: 10 }), false) assert.equal(hasFailureMode({ timeoutMs: 0, circuit: DEFAULT_CIRCUIT }), false) assert.equal(hasFailureMode({ timeoutMs: 10, circuit: { threshold: 0, cooldownMs: 1 } }), false) }) test('§六.1-8 三个示例插件的**每一个**扩展点条目都带 timeoutMs + circuit', async () => { const { office, mud, trpg } = await loadPlugins() for (const p of [office, mud, trpg]) { const rows = auditFailureModes(p.manifest) for (const row of rows) { if (row.count === 0) continue assert.equal(row.hasFailureMode, true, `${p.manifest.pluginId} 的 ${row.key} 缺失败模式:${row.missing.join(',')}`) } } }) test('§六.1-8 审计面能报出缺项(构造一个漏声明的清单)', () => { const rows = auditFailureModes({ pluginId: 'p_y', label: 'y', packageName: '@dsh-local/p-y', version: '1', payloads: [{ kind: 'x' }], }) const row = rows.find((r) => r.key === 'payloads') assert.equal(row.hasFailureMode, false) assert.deepEqual(row.missing, ['payloads[0]']) }) /* ── §六.1-10 · payload = 密文 ───────────────────────────────────────── */ test('§六.1-10 契约里 payload 定义为密文:三个示例插件的载荷契约都不含明文语义字段之外的信封字段', async () => { const { office, mud, trpg } = await loadPlugins() for (const p of [office, mud, trpg]) { for (const pl of p.manifest.payloads) { // 契约只声明"明文恢复后如何校验",⛔ 不声明 seq / author / visibility 这类 envelope 字段。 for (const f of ['seq', 'authorId', 'visibility', 'roomId']) { assert.ok(!(pl.requires ?? []).includes(f), `${p.manifest.pluginId} 的载荷契约声明了 envelope 字段 ${f}`) } } } }) test('§六.1-10 内核 side 对未知 payload 原样透传(本单⛔ 未改内核 ⇒ 由判据2 的零改动保证)', () => { for (const f of ['src/im/store.ts', 'src/im/hub.ts', 'src/web/routes/im.ts']) { const diff = execFileSync('git', ['diff', '--', f], { cwd: REPO, encoding: 'utf8' }) assert.equal(diff.trim(), '', `${f} 有 diff(内核被改过)`) } }) /* ── §九.7 判据 8–13 · 数据面 ──────────────────────────────────────── */ test('§九.7-8 零裸 SQL:三个示例插件里 CREATE TABLE 零命中', () => { const dirs = ['office-tasks', 'mud-rate', 'trpg-turn'] for (const dir of dirs) { const src = readFileSync(join(PLUGIN_ROOT, dir, 'lib/index.js'), 'utf8') assert.equal((src.match(/CREATE TABLE/gi) ?? []).length, 0, `${dir} 出现裸 CREATE TABLE`) } }) test('§九.7-8 零裸 SQL:示例插件**全部文件**(含 cordis patch / package.json)也无裸 DDL', () => { for (const dir of ['office-tasks', 'mud-rate', 'trpg-turn']) { for (const rel of ['lib/index.js', 'cordis.patch.yml', 'package.json']) { const src = readFileSync(join(PLUGIN_ROOT, dir, rel), 'utf8') assert.doesNotMatch(src, /\bCREATE\s+TABLE\b/i, `${dir}/${rel} 出现裸 CREATE TABLE`) assert.doesNotMatch(src, /\bDROP\s+TABLE\b/i, `${dir}/${rel} 出现裸 DROP TABLE`) } } }) test('§九.7-9 双后端:同一份声明在 sqlite 与 pg 上都能建表通过', async () => { const { office } = await loadPlugins() const table = office.manifest.tables[0] const ddl = buildTableDdl(office.manifest, table) assert.equal(ddl.fullName, 'p_im_plugin_office_tasks') assert.match(ddl.sqlite, /CREATE TABLE IF NOT EXISTS p_im_plugin_office_tasks/) assert.match(ddl.pg, /CREATE TABLE IF NOT EXISTS p_im_plugin_office_tasks/) assert.match(ddl.sqlite, /room_id TEXT/) // 归属列自动补 }) test('§九.7-9 双后端**逐列**翻译(含差异列才证明"两套 DDL")', async () => { const { trpg } = await loadPlugins() const ddl = buildTableDdl(trpg.manifest, { name: 't', scope: 'room', schemaVersion: 1, columns: [ { name: 'a', type: 'bigint' }, { name: 'b', type: 'real' }, { name: 'c', type: 'boolean' }, { name: 'd', type: 'json' }, { name: 'e', type: 'uuid' }, ], }) assert.notEqual(ddl.sqlite, ddl.pg, '两套 DDL 完全相同 ⇒ 说明没做双后端翻译') assert.match(ddl.sqlite, /a INTEGER/) assert.match(ddl.pg, /a BIGINT/) assert.match(ddl.sqlite, /b REAL/) assert.match(ddl.pg, /b DOUBLE PRECISION/) assert.match(ddl.pg, /d JSONB/) assert.match(ddl.pg, /e UUID/) }) test('§九.7-9 表声明⛔ 不得自带归属列 / 主键(内核自动补)', () => { const h = makeHost() const withRoom = h.host.register({ pluginId: 'p_z', label: 'z', packageName: '@dsh-local/p-z', version: '1', tables: [{ name: 't', scope: 'room', schemaVersion: 1, columns: [{ name: 'room_id', type: 'text' }] }], }) assert.equal(withRoom.ok, false) assert.match(String(withRoom.detail), /归属列/) }) test('§九.7-10 越权:跨前缀读 ⇒ 另一插件的表名不可见(端口按表名隔离,拿到 0 行)', async () => { const { office, mud } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) h.host.register(mud.manifest) await h.data.insert(dataTableFullName('im_plugin_office', 'tasks'), { room_id: 'imr_1', text: 'only office' }) const foreign = await h.host.find(dataTableFullName('im_plugin_mud', 'tasks'), { roomId: 'imr_1' }) assert.equal(foreign.ok, true) assert.deepEqual(foreign.value, []) }) test('§九.7-11 房间 ACL:非本房的行经插件 API 读不到(按 room_id 强制过滤)', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) const table = dataTableFullName('im_plugin_office', 'tasks') await h.data.insert(table, { room_id: 'imr_1', text: 'mine' }) await h.data.insert(table, { room_id: 'imr_2', text: 'other room' }) const mine = await h.host.find(table, { roomId: 'imr_1' }) assert.equal(mine.value.length, 1) assert.equal(mine.value[0].text, 'mine') }) test('§九.7-12 迁移只增不减:加列(带 schemaVersion 递增)⇒ 接受', () => { const h = makeHost() const additive = h.host.register({ pluginId: 'p_m', label: 'm', packageName: '@dsh-local/p-m', version: '2', tables: [{ name: 't', scope: 'room', schemaVersion: 2, columns: [{ name: 'newcol', type: 'text' }] }], }) assert.equal(additive.ok, true) }) test('§九.7-12 迁移版本号必填且为 ≥1 的整数(声明面硬约束)', () => { const h = makeHost() const base = { pluginId: 'p_n', label: 'n', packageName: '@dsh-local/p-n', version: '1' } // 缺 schemaVersion assert.equal(h.host.register({ ...base, tables: [{ name: 't', scope: 'room', columns: [{ name: 'a', type: 'text' }] }] }).ok, false) // 版本 0 assert.equal(h.host.register({ ...base, tables: [{ name: 't', scope: 'room', schemaVersion: 0, columns: [{ name: 'a', type: 'text' }] }] }).ok, false) // 小数 assert.equal(h.host.register({ ...base, tables: [{ name: 't', scope: 'room', schemaVersion: 1.5, columns: [{ name: 'a', type: 'text' }] }] }).ok, false) // 合法 assert.equal(h.host.register({ ...base, tables: [{ name: 't', scope: 'room', schemaVersion: 1, columns: [{ name: 'a', type: 'text' }] }] }).ok, true) }) test('§九.7-12 非中性类型 ⇒ 拒绝启用(保住"双后端可切"的承诺)', () => { const h = makeHost() const res = h.host.register({ pluginId: 'p_t', label: 't', packageName: '@dsh-local/p-t', version: '1', tables: [{ name: 't', scope: 'room', schemaVersion: 1, columns: [{ name: 'a', type: 'jsonb' }] }], }) assert.equal(res.ok, false) assert.match(String(res.detail), /非中性类型/) }) test('§九.7-12 scope 非法 ⇒ 拒绝(归属列强制是"按用户迁移"的前提)', () => { const h = makeHost() const res = h.host.register({ pluginId: 'p_s', label: 's', packageName: '@dsh-local/p-s', version: '1', tables: [{ name: 't', scope: 'global', schemaVersion: 1, columns: [{ name: 'a', type: 'text' }] }], }) assert.equal(res.ok, false) assert.match(String(res.detail), /scope 必须是/) }) test('§九.7-13 卸载:停用后数据仍在且可查(unregister ⛔ 不删数据)', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) const table = dataTableFullName('im_plugin_office', 'tasks') await h.data.insert(table, { room_id: 'imr_1', text: '保留' }) h.host.unregister(office.manifest.pluginId) assert.equal(h.host.has(office.manifest.pluginId), false) const still = await h.data.find(table, { roomId: 'imr_1' }) assert.equal(still.value.length, 1) assert.equal(still.value[0].text, '保留') }) test('§九.7-13 卸载⛔ 不自动 DROP:DDL 只在显式调用时生成,unregister 不产出任何 DDL', async () => { const { office } = await loadPlugins() const h = makeHost() h.host.register(office.manifest) h.host.unregister(office.manifest.pluginId) // 生成 DDL 是**纯函数**,只有在 admin 显式确认后才由调用方执行 ⇒ unregister 不触发它。 const ddl = buildTableDdl(office.manifest, office.manifest.tables[0]) assert.match(ddl.sqlite, /CREATE TABLE/) }) /* ── 数据面端口:插件只能经它(判据 3 的正面证据) ───────────────────── */ test('数据面:insert/find 走端口,归属列由端口侧落(插件不自填 room_id 也可被过滤)', async () => { const h = makeHost() const res = await h.host.insert('p_x_t', { room_id: 'imr_9', text: 'v' }) assert.equal(res.ok, true) const found = await h.host.find('p_x_t', { roomId: 'imr_9' }) assert.equal(found.value.length, 1) }) test('数据面:未注入端口 ⇒ 具名拒绝(⛔ 不返回空成功)', async () => { const host = new ImSdkHost() const res = await host.find('t') assert.equal(res.ok, false) assert.equal(res.reason, 'not-registered') }) /* ── 观测面 / 审计 ──────────────────────────────────────────────────── */ test('审计:调用记录写进缓冲并能传出(点 / 成功 / 耗时 / 原因)', async () => { const seen = [] const h = makeHost({ onAudit: (r) => seen.push(r) }) h.host.register({ pluginId: 'p_a', label: 'a', packageName: '@dsh-local/p-a', version: '1', speakRules: [{ rule: 'free', timeoutMs: 20, circuit: DEFAULT_CIRCUIT, check: () => ({ ok: true, reason: 'ok' }) }], roomTypes: [{ type: 'a', label: 'a', timeoutMs: 10, circuit: DEFAULT_CIRCUIT }], }) await h.host.checkSpeak(speakInput({ roomType: 'a' })) assert.equal(seen.length, 1) assert.equal(seen[0].point, 'speakRules:p_a') assert.equal(seen[0].ok, true) assert.equal(h.host.audit().length, 1) }) test('时钟:宿主用注入的 now(⛔ 不用 Date.now)⇒ 判据可复现', () => { const h = makeHost({ now: 5_000 }) assert.equal(h.now(), 5_000) h.advance(1_500) assert.equal(h.now(), 6_500) }) /* ── 熔断隔离:一个点挂了不连坐另一个插件 ─────────────────────────── */ test('熔断按「扩展点:插件」隔离(一个插件熔断不影响另一个)', async () => { const h = makeHost() for (const id of ['p_1', 'p_2']) { h.host.register({ pluginId: id, label: id, packageName: `@dsh-local/${id.replace(/_/g, '-')}`, version: '1', events: [ { events: ['message.created'], onEvent: () => { throw new Error('boom') }, timeoutMs: 20, circuit: { threshold: 1, cooldownMs: 60_000 }, }, ], }) } const ev = { name: 'message.created', roomId: 'imr_1', roomType: 'x', at: h.now(), data: {} } await h.host.emit(ev) // 两者都失败并熔断 assert.equal(h.host.hostCircuitOf('events', 'p_1').state(h.now()).state, 'open') assert.equal(h.host.hostCircuitOf('events', 'p_2').state(h.now()).state, 'open') assert.equal(h.host.hostCircuitOf('speakRules', 'p_1').state(h.now()).state, 'closed') }) /* ── 内核模块未被本单 import(分层方向) ───────────────────────────── */ test('分层:src/im/sdk/** 只依赖 node:* 与同层 types(⛔ 不反向依赖 web/supervisor)', () => { for (const f of ['src/im/sdk/host.ts', 'src/im/sdk/index.ts']) { const src = readFileSync(join(REPO, f), 'utf8') const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]) for (const spec of imports) { assert.ok( spec.startsWith('./') || spec.startsWith('node:'), `${f} 出现了越层依赖:${spec}`, ) } } })