test(cli): add argv, cli-name, cli-utils, ports, profile, profile-utils, and respawn-policy tests

This commit is contained in:
kumarabhirup 2026-03-02 18:31:56 -08:00
parent 55a94523a7
commit 6d51543f39
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
7 changed files with 373 additions and 0 deletions

93
src/cli/argv.test.ts Normal file
View File

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
buildParseArgv,
getCommandPath,
getFlagValue,
getPositiveIntFlagValue,
getPrimaryCommand,
hasHelpOrVersion,
hasRootVersionAlias,
shouldMigrateState,
shouldMigrateStateFromPath,
} from "./argv.js";
describe("argv helpers", () => {
it("detects help/version flags and root -v alias only in root-flag contexts", () => {
expect(hasHelpOrVersion(["node", "ironclaw", "--help"])).toBe(true);
expect(hasHelpOrVersion(["node", "ironclaw", "-V"])).toBe(true);
expect(hasHelpOrVersion(["node", "ironclaw", "-v"])).toBe(true);
expect(hasRootVersionAlias(["node", "ironclaw", "-v", "chat"])).toBe(false);
});
it("extracts flag values across --name value and --name=value forms", () => {
expect(getFlagValue(["node", "ironclaw", "--profile", "dev"], "--profile")).toBe("dev");
expect(getFlagValue(["node", "ironclaw", "--profile=team-a"], "--profile")).toBe("team-a");
expect(getFlagValue(["node", "ironclaw", "--profile", "--verbose"], "--profile")).toBeNull();
expect(getFlagValue(["node", "ironclaw", "--profile="], "--profile")).toBeNull();
});
it("parses positive integer flags and rejects invalid numeric values", () => {
expect(getPositiveIntFlagValue(["node", "ironclaw", "--port", "19001"], "--port")).toBe(19001);
expect(getPositiveIntFlagValue(["node", "ironclaw", "--port", "0"], "--port")).toBeUndefined();
expect(getPositiveIntFlagValue(["node", "ironclaw", "--port", "-1"], "--port")).toBeUndefined();
expect(
getPositiveIntFlagValue(["node", "ironclaw", "--port", "abc"], "--port"),
).toBeUndefined();
});
it("derives command path while skipping leading flags and stopping at terminator", () => {
// Low-level parser skips flag tokens but not their values.
expect(getCommandPath(["node", "ironclaw", "--profile", "dev", "chat"], 2)).toEqual([
"dev",
"chat",
]);
expect(getCommandPath(["node", "ironclaw", "config", "get"], 2)).toEqual(["config", "get"]);
expect(getCommandPath(["node", "ironclaw", "--", "chat", "send"], 2)).toEqual([]);
expect(getPrimaryCommand(["node", "ironclaw", "--verbose", "status"])).toBe("status");
});
it("builds parse argv consistently across runtime invocation styles", () => {
expect(
buildParseArgv({
programName: "ironclaw",
rawArgs: ["node", "cli.js", "status"],
}),
).toEqual(["node", "cli.js", "status"]);
expect(
buildParseArgv({
programName: "ironclaw",
rawArgs: ["ironclaw", "status"],
}),
).toEqual(["node", "ironclaw", "status"]);
expect(
buildParseArgv({
programName: "ironclaw",
rawArgs: ["node-22.12.0.exe", "cli.js", "agent", "run"],
}),
).toEqual(["node-22.12.0.exe", "cli.js", "agent", "run"]);
expect(
buildParseArgv({
programName: "ironclaw",
rawArgs: ["bun", "cli.ts", "status"],
}),
).toEqual(["bun", "cli.ts", "status"]);
});
it("skips state migration for read-only command paths and keeps mutations enabled for others", () => {
expect(shouldMigrateStateFromPath([])).toBe(true);
expect(shouldMigrateStateFromPath(["health"])).toBe(false);
expect(shouldMigrateStateFromPath(["status"])).toBe(false);
expect(shouldMigrateStateFromPath(["sessions"])).toBe(false);
expect(shouldMigrateStateFromPath(["config", "get"])).toBe(false);
expect(shouldMigrateStateFromPath(["models", "list"])).toBe(false);
expect(shouldMigrateStateFromPath(["memory", "status"])).toBe(false);
expect(shouldMigrateStateFromPath(["agent"])).toBe(false);
expect(shouldMigrateStateFromPath(["chat", "send"])).toBe(true);
expect(shouldMigrateState(["node", "ironclaw", "health"])).toBe(false);
expect(shouldMigrateState(["node", "ironclaw", "chat", "send"])).toBe(true);
});
});

