import { beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/react' import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications' const { listMock, getRecentRunsMock, notifyDesktopMock, serverReadyMock } = vi.hoisted(() => ({ listMock: vi.fn(), getRecentRunsMock: vi.fn(), notifyDesktopMock: vi.fn(), serverReadyMock: vi.fn(), })) vi.mock('../api/tasks', () => ({ tasksApi: { list: listMock, getRecentRuns: getRecentRunsMock, }, })) vi.mock('../lib/desktopNotifications', () => ({ notifyDesktop: notifyDesktopMock, })) vi.mock('../lib/desktopRuntime', () => ({ whenDesktopServerReady: serverReadyMock, })) function Harness() { useScheduledTaskDesktopNotifications() return null } describe('useScheduledTaskDesktopNotifications', () => { beforeEach(() => { vi.useFakeTimers() localStorage.clear() listMock.mockReset() getRecentRunsMock.mockReset() notifyDesktopMock.mockReset() notifyDesktopMock.mockResolvedValue(true) serverReadyMock.mockReset() serverReadyMock.mockResolvedValue(undefined) }) it('does not poll until the desktop server is ready', async () => { let resolveReady: () => void = () => {} serverReadyMock.mockReturnValue( new Promise((resolve) => { resolveReady = resolve }), ) listMock.mockResolvedValue({ tasks: [] }) getRecentRunsMock.mockResolvedValue({ runs: [] }) render() // While the server is not ready, the poller must stay silent — this is the // regression guard for the startup race that logged "Failed to fetch" warnings. await vi.advanceTimersByTimeAsync(60_000) expect(listMock).not.toHaveBeenCalled() expect(getRecentRunsMock).not.toHaveBeenCalled() resolveReady() await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) expect(listMock).toHaveBeenCalledTimes(1) }) it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => { listMock.mockResolvedValue({ tasks: [{ id: 'task-1', name: 'Daily review', cron: '* * * * *', prompt: 'review', enabled: true, createdAt: 1, notification: { enabled: true, channels: ['desktop'] }, }], }) getRecentRunsMock .mockResolvedValueOnce({ runs: [{ id: 'run-old', taskId: 'task-1', taskName: 'Daily review', startedAt: '2026-05-03T00:00:00.000Z', completedAt: '2026-05-03T00:00:01.000Z', status: 'completed', prompt: 'review', output: 'old result', }], }) .mockResolvedValueOnce({ runs: [ { id: 'run-old', taskId: 'task-1', taskName: 'Daily review', startedAt: '2026-05-03T00:00:00.000Z', completedAt: '2026-05-03T00:00:01.000Z', status: 'completed', prompt: 'review', output: 'old result', }, { id: 'run-new', taskId: 'task-1', taskName: 'Daily review', startedAt: '2026-05-03T00:01:00.000Z', completedAt: '2026-05-03T00:01:01.000Z', status: 'failed', prompt: 'review', error: 'provider timeout', }, ], }) render() await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) expect(notifyDesktopMock).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(30_000) await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'scheduled-task:run-new', title: '定时任务 Daily review', body: '失败: provider timeout', target: { type: 'scheduled' }, }) }) it('targets the run session when a scheduled task run has a session id', async () => { listMock.mockResolvedValue({ tasks: [{ id: 'task-1', name: 'Daily review', cron: '* * * * *', prompt: 'review', enabled: true, createdAt: 1, notification: { enabled: true, channels: ['desktop'] }, }], }) getRecentRunsMock .mockResolvedValueOnce({ runs: [] }) .mockResolvedValueOnce({ runs: [{ id: 'run-new', taskId: 'task-1', taskName: 'Daily review', startedAt: '2026-05-03T00:01:00.000Z', completedAt: '2026-05-03T00:01:01.000Z', status: 'completed', prompt: 'review', output: 'done', sessionId: 'session-task-run', }], }) render() await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(30_000) await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) expect(notifyDesktopMock).toHaveBeenCalledWith({ dedupeKey: 'scheduled-task:run-new', title: '定时任务 Daily review', body: '完成: done', target: { type: 'session', sessionId: 'session-task-run', title: 'Daily review', }, }) }) it('ignores task runs without the desktop notification channel', async () => { listMock.mockResolvedValue({ tasks: [{ id: 'task-1', name: 'IM only', cron: '* * * * *', prompt: 'review', enabled: true, createdAt: 1, notification: { enabled: true, channels: ['telegram'] }, }], }) getRecentRunsMock.mockResolvedValue({ runs: [{ id: 'run-1', taskId: 'task-1', taskName: 'IM only', startedAt: '2026-05-03T00:00:00.000Z', completedAt: '2026-05-03T00:00:01.000Z', status: 'completed', prompt: 'review', }], }) render() await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(30_000) expect(notifyDesktopMock).not.toHaveBeenCalled() }) it('does not mark a run as notified when desktop notification delivery fails', async () => { notifyDesktopMock .mockResolvedValueOnce(false) .mockResolvedValueOnce(true) listMock.mockResolvedValue({ tasks: [{ id: 'task-1', name: 'Daily review', cron: '* * * * *', prompt: 'review', enabled: true, createdAt: 1, notification: { enabled: true, channels: ['desktop'] }, }], }) getRecentRunsMock .mockResolvedValueOnce({ runs: [] }) .mockResolvedValue({ runs: [{ id: 'run-new', taskId: 'task-1', taskName: 'Daily review', startedAt: '2026-05-03T00:01:00.000Z', completedAt: '2026-05-03T00:01:01.000Z', status: 'completed', prompt: 'review', }], }) render() await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(30_000) await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(30_000) await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(2)) }) })