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

588 lines
35 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 会话界面 UI 回归测试(node:test)—— E 单 `交接单/IM群组-E-会话界面UI.md` §六(1–13)+ §六.1(8–9)
// + **判据 14(协议一致性,第 13 棒新增)**:源 = 入口 `接续入口_IM线_20260922.md` §2「第 13 棒」第 4 条
// (修选型单 §15.7 的 P0 —— 客户端帧由 `type:` 回到平台的 `op:` 制)。
//
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
//
// ## 覆盖策略(为什么要读源码文本 —— ⛔ 不是"看了就当验过")
// 本单交付的**主件是客户端 bundle**(`poc/im-conversation-tabs/lib/client.js`),它跑在
// **浏览器**里(React + DOM + WebSocket),Node 侧无法执行。因此判据拆成两类:
// ① **可执行判据**(服务端增量:v16 迁移 / 可发现列表 / 自助加入 / 成员面 / 上限)⇒ 真跑;
// ② **结构判据**(客户端 bundle)⇒ 对**源码文本**做机械断言(选择器清单、`!important`、
// 反向还原、`display:none`、上限常量、词条键集、⛔ 无 inline 布局 style)。
// ⚠️ 口径写明:结构判据只保证"该写的都在",**不等于**浏览器里跑过(E 单 §六-5 明确
// 「本机改完不算交付」⇒ 生效链路四关是本单的**未完成项**,已记入回报)。
//
// ⚠️ Postgres 段自跳过:未设 `DSHS_TEST_DB_URL` 即 skip(与 `test/im-store.test.mjs` 同约定)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { openImSqlite } from '../lib/im/db.js'
import { ImStore } from '../lib/im/store.js'
import { ROOM_LIMITS, toJoinPolicy, JOIN_POLICIES } from '../lib/im/types.js'
const CLIENT_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'lib', 'client.js')
const HOST_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'lib', 'index.js')
const PKG_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'package.json')
const PATCH_PATH = join(process.cwd(), 'poc', 'im-conversation-tabs', 'cordis.patch.yml')
/** 平台 WS 端点源码 —— 协议一致性判据(E-14)必须**两侧源码互相对照**,⛔ 不是"看了一遍"。 */
const WS_PATH = join(process.cwd(), 'src', 'im', 'ws.ts')
/** 平台扇出源码 —— `presence` / `message` 的推帧在这里。 */
const HUB_PATH = join(process.cwd(), 'src', 'im', 'hub.ts')
/** 读客户端 bundle 源码(判据 8/9/10/11/12/13 的结构判据都基于它)。 */
function clientSource() {
return readFileSync(CLIENT_PATH, 'utf8')
}
/** 打开一个内存库 + 内核,并在用例结束时关库。 */
function freshStore(t, options = {}) {
const db = openImSqlite(':memory:')
t.after(() => db.close())
return { db, store: new ImStore(db, options) }
}
/** 建一个跑完全部迁移的临时库文件(判据 7 用;收尾**先关库**再删目录 —— Windows 上
* 文件被占用时 `rmSync` 会 EBUSY)。 */
function migratedStore(t) {
const dir = mkdtempSync(join(tmpdir(), 'im-e16-'))
const path = join(dir, 'control.db')
const db = openImSqlite(path)
t.after(async () => {
await db.close()
rmSync(dir, { recursive: true, force: true })
})
return db
}
// ── 判据 7 的路线声明:**显式列 + v16 迁移(只增)** ─────────────────────────
// 🔴 本单自决(理由写在 `src/db/schema.ts` 的 V16 注释与 E 单回报):授权语义不埋 config。
test('E-7 · 路线 = 显式列:v16 迁移存在且只增列(discoverable / join_policy)', async (t) => {
const db = migratedStore(t)
const rows = await db.all('SELECT version FROM schema_migrations ORDER BY version')
const versions = rows.map((r) => Number(r.version))
assert.ok(versions.includes(16), `v16(E 单房间可见性列)必须已应用(实测 ${versions.join(',')})`)
assert.equal(Number(rows.at(0).version), 1, '首条仍 = v1(⛔ 未改既有迁移)')
assert.deepEqual(
versions,
Array.from({ length: versions.length }, (_, i) => i + 1),
'迁移号连续(无跳号;新增只许追加)',
)
const cols = (await db.all('PRAGMA table_info(rooms)')).map((r) => r.name)
assert.ok(cols.includes('discoverable'), 'rooms 应有 discoverable 列(显式列路线)')
assert.ok(cols.includes('join_policy'), 'rooms 应有 join_policy 列(显式列路线)')
// 只增:v14 的既有列仍在(⛔ 迁移不得删列)。
for (const c of ['id', 'room_type', 'name', 'owner_id', 'region', 'network', 'config_json', 'created_at']) {
assert.ok(cols.includes(c), `v14 既有列 ${c} 不得被删`)
}
})
test('E-7b · 只增迁移:既有行默认「不可发现 + 仅受邀」(⛔ 不静默扩大可见面)', async (t) => {
const db = migratedStore(t)
// 手工插一行"v15 时代"的房(不显式带新列)⇒ 默认值必须落在保守侧。
await db.run(
`INSERT INTO rooms (id, room_type, name, owner_id, region, network, config_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
['imr_legacy', 'office', '老房', 'u1', '', '', '{}', Date.now()],
)
const row = (await db.all('SELECT discoverable, join_policy FROM rooms WHERE id = ?', ['imr_legacy']))[0]
assert.equal(Number(row.discoverable), 0, '存量房必须默认不可发现')
assert.equal(row.join_policy, 'invite', '存量房必须默认仅受邀')
})
test('E-7c · toJoinPolicy 值域:只认 open,其余一律回落 invite(保守侧)', () => {
assert.deepEqual([...JOIN_POLICIES], ['open', 'invite'])
assert.equal(toJoinPolicy('open'), 'open')
assert.equal(toJoinPolicy('invite'), 'invite')
for (const bad of [undefined, null, 0, 1, true, '', 'OPEN', 'public', {}]) {
assert.equal(toJoinPolicy(bad), 'invite', `${String(bad)} 必须回落 invite`)
}
})
// ── 判据 13 · 发现 + 加入(服务端增量 · 可执行)────────────────────────────
test('E-13a · 可发现列表:只列 discoverable 且 joinPolicy=open 的房', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r_open', roomType: 'office', name: '公开房', ownerId: 'u1', discoverable: true, joinPolicy: 'open' })
await store.upsertRoom({ id: 'r_closed', roomType: 'office', name: '受邀房', ownerId: 'u1', discoverable: true, joinPolicy: 'invite' })
await store.upsertRoom({ id: 'r_hidden', roomType: 'office', name: '隐藏房', ownerId: 'u1' })
// 🔴 默认值方向断言:不传 ⇒ 不可发现(⛔ 反向默认 = 静默扩大可见面)。
const hiddenDefault = await store.findRoom('r_hidden')
assert.equal(hiddenDefault.discoverable, false, '不传 discoverable ⇒ 默认 false')
assert.equal(hiddenDefault.joinPolicy, 'invite', '不传 joinPolicy ⇒ 默认 invite')
// 下推查询(进 WHERE)而不是取回后过滤。
const discoverable = await store.listRooms({ discoverable: true })
assert.deepEqual(discoverable.map((r) => r.id).sort(), ['r_closed', 'r_open'])
const openOnly = discoverable.filter((r) => r.joinPolicy === 'open')
assert.deepEqual(openOnly.map((r) => r.id), ['r_open'], '前端候选 = discoverable ∩ open')
})
test('E-13b · discoverable 双后端归一:读回为布尔真(⛔ 不是 1/0)', async (t) => {
const { store } = freshStore(t)
const room = await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'u1', discoverable: true, joinPolicy: 'open' })
assert.equal(room.discoverable, true)
assert.equal(room.joinPolicy, 'open')
const reread = await store.findRoom('r1')
assert.equal(reread.discoverable, true, 'SQLite 存 0/1 ⇒ 读回必须归一成 boolean')
})
test('E-13c · 自助加入成功 + 幂等(已是成员 ⇒ joined=false,⛔ 不是错误)', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'owner', discoverable: true, joinPolicy: 'open' })
await store.addMember({ roomId: 'r1', memberId: 'owner', kind: 'human', role: 'owner' })
const first = await store.joinRoom('r1', 'u2')
assert.equal(first.ok, true)
assert.equal(first.joined, true)
assert.equal(first.member.role, 'member', '自助加入的角色恒为 member(⛔ 不是 owner/admin)')
assert.equal(first.members, 2)
const second = await store.joinRoom('r1', 'u2')
assert.equal(second.ok, true)
assert.equal(second.joined, false, '已是成员 ⇒ 幂等返回 joined=false')
assert.equal(second.members, 2, '幂等不得重复计数')
})
test('E-13d · 具名拒绝:不可发现 ⇒ not_discoverable;可见但仅受邀 ⇒ join_closed', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r_hidden', roomType: 'office', name: 'h', ownerId: 'o', joinPolicy: 'open' })
await store.upsertRoom({ id: 'r_closed', roomType: 'office', name: 'c', ownerId: 'o', discoverable: true, joinPolicy: 'invite' })
const a = await store.joinRoom('r_hidden', 'u2')
assert.equal(a.ok, false)
assert.equal(a.reason, 'not_discoverable', '不可发现 ⇒ 具名理由(路由层归成 404)')
const b = await store.joinRoom('r_closed', 'u2')
assert.equal(b.ok, false)
assert.equal(b.reason, 'join_closed', '可见但需受邀 ⇒ join_closed(路由层 403)')
const c = await store.joinRoom('r_nope', 'u2')
assert.equal(c.ok, false)
assert.equal(c.reason, 'not_found')
})
test('E-13e · 容量判据:满房 ⇒ room_full(沿用 ROOM_LIMITS.maxMembers = 2000)', async (t) => {
// 用极小上限构造"已满",避免插 2000 行。
const { store } = freshStore(t, { limits: { maxMembers: 2, agentQuotaPer10Min: 10, silencePeriodMs: 30_000 } })
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'o', discoverable: true, joinPolicy: 'open' })
await store.addMember({ roomId: 'r1', memberId: 'o', kind: 'human', role: 'owner' })
await store.addMember({ roomId: 'r1', memberId: 'u2', kind: 'human', role: 'member' })
const full = await store.joinRoom('r1', 'u3')
assert.equal(full.ok, false)
assert.equal(full.reason, 'room_full', '满房 ⇒ 具名拒绝(⛔ 不静默放行)')
assert.match(String(full.detail), /2/)
// 已在房里的人仍可"加入"(幂等优先于容量 —— ⛔ 否则满房后老成员会被自己拒之门外)。
const already = await store.joinRoom('r1', 'u2')
assert.equal(already.ok, true)
assert.equal(already.joined, false)
assert.equal(ROOM_LIMITS.maxMembers, 2000, '生产上限口径不变(A 单初值)')
})
// ── 判据 2 / 12 · 成员模型可见性 + 在线态(数据源 = hub,⛔ 不新增)─────────
test('E-2/12a · 成员表:bot 独立成行;形态 B 组员不出现第二行(⛔ 不合成 agent 成员)', async (t) => {
const { store } = freshStore(t)
await store.upsertRoom({ id: 'r1', roomType: 'office', name: 'x', ownerId: 'o' })
await store.addMember({ roomId: 'r1', memberId: 'o', kind: 'human', role: 'owner' })
await store.addMember({ roomId: 'r1', memberId: 'bot1', kind: 'bot', role: 'member' })
// 形态 B:给人类成员挂 agent —— ⛔ 不得因此多出一行。
await store.addMember({ roomId: 'r1', memberId: 'u2', kind: 'human', role: 'member', agentRef: 'agent-of-u2' })
const members = await store.listMembers('r1')
assert.equal(members.length, 3, '挂 agent 的人类成员不得产生第二行(142 §3.1)')
const kinds = members.map((m) => m.kind)
assert.equal(kinds.filter((k) => k === 'bot').length, 1, 'bot 应独立成行')
const withAgent = members.find((m) => m.memberId === 'u2')
assert.equal(withAgent.kind, 'human', '形态 B 组员仍是 human')
assert.equal(withAgent.agentRef, 'agent-of-u2', 'agent 绑定挂在同一行的 agentRef 上')
})
test('E-12b · 在线态数据源 = hub(presenceSnapshot 存在且只回在线成员)', async (t) => {
const { ImHub } = await import('../lib/im/hub.js')
const { store } = freshStore(t)
const hub = new ImHub(store)
const conn = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: () => true,
close: () => {},
lastSeenAt: Date.now(),
openedAt: Date.now(),
}
assert.equal(hub.attach(conn), true, '挂连接成功')
hub.subscribe(conn, 'r1')
const snap = hub.presenceSnapshot('r1')
assert.deepEqual(snap, { u1: 'online' }, '快照只含在线成员(缺省 = 离线,由客户端处理)')
assert.equal(hub.onlineIn('r1'), 1, '与 onlineIn 同源')
hub.shutdown()
})
test('E-12c · 在线态在 1 s 批窗口内收敛(presence 批合并,⛔ 不新增数据源)', async (t) => {
const { ImHub, IM_PRESENCE_BATCH_MS } = await import('../lib/im/hub.js')
const { store } = freshStore(t)
let clock = 1_000_000
const frames = []
const hub = new ImHub(store, { now: () => clock })
const conn = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: (text) => {
frames.push(JSON.parse(text))
return true
},
close: () => {},
lastSeenAt: clock,
openedAt: clock,
}
hub.attach(conn)
hub.subscribe(conn, 'r1')
// 订阅自己会记一次 online ⇒ 先刷掉,再单独验"批合并"语义。
assert.equal(hub.flushPresence(clock), 0, '窗口未到 ⇒ 不刷(批次窗口语义)')
clock += IM_PRESENCE_BATCH_MS + 1
assert.equal(hub.flushPresence(clock), 1, '订阅产生的 online 在窗口到达后刷出')
// 同一批里同成员多次变更 ⇒ 覆盖,只出一帧一条。
hub.notePresence('r1', 'u1', 'online', clock)
hub.notePresence('r1', 'u1', 'offline', clock + 1)
hub.notePresence('r1', 'u1', 'online', clock + 2)
clock += IM_PRESENCE_BATCH_MS + 1
const n = hub.flushPresence(clock)
assert.equal(n, 1, '一批一帧/连接')
const presenceFrames = frames.filter((f) => f.op === 'presence')
assert.equal(presenceFrames.length, 2, '共两批')
assert.equal(presenceFrames[1].members.length, 1, '同批内同成员覆盖 ⇒ 只剩最后一条')
assert.equal(presenceFrames[1].members[0].state, 'online')
hub.shutdown()
})
// ── 判据 8/9/10/11 · 客户端 bundle 结构判据(源码文本级)────────────────────
test('E-8/11a · 🔴 多选择器兜底:清单含新名(主)+ 旧名(兜底),按序取首个命中', () => {
const src = clientSource()
// ⚠️ 逐行抽(数组字面量跨行),且按 **行首 `]`** 收尾 —— ⛔ 不能用 indexOf(']'),
// CSS 属性选择器 `[data-slot="…"]` 自带 `]`,会提前截断 ⇒ 抽取结果只剩第一项。
const lines = src.split(/\r?\n/)
const start = lines.findIndex((l) => /const CONVERSATION_SLOT_SELECTORS\s*=\s*\[/.test(l))
assert.ok(start >= 0, '必须有 CONVERSATION_SLOT_SELECTORS 清单')
const body = []
for (let i = start + 1; i < lines.length; i++) {
if (/^\s*\]/.test(lines[i])) break
body.push(lines[i])
}
const list = [...body.join('\n').matchAll(/'([^']*)'/g)].map((x) => x[1])
assert.equal(list.length, 2, `清单应恰有 2 项(新名 + 旧名兜底),实测 ${list.length}`)
assert.ok(
list[0].includes('main.conversation'),
'清单须含新槽名 data-slot="main.conversation"',
)
assert.ok(
/\[data-slot="conversation"\]/.test(list[1]),
'清单须含旧槽名 data-slot="conversation"(兜底)',
)
assert.ok(list[0] !== list[1], '两项必须不同(新名 / 旧名)')
assert.ok(/function findConversationSlot\(\)/.test(src), '必须有 findConversationSlot 兜底函数')
assert.ok(/for \(const sel of CONVERSATION_SLOT_SELECTORS\)/.test(src), '必须**逐个**试选择器(⛔ 不用单选择器)')
})
test('E-11b · 🔴 槽位失效**可见提示**(⛔ 不静默失败)', () => {
const src = clientSource()
assert.ok(/slotMissing/.test(src), '必须有失配状态位')
assert.ok(/imtabs-alert/.test(src), '失配时必须渲染可见提示条(imtabs-alert)')
assert.ok(/t\('slot\.missing'\)|T\('slot\.missing'\)/.test(src), '提示文案须走词条(zh/en 双侧)')
// ⛔ 失配路径不得"只 return null 了事"。
assert.ok(/if \(slotMissing\) \{[\s\S]{0,400}?imtabs-alert/.test(src), '失配分支必须落到提示条而不是空返回')
})
test('E-8b · 🔴 flex 布局由 CSS 规则 + !important 驱动(⛔ 不用 inline style)', () => {
const src = clientSource()
// 关键:会话区子选择器的布局压制定必须在 CSS 里带 !important。
assert.ok(/\.imtabs-convWrap>\[data-slot='conversation'\]>div/.test(src), 'CSS 须覆盖旧槽名子选择器')
assert.ok(/\.imtabs-convWrap>\[data-slot='main\.conversation'\]>div/.test(src), 'CSS 须覆盖新槽名子选择器')
const importantCount = (src.match(/!important/g) ?? []).length
assert.ok(importantCount >= 6, `!important 用量应 ≥6(实测 ${importantCount})`)
// ⛔ 不得用 inline style 驱动布局:React 元素上只允许 2 处"非布局"用途(at 选择器容器 + 无)。
const styleAttr = src.match(/style:\s*\{[^}]*\}/g) ?? []
const layoutish = styleAttr.filter((s) => /(flex|width|height|display|minWidth|minHeight|overflow)/.test(s))
assert.equal(
layoutish.length,
1,
`⛔ 只允许 at 选择器包裹层用 inline(1 处),实测布局性 inline style ${layoutish.length} 处:${layoutish.join(' | ')}`,
)
})
test('E-8c · 🔴 卸载反向还原(会话区插回原位 + 恢复 previousCss + 移除 wrapper)', () => {
const src = clientSource()
assert.ok(/prevCss/.test(src), '必须记录会话区的 previousCss')
assert.ok(/style\.cssText/.test(src), '必须原样写回 cssText')
assert.ok(
/insertBefore\(cur\.conversation,\s*cur\.wrap\)/.test(src),
'必须把会话区**插回原位**(插在 wrapper 之前)',
)
assert.ok(/cur\.wrap\.remove\(\)/.test(src), '必须移除 wrapper')
// 幂等:不得重复建 wrapper。
assert.ok(/querySelector\(':scope > \[data-imtabs-root="1"\]'\)/.test(src), '必须复用既有 wrapper(幂等)')
})
test('E-1 · 接管走官方扩展面(⛔ 不改 dsh 主程序 / client bundle / 缓存,不触 R2)', () => {
const src = clientSource()
assert.ok(/data-slot/.test(src), '接管锚点必须是官方 data-slot(公开可寻址 DOM 槽)')
// ⛔ 不得出现对 dsh 官方资源的写操作。
for (const forbidden of ['localStorage.clear', 'indexedDB.deleteDatabase', 'document.cookie =', 'window.__DSH']) {
assert.equal(src.includes(forbidden), false, `⛔ 不得出现 ${forbidden}`)
}
})
test('E-9 · tab 保活 = display:none(候选一):切走不卸载,滚动位置与草稿保留', () => {
const src = clientSource()
assert.ok(/imtabs-paneHidden\{display:none!important\}/.test(src), '必须用 CSS 类做 display:none 保活')
assert.ok(
/classList\.toggle\('imtabs-paneHidden'/.test(src),
'显隐必须走 class toggle(⛔ 不用 unmount / 条件渲染)',
)
// 🔴 关键:群组 pane 的子树**常驻渲染**(靠 CSS 显隐)⇒ 草稿 state 不丢。
assert.ok(
/groups\.map\(\(g\)[\s\S]{0,600}?createPortal\(\s*jsx\.jsx\(RoomPane/.test(src),
'pane 子树必须常驻渲染(保活的成立前提)',
)
// 草稿与滚动位置必须存在 state/ref(保活的被保对象)。
assert.ok(/const \[draft, setDraft\] = React\.useState\(''\)/.test(src), '输入框草稿必须在 React state 里')
assert.ok(/const listRef = React\.useRef\(null\)/.test(src), '消息列表必须有 ref(滚动位置载体)')
assert.ok(/scrollTop/.test(src), '必须有滚动位置读写(保活的验收点)')
})
test('E-10 · 🔴 群组 tab 上限 = 5(超限具名提示,⛔ 不静默丢弃)', () => {
const src = clientSource()
assert.ok(/const MAX_GROUP_TABS = 5\b/.test(src), '上限常量必须恰为 5(E 单 §四-4 拍板)')
assert.ok(/groups\.some\(\(g\) => g\.id === room\.id\)/.test(src), '已开过的房间必须去重(⛔ 不开重复 tab)')
assert.ok(
/if \(groups\.length >= MAX_GROUP_TABS\) \{[\s\S]{0,140}?setToast\(T\('tab\.limit'\)\)/.test(src),
'增 tab 前必须查上限,且超限出具名提示',
)
assert.ok(!/\.slice\(0, MAX_GROUP_TABS\)/.test(src), '⛔ 不得用截断静默丢弃超限项')
})
test('E-8d · tab 行形态:`dsh 会话` + 群组会话 1..n;切换不返回到处', () => {
const src = clientSource()
assert.ok(/T\('tab\.dsh'\)/.test(src), '必须有「dsh 会话」tab')
assert.ok(/T\('tab\.group', \{ n: i \+ 1 \}\)/.test(src), '群组 tab 标题必须是「群组会话 + 序号」')
assert.ok(/activeKey === tb\.key \? ' imtabs-tabActive'/.test(src), '必须有激活态样式切换')
assert.ok(/onClick: \(\) => onTabClick\(tb\.key\)/.test(src), '点击必须走 onTabClick(tb.key)(⛔ 不跳转/不刷新)')
assert.ok(
/const onTabClick = \(key\) => \{\s*setActiveKey\(key\)/.test(src),
'onTabClick 内部必须 setActiveKey(切激活 key,⛔ 不改 location / 不 reload)',
)
assert.ok(!/location\.(href|assign|reload)|window\.open\(/.test(src), '⛔ 切 tab 不得跳转或刷新')
})
test('E-9b · 追加判据 8 · 重连指数退避(2^n 封顶)+ 抖动,60 s 内 ≤ 8 次', () => {
const src = clientSource()
assert.ok(/RECONNECT_BASE_MS = 1000/.test(src), '退避基值 1 s')
assert.ok(/RECONNECT_MAX_MS = 15000/.test(src), '退避封顶 15 s')
assert.ok(/Math\.min\(RECONNECT_BASE_MS \* Math\.pow\(2, attempt - 1\), RECONNECT_MAX_MS\)/.test(src), '必须 2^n 封顶')
assert.ok(/RECONNECT_JITTER_MS/.test(src), '必须有抖动(⛔ 无抖动的同步重连 = 重连风暴)')
assert.ok(/Math\.random\(\)/.test(src), '抖动必须是随机的')
assert.ok(/attempt >= 8/.test(src), '必须封顶 8 次尝试(60 s 内 ≤ 8)')
assert.ok(/attempt = 0/.test(src), '连上后必须重置计数(⛔ 否则长连接会被历史计数拖死)')
})
test('E-9c · 追加判据 9 · 断网 ⇒ 界面出现**具名**「重连中」状态(⛔ 不静默)', () => {
const src = clientSource()
assert.ok(/T\('conn\.reconnecting', \{ n: retries \}\)/.test(src), '必须显示「重连中(第 n 次)」具名状态')
assert.ok(/T\('conn\.offline'\)/.test(src), '必须有「已断开」状态')
assert.ok(/T\('conn\.online'\)/.test(src), '必须有「已连接」状态')
assert.ok(/T\('conn\.retryNow'\)/.test(src), '超限后必须有手动重试逃生口')
// 恢复后按游标补齐 ⇒ 无重复消息。
assert.ok(/pull\(cursorRef\.current\)/.test(src), '重连后必须按游标补齐')
assert.ok(/const seen = new Set\(prev\.map\(\(m\) => m\.id\)\)/.test(src), '合并必须按 id 去重(⛔ 否则补齐会重复)')
})
// ── 判据 4 / 6 · 规范与 i18n ────────────────────────────────────────────────
test('E-6 · i18n:zh/en 键集一致(⛔ 无硬编码中文残留于渲染路径)', () => {
const src = clientSource()
const dictStart = src.indexOf('const DICT = {')
assert.ok(dictStart >= 0, '必须有 DICT 词典')
const zhAt = src.indexOf('\n zh: {', dictStart)
const enAt = src.indexOf('\n en: {', dictStart)
assert.ok(zhAt > dictStart && enAt > zhAt, 'DICT 必须含 zh / en 两段(顺序 zh → en)')
const zhBlock = src.slice(zhAt, enAt)
const enBlock = src.slice(enAt, src.indexOf('\n },', enAt) + 8)
const keysOf = (block) => {
const out = new Set()
const re = /'([a-zA-Z][a-zA-Z0-9._]*)':/g
let m
while ((m = re.exec(block)) !== null) out.add(m[1])
return out
}
const zh = keysOf(zhBlock)
const en = keysOf(enBlock)
assert.ok(zh.size > 40, `zh 词条数应 > 40(实测 ${zh.size})`)
assert.ok(en.size > 40, `en 词条数应 > 40(实测 ${en.size})`)
const missingInEn = [...zh].filter((k) => !en.has(k))
const missingInZh = [...en].filter((k) => !zh.has(k))
assert.deepEqual(missingInEn, [], `en 缺这些键:${missingInEn.join(', ')}`)
assert.deepEqual(missingInZh, [], `zh 缺这些键:${missingInZh.join(', ')}`)
})
test('E-6b · 渲染路径走词条函数(⛔ 不直接写死中文到 UI)—— 允许清单外为零', () => {
const src = clientSource()
// 允许清单:注释、错误码 fallback、符号(✕ / + / @ / · / #)。
const ALLOW = ['张', '阅', '…']
// 抓 JSX children 位置的字面中文(粗筛:`children: '…'` 形式)。
const hits = src.match(/children:\s*'[^']*[\u4e00-\u9fa5][^']*'/g) ?? []
const bad = hits.filter((h) => !ALLOW.some((a) => h.includes(a)))
assert.deepEqual(bad, [], `渲染路径不得硬编码中文:${bad.join(' | ')}`)
})
test('E-4b · ⛔ 不引完整开源 IM 前端(只借交互与组件)—— 零新依赖', () => {
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
assert.deepEqual(pkg.dependencies ?? {}, {}, '⛔ 不得引入任何依赖(含 Rocket.Chat / Mattermost 类整站)')
assert.deepEqual(pkg.devDependencies ?? {}, {}, '⛔ 不得引入任何 devDependencies')
const src = clientSource()
// 🔴 判据面 = **依赖 / 引入面**(require / import / script src),⛔ 不是注释文本 ——
// E 单 §四-5 允许在注释里点名「不引 Rocket.Chat / Mattermost」作为口径说明。
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const imported = [
...[...code.matchAll(/require\('([^']+)'\)/g)].map((x) => x[1]),
...[...code.matchAll(/from\s+'([^']+)'/g)].map((x) => x[1]),
].map((s) => s.toLowerCase())
for (const banned of ['rocket.chat', 'rocketchat', 'mattermost', 'matrix-js-sdk', 'socket.io-client']) {
assert.equal(
imported.some((r) => r === banned || r.startsWith(banned + '/')),
false,
`⛔ 不得在引入面引用 ${banned}`,
)
}
assert.equal(
/<script[^>]+src=\s*["'][^"']*(rocket|mattermost)/i.test(code),
false,
'⛔ 不得从整站应用注入前端脚本',
)
// 前端宿主能力只允许这三个(官方 react 运行时)。
const allowed = new Set(['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client'])
const extra = imported.filter((r) => !allowed.has(r))
assert.deepEqual(extra, [], `⛔ 只允许官方宿主 require:实测多出 ${extra.join(', ')}`)
})
test('E-1b · 插件包结构齐备(package.json + cordis.patch.yml + host 半边)', () => {
assert.ok(existsSync(PKG_PATH), 'package.json 必须存在')
assert.ok(existsSync(PATCH_PATH), 'cordis.patch.yml 必须存在')
assert.ok(existsSync(HOST_PATH), 'host 半边 lib/index.js 必须存在')
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
assert.equal(pkg.name, '@dsh-local/im-conversation-tabs')
assert.equal(pkg.exports['./client'], './lib/client.js', 'client 半边必须在 exports 里声明')
assert.equal(pkg.dsh.client.platform, 'web', 'client 平台必须是 web')
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
const patch = readFileSync(PATCH_PATH, 'utf8')
assert.ok(/id:\s*im-conversation-tabs/.test(patch), 'patch 必须 insert 本插件行')
assert.ok(/name:\s*'@dsh-local\/im-conversation-tabs'/.test(patch))
})
test('E-12d · 客户端消费 hub 协议:subscribe / ping / message / presence 四类帧', () => {
const src = clientSource()
// 🔴 第 13 棒(P0 修复):出站帧由 `type:` 改回**平台已实现的 op 制**。
// 改前那 5 条断言(`type:'subscribe'` / `type:'ping'` / `msg.type === …` / `batch`)
// 正是把 P0 钉在绿灯上的原因 —— 客户端发 `type` ⇒ 服务端 `bad-op` ⇒ `destroy()`。
assert.ok(/sendOp\(ws, 'subscribe'/.test(src), '必须发 subscribe(op 制;平台的 subscribe ⛔ 不读 since)')
assert.ok(/sendOp\(ws, 'ping'/.test(src), '必须发 ping 保活(⚠️ 平台**没有** `case \'pong\'`)')
assert.ok(/case 'message'/.test(src), '必须处理 message 帧(`messages` 是数组)')
assert.ok(/case 'presence'/.test(src), '必须处理 presence 帧(`members` 是数组)')
assert.ok(/case 'messages'/.test(src), '必须处理 messages 帧(补拉结果,与 message 同形)')
// ⚠️ IM 数据走同源相对路径(⛔ 不硬编码主机 / 区域)。
assert.ok(/const API = '\/api\/im'/.test(src), 'API 基址必须是同源相对路径')
assert.equal(/https?:\/\/[a-zA-Z0-9.-]+\/api\/im/.test(src), false, '⛔ 不得硬编码主机')
assert.ok(/window\.location\.host/.test(src), 'WS 地址必须由当前 location 推出(区域无关)')
})
// ── 判据 14 · 协议一致性(第 13 棒新增)────────────────────────────────────────
// 🔴 这条判据是 **两侧源码文本互相对照的机械断言**,⛔ 不是"读了一遍觉得对"。
// 起因(第 12 棒 §15.7 的 P0):E 单客户端 bundle 与平台 `/api/im/ws` 的帧协议不一致 ——
// 客户端发 `{type:…}`,平台认 `{op:…}` ⇒ 首帧即 `reject('bad-op')` + `socket.destroy()`
// ⇒ 投放后「连上就断、一条消息看不到」。
test('E-14 · 协议一致性:客户端出站 ⊂ 平台认的 op;入站分支 ⊇ 平台推的 op(机械对账)', () => {
const client = clientSource()
/** 去掉块注释 / 整行注释 —— 否则**模块头表格**里的 `{op:'subscribe'}` 会被当成"平台推帧"
* (首版实测踩过:抽出 subscribe/unsubscribe/send/resume 四条假的"缺失分支")。 */
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/[^\n]*$/gm, '')
const ws = stripComments(readFileSync(WS_PATH, 'utf8'))
const hub = stripComments(readFileSync(HUB_PATH, 'utf8'))
/** 抽 `switch (op)` 里的 `case '…'`(= 平台**已实现**的客户端 → 服务端 op)。 */
const serverOpStart = ws.indexOf('const op = msg.op')
assert.ok(serverOpStart > 0, '平台 ws.ts 必须仍有 `const op = msg.op`(op 制的锚点)')
const serverOpRegion = ws.slice(serverOpStart, ws.indexOf('function onFrame(', serverOpStart))
const serverOps = new Set([...serverOpRegion.matchAll(/case '([a-z][a-z0-9-]*)':/g)].map((m) => m[1]))
assert.ok(serverOps.size >= 5, `平台认的 op 应 ≥5(实测 ${[...serverOps].join('/')})`)
/** 抽客户端**唯一的出站入口** `sendOp(ws, '<op>'` 的字面量。 */
const outOps = [...client.matchAll(/sendOp\(ws, '([a-z][a-z0-9-]*)'/g)].map((m) => m[1])
assert.ok(outOps.length >= 3, `客户端应至少发 3 类出站帧(实测 ${outOps.join('/')})`)
const outUnique = [...new Set(outOps)]
// ① 出站字面量集合 **⊆** 平台认的 op 集合。
const illegal = outUnique.filter((o) => !serverOps.has(o))
assert.deepEqual(illegal, [], `⛔ 客户端出了平台不认的 op(会被 bad-op 断开):${illegal.join(', ')}`)
// ⚠️ 反向专测:`pong` 只出现在平台**注释**里,实现里没有这个 case ⇒ 发了必被断开。
assert.equal(
serverOpRegion.includes("case 'pong'"),
false,
'⚠️ 前提失效:平台若已实现 `case \'pong\'`,本条判据的两侧口径需重新对账',
)
assert.equal(outUnique.includes('pong'), false, '⛔ 客户端⛔ 不得把心跳写成 pong(平台未实现该 case)')
// ② 客户端**不存在** `type:` 形式的出站帧 / `msg.type` 入站判据。
assert.equal(/send\(\s*JSON\.stringify\(\{\s*type\s*:/.test(client), false, '⛔ 出站帧不得用 type 字段')
assert.equal(/msg\.type\s*===/.test(client), false, '⛔ 入站分支不得按 msg.type 判(平台推的是 op)')
// ③ 客户端入站分支覆盖**平台实际推的 op**(从 ws.ts + hub.ts 的推帧字面量机械抽取)。
const pushed = new Set([...ws.matchAll(/op:\s*'([a-z][a-z0-9-]*)'/g)].map((m) => m[1]))
for (const m of hub.matchAll(/op:\s*'([a-z][a-z0-9-]*)'/g)) pushed.add(m[1])
const caseStart = client.indexOf('switch (msg.op)')
assert.ok(caseStart > 0, '客户端必须有 `switch (msg.op)` 入站分发')
const inCases = new Set([...client.slice(caseStart).matchAll(/case '([a-z][a-z0-9-]*)':/g)].map((m) => m[1]))
const missing = [...pushed].filter((o) => !inCases.has(o))
assert.deepEqual(missing, [], `⛔ 平台会推这些 op 但客户端没有分支:${missing.join(', ')}`)
// 入口本条要求显式点名的 8 类。
for (const need of ['hello', 'subscribed', 'unsubscribed', 'message', 'messages', 'presence', 'sent', 'error']) {
assert.ok(inCases.has(need), `客户端必须覆盖入站 op:${need}`)
}
// ⛔ `error` 不得静默丢弃 ⇒ 必须有可见面(写进 UI 错误位)。
assert.ok(/case 'error'/.test(client) && /T\('err\.ws'/.test(client), '⛔ error 帧必须让用户看得见')
// 客户端常量自述必须与实测一致(⛔ 防"常量写一套、发送写另一套")。
for (const o of outUnique) assert.ok(client.includes(`'${o}'`), `出站 op ${o} 必须在常量区有自述`)
// ④ 保活:周期 + **计时起点 = 本连接自己的 open**(与 `src/im/keepalive.ts` 同口径)。
assert.ok(/WS_IDLE_TIMEOUT_MS = 26000/.test(client), '空窗必须与 `IM_GATEWAY_OBSERVED_IDLE_MS` 同值')
assert.ok(/WS_PING_MS = Math\.max\(1000, Math\.floor\(WS_IDLE_TIMEOUT_MS \/ WS_PING_CYCLES\)\)/.test(client),
'周期必须由「空窗 ÷ 周期数」推出(照 `planKeepalive()`)')
assert.ok(/let pingAnchorAt = openedAt/.test(client), '🔴 计时起点必须绑定本次 open 时刻')
assert.ok(/const openedAt = Date\.now\(\)/.test(client), '必须有本连接的 open 时刻锚点')
assert.ok(/nextPingAt = pingAnchorAt \+/.test(client), '死线必须由 anchor 推算(⛔ 不按上次发送漂移)')
assert.ok(/lastInboundAt > WS_IDLE_TIMEOUT_MS/.test(client), '半开链路检测:整窗无入向流量必须主动重连')
})
test('E-3b · 形态 B 代答标识渲染(不只靠作者名)', () => {
const src = clientSource()
assert.ok(/byAgentId/.test(src), '必须识别代答载荷(byAgentId)')
assert.ok(/onBehalfOf/.test(src), '必须识别代答对象(onBehalfOf)')
assert.ok(/imrt-agent/.test(src), '必须有代答标识的可见样式(徽章)')
assert.ok(/T\('agent\.badge', \{ name: agentName \}\)/.test(src), '代答徽章必须写「由 X 的助手代答」')
})
test('E-5b · 消息面闭环:游标拉取 + 发送 + @ 选择器', () => {
const src = clientSource()
assert.ok(/\/messages' \+ q/.test(src) || /\/messages\?/.test(src), '必须按 since 游标拉取')
assert.ok(/since=\$\{since\}&limit=200/.test(src), 'since 递增拉取(⛔ 无 OFFSET)')
assert.ok(/method: 'POST'/.test(src), '必须有发送(POST)路径')
assert.ok(/imrt-atList/.test(src), '@ 选择器必须渲染候选列表')
assert.ok(/insertAt\(m\)/.test(src), '@ 选择器必须能插入对象')
assert.ok(/lastIndexOf\('@'\)/.test(src), '@ 触发判据必须存在')
})
test('E-6c · 暗色/亮色跟随官方 design token(⛔ 不硬编码主题色)', () => {
const src = clientSource()
// 颜色必须走 --dsw-* token(跟随 dsh 主题);自研品牌色只允许作为 fallback。
assert.ok(/var\(--dsw-alias-label-primary\)/.test(src), '文本色必须走官方 token')
assert.ok(/var\(--dsw-alias-bg-base\)/.test(src), '背景色必须走官方 token')
assert.ok(/var\(--dsw-alias-border-l2/.test(src), '边框色必须走官方 token')
assert.ok(/--imtabs-brand,/.test(src), '自研品牌色必须**带 fallback**(token 缺失时仍可读)')
})