test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -12,6 +12,63 @@ import {
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
async function createTelegramDeliveryFixture(home: string): Promise<{
storePath: string;
deps: CliDeps;
}> {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
return { storePath, deps };
}
function mockEmbeddedAgentPayloads(payloads: Array<{ text: string; mediaUrl?: string }>) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads,
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
}
async function runTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
cfg?: ReturnType<typeof makeCfg>;
signal?: AbortSignal;
}) {
return runCronIsolatedAgentTurn({
cfg: params.cfg ?? makeCfg(params.home, params.storePath),
deps: params.deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
signal: params.signal,
lane: "cron",
});
}
describe("runCronIsolatedAgentTurn", () => {
beforeEach(() => {
setupIsolatedAgentTurnMocks({ fast: true });
@@ -19,45 +76,17 @@ describe("runCronIsolatedAgentTurn", () => {
it("handles media heartbeat delivery and announce cleanup modes", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
const { storePath, deps } = await createTelegramDeliveryFixture(home);
// Media should still be delivered even if text is just HEARTBEAT_OK.
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
]);
const mediaRes = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
const mediaRes = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(mediaRes.status).toBe("ok");
@@ -66,13 +95,7 @@ describe("runCronIsolatedAgentTurn", () => {
vi.mocked(runSubagentAnnounceFlow).mockClear();
vi.mocked(deps.sendMessageTelegram).mockClear();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([{ text: "HEARTBEAT_OK 🦞" }]);
const cfg = makeCfg(home, storePath);
cfg.agents = {
@@ -136,47 +159,19 @@ describe("runCronIsolatedAgentTurn", () => {
it("skips structured outbound delivery when timeout abort is already set", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, {
lastProvider: "telegram",
lastChannel: "telegram",
lastTo: "123",
});
const deps: CliDeps = {
sendMessageSlack: vi.fn(),
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "123",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
const { storePath, deps } = await createTelegramDeliveryFixture(home);
const controller = new AbortController();
controller.abort("cron: job execution timed out");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedAgentPayloads([
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({
kind: "agentTurn",
message: "do it",
}),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
signal: controller.signal,
lane: "cron",
});
expect(res.status).toBe("error");

View File

@@ -1,6 +1,8 @@
import { vi } from "vitest";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob } from "./isolated-agent.test-harness.js";
export function createCliDeps(overrides: Partial<CliDeps> = {}): CliDeps {
return {
@@ -27,3 +29,29 @@ export function mockAgentPayloads(
...extra,
});
}
export async function runTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
delivery: {
mode: "announce";
channel: string;
to?: string;
bestEffort?: boolean;
};
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
return runCronIsolatedAgentTurn({
cfg: makeCfg(params.home, params.storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps: params.deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: params.delivery,
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
}

View File

@@ -1,14 +1,12 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
} from "./isolated-agent.test-harness.js";
createCliDeps,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
import { withTempCronHome, writeSessionStore } from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
describe("runCronIsolatedAgentTurn forum topic delivery", () => {
@@ -22,18 +20,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
const deps = createCliDeps();
mockAgentPayloads([{ text: "forum message" }]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
delivery: { mode: "announce", channel: "telegram", to: "123:topic:42" },
});
expect(res.status).toBe("ok");
@@ -56,18 +47,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
const deps = createCliDeps();
mockAgentPayloads([{ text: "plain message" }]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
const res = await runTelegramAnnounceTurn({
home,
storePath,
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: { mode: "announce", channel: "telegram", to: "123" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");

View File

@@ -3,7 +3,11 @@ import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js";
import {
createCliDeps,
mockAgentPayloads,
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import {
makeCfg,
@@ -13,32 +17,22 @@ import {
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
async function runTelegramAnnounceTurn(params: {
async function runExplicitTelegramAnnounceTurn(params: {
home: string;
storePath: string;
deps: CliDeps;
delivery: {
mode: "announce";
channel: string;
to?: string;
bestEffort?: boolean;
};
}): Promise<Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>> {
return runCronIsolatedAgentTurn({
cfg: makeCfg(params.home, params.storePath, {
channels: { telegram: { botToken: "t-1" } },
}),
deps: params.deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
delivery: params.delivery,
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
return runTelegramAnnounceTurn({
...params,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
}
function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgentTurn>>): void {
expect(result.status).toBe("ok");
expect(result.delivered).toBe(true);
}
async function expectBestEffortTelegramNotDelivered(
payload: Record<string, unknown>,
): Promise<void> {
@@ -75,15 +69,13 @@ async function expectExplicitTelegramTargetAnnounce(params: {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = createCliDeps();
mockAgentPayloads(params.payloads);
const res = await runTelegramAnnounceTurn({
const res = await runExplicitTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expectDeliveredOk(res);
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
| {
@@ -210,15 +202,13 @@ describe("runCronIsolatedAgentTurn", () => {
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
});
const res = await runTelegramAnnounceTurn({
const res = await runExplicitTelegramAnnounceTurn({
home,
storePath,
deps,
delivery: { mode: "announce", channel: "telegram", to: "123" },
});
expect(res.status).toBe("ok");
expect(res.delivered).toBe(true);
expectDeliveredOk(res);
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});

View File

@@ -11,25 +11,24 @@ export async function withTempCronHome<T>(fn: (home: string) => Promise<T>): Pro
export async function writeSessionStore(
home: string,
session: { lastProvider: string; lastTo: string; lastChannel?: string },
): Promise<string> {
return writeSessionStoreEntries(home, {
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
...session,
},
});
}
export async function writeSessionStoreEntries(
home: string,
entries: Record<string, Record<string, unknown>>,
): Promise<string> {
const dir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(dir, { recursive: true });
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
...session,
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
return storePath;
}

View File

@@ -6,7 +6,13 @@ import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, withTempCronHome } from "./isolated-agent.test-harness.js";
import {
makeCfg,
makeJob,
withTempCronHome,
writeSessionStore,
writeSessionStoreEntries,
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
const withTempHome = withTempCronHome;
@@ -44,33 +50,6 @@ function expectEmbeddedProviderModel(expected: { provider: string; model: string
expect(call?.model).toBe(expected.model);
}
async function writeSessionStore(
home: string,
entries: Record<string, Record<string, unknown>> = {},
) {
const dir = path.join(home, ".openclaw", "sessions");
await fs.mkdir(dir, { recursive: true });
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
...entries,
},
null,
2,
),
"utf-8",
);
return storePath;
}
async function readSessionEntry(storePath: string, key: string) {
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, { sessionId?: string; label?: string }>;
@@ -98,7 +77,17 @@ type RunCronTurnOptions = {
};
async function runCronTurn(home: string, options: RunCronTurnOptions = {}) {
const storePath = options.storePath ?? (await writeSessionStore(home, options.storeEntries));
const storePath =
options.storePath ??
(await writeSessionStoreEntries(home, {
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastProvider: "webchat",
lastTo: "",
},
...options.storeEntries,
}));
const deps = options.deps ?? makeDeps();
if (options.mockTexts === null) {
vi.mocked(runEmbeddedPiAgent).mockClear();
@@ -468,7 +457,7 @@ describe("runCronIsolatedAgentTurn", () => {
it("starts a fresh session id for each cron run", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const deps = makeDeps();
const first = (
@@ -502,7 +491,7 @@ describe("runCronIsolatedAgentTurn", () => {
it("preserves an existing cron session label", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
store["agent:main:cron:job-1"] = {

View File

@@ -1,26 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ChannelId } from "../channels/plugins/types.js";
import { CronService, type CronServiceDeps } from "./service.js";
import {
createCronStoreHarness,
createNoopLogger,
withCronServiceForTest,
} from "./service.test-harness.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-"));
return {
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-delivery-" });
type DeliveryMode = "none" | "announce";
@@ -40,27 +28,15 @@ async function withCronService(
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
cronEnabled: true,
storePath: store.storePath,
log: noopLogger,
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();
}
await withCronServiceForTest(
{
makeStorePath,
logger: noopLogger,
cronEnabled: true,
runIsolatedAgentJob: params.runIsolatedAgentJob,
},
run,
);
}
async function addIsolatedAgentTurnJob(

View File

@@ -3,25 +3,35 @@ import { createMockCronStateForJobs } from "./service.test-harness.js";
import { recomputeNextRunsForMaintenance } from "./service/jobs.js";
import type { CronJob } from "./types.js";
function createCronSystemEventJob(now: number, overrides: Partial<CronJob> = {}): CronJob {
const { state, ...jobOverrides } = overrides;
return {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
...jobOverrides,
state: state ? { ...state } : {},
};
}
describe("issue #13992 regression - cron jobs skip execution", () => {
it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => {
const now = Date.now();
const pastDue = now - 60_000; // 1 minute ago
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
const job = createCronSystemEventJob(now, {
createdAtMs: now - 3600_000,
updatedAtMs: now - 3600_000,
state: {
nextRunAtMs: pastDue, // This is in the past and should NOT be recomputed
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -33,20 +43,11 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
it("should compute missing nextRunAtMs during maintenance", () => {
const now = Date.now();
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
const job = createCronSystemEventJob(now, {
state: {
// nextRunAtMs is missing
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -60,20 +61,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
const now = Date.now();
const futureTime = now + 3600_000;
const job: CronJob = {
id: "test-job",
name: "test job",
const job = createCronSystemEventJob(now, {
enabled: false, // Disabled
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
state: {
nextRunAtMs: futureTime,
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);
@@ -87,21 +80,12 @@ describe("issue #13992 regression - cron jobs skip execution", () => {
const stuckTime = now - 3 * 60 * 60_000; // 3 hours ago (> 2 hour threshold)
const futureTime = now + 3600_000;
const job: CronJob = {
id: "test-job",
name: "test job",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
payload: { kind: "systemEvent", text: "test" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
createdAtMs: now,
updatedAtMs: now,
const job = createCronSystemEventJob(now, {
state: {
nextRunAtMs: futureTime,
runningAtMs: stuckTime, // Stuck running marker
},
};
});
const state = createMockCronStateForJobs({ jobs: [job], nowMs: now });
recomputeNextRunsForMaintenance(state);

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
import {
createCronStoreHarness,
createNoopLogger,
withCronServiceForTest,
} from "./service.test-harness.js";
import type { CronJob } from "./types.js";
const noopLogger = createNoopLogger();
@@ -30,25 +34,15 @@ async function withCronService(
requestHeartbeatNow: ReturnType<typeof vi.fn>;
}) => Promise<void>,
) {
const store = await makeStorePath();
const enqueueSystemEvent = vi.fn();
const requestHeartbeatNow = vi.fn();
const cron = new CronService({
storePath: store.storePath,
cronEnabled,
log: noopLogger,
enqueueSystemEvent,
requestHeartbeatNow,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
await cron.start();
try {
await run({ cron, enqueueSystemEvent, requestHeartbeatNow });
} finally {
cron.stop();
await store.cleanup();
}
await withCronServiceForTest(
{
makeStorePath,
logger: noopLogger,
cronEnabled,
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
},
run,
);
}
describe("CronService", () => {

View File

@@ -1,28 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CronService } from "./service.js";
import { createCronStoreHarness, createNoopLogger } from "./service.test-harness.js";
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
import { loadCronStore } from "./store.js";
const noopLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
async function makeStorePath() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-migrate-"));
return {
dir,
storePath: path.join(dir, "cron", "jobs.json"),
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true });
},
};
}
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-migrate-" });
async function writeLegacyStore(storePath: string, legacyJob: Record<string, unknown>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });

View File

@@ -3,7 +3,7 @@ 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 } from "./service.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";
@@ -140,6 +140,42 @@ export function createStartedCronServiceWithFinishedBarrier(params: {
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>;