Files
WechatExplorer/tests/unit/automation-service.test.ts
T

667 lines
24 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.
// `vi.mock` 的工厂会被提升到 import 之前执行,所以根目录必须用 `vi.hoisted` 先备好,
// 否则工厂里引用的 `root` 还在 TDZ 里。
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-automation-service-'))
})
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
import fs from 'fs-extra'
import path from 'path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
import { AutomationService } from '../../src/main/services/automation-service'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import type { NormalizedIncomingMessage } from '../../src/main/services/message-listener-service'
import type { AutomationRunInput, AutomationRunResult } from '../../src/main/services/automation-action-runner'
/**
* AutomationService 的编排语义:四道闸 + 执行日志落盘 + 发送能力预检。
*/
const SELF = 'my_account_9527'
const GROUP_ID = '12345678@chatroom'
const OTHER_GROUP = '87654321@chatroom'
/** 完整可用的发送能力替身(不用强转,避免类型加字段后静默失真)。 */
function capability(overrides: Partial<PersonalWechatSendCapability> = {}): PersonalWechatSendCapability {
return {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: false },
message: '个人微信已准备好发送日报',
senderStatus: {
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: false,
wechatRunning: true,
endpoint: '127.0.0.1:0',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: false,
message: '个人微信已准备好发送日报'
},
...overrides
}
}
function message(overrides: Partial<NormalizedIncomingMessage> = {}): NormalizedIncomingMessage {
return {
sessionId: GROUP_ID,
localId: '100',
createTime: 1_789_900_000,
messageType: 1,
isSelf: false,
senderId: 'wxid_sender',
senderNickname: '张三',
content: '今日日报',
isGroup: true,
mentionTargets: [SELF],
...overrides
}
}
interface Harness {
service: AutomationService
store: AutomationRuleStore
log: AutomationExecutionLogService
runs: AutomationRunInput[]
setCapability: (value: PersonalWechatSendCapability) => void
setRunResult: (value: AutomationRunResult) => void
}
function buildHarness(
options: {
now?: () => number
capability?: PersonalWechatSendCapability
/** 执行期间的挂起点:resolve 之前 `runner.run` 不会返回(用来模拟长任务)。 */
holdExecution?: () => Promise<void>
/** 每次真正进入 runner 时通知测试,便于确定时序。 */
onRunStart?: () => void
/** gate 表容量上限,用来在单测里触发清理路径。 */
gateMaxEntries?: number
} = {}
): Harness {
const store = new AutomationRuleStore()
const log = new AutomationExecutionLogService()
const runs: AutomationRunInput[] = []
let currentCapability = options.capability ?? capability()
let runResult: AutomationRunResult = {
steps: [
{ key: 'received', label: '收到消息', status: 'success' },
{ key: 'matched', label: '规则匹配', status: 'success' },
{ key: 'reply', label: '回复确认', status: 'success' },
{ key: 'report', label: '生成日报', status: 'success' },
{ key: 'send', label: '发送日报图片', status: 'success' }
],
status: 'success'
}
// 只观察 Runner 被怎么调用,不在这里验证它的内部步骤(那是 runner 测试的职责)。
const spyRunner = {
run: async (input: AutomationRunInput): Promise<AutomationRunResult> => {
runs.push(input)
options.onRunStart?.()
await options.holdExecution?.()
return runResult
}
} as unknown as AutomationActionRunner
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: GROUP_ID, nickname: '测试群' }]
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner: spyRunner,
getCapability: async () => currentCapability,
isListening: () => true,
...(options.now ? { now: options.now } : {}),
...(options.gateMaxEntries !== undefined ? { gateMaxEntries: options.gateMaxEntries } : {})
})
return {
service,
store,
log,
runs,
setCapability: (value) => {
currentCapability = value
},
setRunResult: (value) => {
runResult = value
}
}
}
describe('AutomationService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
afterAll(() => fs.removeSync(root))
it('真 @ + 关键词命中时执行一次,并留下完整的执行记录', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(1)
expect(harness.runs[0]).toMatchObject({
conversationId: GROUP_ID,
isGroup: true,
sourceDisplayName: '测试群'
})
const records = harness.log.list()
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({
ruleName: '@我生成日报',
sourceDisplayName: '测试群',
status: 'success'
})
expect(records[0].steps.map((step) => step.key)).toEqual([
'received',
'matched',
'reply',
'report',
'send'
])
expect(records[0].executionId).toBe(harness.runs[0].executionId)
})
it('同一条消息被重复投递时只执行一次,且**不产生任何额外记录**', async () => {
const harness = buildHarness()
// 关掉 cooldown,单独验证「同消息幂等」这条闸 —— 否则第二次会先被 gate 拦下。
const seeded = harness.store.listRules()[0]
harness.store.updateRule(seeded.id, { ...seeded, cooldownSeconds: 0 })
await harness.service.handleMessage(message())
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(1)
// 重复投递是「这条消息已经处理过」,不是「跳过了一次执行」——用户日志里不该出现。
expect(harness.log.list()).toHaveLength(1)
})
it('同一条消息在不同群里各算一次(localId 单独跨群不唯一)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
await harness.service.handleMessage(message({ sessionId: OTHER_GROUP }))
expect(harness.runs).toHaveLength(2)
})
it('cooldown 内不重复触发,且对用户完全静默', async () => {
let now = 1_700_000_000_000
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now += 30_000
await harness.service.handleMessage(message({ localId: '2' }))
// 只执行一次 —— 这条是硬要求。
expect(harness.runs).toHaveLength(1)
// 但第二次**不留任何痕迹**:没有 execution、没有「已跳过」、没有「冷却中」。
expect(harness.log.list()).toHaveLength(1)
// 只进诊断计数(不上 UI)。
expect(harness.service.getBlockedMessageCount()).toBe(1)
})
it('cooldown 过后可以再次触发', async () => {
let now = 1_700_000_000_000
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now += 61_000
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(2)
})
it('自己发送的消息不触发(否则「收到,正在生成今日日报」会形成死循环)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message({ isSelf: true, content: '收到,正在生成今日日报' }))
expect(harness.runs).toHaveLength(0)
expect(harness.log.list()).toHaveLength(0)
})
it('正文里手打 @昵称 但没有真 @ 元数据时不触发', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
expect(harness.runs).toHaveLength(0)
})
it('停用的规则不触发', async () => {
const harness = buildHarness()
const rule = harness.store.listRules()[0]
harness.store.setRuleEnabled(rule.id, false)
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
})
it('不在生效范围内的群不触发', async () => {
const harness = buildHarness()
const rule = harness.store.listRules()[0]
harness.store.updateRule(rule.id, { ...rule, conditions: { ...rule.conditions, conversationIds: [OTHER_GROUP] } })
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
})
it('发送能力不足时不执行,并记下说明清楚的失败记录', async () => {
const harness = buildHarness({
capability: capability({
ready: false,
capabilities: { text: false, image: false, voice: false },
message: '请先绑定个人微信'
})
})
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
const records = harness.log.list()
expect(records).toHaveLength(1)
expect(records[0].status).toBe('failed')
expect(records[0].errorSummary).toContain('当前环境无法发送文字')
})
it('只能发文字、不能发图片时不执行(不能假装日报发得出去)', async () => {
const harness = buildHarness({
capability: capability({ capabilities: { text: true, image: false, voice: false } })
})
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(0)
expect(harness.log.list()[0].errorSummary).toContain('当前环境无法发送图片')
})
it('拿不到会话昵称时也不把 wxid 写进执行记录', async () => {
const store = new AutomationRuleStore()
const log = new AutomationExecutionLogService()
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => []
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner: {
run: async (input: AutomationRunInput) => ({
steps: [],
status: 'success' as const,
...(input ? {} : {})
})
} as unknown as AutomationActionRunner,
getCapability: async () => capability()
})
await service.handleMessage(message())
const record = log.list()[0]
expect(record.sourceDisplayName).toBe('群聊')
expect(JSON.stringify(record)).not.toContain('@chatroom')
expect(JSON.stringify(record)).not.toContain('wxid')
})
it('执行记录里不出现 localId / serverId / source 等内部字段', async () => {
const harness = buildHarness()
await harness.service.handleMessage(
message({ serverId: '7000000000000000001', localId: '424242' })
)
const serialized = JSON.stringify(harness.log.list())
expect(serialized).not.toContain('424242')
expect(serialized).not.toContain('7000000000000000001')
expect(serialized).not.toContain('localId')
expect(serialized).not.toContain('serverId')
})
it('执行失败时把失败摘要带进执行记录', async () => {
const harness = buildHarness()
harness.setRunResult({
steps: [
{ key: 'received', label: '收到消息', status: 'success' },
{ key: 'matched', label: '规则匹配', status: 'success' },
{ key: 'reply', label: '回复确认', status: 'success' },
{ key: 'report', label: '生成日报', status: 'failed', error: 'AI 服务不可达' },
{ key: 'send', label: '发送日报图片', status: 'skipped' }
],
status: 'failed',
errorSummary: 'AI 服务不可达'
})
await harness.service.handleMessage(message())
const record = harness.log.list()[0]
expect(record.status).toBe('failed')
expect(record.errorSummary).toBe('AI 服务不可达')
expect(record.steps.find((step) => step.key === 'send')?.status).toBe('skipped')
})
it('执行日志落盘后能被新的实例读到(重启不丢)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
const reloaded = new AutomationExecutionLogService()
expect(reloaded.list()).toHaveLength(1)
expect(reloaded.list()[0].ruleName).toBe('@我生成日报')
})
it('getStatus 如实反映监听状态、发送能力与今日执行数', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
const status = await harness.service.getStatus()
expect(status.listening).toBe(true)
// 底层只能回读最近活跃会话,这个降级标记必须一直是 true。
expect(status.listeningDegraded).toBe(true)
expect(status.todayExecutions).toBe(1)
expect(status.todaySuccesses).toBe(1)
expect(status.sendCapability).toMatchObject({
supported: true,
ready: true,
canSendText: true,
canSendImage: true
})
})
it('把命中时的规则整条交给 Runner(含回复前等待)', async () => {
const harness = buildHarness()
await harness.service.handleMessage(message())
expect(harness.runs).toHaveLength(1)
// 「回复前等待」是规则自己的字段,编排层只负责把规则原样传下去 ——
// 不该再有一份全局设置参与,否则同一字段有两个来源就必然对不上。
expect(harness.runs[0].rule.replyDelaySeconds).toBe(2)
})
it('handleMessage 内部异常不会向外抛', async () => {
const harness = buildHarness()
const brokenStore = {
listRules: () => {
throw new Error('磁盘炸了')
}
} as unknown as AutomationRuleStore
const service = new AutomationService(
{ getMyUsernameCandidates: () => [SELF], getSessions: () => [] } as unknown as Wcdb4Client,
{ ruleStore: brokenStore }
)
await expect(service.handleMessage(message())).resolves.toBeUndefined()
void harness
})
})
/**
* Automation rule conversation gate —— 规则 × 会话维度的阻塞窗口。
*
* ```
* blocked = inFlight || now < triggeredAt + cooldown // 粒度:ruleId + conversationId
* ```
*
* 窗口内的消息:不匹配、不执行、不回复、**不写用户执行日志**,只进诊断计数。
* `triggeredAt` 是**第一次触发**的时间;执行完成不重置它。
*/
describe('AutomationService · rule conversation gate', () => {
const T0 = 1_700_000_000_000
// 这个 describe 在文件后段,**不会**继承上一个 describe 的 beforeEach;
// 而规则是写盘的(同一个 userData root),不清理就会互相污染
// (Test 6 建的第二条规则会漏给 Test 7/8)。
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
it('Test 1 · 0s 触发,2/20/59s 全忽略,61s 重新触发(executions = 2 而不是 5)', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
for (const offset of [2_000, 20_000, 59_000]) {
now = T0 + offset
await harness.service.handleMessage(message({ localId: `t${offset}` }))
}
now = T0 + 61_000
await harness.service.handleMessage(message({ localId: 'e' }))
expect(harness.runs).toHaveLength(2)
expect(harness.log.list()).toHaveLength(2)
expect(harness.service.getBlockedMessageCount()).toBe(3)
})
it('Test 2 · 短任务(8s 就完成)仍然阻塞到 cooldown 到期', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' })) // 0s 触发,立即完成
now = T0 + 8_000
now = T0 + 30_000 // 任务早完成了,但 cooldown 还没到
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(1)
now = T0 + 61_000
await harness.service.handleMessage(message({ localId: '3' }))
expect(harness.runs).toHaveLength(2)
})
it('Test 3 · 长任务(超过 cooldown)一直阻塞到执行结束:解锁 = max(完成, 触发+cooldown)', async () => {
let now = T0
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
const harness = buildHarness({
now: () => now,
holdExecution: () => held,
onRunStart: markStarted
})
const firstRun = harness.service.handleMessage(message({ localId: '1' }))
await started
// 61s:cooldown 早就过期了,但第一条执行还在跑 ⇒ 仍然忽略
now = T0 + 61_000
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(1)
// 75s:第一条终于结束
now = T0 + 75_000
release()
await firstRun
// 76s:这才重新允许 —— **不是**「完成后再等 60 秒」
now = T0 + 76_000
await harness.service.handleMessage(message({ localId: '3' }))
expect(harness.runs).toHaveLength(2)
})
it('Test 4 · 同群不同发送者也忽略(gate 不是 sender 级)', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now = T0 + 20_000
await harness.service.handleMessage(
message({ localId: '2', senderId: 'wxid_other', senderNickname: '李四' })
)
expect(harness.runs).toHaveLength(1)
})
it('Test 5 · 同一条规则在另一个群正常执行(gate 是 ruleId + conversationId)', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
now = T0 + 10_000
await harness.service.handleMessage(message({ localId: '2', sessionId: OTHER_GROUP }))
expect(harness.runs).toHaveLength(2)
})
it('Test 6 · 同一群里的另一条规则不受影响(不是会话全局锁)', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
const seeded = harness.store.listRules()[0]
// 第二条规则同样命中这条消息,但**没有冷却** —— 用它证明 gate 按 ruleId 隔离。
harness.store.createRule({
name: '帮助',
enabled: true,
scope: 'group',
conditions: {
requireMentionMe: true,
keyword: '日报',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [{ type: 'replyText', enabled: true, text: '好的' }],
cooldownSeconds: 0,
replyDelaySeconds: 0
})
await harness.service.handleMessage(message({ localId: '1' }))
expect(harness.runs).toHaveLength(2)
now = T0 + 10_000
await harness.service.handleMessage(message({ localId: '2' }))
// 规则 A 被 gate 挡下,规则 B 照常执行。
expect(harness.runs).toHaveLength(3)
expect(harness.runs[2].rule.id).not.toBe(seeded.id)
})
it('Test 7 · 第一条失败后仍然走完整 cooldown(避免故障期疯狂重试)', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
harness.setRunResult({ steps: [], status: 'failed', errorSummary: 'AI 服务不可达' })
await harness.service.handleMessage(message({ localId: '1' }))
expect(harness.runs).toHaveLength(1)
now = T0 + 5_000
await harness.service.handleMessage(message({ localId: '2' }))
expect(harness.runs).toHaveLength(1)
now = T0 + 61_000
await harness.service.handleMessage(message({ localId: '3' }))
expect(harness.runs).toHaveLength(2)
})
it('Test 8 · 60 秒内 10 条符合条件的消息,用户执行日志只留 1 条', async () => {
let now = T0
const harness = buildHarness({ now: () => now })
await harness.service.handleMessage(message({ localId: '1' }))
for (let index = 2; index <= 11; index += 1) {
now = T0 + index * 5_000 // 5s..55s,全部落在 60s 窗口内
await harness.service.handleMessage(message({ localId: String(index) }))
}
expect(harness.runs).toHaveLength(1)
const records = harness.log.list()
expect(records).toHaveLength(1)
expect(records[0].status).toBe('success')
// 不许出现「冷却中 / 剩余 N 秒」这类记录。
expect(JSON.stringify(records)).not.toContain('冷却')
expect(harness.service.getBlockedMessageCount()).toBe(10)
})
})
/**
* gate 表清理的硬不变量:**正在执行(`inFlight === true`)的 gate 永不被淘汰**。
*
* 淘汰它 = 把一条正在跑的规则提前放开,立刻会产生第二次并发执行。
* 这里用极小的上限(1)逼出清理路径。
*/
describe('AutomationService · gate 缓存清理', () => {
const T0 = 1_700_000_000_000
const GROUP_A = 'aaa@chatroom'
const GROUP_B = 'bbb@chatroom'
const GROUP_C = 'ccc@chatroom'
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
})
it('清理只删「inFlight=false 且 cooldown 已过期」的条目,正在执行的一条必须留着', async () => {
let now = T0
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
let markStarted!: () => void
const started = new Promise<void>((resolve) => {
markStarted = resolve
})
// 只把**第一次**执行挂住(也就是 A 群那条);B/C 的执行立即完成。
let runCount = 0
const harness = buildHarness({
now: () => now,
gateMaxEntries: 1,
holdExecution: () => {
runCount += 1
return runCount === 1 ? held : Promise.resolve()
},
onRunStart: markStarted
})
// cooldown = 0:这样「执行完成」的 gate 立刻就算「已解锁」,可以被清理。
const seeded = harness.store.listRules()[0]
harness.store.updateRule(seeded.id, { ...seeded, cooldownSeconds: 0 })
// A 群:开始执行并**挂住**(gate A 处于 inFlight)
const runningA = harness.service.handleMessage(
message({ localId: 'a1', sessionId: GROUP_A })
)
await started
expect(harness.runs).toHaveLength(1)
// B 群:执行一次并正常结束(gate B 变成「已解锁」,是本次清理的目标)
now = T0 + 1_000
await harness.service.handleMessage(message({ localId: 'b1', sessionId: GROUP_B }))
expect(harness.runs).toHaveLength(2)
// C 群:claim 一条新 gate ⇒ 表 size 超过上限 ⇒ 触发 evictGatesIfNeeded
now = T0 + 2_000
await harness.service.handleMessage(message({ localId: 'c1', sessionId: GROUP_C }))
// 关键不变量:A 的执行还在跑,往 A 发消息必须**仍然被挡住**。
now = T0 + 3_000
await harness.service.handleMessage(message({ localId: 'a2', sessionId: GROUP_A }))
expect(harness.runs.filter((run) => run.conversationId === GROUP_A)).toHaveLength(1)
release()
await runningA
})
})