diff --git a/src/cli/cron-cli/register.cron-edit.test.ts b/src/cli/cron-cli/register.cron-edit.test.ts index aa4eb2a75374..503621023bcb 100644 --- a/src/cli/cron-cli/register.cron-edit.test.ts +++ b/src/cli/cron-cli/register.cron-edit.test.ts @@ -72,6 +72,23 @@ describe("cron edit command", () => { }); }); + it("rethrows contradictory options in JSON mode without accessing the Gateway", async () => { + const originalArgv = process.argv; + const exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation((() => undefined) as never); + 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; + exitSpy.mockRestore(); + } + }); + 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", diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index dea2ebd46c75..37347b071616 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -7,15 +7,10 @@ import { import type { Command } from "commander"; import type { CronJob } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; -import { danger } from "../../globals.js"; -import { formatErrorMessage } from "../../infra/errors.js"; import { sanitizeAgentId } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; -import { - addGatewayClientOptions, - callGatewayFromCli, - type GatewayRpcOpts, -} from "../gateway-rpc.js"; +import type { GatewayRpcOpts } from "../gateway-rpc.js"; +import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parseDurationMs } from "../parse-duration.js"; import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js"; import { createCronOutputCommand } from "./output-mode.js"; @@ -27,7 +22,11 @@ import { resolveCronEditScheduleRequest, validateStreamScheduleMetadata, } from "./schedule-options.js"; -import { getCronChannelOptions, warnIfCronSchedulerDisabled } from "./shared.js"; +import { + getCronChannelOptions, + handleCronCliError, + warnIfCronSchedulerDisabled, +} from "./shared.js"; import { normalizeCronSessionTargetOption } from "./thread-id-shared.js"; import { readCronTriggerScript } from "./trigger-options.js"; @@ -439,8 +438,7 @@ export function registerCronEditCommand(cron: Command) { defaultRuntime.writeJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { - defaultRuntime.error(danger(formatErrorMessage(err))); - defaultRuntime.exit(1); + handleCronCliError(err); } }), ); diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index e36998684926..4ccb12e632b6 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -53,6 +53,117 @@ async function seedTrajectorySession(tempHome: string, sessionKey: string) { } describe("cli json stdout contract", () => { + it.each([ + { + name: "implicit JSON", + args: ["cron", "edit", "job-1", "--enable", "--disable"], + }, + { + name: "explicit JSON", + args: ["cron", "edit", "job-1", "--enable", "--disable", "--json"], + }, + { + name: "automation alias implicit JSON", + args: ["automations", "edit", "job-1", "--enable", "--disable"], + }, + { + name: "ordinary local validation failure", + args: ["cron", "edit", "job-1", "--command-cwd", "", "--json"], + message: "--command-cwd must not be blank", + }, + { + name: "Gateway failure implicit JSON", + args: ["cron", "edit", "job-1", "--enable", "--port", "29793", "--token", "fixture-token"], + gatewayRequest: true, + }, + { + name: "Gateway failure explicit JSON", + args: [ + "cron", + "edit", + "job-1", + "--enable", + "--port", + "29793", + "--token", + "fixture-token", + "--json", + ], + gatewayRequest: true, + }, + { + name: "forced Commander JSON", + args: ["cron", "edit", "job-1", "--enable", "--disable", "--json"], + commander: true, + }, + { + name: "dual-TTY JSON", + args: ["cron", "edit", "job-1", "--enable", "--disable", "--json"], + tty: true, + }, + { + name: "human-output sibling", + args: ["cron", "list", "--agent", ""], + message: "--agent must not be blank", + human: true, + }, + ])("renders cron edit failures through the shared owner for $name", async (testCase) => { + await withTempHome( + async (tempHome) => { + const configPath = path.join(tempHome, "missing-openclaw.json"); + const stateDir = path.join(tempHome, "isolated-state"); + const gatewayError = "AUTOQA_INJECTED_GATEWAY_FAILURE"; + const preload = Buffer.from( + [ + 'import net from "node:net";', + `net.Socket.prototype.connect = function () { throw new Error(${JSON.stringify(gatewayError)}); };`, + ...("tty" in testCase + ? [ + 'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });', + 'Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });', + ] + : []), + ].join("\n"), + ).toString("base64"); + const result = runBuiltCli(tempHome, testCase.args, { + NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + ...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}), + ...("tty" in testCase ? { FORCE_COLOR: "1" } : {}), + }); + const message = + "gatewayRequest" in testCase + ? gatewayError + : (testCase.message ?? "Choose --enable or --disable, not both"); + + expect(result.status, result.stderr).toBe(1); + expect(result.stdout, result.stderr).not.toMatch(/[\u001B\u0007]/u); + if ("human" in testCase) { + expect(result.stdout).toBe(""); + } else { + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { type: "cli_error", message }, + }); + } + expect(result.stderr).toContain(message); + if ("gatewayRequest" in testCase) { + expect(result.stderr).toContain(gatewayError); + } else { + expect(result.stderr).not.toContain(gatewayError); + await expect(fs.stat(stateDir)).rejects.toMatchObject({ code: "ENOENT" }); + } + if ("tty" in testCase) { + expect(result.stderr).toContain("\u001B[?25h"); + } + await expect(fs.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + { prefix: "openclaw-cron-edit-json-failure-e2e-" }, + ); + }); + it.each([ { name: "bare report with parent JSON",