openclaw / src /cron /command-runner.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3144483 verified
Raw
History Blame Contribute Delete
9.92 kB
import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import * as execSpawn from "../process/exec-spawn.js";
import * as processExecution from "../process/exec.js";
import { isPidAlive } from "../shared/pid-alive.js";
import { readPidFile, waitForPidToExit } from "../test-utils/process-tree.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import { runCronCommandJob } from "./command-runner.js";
import type { CronJob } from "./types.js";
function makeCommandJob(payload: Extract<CronJob["payload"], { kind: "command" }>): CronJob {
const now = Date.now();
return {
id: "command-job",
name: "Command job",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload,
state: {},
};
}
describe("runCronCommandJob", () => {
it("runs command argv and returns stdout as the deliverable summary", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('hello from cron')"],
timeoutSeconds: 5,
}),
nowMs: () => 123,
});
expect(result.status).toBe("ok");
expect(result.errorClassification).toBeUndefined();
expect(result.summary).toBe("hello from cron");
expect(result.diagnostics?.entries[0]).toMatchObject({
ts: 123,
source: "exec",
severity: "info",
exitCode: 0,
});
});
it("preserves exact NO_REPLY stdout for outbound suppression", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('NO_REPLY\\n')"],
timeoutSeconds: 5,
}),
});
expect(result.status).toBe("ok");
expect(result.summary).toBe("NO_REPLY");
});
it("marks non-zero exit codes as cron errors and keeps stderr as summary", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stderr.write('bad thing'); process.exit(7)"],
timeoutSeconds: 5,
}),
});
expect(result.status).toBe("error");
expect(result.error).toBe("command exited with code 7");
expect(result.errorClassification).toEqual({ kind: "permanent" });
expect(result.failureNotificationDetail).toEqual({ kind: "command-exit", exitCode: 7 });
expect(result.summary).toBe("bad thing");
expect(result.diagnostics?.entries[0]).toMatchObject({
source: "exec",
severity: "error",
exitCode: 7,
});
});
it("preserves early action-required command output when the captured tail is truncated", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [
process.execPath,
"-e",
[
"process.stdout.write('Visit https://example.com/device and enter code ABCD-EFGH\\n')",
"process.stdout.write('x'.repeat(200))",
].join(";"),
],
timeoutSeconds: 5,
outputMaxBytes: 24,
}),
});
expect(result.status).toBe("ok");
expect(result.summary).toBe(
`action-required output preserved:\nVisit https://example.com/device and enter code ABCD-EFGH\n\n${"x".repeat(24)}`,
);
expect(result.diagnostics?.summary).toBe(result.summary);
expect(result.diagnostics?.entries[0]).toMatchObject({ truncated: true });
});
it("marks command timeouts as cron errors", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"],
timeoutSeconds: 0.05,
}),
nowMs: () => 456,
});
expect(result.status).toBe("error");
expect(result.error).toBe("command timed out");
expect(result.errorClassification).toEqual({ kind: "reason", reason: "timeout" });
expect(result.failureNotificationDetail).toEqual({
kind: "command-timeout",
mode: "wall-clock",
});
expect(result.diagnostics?.entries[0]).toMatchObject({
ts: 456,
source: "exec",
severity: "error",
});
});
it.skipIf(process.platform === "win32")("kills shell process groups on timeout", async () =>
withTempDir("openclaw-cron-command-", async (tempDir) => {
const childPidPath = path.join(tempDir, "child.pid");
const shellCommand = [
"sleep 60 &",
"child_pid=$!",
`printf '%s' "$child_pid" > ${JSON.stringify(childPidPath)}`,
'wait "$child_pid"',
].join("\n");
const controller = new AbortController();
const realSetTimeout = setTimeout;
const spawnSpy = vi.spyOn(execSpawn, "spawnCommandWithInvocation");
let parent: ChildProcess | undefined;
let command: ReturnType<typeof runCronCommandJob> | undefined;
try {
// Freeze the deadline until the real shell has published a live child;
// startup time must not consume the behavior this test is exercising.
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] });
command = runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: ["sh", "-lc", shellCommand],
timeoutSeconds: 0.5,
}),
abortSignal: controller.signal,
});
const spawnResult = spawnSpy.mock.results[0];
if (spawnResult?.type !== "return") {
throw new Error("command did not spawn");
}
parent = spawnResult.value.child.nodeChildProcess;
let childPid = 0;
for (let attempt = 0; attempt < 80; attempt += 1) {
if (existsSync(childPidPath)) {
childPid = await readPidFile(childPidPath);
if (Number.isSafeInteger(childPid) && isPidAlive(childPid)) {
break;
}
}
await new Promise<void>((resolve) => {
realSetTimeout(resolve, 25);
});
}
expect(Number.isSafeInteger(childPid)).toBe(true);
expect(isPidAlive(childPid)).toBe(true);
await vi.advanceTimersByTimeAsync(500);
await vi.advanceTimersByTimeAsync(execSpawn.COMMAND_PROCESS_TREE_KILL_GRACE_MS);
// Force delivery now has a separate bounded exit-observation phase.
await vi.advanceTimersByTimeAsync(execSpawn.COMMAND_PROCESS_TREE_KILL_GRACE_MS);
const result = await command;
expect(result.status).toBe("error");
expect(result.error).toBe("command timed out");
vi.useRealTimers();
expect(await waitForPidToExit(childPid)).toBe(true);
} finally {
try {
controller.abort();
if (parent?.pid) {
try {
process.kill(-parent.pid, "SIGKILL");
} catch {
// The command may already have reaped its process group.
}
}
if (vi.isFakeTimers()) {
await vi.runAllTimersAsync();
}
} finally {
vi.useRealTimers();
spawnSpy.mockRestore();
await command;
}
}
}),
);
it("marks no-output timeouts as cron errors", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"],
timeoutSeconds: 5,
noOutputTimeoutSeconds: 0.05,
}),
});
expect(result.status).toBe("error");
expect(result.error).toBe("command produced no output before noOutputTimeoutSeconds");
expect(result.errorClassification).toEqual({ kind: "reason", reason: "timeout" });
expect(result.failureNotificationDetail).toEqual({
kind: "command-timeout",
mode: "no-output",
});
expect(result.diagnostics?.entries[0]).toMatchObject({
source: "exec",
severity: "error",
});
});
it("marks aborted command runs as cron errors", async () => {
const controller = new AbortController();
controller.abort();
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('should not run')"],
timeoutSeconds: 5,
}),
abortSignal: controller.signal,
});
expect(result.status).toBe("error");
expect(result.error).toBe("command stopped");
expect(result.errorClassification).toBeUndefined();
expect(result.summary).toBeUndefined();
expect(result.failureNotificationDetail).toBeUndefined();
});
it("keeps command start failures generic", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: ["openclaw-command-that-does-not-exist"],
timeoutSeconds: 5,
}),
});
expect(result.status).toBe("error");
expect(result.failureNotificationDetail).toBeUndefined();
expect(result.errorClassification).toEqual({ kind: "permanent" });
});
it("leaves transient command start errors unclassified", async () => {
const spawnError = Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" });
const runCommand = vi
.spyOn(processExecution, "runCommandWithTimeout")
.mockRejectedValueOnce(spawnError);
try {
const result = await runCronCommandJob({
job: makeCommandJob({ kind: "command", argv: [process.execPath] }),
});
expect(result.status).toBe("error");
expect(result.error).toBe("spawn EAGAIN");
expect(result.errorClassification).toBeUndefined();
} finally {
runCommand.mockRestore();
}
});
});