25
src/cli/cli-name.test.ts Normal file
View File

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_CLI_NAME, replaceCliName, resolveCliName } from "./cli-name.js";
describe("cli-name", () => {
it("resolves known CLI names from argv[1]", () => {
expect(resolveCliName(["node", "openclaw"])).toBe("openclaw");
expect(resolveCliName(["node", "ironclaw"])).toBe("ironclaw");
expect(resolveCliName(["node", "/usr/local/bin/openclaw"])).toBe("openclaw");
});
it("falls back to default name for unknown binaries", () => {
expect(resolveCliName(["node", "custom-cli"])).toBe(DEFAULT_CLI_NAME);
});
it("replaces CLI name in command prefixes while preserving package runner prefix", () => {
expect(replaceCliName("openclaw status", "ironclaw")).toBe("ironclaw status");
expect(replaceCliName("pnpm openclaw status", "ironclaw")).toBe("pnpm ironclaw status");
expect(replaceCliName("npx ironclaw status", "openclaw")).toBe("npx openclaw status");
});
it("keeps command unchanged when it does not start with a known CLI prefix", () => {
expect(replaceCliName("echo openclaw status", "ironclaw")).toBe("echo openclaw status");
expect(replaceCliName(" ", "openclaw")).toBe(" ");
});
});

116
src/cli/cli-utils.test.ts Normal file
View File

