import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createHeartbeatController } from '../../src/heartbeat-controller'; describe('heartbeat controller', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); test('does not overlap ticks while a heartbeat is in flight', async () => { let resolveHeartbeat!: (value: { cancelRequested: boolean }) => void; const heartbeat = vi.fn( () => new Promise<{ cancelRequested: boolean }>((resolve) => { resolveHeartbeat = resolve; }), ); const controller = createHeartbeatController({ intervalMs: 30_000, heartbeat, onCancel: vi.fn(), onError: vi.fn(), }); controller.start('job-1'); await vi.advanceTimersByTimeAsync(90_000); expect(heartbeat).toHaveBeenCalledOnce(); resolveHeartbeat({ cancelRequested: false }); await Promise.resolve(); await vi.advanceTimersByTimeAsync(30_000); expect(heartbeat).toHaveBeenCalledTimes(2); controller.stop('job-1'); }); test('removes the timer before awaiting cancellation teardown', async () => { let finishCancel!: () => void; const onCancel = vi.fn( () => new Promise((resolve) => { finishCancel = resolve; }), ); const heartbeat = vi.fn().mockResolvedValue({ cancelRequested: true }); const controller = createHeartbeatController({ intervalMs: 30_000, heartbeat, onCancel, onError: vi.fn(), }); controller.start('job-1'); await vi.advanceTimersByTimeAsync(30_000); expect(onCancel).toHaveBeenCalledOnce(); expect(controller.has('job-1')).toBe(false); await vi.advanceTimersByTimeAsync(90_000); expect(heartbeat).toHaveBeenCalledOnce(); finishCancel(); }); });