Files
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

1020 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IM 插件 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}`,
)
}
}
})