// 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( /]+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, ''` 的字面量。 */ 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 缺失时仍可读)') })