openclaw / src /cli /cron-cli /trigger-options.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
10.6 kB
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultRuntime } from "../../runtime.js";
const callGatewayFromCli = vi.fn();
vi.mock("../gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("../gateway-rpc.js")>("../gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
callGatewayFromCli(...args),
};
});
const { registerCronAddCommand } = await import("./register.cron-add.js");
const { registerCronEditCommand } = await import("./register.cron-edit.js");
const { readCronPayloadScript, readCronTriggerScript } = await import("./trigger-options.js");
describe("cron trigger CLI options", () => {
let fixtureRoot = "";
beforeEach(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cron-trigger-cli-"));
callGatewayFromCli.mockReset();
callGatewayFromCli.mockResolvedValue({ ok: true });
});
afterEach(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("advertises every canonical thinking level on add and edit", () => {
const program = new Command().exitOverride();
registerCronAddCommand(program);
registerCronEditCommand(program);
for (const commandName of ["add", "edit"]) {
const help = program.commands
.find((command) => command.name() === commandName)
?.helpInformation();
expect(help).toContain("off|minimal|low|medium|high|xhigh|adaptive|max|ultra");
}
});
it.each(["watch.js", "watch.js "])("reads trigger file %j on add", async (fileName) => {
const scriptPath = path.join(fixtureRoot, fileName);
await fs.writeFile(path.join(fixtureRoot, "watch.js"), "json({ fire: false })", "utf8");
await fs.writeFile(scriptPath, " json({ fire: true }) \n", "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
await program.parseAsync(
[
"add",
"--name",
"watcher",
"--every",
"30s",
"--trigger-script",
scriptPath,
"--trigger-once",
"--system-event",
"changed",
"--session",
"main",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.add",
expect.objectContaining({ triggerScript: scriptPath, triggerOnce: true }),
expect.objectContaining({
trigger: { script: "json({ fire: true })", once: true },
}),
);
});
it.each([
["empty", ""],
["whitespace", " "],
])("rejects an explicitly %s trigger script before adding a job", async (_label, value) => {
const program = new Command().exitOverride();
registerCronAddCommand(program);
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
try {
await expect(
program.parseAsync(
[
"add",
"--name",
"watcher",
"--every",
"30s",
"--trigger-script",
value,
"--system-event",
"changed",
"--session",
"main",
],
{ from: "user" },
),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("--trigger-script must not be blank"),
);
expect(callGatewayFromCli).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
it.each(["job.js", "job.js "])("reads payload file %j and budgets on add", async (fileName) => {
const scriptPath = path.join(fixtureRoot, fileName);
await fs.writeFile(path.join(fixtureRoot, "job.js"), "return { notify: 'wrong file' }", "utf8");
await fs.writeFile(scriptPath, " return { notify: 'done' } \n", "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
await program.parseAsync(
[
"add",
"--name",
"script job",
"--every",
"30s",
"--script",
scriptPath,
"--script-timeout-seconds",
"450",
"--script-tool-budget",
"75",
"--session",
"isolated",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.add",
expect.objectContaining({
script: scriptPath,
scriptTimeoutSeconds: "450",
scriptToolBudget: "75",
}),
expect.objectContaining({
sessionTarget: "isolated",
payload: {
kind: "script",
script: "return { notify: 'done' }",
timeoutSeconds: 450,
toolBudget: 75,
},
}),
);
});
it.each([
{ label: "generic timeout only", args: ["--timeout-seconds", "30"] },
{
label: "generic and script-specific timeouts",
args: ["--timeout-seconds", "30", "--script-timeout-seconds", "60"],
},
])("rejects script creation with $label", async ({ args }) => {
const scriptPath = path.join(fixtureRoot, "job.js");
await fs.writeFile(scriptPath, "return { notify: 'done' }", "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
try {
await expect(
program.parseAsync(
["add", "--name", "script job", "--every", "30s", "--script", scriptPath, ...args],
{ from: "user" },
),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Use --script-timeout-seconds for script jobs, not --timeout-seconds.",
),
);
expect(callGatewayFromCli).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
it.each(["edit-job.js", "edit-job.js "])("reads payload file %j on edit", async (fileName) => {
const scriptPath = path.join(fixtureRoot, fileName);
await fs.writeFile(
path.join(fixtureRoot, "edit-job.js"),
"return { state: { ok: false } }",
"utf8",
);
await fs.writeFile(scriptPath, "return { state: { ok: true } }\n", "utf8");
const program = new Command().exitOverride();
registerCronEditCommand(program);
await program.parseAsync(
[
"edit",
"job-1",
"--script",
scriptPath,
"--script-timeout-seconds",
"600",
"--script-tool-budget",
"100",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ script: scriptPath }),
{
id: "job-1",
patch: {
payload: {
kind: "script",
script: "return { state: { ok: true } }",
timeoutSeconds: 600,
toolBudget: 100,
},
},
},
);
});
it("sends pacing bounds on add", async () => {
const program = new Command().exitOverride();
registerCronAddCommand(program);
await program.parseAsync(
[
"add",
"--name",
"paced",
"--every",
"30m",
"--pacing-min",
"15m",
"--pacing-max",
"4h",
"--system-event",
"check",
"--session",
"main",
],
{ from: "user" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.add",
expect.anything(),
expect.objectContaining({ pacing: { min: "15m", max: "4h" } }),
);
});
it("accepts trigger script files at the byte limit", async () => {
const scriptPath = path.join(fixtureRoot, "at-limit.js");
await fs.writeFile(scriptPath, "x".repeat(65_536), "utf8");
await expect(readCronTriggerScript(scriptPath)).resolves.toHaveLength(65_536);
});
it("uses the same size and empty-input validation for payload scripts", async () => {
const atLimitPath = path.join(fixtureRoot, "payload-at-limit.js");
const emptyPath = path.join(fixtureRoot, "payload-empty.js");
await fs.writeFile(atLimitPath, "x".repeat(65_536), "utf8");
await fs.writeFile(emptyPath, " \n", "utf8");
await expect(readCronPayloadScript(atLimitPath)).resolves.toHaveLength(65_536);
await expect(readCronPayloadScript(emptyPath)).rejects.toThrow(
"Script payload must not be empty",
);
});
it("stops oversized trigger script files before the gateway call", async () => {
const scriptPath = path.join(fixtureRoot, "oversized.js");
await fs.writeFile(scriptPath, "x".repeat(65_537), "utf8");
const program = new Command().exitOverride();
registerCronAddCommand(program);
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
try {
await expect(
program.parseAsync(
[
"add",
"--name",
"oversized",
"--every",
"30s",
"--trigger-script",
scriptPath,
"--system-event",
"changed",
"--session",
"main",
],
{ from: "user" },
),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Trigger script exceeds 65536 bytes"),
);
expect(callGatewayFromCli).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
it("maps --clear-trigger to a nullable edit patch", async () => {
const program = new Command().exitOverride();
registerCronEditCommand(program);
await program.parseAsync(["edit", "job-1", "--clear-trigger"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ clearTrigger: true }),
{ id: "job-1", patch: { trigger: null } },
);
});
it("maps --clear-pacing to a nullable edit patch", async () => {
const program = new Command().exitOverride();
registerCronEditCommand(program);
await program.parseAsync(["edit", "job-1", "--clear-pacing"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ clearPacing: true }),
{ id: "job-1", patch: { pacing: null } },
);
});
});