@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import { resolveOptionFromCommand, runCommandWithRuntime, withManager } from "./cli-utils.js";
describe("withManager", () => {
it("runs command and closes manager when manager lookup succeeds", async () => {
const manager = { id: "mgr-1" };
const run = vi.fn(async () => {});
const close = vi.fn(async () => {});
await withManager({
getManager: async () => ({ manager }),
onMissing: vi.fn(),
run,
close,
});
expect(run).toHaveBeenCalledWith(manager);
expect(close).toHaveBeenCalledWith(manager);
});
it("calls onMissing and skips run/close when manager is absent", async () => {
const onMissing = vi.fn();
const run = vi.fn(async () => {});
const close = vi.fn(async () => {});
await withManager({
getManager: async () => ({ manager: null, error: "missing manager" }),
onMissing,
run,
close,
});
expect(onMissing).toHaveBeenCalledWith("missing manager");
expect(run).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
});
it("reports close errors through onCloseError", async () => {
const manager = { id: "mgr-2" };
const closeError = new Error("close failed");
const onCloseError = vi.fn();
await withManager({
getManager: async () => ({ manager }),
onMissing: vi.fn(),
run: async () => {},
close: async () => {
throw closeError;
},
onCloseError,
});
expect(onCloseError).toHaveBeenCalledWith(closeError);
});
});
describe("runCommandWithRuntime", () => {
it("does nothing on successful action completion", async () => {
const runtime = {
error: vi.fn(),
exit: vi.fn(),
};
await runCommandWithRuntime(runtime, async () => {});
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("writes runtime error and exits with code 1 when action throws", async () => {
const runtime = {
error: vi.fn(),
exit: vi.fn(),
};
await runCommandWithRuntime(runtime, async () => {
throw new Error("boom");
});
expect(runtime.error).toHaveBeenCalledWith("Error: boom");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("delegates thrown errors to onError override when provided", async () => {
const runtime = {
error: vi.fn(),
exit: vi.fn(),
};
const onError = vi.fn();
await runCommandWithRuntime(
runtime,
async () => {
throw new Error("custom");
},
onError,
);
expect(onError).toHaveBeenCalledTimes(1);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
});
describe("resolveOptionFromCommand", () => {
it("resolves options from command chain nearest-first", () => {
const root = {
opts: () => ({ profile: "root", verbose: true }),
parent: undefined,
};
const child = {
opts: () => ({ profile: "child" }),
parent: root,
};
expect(resolveOptionFromCommand<string>(child as never, "profile")).toBe("child");
expect(resolveOptionFromCommand<boolean>(child as never, "verbose")).toBe(true);
expect(resolveOptionFromCommand<string>(child as never, "missing")).toBeUndefined();
});
});

23
src/cli/ports.test.ts Normal file
View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseLsofOutput } from "./ports.js";
describe("parseLsofOutput", () => {
it("parses listener records with pid and command", () => {
const output = ["p123", "cnode", "p456", "cpython"].join("\n");
expect(parseLsofOutput(output)).toEqual([
{ pid: 123, command: "node" },
{ pid: 456, command: "python" },
]);
});
it("ignores malformed pid records and keeps valid listeners", () => {
const output = ["pnot-a-number", "cnode", "p789", "cbun"].join("\n");
expect(parseLsofOutput(output)).toEqual([{ pid: 789, command: "bun" }]);
});
it("supports listeners without command metadata", () => {
expect(parseLsofOutput("p321")).toEqual([{ pid: 321 }]);
});
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isValidProfileName, normalizeProfileName } from "./profile-utils.js";
describe("profile-utils", () => {
it("accepts path-safe profile names and rejects unsafe values", () => {
expect(isValidProfileName("ironclaw")).toBe(true);
expect(isValidProfileName("Team_A-1")).toBe(true);
expect(isValidProfileName("")).toBe(false);
expect(isValidProfileName(" has-space ")).toBe(false);
expect(isValidProfileName("../escape")).toBe(false);
expect(isValidProfileName("slash/name")).toBe(false);
});
it("normalizes profile input and collapses default/invalid profiles to null", () => {
expect(normalizeProfileName(" dev ")).toBe("dev");
expect(normalizeProfileName("DEFAULT")).toBeNull();
expect(normalizeProfileName("")).toBeNull();
expect(normalizeProfileName("bad profile")).toBeNull();
expect(normalizeProfileName(undefined)).toBeNull();
});
});

82
src/cli/profile.test.ts Normal file
View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
describe("parseCliProfileArgs", () => {
it("returns default profile parsing when no args are provided", () => {
expect(parseCliProfileArgs(["node", "ironclaw"])).toEqual({
ok: true,
profile: null,
argv: ["node", "ironclaw"],
});
});
it("parses --profile and strips profile flags before command execution", () => {
expect(parseCliProfileArgs(["node", "ironclaw", "--profile", "dev", "chat"])).toEqual({
ok: true,
profile: "dev",
argv: ["node", "ironclaw", "chat"],
});
expect(parseCliProfileArgs(["node", "ironclaw", "--profile=team-a", "status"])).toEqual({
ok: true,
profile: "team-a",
argv: ["node", "ironclaw", "status"],
});
});
it("rejects missing, invalid, and conflicting profile inputs", () => {
expect(parseCliProfileArgs(["node", "ironclaw", "--profile"])).toEqual({
ok: false,
error: "--profile requires a value",
});
expect(parseCliProfileArgs(["node", "ironclaw", "--profile", "bad profile"])).toEqual({
ok: false,
error: 'Invalid --profile (use letters, numbers, "_", "-" only)',
});
expect(parseCliProfileArgs(["node", "ironclaw", "--dev", "--profile", "team-a"])).toEqual({
ok: false,
error: "Cannot combine --dev with --profile",
});
});
it("stops profile parsing once command path begins", () => {
expect(parseCliProfileArgs(["node", "ironclaw", "chat", "--profile", "dev"])).toEqual({
ok: true,
profile: null,
argv: ["node", "ironclaw", "chat", "--profile", "dev"],
});
});
});
describe("applyCliProfileEnv", () => {
it("fills profile defaults without overriding explicit state/config vars", () => {
const env: Record<string, string | undefined> = {};
applyCliProfileEnv({
profile: "team-a",
env,
homedir: () => "/tmp/home",
});
expect(env.OPENCLAW_PROFILE).toBe("team-a");
expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/home/.openclaw-team-a");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/tmp/home/.openclaw-team-a/openclaw.json");
});
it("respects explicit state/config paths and assigns dev gateway port when absent", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_STATE_DIR: "/custom/state",
OPENCLAW_CONFIG_PATH: "/custom/state/openclaw.json",
};
applyCliProfileEnv({
profile: "dev",
env,
homedir: () => "/tmp/home",
});
expect(env.OPENCLAW_STATE_DIR).toBe("/custom/state");
expect(env.OPENCLAW_CONFIG_PATH).toBe("/custom/state/openclaw.json");
expect(env.OPENCLAW_GATEWAY_PORT).toBe("19001");
});
});

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { shouldSkipRespawnForArgv } from "./respawn-policy.js";
describe("shouldSkipRespawnForArgv", () => {
it("skips respawn for help/version invocations", () => {
expect(shouldSkipRespawnForArgv(["node", "ironclaw", "--help"])).toBe(true);
expect(shouldSkipRespawnForArgv(["node", "ironclaw", "-V"])).toBe(true);
});
it("does not skip respawn for normal command execution", () => {
expect(shouldSkipRespawnForArgv(["node", "ironclaw", "chat", "send"])).toBe(false);
});
});