import { describe, expect, it } from "@suaveplan/testing/runner"; import type { EventBusLike, LoggerLike } from "@suaveplan/types"; import type { ExecutionTimers } from "../src/execution/job-execution.js"; import { createJobScheduler } from "../src/scheduler/job-scheduler.js"; import type { JobSchedulerEventMap } from "../src/scheduler/job-scheduler.types.js"; interface TimerState { now: number; nextId: number; pending: Map void }>; } function harness(start: number): { state: TimerState; timers: ExecutionTimers; advance: (delta: number) => Promise; bus: EventBusLike; logger: LoggerLike; } { const state: TimerState = { now: start, nextId: 1, pending: new Map() }; const timers: ExecutionTimers = { setTimer: (cb, ms) => { const id = state.nextId++; state.pending.set(id, { fireAt: state.now + ms, cb }); return id; }, clearTimer: (h) => { state.pending.delete(h as number); }, }; const advance = async (delta: number): Promise => { const target = state.now + delta; for (;;) { let id: number | undefined; let at = Number.POSITIVE_INFINITY; for (const [k, v] of state.pending) { if (v.fireAt <= target && v.fireAt < at) { id = k; at = v.fireAt; } } if (id === undefined) break; const entry = state.pending.get(id); if (entry === undefined) break; state.pending.delete(id); state.now = entry.fireAt; entry.cb(); await Promise.resolve(); await Promise.resolve(); } state.now = target; }; const bus: EventBusLike = { on: () => () => undefined, emit: () => undefined, }; const logger: LoggerLike = { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; return { state, timers, advance, bus, logger }; } describe("integration: three cron jobs over 10 simulated minutes", () => { it("fires every-minute, every-2-minutes, every-5-minutes the expected counts", { timeout: 30_000, }, async () => { const startAt = Date.UTC(2026, 4, 12, 9, 0, 0); const h = harness(startAt); const s = createJobScheduler({ clock: () => h.state.now, timers: h.timers, events: h.bus, logger: h.logger, tickIntervalMs: 100, }); const counts = { every1: 0, every2: 0, every5: 0 }; s.schedule("* * * * *", async () => { counts.every1 += 1; }); s.schedule("*/2 * * * *", async () => { counts.every2 += 1; }); s.schedule("*/5 * * * *", async () => { counts.every5 += 1; }); s.start(); await h.advance(10 * 60_000 + 500); await s.stop(); // Over a 10-minute window starting at HH:00:00.5, every-minute // fires 10 times (HH:01 through HH:10), every-2-minutes fires 5 // times (02, 04, 06, 08, 10), every-5-minutes fires 2 times // (05, 10). expect(counts.every1).toBe(10); expect(counts.every2).toBe(5); expect(counts.every5).toBe(2); }); }); describe("integration: stop() drains a mid-run job", () => { it("waits for a slow handler to finish before resolving", { timeout: 30_000, }, async () => { const h = harness(Date.UTC(2026, 4, 12, 9, 0, 0)); const s = createJobScheduler({ clock: () => h.state.now, timers: h.timers, events: h.bus, logger: h.logger, tickIntervalMs: 50, }); let resolved = false; let resolver: () => void = () => undefined; s.queue( () => new Promise((res) => { resolver = () => { resolved = true; res(); }; }), { delayMs: 0 } ); s.start(); await h.advance(100); const stopP = s.stop(); resolver(); await stopP; expect(resolved).toBe(true); }); });