Files
Moltbot/src/cron/service.test-harness.ts
2026-02-23 05:45:54 +00:00

241 lines
6.3 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { CronEvent, CronServiceDeps } from "./service.js";
import { CronService } from "./service.js";
import { createCronServiceState, type CronServiceState } from "./service/state.js";
import type { CronJob } from "./types.js";
export type NoopLogger = {
debug: MockFn;
info: MockFn;
warn: MockFn;
error: MockFn;
};
export function createNoopLogger(): NoopLogger {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
export function createCronStoreHarness(options?: { prefix?: string }) {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-cron-"));
});
afterAll(async () => {
if (!fixtureRoot) {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
async function makeStorePath() {
const dir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(dir, { recursive: true });
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {},
};
}
return { makeStorePath };
}
export async function writeCronStoreSnapshot(params: { storePath: string; jobs: CronJob[] }) {
await fs.mkdir(path.dirname(params.storePath), { recursive: true });
await fs.writeFile(
params.storePath,
JSON.stringify(
{
version: 1,
jobs: params.jobs,
},
null,
2,
),
"utf-8",
);
}
export function installCronTestHooks(options: {
logger: ReturnType<typeof createNoopLogger>;
baseTimeIso?: string;
}) {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(options.baseTimeIso ?? "2025-12-13T00:00:00.000Z"));
options.logger.debug.mockClear();
options.logger.info.mockClear();
options.logger.warn.mockClear();
options.logger.error.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
}
export function setupCronServiceSuite(options?: { prefix?: string; baseTimeIso?: string }) {
const logger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: options?.prefix });
installCronTestHooks({
logger,
baseTimeIso: options?.baseTimeIso,
});
return { logger, makeStorePath };
}
export function createFinishedBarrier() {
const resolvers = new Map<string, (evt: CronEvent) => void>();
return {
waitForOk: (jobId: string) =>
new Promise<CronEvent>((resolve) => {
resolvers.set(jobId, resolve);
}),
onEvent: (evt: CronEvent) => {
if (evt.action !== "finished" || evt.status !== "ok") {
return;
}
const resolve = resolvers.get(evt.jobId);
if (!resolve) {
return;
}
resolvers.delete(evt.jobId);
resolve(evt);
},
};
}
export function createStartedCronServiceWithFinishedBarrier(params: {
storePath: string;
logger: ReturnType<typeof createNoopLogger>;
}): {
cron: CronService;
enqueueSystemEvent: MockFn;
requestHeartbeatNow: MockFn;
finished: ReturnType<typeof createFinishedBarrier>;
} {
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const finished = createFinishedBarrier();
const cron = new CronService({
storePath: params.storePath,
cronEnabled: true,
log: params.logger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
onEvent: finished.onEvent,
});
return { cron, enqueueSystemEvent, requestHeartbeatNow, finished };
}
export async function withCronServiceForTest(
params: {
makeStorePath: () => Promise<{ storePath: string; cleanup: () => Promise<void> }>;
logger: ReturnType<typeof createNoopLogger>;
cronEnabled: boolean;
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"];
},
run: (context: {
cron: CronService;
enqueueSystemEvent: ReturnType<typeof vi.fn>;
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
): Promise<void> {
const store = await params.makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
cronEnabled: params.cronEnabled,
storePath: store.storePath,
log: params.logger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob:
params.runIsolatedAgentJob ??
(vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never),
});
await cron.start();
try {
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
} finally {
cron.stop();
await store.cleanup();
}
}
export function createRunningCronServiceState(params: {
storePath: string;
log: ReturnType<typeof createNoopLogger>;
nowMs: () => number;
jobs: CronJob[];
}) {
const state = createCronServiceState({
cronEnabled: true,
storePath: params.storePath,
log: params.log,
nowMs: params.nowMs,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }),
});
state.running = true;
state.store = {
version: 1,
jobs: params.jobs,
};
return state;
}
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function createMockCronStateForJobs(params: {
jobs: CronJob[];
nowMs?: number;
}): CronServiceState {
const nowMs = params.nowMs ?? Date.now();
return {
store: { version: 1, jobs: params.jobs },
running: false,
timer: null,
storeLoadedAtMs: nowMs,
storeFileMtimeMs: null,
op: Promise.resolve(),
warnedDisabled: false,
deps: {
storePath: "/mock/path",
cronEnabled: true,
nowMs: () => nowMs,
enqueueSystemEvent: () => {},
requestHeartbeatNow: () => {},
runIsolatedAgentJob: async () => ({ status: "ok" }),
log: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
} as never,
},
};
}