1326 lines
55 KiB
JavaScript
1326 lines
55 KiB
JavaScript
// 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,
|
||
OUTBOUND_EXTENSION_KEY,
|
||
TRANSPORT_PROFILES,
|
||
assembleText,
|
||
auditFailureModes,
|
||
buildTableDdl,
|
||
chunkText,
|
||
createImPluginBinding,
|
||
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 · 内核零改动 ────────────────────────────────────── */
|
||
//
|
||
// 🔴 **判据升级(2026-09-25 · 插件接入面)** —— 改造前这里断的是"三个内核文件 diff 空"。
|
||
// 本次为「各类功能插件接入」**有意**在两处接上插件调用点(`ws.ts` 的 `send` 分支、
|
||
// `routes/im.ts` 的装配与两处钩子)⇒ 判据改为**更能表达意图**的两条:
|
||
// ① **未涉及**的既有模块(含 `store.ts` / `hub.ts`,即数据面与扇出面)**零改动**;
|
||
// ② 有意接线的两个文件**只准纯插入**(`numstat` 删除列 = 0 ⇒ 既有语句一行未改)。
|
||
// 🔴 **意图不变**:⛔「为了实现某个场景而改内核」仍然不允许 —— 允许的只有"把扩展点接上",
|
||
// 且接线必须纯插入 ⇒ **未接线时行为逐字不变**(`hasPlugins()` 短路 + `binding === undefined` 守卫)。
|
||
|
||
/**
|
||
* git 取证。本机实测 `execFileSync git` 可能 `EBUSY` ⇒ 返回 `null`,调用方**具名跳过**,
|
||
* ⛔ 不误判为"代码坏"(同对接单 §1-6 记录的那一条实测)。
|
||
*/
|
||
function gitOut(args) {
|
||
try {
|
||
return execFileSync('git', args, { cwd: REPO, encoding: 'utf8', windowsHide: true })
|
||
} catch (err) {
|
||
if (err !== null && typeof err === 'object' && (err.code === 'EBUSY' || err.code === 'ETXTBSY')) return null
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/** 本次改造**未涉及**的既有模块(必须零改动)。 */
|
||
const KERNEL_UNTOUCHED = [
|
||
'src/im/types.ts',
|
||
'src/im/db.ts',
|
||
'src/im/store.ts',
|
||
'src/im/clock.ts',
|
||
'src/im/hub.ts',
|
||
'src/im/agent-bridge.ts',
|
||
'src/im/instance-token.ts',
|
||
]
|
||
|
||
/** 本次**有意接线**的两个文件(只准纯插入)。 */
|
||
const KERNEL_WIRED = ['src/im/ws.ts', 'src/web/routes/im.ts']
|
||
|
||
/** 断言"只有新增、没有删改";git 不可用 ⇒ `t.skip` 并返回 false。 */
|
||
function assertPureInsertion(t, files) {
|
||
const out = gitOut(['diff', '--numstat', '--', ...files])
|
||
if (out === null) {
|
||
t.skip('git 不可用(EBUSY)⇒ 跳过 numstat 取证(⛔ 不是代码坏)')
|
||
return false
|
||
}
|
||
const rows = out
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => l !== '')
|
||
assert.ok(rows.length > 0, `预期 ${files.join(' / ')} 有新增行(插件接线点)`)
|
||
for (const row of rows) {
|
||
const [added, deleted, file] = row.split('\t')
|
||
assert.equal(Number(deleted), 0, `${file} 删改了既有行(+${added} / -${deleted})⇒ 接线只准**纯插入**`)
|
||
}
|
||
return true
|
||
}
|
||
|
||
test('判据2 内核零改动:**未涉及**的既有模块(含 store.ts / hub.ts)相对 HEAD 无 diff', (t) => {
|
||
for (const f of KERNEL_UNTOUCHED) {
|
||
const diff = gitOut(['diff', '--', f])
|
||
if (diff === null) {
|
||
t.skip('git 不可用(EBUSY)⇒ 跳过既有模块取证')
|
||
return
|
||
}
|
||
assert.equal(diff.trim(), '', `${f} 有 diff(本次改造⛔ 不涉及它)`)
|
||
}
|
||
})
|
||
|
||
test('判据2 接线只准**纯插入**:ws.ts / routes/im.ts 无既有行被删改', (t) => {
|
||
assertPureInsertion(t, KERNEL_WIRED)
|
||
})
|
||
|
||
/* ── §五 步 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:)?(?: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 ['"]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 原样透传(数据/扇出面零改动 + 接线纯插入 ⇒ 透传路径未被动过)', (t) => {
|
||
for (const f of ['src/im/store.ts', 'src/im/hub.ts']) {
|
||
const diff = gitOut(['diff', '--', f])
|
||
if (diff === null) {
|
||
t.skip('git 不可用(EBUSY)⇒ 跳过')
|
||
return
|
||
}
|
||
assert.equal(diff.trim(), '', `${f} 有 diff(内核被改过)`)
|
||
}
|
||
assertPureInsertion(t, KERNEL_WIRED)
|
||
})
|
||
|
||
/* ── §九.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}`,
|
||
)
|
||
}
|
||
}
|
||
})
|
||
|
||
/* ══ 插件接入面(2026-09-25)—— 出向端口 / 面板动作 / 接线器 / 分片 ══════════ */
|
||
//
|
||
// 判据组:① 出向端口(第 8 项,**独立成键**)② 面板动作 ③ 接线器(含越权与 fail-closed)
|
||
// ④ 分片工具 ⑤ bigtext 列。前四组治的是"插件**没有任何写入口**、注册了也没人调用"。
|
||
|
||
const FIX_CIRCUIT = { threshold: 3, cooldownMs: 1000 }
|
||
|
||
/** 最小可注册清单(含出向 + 可交互面板 + 一个 bot)。 */
|
||
function makeEmitter(extra = {}) {
|
||
return {
|
||
pluginId: 'im_plugin_demo',
|
||
label: '示例',
|
||
packageName: '@dsh-local/im-plugin-demo',
|
||
version: '0.1.0',
|
||
roomTypes: [{ type: 'demo', label: '示例房', transport: 'chat', timeoutMs: 50, circuit: FIX_CIRCUIT }],
|
||
bots: [{ botId: 'bot_demo', label: 'demo', timeoutMs: 50, circuit: FIX_CIRCUIT }],
|
||
panels: [
|
||
{
|
||
panelId: 'main',
|
||
label: '主面板',
|
||
slot: 'im.room.side',
|
||
timeoutMs: 50,
|
||
circuit: FIX_CIRCUIT,
|
||
onAction: (input) => ({ ok: true, reason: 'ok', value: { echo: input.action, actor: input.actorId } }),
|
||
},
|
||
],
|
||
outbounds: [{ outboundId: 'demo_out', label: '出向', timeoutMs: 50, circuit: FIX_CIRCUIT }],
|
||
...extra,
|
||
}
|
||
}
|
||
|
||
/** 只声明了 `roomTypes` 的出向(用来验归属闸)。 */
|
||
function scopedOutbound() {
|
||
return [{ outboundId: 'demo_out', label: 'scoped', roomTypes: ['demo'], timeoutMs: 50, circuit: FIX_CIRCUIT }]
|
||
}
|
||
|
||
test('接入面 出向端口**独立成键**:EXTENSION_KEYS 仍恰为 142 §3.6 的七点(⛔ 既有判据一个字未改)', () => {
|
||
assert.equal(EXTENSION_KEYS.length, 7)
|
||
assert.equal(EXTENSION_KEYS.includes('outbounds'), false)
|
||
assert.equal(OUTBOUND_EXTENSION_KEY, 'outbounds')
|
||
})
|
||
|
||
test('接入面 出向声明校验:空 / 重复 outboundId、缺失败模式、非法 kinds 一律**具名拒绝**', () => {
|
||
const h = new ImSdkHost()
|
||
const base = makeEmitter()
|
||
const one = base.outbounds[0]
|
||
assert.equal(h.register({ ...base, outbounds: [{ ...one, outboundId: '' }] }).reason, 'bad-args')
|
||
assert.equal(h.register({ ...base, outbounds: [one, { ...one }] }).reason, 'bad-args')
|
||
assert.equal(h.register({ ...base, outbounds: [{ outboundId: 'x', label: 'x' }] }).reason, 'bad-args')
|
||
assert.equal(h.register({ ...base, outbounds: [{ ...one, kinds: ['udp'] }] }).reason, 'bad-args')
|
||
// 合法 ⇒ 通过,且 `registered.outbounds` 如实回报(⛔ 不静默丢弃)
|
||
const good = h.register(base)
|
||
assert.equal(good.ok, true)
|
||
assert.deepEqual(good.value.registered.outbounds, ['demo_out'])
|
||
})
|
||
|
||
test('接入面 出向归属 **fail-closed**:声明了 roomTypes 但没注入房间解析器 ⇒ outbound-denied', async () => {
|
||
const h = new ImSdkHost({
|
||
outbound: { frame: () => ({ delivered: 1, skipped: 0 }), message: async () => ({ id: 'm', seq: 1, inserted: true }) },
|
||
})
|
||
h.register(makeEmitter({ outbounds: scopedOutbound() }))
|
||
const port = h.outboundPortFor('im_plugin_demo')
|
||
const res = await port.frame({ roomId: 'imr_1', channel: 'doc', data: { a: 1 } })
|
||
assert.equal(res.ok, false)
|
||
assert.equal(res.reason, 'outbound-denied')
|
||
})
|
||
|
||
test('接入面 帧节流:窗口内第二帧 ⇒ throttled;帧**不落库**(op=frame);过窗口即放行', async () => {
|
||
let t = 1000
|
||
const sent = []
|
||
const h = new ImSdkHost({
|
||
now: () => t,
|
||
frameMinIntervalMs: 100,
|
||
resolveRoomType: () => 'demo',
|
||
outbound: {
|
||
frame: ({ text }) => {
|
||
sent.push(text)
|
||
return { delivered: 2, skipped: 0 }
|
||
},
|
||
message: async () => ({ id: 'm', seq: 1, inserted: true }),
|
||
},
|
||
})
|
||
h.register(makeEmitter({ outbounds: scopedOutbound() }))
|
||
const port = h.outboundPortFor('im_plugin_demo')
|
||
const first = await port.frame({ roomId: 'imr_1', channel: 'doc', data: { v: 1 } })
|
||
assert.equal(first.ok, true)
|
||
assert.equal(first.value.delivered, 2)
|
||
assert.equal(first.value.throttled, false)
|
||
const second = await port.frame({ roomId: 'imr_1', channel: 'doc', data: { v: 2 } })
|
||
assert.equal(second.reason, 'throttled')
|
||
// 🔴 帧是 ephemeral:只走扇出端口,⛔ 不进 messages 表(文本 op 是 `frame` 不是 `message`)
|
||
assert.equal(sent.length, 1)
|
||
assert.match(sent[0], /"op":"frame"/)
|
||
// 过窗口(注入时钟推进 ⇒ 判据可复现)
|
||
t += 100
|
||
assert.equal((await port.frame({ roomId: 'imr_1', channel: 'doc', data: { v: 3 } })).ok, true)
|
||
assert.equal(sent.length, 2)
|
||
})
|
||
|
||
test('接入面 身份不可借:botId 不是本插件注册的 ⇒ outbound-denied(⛔ 不能冒充他人发言)', async () => {
|
||
const written = []
|
||
const h = new ImSdkHost({
|
||
resolveRoomType: () => 'demo',
|
||
outbound: {
|
||
frame: () => ({ delivered: 1, skipped: 0 }),
|
||
message: async (input) => {
|
||
written.push(input)
|
||
return { id: 'm1', seq: 7, inserted: true }
|
||
},
|
||
},
|
||
})
|
||
h.register(makeEmitter({ outbounds: scopedOutbound() }))
|
||
const port = h.outboundPortFor('im_plugin_demo')
|
||
const stolen = await port.message({ roomId: 'imr_1', botId: 'bot_other', payload: { kind: 'text' } })
|
||
assert.equal(stolen.reason, 'outbound-denied')
|
||
assert.equal(written.length, 0)
|
||
const own = await port.message({ roomId: 'imr_1', botId: 'bot_demo', payload: { kind: 'text' } })
|
||
assert.equal(own.ok, true)
|
||
assert.deepEqual({ id: own.value.id, seq: own.value.seq }, { id: 'm1', seq: 7 })
|
||
assert.equal(written[0].authorKind, 'bot')
|
||
})
|
||
|
||
test('接入面 面板动作:onAction 被派发、actorId 原样传入;**只读面板** ⇒ 具名 plugin-unavailable', async () => {
|
||
const h = new ImSdkHost()
|
||
h.register(makeEmitter())
|
||
const res = await h.dispatchPanelAction({
|
||
roomId: 'imr_1',
|
||
roomType: 'demo',
|
||
panelId: 'main',
|
||
action: 'create-task',
|
||
actorId: 'u_1',
|
||
at: 1,
|
||
})
|
||
assert.equal(res.ok, true)
|
||
assert.deepEqual(res.value, { echo: 'create-task', actor: 'u_1' })
|
||
|
||
const ro = new ImSdkHost()
|
||
ro.register(
|
||
makeEmitter({ panels: [{ panelId: 'ro', label: '只读', slot: 'im.room.side', timeoutMs: 50, circuit: FIX_CIRCUIT }] }),
|
||
)
|
||
const readOnly = await ro.dispatchPanelAction({
|
||
roomId: 'imr_1',
|
||
roomType: 'demo',
|
||
panelId: 'ro',
|
||
action: 'x',
|
||
actorId: 'u_1',
|
||
at: 1,
|
||
})
|
||
assert.equal(readOnly.reason, 'plugin-unavailable')
|
||
assert.equal(ro.panelsFor('demo').length, 1)
|
||
// 未注册的房间类型 ⇒ 空面板(⛔ 不抛、不瞎给)
|
||
assert.equal(ro.panelsFor('nope').length, 0)
|
||
})
|
||
|
||
test('接入面 接线器 afterAppend:room 消息派发 message.created;**private 消息不派发**(越权不出门)', async () => {
|
||
const got = []
|
||
const b = createImPluginBinding({ now: () => 1 })
|
||
b.host.register(
|
||
makeEmitter({
|
||
events: [
|
||
{
|
||
events: ['message.created'],
|
||
timeoutMs: 50,
|
||
circuit: FIX_CIRCUIT,
|
||
onEvent: (e) => {
|
||
got.push(e)
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
)
|
||
const env = (visibility) => ({
|
||
id: 'm1',
|
||
roomId: 'imr_1',
|
||
seq: 1,
|
||
ts: 5,
|
||
authorId: 'u_1',
|
||
authorKind: 'human',
|
||
via: 'web',
|
||
visibility,
|
||
payload: { kind: 'text', text: 'hi' },
|
||
})
|
||
b.afterAppend({ envelope: env('room'), roomType: 'demo' })
|
||
b.afterAppend({ envelope: env('private'), roomType: 'demo' })
|
||
// afterAppend 是 fire-and-forget(⛔ 不阻塞消息流)⇒ 让事件循环走一轮再断言
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
assert.equal(got.length, 1)
|
||
assert.equal(got[0].name, 'message.created')
|
||
assert.equal(got[0].data.messageId, 'm1')
|
||
assert.equal(got[0].roomType, 'demo')
|
||
})
|
||
|
||
test('接入面 接线器面板动作:非成员拒 / **未注入成员校验 ⇒ fail-closed 拒** / 成员通过', async () => {
|
||
const bare = createImPluginBinding({})
|
||
bare.host.register(makeEmitter())
|
||
const noWiring = await bare.panelAction({ roomId: 'r1', panelId: 'main', action: 'a', actorId: 'u1' })
|
||
assert.equal(noWiring.ok, false)
|
||
assert.equal(noWiring.reason, 'outbound-denied')
|
||
|
||
const denied = createImPluginBinding({ findRoom: async () => ({ roomType: 'demo' }), isMember: async () => false })
|
||
denied.host.register(makeEmitter())
|
||
assert.equal(
|
||
(await denied.panelAction({ roomId: 'r1', panelId: 'main', action: 'a', actorId: 'u1' })).reason,
|
||
'outbound-denied',
|
||
)
|
||
|
||
const allowed = createImPluginBinding({ findRoom: async () => ({ roomType: 'demo' }), isMember: async () => true })
|
||
allowed.host.register(makeEmitter())
|
||
const res = await allowed.panelAction({ roomId: 'r1', panelId: 'main', action: 'a', actorId: 'u_9' })
|
||
assert.equal(res.ok, true)
|
||
assert.equal(res.value.actor, 'u_9')
|
||
})
|
||
|
||
test('接入面 零插件短路:无插件注册时 hasPlugins()=false(存量消息路径零额外开销的判据)', () => {
|
||
const b = createImPluginBinding({})
|
||
assert.equal(b.host.hasPlugins(), false)
|
||
b.host.register(makeEmitter())
|
||
assert.equal(b.host.hasPlugins(), true)
|
||
})
|
||
|
||
test('接入面 分片工具:UTF-8 边界安全 + 往返无损(⛔ 不切出乱码、不丢字符)', () => {
|
||
const text = '中文🙂abc'.repeat(50)
|
||
const chunks = chunkText(text, 16)
|
||
assert.ok(chunks.length > 1)
|
||
for (const c of chunks) assert.ok(Buffer.byteLength(c, 'utf8') <= 16, `分片超限:${c}`)
|
||
assert.equal(assembleText(chunks), text)
|
||
assert.deepEqual(chunkText('', 8), [])
|
||
assert.deepEqual(chunkText('a'), ['a'])
|
||
assert.equal(assembleText([]), '')
|
||
})
|
||
|
||
test('接入面 bigtext 列 = "第 9 种中性类型":SDK 建表翻译支持它(与 schema.ts / DB-03 / 08 规范一致)', () => {
|
||
const h = new ImSdkHost()
|
||
const manifest = makeEmitter({
|
||
tables: [{ name: 'docs', scope: 'room', schemaVersion: 1, columns: [{ name: 'body', type: 'bigtext' }] }],
|
||
})
|
||
const res = h.register(manifest)
|
||
assert.equal(res.ok, true, `bigtext 应被接受,实际:${JSON.stringify(res)}`)
|
||
const ddl = buildTableDdl(manifest, manifest.tables[0])
|
||
assert.match(ddl.sqlite, /body TEXT/i)
|
||
assert.match(ddl.pg, /body TEXT/i)
|
||
})
|
||
|
||
test('接入面 接线器分层:只依赖 node:* 与同层(./ 或 ../types.js),⛔ 不反向依赖 web/supervisor', () => {
|
||
const src = readFileSync(join(REPO, 'src/im/sdk/binding.ts'), 'utf8')
|
||
const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1])
|
||
assert.ok(imports.length > 0, 'binding.ts 应至少 import 宿主与类型')
|
||
for (const spec of imports) {
|
||
assert.ok(
|
||
spec.startsWith('./') || spec.startsWith('node:') || spec === '../types.js',
|
||
`binding.ts 出现了越层依赖:${spec}`,
|
||
)
|
||
}
|
||
})
|