mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 16:13:00 +00:00
refactor: share commands test helpers
This commit is contained in:
@@ -72,18 +72,19 @@ const mockChatCommands: ChatCommandDefinition[] = [
|
||||
];
|
||||
|
||||
const mockPluginSpecs = [{ name: "tts", description: "Text to speech", acceptsArgs: false }];
|
||||
type RuntimeCommandRegistration = {
|
||||
pluginId: string;
|
||||
command: {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs?: boolean;
|
||||
nativeNames?: Record<string, string>;
|
||||
channels?: string[];
|
||||
};
|
||||
};
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
gatewayRegistry: null as null | {
|
||||
commands: Array<{
|
||||
pluginId: string;
|
||||
command: {
|
||||
name: string;
|
||||
description: string;
|
||||
acceptsArgs?: boolean;
|
||||
nativeNames?: Record<string, string>;
|
||||
channels?: string[];
|
||||
};
|
||||
}>;
|
||||
commands: RuntimeCommandRegistration[];
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -177,46 +178,10 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getLoadedChannelPlugin: vi.fn((provider: string) => {
|
||||
if (provider === "discord") {
|
||||
return {
|
||||
commands: {
|
||||
resolveNativeCommandName: ({
|
||||
commandKey,
|
||||
defaultName,
|
||||
}: {
|
||||
commandKey: string;
|
||||
defaultName: string;
|
||||
}) => {
|
||||
if (commandKey === "model") {
|
||||
return "set_model";
|
||||
}
|
||||
return defaultName;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
return provider === "discord" ? createDiscordChannelPlugin() : undefined;
|
||||
}),
|
||||
getChannelPlugin: vi.fn((provider: string) => {
|
||||
if (provider === "discord") {
|
||||
return {
|
||||
commands: {
|
||||
resolveNativeCommandName: ({
|
||||
commandKey,
|
||||
defaultName,
|
||||
}: {
|
||||
commandKey: string;
|
||||
defaultName: string;
|
||||
}) => {
|
||||
if (commandKey === "model") {
|
||||
return "set_model";
|
||||
}
|
||||
return defaultName;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
return provider === "discord" ? createDiscordChannelPlugin() : undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -231,6 +196,20 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/schema.js";
|
||||
import { commandsHandlers, buildCommandsListResult } from "./commands.js";
|
||||
|
||||
function createDiscordChannelPlugin() {
|
||||
return {
|
||||
commands: {
|
||||
resolveNativeCommandName: ({
|
||||
commandKey,
|
||||
defaultName,
|
||||
}: {
|
||||
commandKey: string;
|
||||
defaultName: string;
|
||||
}) => (commandKey === "model" ? "set_model" : defaultName),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function callHandler(params: Record<string, unknown> = {}) {
|
||||
let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
|
||||
const respond = (ok: boolean, payload?: unknown, error?: unknown) => {
|
||||
@@ -250,6 +229,20 @@ function callHandler(params: Record<string, unknown> = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
type ListedCommand = Record<string, unknown> & {
|
||||
name: string;
|
||||
source: string;
|
||||
scope?: string;
|
||||
textAliases?: string[];
|
||||
nativeName?: string;
|
||||
args?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function listCommands(params: Record<string, unknown> = {}): ListedCommand[] {
|
||||
const { payload } = callHandler(params);
|
||||
return (payload as { commands: ListedCommand[] }).commands;
|
||||
}
|
||||
|
||||
function requireCommand<T extends { name: string }>(commands: T[], name: string): T {
|
||||
const command = commands.find((entry) => entry.name === name);
|
||||
if (!command) {
|
||||
@@ -268,6 +261,36 @@ function collectBuiltinNames(commands: readonly { name: string; source: string }
|
||||
return names;
|
||||
}
|
||||
|
||||
function pluginCommand(params: Record<string, unknown> = {}): ListedCommand | undefined {
|
||||
return listCommands(params).find((command) => command.source === "plugin");
|
||||
}
|
||||
|
||||
function setGatewayRegistry(commands: RuntimeCommandRegistration[]): void {
|
||||
runtimeMocks.gatewayRegistry = { commands };
|
||||
}
|
||||
|
||||
function providerFilteredPluginRegistrations(params: { nativeName?: string } = {}) {
|
||||
return [
|
||||
{
|
||||
pluginId: "android-only",
|
||||
command: {
|
||||
name: "android_only",
|
||||
description: "Android-only command",
|
||||
channels: ["android"],
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "phone-control",
|
||||
command: {
|
||||
name: "phone",
|
||||
description: "Control paired phones",
|
||||
...(params.nativeName ? { nativeNames: { discord: params.nativeName } } : {}),
|
||||
channels: ["discord"],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("commands.list handler", () => {
|
||||
beforeEach(() => {
|
||||
runtimeMocks.gatewayRegistry = null;
|
||||
@@ -283,12 +306,7 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("maps native commands with category, scope, and args", () => {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as {
|
||||
commands: Array<
|
||||
Record<string, unknown> & { name: string; args?: Array<Record<string, unknown>> }
|
||||
>;
|
||||
};
|
||||
const commands = listCommands();
|
||||
const model = requireCommand(commands, "model");
|
||||
expect(model.name).toBe("model");
|
||||
expect(model.nativeName).toBe("model");
|
||||
@@ -307,8 +325,7 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("exposes per-command scope", () => {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<{ name: string; scope: string }> };
|
||||
const commands = listCommands();
|
||||
expect(requireCommand(commands, "model").scope).toBe("both");
|
||||
expect(requireCommand(commands, "commands").scope).toBe("text");
|
||||
expect(requireCommand(commands, "debug_prompt").scope).toBe("native");
|
||||
@@ -316,12 +333,7 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("skips args when acceptsArgs is false", () => {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
const debug = requireCommand(
|
||||
commands as Array<Record<string, unknown> & { name: string }>,
|
||||
"debug_prompt",
|
||||
);
|
||||
const debug = requireCommand(listCommands(), "debug_prompt");
|
||||
expect(debug.args).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -330,12 +342,7 @@ describe("commands.list handler", () => {
|
||||
const saved = debugCmd.acceptsArgs;
|
||||
debugCmd.acceptsArgs = true;
|
||||
try {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
const debug = requireCommand(
|
||||
commands as Array<Record<string, unknown> & { name: string }>,
|
||||
"debug_prompt",
|
||||
);
|
||||
const debug = requireCommand(listCommands(), "debug_prompt");
|
||||
const args = debug.args as Array<Record<string, unknown>>;
|
||||
expect(args[0].dynamic).toBe(true);
|
||||
expect(args[0].choices).toBeUndefined();
|
||||
@@ -345,8 +352,7 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("identifies skill commands by source", () => {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
const commands = listCommands();
|
||||
const skill = commands.find((c) => c.name === "code_review");
|
||||
expect(skill?.source).toBe("skill");
|
||||
expect(skill?.category).toBe("tools");
|
||||
@@ -354,16 +360,14 @@ describe("commands.list handler", () => {
|
||||
|
||||
it("always includes plugin commands regardless of scope filter", () => {
|
||||
for (const scope of ["native", "text", "both"] as const) {
|
||||
const { payload } = callHandler({ scope });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const commands = listCommands({ scope });
|
||||
const sources = commands.map((command) => command.source);
|
||||
expect(sources).toContain("plugin");
|
||||
}
|
||||
});
|
||||
|
||||
it("filters built-in commands by scope=native (excludes text-only)", () => {
|
||||
const { payload } = callHandler({ scope: "native" });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const commands = listCommands({ scope: "native" });
|
||||
const builtinNames = collectBuiltinNames(commands);
|
||||
expect(builtinNames).not.toContain("commands");
|
||||
expect(builtinNames).toContain("model");
|
||||
@@ -371,51 +375,38 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("filters built-in commands by scope=text (excludes native-only)", () => {
|
||||
const { payload } = callHandler({ scope: "text" });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const commands = listCommands({ scope: "text" });
|
||||
const builtinNames = collectBuiltinNames(commands);
|
||||
expect(builtinNames).toContain("commands");
|
||||
expect(builtinNames).not.toContain("debug_prompt");
|
||||
});
|
||||
|
||||
it("resolves provider-specific native names", () => {
|
||||
const { payload } = callHandler({ provider: "discord" });
|
||||
const { commands } = payload as { commands: Array<{ name: string }> };
|
||||
const commands = listCommands({ provider: "discord" });
|
||||
expect(requireCommand(commands, "set_model").name).toBe("set_model");
|
||||
expect(commands.find((c) => c.name === "model")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes mixed-case provider", () => {
|
||||
const { payload } = callHandler({ provider: "Discord" });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const commands = listCommands({ provider: "Discord" });
|
||||
expect(requireCommand(commands, "set_model").name).toBe("set_model");
|
||||
const plugin = commands.find((c) => c.source === "plugin");
|
||||
expect(plugin?.name).toBe("discord_tts");
|
||||
});
|
||||
|
||||
it("uses default names without provider", () => {
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<{ name: string }> };
|
||||
const commands = listCommands();
|
||||
expect(requireCommand(commands, "model").name).toBe("model");
|
||||
expect(commands.find((c) => c.name === "set_model")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits plugin commands when provider lacks nativeCommandsAutoEnabled", () => {
|
||||
const { payload } = callHandler({ provider: "whatsapp" });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const commands = listCommands({ provider: "whatsapp" });
|
||||
expect(commands.some((c) => c.source === "plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses text-surface names when scope=text even with provider-native aliases", () => {
|
||||
const { payload } = callHandler({ provider: "discord", scope: "text" });
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
nativeName?: string;
|
||||
textAliases?: string[];
|
||||
source: string;
|
||||
}>;
|
||||
};
|
||||
const commands = listCommands({ provider: "discord", scope: "text" });
|
||||
const model = commands.find((c) => c.source === "native" && c.name === "model");
|
||||
expect(model?.name).toBe("model");
|
||||
expect(model?.nativeName).toBe("set_model");
|
||||
@@ -424,61 +415,32 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("keeps plugin text commands visible for scope=text even without native provider support", () => {
|
||||
const { payload } = callHandler({ provider: "whatsapp", scope: "text" });
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
source: string;
|
||||
textAliases?: string[];
|
||||
nativeName?: string;
|
||||
}>;
|
||||
};
|
||||
const plugin = commands.find((c) => c.source === "plugin");
|
||||
const plugin = pluginCommand({ provider: "whatsapp", scope: "text" });
|
||||
expect(plugin?.name).toBe("tts");
|
||||
expect(plugin?.textAliases).toEqual(["/tts"]);
|
||||
expect(plugin?.nativeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps plugin text names while exposing provider-native aliases for scope=text", () => {
|
||||
const { payload } = callHandler({ provider: "discord", scope: "text" });
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
source: string;
|
||||
textAliases?: string[];
|
||||
nativeName?: string;
|
||||
}>;
|
||||
};
|
||||
const plugin = commands.find((c) => c.source === "plugin");
|
||||
const plugin = pluginCommand({ provider: "discord", scope: "text" });
|
||||
expect(plugin?.name).toBe("tts");
|
||||
expect(plugin?.nativeName).toBe("discord_tts");
|
||||
expect(plugin?.textAliases).toEqual(["/tts"]);
|
||||
});
|
||||
|
||||
it("reads plugin commands from the gateway registry before the global command table", () => {
|
||||
runtimeMocks.gatewayRegistry = {
|
||||
commands: [
|
||||
{
|
||||
pluginId: "phone-control",
|
||||
command: {
|
||||
name: " phone ",
|
||||
description: " Control paired phones ",
|
||||
acceptsArgs: true,
|
||||
},
|
||||
setGatewayRegistry([
|
||||
{
|
||||
pluginId: "phone-control",
|
||||
command: {
|
||||
name: " phone ",
|
||||
description: " Control paired phones ",
|
||||
acceptsArgs: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
textAliases?: string[];
|
||||
acceptsArgs?: boolean;
|
||||
}>;
|
||||
};
|
||||
const commands = listCommands();
|
||||
const phone = commands.find((c) => c.source === "plugin");
|
||||
|
||||
expect(phone?.name).toBe("phone");
|
||||
@@ -489,39 +451,10 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("keeps provider-filtered native plugin names paired with their text aliases", () => {
|
||||
runtimeMocks.gatewayRegistry = {
|
||||
commands: [
|
||||
{
|
||||
pluginId: "android-only",
|
||||
command: {
|
||||
name: "android_only",
|
||||
description: "Android-only command",
|
||||
channels: ["android"],
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "phone-control",
|
||||
command: {
|
||||
name: "phone",
|
||||
description: "Control paired phones",
|
||||
acceptsArgs: true,
|
||||
channels: ["discord"],
|
||||
nativeNames: { discord: "discord_phone" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
setGatewayRegistry(providerFilteredPluginRegistrations({ nativeName: "discord_phone" }));
|
||||
|
||||
const { payload } = callHandler({ provider: "discord" });
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
source: string;
|
||||
textAliases?: string[];
|
||||
nativeName?: string;
|
||||
}>;
|
||||
};
|
||||
const plugin = commands.find((c) => c.source === "plugin");
|
||||
const commands = listCommands({ provider: "discord" });
|
||||
const plugin = pluginCommand({ provider: "discord" });
|
||||
|
||||
expect(plugin?.name).toBe("discord_phone");
|
||||
expect(plugin?.nativeName).toBe("discord_phone");
|
||||
@@ -532,35 +465,9 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("filters provider-incompatible plugin commands from the text surface", () => {
|
||||
runtimeMocks.gatewayRegistry = {
|
||||
commands: [
|
||||
{
|
||||
pluginId: "android-only",
|
||||
command: {
|
||||
name: "android_only",
|
||||
description: "Android-only command",
|
||||
channels: ["android"],
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "phone-control",
|
||||
command: {
|
||||
name: "phone",
|
||||
description: "Control paired phones",
|
||||
channels: ["discord"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
setGatewayRegistry(providerFilteredPluginRegistrations());
|
||||
|
||||
const { payload } = callHandler({ provider: "discord", scope: "text" });
|
||||
const { commands } = payload as {
|
||||
commands: Array<{
|
||||
name: string;
|
||||
source: string;
|
||||
textAliases?: string[];
|
||||
}>;
|
||||
};
|
||||
const commands = listCommands({ provider: "discord", scope: "text" });
|
||||
|
||||
expect(
|
||||
commands.find((c) => c.source === "plugin" && c.name === "android_only"),
|
||||
@@ -569,19 +476,12 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
|
||||
it("returns provider-specific plugin command names", () => {
|
||||
const { payload } = callHandler({ provider: "discord" });
|
||||
const { commands } = payload as { commands: Array<{ name: string; source: string }> };
|
||||
const plugin = commands.find((c) => c.source === "plugin");
|
||||
const plugin = pluginCommand({ provider: "discord" });
|
||||
expect(plugin?.name).toBe("discord_tts");
|
||||
});
|
||||
|
||||
it("excludes args when includeArgs=false", () => {
|
||||
const { payload } = callHandler({ includeArgs: false });
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
const model = requireCommand(
|
||||
commands as Array<Record<string, unknown> & { name: string }>,
|
||||
"model",
|
||||
);
|
||||
const model = requireCommand(listCommands({ includeArgs: false }), "model");
|
||||
expect(model.args).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -619,11 +519,10 @@ describe("commands.list handler", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const { payload } = callHandler();
|
||||
const { commands } = payload as { commands: Array<Record<string, unknown>> };
|
||||
const commands = listCommands();
|
||||
expect(commands).toHaveLength(COMMAND_LIST_MAX_ITEMS);
|
||||
const first = commands[0];
|
||||
expect((first.name as string).length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH);
|
||||
expect(first.name.length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH);
|
||||
expect((first.description as string).length).toBeLessThanOrEqual(
|
||||
COMMAND_DESCRIPTION_MAX_LENGTH,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user