build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(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/;交接单不入库(政策)。
588 lines
35 KiB
JavaScript
588 lines
35 KiB
JavaScript
// 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 缺失时仍可读)')
|
||
})
|