Files
WechatExplorer/tests/unit/wechat-ilink-typing.test.ts
Wxw-Gu 4b2395a8d2 feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
2026-09-17 17:32:37 +08:00

368 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { TypingCoordinator } from '../../src/main/services/wechat-ilink/typing'
import {
ILINK_TYPING_KEEPALIVE_MS,
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING
} from '../../src/main/services/wechat-ilink/types'
interface HarnessOptions {
tickets?: Array<string | undefined | Error>
sendResults?: Array<boolean | Error | undefined>
keepaliveMs?: number
}
function createHarness(options: HarnessOptions = {}): {
coordinator: TypingCoordinator
logs: string[]
typingCalls: Array<{ userId: string; status: number; ticket: string }>
ticks: Array<() => void>
scheduled: number[]
ticketCalls: () => number
cancelled: () => number
advance: (ms: number) => void
} {
const tickets = [...(options.tickets ?? ['ticket-1'])]
const sends = [...(options.sendResults ?? [])]
const logs: string[] = []
const typingCalls: Array<{ userId: string; status: number; ticket: string }> = []
let ticketCalls = 0
const ticks: Array<() => void> = []
const scheduled: number[] = []
let cancelled = 0
let now = 1_000
const coordinator = new TypingCoordinator({
fetchTicket: async () => {
ticketCalls += 1
const next = tickets.length > 1 ? tickets.shift() : tickets[0]
if (next instanceof Error) throw next
return typeof next === 'string' ? next : undefined
},
sendTyping: async ({ ilinkUserId, ticket, status }) => {
typingCalls.push({ userId: ilinkUserId, status, ticket })
const next = sends.length > 1 ? sends.shift() : sends[0]
if (next instanceof Error) throw next
return next ?? true
},
log: (level, message) => logs.push(`${level}:${message}`),
now: () => now,
...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
schedule: (tick, intervalMs) => {
scheduled.push(intervalMs)
ticks.push(tick)
return () => {
cancelled += 1
}
}
})
return {
coordinator,
logs,
typingCalls,
ticks,
scheduled,
ticketCalls: () => ticketCalls,
cancelled: () => cancelled,
advance: (ms: number) => {
now += ms
}
}
}
const PEER = 'fixture-user-openid@im.wechat'
describe('TypingCoordinator', () => {
it('正常时序:取票 → status=1 → stop 时 status=2', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.ticketCalls()).toBe(1)
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
expect(harness.coordinator.activePeerCount).toBe(1)
expect(harness.logs.some((line) => line.includes('typing.start'))).toBe(true)
harness.advance(1_200)
await lease.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.stop'))).toBe(true)
expect(harness.logs.find((line) => line.includes('typing.stop'))).toContain('duration=1200ms')
})
it('typing_ticket 按「账号+对端」缓存,不会每次重取', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(1)
expect(harness.typingCalls).toHaveLength(4)
})
it('换账号或重新登录后缓存作废,会重新取票', async () => {
const harness = createHarness({ tickets: ['ticket-1', 'ticket-2'] })
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
harness.coordinator.invalidateTickets()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(2)
expect(harness.typingCalls[2].ticket).toBe('ticket-2')
})
it('没有 typing_ticket 时降级:不下发任何 typing,也不抛异常', async () => {
const harness = createHarness({ tickets: [undefined] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.typingCalls).toHaveLength(0)
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.ticket.missing'))).toBe(true)
})
it('getconfig 失败时降级:业务照常,不抛异常', async () => {
const harness = createHarness({ tickets: [new Error('getconfig 网络错误')] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.typingCalls).toHaveLength(0)
expect(harness.logs.some((line) => line.includes('typing.ticket.failed'))).toBe(true)
})
it('sendtyping start 失败(返回 ret!=0)时不当成成功,也不抛异常', async () => {
const harness = createHarness({ sendResults: [false] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.coordinator.activePeerCount).toBe(0)
expect(harness.logs.some((line) => line.includes('typing.start.failed'))).toBe(true)
await lease.stop()
// 没进入 active 就不该下发 cancel。
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
})
it('sendtyping 抛异常时不冒泡', async () => {
const harness = createHarness({ sendResults: [new Error('boom')] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('start 失败后 ticket 缓存作废,下次会重取', async () => {
const harness = createHarness({
tickets: ['stale-ticket', 'fresh-ticket'],
sendResults: [false, true]
})
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await second.stop()
expect(harness.ticketCalls()).toBe(2)
expect(harness.typingCalls[1].ticket).toBe('fresh-ticket')
})
it('stop 失败时记 typing.stop.failed,但 stop 本身不抛异常', async () => {
const harness = createHarness({ sendResults: [true, false] })
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await expect(lease.stop()).resolves.toBeUndefined()
expect(harness.logs.some((line) => line.includes('typing.stop.failed'))).toBe(true)
})
it('长任务按 5 秒节奏维持 typing', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.scheduled).toEqual([ILINK_TYPING_KEEPALIVE_MS])
harness.ticks[0]()
harness.ticks[0]()
await Promise.resolve()
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING)
).toHaveLength(3)
await lease.stop()
expect(harness.cancelled()).toBe(1)
})
it('keepalive 失败只在同一会话里记一次日志', async () => {
const harness = createHarness({ sendResults: [true, false, false, false] })
await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
harness.ticks[0]()
await Promise.resolve()
harness.ticks[0]()
harness.ticks[0]()
await Promise.resolve()
expect(harness.logs.filter((line) => line.includes('typing.keepalive.failed'))).toHaveLength(1)
})
it('stop 之后 keepalive 不再产生请求', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
const countAfterStop = harness.typingCalls.length
harness.ticks[0]()
await Promise.resolve()
expect(harness.typingCalls).toHaveLength(countAfterStop)
})
it('并发:先结束的任务不会取消仍在执行任务的 typing', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
// 引用计数为 2 时不应重复 start。
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_TYPING)
).toHaveLength(1)
await first.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([ILINK_TYPING_STATUS_TYPING])
expect(harness.coordinator.activePeerCount).toBe(1)
await second.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('并发:最后一个任务结束后再次 begin 会重新 start', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
await second.stop()
const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await third.stop()
expect(harness.typingCalls.map((call) => call.status)).toEqual([
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL,
ILINK_TYPING_STATUS_TYPING,
ILINK_TYPING_STATUS_CANCEL
])
})
it('不同对端的 typing 状态互相独立', async () => {
const other = 'other-openid@im.wechat'
const harness = createHarness()
const a = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const b = await harness.coordinator.begin({ accountId: 'bot-1', to: other })
expect(harness.coordinator.activePeerCount).toBe(2)
await a.stop()
expect(harness.coordinator.activePeerCount).toBe(1)
expect(
harness.typingCalls.some(
(call) => call.userId === other && call.status === ILINK_TYPING_STATUS_TYPING
)
).toBe(true)
// 只取消了自己的那一个。
const cancels = harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL)
expect(cancels.map((call) => call.userId)).toEqual([PEER])
await b.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('lease.stop 幂等:重复调用只取消一次', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await lease.stop()
await lease.stop()
expect(
harness.typingCalls.filter((call) => call.status === ILINK_TYPING_STATUS_CANCEL)
).toHaveLength(1)
})
it('stop 超过 begin 次数时不会把引用计数压成负数', async () => {
const harness = createHarness()
const first = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
const second = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
await first.stop()
await first.stop()
await second.stop()
expect(harness.coordinator.activePeerCount).toBe(0)
const third = await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
expect(harness.coordinator.activePeerCount).toBe(1)
await third.stop()
})
it('clear 会取消 keepalive 定时器', async () => {
const harness = createHarness()
await harness.coordinator.begin({ accountId: 'bot-1', to: PEER })
harness.coordinator.clear()
expect(harness.cancelled()).toBe(1)
expect(harness.coordinator.activePeerCount).toBe(0)
})
it('日志里不出现 typing_ticket / context_token 原文', async () => {
const secretTicket = 'TICKET-SECRET-VALUE'
const secretContext = 'CONTEXT-TOKEN-SECRET'
const harness = createHarness({ tickets: [secretTicket] })
const lease = await harness.coordinator.begin({
accountId: 'bot-1',
to: PEER,
contextToken: secretContext
})
harness.ticks[0]()
await Promise.resolve()
await lease.stop()
const joined = harness.logs.join('\n')
expect(joined).not.toContain(secretTicket)
expect(joined).not.toContain(secretContext)
// 也不该出现 openid 原文。
expect(joined).not.toContain(PEER)
// 但要有可诊断的信息。
expect(joined).toContain('typing.start')
expect(joined).toContain('ticketPresent=true')
})
it('空 to 直接返回空实现,不打任何接口', async () => {
const harness = createHarness()
const lease = await harness.coordinator.begin({ accountId: 'bot-1', to: ' ' })
await lease.stop()
expect(harness.ticketCalls()).toBe(0)
expect(harness.typingCalls).toHaveLength(0)
})
})