Files

1019 lines
44 KiB
JavaScript
Raw Permalink Normal View History

// 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_<pluginId>_<name>` 归一(§九-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}`,
)
}
}
})