test: dedupe and optimize test suites

This commit is contained in:
Peter Steinberger
2026-02-19 15:18:50 +00:00
parent b0e55283d5
commit a1cb700a05
80 changed files with 2627 additions and 2962 deletions

View File

@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {});
const serveAcpGateway = vi.fn(async (_opts: unknown) => {});
@@ -25,6 +26,12 @@ vi.mock("../runtime.js", () => ({
}));
describe("acp cli option collisions", () => {
let registerAcpCli: typeof import("./acp-cli.js").registerAcpCli;
beforeAll(async () => {
({ registerAcpCli } = await import("./acp-cli.js"));
});
beforeEach(() => {
runAcpClientInteractive.mockClear();
serveAcpGateway.mockClear();
@@ -33,11 +40,10 @@ describe("acp cli option collisions", () => {
});
it("forwards --verbose to `acp client` when parent and child option names collide", async () => {
const { registerAcpCli } = await import("./acp-cli.js");
const program = new Command();
registerAcpCli(program);
await program.parseAsync(["acp", "client", "--verbose"], { from: "user" });
await runRegisteredCli({
register: registerAcpCli as (program: Command) => void,
argv: ["acp", "client", "--verbose"],
});
expect(runAcpClientInteractive).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -13,40 +13,106 @@ import {
} from "./argv.js";
describe("argv helpers", () => {
it("detects help/version flags", () => {
expect(hasHelpOrVersion(["node", "openclaw", "--help"])).toBe(true);
expect(hasHelpOrVersion(["node", "openclaw", "-V"])).toBe(true);
expect(hasHelpOrVersion(["node", "openclaw", "status"])).toBe(false);
it.each([
{
name: "help flag",
argv: ["node", "openclaw", "--help"],
expected: true,
},
{
name: "version flag",
argv: ["node", "openclaw", "-V"],
expected: true,
},
{
name: "normal command",
argv: ["node", "openclaw", "status"],
expected: false,
},
])("detects help/version flags: $name", ({ argv, expected }) => {
expect(hasHelpOrVersion(argv)).toBe(expected);
});
it("extracts command path ignoring flags and terminator", () => {
expect(getCommandPath(["node", "openclaw", "status", "--json"], 2)).toEqual(["status"]);
expect(getCommandPath(["node", "openclaw", "agents", "list"], 2)).toEqual(["agents", "list"]);
expect(getCommandPath(["node", "openclaw", "status", "--", "ignored"], 2)).toEqual(["status"]);
it.each([
{
name: "single command with trailing flag",
argv: ["node", "openclaw", "status", "--json"],
expected: ["status"],
},
{
name: "two-part command",
argv: ["node", "openclaw", "agents", "list"],
expected: ["agents", "list"],
},
{
name: "terminator cuts parsing",
argv: ["node", "openclaw", "status", "--", "ignored"],
expected: ["status"],
},
])("extracts command path: $name", ({ argv, expected }) => {
expect(getCommandPath(argv, 2)).toEqual(expected);
});
it("returns primary command", () => {
expect(getPrimaryCommand(["node", "openclaw", "agents", "list"])).toBe("agents");
expect(getPrimaryCommand(["node", "openclaw"])).toBeNull();
it.each([
{
name: "returns first command token",
argv: ["node", "openclaw", "agents", "list"],
expected: "agents",
},
{
name: "returns null when no command exists",
argv: ["node", "openclaw"],
expected: null,
},
])("returns primary command: $name", ({ argv, expected }) => {
expect(getPrimaryCommand(argv)).toBe(expected);
});
it("parses boolean flags and ignores terminator", () => {
expect(hasFlag(["node", "openclaw", "status", "--json"], "--json")).toBe(true);
expect(hasFlag(["node", "openclaw", "--", "--json"], "--json")).toBe(false);
it.each([
{
name: "detects flag before terminator",
argv: ["node", "openclaw", "status", "--json"],
flag: "--json",
expected: true,
},
{
name: "ignores flag after terminator",
argv: ["node", "openclaw", "--", "--json"],
flag: "--json",
expected: false,
},
])("parses boolean flags: $name", ({ argv, flag, expected }) => {
expect(hasFlag(argv, flag)).toBe(expected);
});
it("extracts flag values with equals and missing values", () => {
expect(getFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout")).toBe(
"5000",
);
expect(getFlagValue(["node", "openclaw", "status", "--timeout=2500"], "--timeout")).toBe(
"2500",
);
expect(getFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout")).toBeNull();
expect(getFlagValue(["node", "openclaw", "status", "--timeout", "--json"], "--timeout")).toBe(
null,
);
expect(getFlagValue(["node", "openclaw", "--", "--timeout=99"], "--timeout")).toBeUndefined();
it.each([
{
name: "value in next token",
argv: ["node", "openclaw", "status", "--timeout", "5000"],
expected: "5000",
},
{
name: "value in equals form",
argv: ["node", "openclaw", "status", "--timeout=2500"],
expected: "2500",
},
{
name: "missing value",
argv: ["node", "openclaw", "status", "--timeout"],
expected: null,
},
{
name: "next token is another flag",
argv: ["node", "openclaw", "status", "--timeout", "--json"],
expected: null,
},
{
name: "flag appears after terminator",
argv: ["node", "openclaw", "--", "--timeout=99"],
expected: undefined,
},
])("extracts flag values: $name", ({ argv, expected }) => {
expect(getFlagValue(argv, "--timeout")).toBe(expected);
});
it("parses verbose flags", () => {
@@ -57,79 +123,82 @@ describe("argv helpers", () => {
);
});
it("parses positive integer flag values", () => {
expect(getPositiveIntFlagValue(["node", "openclaw", "status"], "--timeout")).toBeUndefined();
expect(
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout"], "--timeout"),
).toBeNull();
expect(
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "5000"], "--timeout"),
).toBe(5000);
expect(
getPositiveIntFlagValue(["node", "openclaw", "status", "--timeout", "nope"], "--timeout"),
).toBeUndefined();
it.each([
{
name: "missing flag",
argv: ["node", "openclaw", "status"],
expected: undefined,
},
{
name: "missing value",
argv: ["node", "openclaw", "status", "--timeout"],
expected: null,
},
{
name: "valid positive integer",
argv: ["node", "openclaw", "status", "--timeout", "5000"],
expected: 5000,
},
{
name: "invalid integer",
argv: ["node", "openclaw", "status", "--timeout", "nope"],
expected: undefined,
},
])("parses positive integer flag values: $name", ({ argv, expected }) => {
expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected);
});
it("builds parse argv from raw args", () => {
const nodeArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node", "openclaw", "status"],
});
expect(nodeArgv).toEqual(["node", "openclaw", "status"]);
const cases = [
{
rawArgs: ["node", "openclaw", "status"],
expected: ["node", "openclaw", "status"],
},
{
rawArgs: ["node-22", "openclaw", "status"],
expected: ["node-22", "openclaw", "status"],
},
{
rawArgs: ["node-22.2.0.exe", "openclaw", "status"],
expected: ["node-22.2.0.exe", "openclaw", "status"],
},
{
rawArgs: ["node-22.2", "openclaw", "status"],
expected: ["node-22.2", "openclaw", "status"],
},
{
rawArgs: ["node-22.2.exe", "openclaw", "status"],
expected: ["node-22.2.exe", "openclaw", "status"],
},
{
rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"],
expected: ["/usr/bin/node-22.2.0", "openclaw", "status"],
},
{
rawArgs: ["nodejs", "openclaw", "status"],
expected: ["nodejs", "openclaw", "status"],
},
{
rawArgs: ["node-dev", "openclaw", "status"],
expected: ["node", "openclaw", "node-dev", "openclaw", "status"],
},
{
rawArgs: ["openclaw", "status"],
expected: ["node", "openclaw", "status"],
},
{
rawArgs: ["bun", "src/entry.ts", "status"],
expected: ["bun", "src/entry.ts", "status"],
},
] as const;
const versionedNodeArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node-22", "openclaw", "status"],
});
expect(versionedNodeArgv).toEqual(["node-22", "openclaw", "status"]);
const versionedNodeWindowsArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node-22.2.0.exe", "openclaw", "status"],
});
expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "openclaw", "status"]);
const versionedNodePatchlessArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node-22.2", "openclaw", "status"],
});
expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "openclaw", "status"]);
const versionedNodeWindowsPatchlessArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node-22.2.exe", "openclaw", "status"],
});
expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "openclaw", "status"]);
const versionedNodeWithPathArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["/usr/bin/node-22.2.0", "openclaw", "status"],
});
expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "openclaw", "status"]);
const nodejsArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["nodejs", "openclaw", "status"],
});
expect(nodejsArgv).toEqual(["nodejs", "openclaw", "status"]);
const nonVersionedNodeArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["node-dev", "openclaw", "status"],
});
expect(nonVersionedNodeArgv).toEqual(["node", "openclaw", "node-dev", "openclaw", "status"]);
const directArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["openclaw", "status"],
});
expect(directArgv).toEqual(["node", "openclaw", "status"]);
const bunArgv = buildParseArgv({
programName: "openclaw",
rawArgs: ["bun", "src/entry.ts", "status"],
});
expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]);
for (const testCase of cases) {
const parsed = buildParseArgv({
programName: "openclaw",
rawArgs: [...testCase.rawArgs],
});
expect(parsed).toEqual([...testCase.expected]);
}
});
it("builds parse argv from fallback args", () => {
@@ -141,23 +210,36 @@ describe("argv helpers", () => {
});
it("decides when to migrate state", () => {
expect(shouldMigrateState(["node", "openclaw", "status"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "health"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "sessions"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "config", "get", "update"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "config", "unset", "update"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "models", "list"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "models", "status"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "memory", "status"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "agent", "--message", "hi"])).toBe(false);
expect(shouldMigrateState(["node", "openclaw", "agents", "list"])).toBe(true);
expect(shouldMigrateState(["node", "openclaw", "message", "send"])).toBe(true);
const nonMutatingArgv = [
["node", "openclaw", "status"],
["node", "openclaw", "health"],
["node", "openclaw", "sessions"],
["node", "openclaw", "config", "get", "update"],
["node", "openclaw", "config", "unset", "update"],
["node", "openclaw", "models", "list"],
["node", "openclaw", "models", "status"],
["node", "openclaw", "memory", "status"],
["node", "openclaw", "agent", "--message", "hi"],
] as const;
const mutatingArgv = [
["node", "openclaw", "agents", "list"],
["node", "openclaw", "message", "send"],
] as const;
for (const argv of nonMutatingArgv) {
expect(shouldMigrateState([...argv])).toBe(false);
}
for (const argv of mutatingArgv) {
expect(shouldMigrateState([...argv])).toBe(true);
}
});
it("reuses command path for migrate state decisions", () => {
expect(shouldMigrateStateFromPath(["status"])).toBe(false);
expect(shouldMigrateStateFromPath(["config", "get"])).toBe(false);
expect(shouldMigrateStateFromPath(["models", "status"])).toBe(false);
expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true);
it.each([
{ path: ["status"], expected: false },
{ path: ["config", "get"], expected: false },
{ path: ["models", "status"], expected: false },
{ path: ["agents", "list"], expected: true },
])("reuses command path for migrate state decisions: $path", ({ path, expected }) => {
expect(shouldMigrateStateFromPath(path)).toBe(expected);
});
});

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const copyToClipboard = vi.fn();
@@ -117,7 +118,6 @@ beforeEach(() => {
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
vi.clearAllMocks();
});
function writeManifest(dir: string) {
@@ -177,8 +177,6 @@ describe("browser extension install (fs-mocked)", () => {
const dir = path.join(tmp, "browser", "chrome-extension");
writeManifest(dir);
const { Command } = await import("commander");
const program = new Command();
const browser = program.command("browser").option("--json", "JSON output", false);
registerBrowserExtensionCommands(

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const gatewayMocks = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(async () => ({
@@ -56,56 +56,63 @@ vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands;
describe("browser cli snapshot defaults", () => {
const runSnapshot = async (args: string[]) => {
const program = new Command();
const browser = program.command("browser").option("--json", "JSON output", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot", ...args], { from: "user" });
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
return params as { path?: string; query?: Record<string, unknown> } | undefined;
};
beforeAll(async () => {
({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"));
});
afterEach(() => {
vi.clearAllMocks();
configMocks.loadConfig.mockReturnValue({ browser: {} });
});
it("uses config snapshot defaults when mode is not provided", async () => {
it.each([
{
label: "uses config snapshot defaults when mode is not provided",
args: [],
expectMode: "efficient",
},
{
label: "does not apply config snapshot defaults to aria snapshots",
args: ["--format", "aria"],
expectMode: undefined,
},
])("$label", async ({ args, expectMode }) => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", "JSON output", false);
registerBrowserInspectCommands(browser, () => ({}));
if (args.includes("--format")) {
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
ok: true,
format: "aria",
targetId: "t1",
url: "https://example.com",
snapshot: "ok",
});
}
await program.parseAsync(["browser", "snapshot"], { from: "user" });
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
const params = await runSnapshot(args);
expect(params?.path).toBe("/snapshot");
expect(params?.query).toMatchObject({
format: "ai",
mode: "efficient",
});
});
it("does not apply config snapshot defaults to aria snapshots", async () => {
configMocks.loadConfig.mockReturnValue({
browser: { snapshotDefaults: { mode: "efficient" } },
});
gatewayMocks.callGatewayFromCli.mockResolvedValueOnce({
ok: true,
format: "aria",
targetId: "t1",
url: "https://example.com",
snapshot: "ok",
});
const { registerBrowserInspectCommands } = await import("./browser-cli-inspect.js");
const program = new Command();
const browser = program.command("browser").option("--json", "JSON output", false);
registerBrowserInspectCommands(browser, () => ({}));
await program.parseAsync(["browser", "snapshot", "--format", "aria"], { from: "user" });
expect(sharedMocks.callBrowserRequest).toHaveBeenCalled();
const [, params] = sharedMocks.callBrowserRequest.mock.calls.at(-1) ?? [];
expect(params?.path).toBe("/snapshot");
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
if (expectMode === undefined) {
expect((params?.query as { mode?: unknown } | undefined)?.mode).toBeUndefined();
} else {
expect(params?.query).toMatchObject({
format: "ai",
mode: expectMode,
});
}
});
});

View File

@@ -46,6 +46,12 @@ describe("browser state option collisions", () => {
return call[1] as { body?: Record<string, unknown> };
};
const runBrowserCommand = async (argv: string[]) => {
const program = createBrowserProgram();
await program.parseAsync(["browser", ...argv], { from: "user" });
return getLastRequest();
};
beforeEach(() => {
mocks.callBrowserRequest.mockClear();
mocks.runBrowserResizeWithOutput.mockClear();
@@ -55,35 +61,24 @@ describe("browser state option collisions", () => {
});
it("forwards parent-captured --target-id on `browser cookies set`", async () => {
const program = createBrowserProgram();
const request = await runBrowserCommand([
"cookies",
"set",
"session",
"abc",
"--url",
"https://example.com",
"--target-id",
"tab-1",
]);
await program.parseAsync(
[
"browser",
"cookies",
"set",
"session",
"abc",
"--url",
"https://example.com",
"--target-id",
"tab-1",
],
{ from: "user" },
);
const request = getLastRequest() as { body?: { targetId?: string } };
expect(request.body?.targetId).toBe("tab-1");
expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1");
});
it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => {
const program = createBrowserProgram();
await program.parseAsync(["browser", "set", "headers", "--json", '{"x-auth":"ok"}'], {
from: "user",
});
const request = getLastRequest() as { body?: { headers?: Record<string, string> } };
const request = (await runBrowserCommand(["set", "headers", "--json", '{"x-auth":"ok"}'])) as {
body?: { headers?: Record<string, string> };
};
expect(request.body?.headers).toEqual({ "x-auth": "ok" });
});
});

View File

@@ -1,70 +1,50 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
describe("browser CLI --browser-profile flag", () => {
it("parses --browser-profile from parent command options", () => {
const program = new Command();
program.name("test");
function runBrowserStatus(argv: string[]) {
const program = new Command();
program.name("test");
program.option("--profile <name>", "Global config profile");
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile name");
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile name");
let capturedProfile: string | undefined;
let globalProfile: string | undefined;
let browserProfile: string | undefined = "should-be-undefined";
browser.command("status").action((_opts, cmd) => {
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
capturedProfile = parent?.browserProfile;
});
program.parse(["node", "test", "browser", "--browser-profile", "onasset", "status"]);
expect(capturedProfile).toBe("onasset");
browser.command("status").action((_opts, cmd) => {
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
browserProfile = parent?.browserProfile;
globalProfile = program.opts().profile;
});
it("defaults to undefined when --browser-profile not provided", () => {
const program = new Command();
program.name("test");
program.parse(["node", "test", ...argv]);
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile name");
return { globalProfile, browserProfile };
}
let capturedProfile: string | undefined = "should-be-undefined";
browser.command("status").action((_opts, cmd) => {
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
capturedProfile = parent?.browserProfile;
});
program.parse(["node", "test", "browser", "status"]);
expect(capturedProfile).toBeUndefined();
describe("browser CLI --browser-profile flag", () => {
it.each([
{
label: "parses --browser-profile from parent command options",
argv: ["browser", "--browser-profile", "onasset", "status"],
expectedBrowserProfile: "onasset",
},
{
label: "defaults to undefined when --browser-profile not provided",
argv: ["browser", "status"],
expectedBrowserProfile: undefined,
},
])("$label", ({ argv, expectedBrowserProfile }) => {
const { browserProfile } = runBrowserStatus(argv);
expect(browserProfile).toBe(expectedBrowserProfile);
});
it("does not conflict with global --profile flag", () => {
// The global --profile flag is handled by /entry.js before Commander
// This test verifies --browser-profile is a separate option
const program = new Command();
program.name("test");
program.option("--profile <name>", "Global config profile");
const browser = program
.command("browser")
.option("--browser-profile <name>", "Browser profile name");
let globalProfile: string | undefined;
let browserProfile: string | undefined;
browser.command("status").action((_opts, cmd) => {
const parent = cmd.parent?.opts?.() as { browserProfile?: string };
browserProfile = parent?.browserProfile;
globalProfile = program.opts().profile;
});
program.parse([
"node",
"test",
const { globalProfile, browserProfile } = runBrowserStatus([
"--profile",
"dev",
"browser",

View File

@@ -2,40 +2,40 @@ import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { inheritOptionFromParent } from "./command-options.js";
function attachRunCommandAndCaptureInheritedToken(command: Command) {
let inherited: string | undefined;
command
.command("run")
.option("--token <token>", "Run token")
.action((_opts, childCommand) => {
inherited = inheritOptionFromParent<string>(childCommand, "token");
});
return () => inherited;
}
describe("inheritOptionFromParent", () => {
it("inherits from grandparent when parent does not define the option", async () => {
it.each([
{
label: "inherits from grandparent when parent does not define the option",
parentHasTokenOption: false,
argv: ["--token", "root-token", "gateway", "run"],
expected: "root-token",
},
{
label: "prefers nearest ancestor value when multiple ancestors set the same option",
parentHasTokenOption: true,
argv: ["--token", "root-token", "gateway", "--token", "gateway-token", "run"],
expected: "gateway-token",
},
])("$label", async ({ parentHasTokenOption, argv, expected }) => {
const program = new Command().option("--token <token>", "Root token");
const gateway = program.command("gateway");
let inherited: string | undefined;
const gateway = parentHasTokenOption
? program.command("gateway").option("--token <token>", "Gateway token")
: program.command("gateway");
const getInherited = attachRunCommandAndCaptureInheritedToken(gateway);
gateway
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
await program.parseAsync(["--token", "root-token", "gateway", "run"], { from: "user" });
expect(inherited).toBe("root-token");
});
it("prefers nearest ancestor value when multiple ancestors set the same option", async () => {
const program = new Command().option("--token <token>", "Root token");
const gateway = program.command("gateway").option("--token <token>", "Gateway token");
let inherited: string | undefined;
gateway
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
await program.parseAsync(
["--token", "root-token", "gateway", "--token", "gateway-token", "run"],
{ from: "user" },
);
expect(inherited).toBe("gateway-token");
await program.parseAsync(argv, { from: "user" });
expect(getInherited()).toBe(expected);
});
it("does not inherit when the child option was set explicitly", async () => {
@@ -54,18 +54,11 @@ describe("inheritOptionFromParent", () => {
const program = new Command().option("--token <token>", "Root token");
const level1 = program.command("level1");
const level2 = level1.command("level2");
let inherited: string | undefined;
level2
.command("run")
.option("--token <token>", "Run token")
.action((_opts, command) => {
inherited = inheritOptionFromParent<string>(command, "token");
});
const getInherited = attachRunCommandAndCaptureInheritedToken(level2);
await program.parseAsync(["--token", "root-token", "level1", "level2", "run"], {
from: "user",
});
expect(inherited).toBeUndefined();
expect(getInherited()).toBeUndefined();
});
});

View File

@@ -63,18 +63,24 @@ function resetGatewayMock() {
callGatewayFromCli.mockImplementation(defaultGatewayMock);
}
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
async function runCronCommand(args: string[]): Promise<void> {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
await program.parseAsync(args, { from: "user" });
}
async function expectCronCommandExit(args: string[]): Promise<void> {
await expect(runCronCommand(args)).rejects.toThrow("__exit__:1");
}
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
await runCronCommand(["cron", "edit", "job-1", ...editArgs]);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
return (updateCall?.[2] ?? {}) as CronUpdatePatch;
}
async function runCronAddAndGetParams(addArgs: string[]): Promise<CronAddParams> {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(["cron", "add", ...addArgs], { from: "user" });
await runCronCommand(["cron", "add", ...addArgs]);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
return (addCall?.[2] ?? {}) as CronAddParams;
}
@@ -82,9 +88,7 @@ async function runCronAddAndGetParams(addArgs: string[]): Promise<CronAddParams>
async function runCronSimpleAndGetUpdatePatch(
command: "enable" | "disable",
): Promise<{ enabled?: boolean }> {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(["cron", command, "job-1"], { from: "user" });
await runCronCommand(["cron", command, "job-1"]);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
return ((updateCall?.[2] as { patch?: { enabled?: boolean } } | undefined)?.patch ?? {}) as {
enabled?: boolean;
@@ -109,31 +113,52 @@ function mockCronEditJobLookup(schedule: unknown): void {
);
}
function getGatewayCallParams<T>(method: string): T {
const call = callGatewayFromCli.mock.calls.find((entry) => entry[0] === method);
return (call?.[2] ?? {}) as T;
}
async function runCronEditWithScheduleLookup(
schedule: unknown,
editArgs: string[],
): Promise<CronUpdatePatch> {
resetGatewayMock();
mockCronEditJobLookup(schedule);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
return getGatewayCallParams<CronUpdatePatch>("cron.update");
}
async function expectCronEditWithScheduleLookupExit(
schedule: unknown,
editArgs: string[],
): Promise<void> {
resetGatewayMock();
mockCronEditJobLookup(schedule);
const program = buildProgram();
await expect(
program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" }),
).rejects.toThrow("__exit__:1");
}
describe("cron cli", () => {
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"Daily",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--model",
" opus ",
"--thinking",
" low ",
],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Daily",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--model",
" opus ",
"--thinking",
" low ",
]);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as {
@@ -145,25 +170,18 @@ describe("cron cli", () => {
});
it("defaults isolated cron add to announce delivery", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"Daily",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Daily",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
]);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { delivery?: { mode?: string } };
@@ -172,26 +190,32 @@ describe("cron cli", () => {
});
it("infers sessionTarget from payload when --session is omitted", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Main reminder",
"--cron",
"* * * * *",
"--system-event",
"hi",
]);
let addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
let params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
expect(params?.sessionTarget).toBe("main");
expect(params?.payload?.kind).toBe("systemEvent");
resetGatewayMock();
await program.parseAsync(
["cron", "add", "--name", "Isolated task", "--cron", "* * * * *", "--message", "hello"],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Isolated task",
"--cron",
"* * * * *",
"--message",
"hello",
]);
addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
params = addCall?.[2] as { sessionTarget?: string; payload?: { kind?: string } };
@@ -200,133 +224,90 @@ describe("cron cli", () => {
});
it("supports --keep-after-run on cron add", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"Keep me",
"--at",
"20m",
"--session",
"main",
"--system-event",
"hello",
"--keep-after-run",
],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Keep me",
"--at",
"20m",
"--session",
"main",
"--system-event",
"hello",
"--keep-after-run",
]);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { deleteAfterRun?: boolean };
expect(params?.deleteAfterRun).toBe(false);
});
it("cron enable sets enabled=true patch", async () => {
const patch = await runCronSimpleAndGetUpdatePatch("enable");
expect(patch.enabled).toBe(true);
});
it("cron disable sets enabled=false patch", async () => {
const patch = await runCronSimpleAndGetUpdatePatch("disable");
expect(patch.enabled).toBe(false);
it.each([
{ command: "enable" as const, expectedEnabled: true },
{ command: "disable" as const, expectedEnabled: false },
])("cron $command sets enabled=$expectedEnabled patch", async ({ command, expectedEnabled }) => {
const patch = await runCronSimpleAndGetUpdatePatch(command);
expect(patch.enabled).toBe(expectedEnabled);
});
it("sends agent id on cron add", async () => {
resetGatewayMock();
const program = buildProgram();
await program.parseAsync(
[
"cron",
"add",
"--name",
"Agent pinned",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hi",
"--agent",
"ops",
],
{ from: "user" },
);
await runCronCommand([
"cron",
"add",
"--name",
"Agent pinned",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hi",
"--agent",
"ops",
]);
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
const params = addCall?.[2] as { agentId?: string };
expect(params?.agentId).toBe("ops");
});
it("omits empty model and thinking on cron edit", async () => {
const patch = await runCronEditAndGetPatch([
"--message",
"hello",
"--model",
" ",
"--thinking",
" ",
]);
expect(patch?.patch?.payload?.model).toBeUndefined();
expect(patch?.patch?.payload?.thinking).toBeUndefined();
});
it("trims model and thinking on cron edit", async () => {
const patch = await runCronEditAndGetPatch([
"--message",
"hello",
"--model",
" opus ",
"--thinking",
" high ",
]);
expect(patch?.patch?.payload?.model).toBe("opus");
expect(patch?.patch?.payload?.thinking).toBe("high");
it.each([
{
label: "omits empty model and thinking",
args: ["--message", "hello", "--model", " ", "--thinking", " "],
expectedModel: undefined,
expectedThinking: undefined,
},
{
label: "trims model and thinking",
args: ["--message", "hello", "--model", " opus ", "--thinking", " high "],
expectedModel: "opus",
expectedThinking: "high",
},
])("cron edit $label", async ({ args, expectedModel, expectedThinking }) => {
const patch = await runCronEditAndGetPatch(args);
expect(patch?.patch?.payload?.model).toBe(expectedModel);
expect(patch?.patch?.payload?.thinking).toBe(expectedThinking);
});
it("sets and clears agent id on cron edit", async () => {
resetGatewayMock();
await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
const patch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update");
expect(patch?.patch?.agentId).toBe("ops");
resetGatewayMock();
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
from: "user",
});
const clearCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const clearPatch = clearCall?.[2] as { patch?: { agentId?: unknown } };
await runCronCommand(["cron", "edit", "job-2", "--clear-agent"]);
const clearPatch = getGatewayCallParams<{ patch?: { agentId?: unknown } }>("cron.update");
expect(clearPatch?.patch?.agentId).toBeNull();
});
it("allows model/thinking updates without --message", async () => {
resetGatewayMock();
await runCronCommand(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"]);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
const patch = getGatewayCallParams<{
patch?: { payload?: { kind?: string; model?: string; thinking?: string } };
};
}>("cron.update");
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.model).toBe("opus");
@@ -334,22 +315,23 @@ describe("cron cli", () => {
});
it("updates delivery settings without requiring --message", async () => {
resetGatewayMock();
await runCronCommand([
"cron",
"edit",
"job-1",
"--deliver",
"--channel",
"telegram",
"--to",
"19098680",
]);
const program = buildProgram();
await program.parseAsync(
["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
const patch = getGatewayCallParams<{
patch?: {
payload?: { kind?: string; message?: string };
delivery?: { mode?: string; channel?: string; to?: string };
};
};
}>("cron.update");
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.delivery?.mode).toBe("announce");
@@ -359,33 +341,21 @@ describe("cron cli", () => {
});
it("supports --no-deliver on cron edit", async () => {
resetGatewayMock();
await runCronCommand(["cron", "edit", "job-1", "--no-deliver"]);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
const patch = getGatewayCallParams<{
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
};
}>("cron.update");
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.delivery?.mode).toBe("none");
});
it("does not include undefined delivery fields when updating message", async () => {
resetGatewayMock();
const program = buildProgram();
// Update message without delivery flags - should NOT include undefined delivery fields
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
from: "user",
});
await runCronCommand(["cron", "edit", "job-1", "--message", "Updated message"]);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
const patch = getGatewayCallParams<{
patch?: {
payload?: {
message?: string;
@@ -396,7 +366,7 @@ describe("cron cli", () => {
};
delivery?: unknown;
};
};
}>("cron.update");
// Should include the new message
expect(patch?.patch?.payload?.message).toBe("Updated message");
@@ -427,28 +397,14 @@ describe("cron cli", () => {
expect(patch?.patch?.delivery?.to).toBe("19098680");
});
it("includes best-effort delivery when provided with message", async () => {
const patch = await runCronEditAndGetPatch([
"--message",
"Updated message",
"--best-effort-deliver",
]);
it.each([
{ flag: "--best-effort-deliver", expectedBestEffort: true },
{ flag: "--no-best-effort-deliver", expectedBestEffort: false },
])("applies $flag on cron edit message updates", async ({ flag, expectedBestEffort }) => {
const patch = await runCronEditAndGetPatch(["--message", "Updated message", flag]);
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.bestEffort).toBe(true);
});
it("includes no-best-effort delivery when provided with message", async () => {
const patch = await runCronEditAndGetPatch([
"--message",
"Updated message",
"--no-best-effort-deliver",
]);
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.bestEffort).toBe(false);
expect(patch?.patch?.delivery?.bestEffort).toBe(expectedBestEffort);
});
it("sets explicit stagger for cron add", async () => {
@@ -485,83 +441,55 @@ describe("cron cli", () => {
});
it("rejects --stagger with --exact on add", async () => {
resetGatewayMock();
const program = buildProgram();
await expect(
program.parseAsync(
[
"cron",
"add",
"--name",
"invalid",
"--cron",
"0 * * * *",
"--stagger",
"1m",
"--exact",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid",
"--cron",
"0 * * * *",
"--stagger",
"1m",
"--exact",
"--session",
"main",
"--system-event",
"tick",
]);
});
it("rejects --stagger when schedule is not cron", async () => {
resetGatewayMock();
const program = buildProgram();
await expect(
program.parseAsync(
[
"cron",
"add",
"--name",
"invalid",
"--every",
"10m",
"--stagger",
"30s",
"--session",
"main",
"--system-event",
"tick",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid",
"--every",
"10m",
"--stagger",
"30s",
"--session",
"main",
"--system-event",
"tick",
]);
});
it("sets explicit stagger for cron edit", async () => {
resetGatewayMock();
const program = buildProgram();
await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]);
await program.parseAsync(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"], {
from: "user",
});
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
const patch = getGatewayCallParams<{
patch?: { schedule?: { kind?: string; staggerMs?: number } };
};
}>("cron.update");
expect(patch?.patch?.schedule?.kind).toBe("cron");
expect(patch?.patch?.schedule?.staggerMs).toBe(30_000);
});
it("applies --exact to existing cron job without requiring --cron on edit", async () => {
resetGatewayMock();
mockCronEditJobLookup({ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 });
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number } };
};
const patch = await runCronEditWithScheduleLookup(
{ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 },
["--exact"],
);
expect(patch?.patch?.schedule).toEqual({
kind: "cron",
expr: "0 */2 * * *",
@@ -571,12 +499,6 @@ describe("cron cli", () => {
});
it("rejects --exact on edit when existing job is not cron", async () => {
resetGatewayMock();
mockCronEditJobLookup({ kind: "every", everyMs: 60_000 });
const program = buildProgram();
await expect(
program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }),
).rejects.toThrow("__exit__:1");
await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]);
});
});

View File

@@ -72,6 +72,25 @@ vi.mock("./progress.js", () => ({
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
}));
const { registerDaemonCli } = await import("./daemon-cli.js");
function createDaemonProgram() {
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
return program;
}
async function runDaemonCommand(args: string[]) {
const program = createDaemonProgram();
await program.parseAsync(args, { from: "user" });
}
function parseFirstJsonRuntimeLine<T>() {
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
return JSON.parse(jsonLine ?? "{}") as T;
}
describe("daemon-cli coverage", () => {
const originalEnv = {
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
@@ -118,12 +137,7 @@ describe("daemon-cli coverage", () => {
resetRuntimeCapture();
callGateway.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status"], { from: "user" });
await runDaemonCommand(["daemon", "status"]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" }));
@@ -147,12 +161,7 @@ describe("daemon-cli coverage", () => {
sourcePath: "/tmp/bot.molt.gateway.plist",
});
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--json"], { from: "user" });
await runDaemonCommand(["daemon", "status", "--json"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
@@ -162,12 +171,11 @@ describe("daemon-cli coverage", () => {
);
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as {
const parsed = parseFirstJsonRuntimeLine<{
gateway?: { port?: number; portSource?: string; probeUrl?: string };
config?: { mismatch?: boolean };
rpc?: { url?: string; ok?: boolean };
};
}>();
expect(parsed.gateway?.port).toBe(19001);
expect(parsed.gateway?.portSource).toBe("service args");
expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001");
@@ -179,12 +187,7 @@ describe("daemon-cli coverage", () => {
it("passes deep scan flag for daemon status", async () => {
findExtraGatewayServices.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--deep"], { from: "user" });
await runDaemonCommand(["daemon", "status", "--deep"]);
expect(findExtraGatewayServices).toHaveBeenCalledWith(
expect.anything(),
@@ -192,81 +195,53 @@ describe("daemon-cli coverage", () => {
);
});
it("installs the daemon when requested", async () => {
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "install", "--port", "18789"], {
from: "user",
});
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("installs the daemon with json output", async () => {
it.each([
{ label: "plain output", includeJsonFlag: false },
{ label: "json output", includeJsonFlag: true },
])("installs the daemon ($label)", async ({ includeJsonFlag }) => {
resetRuntimeCapture();
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
const args = includeJsonFlag
? ["daemon", "install", "--port", "18789", "--json"]
: ["daemon", "install", "--port", "18789"];
await runDaemonCommand(args);
await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], {
from: "user",
});
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as {
ok?: boolean;
action?: string;
result?: string;
};
expect(parsed.ok).toBe(true);
expect(parsed.action).toBe("install");
expect(parsed.result).toBe("installed");
expect(serviceInstall).toHaveBeenCalledTimes(1);
if (includeJsonFlag) {
const parsed = parseFirstJsonRuntimeLine<{
ok?: boolean;
action?: string;
result?: string;
}>();
expect(parsed.ok).toBe(true);
expect(parsed.action).toBe("install");
expect(parsed.result).toBe("installed");
}
});
it("starts and stops the daemon via service helpers", async () => {
it.each([
{ label: "plain output", includeJsonFlag: false },
{ label: "json output", includeJsonFlag: true },
])("starts and stops daemon ($label)", async ({ includeJsonFlag }) => {
resetRuntimeCapture();
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start"], { from: "user" });
await program.parseAsync(["daemon", "stop"], { from: "user" });
const startArgs = includeJsonFlag ? ["daemon", "start", "--json"] : ["daemon", "start"];
const stopArgs = includeJsonFlag ? ["daemon", "stop", "--json"] : ["daemon", "stop"];
await runDaemonCommand(startArgs);
await runDaemonCommand(stopArgs);
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
it("emits json for daemon start/stop", async () => {
resetRuntimeCapture();
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start", "--json"], { from: "user" });
await program.parseAsync(["daemon", "stop", "--json"], { from: "user" });
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
if (includeJsonFlag) {
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
}
});
});

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(() => ({
gateway: {
@@ -38,7 +38,13 @@ vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
describe("runServiceRestart token drift", () => {
beforeAll(async () => {
({ runServiceRestart } = await import("./lifecycle-core.js"));
});
beforeEach(() => {
runtimeLogs.length = 0;
loadConfig.mockClear();
@@ -56,8 +62,6 @@ describe("runServiceRestart token drift", () => {
});
it("emits drift warning when enabled", async () => {
const { runServiceRestart } = await import("./lifecycle-core.js");
await runServiceRestart({
serviceNoun: "Gateway",
service,
@@ -73,8 +77,6 @@ describe("runServiceRestart token drift", () => {
});
it("skips drift warning when disabled", async () => {
const { runServiceRestart } = await import("./lifecycle-core.js");
await runServiceRestart({
serviceNoun: "Node",
service,

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const callGateway = vi.fn();
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
@@ -21,29 +21,23 @@ vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli;
beforeAll(async () => {
({ registerDevicesCli } = await import("./devices-cli.js"));
});
async function runDevicesApprove(argv: string[]) {
const { registerDevicesCli } = await import("./devices-cli.js");
const program = new Command();
registerDevicesCli(program);
await program.parseAsync(["devices", "approve", ...argv], { from: "user" });
await runDevicesCommand(["approve", ...argv]);
}
async function runDevicesCommand(argv: string[]) {
const { registerDevicesCli } = await import("./devices-cli.js");
const program = new Command();
registerDevicesCli(program);
await program.parseAsync(["devices", ...argv], { from: "user" });
}
describe("devices cli approve", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("approves an explicit request id without listing", async () => {
callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } });
@@ -58,17 +52,33 @@ describe("devices cli approve", () => {
);
});
it("auto-approves the latest pending request when id is omitted", async () => {
it.each([
{
name: "id is omitted",
args: [] as string[],
pending: [
{ requestId: "req-1", ts: 1000 },
{ requestId: "req-2", ts: 2000 },
],
expectedRequestId: "req-2",
},
{
name: "--latest is passed",
args: ["req-old", "--latest"] as string[],
pending: [
{ requestId: "req-2", ts: 2000 },
{ requestId: "req-3", ts: 3000 },
],
expectedRequestId: "req-3",
},
])("uses latest pending request when $name", async ({ args, pending, expectedRequestId }) => {
callGateway
.mockResolvedValueOnce({
pending: [
{ requestId: "req-1", ts: 1000 },
{ requestId: "req-2", ts: 2000 },
],
pending,
})
.mockResolvedValueOnce({ device: { deviceId: "device-2" } });
await runDevicesApprove([]);
await runDevicesApprove(args);
expect(callGateway).toHaveBeenNthCalledWith(
1,
@@ -78,28 +88,7 @@ describe("devices cli approve", () => {
2,
expect.objectContaining({
method: "device.pair.approve",
params: { requestId: "req-2" },
}),
);
});
it("uses latest pending request when --latest is passed", async () => {
callGateway
.mockResolvedValueOnce({
pending: [
{ requestId: "req-2", ts: 2000 },
{ requestId: "req-3", ts: 3000 },
],
})
.mockResolvedValueOnce({ device: { deviceId: "device-3" } });
await runDevicesApprove(["req-old", "--latest"]);
expect(callGateway).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "device.pair.approve",
params: { requestId: "req-3" },
params: { requestId: expectedRequestId },
}),
);
});
@@ -122,14 +111,6 @@ describe("devices cli approve", () => {
});
describe("devices cli remove", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("removes a paired device by id", async () => {
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
@@ -146,14 +127,6 @@ describe("devices cli remove", () => {
});
describe("devices cli clear", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("requires --yes before clearing", async () => {
await runDevicesCommand(["clear"]);
@@ -194,55 +167,44 @@ describe("devices cli clear", () => {
});
describe("devices cli tokens", () => {
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});
it("rotates a token for a device role", async () => {
callGateway.mockResolvedValueOnce({ ok: true });
await runDevicesCommand([
"rotate",
"--device",
"device-1",
"--role",
"main",
"--scope",
"messages:send",
"--scope",
"messages:read",
]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
it.each([
{
label: "rotates a token for a device role",
argv: [
"rotate",
"--device",
"device-1",
"--role",
"main",
"--scope",
"messages:send",
"--scope",
"messages:read",
],
expectedCall: {
method: "device.token.rotate",
params: {
deviceId: "device-1",
role: "main",
scopes: ["messages:send", "messages:read"],
},
}),
);
});
it("revokes a token for a device role", async () => {
callGateway.mockResolvedValueOnce({ ok: true });
await runDevicesCommand(["revoke", "--device", "device-1", "--role", "main"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
},
},
{
label: "revokes a token for a device role",
argv: ["revoke", "--device", "device-1", "--role", "main"],
expectedCall: {
method: "device.token.revoke",
params: {
deviceId: "device-1",
role: "main",
},
}),
);
},
},
])("$label", async ({ argv, expectedCall }) => {
callGateway.mockResolvedValueOnce({ ok: true });
await runDevicesCommand(argv);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall));
});
it("rejects blank device or role values", async () => {
@@ -253,3 +215,11 @@ describe("devices cli tokens", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
});
afterEach(() => {
callGateway.mockReset();
withProgress.mockClear();
runtime.log.mockReset();
runtime.error.mockReset();
runtime.exit.mockReset();
});

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
@@ -67,27 +67,31 @@ describe("exec approvals CLI", () => {
return program;
};
it("routes get command to local, gateway, and node modes", async () => {
const runApprovalsCommand = async (args: string[]) => {
const program = createProgram();
await program.parseAsync(args, { from: "user" });
};
beforeEach(() => {
resetLocalSnapshot();
resetRuntimeCapture();
callGatewayFromCli.mockClear();
});
const localProgram = createProgram();
await localProgram.parseAsync(["approvals", "get"], { from: "user" });
it("routes get command to local, gateway, and node modes", async () => {
await runApprovalsCommand(["approvals", "get"]);
expect(callGatewayFromCli).not.toHaveBeenCalled();
expect(runtimeErrors).toHaveLength(0);
callGatewayFromCli.mockClear();
const gatewayProgram = createProgram();
await gatewayProgram.parseAsync(["approvals", "get", "--gateway"], { from: "user" });
await runApprovalsCommand(["approvals", "get", "--gateway"]);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
expect(runtimeErrors).toHaveLength(0);
callGatewayFromCli.mockClear();
const nodeProgram = createProgram();
await nodeProgram.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
await runApprovalsCommand(["approvals", "get", "--node", "macbook"]);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
nodeId: "node-1",
@@ -96,18 +100,10 @@ describe("exec approvals CLI", () => {
});
it("defaults allowlist add to wildcard agent", async () => {
resetLocalSnapshot();
resetRuntimeCapture();
callGatewayFromCli.mockClear();
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
saveExecApprovals.mockClear();
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "allowlist", "add", "/usr/bin/uname"], { from: "user" });
await runApprovalsCommand(["approvals", "allowlist", "add", "/usr/bin/uname"]);
expect(callGatewayFromCli).not.toHaveBeenCalledWith(
"exec.approvals.set",
@@ -124,7 +120,6 @@ describe("exec approvals CLI", () => {
});
it("removes wildcard allowlist entry and prunes empty agent", async () => {
resetLocalSnapshot();
localSnapshot.file = {
version: 1,
agents: {
@@ -133,16 +128,11 @@ describe("exec approvals CLI", () => {
},
},
};
resetRuntimeCapture();
callGatewayFromCli.mockClear();
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
saveExecApprovals.mockClear();
const program = createProgram();
await program.parseAsync(["approvals", "allowlist", "remove", "/usr/bin/uname"], {
from: "user",
});
await runApprovalsCommand(["approvals", "allowlist", "remove", "/usr/bin/uname"]);
expect(saveExecApprovals).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -85,19 +85,30 @@ vi.mock("../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
}));
const { registerGatewayCli } = await import("./gateway-cli.js");
function createGatewayProgram() {
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
return program;
}
async function runGatewayCommand(args: string[]) {
const program = createGatewayProgram();
await program.parseAsync(args, { from: "user" });
}
async function expectGatewayExit(args: string[]) {
await expect(runGatewayCommand(args)).rejects.toThrow("__exit__:1");
}
describe("gateway-cli coverage", () => {
it("registers call/health commands and routes to callGateway", async () => {
resetRuntimeCapture();
callGateway.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "call", "health", "--params", '{"x":1}', "--json"], {
from: "user",
});
await runGatewayCommand(["gateway", "call", "health", "--params", '{"x":1}', "--json"]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"ok": true');
@@ -107,48 +118,30 @@ describe("gateway-cli coverage", () => {
resetRuntimeCapture();
gatewayStatusCommand.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "probe", "--json"], { from: "user" });
await runGatewayCommand(["gateway", "probe", "--json"]);
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
}, 60_000);
it("registers gateway discover and prints JSON", async () => {
resetRuntimeCapture();
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (OpenClaw)",
displayName: "Studio",
domain: "local.",
host: "studio.local",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--json"], {
from: "user",
});
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("registers gateway discover and prints human output with details on new lines", async () => {
it.each([
{
label: "json output",
args: ["gateway", "discover", "--json"],
expectedOutput: ['"beacons"', '"wsUrl"', "ws://"],
},
{
label: "human output",
args: ["gateway", "discover", "--timeout", "1"],
expectedOutput: [
"Gateway Discovery",
"Found 1 gateway(s)",
"- Studio openclaw.internal.",
" tailnet: studio.tailnet.ts.net",
" host: studio.openclaw.internal",
" ws: ws://studio.openclaw.internal:18789",
],
},
])("registers gateway discover and prints $label", async ({ args, expectedOutput }) => {
resetRuntimeCapture();
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
@@ -164,38 +157,19 @@ describe("gateway-cli coverage", () => {
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--timeout", "1"], {
from: "user",
});
await runGatewayCommand(args);
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
const out = runtimeLogs.join("\n");
expect(out).toContain("Gateway Discovery");
expect(out).toContain("Found 1 gateway(s)");
expect(out).toContain("- Studio openclaw.internal.");
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
expect(out).toContain(" host: studio.openclaw.internal");
expect(out).toContain(" ws: ws://studio.openclaw.internal:18789");
for (const text of expectedOutput) {
expect(out).toContain(text);
}
});
it("validates gateway discover timeout", async () => {
resetRuntimeCapture();
discoverGatewayBeacons.mockReset();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
await expectGatewayExit(["gateway", "discover", "--timeout", "0"]);
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
@@ -204,15 +178,7 @@ describe("gateway-cli coverage", () => {
it("fails gateway call on invalid params JSON", async () => {
resetRuntimeCapture();
callGateway.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "call", "status", "--params", "not-json"], { from: "user" }),
).rejects.toThrow("__exit__:1");
await expectGatewayExit(["gateway", "call", "status", "--params", "not-json"]);
expect(callGateway).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Gateway call failed:");
@@ -221,47 +187,35 @@ describe("gateway-cli coverage", () => {
it("validates gateway ports and handles force/start errors", async () => {
resetRuntimeCapture();
const { registerGatewayCli } = await import("./gateway-cli.js");
// Invalid port
const programInvalidPort = new Command();
programInvalidPort.exitOverride();
registerGatewayCli(programInvalidPort);
await expect(
programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
await expectGatewayExit(["gateway", "--port", "0", "--token", "test-token"]);
// Force free failure
forceFreePortAndWait.mockImplementationOnce(async () => {
throw new Error("boom");
});
const programForceFail = new Command();
programForceFail.exitOverride();
registerGatewayCli(programForceFail);
await expect(
programForceFail.parseAsync(
["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
await expectGatewayExit([
"gateway",
"--port",
"18789",
"--token",
"test-token",
"--force",
"--allow-unconfigured",
]);
// Start failure (generic)
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
const programStartFail = new Command();
programStartFail.exitOverride();
registerGatewayCli(programStartFail);
const beforeSigterm = new Set(process.listeners("SIGTERM"));
const beforeSigint = new Set(process.listeners("SIGINT"));
await expect(
programStartFail.parseAsync(
["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"],
{
from: "user",
},
),
).rejects.toThrow("__exit__:1");
await expectGatewayExit([
"gateway",
"--port",
"18789",
"--token",
"test-token",
"--allow-unconfigured",
]);
for (const listener of process.listeners("SIGTERM")) {
if (!beforeSigterm.has(listener)) {
process.removeListener("SIGTERM", listener);
@@ -282,17 +236,7 @@ describe("gateway-cli coverage", () => {
startGatewayServer.mockRejectedValueOnce(
new GatewayLockError("another gateway instance is already listening"),
);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
@@ -304,17 +248,8 @@ describe("gateway-cli coverage", () => {
resetRuntimeCapture();
startGatewayServer.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
await expect(
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
await expectGatewayExit(["gateway", "--token", "test-token", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything());
});

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../../test-utils/command-runner.js";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
const callGatewayCli = vi.fn(async (_method: string, _opts: unknown, _params?: unknown) => ({
@@ -111,6 +112,12 @@ vi.mock("./discover.js", () => ({
}));
describe("gateway register option collisions", () => {
let registerGatewayCli: typeof import("./register.js").registerGatewayCli;
beforeAll(async () => {
({ registerGatewayCli } = await import("./register.js"));
});
beforeEach(() => {
resetRuntimeCapture();
callGatewayCli.mockClear();
@@ -118,12 +125,9 @@ describe("gateway register option collisions", () => {
});
it("forwards --token to gateway call when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], {
from: "user",
await runRegisteredCli({
register: registerGatewayCli as (program: Command) => void,
argv: ["gateway", "call", "health", "--token", "tok_call", "--json"],
});
expect(callGatewayCli).toHaveBeenCalledWith(
@@ -136,12 +140,9 @@ describe("gateway register option collisions", () => {
});
it("forwards --token to gateway probe when parent and child option names collide", async () => {
const { registerGatewayCli } = await import("./register.js");
const program = new Command();
registerGatewayCli(program);
await program.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], {
from: "user",
await runRegisteredCli({
register: registerGatewayCli as (program: Command) => void,
argv: ["gateway", "probe", "--token", "tok_probe", "--json"],
});
expect(gatewayStatusCommand).toHaveBeenCalledWith(

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../../test-utils/command-runner.js";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({
@@ -91,6 +92,12 @@ vi.mock("./run-loop.js", () => ({
}));
describe("gateway run option collisions", () => {
let addGatewayRunCommand: typeof import("./run.js").addGatewayRunCommand;
beforeAll(async () => {
({ addGatewayRunCommand } = await import("./run.js"));
});
beforeEach(() => {
resetRuntimeCapture();
startGatewayServer.mockClear();
@@ -101,25 +108,27 @@ describe("gateway run option collisions", () => {
runGatewayLoop.mockClear();
});
it("forwards parent-captured options to `gateway run` subcommand", async () => {
const { addGatewayRunCommand } = await import("./run.js");
const program = new Command();
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
async function runGatewayCli(argv: string[]) {
await runRegisteredCli({
register: ((program: Command) => {
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
}) as (program: Command) => void,
argv,
});
}
await program.parseAsync(
[
"gateway",
"run",
"--token",
"tok_run",
"--allow-unconfigured",
"--ws-log",
"full",
"--force",
],
{ from: "user" },
);
it("forwards parent-captured options to `gateway run` subcommand", async () => {
await runGatewayCli([
"gateway",
"run",
"--token",
"tok_run",
"--allow-unconfigured",
"--ws-log",
"full",
"--force",
]);
expect(forceFreePortAndWait).toHaveBeenCalledWith(18789, expect.anything());
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
@@ -134,14 +143,7 @@ describe("gateway run option collisions", () => {
});
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
const { addGatewayRunCommand } = await import("./run.js");
const program = new Command();
const gateway = addGatewayRunCommand(program.command("gateway"));
addGatewayRunCommand(gateway.command("run"));
await program.parseAsync(["gateway", "run", "--allow-unconfigured"], {
from: "user",
});
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalledWith(
18789,

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
import { formatLogTimestamp } from "./logs-cli.js";
const callGatewayFromCli = vi.fn();
@@ -12,17 +12,23 @@ vi.mock("./gateway-rpc.js", async () => {
};
});
let registerLogsCli: typeof import("./logs-cli.js").registerLogsCli;
beforeAll(async () => {
({ registerLogsCli } = await import("./logs-cli.js"));
});
async function runLogsCli(argv: string[]) {
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(argv, { from: "user" });
await runRegisteredCli({
register: registerLogsCli as (program: import("commander").Command) => void,
argv,
});
}
describe("logs cli", () => {
afterEach(() => {
callGatewayFromCli.mockReset();
vi.restoreAllMocks();
});
it("writes output directly to stdout/stderr", async () => {
@@ -37,20 +43,17 @@ describe("logs cli", () => {
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
await runLogsCli(["logs"]);
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stdoutWrites.join("")).toContain("Log file:");
expect(stdoutWrites.join("")).toContain("raw line");
expect(stderrWrites.join("")).toContain("Log tail truncated");
@@ -70,15 +73,13 @@ describe("logs cli", () => {
});
const stdoutWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
await runLogsCli(["logs", "--local-time", "--plain"]);
stdoutSpy.mockRestore();
const output = stdoutWrites.join("");
expect(output).toContain("line one");
const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0];
@@ -93,21 +94,18 @@ describe("logs cli", () => {
});
const stderrWrites: string[] = [];
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => {
vi.spyOn(process.stdout, "write").mockImplementation(() => {
const err = new Error("EPIPE") as NodeJS.ErrnoException;
err.code = "EPIPE";
throw err;
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
await runLogsCli(["logs"]);
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
expect(stderrWrites.join("")).toContain("output stdout closed");
});
@@ -143,15 +141,13 @@ describe("logs cli", () => {
}
});
it("handles empty or invalid timestamps", () => {
expect(formatLogTimestamp(undefined)).toBe("");
expect(formatLogTimestamp("")).toBe("");
expect(formatLogTimestamp("invalid-date")).toBe("invalid-date");
});
it("preserves original value for invalid dates", () => {
const result = formatLogTimestamp("not-a-date");
expect(result).toBe("not-a-date");
it.each([
{ input: undefined, expected: "" },
{ input: "", expected: "" },
{ input: "invalid-date", expected: "invalid-date" },
{ input: "not-a-date", expected: "not-a-date" },
])("preserves timestamp fallback for $input", ({ input, expected }) => {
expect(formatLogTimestamp(input)).toBe(expected);
});
});
});

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const getMemorySearchManager = vi.fn();
const loadConfig = vi.fn(() => ({}));
@@ -20,11 +20,21 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
}));
afterEach(async () => {
let registerMemoryCli: typeof import("./memory-cli.js").registerMemoryCli;
let defaultRuntime: typeof import("../runtime.js").defaultRuntime;
let isVerbose: typeof import("../globals.js").isVerbose;
let setVerbose: typeof import("../globals.js").setVerbose;
beforeAll(async () => {
({ registerMemoryCli } = await import("./memory-cli.js"));
({ defaultRuntime } = await import("../runtime.js"));
({ isVerbose, setVerbose } = await import("../globals.js"));
});
afterEach(() => {
vi.restoreAllMocks();
getMemorySearchManager.mockReset();
process.exitCode = undefined;
const { setVerbose } = await import("../globals.js");
setVerbose(false);
});
@@ -55,15 +65,45 @@ describe("memory cli", () => {
}
async function runMemoryCli(args: string[]) {
const { registerMemoryCli } = await import("./memory-cli.js");
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", ...args], { from: "user" });
}
async function withQmdIndexDb(content: string, run: (dbPath: string) => Promise<void>) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
const dbPath = path.join(tmpDir, "index.sqlite");
try {
await fs.writeFile(dbPath, content, "utf-8");
await run(dbPath);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
async function expectCloseFailureAfterCommand(params: {
args: string[];
manager: Record<string, unknown>;
beforeExpect?: () => void;
}) {
const close = vi.fn(async () => {
throw new Error("close boom");
});
mockManager({ ...params.manager, close });
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(params.args);
params.beforeExpect?.();
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
);
expect(process.exitCode).toBeUndefined();
}
it("prints vector status when available", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
@@ -97,7 +137,6 @@ describe("memory cli", () => {
});
it("prints vector error when unavailable", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => false),
@@ -122,7 +161,6 @@ describe("memory cli", () => {
});
it("prints embeddings status when deep", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
mockManager({
@@ -141,7 +179,6 @@ describe("memory cli", () => {
});
it("enables verbose logging with --verbose", async () => {
const { isVerbose } = await import("../globals.js");
const close = vi.fn(async () => {});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
@@ -155,28 +192,16 @@ describe("memory cli", () => {
});
it("logs close failure after status", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {
throw new Error("close boom");
await expectCloseFailureAfterCommand({
args: ["status"],
manager: {
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
},
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ files: 1, chunks: 1 }),
close,
});
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(["status"]);
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
);
expect(process.exitCode).toBeUndefined();
});
it("reindexes on status --index", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
@@ -197,7 +222,6 @@ describe("memory cli", () => {
});
it("closes manager after index", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
mockManager({ sync, close });
@@ -211,69 +235,51 @@ describe("memory cli", () => {
});
it("logs qmd index file path and size after index", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
const dbPath = path.join(tmpDir, "index.sqlite");
await fs.writeFile(dbPath, "sqlite-bytes", "utf-8");
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
await withQmdIndexDb("sqlite-bytes", async (dbPath) => {
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
await runMemoryCli(["index"]);
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
expect(close).toHaveBeenCalled();
await fs.rm(tmpDir, { recursive: true, force: true });
expectCliSync(sync);
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
expect(close).toHaveBeenCalled();
});
});
it("fails index when qmd db file is empty", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-qmd-index-"));
const dbPath = path.join(tmpDir, "index.sqlite");
await fs.writeFile(dbPath, "", "utf-8");
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
await withQmdIndexDb("", async (dbPath) => {
mockManager({ sync, status: () => ({ backend: "qmd", dbPath }), close });
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(["index"]);
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
);
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBe(1);
await fs.rm(tmpDir, { recursive: true, force: true });
expectCliSync(sync);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
);
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
});
it("logs close failures without failing the command", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {
throw new Error("close boom");
});
const sync = vi.fn(async () => {});
mockManager({ sync, close });
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(["index"]);
expectCliSync(sync);
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
);
expect(process.exitCode).toBeUndefined();
await expectCloseFailureAfterCommand({
args: ["index"],
manager: { sync },
beforeExpect: () => {
expectCliSync(sync);
},
});
});
it("logs close failure after search", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {
throw new Error("close boom");
});
const search = vi.fn(async () => [
{
path: "memory/2026-01-12.md",
@@ -283,21 +289,16 @@ describe("memory cli", () => {
snippet: "Hello",
},
]);
mockManager({ search, close });
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await runMemoryCli(["search", "hello"]);
expect(search).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
);
expect(process.exitCode).toBeUndefined();
await expectCloseFailureAfterCommand({
args: ["search", "hello"],
manager: { search },
beforeExpect: () => {
expect(search).toHaveBeenCalled();
},
});
});
it("closes manager after search error", async () => {
const { defaultRuntime } = await import("../runtime.js");
const close = vi.fn(async () => {});
const search = vi.fn(async () => {
throw new Error("boom");

View File

@@ -1,4 +1,6 @@
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
const githubCopilotLoginCommand = vi.fn();
const modelsStatusCommand = vi.fn().mockResolvedValue(undefined);
@@ -32,12 +34,10 @@ vi.mock("../commands/models.js", () => ({
}));
describe("models cli", () => {
let Command: typeof import("commander").Command;
let registerModelsCli: (typeof import("./models-cli.js"))["registerModelsCli"];
beforeAll(async () => {
// Load once; vi.mock above ensures command handlers are already mocked.
({ Command } = await import("commander"));
({ registerModelsCli } = await import("./models-cli.js"));
});
@@ -52,6 +52,13 @@ describe("models cli", () => {
return program;
}
async function runModelsCommand(args: string[]) {
await runRegisteredCli({
register: registerModelsCli as (program: Command) => void,
argv: args,
});
}
it("registers github-copilot login command", async () => {
const program = createProgram();
const models = program.commands.find((cmd) => cmd.name() === "models");
@@ -74,22 +81,11 @@ describe("models cli", () => {
);
});
it("passes --agent to models status", async () => {
const program = createProgram();
await program.parseAsync(["models", "status", "--agent", "poe"], { from: "user" });
expect(modelsStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({ agent: "poe" }),
expect.any(Object),
);
});
it("passes parent --agent to models status", async () => {
const program = createProgram();
await program.parseAsync(["models", "--agent", "poe", "status"], { from: "user" });
it.each([
{ label: "status flag", args: ["models", "status", "--agent", "poe"] },
{ label: "parent flag", args: ["models", "--agent", "poe", "status"] },
])("passes --agent to models status ($label)", async ({ args }) => {
await runModelsCommand(args);
expect(modelsStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({ agent: "poe" }),
expect.any(Object),

View File

@@ -83,6 +83,19 @@ describe("nodes-cli coverage", () => {
const getNodeInvokeCall = () =>
callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0] as NodeInvokeCall;
const createNodesProgram = () => {
const program = new Command();
program.exitOverride();
registerNodesCli(program);
return program;
};
const runNodesCommand = async (args: string[]) => {
const program = createNodesProgram();
await program.parseAsync(args, { from: "user" });
return getNodeInvokeCall();
};
beforeAll(async () => {
({ registerNodesCli } = await import("./nodes-cli.js"));
});
@@ -94,32 +107,23 @@ describe("nodes-cli coverage", () => {
});
it("invokes system.run with parsed params", async () => {
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
[
"nodes",
"run",
"--node",
"mac-1",
"--cwd",
"/tmp",
"--env",
"FOO=bar",
"--command-timeout",
"1200",
"--needs-screen-recording",
"--invoke-timeout",
"5000",
"echo",
"hi",
],
{ from: "user" },
);
const invoke = getNodeInvokeCall();
const invoke = await runNodesCommand([
"nodes",
"run",
"--node",
"mac-1",
"--cwd",
"/tmp",
"--env",
"FOO=bar",
"--command-timeout",
"1200",
"--needs-screen-recording",
"--invoke-timeout",
"5000",
"echo",
"hi",
]);
expect(invoke).toBeTruthy();
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
@@ -139,16 +143,16 @@ describe("nodes-cli coverage", () => {
});
it("invokes system.run with raw command", async () => {
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
["nodes", "run", "--agent", "main", "--node", "mac-1", "--raw", "echo hi"],
{ from: "user" },
);
const invoke = getNodeInvokeCall();
const invoke = await runNodesCommand([
"nodes",
"run",
"--agent",
"main",
"--node",
"mac-1",
"--raw",
"echo hi",
]);
expect(invoke).toBeTruthy();
expect(invoke?.params?.idempotencyKey).toBe("rk_test");
@@ -164,27 +168,18 @@ describe("nodes-cli coverage", () => {
});
it("invokes system.notify with provided fields", async () => {
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
[
"nodes",
"notify",
"--node",
"mac-1",
"--title",
"Ping",
"--body",
"Gateway ready",
"--delivery",
"overlay",
],
{ from: "user" },
);
const invoke = getNodeInvokeCall();
const invoke = await runNodesCommand([
"nodes",
"notify",
"--node",
"mac-1",
"--title",
"Ping",
"--body",
"Gateway ready",
"--delivery",
"overlay",
]);
expect(invoke).toBeTruthy();
expect(invoke?.params?.command).toBe("system.notify");
@@ -198,30 +193,21 @@ describe("nodes-cli coverage", () => {
});
it("invokes location.get with params", async () => {
const program = new Command();
program.exitOverride();
registerNodesCli(program);
await program.parseAsync(
[
"nodes",
"location",
"get",
"--node",
"mac-1",
"--accuracy",
"precise",
"--max-age",
"1000",
"--location-timeout",
"5000",
"--invoke-timeout",
"6000",
],
{ from: "user" },
);
const invoke = getNodeInvokeCall();
const invoke = await runNodesCommand([
"nodes",
"location",
"get",
"--node",
"mac-1",
"--accuracy",
"precise",
"--max-age",
"1000",
"--location-timeout",
"5000",
"--invoke-timeout",
"6000",
]);
expect(invoke).toBeTruthy();
expect(invoke?.params?.command).toBe("location.get");

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS } from "../../infra/exec-approvals.js";
import { parseTimeoutMs } from "../nodes-run.js";
@@ -33,14 +33,18 @@ vi.mock("../progress.js", () => ({
}));
describe("nodes run: approval transport timeout (#12098)", () => {
let callGatewayCli: typeof import("./rpc.js").callGatewayCli;
beforeAll(async () => {
({ callGatewayCli } = await import("./rpc.js"));
});
beforeEach(() => {
callGatewaySpy.mockReset();
callGatewaySpy.mockResolvedValue({ decision: "allow-once" });
});
it("callGatewayCli forwards opts.timeout as the transport timeoutMs", async () => {
const { callGatewayCli } = await import("./rpc.js");
await callGatewayCli("exec.approval.request", { timeout: "35000" } as never, {
timeoutMs: 120_000,
});
@@ -52,8 +56,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
});
it("fix: overriding transportTimeoutMs gives the approval enough transport time", async () => {
const { callGatewayCli } = await import("./rpc.js");
const approvalTimeoutMs = 120_000;
// Mirror the production code: parseTimeoutMs(opts.timeout) ?? 0
const transportTimeoutMs = Math.max(parseTimeoutMs("35000") ?? 0, approvalTimeoutMs + 10_000);
@@ -73,8 +75,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
});
it("fix: user-specified timeout larger than approval is preserved", async () => {
const { callGatewayCli } = await import("./rpc.js");
const approvalTimeoutMs = 120_000;
const userTimeout = 200_000;
// Mirror the production code: parseTimeoutMs preserves valid large values
@@ -96,8 +96,6 @@ describe("nodes run: approval transport timeout (#12098)", () => {
});
it("fix: non-numeric timeout falls back to approval floor", async () => {
const { callGatewayCli } = await import("./rpc.js");
const approvalTimeoutMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
// parseTimeoutMs returns undefined for garbage input, ?? 0 ensures
// Math.max picks the approval floor instead of producing NaN

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listChannelPairingRequests = vi.fn();
const approveChannelPairingCode = vi.fn();
@@ -45,167 +45,153 @@ vi.mock("../config/config.js", () => ({
}));
describe("pairing cli", () => {
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
let registerPairingCli: typeof import("./pairing-cli.js").registerPairingCli;
beforeAll(async () => {
({ registerPairingCli } = await import("./pairing-cli.js"));
});
beforeEach(() => {
listChannelPairingRequests.mockReset();
approveChannelPairingCode.mockReset();
notifyPairingApproved.mockReset();
normalizeChannelId.mockClear();
getPairingAdapter.mockClear();
listPairingChannels.mockClear();
});
const { registerPairingCli } = await import("./pairing-cli.js");
expect(listPairingChannels).not.toHaveBeenCalled();
function createProgram() {
const program = new Command();
program.name("test");
registerPairingCli(program);
return program;
}
async function runPairing(args: string[]) {
const program = createProgram();
await program.parseAsync(args, { from: "user" });
}
function mockApprovedPairing() {
approveChannelPairingCode.mockResolvedValueOnce({
id: "123",
entry: {
id: "123",
code: "ABCDEFGH",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
},
});
}
it("evaluates pairing channels when registering the CLI (not at import)", async () => {
expect(listPairingChannels).not.toHaveBeenCalled();
createProgram();
expect(listPairingChannels).toHaveBeenCalledTimes(1);
});
it("labels Telegram ids as telegramUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
it.each([
{
name: "telegram ids",
channel: "telegram",
id: "123",
label: "telegramUserId",
meta: { username: "peter" },
},
{
name: "discord ids",
channel: "discord",
id: "999",
label: "discordUserId",
meta: { tag: "Ada#0001" },
},
])("labels $name correctly", async ({ channel, id, label, meta }) => {
listChannelPairingRequests.mockResolvedValueOnce([
{
id: "123",
id,
code: "ABC123",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { username: "peter" },
meta,
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
from: "user",
});
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("telegramUserId");
expect(output).toContain("123");
try {
await runPairing(["pairing", "list", "--channel", channel]);
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain(label);
expect(output).toContain(id);
} finally {
log.mockRestore();
}
});
it("accepts channel as positional for list", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
await runPairing(["pairing", "list", "telegram"]);
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
});
it("forwards --account for list", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--channel", "telegram", "--account", "yy"], {
from: "user",
});
await runPairing(["pairing", "list", "--channel", "telegram", "--account", "yy"]);
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram", process.env, "yy");
});
it("normalizes channel aliases", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "imsg"], { from: "user" });
await runPairing(["pairing", "list", "imsg"]);
expect(normalizeChannelId).toHaveBeenCalledWith("imsg");
expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage");
});
it("accepts extension channels outside the registry", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "zalo"], { from: "user" });
await runPairing(["pairing", "list", "zalo"]);
expect(normalizeChannelId).toHaveBeenCalledWith("zalo");
expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo");
});
it("labels Discord ids as discordUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listChannelPairingRequests.mockResolvedValueOnce([
{
id: "999",
code: "DEF456",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
meta: { tag: "Ada#0001" },
},
]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
from: "user",
});
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("discordUserId");
expect(output).toContain("999");
});
it("accepts channel as positional for approve (npm-run compatible)", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
approveChannelPairingCode.mockResolvedValueOnce({
id: "123",
entry: {
id: "123",
code: "ABCDEFGH",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
},
});
mockApprovedPairing();
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "approve", "telegram", "ABCDEFGH"], {
from: "user",
});
try {
await runPairing(["pairing", "approve", "telegram", "ABCDEFGH"]);
expect(approveChannelPairingCode).toHaveBeenCalledWith({
channel: "telegram",
code: "ABCDEFGH",
});
expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
expect(approveChannelPairingCode).toHaveBeenCalledWith({
channel: "telegram",
code: "ABCDEFGH",
});
expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved"));
} finally {
log.mockRestore();
}
});
it("forwards --account for approve", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
approveChannelPairingCode.mockResolvedValueOnce({
id: "123",
entry: {
id: "123",
code: "ABCDEFGH",
createdAt: "2026-01-08T00:00:00Z",
lastSeenAt: "2026-01-08T00:00:00Z",
},
});
mockApprovedPairing();
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(
["pairing", "approve", "--channel", "telegram", "--account", "yy", "ABCDEFGH"],
{
from: "user",
},
);
await runPairing([
"pairing",
"approve",
"--channel",
"telegram",
"--account",
"yy",
"ABCDEFGH",
]);
expect(approveChannelPairingCode).toHaveBeenCalledWith({
channel: "telegram",

View File

@@ -42,13 +42,11 @@ describe("parseCliProfileArgs", () => {
expect(res.ok).toBe(false);
});
it("rejects combining --dev with --profile (dev first)", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--dev", "--profile", "work", "status"]);
expect(res.ok).toBe(false);
});
it("rejects combining --dev with --profile (profile first)", () => {
const res = parseCliProfileArgs(["node", "openclaw", "--profile", "work", "--dev", "status"]);
it.each([
["--dev first", ["node", "openclaw", "--dev", "--profile", "work", "status"]],
["--profile first", ["node", "openclaw", "--profile", "work", "--dev", "status"]],
])("rejects combining --dev with --profile (%s)", (_name, argv) => {
const res = parseCliProfileArgs(argv);
expect(res.ok).toBe(false);
});
});
@@ -103,38 +101,45 @@ describe("applyCliProfileEnv", () => {
});
describe("formatCliCommand", () => {
it("returns command unchanged when no profile is set", () => {
expect(formatCliCommand("openclaw doctor --fix", {})).toBe("openclaw doctor --fix");
});
it("returns command unchanged when profile is default", () => {
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "default" })).toBe(
"openclaw doctor --fix",
);
});
it("returns command unchanged when profile is Default (case-insensitive)", () => {
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "Default" })).toBe(
"openclaw doctor --fix",
);
});
it("returns command unchanged when profile is invalid", () => {
expect(formatCliCommand("openclaw doctor --fix", { OPENCLAW_PROFILE: "bad profile" })).toBe(
"openclaw doctor --fix",
);
});
it("returns command unchanged when --profile is already present", () => {
expect(
formatCliCommand("openclaw --profile work doctor --fix", { OPENCLAW_PROFILE: "work" }),
).toBe("openclaw --profile work doctor --fix");
});
it("returns command unchanged when --dev is already present", () => {
expect(formatCliCommand("openclaw --dev doctor", { OPENCLAW_PROFILE: "dev" })).toBe(
"openclaw --dev doctor",
);
it.each([
{
name: "no profile is set",
cmd: "openclaw doctor --fix",
env: {},
expected: "openclaw doctor --fix",
},
{
name: "profile is default",
cmd: "openclaw doctor --fix",
env: { OPENCLAW_PROFILE: "default" },
expected: "openclaw doctor --fix",
},
{
name: "profile is Default (case-insensitive)",
cmd: "openclaw doctor --fix",
env: { OPENCLAW_PROFILE: "Default" },
expected: "openclaw doctor --fix",
},
{
name: "profile is invalid",
cmd: "openclaw doctor --fix",
env: { OPENCLAW_PROFILE: "bad profile" },
expected: "openclaw doctor --fix",
},
{
name: "--profile is already present",
cmd: "openclaw --profile work doctor --fix",
env: { OPENCLAW_PROFILE: "work" },
expected: "openclaw --profile work doctor --fix",
},
{
name: "--dev is already present",
cmd: "openclaw --dev doctor",
env: { OPENCLAW_PROFILE: "dev" },
expected: "openclaw --dev doctor",
},
])("returns command unchanged when $name", ({ cmd, env, expected }) => {
expect(formatCliCommand(cmd, env)).toBe(expected);
});
it("inserts --profile flag when profile is set", () => {

View File

@@ -23,6 +23,21 @@ function formatRuntimeLogCallArg(value: unknown): string {
}
describe("cli program (nodes basics)", () => {
function createProgramWithCleanRuntimeLog() {
const program = buildProgram();
runtime.log.mockClear();
return program;
}
async function runProgram(argv: string[]) {
const program = createProgramWithCleanRuntimeLog();
await program.parseAsync(argv, { from: "user" });
}
function getRuntimeOutput() {
return runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
}
function mockGatewayWithIosNodeListAnd(method: "node.describe" | "node.invoke", result: unknown) {
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
@@ -53,9 +68,7 @@ describe("cli program (nodes basics)", () => {
it("runs nodes list and calls node.pair.list", async () => {
callGateway.mockResolvedValue({ pending: [], paired: [] });
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "list"], { from: "user" });
await runProgram(["nodes", "list"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
});
@@ -93,12 +106,10 @@ describe("cli program (nodes basics)", () => {
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
await runProgram(["nodes", "list", "--connected"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
const output = getRuntimeOutput();
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
@@ -127,89 +138,83 @@ describe("cli program (nodes basics)", () => {
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "status", "--last-connected", "24h"], {
from: "user",
});
await runProgram(["nodes", "status", "--last-connected", "24h"]);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
const output = getRuntimeOutput();
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status and calls node.list", async () => {
it.each([
{
label: "paired node details",
node: {
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
paired: true,
connected: true,
},
expectedOutput: [
"Known: 1 · Paired: 1 · Connected: 1",
"iOS Node",
"Detail",
"device: iPad",
"hw: iPad16,6",
"Status",
"paired",
"Caps",
"camera",
"canvas",
],
},
{
label: "unpaired node details",
node: {
nodeId: "android-node",
displayName: "Peter's Tab S10 Ultra",
remoteIp: "192.168.0.99",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera"],
paired: false,
connected: true,
},
expectedOutput: [
"Known: 1 · Paired: 0 · Connected: 1",
"Peter's Tab",
"S10 Ultra",
"Detail",
"device: Android",
"hw: samsung",
"SM-X926B",
"Status",
"unpaired",
"connected",
"Caps",
"camera",
"canvas",
],
},
])("runs nodes status and renders $label", async ({ node, expectedOutput }) => {
callGateway.mockResolvedValue({
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
paired: true,
connected: true,
},
],
nodes: [node],
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "status"], { from: "user" });
await runProgram(["nodes", "status"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
);
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
expect(output).toContain("iOS Node");
expect(output).toContain("Detail");
expect(output).toContain("device: iPad");
expect(output).toContain("hw: iPad16,6");
expect(output).toContain("Status");
expect(output).toContain("paired");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
});
it("runs nodes status and shows unpaired nodes", async () => {
callGateway.mockResolvedValue({
ts: Date.now(),
nodes: [
{
nodeId: "android-node",
displayName: "Peter's Tab S10 Ultra",
remoteIp: "192.168.0.99",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera"],
paired: false,
connected: true,
},
],
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "status"], { from: "user" });
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
expect(output).toContain("Peter's Tab");
expect(output).toContain("S10 Ultra");
expect(output).toContain("Detail");
expect(output).toContain("device: Android");
expect(output).toContain("hw: samsung");
expect(output).toContain("SM-X926B");
expect(output).toContain("Status");
expect(output).toContain("unpaired");
expect(output).toContain("connected");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
const output = getRuntimeOutput();
for (const expected of expectedOutput) {
expect(output).toContain(expected);
}
});
it("runs nodes describe and calls node.describe", async () => {
@@ -222,11 +227,7 @@ describe("cli program (nodes basics)", () => {
connected: true,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "describe", "--node", "ios-node"], {
from: "user",
});
await runProgram(["nodes", "describe", "--node", "ios-node"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),
@@ -238,7 +239,7 @@ describe("cli program (nodes basics)", () => {
}),
);
const out = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
const out = getRuntimeOutput();
expect(out).toContain("Commands");
expect(out).toContain("canvas.eval");
});
@@ -248,9 +249,7 @@ describe("cli program (nodes basics)", () => {
requestId: "r1",
node: { nodeId: "n1", token: "t1" },
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "approve", "r1"], { from: "user" });
await runProgram(["nodes", "approve", "r1"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.pair.approve",
@@ -268,21 +267,16 @@ describe("cli program (nodes basics)", () => {
payload: { result: "ok" },
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
[
"nodes",
"invoke",
"--node",
"ios-node",
"--command",
"canvas.eval",
"--params",
'{"javaScript":"1+1"}',
],
{ from: "user" },
);
await runProgram([
"nodes",
"invoke",
"--node",
"ios-node",
"--command",
"canvas.eval",
"--params",
'{"javaScript":"1+1"}',
]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "node.list", params: {} }),

View File

@@ -30,6 +30,24 @@ async function expectLoggedSingleMediaFile(params?: {
return mediaPath;
}
function expectParserAcceptsUrlWithoutBase64(
parse: (payload: Record<string, unknown>) => { url?: string; base64?: string },
payload: Record<string, unknown>,
expectedUrl: string,
) {
const result = parse(payload);
expect(result.url).toBe(expectedUrl);
expect(result.base64).toBeUndefined();
}
function expectParserRejectsMissingMedia(
parse: (payload: Record<string, unknown>) => unknown,
payload: Record<string, unknown>,
expectedMessage: string,
) {
expect(() => parse(payload)).toThrow(expectedMessage);
}
const IOS_NODE = {
nodeId: "ios-node",
displayName: "iOS Node",
@@ -61,6 +79,31 @@ function mockNodeGateway(command?: string, payload?: Record<string, unknown>) {
const { buildProgram } = await import("./program.js");
describe("cli program (nodes media)", () => {
function createProgramWithCleanRuntimeLog() {
const program = buildProgram();
runtime.log.mockClear();
return program;
}
async function runNodesCommand(argv: string[]) {
const program = createProgramWithCleanRuntimeLog();
await program.parseAsync(argv, { from: "user" });
}
async function runAndExpectUrlPayloadMediaFile(params: {
command: "camera.snap" | "camera.clip";
payload: Record<string, unknown>;
argv: string[];
expectedPathPattern: RegExp;
}) {
mockNodeGateway(params.command, params.payload);
await runNodesCommand(params.argv);
await expectLoggedSingleMediaFile({
expectedPathPattern: params.expectedPathPattern,
expectedContent: "url-content",
});
}
beforeEach(() => {
vi.clearAllMocks();
runTui.mockResolvedValue(undefined);
@@ -69,9 +112,7 @@ describe("cli program (nodes media)", () => {
it("runs nodes camera snap and prints two MEDIA paths", async () => {
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" });
await runNodesCommand(["nodes", "camera", "snap", "--node", "ios-node"]);
const invokeCalls = callGateway.mock.calls
.map((call) => call[0] as { method?: string; params?: Record<string, unknown> })
@@ -107,12 +148,7 @@ describe("cli program (nodes media)", () => {
hasAudio: true,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"],
{ from: "user" },
);
await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "3000"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
@@ -140,28 +176,23 @@ describe("cli program (nodes media)", () => {
it("runs nodes camera snap with facing front and passes params", async () => {
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
[
"nodes",
"camera",
"snap",
"--node",
"ios-node",
"--facing",
"front",
"--max-width",
"640",
"--quality",
"0.8",
"--delay-ms",
"2000",
"--device-id",
"cam-123",
],
{ from: "user" },
);
await runNodesCommand([
"nodes",
"camera",
"snap",
"--node",
"ios-node",
"--facing",
"front",
"--max-width",
"640",
"--quality",
"0.8",
"--delay-ms",
"2000",
"--device-id",
"cam-123",
]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
@@ -193,23 +224,18 @@ describe("cli program (nodes media)", () => {
hasAudio: false,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
[
"nodes",
"camera",
"clip",
"--node",
"ios-node",
"--duration",
"3000",
"--no-audio",
"--device-id",
"cam-123",
],
{ from: "user" },
);
await runNodesCommand([
"nodes",
"camera",
"clip",
"--node",
"ios-node",
"--duration",
"3000",
"--no-audio",
"--device-id",
"cam-123",
]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
@@ -238,12 +264,7 @@ describe("cli program (nodes media)", () => {
hasAudio: true,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"],
{ from: "user" },
);
await runNodesCommand(["nodes", "camera", "clip", "--node", "ios-node", "--duration", "10s"]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
@@ -260,12 +281,7 @@ describe("cli program (nodes media)", () => {
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" });
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"],
{ from: "user" },
);
await runNodesCommand(["nodes", "canvas", "snapshot", "--node", "ios-node", "--format", "png"]);
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/,
@@ -307,62 +323,86 @@ describe("cli program (nodes media)", () => {
globalThis.fetch = originalFetch;
});
it("runs nodes camera snap with url payload", async () => {
mockNodeGateway("camera.snap", {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"],
{ from: "user" },
);
await expectLoggedSingleMediaFile({
it.each([
{
label: "runs nodes camera snap with url payload",
command: "camera.snap" as const,
payload: {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
},
argv: ["nodes", "camera", "snap", "--node", "ios-node", "--facing", "front"],
expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/,
expectedContent: "url-content",
});
});
it("runs nodes camera clip with url payload", async () => {
mockNodeGateway("camera.clip", {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 5000,
hasAudio: true,
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(
["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"],
{ from: "user" },
);
await expectLoggedSingleMediaFile({
},
{
label: "runs nodes camera clip with url payload",
command: "camera.clip" as const,
payload: {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 5000,
hasAudio: true,
},
argv: ["nodes", "camera", "clip", "--node", "ios-node", "--duration", "5000"],
expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/,
expectedContent: "url-content",
},
])("$label", async ({ command, payload, argv, expectedPathPattern }) => {
await runAndExpectUrlPayloadMediaFile({
command,
payload,
argv,
expectedPathPattern,
});
});
});
describe("parseCameraSnapPayload with url", () => {
it("accepts url without base64", () => {
const result = parseCameraSnapPayload({
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
});
expect(result.url).toBe("https://example.com/photo.jpg");
expect(result.base64).toBeUndefined();
});
describe("url payload parsers", () => {
const parserCases = [
{
label: "camera snap parser",
parse: (payload: Record<string, unknown>) => parseCameraSnapPayload(payload),
validPayload: {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
},
invalidPayload: { format: "jpg", width: 640, height: 480 },
expectedUrl: "https://example.com/photo.jpg",
expectedError: "invalid camera.snap payload",
},
{
label: "camera clip parser",
parse: (payload: Record<string, unknown>) => parseCameraClipPayload(payload),
validPayload: {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 3000,
hasAudio: true,
},
invalidPayload: { format: "mp4", durationMs: 3000, hasAudio: true },
expectedUrl: "https://example.com/clip.mp4",
expectedError: "invalid camera.clip payload",
},
] as const;
it("accepts both base64 and url", () => {
it.each(parserCases)(
"accepts url without base64: $label",
({ parse, validPayload, expectedUrl }) => {
expectParserAcceptsUrlWithoutBase64(parse, validPayload, expectedUrl);
},
);
it.each(parserCases)(
"rejects payload with neither base64 nor url: $label",
({ parse, invalidPayload, expectedError }) => {
expectParserRejectsMissingMedia(parse, invalidPayload, expectedError);
},
);
it("snap parser accepts both base64 and url", () => {
const result = parseCameraSnapPayload({
format: "jpg",
base64: "aGk=",
@@ -373,30 +413,5 @@ describe("cli program (nodes media)", () => {
expect(result.base64).toBe("aGk=");
expect(result.url).toBe("https://example.com/photo.jpg");
});
it("rejects payload with neither base64 nor url", () => {
expect(() => parseCameraSnapPayload({ format: "jpg", width: 640, height: 480 })).toThrow(
"invalid camera.snap payload",
);
});
});
describe("parseCameraClipPayload with url", () => {
it("accepts url without base64", () => {
const result = parseCameraClipPayload({
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 3000,
hasAudio: true,
});
expect(result.url).toBe("https://example.com/clip.mp4");
expect(result.base64).toBeUndefined();
});
it("rejects payload with neither base64 nor url", () => {
expect(() =>
parseCameraClipPayload({ format: "mp4", durationMs: 3000, hasAudio: true }),
).toThrow("invalid camera.clip payload");
});
});
});

View File

@@ -20,99 +20,108 @@ installSmokeProgramMocks();
const { buildProgram } = await import("./program.js");
describe("cli program (smoke)", () => {
function createProgram() {
return buildProgram();
}
async function runProgram(argv: string[]) {
const program = createProgram();
await program.parseAsync(argv, { from: "user" });
}
beforeEach(() => {
vi.clearAllMocks();
runTui.mockResolvedValue(undefined);
ensureConfigReady.mockResolvedValue(undefined);
});
it("runs message with required options", async () => {
const program = buildProgram();
await expect(
program.parseAsync(["message", "send", "--target", "+1", "--message", "hi"], {
from: "user",
}),
).rejects.toThrow("exit");
expect(messageCommand).toHaveBeenCalled();
});
it("runs message react with signal author fields", async () => {
const program = buildProgram();
await expect(
program.parseAsync(
[
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
{ from: "user" },
),
).rejects.toThrow("exit");
it.each([
{
label: "runs message with required options",
argv: ["message", "send", "--target", "+1", "--message", "hi"],
},
{
label: "runs message react with signal author fields",
argv: [
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
},
])("$label", async ({ argv }) => {
await expect(runProgram(argv)).rejects.toThrow("exit");
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
const program = buildProgram();
await program.parseAsync(["status"], { from: "user" });
await runProgram(["status"]);
expect(statusCommand).toHaveBeenCalled();
});
it("registers memory command", () => {
const program = buildProgram();
const program = createProgram();
const names = program.commands.map((command) => command.name());
expect(names).toContain("memory");
});
it("runs tui without overriding timeout", async () => {
const program = buildProgram();
await program.parseAsync(["tui"], { from: "user" });
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined }));
});
it("runs tui with explicit timeout override", async () => {
const program = buildProgram();
await program.parseAsync(["tui", "--timeout-ms", "45000"], {
from: "user",
});
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 45000 }));
});
it("warns and ignores invalid tui timeout override", async () => {
const program = buildProgram();
await program.parseAsync(["tui", "--timeout-ms", "nope"], { from: "user" });
expect(runtime.error).toHaveBeenCalledWith('warning: invalid --timeout-ms "nope"; ignoring');
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: undefined }));
it.each([
{
label: "runs tui without overriding timeout",
argv: ["tui"],
expectedTimeoutMs: undefined,
expectedWarning: undefined,
},
{
label: "runs tui with explicit timeout override",
argv: ["tui", "--timeout-ms", "45000"],
expectedTimeoutMs: 45000,
expectedWarning: undefined,
},
{
label: "warns and ignores invalid tui timeout override",
argv: ["tui", "--timeout-ms", "nope"],
expectedTimeoutMs: undefined,
expectedWarning: 'warning: invalid --timeout-ms "nope"; ignoring',
},
])("$label", async ({ argv, expectedTimeoutMs, expectedWarning }) => {
await runProgram(argv);
if (expectedWarning) {
expect(runtime.error).toHaveBeenCalledWith(expectedWarning);
}
expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expectedTimeoutMs }));
});
it("runs config alias as configure", async () => {
const program = buildProgram();
await program.parseAsync(["config"], { from: "user" });
await runProgram(["config"]);
expect(configureCommand).toHaveBeenCalled();
});
it("runs setup without wizard flags", async () => {
const program = buildProgram();
await program.parseAsync(["setup"], { from: "user" });
expect(setupCommand).toHaveBeenCalled();
expect(onboardCommand).not.toHaveBeenCalled();
});
it("runs setup wizard when wizard flags are present", async () => {
const program = buildProgram();
await program.parseAsync(["setup", "--remote-url", "ws://example"], {
from: "user",
});
expect(onboardCommand).toHaveBeenCalled();
expect(setupCommand).not.toHaveBeenCalled();
it.each([
{
label: "runs setup without wizard flags",
argv: ["setup"],
expectSetupCalled: true,
expectOnboardCalled: false,
},
{
label: "runs setup wizard when wizard flags are present",
argv: ["setup", "--remote-url", "ws://example"],
expectSetupCalled: false,
expectOnboardCalled: true,
},
])("$label", async ({ argv, expectSetupCalled, expectOnboardCalled }) => {
await runProgram(argv);
expect(setupCommand).toHaveBeenCalledTimes(expectSetupCalled ? 1 : 0);
expect(onboardCommand).toHaveBeenCalledTimes(expectOnboardCalled ? 1 : 0);
});
it("passes auth api keys to onboard", async () => {
@@ -168,11 +177,14 @@ describe("cli program (smoke)", () => {
] as const;
for (const entry of cases) {
const program = buildProgram();
await program.parseAsync(
["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key],
{ from: "user" },
);
await runProgram([
"onboard",
"--non-interactive",
"--auth-choice",
entry.authChoice,
entry.flag,
entry.key,
]);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
@@ -186,26 +198,22 @@ describe("cli program (smoke)", () => {
});
it("passes custom provider flags to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"custom-api-key",
"--custom-base-url",
"https://llm.example.com/v1",
"--custom-api-key",
"sk-custom-test",
"--custom-model-id",
"foo-large",
"--custom-provider-id",
"my-custom",
"--custom-compatibility",
"anthropic",
],
{ from: "user" },
);
await runProgram([
"onboard",
"--non-interactive",
"--auth-choice",
"custom-api-key",
"--custom-base-url",
"https://llm.example.com/v1",
"--custom-api-key",
"sk-custom-test",
"--custom-model-id",
"foo-large",
"--custom-provider-id",
"my-custom",
"--custom-compatibility",
"anthropic",
]);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
@@ -221,22 +229,27 @@ describe("cli program (smoke)", () => {
);
});
it("runs channels login", async () => {
const program = buildProgram();
await program.parseAsync(["channels", "login", "--account", "work"], {
from: "user",
});
expect(runChannelLogin).toHaveBeenCalledWith(
{ channel: undefined, account: "work", verbose: false },
runtime,
);
});
it("runs channels logout", async () => {
const program = buildProgram();
await program.parseAsync(["channels", "logout", "--account", "work"], {
from: "user",
});
expect(runChannelLogout).toHaveBeenCalledWith({ channel: undefined, account: "work" }, runtime);
it.each([
{
label: "runs channels login",
argv: ["channels", "login", "--account", "work"],
expectCall: () =>
expect(runChannelLogin).toHaveBeenCalledWith(
{ channel: undefined, account: "work", verbose: false },
runtime,
),
},
{
label: "runs channels logout",
argv: ["channels", "logout", "--account", "work"],
expectCall: () =>
expect(runChannelLogout).toHaveBeenCalledWith(
{ channel: undefined, account: "work" },
runtime,
),
},
])("$label", async ({ argv, expectCall }) => {
await runProgram(argv);
expectCall();
});
});

View File

@@ -39,6 +39,18 @@ const testProgramContext: ProgramContext = {
};
describe("command-registry", () => {
const createProgram = () => new Command();
const withProcessArgv = async (argv: string[], run: () => Promise<void>) => {
const prevArgv = process.argv;
process.argv = argv;
try {
await run();
} finally {
process.argv = prevArgv;
}
};
it("includes both agent and agents in core CLI command names", () => {
const names = getCoreCliCommandNames();
expect(names).toContain("agent");
@@ -46,7 +58,7 @@ describe("command-registry", () => {
});
it("registerCoreCliByName resolves agents to the agent entry", async () => {
const program = new Command();
const program = createProgram();
const found = await registerCoreCliByName(program, testProgramContext, "agents");
expect(found).toBe(true);
const agentsCmd = program.commands.find((c) => c.name() === "agents");
@@ -57,20 +69,20 @@ describe("command-registry", () => {
});
it("registerCoreCliByName returns false for unknown commands", async () => {
const program = new Command();
const program = createProgram();
const found = await registerCoreCliByName(program, testProgramContext, "nonexistent");
expect(found).toBe(false);
});
it("registers doctor placeholder for doctor primary command", () => {
const program = new Command();
const program = createProgram();
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "doctor"]);
expect(program.commands.map((command) => command.name())).toEqual(["doctor"]);
});
it("treats maintenance commands as top-level builtins", async () => {
const program = new Command();
const program = createProgram();
expect(await registerCoreCliByName(program, testProgramContext, "doctor")).toBe(true);
@@ -83,17 +95,12 @@ describe("command-registry", () => {
});
it("registers grouped core entry placeholders without duplicate command errors", async () => {
const program = new Command();
const program = createProgram();
registerCoreCliCommands(program, testProgramContext, ["node", "openclaw", "vitest"]);
const prevArgv = process.argv;
process.argv = ["node", "openclaw", "status"];
try {
program.exitOverride();
program.exitOverride();
await withProcessArgv(["node", "openclaw", "status"], async () => {
await program.parseAsync(["node", "openclaw", "status"]);
} finally {
process.argv = prevArgv;
}
});
const names = program.commands.map((command) => command.name());
expect(names).toContain("status");

View File

@@ -29,22 +29,30 @@ function makeRuntime() {
}
describe("ensureConfigReady", () => {
async function runEnsureConfigReady(commandPath: string[]) {
vi.resetModules();
const { ensureConfigReady } = await import("./config-guard.js");
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath });
}
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
});
it("skips doctor flow for read-only fast path commands", async () => {
vi.resetModules();
const { ensureConfigReady } = await import("./config-guard.js");
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["status"] });
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
it("runs doctor flow for commands that may mutate state", async () => {
vi.resetModules();
const { ensureConfigReady } = await import("./config-guard.js");
await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["message"] });
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
it.each([
{
name: "skips doctor flow for read-only fast path commands",
commandPath: ["status"],
expectedDoctorCalls: 0,
},
{
name: "runs doctor flow for commands that may mutate state",
commandPath: ["message"],
expectedDoctorCalls: 1,
},
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
});
});

View File

@@ -1,5 +1,5 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const doctorCommand = vi.fn();
const dashboardCommand = vi.fn();
@@ -32,7 +32,19 @@ vi.mock("../../runtime.js", () => ({
defaultRuntime: runtime,
}));
let registerMaintenanceCommands: typeof import("./register.maintenance.js").registerMaintenanceCommands;
beforeAll(async () => {
({ registerMaintenanceCommands } = await import("./register.maintenance.js"));
});
describe("registerMaintenanceCommands doctor action", () => {
async function runMaintenanceCli(args: string[]) {
const program = new Command();
registerMaintenanceCommands(program);
await program.parseAsync(args, { from: "user" });
}
beforeEach(() => {
vi.clearAllMocks();
});
@@ -40,11 +52,7 @@ describe("registerMaintenanceCommands doctor action", () => {
it("exits with code 0 after successful doctor run", async () => {
doctorCommand.mockResolvedValue(undefined);
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
const program = new Command();
registerMaintenanceCommands(program);
await program.parseAsync(["doctor", "--non-interactive", "--yes"], { from: "user" });
await runMaintenanceCli(["doctor", "--non-interactive", "--yes"]);
expect(doctorCommand).toHaveBeenCalledWith(
runtime,
@@ -59,11 +67,7 @@ describe("registerMaintenanceCommands doctor action", () => {
it("exits with code 1 when doctor fails", async () => {
doctorCommand.mockRejectedValue(new Error("doctor failed"));
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
const program = new Command();
registerMaintenanceCommands(program);
await program.parseAsync(["doctor"], { from: "user" });
await runMaintenanceCli(["doctor"]);
expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed");
expect(runtime.exit).toHaveBeenCalledWith(1);

View File

@@ -27,6 +27,16 @@ describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
const createRegisteredProgram = (argv: string[], name?: string) => {
process.argv = argv;
const program = new Command();
if (name) {
program.name(name);
}
registerSubCliCommands(program, process.argv);
return program;
};
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS;
@@ -42,9 +52,7 @@ describe("registerSubCliCommands", () => {
});
it("registers only the primary placeholder and dispatches", async () => {
process.argv = ["node", "openclaw", "acp"];
const program = new Command();
registerSubCliCommands(program, process.argv);
const program = createRegisteredProgram(["node", "openclaw", "acp"]);
expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]);
@@ -55,9 +63,7 @@ describe("registerSubCliCommands", () => {
});
it("registers placeholders for all subcommands when no primary", () => {
process.argv = ["node", "openclaw"];
const program = new Command();
registerSubCliCommands(program, process.argv);
const program = createRegisteredProgram(["node", "openclaw"]);
const names = program.commands.map((cmd) => cmd.name());
expect(names).toContain("acp");
@@ -67,10 +73,7 @@ describe("registerSubCliCommands", () => {
});
it("re-parses argv for lazy subcommands", async () => {
process.argv = ["node", "openclaw", "nodes", "list"];
const program = new Command();
program.name("openclaw");
registerSubCliCommands(program, process.argv);
const program = createRegisteredProgram(["node", "openclaw", "nodes", "list"], "openclaw");
expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]);
@@ -81,10 +84,7 @@ describe("registerSubCliCommands", () => {
});
it("replaces placeholder when registering a subcommand by name", async () => {
process.argv = ["node", "openclaw", "acp", "--help"];
const program = new Command();
program.name("openclaw");
registerSubCliCommands(program, process.argv);
const program = createRegisteredProgram(["node", "openclaw", "acp", "--help"], "openclaw");
await registerSubCliByName(program, "acp");

View File

@@ -47,6 +47,21 @@ function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
}
describe("registerQrCli", () => {
function createProgram() {
const program = new Command();
registerQrCli(program);
return program;
}
async function runQr(args: string[]) {
const program = createProgram();
await program.parseAsync(["qr", ...args], { from: "user" });
}
async function expectQrExit(args: string[]) {
await expect(runQr(args)).rejects.toThrow("exit");
}
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
@@ -68,10 +83,7 @@ describe("registerQrCli", () => {
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only"], { from: "user" });
await runQr(["--setup-code-only"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
@@ -90,10 +102,7 @@ describe("registerQrCli", () => {
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr"], { from: "user" });
await runQr([]);
expect(qrGenerate).toHaveBeenCalledTimes(1);
const output = runtime.log.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
@@ -111,12 +120,7 @@ describe("registerQrCli", () => {
},
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only", "--token", "override-token"], {
from: "user",
});
await runQr(["--setup-code-only", "--token", "override-token"]);
const expected = encodePairingSetupCode({
url: "ws://gateway.local:18789",
@@ -133,10 +137,7 @@ describe("registerQrCli", () => {
},
});
const program = new Command();
registerQrCli(program);
await expect(program.parseAsync(["qr"], { from: "user" })).rejects.toThrow("exit");
await expectQrExit([]);
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("only bound to loopback");
@@ -144,10 +145,7 @@ describe("registerQrCli", () => {
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--setup-code-only", "--remote"], { from: "user" });
await runQr(["--setup-code-only", "--remote"]);
const expected = encodePairingSetupCode({
url: "wss://remote.example.com:444",
@@ -156,12 +154,18 @@ describe("registerQrCli", () => {
expect(runtime.log).toHaveBeenCalledWith(expected);
});
it("reports gateway.remote.url as source in --remote json output", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig());
it.each([
{ name: "without tailscale configured", withTailscale: false },
{ name: "when tailscale is configured", withTailscale: true },
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
await runQr(["--json", "--remote"]);
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
setupCode?: string;
@@ -172,6 +176,7 @@ describe("registerQrCli", () => {
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(payload.auth).toBe("token");
expect(payload.urlSource).toBe("gateway.remote.url");
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
it("errors when --remote is set but no remote URL is configured", async () => {
@@ -183,33 +188,8 @@ describe("registerQrCli", () => {
},
});
const program = new Command();
registerQrCli(program);
await expect(program.parseAsync(["qr", "--remote"], { from: "user" })).rejects.toThrow("exit");
await expectQrExit(["--remote"]);
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("qr --remote requires");
});
it("prefers gateway.remote.url over tailscale when --remote is set", async () => {
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: true }));
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
const program = new Command();
registerQrCli(program);
await program.parseAsync(["qr", "--json", "--remote"], { from: "user" });
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
gatewayUrl?: string;
urlSource?: string;
};
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(payload.urlSource).toBe("gateway.remote.url");
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
const updateCommand = vi.fn(async (_opts: unknown) => {});
const updateStatusCommand = vi.fn(async (_opts: unknown) => {});
@@ -28,6 +29,12 @@ vi.mock("../runtime.js", () => ({
}));
describe("update cli option collisions", () => {
let registerUpdateCli: typeof import("./update-cli.js").registerUpdateCli;
beforeAll(async () => {
({ registerUpdateCli } = await import("./update-cli.js"));
});
beforeEach(() => {
updateCommand.mockClear();
updateStatusCommand.mockClear();
@@ -38,11 +45,10 @@ describe("update cli option collisions", () => {
});
it("forwards parent-captured --json/--timeout to `update status`", async () => {
const { registerUpdateCli } = await import("./update-cli.js");
const program = new Command();
registerUpdateCli(program);
await program.parseAsync(["update", "status", "--json", "--timeout", "9"], { from: "user" });
await runRegisteredCli({
register: registerUpdateCli as (program: Command) => void,
argv: ["update", "status", "--json", "--timeout", "9"],
});
expect(updateStatusCommand).toHaveBeenCalledWith(
expect.objectContaining({
@@ -53,11 +59,10 @@ describe("update cli option collisions", () => {
});
it("forwards parent-captured --timeout to `update wizard`", async () => {
const { registerUpdateCli } = await import("./update-cli.js");
const program = new Command();
registerUpdateCli(program);
await program.parseAsync(["update", "wizard", "--timeout", "13"], { from: "user" });
await runRegisteredCli({
register: registerUpdateCli as (program: Command) => void,
argv: ["update", "wizard", "--timeout", "13"],
});
expect(updateWizardCommand).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -1,7 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { captureEnv } from "../test-utils/env.js";
@@ -120,23 +118,15 @@ const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardComma
await import("./update-cli.js");
describe("update-cli", () => {
let fixtureRoot = "";
const fixtureRoot = "/tmp/openclaw-update-tests";
let fixtureCount = 0;
const createCaseDir = async (prefix: string) => {
const createCaseDir = (prefix: string) => {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
// Tests only need a stable path; the directory does not have to exist because all I/O is mocked.
return dir;
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-tests-"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
const baseConfig = {} as OpenClawConfig;
const baseSnapshot: ConfigFileSnapshot = {
path: "/tmp/openclaw-config.json",
@@ -186,8 +176,17 @@ describe("update-cli", () => {
return call;
};
const makeOkUpdateResult = (overrides: Partial<UpdateRunResult> = {}): UpdateRunResult =>
({
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
...overrides,
}) as UpdateRunResult;
const setupNonInteractiveDowngrade = async () => {
const tempDir = await createCaseDir("openclaw-update");
const tempDir = createCaseDir("openclaw-update");
setTty(false);
readPackageVersion.mockResolvedValue("2.0.0");
@@ -332,55 +331,53 @@ describe("update-cli", () => {
expect(parsed.channel.value).toBe("stable");
});
it("defaults to dev channel for git installs when unset", async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
});
it.each([
{
name: "defaults to dev channel for git installs when unset",
mode: "git" as const,
options: {},
prepare: async () => {},
expectedChannel: "dev" as const,
expectedTag: undefined as string | undefined,
},
{
name: "defaults to stable channel for package installs when unset",
mode: "npm" as const,
options: { yes: true },
prepare: async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
},
expectedChannel: "stable" as const,
expectedTag: "latest",
},
{
name: "uses stored beta channel when configured",
mode: "git" as const,
options: {},
prepare: async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
config: { update: { channel: "beta" } } as OpenClawConfig,
});
},
expectedChannel: "beta" as const,
expectedTag: undefined as string | undefined,
},
])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => {
await prepare();
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
await updateCommand({});
await updateCommand(options);
expectUpdateCallChannel("dev");
});
it("defaults to stable channel for package installs when unset", async () => {
const tempDir = await createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
await updateCommand({ yes: true });
const call = expectUpdateCallChannel("stable");
expect(call?.tag).toBe("latest");
});
it("uses stored beta channel when configured", async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
config: { update: { channel: "beta" } } as OpenClawConfig,
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
});
await updateCommand({});
expectUpdateCallChannel("beta");
const call = expectUpdateCallChannel(expectedChannel);
if (expectedTag !== undefined) {
expect(call?.tag).toBe(expectedTag);
}
});
it("falls back to latest when beta tag is older than release", async () => {
const tempDir = await createCaseDir("openclaw-update");
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
@@ -391,12 +388,11 @@ describe("update-cli", () => {
tag: "latest",
version: "1.2.3-1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
await updateCommand({});
@@ -405,15 +401,14 @@ describe("update-cli", () => {
});
it("honors --tag override", async () => {
const tempDir = await createCaseDir("openclaw-update");
const tempDir = createCaseDir("openclaw-update");
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
vi.mocked(runGatewayUpdate).mockResolvedValue(
makeOkUpdateResult({
mode: "npm",
}),
);
await updateCommand({ tag: "next" });
@@ -422,14 +417,7 @@ describe("update-cli", () => {
});
it("updateCommand outputs JSON when --json is set", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.log).mockClear();
await updateCommand({ json: true });
@@ -464,14 +452,7 @@ describe("update-cli", () => {
});
it("updateCommand restarts daemon by default", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(runDaemonRestart).mockResolvedValue(true);
await updateCommand({});
@@ -480,18 +461,11 @@ describe("update-cli", () => {
});
it("updateCommand continues after doctor sub-step and clears update flag", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]);
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
try {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(runDaemonRestart).mockResolvedValue(true);
vi.mocked(doctorCommand).mockResolvedValue(undefined);
vi.mocked(defaultRuntime.log).mockClear();
@@ -515,14 +489,7 @@ describe("update-cli", () => {
});
it("updateCommand skips restart when --no-restart is set", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
await updateCommand({ restart: false });
@@ -530,14 +497,7 @@ describe("update-cli", () => {
});
it("updateCommand skips success message when restart does not run", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(runDaemonRestart).mockResolvedValue(false);
vi.mocked(defaultRuntime.log).mockClear();
@@ -547,35 +507,35 @@ describe("update-cli", () => {
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false);
});
it("updateCommand validates timeout option", async () => {
it.each([
{
name: "update command",
run: async () => await updateCommand({ timeout: "invalid" }),
requireTty: false,
},
{
name: "update status command",
run: async () => await updateStatusCommand({ timeout: "invalid" }),
requireTty: false,
},
{
name: "update wizard command",
run: async () => await updateWizardCommand({ timeout: "invalid" }),
requireTty: true,
},
])("validates timeout option for $name", async ({ run, requireTty }) => {
setTty(requireTty);
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({ timeout: "invalid" });
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("updateStatusCommand validates timeout option", async () => {
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateStatusCommand({ timeout: "invalid" });
await run();
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("persists update channel when --channel is set", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
await updateCommand({ channel: "beta" });
@@ -586,26 +546,31 @@ describe("update-cli", () => {
expect(call?.update?.channel).toBe("beta");
});
it("requires confirmation on downgrade when non-interactive", async () => {
it.each([
{
name: "requires confirmation without --yes",
options: {},
shouldExit: true,
shouldRunUpdate: false,
},
{
name: "allows downgrade with --yes",
options: { yes: true },
shouldExit: false,
shouldRunUpdate: true,
},
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => {
await setupNonInteractiveDowngrade();
await updateCommand(options);
await updateCommand({});
expect(defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("Downgrade confirmation required."),
const downgradeMessageSeen = vi
.mocked(defaultRuntime.error)
.mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required."));
expect(downgradeMessageSeen).toBe(shouldExit);
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
shouldExit,
);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("allows downgrade with --yes in non-interactive mode", async () => {
await setupNonInteractiveDowngrade();
await updateCommand({ yes: true });
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
expect.stringContaining("Downgrade confirmation required."),
);
expect(runGatewayUpdate).toHaveBeenCalled();
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
});
it("updateWizardCommand requires a TTY", async () => {
@@ -621,19 +586,8 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("updateWizardCommand validates timeout option", async () => {
setTty(true);
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateWizardCommand({ timeout: "invalid" });
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
const tempDir = await createCaseDir("openclaw-update-wizard");
const tempDir = createCaseDir("openclaw-update-wizard");
const envSnapshot = captureEnv(["OPENCLAW_GIT_DIR"]);
try {
setTty(true);