cc-haha/adapters/feishu/__tests__/card-errors.test.ts
程序员阿江(Relakkes) ae586b0d9b feat(feishu): rewrite streaming to CardKit Schema 2.0 with proper markdown
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>
2026-04-11 21:04:22 +08:00

195 lines
5.9 KiB
TypeScript
Raw 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.

/**
* card-errors 单元测试
*/
import { describe, it, expect } from 'bun:test'
import {
CARD_ERROR,
CARD_CONTENT_SUB_ERROR,
extractLarkApiCode,
extractSubCode,
parseCardApiError,
isCardRateLimitError,
isCardTableLimitError,
} from '../card-errors.js'
describe('extractLarkApiCode', () => {
it('从 err.code 直接提取', () => {
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
})
it('从 err.data.code 提取', () => {
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
})
it('从 err.response.data.code 提取Axios 风格)', () => {
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
})
it('数字字符串被强制转成 number', () => {
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
})
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
const err = {
code: 1,
data: { code: 2 },
response: { data: { code: 3 } },
}
expect(extractLarkApiCode(err)).toBe(1)
})
it('none → undefined', () => {
expect(extractLarkApiCode({})).toBeUndefined()
expect(extractLarkApiCode(null)).toBeUndefined()
expect(extractLarkApiCode(undefined)).toBeUndefined()
expect(extractLarkApiCode('just a string')).toBeUndefined()
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
})
it('非有限数字被忽略', () => {
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
})
})
describe('extractSubCode', () => {
it('识别标准的 ErrCode: 11310', () => {
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
expect(extractSubCode(msg)).toBe(11310)
})
it('无 ErrCode 时返回 null', () => {
expect(extractSubCode('random error message')).toBeNull()
expect(extractSubCode('')).toBeNull()
})
it('ErrCode 大小写容错(冒号后多空格)', () => {
expect(extractSubCode('ErrCode: 42')).toBe(42)
})
})
describe('parseCardApiError', () => {
it('从 SDK 风格错误提取完整结构', () => {
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
const parsed = parseCardApiError(err)
expect(parsed).toEqual({
code: 230099,
subCode: 11310,
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
})
})
it('从 Axios 风格错误response.data.msg提取', () => {
const err = {
response: {
data: {
code: 230020,
msg: 'rate limited',
},
},
}
const parsed = parseCardApiError(err)
expect(parsed?.code).toBe(230020)
expect(parsed?.errMsg).toBe('rate limited')
expect(parsed?.subCode).toBeNull()
})
it('无 code 时返回 null', () => {
expect(parseCardApiError({})).toBeNull()
expect(parseCardApiError(null)).toBeNull()
expect(parseCardApiError('string')).toBeNull()
})
it('有 code 无 msg 时 errMsg 为空字符串', () => {
const parsed = parseCardApiError({ code: 230020 })
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
})
it('fallback 到 err.message', () => {
const err = Object.assign(new Error('fallback text'), { code: 230099 })
const parsed = parseCardApiError(err)
expect(parsed?.errMsg).toBe('fallback text')
})
})
describe('isCardRateLimitError', () => {
it('识别 230020', () => {
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
})
it('识别 Axios 风格 230020', () => {
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
})
it('不匹配其他 code', () => {
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
})
it('非错误对象返回 false', () => {
expect(isCardRateLimitError(null)).toBe(false)
expect(isCardRateLimitError({})).toBe(false)
expect(isCardRateLimitError(new Error('random'))).toBe(false)
})
})
describe('isCardTableLimitError', () => {
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
expect(isCardTableLimitError(err)).toBe(true)
})
it('从 Axios 风格的 response.data 匹配', () => {
const err = {
response: {
data: {
code: 230099,
msg: validMsg,
},
},
}
expect(isCardTableLimitError(err)).toBe(true)
})
it('230099 + 11310 但没有 "table number over limit" 字样 → false其它元素超限', () => {
const err = {
code: CARD_ERROR.CARD_CONTENT_FAILED,
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
}
expect(isCardTableLimitError(err)).toBe(false)
})
it('code 不是 230099 → false', () => {
const err = { code: 230020, msg: validMsg }
expect(isCardTableLimitError(err)).toBe(false)
})
it('没有 subCode → false', () => {
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
expect(isCardTableLimitError(err)).toBe(false)
})
it('不区分 "table number" 的大小写', () => {
const err = {
code: 230099,
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
}
expect(isCardTableLimitError(err)).toBe(true)
})
})
describe('常量值', () => {
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
})
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
})
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
})
})