mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:11:23 +00:00
test: dedupe fixtures and test harness setup
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user