fix(cli): render cron edit JSON failures (#129022)

This commit is contained in:
Peter Steinberger
2026-08-24 22:38:23 -07:00
committed by GitHub
parent aec1cd40c0
commit 5cb07fb93b
3 changed files with 136 additions and 10 deletions
@@ -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",
+8 -10
View File
@@ -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);
}
}),
);
+111
View File
@@ -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",