openclaw / src /cli /gateway-cli /suspend-cli.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
7.66 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNonExitingRuntimeEnv } from "../../test-utils/plugin-runtime-env.js";
import { runGatewayResume, runGatewaySuspend } from "./suspend-cli.js";
const readyResult = {
status: "ready" as const,
suspensionId: "suspension-1",
expiresAtMs: Date.parse("2026-08-11T12:00:00.000Z"),
activeCount: 0,
blockers: [],
};
const busyResult = {
status: "busy" as const,
reason: "active-work" as const,
retryAfterMs: 200,
activeCount: 1,
blockers: [{ kind: "root-request" as const, count: 1, message: "1 active request" }],
};
describe("gateway suspend CLI", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllEnvs());
it.each([undefined, 0, "0", " 0 ", "0.25", "1e1"])(
"prints a ready lease for wait %j",
async (waitSeconds) => {
const callGateway = vi.fn(async () => readyResult);
const runtime = createNonExitingRuntimeEnv();
await runGatewaySuspend({ rpcOpts: {}, waitSeconds }, { callGateway, runtime });
expect(callGateway).toHaveBeenCalledWith(
"gateway.suspend.prepare",
{},
{ requestId: expect.stringMatching(/^cli-[0-9a-f]{8}$/u) },
);
expect(callGateway).toHaveBeenCalledOnce();
expect(runtime.log).toHaveBeenCalledWith("Gateway suspension prepared.");
expect(runtime.log).toHaveBeenCalledWith("Suspension ID: suspension-1");
expect(runtime.log).toHaveBeenCalledWith(
`Expires: 2026-08-11T12:00:00.000Z (${readyResult.expiresAtMs} ms)`,
);
expect(runtime.log).toHaveBeenCalledWith("Resume with: openclaw gateway resume suspension-1");
},
);
it.each(["", " ", "\t\n"])(
"rejects blank wait %j before acquiring a lease",
async (waitSeconds) => {
const callGateway = vi.fn(async () => readyResult);
await expect(
runGatewaySuspend(
{ rpcOpts: {}, waitSeconds },
{ callGateway, runtime: createNonExitingRuntimeEnv() },
),
).rejects.toThrow("--wait must be a non-negative number of seconds");
expect(callGateway).not.toHaveBeenCalled();
},
);
it.each([
{
name: "default gateway",
rpcOpts: {},
profile: "",
container: "",
command: "openclaw gateway resume suspension-1",
},
{
name: "custom local port",
rpcOpts: { localPortOverride: 18999 },
profile: "",
container: "",
command: "openclaw gateway resume suspension-1 --port 18999",
},
{
name: "named profile and custom local port",
rpcOpts: { localPortOverride: 18999 },
profile: "work",
container: "",
command: "openclaw --profile work gateway resume suspension-1 --port 18999",
},
{
name: "container takes precedence over profile",
rpcOpts: { localPortOverride: 18999 },
profile: "work",
container: "demo",
command: "openclaw --container demo gateway resume suspension-1 --port 18999",
},
{
name: "explicit URL and credentials remain private",
rpcOpts: { url: "wss://gateway.example:19444", token: "opaque-credential" },
profile: "",
container: "",
command: "openclaw gateway resume suspension-1",
},
])(
"prints a correctly scoped resume hint for $name",
async ({ rpcOpts, profile, container, command }) => {
vi.stubEnv("OPENCLAW_PROFILE", profile);
vi.stubEnv("OPENCLAW_CONTAINER_HINT", container);
const runtime = createNonExitingRuntimeEnv();
await runGatewaySuspend(
{ rpcOpts },
{ callGateway: vi.fn(async () => readyResult), runtime },
);
expect(runtime.log).toHaveBeenCalledWith(`Resume with: ${command}`);
},
);
it("reports blockers without polling when --wait is omitted", async () => {
const callGateway = vi.fn(async () => busyResult);
await expect(
runGatewaySuspend(
{ rpcOpts: {}, requestId: "host-operation" },
{ callGateway, runtime: createNonExitingRuntimeEnv() },
),
).rejects.toThrow(
"Gateway suspension is busy (active-work; 1 active).\nBlockers:\n- 1 active request\nRetry later or use --wait <seconds>.",
);
expect(callGateway).toHaveBeenCalledOnce();
});
it("polls with one stable request id until the Gateway is ready", async () => {
const callGateway = vi
.fn()
.mockResolvedValueOnce(busyResult)
.mockResolvedValueOnce(readyResult);
let now = 1_000;
const sleep = vi.fn(async (delayMs: number) => {
now += delayMs;
});
await runGatewaySuspend(
{ rpcOpts: {}, requestId: "host-operation", waitSeconds: "2" },
{ callGateway, runtime: createNonExitingRuntimeEnv(), nowMs: () => now, sleep },
);
expect(sleep).toHaveBeenCalledExactlyOnceWith(200);
expect(callGateway).toHaveBeenCalledTimes(2);
expect(callGateway.mock.calls.map((call) => call[2])).toEqual([
{ requestId: "host-operation" },
{ requestId: "host-operation" },
]);
});
it("emits the latest busy result and exits nonzero in JSON mode", async () => {
const runtime = createNonExitingRuntimeEnv();
await runGatewaySuspend(
{ rpcOpts: { json: true }, requestId: "host-operation", json: true },
{ callGateway: vi.fn(async () => busyResult), runtime },
);
expect(runtime.writeJson).toHaveBeenCalledWith({
...busyResult,
requestId: "host-operation",
});
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("never issues another prepare after a sleep overshoots the deadline", async () => {
let now = 1_000;
const callGateway = vi.fn(async () => busyResult);
await expect(
runGatewaySuspend(
{ rpcOpts: {}, requestId: "host-operation", waitSeconds: "0.2" },
{
callGateway,
runtime: createNonExitingRuntimeEnv(),
nowMs: () => now,
sleep: async () => {
// A lagging clock can wake far past the advertised --wait window.
now += 10_000;
},
},
),
).rejects.toThrow("Timed out waiting for the Gateway to become idle.");
expect(callGateway).toHaveBeenCalledOnce();
});
it("reports the latest blockers when the wait deadline expires", async () => {
let now = 1_000;
await expect(
runGatewaySuspend(
{ rpcOpts: {}, requestId: "host-operation", waitSeconds: "0.1" },
{
callGateway: vi.fn(async () => busyResult),
runtime: createNonExitingRuntimeEnv(),
nowMs: () => now,
sleep: async (delayMs) => {
now += delayMs;
},
},
),
).rejects.toThrow(
"Gateway suspension is busy (active-work; 1 active).\nBlockers:\n- 1 active request\nTimed out waiting for the Gateway to become idle.",
);
});
});
describe("gateway resume CLI", () => {
it.each([
{ resumed: true, message: "Gateway resumed." },
{
resumed: false,
message:
"No matching suspension was held (lease already expired or resumed); gateway is running.",
},
])("prints the resumed=$resumed outcome", async ({ resumed, message }) => {
const runtime = createNonExitingRuntimeEnv();
const callGateway = vi.fn(async () => ({ ok: true, status: "running", resumed }));
await runGatewayResume({ rpcOpts: {}, suspensionId: "suspension-1" }, { callGateway, runtime });
expect(callGateway).toHaveBeenCalledExactlyOnceWith(
"gateway.suspend.resume",
{},
{ suspensionId: "suspension-1" },
);
expect(runtime.log).toHaveBeenCalledExactlyOnceWith(message);
});
});