Files
dsh_shenxian/test/im-agent.test.mjs
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

542 lines
24 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 agent 接入回归测试(node:test)—— C 单 `交接单/IM群组-C-Agent接入插件.md` §六 ∪ §六.1。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// 覆盖面:凭据 200/401/不注入不启动(判据 1)· 只拨出无监听(判据 2/10)· 防自激(判据 3/9)·
// 上下文含窗口内非 @ 那条(判据 4)· 可见性(判据 4)· 四条硬约束(判据 5/9)·
// 代答标识 author/via(判据 6)· 故障隔离(判据 8)· 区判据(§〇-4③)。
//
// ⚠️ 本文件**不碰网络、不碰 DB**:所有判据都断 `lib/im/agent-bridge.js` 与
// `lib/im/instance-token.js` 的纯逻辑,以及插件包 `poc/im-agent-bridge/lib/index.js`
// 的静态形状(无 `listen(`)。端到端(真起 socket)由 B 单的验收脚本覆盖。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
AGENT_CONTEXT_MAX_MESSAGES,
AGENT_CONTEXT_WINDOW_MS,
RoomRateLimiter,
buildAgentContext,
mentionsOf,
replyMarkerOf,
replyPayloadOf,
shouldTrigger,
visibleTo,
} from '../lib/im/agent-bridge.js'
import {
IM_INSTANCE_TOKEN_BYTES,
ImInstanceTokenRegistry,
INSTANCE_TOKEN_REASONS,
looksLikeImInstanceToken,
} from '../lib/im/instance-token.js'
import { IM_MAX_CONN_PER_USER } from '../lib/im/hub.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '..')
const PLUGIN_ENTRY = join(REPO, 'poc/im-agent-bridge/lib/index.js')
/** Windows 上 `import()` 必须用 file:// URL(⛔ 不能传 `D:\...` 裸路径)。 */
function pathToFileUrl(p) {
return `file:///${p.replace(/\\/g, '/')}`
}
/* ── 夹具 ────────────────────────────────────────────────────────────────── */
/** 造一条消息(形状 = A 单 `MessageEnvelope`)。 */
function msg(seq, over = {}) {
return {
id: `h${seq}`,
roomId: 'imr_test',
seq,
ts: over.ts ?? 1_000_000 + seq,
authorId: over.authorId ?? 'u_alice',
authorKind: over.authorKind ?? 'human',
via: over.via ?? 'web',
visibility: over.visibility ?? 'room',
payload: over.payload ?? { text: `第 ${seq} 条` },
}
}
/** 造一个成员(形状 = A 单 `RoomMember`)。 */
function member(memberId, over = {}) {
return {
roomId: 'imr_test',
memberId,
kind: over.kind ?? 'human',
role: over.role ?? 'member',
autoReply: over.autoReply ?? false,
agentRef: over.agentRef ?? null,
joinedAt: 0,
}
}
/* ── 判据 1:凭据 200 / 401 / 不注入则不启动 ─────────────────────────────── */
test('判据 1 / §五-1:正确 token 通过校验', () => {
const reg = new ImInstanceTokenRegistry()
const { token, record } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
assert.ok(looksLikeImInstanceToken(token), 'token 外形应为 43 字符 base64url')
assert.ok(token.length >= Math.ceil((IM_INSTANCE_TOKEN_BYTES * 4) / 3) - 1)
const v = reg.verify(token)
assert.equal(v.ok, true)
assert.equal(v.record.userId, 'u_alice', '必须能答出"这是谁的实例"(§〇-4②)')
assert.equal(v.record.instanceId, 'inst_1')
assert.equal(record.expiresAt > record.issuedAt, true)
})
test('判据 1:错误 / 缺失 / 过期 token 一律拒绝(fail-closed,⛔ 不是放行)', () => {
let clock = 1000
const reg = new ImInstanceTokenRegistry({ now: () => clock })
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', ttlMs: 500 })
// 缺失
assert.deepEqual(reg.verify(undefined), { ok: false, reason: 'missing' })
assert.deepEqual(reg.verify(''), { ok: false, reason: 'missing' })
// 外形不对(长度不对 ⇒ 不认,且⛔不走定长比较)
assert.equal(reg.verify('short').ok, false)
assert.equal(reg.verify('short').reason, 'unknown')
// 未签发过的(合法外形)
assert.equal(reg.verify('A'.repeat(43)).reason, 'unknown')
// 有效期内 ⇒ 通过
assert.equal(reg.verify(token).ok, true)
// 时钟推过 TTL ⇒ 具名过期(⛔ 不是 unknown —— 客户端要能区分)
clock = 1600
const expired = reg.verify(token)
assert.equal(expired.ok, false)
assert.equal(expired.reason, 'expired')
assert.equal(typeof INSTANCE_TOKEN_REASONS.expired, 'string')
// sweepExpired 之后变成 unknown(登记簿不留过期行)
assert.equal(reg.sweepExpired(), 1)
assert.equal(reg.verify(token).reason, 'unknown')
})
test('判据 1 / §〇-4③:跨区一律拒(本期单区)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', region: 'cn-east' })
// 不声明区 ⇒ 不触发区判据
assert.equal(reg.verify(token).ok, true)
// 声明同区 ⇒ 通过
assert.equal(reg.verify(token, { region: 'cn-east' }).ok, true)
// 声明异区 ⇒ 拒(具名)
const bad = reg.verify(token, { region: 'cn-west' })
assert.equal(bad.ok, false)
assert.equal(bad.reason, 'region_mismatch')
})
test('判据 1:实例重启即换 token(旧 token 失效)', () => {
const reg = new ImInstanceTokenRegistry()
const first = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
assert.equal(reg.verify(first).ok, true)
// 实例重启:先撤后签(编排器的动作,见 issueImInstanceToken)
assert.equal(reg.revokeByInstance('inst_1'), 1)
const second = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
assert.equal(reg.verify(first).reason, 'unknown', '旧 token 必须失效')
assert.equal(reg.verify(second).ok, true)
assert.notEqual(first, second)
})
test('判据 1 / §五-1:登记簿只存哈希(token 原文不落登记簿)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' })
// 白盒:内部 Map 的键、以及整对象的字符串化里都不得出现 token 原文
const keys = Object.keys(reg)
assert.equal(keys.includes('byHash'), true, '内部表名变了 ⇒ 重新审计存储口径')
assert.equal(JSON.stringify(reg).includes(token), false, '⛔ 登记簿里不得有 token 原文')
assert.equal(reg.size(), 1)
assert.equal(reg.sweepExpired(), 0, '未过期 ⇒ 清 0')
})
test('判据 1:可达房间是"索引"而非授权判据(未覆盖 ⇒ 具名拒绝)', () => {
const reg = new ImInstanceTokenRegistry()
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
assert.equal(reg.verify(token, { roomId: 'imr_a' }).ok, true)
const bad = reg.verify(token, { roomId: 'imr_b' })
assert.equal(bad.ok, false)
assert.equal(bad.reason, 'not_in_room')
// 补一个房(成员变更时调用方同步)
assert.equal(reg.allowRoom('inst_1', 'imr_b'), true)
assert.equal(reg.verify(token, { roomId: 'imr_b' }).ok, true)
})
/* ── 判据 3:防自激(硬约束 1)──────────────────────────────────────────── */
test('判据 3 / §五-3:agent 的消息**不触发**任何 agent', () => {
const snapshot = {
roomId: 'imr_test',
members: [
member('u_alice'),
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
],
messages: [],
}
// bot 自己发的消息 @ 了 bot_a ⇒ 仍不触发
const fromBot = msg(1, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a 你好' } })
const d = shouldTrigger(fromBot, snapshot)
assert.equal(d.trigger, false)
assert.equal(d.reason, 'not-human-author')
})
test('判据 3 / §五-3:human 的 @bot ⇒ 恰好触发 1 次(形态 A)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
const d = shouldTrigger(msg(2, { payload: { text: '@bot_a 帮我看下' } }), snapshot)
assert.equal(d.trigger, true)
assert.equal(d.form, 'bot')
assert.equal(d.memberId, 'bot_a')
assert.equal(d.agentRef, 'agent_a')
})
test('判据 3 / §五-3:无 @ ⇒ 不触发;结构性 mentions 优先于文本扫描', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
assert.equal(shouldTrigger(msg(3, { payload: { text: '随便说说' } }), snapshot).reason, 'no-mention')
// 结构化 mentions:文本里没 @,也应触发
const structured = msg(4, { payload: { text: '帮我看下', mentions: ['bot_a'] } })
assert.equal(shouldTrigger(structured, snapshot).trigger, true)
// mentions 为空数组 ⇒ 不回落到文本(显式声明了"没有 @")
assert.equal(mentionsOf({ text: '@x', mentions: [] }).length, 0)
})
/* ── 判据 3:形态 B(组员代理)──────────────────────────────────────────── */
test('判据 3 / §五-3:形态 B 在 autoReply=0 时**不触发**', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: false })],
messages: [],
}
const d = shouldTrigger(msg(5, { payload: { text: '@u_alice 在吗' } }), snapshot)
assert.equal(d.trigger, false)
assert.equal(d.reason, 'proxy-autoreply-off')
})
test('判据 3 / §四-1:形态 B 触发时 memberId = **组员本人**(代理不占位)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true })],
messages: [],
}
const d = shouldTrigger(msg(6, { payload: { text: '@u_alice 帮我总结下' } }), snapshot)
assert.equal(d.trigger, true)
assert.equal(d.form, 'proxy')
assert.equal(d.memberId, 'u_alice', '形态 B 的成员落点 = 组员本人')
assert.equal(d.agentRef, 'agent_alice')
})
test('判据 3:开启 agent 互相 @ 时受跳数上限约束(默认关闭)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [],
}
const fromBot = msg(7, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a hi' } })
// 默认关 ⇒ 不触发
assert.equal(shouldTrigger(fromBot, snapshot).trigger, false)
// 开启且 hop=0 ⇒ 触发
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 0 }).trigger, true)
// 达到跳数上限 ⇒ 停(切断自激环)
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 2 }).trigger, false)
})
/* ── 判据 4:上下文(窗口内非 @ 那条 + 可见性)───────────────────────────── */
test('判据 4 / §五-4①:能引用**窗口内、但没有 @ 它**的消息', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [
msg(1, { payload: { text: '项目代号是 ALPHA' } }), // ← 无 @,但窗口内
msg(2, { payload: { text: '排期在周五' } }),
msg(3, { payload: { text: '@bot_a 代号叫什么来着?' } }), // ← 被 @ 那条
],
meta: { name: '测试房', roomType: 'office' },
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now: 1_000_003,
})
assert.equal(ctx.focus?.seq, 3, 'focus 必须是在 @ 那条')
const windowSeqs = ctx.window.map((m) => m.seq)
assert.equal(windowSeqs.includes(1), true, '窗口内非 @ 那条必须在上下文里(否则判据 4 失败)')
assert.equal(ctx.prompt.includes('ALPHA'), true, 'prompt 必须真的带上那条内容')
assert.equal(ctx.prompt.includes('排期在周五'), true)
assert.equal(ctx.room.members, 2)
})
test('判据 4 / §四-3:被 @ 那条若落在窗口外 ⇒ **显式补回**(不变量 ①)', () => {
const many = []
for (let i = 1; i <= 60; i += 1) many.push(msg(i, { ts: 1_000_000 + i }))
// 被 @ 的是**最老**那条(第 1 条),必然被"最近 50 条"裁掉
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: many,
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 1,
now: 1_000_060,
})
assert.equal(ctx.focus?.seq, 1, 'focus 必须还在')
assert.equal(ctx.window.length, AGENT_CONTEXT_MAX_MESSAGES + 1, '窗口 50 + 补回 1')
assert.equal(ctx.window.map((m) => m.seq).includes(1), true)
// 且窗口仍按 seq 严格递增(补回后要重排)
const seqs = ctx.window.map((m) => m.seq)
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b))
})
test('判据 4 / §四-6:上下文窗口 = 最近 50 条或最近 10 分钟,**取小**', () => {
const old = msg(1, { ts: 0, payload: { text: '十分钟前的旧话' } }) // 远超时间窗
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
messages: [old, msg(2, { ts: 1_000_000 }), msg(3, { ts: 1_000_001 })],
meta: {},
}
const now = 1_000_001
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now,
})
// 旧话被时间窗排除 ⇒ 但因为它**可见且落窗** ⇒ 进 folded 摘要(⛔ 不是硬截断)
assert.equal(ctx.window.map((m) => m.seq).includes(1), false, '旧话不进窗口')
assert.equal(ctx.folded.length, 1, '旧话应折叠为摘要')
assert.equal(ctx.prompt.includes('更早消息摘要'), true)
assert.equal(AGENT_CONTEXT_WINDOW_MS, 10 * 60 * 1000)
})
test('判据 4 / §五-4②:**越权内容不出现**(可见性按接收者判)', () => {
const snapshot = {
roomId: 'imr_test',
members: [
member('u_alice'),
member('u_bob'),
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
],
messages: [
msg(1, { authorId: 'u_bob', payload: { text: '公开情报' } }),
// bob 与 alice 的私聊(visibility=private,作者 bob)⇒ bot_a 无权看到
msg(2, { authorId: 'u_bob', visibility: 'private', payload: { text: '机密:代号 OMEGA' } }),
msg(3, { payload: { text: '@bot_a 有啥情报' } }),
],
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'bot_a',
agentRef: 'agent_a',
form: 'bot',
viewerId: 'bot_a',
focusSeq: 3,
now: 1_000_003,
})
assert.equal(ctx.prompt.includes('机密'), false, '越权内容不得出现(判据 4 最硬的一条)')
assert.equal(ctx.prompt.includes('公开情报'), true)
assert.equal(visibleTo({ visibility: 'private', authorId: 'u_bob' }, 'bot_a'), false)
assert.equal(visibleTo({ visibility: 'private', authorId: 'bot_a' }, 'bot_a'), true, '自己的私聊可见')
})
test('判据 4:形态 B 的上下文以**组员本人**为可见性主体(§五-6 一致)', () => {
const snapshot = {
roomId: 'imr_test',
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true }), member('u_bob')],
messages: [
// alice 自己的私聊 ⇒ 代答 agent 应能看到(因为它代表 alice)
msg(1, { authorId: 'u_alice', visibility: 'private', payload: { text: '我的私人备注' } }),
msg(2, { authorId: 'u_bob', payload: { text: '@u_alice 看下' } }),
],
meta: {},
}
const ctx = buildAgentContext({
snapshot,
memberId: 'u_alice',
agentRef: 'agent_alice',
form: 'proxy',
viewerId: 'u_alice', // 🔴 以组员本人为可见性主体
focusSeq: 2,
now: 1_000_002,
})
assert.equal(ctx.prompt.includes('我的私人备注'), true, '代表 alice ⇒ 能看 alice 自己的私聊')
assert.equal(ctx.role.includes('代'), true, '形态 B 的职责说明应点出"代答"')
})
/* ── 判据 5 / 9:四条硬约束 + 房间级限速排队 ─────────────────────────────── */
test('判据 5:房间级限速 —— 超限**排队**(⛔ 不丢消息)', () => {
let clock = 0
const limiter = new RoomRateLimiter(2, 1000, () => clock)
assert.deepEqual(limiter.request('imr_a', 'k1'), { admit: true })
assert.deepEqual(limiter.request('imr_a', 'k2'), { admit: true })
const q3 = limiter.request('imr_a', 'k3')
assert.equal(q3.admit, false)
assert.equal(q3.position, 1, '排队位置按到达序')
const q4 = limiter.request('imr_a', 'k4')
assert.equal(q4.position, 2)
// 重复 key 不重复排队(幂等)
assert.equal(limiter.request('imr_a', 'k3').position, 1)
assert.equal(limiter.state('imr_a').queued, 2)
// 翻窗后按序放行
clock = 1001
assert.deepEqual(limiter.drain('imr_a', 5), ['k3', 'k4'])
assert.equal(limiter.state('imr_a').queued, 0)
assert.equal(limiter.state('imr_a').used, 2)
})
test('判据 5:房间级限速是**按房**隔离的(一个房刷屏不拖累另一个)', () => {
const limiter = new RoomRateLimiter(1, 60_000)
assert.equal(limiter.request('imr_a', 'a1').admit, true)
assert.equal(limiter.request('imr_a', 'a2').admit, false)
assert.equal(limiter.request('imr_b', 'b1').admit, true, '另一个房不受影响')
})
test('判据 6 / §五-6:代答标识 —— 形态 B 的 author 指向组员本人且 via=agent', () => {
const proxy = replyMarkerOf({ form: 'proxy', memberId: 'u_alice', ownerName: 'Alice' })
assert.equal(proxy.authorId, 'u_alice', '形态 B 的 author 必须是组员本人')
assert.equal(proxy.via, 'agent')
assert.equal(proxy.label.includes('代答'), true, '标识必须可见(产品语义)')
assert.equal(proxy.label.includes('Alice'), true)
const bot = replyMarkerOf({ form: 'bot', memberId: 'bot_a' })
assert.equal(bot.authorId, 'bot_a', '形态 A 的 author 指向 bot 成员位')
assert.equal(bot.via, 'agent')
// 回写 payload 带标识(UI 可直读,不必猜)
const payload = replyPayloadOf('答案是 ALPHA', proxy)
assert.equal(payload.text, '答案是 ALPHA')
assert.equal(payload.agentReply, true)
assert.equal(typeof payload.agentLabel, 'string')
})
test('判据 8 / §六-1-8:插件崩溃 ⇒ 只影响自己(判据落在"不抛"上)', async () => {
// 载体:真加载插件包的纯函数面(不 apply ⇒ 不起连接)
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
// 默认 ask 实现是占位,必须**不抛**
const out = await mod.defaultAsk('hello')
assert.equal(typeof out, 'string')
// 解析端点在坏输入下应抛(由 apply 捕获 ⇒ 不拖垮 profile);这里只确认它可解析正常输入
const ep = mod.parseEndpoint('https://ai1net.com')
assert.equal(ep.port, 443)
assert.equal(ep.path, '/api/im/ws')
assert.equal(ep.tls, true)
const ep2 = mod.parseEndpoint('http://127.0.0.1:3080')
assert.equal(ep2.port, 3080)
assert.equal(ep2.tls, false)
})
/* ── 判据 2 / 10:只拨出(无监听端口)──────────────────────────────────── */
test('判据 2 / 10 / E3:插件源码**不含任何 listen(**(只拨出)', () => {
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
// ⚠️ 只查真正的调用形态(`listen(` / `.listen (`),注释里提到这个词不算
const callSites = src
.split('\n')
.filter((line) => !line.trim().startsWith('*') && !line.trim().startsWith('//'))
.filter((line) => /\.listen\s*\(/.test(line) || /(^|[^.\w])listen\s*\(/.test(line))
assert.deepEqual(callSites, [], `⛔ 不得有监听端口(E3 只拨出):\n${callSites.join('\n')}`)
// 正向:必须真的有 connect 调用(否则"无 listen"是空断言)
assert.equal(/connect\s*\(/.test(src), true, '必须有拨出(connect)')
assert.equal(/createServer|createServer\s*\(/.test(src), false, '⛔ 不得建 server')
})
test('判据 2:插件不引任何外部依赖(无 import 第三方包)', () => {
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
assert.deepEqual(pkg.dependencies, {}, '⛔ C 单不引入新依赖')
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1])
assert.equal(imports.length > 0, true)
for (const spec of imports) {
assert.equal(spec.startsWith('node:') || spec.startsWith('.'), true, `⛔ 非内置/相对导入:${spec}`)
}
})
test('判据 2 / §三:插件形态 = cordis.patch.yml + dsh.bundle.patch(⛔ 无 client 面)', () => {
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
assert.equal(pkg.dsh.client, undefined, 'C 单是**服务端**接入,⛔ 不声明 client 面')
const patch = readFileSync(join(REPO, 'poc/im-agent-bridge/cordis.patch.yml'), 'utf8')
assert.equal(patch.includes('@dsh-local/im-agent-bridge'), true)
})
/* ── 判据 1 / §三:env 是唯一开关(不注入则不启动)──────────────────────── */
test('判据 1 / §三:不注入 env ⇒ apply() 直接 return(⛔ 无连接尝试)', async () => {
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
const saved = {
url: process.env.DSH_PLATFORM_IM_URL,
token: process.env.DSH_IM_INSTANCE_TOKEN,
}
delete process.env.DSH_PLATFORM_IM_URL
delete process.env.DSH_IM_INSTANCE_TOKEN
try {
// 无 ctx 服务依赖、不抛 ⇒ 天然开关生效
assert.doesNotThrow(() => mod.apply({ on: () => undefined }))
assert.equal(mod.currentBridge(), null, '不注入 ⇒ 不起桥(日志无连接尝试)')
} finally {
if (saved.url !== undefined) process.env.DSH_PLATFORM_IM_URL = saved.url
if (saved.token !== undefined) process.env.DSH_IM_INSTANCE_TOKEN = saved.token
}
})
test('判据 1 / §五-2:重连退避在 30 s 内(首延迟与上限)', async () => {
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
assert.equal(mod.RECONNECT_FIRST_MS <= 30_000, true, '首延迟必须 ≤ 30 s')
assert.equal(mod.RECONNECT_MAX_MS <= 30_000, true, '上限必须 ≤ 30 s(§五-2 要求 30 s 内恢复)')
})
test('判据 1:凭据头是 `x-dsh-im-instance-token`(与 B 单预留点同名)', () => {
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
assert.equal(src.includes('x-dsh-im-instance-token'), true)
// 与平台侧读取的头必须字面一致
const routes = readFileSync(join(REPO, 'src/web/routes/im.ts'), 'utf8')
assert.equal(routes.includes('x-dsh-im-instance-token'), true)
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
assert.equal(ws.includes('x-dsh-im-instance-token'), true)
})
/* ── 判据 7:边界(官方 dsh 未改 / relay 未改 / 表结构未改)─────────────── */
test('判据 7 / §六-1:A 单的三张表 DDL 计数未变(⛔ 本单不动表结构)', () => {
const schema = readFileSync(join(REPO, 'src/db/schema.ts'), 'utf8')
// ⚠️ 本条原断言「最高版本 = 14」是**A 单自己的 ratchet**:A 单⛔不改表结构。
// 后续单子**允许**加版本(v15 = 插件数据面台账,插件投放与分库线),故判据改成
// 「**v14 仍在** + 没有**大于 v14 的本单改动**」——保留 ratchet 语义(A 单的表结构未被改),
// 同时不把后续合法迁移卡红。⛔ 不要为了过测而删掉 v14 的存在性断言。
const versions = [...schema.matchAll(/\{\s*version:\s*(\d+)/g)].map((m) => Number(m[1]))
assert.equal(versions.includes(14), true, 'A 单的 v14 三张表仍在')
assert.equal(schema.includes('im_room_keys'), false, 'E2EE 的两张表属**待拍板**,本单⛔不建')
})
test('判据 7:B 单的对外形状未变(WS 路径与 op 词表)', () => {
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
for (const op of ['subscribe', 'unsubscribe', 'send', 'resume', 'ping']) {
assert.equal(ws.includes(`'${op}'`), true, `客户端 op 词表应保留 ${op}`)
}
const hub = readFileSync(join(REPO, 'src/im/hub.ts'), 'utf8')
assert.equal(hub.includes("'/api/im/ws'"), true, 'WS 路径形状不变')
assert.equal(IM_MAX_CONN_PER_USER > 0, true)
})