Files
WechatExplorer/tests/unit/automation-service.test.ts
T
2026-09-21 00:35:32 +08:00

388 lines
14 KiB
TypeScript

// `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 } = {}): 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)
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 } : {})
})
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()
await harness.service.handleMessage(message())
await harness.service.handleMessage(message())
await harness.service.handleMessage(message({ localId: '100' }))
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)
})
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('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
})
})