Download src/cli/cron-cli/register.cron-edit.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 37.3 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/cron-cli/register.cron-edit.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/cli/cron-cli/register.cron-edit.test.ts
-
curl -L -o register.cron-edit.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/cron-cli/register.cron-edit.test.ts
37.3 kB
| // Cron edit register tests cover cron edit command registration and option wiring. | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { Command } from "commander"; | |
| import { 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 { registerCronEditCommand } = await import("./register.cron-edit.js"); | |
| function createCronProgram(): Command { | |
| const program = new Command(); | |
| program.exitOverride(); | |
| registerCronEditCommand(program); | |
| return program; | |
| } | |
| async function expectCronEditRejection(args: string[], message: string): Promise<void> { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| try { | |
| await expect( | |
| createCronProgram().parseAsync(["edit", "job-1", ...args], { from: "user" }), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(message)); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| } | |
| describe("cron edit command", () => { | |
| beforeEach(() => { | |
| callGatewayFromCli.mockReset(); | |
| callGatewayFromCli.mockResolvedValue({ ok: true }); | |
| }); | |
| it("documents that --best-effort-deliver implies announce mode when used alone (#83908)", () => { | |
| const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); | |
| const help = editCommand?.helpInformation() ?? ""; | |
| expect(help).toContain("--best-effort-deliver"); | |
| expect(help).toContain("--display-name <name>"); | |
| expect(help).toContain("--clear-display-name"); | |
| expect(help).toContain("--on-exit <shell>"); | |
| expect(help).toContain("--on-exit-cwd <path>"); | |
| expect(help).toContain("main|isolated|current|session:<id>"); | |
| expect(help).toMatch(/also\s+implies --announce when used alone/); | |
| }); | |
| it("accepts --json as the explicit machine-output spelling", async () => { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--enable", "--json"], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { enabled: true }, | |
| }); | |
| }); | |
| it("rethrows contradictory options in JSON mode without accessing the Gateway", async () => { | |
| const originalArgv = process.argv; | |
| process.argv = ["node", "openclaw", "cron", "edit", "job-1", "--json"]; | |
| try { | |
| await expect( | |
| createCronProgram() | |
| .parseAsync(["edit", "job-1", "--enable", "--disable", "--json"], { from: "user" }) | |
| .then(() => undefined), | |
| ).rejects.toThrow("Choose --enable or --disable, not both"); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| } finally { | |
| process.argv = originalArgv; | |
| } | |
| }); | |
| it("updates the human-readable display name without changing the job name", async () => { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--display-name", "Daily summary"], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { displayName: "Daily summary" }, | |
| }); | |
| }); | |
| it.each(["", " "])("rejects a blank --display-name value", async (value) => { | |
| await expectCronEditRejection(["--display-name", value], "--display-name must not be blank"); | |
| }); | |
| it("clears the display name and restores the stable name fallback", async () => { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--clear-display-name"], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { displayName: null }, | |
| }); | |
| }); | |
| it("rejects combining display-name set and clear flags", async () => { | |
| await expectCronEditRejection( | |
| ["--display-name", "Daily summary", "--clear-display-name"], | |
| "Use --display-name or --clear-display-name, not both", | |
| ); | |
| }); | |
| it("updates one pacing bound while preserving the other", async () => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", pacing: { min: "15m", max: "4h" } }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--pacing-min", "30m"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { pacing: { min: "30m", max: "4h" } }, | |
| }); | |
| }); | |
| it.each(["next.js", "next.js "])( | |
| "preserves trigger.once when reading %j (#119916)", | |
| async (fileName) => { | |
| const fixtureDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cron-edit-cli-")); | |
| const scriptPath = path.join(fixtureDir, fileName); | |
| await fs.promises.writeFile( | |
| path.join(fixtureDir, "next.js"), | |
| "return { fire: false };", | |
| "utf8", | |
| ); | |
| await fs.promises.writeFile(scriptPath, "return { fire: true };", "utf8"); | |
| const configRevision = "trigger-script-revision"; | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { | |
| id: "job-1", | |
| configRevision, | |
| trigger: { script: "return { fire: false };", once: true }, | |
| }; | |
| } | |
| return { ok: true }; | |
| }); | |
| try { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--trigger-script", scriptPath], { | |
| from: "user", | |
| }); | |
| } finally { | |
| await fs.promises.rm(fixtureDir, { recursive: true, force: true }); | |
| } | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.anything(), | |
| expect.objectContaining({ | |
| id: "job-1", | |
| patch: { trigger: { script: "return { fire: true };", once: true } }, | |
| expectedConfigRevision: configRevision, | |
| }), | |
| ); | |
| }, | |
| ); | |
| it.each([ | |
| ["empty", "", undefined], | |
| ["whitespace", " ", undefined], | |
| ["empty with --clear-trigger", "", "--clear-trigger"], | |
| ["whitespace with --clear-trigger", " ", "--clear-trigger"], | |
| ])("rejects %s --trigger-script before Gateway access", async (_label, value, clearFlag) => { | |
| await expectCronEditRejection( | |
| ["--trigger-script", value, ...(clearFlag ? [clearFlag] : [])], | |
| "--trigger-script must not be blank", | |
| ); | |
| }); | |
| it.each(["", " "])("rejects blank payload --script %j before Gateway access", async (value) => { | |
| await expectCronEditRejection( | |
| ["--script", value, "--display-name", "Replacement", "--pacing-min", "30m"], | |
| "--script must not be blank", | |
| ); | |
| }); | |
| it("validates trigger script files before Gateway access", async () => { | |
| const fixtureDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cron-edit-invalid-")); | |
| const emptyPath = path.join(fixtureDir, "empty.js"); | |
| const oversizedPath = path.join(fixtureDir, "oversized.js"); | |
| const missingPath = path.join(fixtureDir, "missing.js"); | |
| await Promise.all([ | |
| fs.promises.writeFile(emptyPath, " \n", "utf8"), | |
| fs.promises.writeFile(oversizedPath, "x".repeat(65_537), "utf8"), | |
| ]); | |
| try { | |
| await expectCronEditRejection( | |
| ["--pacing-min", "30m", "--trigger-script", emptyPath], | |
| "Trigger script must not be empty", | |
| ); | |
| await expectCronEditRejection( | |
| ["--pacing-min", "30m", "--trigger-script", oversizedPath], | |
| "Trigger script exceeds 65536 bytes", | |
| ); | |
| await expectCronEditRejection( | |
| ["--pacing-min", "30m", "--trigger-script", missingPath], | |
| "ENOENT", | |
| ); | |
| } finally { | |
| await fs.promises.rm(fixtureDir, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("reuses one versioned snapshot for combined pacing and tool edits", async () => { | |
| const configRevision = "current-job-revision"; | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { | |
| id: "job-1", | |
| configRevision, | |
| pacing: { min: "15m", max: "4h" }, | |
| payload: { kind: "agentTurn", message: "hello" }, | |
| }; | |
| } | |
| return { ok: true }; | |
| }); | |
| await createCronProgram().parseAsync( | |
| ["edit", "job-1", "--pacing-min", "30m", "--tools", "read"], | |
| { from: "user" }, | |
| ); | |
| expect(callGatewayFromCli.mock.calls.filter(([method]) => method === "cron.get")).toHaveLength( | |
| 1, | |
| ); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| pacing: { min: "30m", max: "4h" }, | |
| payload: { kind: "agentTurn", toolsAllow: ["read"] }, | |
| }, | |
| expectedConfigRevision: configRevision, | |
| }); | |
| }); | |
| it.each(["read", ""])("rejects --tools %j combined with --clear-tools", async (tools) => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| try { | |
| await expect( | |
| createCronProgram().parseAsync( | |
| ["edit", "job-1", "--pacing-min", "30m", "--tools", tools, "--clear-tools"], | |
| { from: "user" }, | |
| ), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("Use --tools or --clear-tools, not both"), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| }); | |
| it.each([ | |
| { | |
| label: "empty --agent", | |
| args: ["--agent", ""], | |
| message: "--agent must not be blank", | |
| }, | |
| { | |
| label: "whitespace --agent", | |
| args: ["--agent", " "], | |
| message: "--agent must not be blank", | |
| }, | |
| { | |
| label: "empty --agent with --clear-agent", | |
| args: ["--agent", "", "--clear-agent"], | |
| message: "--agent must not be blank", | |
| }, | |
| { | |
| label: "whitespace --agent with --clear-agent", | |
| args: ["--agent", " ", "--clear-agent"], | |
| message: "--agent must not be blank", | |
| }, | |
| { | |
| label: "--agent with --clear-agent", | |
| args: ["--agent", "main", "--clear-agent"], | |
| message: "Use --agent or --clear-agent, not both", | |
| }, | |
| { | |
| label: "empty --session-key", | |
| args: ["--session-key", ""], | |
| message: "--session-key must not be blank", | |
| }, | |
| { | |
| label: "whitespace --session-key", | |
| args: ["--session-key", " "], | |
| message: "--session-key must not be blank", | |
| }, | |
| { | |
| label: "empty --session-key with --clear-session-key", | |
| args: ["--session-key", "", "--clear-session-key"], | |
| message: "--session-key must not be blank", | |
| }, | |
| { | |
| label: "whitespace --session-key with --clear-session-key", | |
| args: ["--session-key", " ", "--clear-session-key"], | |
| message: "--session-key must not be blank", | |
| }, | |
| { | |
| label: "--session-key with --clear-session-key", | |
| args: ["--session-key", "agent:main:main", "--clear-session-key"], | |
| message: "Use --session-key or --clear-session-key, not both", | |
| }, | |
| ])("rejects $label", async ({ args, message }) => { | |
| await expectCronEditRejection(args, message); | |
| }); | |
| it.each([ | |
| { | |
| flag: "--best-effort-deliver", | |
| bestEffort: true, | |
| delivery: { mode: "announce", bestEffort: true }, | |
| }, | |
| { | |
| flag: "--no-best-effort-deliver", | |
| bestEffort: false, | |
| delivery: { bestEffort: false }, | |
| }, | |
| ])("keeps $flag-only edits delivery-only (#83908)", async ({ flag, bestEffort, delivery }) => { | |
| await createCronProgram().parseAsync(["edit", "job-1", flag], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ bestEffortDeliver: bestEffort }), | |
| { id: "job-1", patch: { delivery } }, | |
| ); | |
| }); | |
| it("does not set delivery mode to announce when disabling best-effort on payload edits", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync( | |
| ["edit", "job-1", "--no-best-effort-deliver", "--message", "new message"], | |
| { from: "user" }, | |
| ); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "agentTurn", | |
| message: "new message", | |
| }, | |
| delivery: { | |
| bestEffort: false, | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("preserves timezone without copying stale stagger when --cron replaces expression (#92291)", async () => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { | |
| id: "job-1", | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 * * * *", | |
| tz: "America/Phoenix", | |
| staggerMs: 120_000, | |
| }, | |
| }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--cron", "0 5 * * *"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { id: "job-1" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 5 * * *", | |
| tz: "America/Phoenix", | |
| staggerMs: undefined, | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("allows --tz override when --cron replaces expression (#92291)", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync( | |
| ["edit", "job-1", "--cron", "0 5 * * *", "--tz", "UTC", "--stagger", "10s"], | |
| { from: "user" }, | |
| ); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 5 * * *", | |
| tz: "UTC", | |
| staggerMs: 10000, | |
| }, | |
| }, | |
| }); | |
| expect(callGatewayFromCli).not.toHaveBeenCalledWith("cron.list", expect.anything(), { | |
| includeDisabled: true, | |
| limit: expect.any(Number), | |
| offset: expect.any(Number), | |
| }); | |
| }); | |
| it("preserves timezone when --cron edits stagger metadata (#92291)", async () => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { | |
| id: "job-1", | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 * * * *", | |
| tz: "America/Phoenix", | |
| staggerMs: 120_000, | |
| }, | |
| }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--cron", "0 5 * * *", "--stagger", "10s"], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 5 * * *", | |
| tz: "America/Phoenix", | |
| staggerMs: 10000, | |
| }, | |
| }, | |
| }); | |
| }); | |
| describe.each(["0", "12"])("timeout-only edits with %s seconds", (timeout) => { | |
| it.each([ | |
| { | |
| kind: "agentTurn", | |
| payload: { kind: "agentTurn", message: "hello" }, | |
| }, | |
| { | |
| kind: "command", | |
| payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] }, | |
| }, | |
| ])("preserves $kind payload kind for timeout-only edits", async ({ kind, payload }) => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--timeout-seconds", timeout], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { | |
| id: "job-1", | |
| }); | |
| expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.list")).toBe(false); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ timeoutSeconds: timeout }), | |
| { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind, | |
| timeoutSeconds: Number(timeout), | |
| }, | |
| }, | |
| }, | |
| ); | |
| }); | |
| it.each([ | |
| { | |
| kind: "script", | |
| payload: { kind: "script", script: "return { notify: 'hello' }", timeoutSeconds: 5 }, | |
| error: "Use --script-timeout-seconds for script jobs", | |
| }, | |
| { | |
| kind: "systemEvent", | |
| payload: { kind: "systemEvent", text: "hello" }, | |
| error: "--timeout-seconds is not supported for systemEvent jobs", | |
| }, | |
| { | |
| kind: "heartbeat", | |
| payload: { kind: "heartbeat" }, | |
| error: "--timeout-seconds is not supported for heartbeat jobs", | |
| }, | |
| ])( | |
| "rejects timeout-only edits for stored $kind payloads before cron.update", | |
| async ({ payload, error }) => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| try { | |
| await expect( | |
| createCronProgram().parseAsync(["edit", "job-1", "--timeout-seconds", timeout], { | |
| from: "user", | |
| }), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(error)); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { | |
| id: "job-1", | |
| }); | |
| expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( | |
| false, | |
| ); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| }, | |
| ); | |
| }); | |
| it("rejects generic timeout combined with an explicit systemEvent before cron.update", async () => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| try { | |
| await expect( | |
| createCronProgram().parseAsync( | |
| ["edit", "job-1", "--system-event", "hello", "--timeout-seconds", "12"], | |
| { from: "user" }, | |
| ), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("--timeout-seconds is not supported for systemEvent jobs"), | |
| ); | |
| expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( | |
| false, | |
| ); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| }); | |
| it.each([ | |
| ["--script", "missing-script.js"], | |
| ["--script-tool-budget", "3"], | |
| ["--script-timeout-seconds", "20"], | |
| ])( | |
| "rejects generic timeout combined with script option %s before cron.update", | |
| async (flag, value) => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| try { | |
| await expect( | |
| createCronProgram().parseAsync( | |
| ["edit", "job-1", "--timeout-seconds", "12", flag, value], | |
| { from: "user" }, | |
| ), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("Use --script-timeout-seconds for script jobs"), | |
| ); | |
| expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.update")).toBe( | |
| false, | |
| ); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| }, | |
| ); | |
| it("updates script timeout with the script-specific option", async () => { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--script-timeout-seconds", "20"], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ scriptTimeoutSeconds: "20" }), | |
| { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "script", | |
| timeoutSeconds: 20, | |
| }, | |
| }, | |
| }, | |
| ); | |
| }); | |
| it("falls back to cron.list when an older Gateway does not support cron.get", async () => { | |
| const unknownMethodError = Object.assign(new Error("unknown method: cron.get"), { | |
| name: "GatewayClientRequestError", | |
| gatewayCode: "INVALID_REQUEST", | |
| }); | |
| callGatewayFromCli.mockImplementation( | |
| async (method: string, _opts: unknown, params?: unknown) => { | |
| if (method === "cron.get") { | |
| throw unknownMethodError; | |
| } | |
| if (method === "cron.list") { | |
| const offset = (params as { offset?: number }).offset ?? 0; | |
| if (offset === 0) { | |
| return { | |
| jobs: [{ id: "other-job", schedule: { kind: "cron", expr: "0 * * * *" } }], | |
| hasMore: true, | |
| nextOffset: 200, | |
| }; | |
| } | |
| return { | |
| jobs: [ | |
| { | |
| id: "job-1", | |
| schedule: { kind: "cron", expr: "0 */2 * * *", staggerMs: 300_000 }, | |
| }, | |
| ], | |
| hasMore: false, | |
| nextOffset: null, | |
| }; | |
| } | |
| return { ok: true, params }; | |
| }, | |
| ); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--exact"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { | |
| id: "job-1", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.list", expect.anything(), { | |
| includeDisabled: true, | |
| limit: 200, | |
| offset: 0, | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.list", expect.anything(), { | |
| includeDisabled: true, | |
| limit: 200, | |
| offset: 200, | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| schedule: { | |
| kind: "cron", | |
| expr: "0 */2 * * *", | |
| staggerMs: 0, | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("clears the model override with --clear-model (CLI parity with cron.update model:null)", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-model"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearModel: true }), | |
| { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "agentTurn", | |
| model: null, | |
| }, | |
| }, | |
| }, | |
| ); | |
| }); | |
| it.each([ | |
| ["--model", "", "--clear-model"], | |
| ["--model", " ", "--clear-model"], | |
| ["--thinking", "", "--clear-thinking"], | |
| ["--thinking", " ", "--clear-thinking"], | |
| ])("rejects blank %s %j combined with %s", async (flag, value, clearFlag) => { | |
| await expectCronEditRejection( | |
| [flag, value, clearFlag], | |
| `Use ${flag} or ${clearFlag}, not both`, | |
| ); | |
| }); | |
| it("stores an explicit wildcard with --clear-tools", async () => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload: { kind: "agentTurn", message: "hello" } }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-tools"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearTools: true }), | |
| { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "agentTurn", | |
| toolsAllow: ["*"], | |
| }, | |
| }, | |
| }, | |
| ); | |
| }); | |
| it.each( | |
| [ | |
| { kind: "agentTurn", payload: { kind: "agentTurn", message: "hello" } }, | |
| { kind: "command", payload: { kind: "command", argv: ["echo", "hello"] } }, | |
| { kind: "script", payload: { kind: "script", script: "return { notify: 'hello' }" } }, | |
| { kind: "systemEvent", payload: { kind: "systemEvent", text: "hello" } }, | |
| ].flatMap((entry) => [ | |
| { ...entry, tools: "read,write", toolsAllow: ["read", "write"] }, | |
| { ...entry, tools: "", toolsAllow: [] }, | |
| ]), | |
| )( | |
| "preserves $kind payloads when editing their tool allowlist to '$tools'", | |
| async ({ kind, payload, tools, toolsAllow }) => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload }; | |
| } | |
| return { ok: true }; | |
| }); | |
| await createCronProgram().parseAsync(["edit", "job-1", "--tools", tools], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { | |
| id: "job-1", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { payload: { kind, toolsAllow } }, | |
| }); | |
| }, | |
| ); | |
| it.each([ | |
| { kind: "agentTurn", payload: { kind: "agentTurn", message: "hello" } }, | |
| { kind: "command", payload: { kind: "command", argv: ["echo", "hello"] } }, | |
| { kind: "script", payload: { kind: "script", script: "return { notify: 'hello' }" } }, | |
| { kind: "systemEvent", payload: { kind: "systemEvent", text: "hello" } }, | |
| ])("preserves $kind payloads when clearing their tool allowlist", async ({ kind, payload }) => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-tools"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { payload: { kind, toolsAllow: ["*"] } }, | |
| }); | |
| }); | |
| it.each([ | |
| { | |
| label: "command with a restricted allowlist", | |
| payload: { kind: "command", argv: ["echo", "hello"] }, | |
| toolArgs: ["--tools", "read,write"], | |
| toolsAllow: ["read", "write"], | |
| }, | |
| { | |
| label: "command with a cleared allowlist", | |
| payload: { kind: "command", argv: ["echo", "hello"] }, | |
| toolArgs: ["--clear-tools"], | |
| toolsAllow: ["*"], | |
| }, | |
| { | |
| label: "agent turn with a restricted allowlist", | |
| payload: { kind: "agentTurn", message: "hello" }, | |
| toolArgs: ["--tools", "read,write"], | |
| toolsAllow: ["read", "write"], | |
| }, | |
| { | |
| label: "agent turn with a cleared allowlist", | |
| payload: { kind: "agentTurn", message: "hello" }, | |
| toolArgs: ["--clear-tools"], | |
| toolsAllow: ["*"], | |
| }, | |
| ])( | |
| "preserves the existing $label when editing its timeout", | |
| async ({ payload, toolArgs, toolsAllow }) => { | |
| callGatewayFromCli.mockImplementation(async (method: string) => { | |
| if (method === "cron.get") { | |
| return { id: "job-1", payload }; | |
| } | |
| return { ok: true }; | |
| }); | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--timeout-seconds", "12", ...toolArgs], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.get", expect.anything(), { | |
| id: "job-1", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: payload.kind, | |
| timeoutSeconds: 12, | |
| toolsAllow, | |
| }, | |
| }, | |
| }); | |
| }, | |
| ); | |
| it("keeps explicit conversational payload changes independent of existing job reads", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync( | |
| ["edit", "job-1", "--message", "new message", "--timeout-seconds", "12", "--tools", "read"], | |
| { from: "user" }, | |
| ); | |
| expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.get")).toBe(false); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "agentTurn", | |
| message: "new message", | |
| timeoutSeconds: 12, | |
| toolsAllow: ["read"], | |
| }, | |
| }, | |
| }); | |
| }); | |
| it.each([ | |
| { duration: "0", cooldownMs: 0 }, | |
| { duration: "0s", cooldownMs: 0 }, | |
| { duration: "0m", cooldownMs: 0 }, | |
| { duration: "30s", cooldownMs: 30_000 }, | |
| { duration: "1h30m", cooldownMs: 5_400_000 }, | |
| ])("accepts failure alert cooldown $duration", async ({ duration, cooldownMs }) => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--failure-alert-cooldown", duration], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { failureAlert: { cooldownMs } }, | |
| }); | |
| }); | |
| it.each(["-1s", "not-a-duration", "999999999999999999d"])( | |
| "rejects invalid failure alert cooldown %s", | |
| async (duration) => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| const program = createCronProgram(); | |
| try { | |
| await expect( | |
| program.parseAsync(["edit", "job-1", "--failure-alert-cooldown", duration], { | |
| from: "user", | |
| }), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("Invalid --failure-alert-cooldown."), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| } finally { | |
| errorSpy.mockRestore(); | |
| } | |
| }, | |
| ); | |
| it("clears the thinking override with --clear-thinking (CLI parity with cron.update thinking:null)", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-thinking"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearThinking: true }), | |
| { | |
| id: "job-1", | |
| patch: { | |
| payload: { | |
| kind: "agentTurn", | |
| thinking: null, | |
| }, | |
| }, | |
| }, | |
| ); | |
| }); | |
| it("rejects combining --thinking with --clear-thinking", async () => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| const program = createCronProgram(); | |
| await expect( | |
| program.parseAsync(["edit", "job-1", "--thinking", "high", "--clear-thinking"], { | |
| from: "user", | |
| }), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("Use --thinking or --clear-thinking, not both"), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| errorSpy.mockRestore(); | |
| }); | |
| it("documents the --clear-model flag alongside the sibling --clear-tools", () => { | |
| const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); | |
| const help = editCommand?.helpInformation() ?? ""; | |
| expect(help).toContain("--clear-model"); | |
| expect(help).toContain("--clear-thinking"); | |
| expect(help).toContain("--clear-tools"); | |
| }); | |
| it("clears the delivery channel with --clear-channel (CLI parity with cron.update channel:null)", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-channel"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearChannel: true }), | |
| { | |
| id: "job-1", | |
| patch: { delivery: { channel: null } }, | |
| }, | |
| ); | |
| }); | |
| it("clears the delivery destination with --clear-to", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-to"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearTo: true }), | |
| { | |
| id: "job-1", | |
| patch: { delivery: { to: null } }, | |
| }, | |
| ); | |
| }); | |
| it("clears the delivery thread id with --clear-thread-id", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-thread-id"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearThreadId: true }), | |
| { | |
| id: "job-1", | |
| patch: { delivery: { threadId: null } }, | |
| }, | |
| ); | |
| }); | |
| it("clears the delivery account override with --clear-account", async () => { | |
| const program = createCronProgram(); | |
| await program.parseAsync(["edit", "job-1", "--clear-account"], { from: "user" }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith( | |
| "cron.update", | |
| expect.objectContaining({ clearAccount: true }), | |
| { | |
| id: "job-1", | |
| patch: { delivery: { accountId: null } }, | |
| }, | |
| ); | |
| }); | |
| it.each([ | |
| { set: "--channel", value: "telegram", clear: "--clear-channel" }, | |
| { set: "--to", value: "12345", clear: "--clear-to" }, | |
| { set: "--thread-id", value: "42", clear: "--clear-thread-id" }, | |
| { set: "--account", value: "writer", clear: "--clear-account" }, | |
| ])("rejects $set combined with $clear", async ({ set, value, clear }) => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| const program = createCronProgram(); | |
| await expect( | |
| program.parseAsync(["edit", "job-1", set, value, clear], { from: "user" }), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining(`Use ${set} or ${clear}, not both`), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| errorSpy.mockRestore(); | |
| }); | |
| it.each(["", " "])("rejects blank --command-cwd %j", async (value) => { | |
| await expectCronEditRejection(["--command-cwd", value], "--command-cwd must not be blank"); | |
| }); | |
| it("rejects blank --command-cwd before loading an existing job", async () => { | |
| await expectCronEditRejection( | |
| ["--pacing-min", "30m", "--command-cwd", " "], | |
| "--command-cwd must not be blank", | |
| ); | |
| }); | |
| it.each(["", " "])("preserves --command-input %j as command stdin", async (value) => { | |
| await createCronProgram().parseAsync(["edit", "job-1", "--command-input", value], { | |
| from: "user", | |
| }); | |
| expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { | |
| id: "job-1", | |
| patch: { payload: { kind: "command", input: value } }, | |
| }); | |
| }); | |
| it("rejects --webhook combined with a delivery clear flag", async () => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| const program = createCronProgram(); | |
| await expect( | |
| program.parseAsync( | |
| ["edit", "job-1", "--webhook", "https://example.invalid/hook", "--clear-channel"], | |
| { from: "user" }, | |
| ), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining("--webhook cannot be combined with chat delivery options."), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| errorSpy.mockRestore(); | |
| }); | |
| it.each(["", "not-a-url"])("rejects invalid --webhook %j before gateway RPC", async (value) => { | |
| await expectCronEditRejection(["--webhook", value], "--webhook must be a valid http(s) URL"); | |
| }); | |
| it("documents the delivery clear flags alongside the sibling --clear-model", () => { | |
| const editCommand = createCronProgram().commands.find((command) => command.name() === "edit"); | |
| const help = editCommand?.helpInformation() ?? ""; | |
| expect(help).toContain("--clear-channel"); | |
| expect(help).toContain("--clear-to"); | |
| expect(help).toContain("--clear-thread-id"); | |
| expect(help).toContain("--clear-account"); | |
| }); | |
| it.each([ | |
| ["--channel", "telegram"], | |
| ["--to", "+1234567890"], | |
| ["--account", "coordinator"], | |
| ["--thread-id", "42"], | |
| ])("rejects explicit chat delivery %s on main systemEvent cron edit", async (flag, value) => { | |
| const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {}); | |
| const program = createCronProgram(); | |
| await expect( | |
| program.parseAsync( | |
| ["edit", "job-1", "--session", "main", "--system-event", "wakeup", flag, value], | |
| { from: "user" }, | |
| ), | |
| ).rejects.toMatchObject({ name: "ExitError", code: 1 }); | |
| expect(errorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining( | |
| "--channel, --to, --account, and --thread-id require a non-main agentTurn or command job with delivery.", | |
| ), | |
| ); | |
| expect(callGatewayFromCli).not.toHaveBeenCalled(); | |
| errorSpy.mockRestore(); | |
| }); | |
| }); | |