Files
openclaw/src/gateway/server-methods/cron.validation.test.ts
Kevin Lin 89db1e5440 feat(cron): surface run diagnostics in status (#75928)
* feat(cron): surface run diagnostics in status

* docs: add cron diagnostics changelog

* fix(cron): preserve latest run diagnostics

* test(cron): update diagnostics regression deps
2026-05-04 07:05:28 -07:00

663 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CronJob } from "../../cron/types.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
const getRuntimeConfig = vi.hoisted(() =>
vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig),
);
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
getRuntimeConfig,
};
});
import { cronHandlers } from "./cron.js";
function createPrefixOnlyChannelPlugin(
id: string,
targetPrefixes: readonly string[],
aliases?: readonly string[],
): ChannelPlugin {
const base = createChannelTestPluginBase({ id });
return {
...base,
meta: {
...base.meta,
...(aliases ? { aliases } : {}),
},
messaging: { targetPrefixes },
};
}
function setCronValidationTestRegistry(): void {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: createPrefixOnlyChannelPlugin("telegram", ["telegram", "tg"]),
source: "test:telegram",
},
{
pluginId: "slack",
plugin: createPrefixOnlyChannelPlugin("slack", ["slack"]),
source: "test:slack",
},
{
pluginId: "msteams",
plugin: createPrefixOnlyChannelPlugin("msteams", ["msteams", "teams"], ["teams"]),
source: "test:msteams",
},
{
pluginId: "synology-chat",
plugin: createPrefixOnlyChannelPlugin("synology-chat", [
"synology-chat",
"synology_chat",
"synology",
]),
source: "test:synology-chat",
},
]),
);
}
function createCronContext(currentJob?: CronJob) {
return {
cron: {
add: vi.fn(async () => ({ id: "cron-1" })),
update: vi.fn(async () => ({ id: "cron-1" })),
getDefaultAgentId: vi.fn(() => "main"),
getJob: vi.fn(() => currentJob),
},
logGateway: {
info: vi.fn(),
},
getRuntimeConfig: () => getRuntimeConfig(),
};
}
async function invokeCronAdd(params: Record<string, unknown>) {
const context = createCronContext();
const respond = vi.fn();
await cronHandlers["cron.add"]({
req: {} as never,
params: params as never,
respond: respond as never,
context: context as never,
client: null,
isWebchatConnect: () => false,
});
return { context, respond };
}
async function invokeCronUpdate(params: Record<string, unknown>, currentJob: CronJob) {
const context = createCronContext(currentJob);
const respond = vi.fn();
await cronHandlers["cron.update"]({
req: {} as never,
params: params as never,
respond: respond as never,
context: context as never,
client: null,
isWebchatConnect: () => false,
});
return { context, respond };
}
function createCronJob(overrides: Partial<CronJob> = {}): CronJob {
return {
id: "cron-1",
name: "cron job",
enabled: true,
createdAtMs: 1,
updatedAtMs: 1,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "none" },
state: {},
...overrides,
};
}
describe("cron method validation", () => {
beforeEach(() => {
getRuntimeConfig.mockReset().mockReturnValue({} as OpenClawConfig);
setCronValidationTestRegistry();
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("accepts threadId on announce delivery add params", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
telegram: {
botToken: "telegram-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "topic announce add",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: {
mode: "announce",
channel: "telegram",
to: "-1001234567890",
threadId: 123,
},
});
expect(context.cron.add).toHaveBeenCalledWith(
expect.objectContaining({
delivery: expect.objectContaining({
mode: "announce",
channel: "telegram",
to: "-1001234567890",
threadId: 123,
}),
}),
);
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
});
it("accepts threadId on announce delivery update params", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
telegram: {
botToken: "telegram-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
delivery: {
mode: "announce",
channel: "telegram",
to: "-1001234567890",
threadId: "456",
},
},
},
createCronJob({
delivery: { mode: "announce", channel: "telegram", to: "-1001234567890" },
}),
);
expect(context.cron.update).toHaveBeenCalledWith(
"cron-1",
expect.objectContaining({
delivery: expect.objectContaining({
mode: "announce",
channel: "telegram",
to: "-1001234567890",
threadId: "456",
}),
}),
);
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
});
it("rejects execution-derived diagnostics in cron.update state patches", async () => {
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
state: {
lastDiagnostics: {
summary: "forged",
entries: [
{
ts: 1,
source: "agent-run",
severity: "error",
message: "forged",
},
],
},
},
},
},
createCronJob(),
);
expect(context.cron.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
}),
);
});
it("rejects ambiguous announce delivery on add when multiple channels are configured", async () => {
getRuntimeConfig.mockReturnValue({
session: {
mainKey: "main",
},
channels: {
telegram: {
botToken: "telegram-token",
},
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "ambiguous announce add",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "announce" },
});
expect(context.cron.add).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("delivery.channel is required"),
}),
);
});
it("accepts provider-prefixed announce target without delivery.channel when multiple channels are configured", async () => {
getRuntimeConfig.mockReturnValue({
session: {
mainKey: "main",
},
channels: {
telegram: {
botToken: "telegram-token",
},
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "prefixed announce add",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "announce", to: "telegram:123" },
});
expect(context.cron.add).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
});
it("rejects announce targets prefixed for a different explicit delivery channel", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
telegram: {
botToken: "telegram-token",
},
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "mismatched announce add",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "announce", channel: "slack", to: "telegram:123" },
});
expect(context.cron.add).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("belongs to telegram, not slack"),
}),
);
});
it("accepts provider-prefixed announce targets when delivery.channel uses a channel alias", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
msteams: {
botToken: "teams-token",
},
},
plugins: {
entries: {
msteams: { enabled: true },
},
},
} as OpenClawConfig);
for (const to of ["teams:19:meeting_abc@thread.tacv2", "msteams:19:meeting_abc@thread.tacv2"]) {
const { context, respond } = await invokeCronAdd({
name: `aliased announce add ${to}`,
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: {
mode: "announce",
channel: "teams",
to,
},
});
expect(context.cron.add).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
}
});
it("validates announce delivery patches that omit mode", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
telegram: {
botToken: "telegram-token",
},
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
delivery: { channel: "slack", to: "telegram:123" },
},
},
createCronJob({
delivery: { mode: "announce", channel: "telegram", to: "123" },
}),
);
expect(context.cron.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("belongs to telegram, not slack"),
}),
);
});
it("rejects underscored provider prefixes for a different explicit delivery channel", async () => {
getRuntimeConfig.mockReturnValue({
channels: {
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
"synology-chat": {
token: "synology-token",
},
},
plugins: {
entries: {
slack: { enabled: true },
"synology-chat": { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "underscored mismatch add",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "announce", channel: "slack", to: "synology_chat:123" },
});
expect(context.cron.add).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("belongs to synology-chat, not slack"),
}),
);
});
it("rejects ambiguous announce delivery on update when multiple channels are configured", async () => {
getRuntimeConfig.mockReturnValue({
session: {
mainKey: "main",
},
channels: {
telegram: {
botToken: "telegram-token",
},
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronUpdate(
{
id: "cron-1",
patch: {
delivery: { mode: "announce" },
},
},
createCronJob(),
);
expect(context.cron.update).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("delivery.channel is required"),
}),
);
});
it("rejects target ids mistakenly supplied as delivery.channel providers", async () => {
getRuntimeConfig.mockReturnValue({
session: {
mainKey: "main",
},
channels: {
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
slack: { enabled: true },
},
},
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd({
name: "invalid delivery provider",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: {
mode: "announce",
channel: "C0AT2Q238MQ",
to: "C0AT2Q238MQ",
},
});
expect(context.cron.add).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("delivery.channel must be one of: slack"),
}),
);
});
it("returns INVALID_REQUEST when cron.add throws a croner parse error (#74066)", async () => {
const context = createCronContext();
context.cron.add.mockRejectedValueOnce(new TypeError("CronPattern: Expected 5 or 6 fields"));
const respond = vi.fn();
await cronHandlers["cron.add"]({
req: {} as never,
params: {
name: "bad-cron",
enabled: true,
schedule: { kind: "cron", cron: "not-a-cron-expr" },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "ping" },
} as never,
respond: respond as never,
context: context as never,
client: null,
isWebchatConnect: () => false,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: expect.stringContaining("CronPattern"),
}),
);
});
it("returns INVALID_REQUEST when cron.update throws a croner parse error (#74066)", async () => {
const existingJob = createCronJob();
const context = createCronContext(existingJob);
context.cron.update.mockRejectedValueOnce(
new RangeError("CronPattern: Value out of range (99)"),
);
const respond = vi.fn();
await cronHandlers["cron.update"]({
req: {} as never,
params: {
id: existingJob.id,
patch: {
schedule: { kind: "cron", cron: "99 * * * *" },
},
} as never,
respond: respond as never,
context: context as never,
client: null,
isWebchatConnect: () => false,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: expect.stringContaining("CronPattern"),
}),
);
});
it("re-throws non-parse errors from cron.add instead of masking as INVALID_REQUEST", async () => {
const context = createCronContext();
context.cron.add.mockRejectedValueOnce(new Error("DB write failed"));
const respond = vi.fn();
await expect(
cronHandlers["cron.add"]({
req: {} as never,
params: {
name: "db-fail",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "ping" },
} as never,
respond: respond as never,
context: context as never,
client: null,
isWebchatConnect: () => false,
}),
).rejects.toThrow("DB write failed");
expect(respond).not.toHaveBeenCalled();
});
});