mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 修复定时日报错误通知微信机器人功能
增加定时日报debug 修复定时日报历史头像显示并区分定时日报标题
This commit is contained in:
@@ -782,6 +782,41 @@ describe('daily report controls', () => {
|
||||
expect(deleteTrigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('labels scheduled reports and uses the hydrated contact avatar as a fallback', () => {
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'scheduled-report-history',
|
||||
contactId: groupContact.md5,
|
||||
contactName: groupContact.m_nsNickName,
|
||||
source: 'scheduled',
|
||||
dateRange: '昨日',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-17T10:00:00.000Z',
|
||||
reportDate: '2026-08-16',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready'
|
||||
}
|
||||
|
||||
render(
|
||||
<ReportHistorySidebar
|
||||
reports={[report]}
|
||||
contacts={[{ ...groupContact, avatar: 'data:image/png;base64,avatar' }]}
|
||||
selectedReportId={report.id}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onSelectReport={vi.fn()}
|
||||
onCreateReport={vi.fn()}
|
||||
onDeleteReport={vi.fn(async () => ({ success: true }))}
|
||||
onOpenSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('测试群的定时日报')).toBeVisible()
|
||||
expect(screen.getByRole('img', { name: '测试群的定时日报' })).toHaveAttribute(
|
||||
'src',
|
||||
'data:image/png;base64,avatar'
|
||||
)
|
||||
})
|
||||
|
||||
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
|
||||
const user = userEvent.setup()
|
||||
const report: GeneratedReportRecord = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
@@ -38,6 +38,7 @@ const task = {
|
||||
describe('ScheduledReportsWorkspace', () => {
|
||||
const onNotice = vi.fn()
|
||||
const onOpenWechatSettings = vi.fn()
|
||||
const onOpenAgentHub = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -45,23 +46,37 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSendCapability: vi.fn(async () => capability('ready')),
|
||||
getScheduledReportNotificationSettings: vi.fn(async () => ({ enabled: false })),
|
||||
setScheduledReportNotificationEnabled: vi.fn(async (enabled: boolean) => ({
|
||||
success: true,
|
||||
data: { enabled }
|
||||
})),
|
||||
listScheduledReports: vi.fn(async () => [task]),
|
||||
listScheduledReportExecutions: vi.fn(async () => []),
|
||||
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
|
||||
updateScheduledReport: vi.fn(async () => ({ success: true, data: task })),
|
||||
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
|
||||
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
|
||||
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } }))
|
||||
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
|
||||
testScheduledReportErrorNotification: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: { status: 'failed', notificationStatus: 'sent' }
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('loads capability and renders a scheduled report task', async () => {
|
||||
render(
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
|
||||
@@ -70,7 +85,7 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
expect(screen.getByText('每天 09:00')).toBeVisible()
|
||||
})
|
||||
|
||||
it('disables creation and offers settings when WeChat is unsupported', async () => {
|
||||
it('keeps creation available and offers settings when WeChat is unsupported', async () => {
|
||||
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
|
||||
capability('unsupported')
|
||||
)
|
||||
@@ -79,12 +94,13 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens the shared dialog and creates a task', async () => {
|
||||
@@ -94,6 +110,7 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
|
||||
@@ -110,4 +127,71 @@ describe('ScheduledReportsWorkspace', () => {
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the notification switch off by default and loads the persisted setting', async () => {
|
||||
render(
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
|
||||
const notificationSwitch = screen.getByRole('switch', { name: '微信异常通知' })
|
||||
await waitFor(() => expect(notificationSwitch).toHaveAttribute('aria-checked', 'false'))
|
||||
expect(window.api.getScheduledReportNotificationSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores the switch off and offers Agent Hub when enabling fails', async () => {
|
||||
vi.mocked(window.api.setScheduledReportNotificationEnabled).mockResolvedValue({
|
||||
success: false,
|
||||
data: { enabled: false },
|
||||
reason: 'agent_hub_offline',
|
||||
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||||
})
|
||||
render(
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('微信异常通知')).toBeVisible())
|
||||
fireEvent.click(screen.getByRole('switch', { name: '微信异常通知' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')).toBeVisible()
|
||||
)
|
||||
expect(screen.getByRole('switch', { name: '微信异常通知' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往 Agent Hub' }))
|
||||
expect(onOpenAgentHub).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the debug notification action only when enabled and sends the test request', async () => {
|
||||
vi.stubEnv('VITE_SCHEDULED_REPORT_DEBUG', 'true')
|
||||
render(
|
||||
<ScheduledReportsWorkspace
|
||||
contacts={[group]}
|
||||
onNotice={onNotice}
|
||||
onOpenWechatSettings={onOpenWechatSettings}
|
||||
onOpenAgentHub={onOpenAgentHub}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(task.name)).toBeVisible())
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试错误信息发送' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(window.api.testScheduledReportErrorNotification).toHaveBeenCalledWith(task.id)
|
||||
)
|
||||
expect(onNotice).toHaveBeenCalledWith(
|
||||
'测试错误信息已发送到 Agent Hub 微信通知接收者',
|
||||
'success'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { launchTestApp } from './support/electron'
|
||||
|
||||
const debugNotificationEnabled = ['1', 'true', 'yes', 'on'].includes(
|
||||
String(process.env.VITE_SCHEDULED_REPORT_DEBUG || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
|
||||
test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport overflow', async () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin' && process.platform !== 'win32',
|
||||
@@ -65,3 +71,61 @@ test('SCHEDULED-REPORT-UI-01 opens the scheduled report dialog without viewport
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SCHEDULED-REPORT-UI-02 keeps notification switch off when Agent Hub is offline', async () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin' && process.platform !== 'win32',
|
||||
'The scheduled report send capability requires macOS or Windows'
|
||||
)
|
||||
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
|
||||
try {
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
|
||||
const notificationSwitch = fixture.page.getByRole('switch', { name: '微信异常通知' })
|
||||
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
await notificationSwitch.click()
|
||||
await expect(
|
||||
fixture.page
|
||||
.getByRole('alert')
|
||||
.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
|
||||
).toBeVisible()
|
||||
await expect(notificationSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
await fixture.page.getByRole('button', { name: '前往 Agent Hub' }).click()
|
||||
await expect(
|
||||
fixture.page.getByRole('heading', { name: 'Agent Hub', exact: true })
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SCHEDULED-REPORT-UI-03 sends a simulated error notification in debug mode', async () => {
|
||||
test.skip(
|
||||
(process.platform !== 'darwin' && process.platform !== 'win32') || !debugNotificationEnabled,
|
||||
'The scheduled report debug action requires a supported platform and VITE_SCHEDULED_REPORT_DEBUG=true'
|
||||
)
|
||||
const fixture = await launchTestApp({ now: Date.parse('2026-08-27T08:30:00+08:00') })
|
||||
try {
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('tab', { name: '定时日报' }).click()
|
||||
await fixture.page
|
||||
.getByRole('button', { name: /新建定时日报/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '新建定时日报' })
|
||||
await dialog.getByPlaceholder('例如:技术交流 · 每日日报').fill('调试定时日报')
|
||||
await dialog.getByRole('button', { name: '创建定时日报' }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const debugButton = fixture.page.getByRole('button', { name: '测试错误信息发送' })
|
||||
await expect(debugButton).toBeVisible()
|
||||
await debugButton.click()
|
||||
await expect(
|
||||
fixture.page.getByText('测试错误信息已发送到 Agent Hub 微信通知接收者')
|
||||
).toBeVisible()
|
||||
await expect(fixture.page.getByText('定时日报错误通知测试')).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -330,6 +330,7 @@ handle('group-exit-monitor:markRead', (readAt) => {
|
||||
const scheduledReportTasks = []
|
||||
const scheduledReportExecutions = []
|
||||
const generatedReports = []
|
||||
let scheduledReportNotificationEnabled = false
|
||||
const scheduledReportNextRun = (scheduleTime) => {
|
||||
const [hours, minutes] = String(scheduleTime || '09:00')
|
||||
.split(':')
|
||||
@@ -437,10 +438,9 @@ handle('wechat-personal:getSendCapability', () => ({
|
||||
canSendVoice: personalWechatSupported,
|
||||
message: '个人微信已绑定'
|
||||
},
|
||||
message:
|
||||
personalWechatSupported
|
||||
? '个人微信已准备好发送日报'
|
||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
||||
message: personalWechatSupported
|
||||
? '个人微信已准备好发送日报'
|
||||
: '微信消息发送目前仅支持 macOS 和 Windows'
|
||||
}))
|
||||
handle('scheduled-report:list', () => [...scheduledReportTasks])
|
||||
handle('scheduled-report:listExecutions', (taskId) =>
|
||||
@@ -448,6 +448,21 @@ handle('scheduled-report:listExecutions', (taskId) =>
|
||||
? scheduledReportExecutions.filter((execution) => execution.taskId === taskId)
|
||||
: [...scheduledReportExecutions]
|
||||
)
|
||||
handle('scheduled-report:getNotificationSettings', () => ({
|
||||
enabled: scheduledReportNotificationEnabled
|
||||
}))
|
||||
handle('scheduled-report:setNotificationEnabled', (enabled) => {
|
||||
if (enabled) {
|
||||
return {
|
||||
success: false,
|
||||
data: { enabled: false },
|
||||
reason: 'agent_hub_offline',
|
||||
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
|
||||
}
|
||||
}
|
||||
scheduledReportNotificationEnabled = false
|
||||
return { success: true, data: { enabled: false } }
|
||||
})
|
||||
handle('scheduled-report:create', (request) => {
|
||||
const now = new Date(fixtureNowMs).toISOString()
|
||||
const task = {
|
||||
@@ -495,18 +510,69 @@ handle('scheduled-report:setEnabled', (taskId, enabled) => {
|
||||
handle('scheduled-report:runNow', (taskId) => {
|
||||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||||
if (!task) return { success: false, error: '任务不存在' }
|
||||
const pngPath = path.join(
|
||||
userData,
|
||||
`fixture-scheduled-report-${scheduledReportExecutions.length + 1}.png`
|
||||
)
|
||||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||
const execution = {
|
||||
id: `fixture-execution-${scheduledReportExecutions.length + 1}`,
|
||||
taskId,
|
||||
triggerType: 'manual',
|
||||
startedAt: new Date(fixtureNowMs).toISOString(),
|
||||
finishedAt: new Date(fixtureNowMs).toISOString(),
|
||||
status: 'success',
|
||||
currentStage: 'send',
|
||||
scheduledSlot: new Date(fixtureNowMs).toISOString().slice(0, 10),
|
||||
retryCount: 0,
|
||||
reportId: `fixture-report-record-${scheduledReportExecutions.length + 1}`,
|
||||
pngPath,
|
||||
sendTarget: task.target,
|
||||
sendStatus: 'success',
|
||||
notificationStatus: 'not_needed',
|
||||
message: '日报生成成功,微信发送成功'
|
||||
}
|
||||
scheduledReportExecutions.push(execution)
|
||||
task.lastRunAt = execution.finishedAt
|
||||
return { success: true, data: execution }
|
||||
})
|
||||
handle('scheduled-report:retrySend', (executionId) => {
|
||||
const execution = scheduledReportExecutions.find((item) => item.id === executionId)
|
||||
if (!execution) return { success: false, error: '执行记录不存在' }
|
||||
execution.status = 'success'
|
||||
execution.currentStage = 'send'
|
||||
execution.finishedAt = new Date(fixtureNowMs).toISOString()
|
||||
execution.retryCount = Number(execution.retryCount || 0) + 1
|
||||
execution.sendStatus = 'success'
|
||||
execution.message = '日报生成成功,微信发送成功'
|
||||
return { success: true, data: execution }
|
||||
})
|
||||
handle('scheduled-report:testErrorNotification', (taskId) => {
|
||||
const task = scheduledReportTasks.find((item) => item.id === taskId)
|
||||
if (!task) return { success: false, error: '任务不存在' }
|
||||
const now = new Date(fixtureNowMs).toISOString()
|
||||
const execution = {
|
||||
id: `fixture-debug-execution-${scheduledReportExecutions.length + 1}`,
|
||||
taskId,
|
||||
triggerType: 'scheduled',
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
status: 'failed',
|
||||
currentStage: 'notify',
|
||||
failedStage: 'report',
|
||||
errorCode: 'DEBUG_TEST_NOTIFICATION',
|
||||
technicalMessage: '这是调试用的模拟错误信息;本次没有调用 AI。',
|
||||
userTitle: '定时日报错误通知测试',
|
||||
userMessage: '这是一条调试用的模拟错误通知,用于验证 Agent Hub 推送链路。',
|
||||
suggestedAction: '确认微信中是否收到这条测试通知。',
|
||||
retryable: false,
|
||||
retryCount: 0,
|
||||
sendStatus: 'unavailable',
|
||||
notificationStatus: 'sent'
|
||||
}
|
||||
scheduledReportExecutions.unshift(execution)
|
||||
return { success: true, data: execution }
|
||||
})
|
||||
handle('wechat-share:getConfig', () => ({
|
||||
success: true,
|
||||
configured: true,
|
||||
|
||||
@@ -141,6 +141,13 @@ describe('preload IPC contract', () => {
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
|
||||
await api.selectPersonalWechatImage()
|
||||
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:selectImage')
|
||||
|
||||
await api.getScheduledReportNotificationSettings()
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
|
||||
await api.setScheduledReportNotificationEnabled(true)
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
|
||||
await api.testScheduledReportErrorNotification('task-1')
|
||||
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
|
||||
})
|
||||
|
||||
it('preserves key API return values without exposing ipcRenderer', async () => {
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('scheduled report Local HTTP API', () => {
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects unsupported capability, ambiguous groups, and duplicate tasks', async () => {
|
||||
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
|
||||
fixture.capability = {
|
||||
...fixture.capability,
|
||||
ready: false,
|
||||
@@ -232,13 +232,16 @@ describe('scheduled report Local HTTP API', () => {
|
||||
message: '请先绑定个人微信'
|
||||
}
|
||||
const first = await startFixture()
|
||||
const blocked = await fetch(`${baseUrl(first.handle)}/api/v1/scheduled-reports`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
})
|
||||
expect(blocked.status).toBe(409)
|
||||
expect(await blocked.json()).toMatchObject({ error: 'wechat_not_ready' })
|
||||
const createdWhileUnavailable = await fetch(
|
||||
`${baseUrl(first.handle)}/api/v1/scheduled-reports`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
}
|
||||
)
|
||||
expect(createdWhileUnavailable.status).toBe(201)
|
||||
expect(await createdWhileUnavailable.json()).toMatchObject({ created: true })
|
||||
|
||||
fixture.capability = { ...fixture.capability, ready: true, status: 'ready' }
|
||||
fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
|
||||
|
||||
@@ -8,6 +8,9 @@ const nodeRequire = createRequire(import.meta.url)
|
||||
const asar = nodeRequire('@electron/asar') as {
|
||||
createPackage: (source: string, destination: string) => Promise<void>
|
||||
}
|
||||
const { hasWindowsSherpaRuntime } = nodeRequire('../../scripts/prepare-win-runtime.cjs') as {
|
||||
hasWindowsSherpaRuntime: (runtimeRoot: string) => boolean
|
||||
}
|
||||
const {
|
||||
validateAsarRuntimeDependencies,
|
||||
validateFfmpegRuntime,
|
||||
@@ -68,6 +71,30 @@ describe('production runtime packaging', () => {
|
||||
expect(config).not.toContain('macos/')
|
||||
})
|
||||
|
||||
it('installs cross-platform optional dependencies before Windows packaging', () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../package.json'), 'utf8')
|
||||
) as { scripts: Record<string, string> }
|
||||
|
||||
expect(packageJson.scripts['prepare:win-runtime']).toBe(
|
||||
'node scripts/prepare-win-runtime.cjs && npm run prepare:ffmpeg:win'
|
||||
)
|
||||
expect(packageJson.scripts['build:win']).toContain('npm run prepare:win-runtime')
|
||||
expect(packageJson.scripts['release:win']).toContain('npm run prepare:win-runtime')
|
||||
})
|
||||
|
||||
it('detects an incomplete Windows sherpa runtime before invoking pnpm', () => {
|
||||
const runtimeRoot = join(root, 'win-sherpa-runtime')
|
||||
mkdirSync(runtimeRoot, { recursive: true })
|
||||
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(false)
|
||||
|
||||
writeFileSync(join(runtimeRoot, 'package.json'), '{}')
|
||||
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(false)
|
||||
|
||||
writeFileSync(join(runtimeRoot, 'sherpa-onnx.node'), 'fixture')
|
||||
expect(hasWindowsSherpaRuntime(runtimeRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an app archive with missing runtime dependencies', async () => {
|
||||
const resources = join(root, 'asar-resources')
|
||||
const source = join(root, 'asar-source')
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('ScheduledReportApiService', () => {
|
||||
})
|
||||
|
||||
it.each(['unsupported', 'unconfigured', 'needs_binding', 'needs_verification', 'error'] as const)(
|
||||
'rejects creation before touching the task service for capability state %s',
|
||||
'allows creation and leaves a send warning for capability state %s',
|
||||
async (status) => {
|
||||
const { api, service } = makeApi({ capability: capability(status) })
|
||||
await expect(
|
||||
@@ -131,8 +131,8 @@ describe('ScheduledReportApiService', () => {
|
||||
group: '技术交流群',
|
||||
schedule: { type: 'daily', time: '09:00' }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
||||
expect(service.createTask).not.toHaveBeenCalled()
|
||||
).resolves.toMatchObject({ id: 'task-1' })
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('ScheduledReportApiService', () => {
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps unsupported platforms blocked even if a provider reports ready', async () => {
|
||||
it('allows task creation on an unsupported platform for later recovery', async () => {
|
||||
const { service } = makeApi()
|
||||
const api = new ScheduledReportApiService({
|
||||
service,
|
||||
@@ -164,8 +164,8 @@ describe('ScheduledReportApiService', () => {
|
||||
})
|
||||
await expect(
|
||||
api.create({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
|
||||
).rejects.toMatchObject({ code: 'wechat_not_ready', status: 409 })
|
||||
expect(service.createTask).not.toHaveBeenCalled()
|
||||
).resolves.toMatchObject({ id: 'task-1' })
|
||||
expect(service.createTask).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns candidates for ambiguous group names and rejects duplicate tasks', async () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeScheduledReportError } from '../../src/shared/scheduled-report-error'
|
||||
|
||||
describe('scheduled report error normalizer', () => {
|
||||
it('maps provider context, quota, auth, timeout and availability errors', () => {
|
||||
expect(
|
||||
normalizeScheduledReportError({
|
||||
error: 'maximum context length exceeded',
|
||||
stage: 'ai'
|
||||
}).code
|
||||
).toBe('AI_CONTEXT_LIMIT')
|
||||
expect(
|
||||
normalizeScheduledReportError({
|
||||
error: 'insufficient_quota',
|
||||
code: 'insufficient_quota',
|
||||
status: 429,
|
||||
stage: 'ai'
|
||||
}).code
|
||||
).toBe('AI_QUOTA_EXCEEDED')
|
||||
expect(
|
||||
normalizeScheduledReportError({
|
||||
error: 'Invalid API key',
|
||||
status: 401,
|
||||
stage: 'ai'
|
||||
}).code
|
||||
).toBe('AI_AUTH_INVALID')
|
||||
expect(normalizeScheduledReportError({ error: 'AI 请求超时', stage: 'ai' }).code).toBe(
|
||||
'AI_TIMEOUT'
|
||||
)
|
||||
expect(
|
||||
normalizeScheduledReportError({ error: 'fetch failed: ECONNREFUSED', stage: 'ai' }).code
|
||||
).toBe('AI_PROVIDER_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('maps legacy errors and keeps unknown errors unknown', () => {
|
||||
expect(normalizeScheduledReportError('wechat_not_ready:needs_verification').code).toBe(
|
||||
'WECHAT_SEND_UNAVAILABLE'
|
||||
)
|
||||
expect(normalizeScheduledReportError({ error: 'connector timeout', stage: 'send' }).code).toBe(
|
||||
'WECHAT_SEND_FAILED'
|
||||
)
|
||||
expect(normalizeScheduledReportError({ error: 'unexpected', stage: 'report' })).toMatchObject({
|
||||
code: 'UNKNOWN',
|
||||
stage: 'report',
|
||||
retryable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose provider credentials in technical details', () => {
|
||||
expect(
|
||||
normalizeScheduledReportError({
|
||||
error: 'Bearer secret-token sk-test_secret',
|
||||
stage: 'ai'
|
||||
}).technicalMessage
|
||||
).toBe('Bearer [已隐藏] ***')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile } from 'fs/promises'
|
||||
import { mkdtemp, readFile, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
ScheduledReportService,
|
||||
validateScheduleTime
|
||||
} from '../../src/main/services/scheduled-report-service'
|
||||
import type { AgentHubStatus } from '../../src/shared/agent-hub'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
import type {
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportNotification
|
||||
} from '../../src/shared/scheduled-report'
|
||||
import type { ScheduledReportDependencies } from '../../src/main/services/scheduled-report-service'
|
||||
|
||||
const capability: PersonalWechatSendCapability = {
|
||||
supported: true,
|
||||
@@ -19,6 +26,74 @@ const capability: PersonalWechatSendCapability = {
|
||||
message: 'ready'
|
||||
}
|
||||
|
||||
const unavailableCapability: PersonalWechatSendCapability = {
|
||||
...capability,
|
||||
ready: false,
|
||||
status: 'needs_verification',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
message: '请先完成微信消息能力检测'
|
||||
}
|
||||
|
||||
const reportRecord = (pngPath: string): GeneratedReportRecord => ({
|
||||
id: 'report-1',
|
||||
contactId: 'group-md5',
|
||||
contactName: '研发群',
|
||||
dateRange: '昨日',
|
||||
messageCount: 12,
|
||||
generatedAt: '2026-08-27T01:00:00.000Z',
|
||||
reportDate: '2026-08-26',
|
||||
pngPath,
|
||||
htmlPath: `${pngPath}.html`,
|
||||
htmlStatus: 'ready' as const,
|
||||
pngStatus: 'ready' as const
|
||||
})
|
||||
|
||||
function makeDependencies(overrides: Record<string, unknown> = {}): ScheduledReportDependencies {
|
||||
return {
|
||||
getCapability: async () => capability,
|
||||
generateReport: async () => ({
|
||||
success: true,
|
||||
groupName: '研发群',
|
||||
pngPath: '/tmp/generated.png',
|
||||
messageCount: 12
|
||||
}),
|
||||
saveGeneratedReport: async () => ({
|
||||
success: true,
|
||||
record: reportRecord('/tmp/saved.png')
|
||||
}),
|
||||
send: async () => ({ success: true, status: capability.senderStatus }),
|
||||
sendNotification: async () => ({ success: true, status: 'sent' as const }),
|
||||
getNotificationRecipient: () => 'owner-wxid',
|
||||
isDatabaseReady: () => true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function enableNotifications(storageDir: string): Promise<void> {
|
||||
await writeFile(join(storageDir, 'settings.json'), JSON.stringify({ enabled: true }))
|
||||
}
|
||||
|
||||
async function runScheduled(
|
||||
service: ScheduledReportService,
|
||||
taskId: string
|
||||
): Promise<ScheduledReportExecution> {
|
||||
const task = (await service.listTasks()).find((item) => item.id === taskId)
|
||||
if (!task) throw new Error('scheduled task not found')
|
||||
await service.tick(new Date(Date.parse(task.nextRunAt) + 1_000))
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const execution = (await service.listExecutions(taskId))[0]
|
||||
if (execution && execution.status !== 'running') return execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('scheduled execution did not finish')
|
||||
}
|
||||
|
||||
const onlineAgentHubStatus = (): AgentHubStatus => ({
|
||||
hub: 'online' as const,
|
||||
connector: 'online' as const,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
describe('scheduled report scheduling', () => {
|
||||
it('validates daily HH:mm and computes the next local occurrence', () => {
|
||||
expect(validateScheduleTime('09:05')).toBe(true)
|
||||
@@ -28,6 +103,99 @@ describe('scheduled report scheduling', () => {
|
||||
expect(calculateNextRunAt('11:05', from)).toBe('2026-08-27T03:05:00.000Z')
|
||||
})
|
||||
|
||||
it('defaults notification settings to off and persists a successful enablement', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-settings-'))
|
||||
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
getAgentHubStatus: onlineAgentHubStatus,
|
||||
getNotificationRecipient: () => 'owner-wxid',
|
||||
sendNotification
|
||||
})
|
||||
|
||||
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||
const enabled = await service.setNotificationEnabled(true)
|
||||
|
||||
expect(enabled).toEqual({ success: true, data: { enabled: true } })
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
to: 'owner-wxid',
|
||||
text: `✅ TraceMemo 定时日报通知已开启
|
||||
|
||||
以后定时日报生成或发送出现异常时,
|
||||
我会通过这里通知你。`
|
||||
})
|
||||
const restored = new ScheduledReportService({ storageDir })
|
||||
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
|
||||
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
|
||||
success: true,
|
||||
data: { enabled: false }
|
||||
})
|
||||
await expect(restored.getNotificationSettings()).resolves.toEqual({ enabled: true })
|
||||
const afterClose = new ScheduledReportService({ storageDir })
|
||||
await expect(afterClose.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'rejects an offline Agent Hub',
|
||||
status: { hub: 'offline' as const, connector: 'online' as const, updatedAt: Date.now() },
|
||||
recipient: 'owner-wxid',
|
||||
reason: 'agent_hub_offline' as const
|
||||
},
|
||||
{
|
||||
name: 'rejects an offline connector',
|
||||
status: { hub: 'online' as const, connector: 'disconnected' as const, updatedAt: Date.now() },
|
||||
recipient: 'owner-wxid',
|
||||
reason: 'connector_offline' as const
|
||||
},
|
||||
{
|
||||
name: 'rejects a missing notification recipient',
|
||||
status: onlineAgentHubStatus(),
|
||||
recipient: undefined,
|
||||
reason: 'recipient_not_bound' as const
|
||||
}
|
||||
])('$name before saving enabled=true', async ({ status, recipient, reason }) => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-capability-'))
|
||||
const sendNotification = vi.fn()
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
getAgentHubStatus: () => status,
|
||||
getNotificationRecipient: () => recipient,
|
||||
sendNotification
|
||||
})
|
||||
|
||||
const result = await service.setNotificationEnabled(true)
|
||||
|
||||
expect(result).toMatchObject({ success: false, data: { enabled: false }, reason })
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('does not enable notifications when the real test send fails', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-send-test-'))
|
||||
const sendNotification = vi.fn(async () => ({
|
||||
success: false,
|
||||
status: 'send_failed' as const,
|
||||
error: '连接器返回 500'
|
||||
}))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
getAgentHubStatus: onlineAgentHubStatus,
|
||||
getNotificationRecipient: () => 'owner-wxid',
|
||||
sendNotification
|
||||
})
|
||||
|
||||
const result = await service.setNotificationEnabled(true)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
data: { enabled: false },
|
||||
reason: 'send_failed',
|
||||
error: '连接器返回 500'
|
||||
})
|
||||
await expect(service.getNotificationSettings()).resolves.toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('persists lifecycle, executes generation and image sending, and restores tasks', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-'))
|
||||
const now = new Date('2026-08-27T01:00:00.000Z')
|
||||
@@ -111,4 +279,484 @@ describe('scheduled report scheduling', () => {
|
||||
expect(result.data?.error).toContain('report_history_save_failed:磁盘不可写')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records manual failures without creating user notifications', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-manual-error-'))
|
||||
const sendNotification = vi.fn()
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
sendNotification,
|
||||
generateReport: async () => ({
|
||||
success: false,
|
||||
error: 'maximum context length exceeded',
|
||||
errorStage: 'ai' as const
|
||||
})
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '手动失败日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const result = await service.runScheduledReportNow(created.data!.id)
|
||||
|
||||
expect(result.data).toMatchObject({ status: 'failed', triggerType: 'manual' })
|
||||
expect(result.data?.notificationStatus).toBe('not_needed')
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
expect(await service.listNotifications()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends a debug scheduled failure notification without generating or sending a report', async () => {
|
||||
const storageDir = await mkdtemp(
|
||||
join(tmpdir(), 'tracememo-scheduled-report-debug-notification-')
|
||||
)
|
||||
await enableNotifications(storageDir)
|
||||
const generateReport = vi.fn()
|
||||
const send = vi.fn()
|
||||
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({ generateReport, send, sendNotification })
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '调试日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const result = await service.testScheduledReportErrorNotification(created.data!.id)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toMatchObject({
|
||||
taskId: created.data!.id,
|
||||
triggerType: 'scheduled',
|
||||
status: 'failed',
|
||||
errorCode: 'DEBUG_TEST_NOTIFICATION',
|
||||
notificationStatus: 'sent'
|
||||
})
|
||||
expect(result.data?.technicalMessage).toContain('没有调用 AI')
|
||||
expect(generateReport).not.toHaveBeenCalled()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(sendNotification).toHaveBeenCalledWith({
|
||||
to: 'owner-wxid',
|
||||
text: expect.stringContaining('定时日报错误通知测试')
|
||||
})
|
||||
expect(await service.listNotifications()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('turns NO_MESSAGES into a neutral skipped execution without notification', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-messages-'))
|
||||
await enableNotifications(storageDir)
|
||||
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
sendNotification,
|
||||
getAgentHubStatus: onlineAgentHubStatus,
|
||||
generateReport: async () => ({
|
||||
success: false,
|
||||
error: '所选时间范围没有可总结的消息',
|
||||
errorCode: 'NO_MESSAGES',
|
||||
errorStage: 'data' as const
|
||||
})
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '空消息日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const execution = await runScheduled(service, created.data!.id)
|
||||
|
||||
expect(execution).toMatchObject({
|
||||
status: 'skipped',
|
||||
errorCode: 'NO_MESSAGES',
|
||||
userTitle: '暂无可生成的日报',
|
||||
userMessage: '所选时间范围内没有可总结的聊天消息。',
|
||||
notificationStatus: 'not_needed'
|
||||
})
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
expect(await service.listNotifications()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('suppresses pending notifications when disabled and does not flush them after reopening', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-suppressed-'))
|
||||
await enableNotifications(storageDir)
|
||||
let hubStatus = onlineAgentHubStatus()
|
||||
const sendNotification = vi.fn(async () => ({
|
||||
success: false,
|
||||
status: 'connector_offline' as const,
|
||||
error: '连接器暂时不可用'
|
||||
}))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
getAgentHubStatus: () => hubStatus,
|
||||
sendNotification,
|
||||
getCapability: async () => unavailableCapability
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '待关闭通知日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
expect(await service.getNotificationSettings()).toEqual({ enabled: true })
|
||||
const execution = await runScheduled(service, created.data!.id)
|
||||
expect(execution.notificationStatus).toBe('pending')
|
||||
expect(await service.listNotifications()).toEqual([
|
||||
expect.objectContaining({ status: 'pending' })
|
||||
])
|
||||
|
||||
await expect(service.setNotificationEnabled(false)).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { enabled: false }
|
||||
})
|
||||
expect(await service.listNotifications()).toEqual([
|
||||
expect.objectContaining({ status: 'suppressed' })
|
||||
])
|
||||
|
||||
hubStatus = onlineAgentHubStatus()
|
||||
sendNotification.mockResolvedValue({ success: true, status: 'sent' })
|
||||
await expect(service.setNotificationEnabled(true)).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { enabled: true }
|
||||
})
|
||||
expect(sendNotification).toHaveBeenCalledTimes(2)
|
||||
expect((await service.listNotifications())[0].status).toBe('suppressed')
|
||||
})
|
||||
|
||||
it('suppresses stale pending notifications when already disabled', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-stale-pending-'))
|
||||
const notification: ScheduledReportNotification = {
|
||||
id: 'notification-1',
|
||||
executionId: 'execution-1',
|
||||
taskId: 'task-1',
|
||||
type: 'failure',
|
||||
severity: 'error',
|
||||
title: '日报发送失败',
|
||||
message: '连接器暂时不可用',
|
||||
dedupeKey: 'execution-1:failure',
|
||||
channel: 'agent_hub',
|
||||
recipient: 'owner-wxid',
|
||||
status: 'pending',
|
||||
createdAt: '2026-09-02T02:00:00.000Z',
|
||||
attempts: 0
|
||||
}
|
||||
await writeFile(join(storageDir, 'notifications.json'), JSON.stringify([notification]))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
now: () => new Date('2026-09-02T03:00:00.000Z')
|
||||
})
|
||||
|
||||
await expect(service.setNotificationEnabled(false)).resolves.toEqual({
|
||||
success: true,
|
||||
data: { enabled: false }
|
||||
})
|
||||
expect(await service.listNotifications()).toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'suppressed',
|
||||
suppressedAt: '2026-09-02T03:00:00.000Z',
|
||||
lastError: '定时日报微信异常通知已关闭。'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('allows creating and executing a report when personal WeChat is unavailable', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-waiting-'))
|
||||
await enableNotifications(storageDir)
|
||||
const generateReport = vi.fn(async () => ({
|
||||
success: true,
|
||||
pngPath: '/tmp/generated.png',
|
||||
groupName: '研发群'
|
||||
}))
|
||||
const saveHistory = vi.fn(async () => ({
|
||||
success: true as const,
|
||||
record: reportRecord('/tmp/saved.png')
|
||||
}))
|
||||
const send = vi.fn()
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
getCapability: async () => unavailableCapability,
|
||||
generateReport,
|
||||
saveGeneratedReport: saveHistory,
|
||||
send
|
||||
})
|
||||
})
|
||||
|
||||
const created = await service.createTask({
|
||||
name: '未发送日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
expect(created.success).toBe(true)
|
||||
|
||||
const execution = await runScheduled(service, created.data!.id)
|
||||
|
||||
expect(execution).toMatchObject({
|
||||
status: 'waiting_to_send',
|
||||
errorCode: 'WECHAT_SEND_UNAVAILABLE',
|
||||
pngPath: '/tmp/saved.png',
|
||||
reportId: 'report-1',
|
||||
sendStatus: 'unavailable',
|
||||
notificationStatus: 'sent'
|
||||
})
|
||||
expect(generateReport).toHaveBeenCalledOnce()
|
||||
expect(saveHistory).toHaveBeenCalledOnce()
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(await service.listNotifications()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps notifications pending when no reliable recipient is available', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-no-recipient-'))
|
||||
await enableNotifications(storageDir)
|
||||
const sendNotification = vi.fn()
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
getCapability: async () => unavailableCapability,
|
||||
getNotificationRecipient: () => undefined,
|
||||
sendNotification
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '待通知日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const execution = await runScheduled(service, created.data!.id)
|
||||
const [notification] = await service.listNotifications()
|
||||
|
||||
expect(execution.notificationStatus).toBe('pending')
|
||||
expect(notification).toMatchObject({ status: 'pending', attempts: 0 })
|
||||
expect(notification.recipient).toBeUndefined()
|
||||
expect(sendNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps notifications pending when Agent Hub delivery fails', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-hub-offline-'))
|
||||
await enableNotifications(storageDir)
|
||||
const sendNotification = vi.fn(async () => ({
|
||||
success: false,
|
||||
status: 'connector_offline' as const,
|
||||
error: 'Agent Hub 连接器不可用'
|
||||
}))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
getCapability: async () => unavailableCapability,
|
||||
sendNotification
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '连接器异常日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const execution = await runScheduled(service, created.data!.id)
|
||||
const [notification] = await service.listNotifications()
|
||||
|
||||
expect(execution.notificationStatus).toBe('pending')
|
||||
expect(notification).toMatchObject({
|
||||
status: 'pending',
|
||||
attempts: 1,
|
||||
lastError: 'Agent Hub 连接器不可用'
|
||||
})
|
||||
expect(sendNotification).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the generated artifact when the image send attempt fails', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-partial-'))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({
|
||||
send: vi.fn(async () => ({
|
||||
success: false,
|
||||
status: capability.senderStatus,
|
||||
error: '连接器响应超时'
|
||||
}))
|
||||
})
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '发送失败日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const result = await service.runScheduledReportNow(created.data!.id)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toMatchObject({
|
||||
status: 'partial_success',
|
||||
errorCode: 'WECHAT_SEND_FAILED',
|
||||
pngPath: '/tmp/saved.png',
|
||||
sendStatus: 'failed',
|
||||
sendTarget: '研发群@chatroom'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries an existing PNG without generating the report again', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-retry-'))
|
||||
await enableNotifications(storageDir)
|
||||
const generateReport = vi.fn(async () => ({
|
||||
success: true,
|
||||
pngPath: '/tmp/generated.png',
|
||||
groupName: '研发群'
|
||||
}))
|
||||
let sendCount = 0
|
||||
const send = vi.fn(async (request: { filePath: string }) => {
|
||||
sendCount += 1
|
||||
if (sendCount === 1) {
|
||||
return { success: false, status: capability.senderStatus, error: '连接器暂时不可用' }
|
||||
}
|
||||
expect(request.filePath).toBe('/tmp/saved.png')
|
||||
return { success: true, status: capability.senderStatus }
|
||||
})
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({ generateReport, send })
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '可恢复日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
const first = { data: await runScheduled(service, created.data!.id) }
|
||||
const recovered = await service.retryScheduledReportSend(first.data!.id)
|
||||
|
||||
expect(first.data?.status).toBe('partial_success')
|
||||
expect(recovered).toMatchObject({
|
||||
success: true,
|
||||
data: { status: 'success', sendStatus: 'success', retryCount: 1 }
|
||||
})
|
||||
expect(recovered.data).not.toHaveProperty('error')
|
||||
expect(recovered.data).not.toHaveProperty('errorCode')
|
||||
expect(recovered.data).not.toHaveProperty('failedStage')
|
||||
expect(recovered.data).not.toHaveProperty('technicalMessage')
|
||||
expect(recovered.data).not.toHaveProperty('sendError')
|
||||
expect(generateReport).toHaveBeenCalledOnce()
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
expect((await service.listNotifications()).map((item) => item.type)).toContain('recovery')
|
||||
})
|
||||
|
||||
it('deduplicates repeated failure notifications for one execution', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-dedupe-'))
|
||||
await enableNotifications(storageDir)
|
||||
const send = vi.fn(async () => ({
|
||||
success: false,
|
||||
status: capability.senderStatus,
|
||||
error: '连接器暂时不可用'
|
||||
}))
|
||||
const sendNotification = vi.fn(async () => ({ success: true, status: 'sent' as const }))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
...makeDependencies({ send, sendNotification })
|
||||
})
|
||||
const created = await service.createTask({
|
||||
name: '重复失败日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
|
||||
const first = { data: await runScheduled(service, created.data!.id) }
|
||||
const retried = await service.retryScheduledReportSend(first.data!.id)
|
||||
|
||||
expect(retried.data?.status).toBe('partial_success')
|
||||
expect(await service.listNotifications()).toHaveLength(1)
|
||||
expect(sendNotification).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('normalizes AI and unknown generation failures without a fake WeChat error', async () => {
|
||||
const contextService = new ScheduledReportService({
|
||||
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-context-')),
|
||||
...makeDependencies({
|
||||
generateReport: async () => ({
|
||||
success: false,
|
||||
error: 'maximum context length exceeded',
|
||||
errorStage: 'ai' as const
|
||||
})
|
||||
})
|
||||
})
|
||||
const contextTask = await contextService.createTask({
|
||||
name: '上下文日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
const contextResult = await contextService.runScheduledReportNow(contextTask.data!.id)
|
||||
expect(contextResult.data).toMatchObject({ errorCode: 'AI_CONTEXT_LIMIT', failedStage: 'ai' })
|
||||
expect(contextResult.data?.error).not.toContain('wechat_not_ready')
|
||||
|
||||
const unknownService = new ScheduledReportService({
|
||||
storageDir: await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-unknown-')),
|
||||
...makeDependencies({
|
||||
generateReport: async () => {
|
||||
throw new Error('unexpected generation failure')
|
||||
}
|
||||
})
|
||||
})
|
||||
const unknownTask = await unknownService.createTask({
|
||||
name: '未知错误日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
const unknownResult = await unknownService.runScheduledReportNow(unknownTask.data!.id)
|
||||
expect(unknownResult.data).toMatchObject({ errorCode: 'UNKNOWN', failedStage: 'report' })
|
||||
expect(unknownResult.data?.error).not.toContain('wechat_not_ready')
|
||||
})
|
||||
|
||||
it('keeps old execution records readable and claims one scheduled slot once', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'tracememo-scheduled-report-compat-'))
|
||||
const oldExecution: ScheduledReportExecution = {
|
||||
id: 'old-execution',
|
||||
taskId: 'old-task',
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
finishedAt: '2026-08-27T01:01:00.000Z',
|
||||
status: 'failed',
|
||||
error: 'report_generation_failed:旧错误',
|
||||
message: '旧错误'
|
||||
}
|
||||
await writeFile(join(storageDir, 'executions.json'), JSON.stringify([oldExecution]))
|
||||
const generated = vi.fn(async () => ({ success: true, pngPath: '/tmp/generated.png' }))
|
||||
const service = new ScheduledReportService({
|
||||
storageDir,
|
||||
now: () => new Date('2026-08-27T01:00:00.000Z'),
|
||||
...makeDependencies({ generateReport: generated })
|
||||
})
|
||||
expect(await service.listExecutions()).toEqual([
|
||||
expect.objectContaining({ triggerType: 'scheduled', retryCount: 0 })
|
||||
])
|
||||
const created = await service.createTask({
|
||||
name: '幂等日报',
|
||||
group: '研发群',
|
||||
target: '研发群@chatroom',
|
||||
scheduleTime: '09:00'
|
||||
})
|
||||
const dueAt = new Date(created.data!.nextRunAt)
|
||||
await service.tick(new Date(dueAt.getTime() + 1_000))
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await service.tick(new Date(dueAt.getTime() + 2_000))
|
||||
|
||||
expect(generated).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
(await service.listExecutions()).filter((item) => item.taskId === created.data!.id)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user