134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
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<number, { fireAt: number; cb: () => void }>;
|
|
}
|
|
|
|
function harness(start: number): {
|
|
state: TimerState;
|
|
timers: ExecutionTimers;
|
|
advance: (delta: number) => Promise<void>;
|
|
bus: EventBusLike<JobSchedulerEventMap>;
|
|
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<void> => {
|
|
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<JobSchedulerEventMap> = {
|
|
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<void>((res) => {
|
|
resolver = () => {
|
|
resolved = true;
|
|
res();
|
|
};
|
|
}),
|
|
{ delayMs: 0 }
|
|
);
|
|
s.start();
|
|
await h.advance(100);
|
|
const stopP = s.stop();
|
|
resolver();
|
|
await stopP;
|
|
expect(resolved).toBe(true);
|
|
});
|
|
});
|