mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.
New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
(rate limit -> skip frame) and 230099/11310 (table limit -> disable
CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
im.message.{create,reply} + cardElement.content + card.settings +
card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
idle -> creating -> streaming -> finalizing -> completed/aborted,
including patch fallback when CardKit create/send fails
index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
enqueue() serial queue, preventing reply reordering when commands
are fired rapidly
- createSessionForChat() now first resets any stale WS session before
calling connectSession, fixing /projects pick_project leaving the
old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
one user turn's full text stay in a single card
markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
in a fenced code block to avoid 230099/11310
Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
/**
|
||
* StreamingCard 生命周期测试
|
||
*
|
||
* 用 mock Lark client 覆盖:
|
||
* - ensureCreated: 成功路径 / 降级路径
|
||
* - appendText: 累积 + 触发 throttled flush
|
||
* - finalize: settings(false) + update 顺序、sequence 单调递增
|
||
* - abort: 渲染错误卡片
|
||
* - 230020 → 跳帧
|
||
* - 230099 table limit → 禁用流式,finalize 时仍走 CardKit
|
||
* - 纯 patch fallback 路径
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach } from 'bun:test'
|
||
import {
|
||
StreamingCard,
|
||
buildInitialStreamingCard,
|
||
buildRenderedCard,
|
||
buildErrorCard,
|
||
} from '../streaming-card.js'
|
||
import { STREAMING_ELEMENT_ID } from '../cardkit.js'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mock client
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type ApiCall = { api: string; args: any }
|
||
|
||
type MockBehavior = {
|
||
'card.create'?: any | ((args: any) => any)
|
||
'card.settings'?: any | ((args: any) => any)
|
||
'card.update'?: any | ((args: any) => any)
|
||
'cardElement.content'?: any | ((args: any, callIdx: number) => any)
|
||
'im.message.create'?: any | ((args: any) => any)
|
||
'im.message.reply'?: any | ((args: any) => any)
|
||
'im.message.patch'?: any | ((args: any, callIdx: number) => any)
|
||
}
|
||
|
||
function makeMockClient(behavior: MockBehavior = {}) {
|
||
const calls: ApiCall[] = []
|
||
let contentCallIdx = 0
|
||
let patchCallIdx = 0
|
||
|
||
function handle(api: string, resp: any, args: any, idx?: number): any {
|
||
calls.push({ api, args })
|
||
if (typeof resp === 'function') return resp(args, idx ?? 0)
|
||
return resp
|
||
}
|
||
|
||
const client: any = {
|
||
cardkit: {
|
||
v1: {
|
||
card: {
|
||
create: async (args: any) =>
|
||
handle('cardkit.v1.card.create', behavior['card.create'] ?? {
|
||
code: 0, data: { card_id: 'ck_default' },
|
||
}, args),
|
||
settings: async (args: any) =>
|
||
handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args),
|
||
update: async (args: any) =>
|
||
handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args),
|
||
},
|
||
cardElement: {
|
||
content: async (args: any) => {
|
||
const idx = contentCallIdx++
|
||
return handle('cardkit.v1.cardElement.content',
|
||
behavior['cardElement.content'] ?? { code: 0 }, args, idx)
|
||
},
|
||
},
|
||
},
|
||
},
|
||
im: {
|
||
message: {
|
||
create: async (args: any) =>
|
||
handle('im.message.create', behavior['im.message.create'] ?? {
|
||
data: { message_id: 'om_default' },
|
||
}, args),
|
||
reply: async (args: any) =>
|
||
handle('im.message.reply', behavior['im.message.reply'] ?? {
|
||
data: { message_id: 'om_reply_default' },
|
||
}, args),
|
||
patch: async (args: any) => {
|
||
const idx = patchCallIdx++
|
||
return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx)
|
||
},
|
||
},
|
||
},
|
||
}
|
||
return { client, calls }
|
||
}
|
||
|
||
async function sleep(ms: number) {
|
||
await new Promise((r) => setTimeout(r, ms))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Card JSON builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('buildInitialStreamingCard', () => {
|
||
it('Schema 2.0 + streaming_mode + element_id', () => {
|
||
const card = buildInitialStreamingCard() as any
|
||
expect(card.schema).toBe('2.0')
|
||
expect(card.config.streaming_mode).toBe(true)
|
||
// 第二个元素是空的 streaming_content 目标(loading 在首位以避免顶部空 padding)
|
||
const streaming = card.body.elements[1]
|
||
expect(streaming.tag).toBe('markdown')
|
||
expect(streaming.content).toBe('')
|
||
expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID)
|
||
})
|
||
|
||
it('loading 提示元素在首位(避免空 streaming_content 挤出顶部 padding)', () => {
|
||
const card = buildInitialStreamingCard() as any
|
||
const elements = card.body.elements as any[]
|
||
expect(elements.length).toBe(2)
|
||
const loading = elements[0]
|
||
expect(loading.tag).toBe('markdown')
|
||
expect(loading.content).toContain('正在思考中')
|
||
expect(loading.text_size).toBe('notation')
|
||
// loading 元素不能有 element_id(那是给 streaming_content 独占的)
|
||
expect(loading.element_id).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('buildRenderedCard', () => {
|
||
it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => {
|
||
const card = buildRenderedCard('hello world') as any
|
||
expect(card.schema).toBe('2.0')
|
||
expect(card.config.streaming_mode).toBeUndefined()
|
||
expect(card.body.elements.length).toBe(1)
|
||
const el = card.body.elements[0]
|
||
expect(el.tag).toBe('markdown')
|
||
expect(el.content).toBe('hello world')
|
||
// 最终卡无需 element_id
|
||
expect(el.element_id).toBeUndefined()
|
||
})
|
||
|
||
it('空字符串保底为单空格', () => {
|
||
const card = buildRenderedCard('') as any
|
||
expect(card.body.elements[0].content).toBe(' ')
|
||
})
|
||
})
|
||
|
||
describe('buildErrorCard', () => {
|
||
it('红色 header + markdown body', () => {
|
||
const card = buildErrorCard('oops') as any
|
||
expect((card.header as any).template).toBe('red')
|
||
expect((card.header as any).title.content).toContain('出错')
|
||
expect(card.body.elements[0].content).toBe('oops')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// StreamingCard lifecycle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('StreamingCard: ensureCreated (CardKit 主路径)', () => {
|
||
it('依次调用 card.create + im.message.create,sequence=1', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
|
||
'im.message.create': { data: { message_id: 'om_main_1' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
|
||
await sc.ensureCreated()
|
||
|
||
expect(sc._getPhase()).toBe('streaming')
|
||
expect(sc._getCardId()).toBe('ck_main_1')
|
||
expect(sc._getMessageId()).toBe('om_main_1')
|
||
expect(sc._getSequence()).toBe(1)
|
||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||
|
||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||
expect(calls[1]!.api).toBe('im.message.create')
|
||
|
||
// 初始卡 JSON 包含 streaming_mode 和 element_id
|
||
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
||
expect(cardJson.schema).toBe('2.0')
|
||
expect(cardJson.config.streaming_mode).toBe(true)
|
||
// loading 元素在首位,streaming_content 占位在第二
|
||
expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID)
|
||
|
||
// IM message 引用 card_id
|
||
const content = JSON.parse(calls[1]!.args.data.content)
|
||
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
|
||
})
|
||
|
||
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_1' } },
|
||
'im.message.create': { data: { message_id: 'om_1' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
await sc.ensureCreated()
|
||
await sc.ensureCreated()
|
||
// 只一次 create + 一次 send
|
||
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
|
||
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
|
||
expect(createCalls.length).toBe(1)
|
||
expect(sendCalls.length).toBe(1)
|
||
})
|
||
|
||
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.reply': { data: { message_id: 'om_reply' } },
|
||
})
|
||
const sc = new StreamingCard({
|
||
larkClient: client,
|
||
chatId: 'c',
|
||
replyToMessageId: 'om_parent',
|
||
})
|
||
await sc.ensureCreated()
|
||
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
|
||
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
|
||
expect(sc._getMessageId()).toBe('om_reply')
|
||
})
|
||
})
|
||
|
||
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
|
||
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 99991672, msg: 'permission denied' },
|
||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
|
||
expect(sc._getPhase()).toBe('streaming')
|
||
expect(sc._getCardId()).toBeNull()
|
||
expect(sc._getMessageId()).toBe('om_fb')
|
||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||
|
||
// fallback 发送的是 Schema 2.0 interactive 卡
|
||
const createCall = calls.find((c) => c.api === 'im.message.create')
|
||
expect(createCall).toBeDefined()
|
||
expect(createCall!.args.data.msg_type).toBe('interactive')
|
||
const cardContent = JSON.parse(createCall!.args.data.content)
|
||
expect(cardContent.schema).toBe('2.0')
|
||
})
|
||
|
||
it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
|
||
let sendCallCount = 0
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.create': () => {
|
||
sendCallCount++
|
||
if (sendCallCount === 1) throw new Error('send failed')
|
||
return { data: { message_id: 'om_fb2' } }
|
||
},
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
expect(sc._getPhase()).toBe('streaming')
|
||
expect(sc._getCardId()).toBeNull()
|
||
expect(sc._getMessageId()).toBe('om_fb2')
|
||
})
|
||
|
||
it('降级发送也失败 → aborted + throw', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 99991672 },
|
||
'im.message.create': () => {
|
||
throw new Error('really broken')
|
||
},
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await expect(sc.ensureCreated()).rejects.toThrow()
|
||
expect(sc._getPhase()).toBe('aborted')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// appendText + flush
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('StreamingCard: appendText + flush', () => {
|
||
it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
|
||
// 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
|
||
sc.appendText('Hello ')
|
||
sc.appendText('world')
|
||
|
||
// 节流窗口 100ms + 余量
|
||
await sleep(150)
|
||
|
||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||
expect(contentCalls.length).toBeGreaterThan(0)
|
||
// 最后一次 flush 的内容应包含完整累积文本
|
||
const lastCall = contentCalls[contentCalls.length - 1]!
|
||
expect(lastCall.args.data.content).toContain('Hello world')
|
||
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
|
||
// sequence 严格单调递增
|
||
const seqs = contentCalls.map((c) => c.args.data.sequence)
|
||
for (let i = 1; i < seqs.length; i++) {
|
||
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
|
||
}
|
||
})
|
||
|
||
it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
|
||
sc.appendText('same')
|
||
await sleep(150)
|
||
|
||
// 强制再跑一次 flush(无新文本)
|
||
await sc._getFlushController().flush()
|
||
|
||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||
// 应该只有一次 content 调用
|
||
expect(contentCalls.length).toBe(1)
|
||
})
|
||
|
||
it('completed 之后的 appendText 被忽略', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
await sc.finalize()
|
||
sc.appendText('ignored')
|
||
expect(sc._getAccumulatedText()).toBe('')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// finalize
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('StreamingCard: finalize', () => {
|
||
it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_final' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('# Title\n\nBody')
|
||
await sleep(150)
|
||
const contentSeqs = calls
|
||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||
.map((c) => c.args.data.sequence)
|
||
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
|
||
|
||
await sc.finalize()
|
||
|
||
expect(sc._getPhase()).toBe('completed')
|
||
|
||
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
|
||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||
expect(settingsCalls.length).toBe(1)
|
||
expect(updateCalls.length).toBe(1)
|
||
|
||
const settingsSeq = settingsCalls[0]!.args.data.sequence
|
||
const updateSeq = updateCalls[0]!.args.data.sequence
|
||
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
|
||
expect(updateSeq).toBeGreaterThan(settingsSeq)
|
||
|
||
// settings 关闭 streaming_mode
|
||
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
|
||
expect(settings.streaming_mode).toBe(false)
|
||
|
||
// update 卡内容是预处理后的 markdown
|
||
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
|
||
const finalContent = finalCardJson.body.elements[0].content
|
||
// H1 被降级为 H4
|
||
expect(finalContent).toContain('#### Title')
|
||
expect(finalContent).toContain('Body')
|
||
})
|
||
|
||
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 99991672 },
|
||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('## Heading\n\nContent')
|
||
await sleep(1600) // 等 PATCH_MS 窗口
|
||
await sc.finalize()
|
||
|
||
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
|
||
expect(patchCalls.length).toBeGreaterThan(0)
|
||
// 最后一次 patch 是 finalize 的(full final card)
|
||
const lastPatch = patchCalls[patchCalls.length - 1]!
|
||
const finalCard = JSON.parse(lastPatch.args.data.content)
|
||
const finalContent = finalCard.body.elements[0].content
|
||
// ## → ##### 降级
|
||
expect(finalContent).toContain('##### Heading')
|
||
})
|
||
|
||
it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
|
||
const { client } = makeMockClient()
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.finalize()
|
||
expect(sc._getPhase()).toBe('completed')
|
||
})
|
||
|
||
it('finalize 失败不抛出', async () => {
|
||
const { client } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
'card.settings': () => {
|
||
throw new Error('settings exploded')
|
||
},
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('text')
|
||
await sleep(150)
|
||
// finalize 内部捕获错误不 rethrow
|
||
await sc.finalize()
|
||
expect(sc._getPhase()).toBe('completed')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rate limit + table limit
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('StreamingCard: 错误处理', () => {
|
||
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
|
||
let callIdx = 0
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
'cardElement.content': () => {
|
||
const i = callIdx++
|
||
if (i === 0) {
|
||
const err: any = new Error('rate limit')
|
||
err.code = 230020
|
||
throw err
|
||
}
|
||
return { code: 0 }
|
||
},
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('first')
|
||
await sleep(150)
|
||
// 第一次被限流
|
||
sc.appendText(' second')
|
||
await sleep(150)
|
||
// 第二次应能成功
|
||
|
||
// CardKit 仍然 active(没降级)
|
||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
||
})
|
||
|
||
it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
'cardElement.content': () => {
|
||
const err: any = new Error('content failed')
|
||
err.code = 230099
|
||
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
|
||
throw err
|
||
},
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('some content')
|
||
await sleep(150)
|
||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
|
||
|
||
await sc.finalize()
|
||
// finalize 仍然走 CardKit 的 settings + update(cardId 还在)
|
||
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
||
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
||
// 不走 patch
|
||
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// abort
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('StreamingCard: abort', () => {
|
||
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
|
||
const { client, calls } = makeMockClient({
|
||
'card.create': { code: 0, data: { card_id: 'ck_err' } },
|
||
'im.message.create': { data: { message_id: 'om' } },
|
||
})
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.ensureCreated()
|
||
sc.appendText('partial...')
|
||
await sleep(150)
|
||
|
||
await sc.abort(new Error('something went wrong'))
|
||
expect(sc._getPhase()).toBe('aborted')
|
||
|
||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||
expect(updateCalls.length).toBeGreaterThan(0)
|
||
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
|
||
expect(errCard.header.template).toBe('red')
|
||
expect(errCard.body.elements[0].content).toContain('something went wrong')
|
||
// 保留已累积的部分文本
|
||
expect(errCard.body.elements[0].content).toContain('partial...')
|
||
})
|
||
|
||
it('idle 阶段 abort 不抛错', async () => {
|
||
const { client } = makeMockClient()
|
||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||
await sc.abort(new Error('before any card'))
|
||
expect(sc._getPhase()).toBe('aborted')
|
||
})
|
||
})
|