From 6d51543f391f362f07d6de88f8318955e826efa0 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Mon, 2 Mar 2026 18:31:56 -0800 Subject: [PATCH] test(cli): add argv, cli-name, cli-utils, ports, profile, profile-utils, and respawn-policy tests --- src/cli/argv.test.ts | 93 ++++++++++++++++++++++++++ src/cli/cli-name.test.ts | 25 +++++++ src/cli/cli-utils.test.ts | 116 +++++++++++++++++++++++++++++++++ src/cli/ports.test.ts | 23 +++++++ src/cli/profile-utils.test.ts | 21 ++++++ src/cli/profile.test.ts | 82 +++++++++++++++++++++++ src/cli/respawn-policy.test.ts | 13 ++++ 7 files changed, 373 insertions(+) create mode 100644 src/cli/argv.test.ts create mode 100644 src/cli/cli-name.test.ts create mode 100644 src/cli/cli-utils.test.ts create mode 100644 src/cli/ports.test.ts create mode 100644 src/cli/profile-utils.test.ts create mode 100644 src/cli/profile.test.ts create mode 100644 src/cli/respawn-policy.test.ts diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts new file mode 100644 index 00000000000..88051ebbee2 --- /dev/null +++ b/src/cli/argv.test.ts @@ -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); + }); +}); diff --git a/src/cli/cli-name.test.ts b/src/cli/cli-name.test.ts new file mode 100644 index 00000000000..e2cbfb845e0 --- /dev/null +++ b/src/cli/cli-name.test.ts @@ -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(" "); + }); +}); diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts new file mode 100644 index 00000000000..f7bba89029d --- /dev/null +++ b/src/cli/cli-utils.test.ts @@ -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(child as never, "profile")).toBe("child"); + expect(resolveOptionFromCommand(child as never, "verbose")).toBe(true); + expect(resolveOptionFromCommand(child as never, "missing")).toBeUndefined(); + }); +}); diff --git a/src/cli/ports.test.ts b/src/cli/ports.test.ts new file mode 100644 index 00000000000..6a212e21d68 --- /dev/null +++ b/src/cli/ports.test.ts @@ -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 }]); + }); +}); diff --git a/src/cli/profile-utils.test.ts b/src/cli/profile-utils.test.ts new file mode 100644 index 00000000000..f2a20e1e6a3 --- /dev/null +++ b/src/cli/profile-utils.test.ts @@ -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(); + }); +}); diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts new file mode 100644 index 00000000000..87b59780dc8 --- /dev/null +++ b/src/cli/profile.test.ts @@ -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 = {}; + 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 = { + 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"); + }); +}); diff --git a/src/cli/respawn-policy.test.ts b/src/cli/respawn-policy.test.ts new file mode 100644 index 00000000000..3d4fe399e52 --- /dev/null +++ b/src/cli/respawn-policy.test.ts @@ -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); + }); +});