| 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 } }, |
| ); |
| }); |
| }); |
|
|