test(cli): add argv, cli-name, cli-utils, ports, profile, profile-utils, and respawn-policy tests
This commit is contained in:
parent
55a94523a7
commit
6d51543f39
93
src/cli/argv.test.ts
Normal file
93
src/cli/argv.test.ts
Normal 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
25
src/cli/cli-name.test.ts
Normal 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
116
src/cli/cli-utils.test.ts
Normal 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
23
src/cli/ports.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
21
src/cli/profile-utils.test.ts
Normal file
21
src/cli/profile-utils.test.ts
Normal 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
82
src/cli/profile.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
13
src/cli/respawn-policy.test.ts
Normal file
13
src/cli/respawn-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user