mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
// Discord tests cover command deploy plugin behavior.
|
|
/* oxlint-disable typescript/unbound-method -- vitest mocks of RequestClient methods (createRest) intentionally expose vi.fn refs via `restA.get`/`.post`; not unbound class methods. */
|
|
import {
|
|
ApplicationCommandType,
|
|
type APIApplicationCommand,
|
|
type APIApplicationCommandOption,
|
|
} from "discord-api-types/v10";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import type { DiscordCommandDeployHashStore } from "../command-deploy-store.js";
|
|
import { commandsEqual } from "./command-comparison.js";
|
|
import { DiscordCommandDeployer } from "./command-deploy.js";
|
|
import { BaseCommand } from "./commands.js";
|
|
import type { RequestClient } from "./rest.js";
|
|
|
|
/**
|
|
* Regression tests for Discord slash-command reconcile/deploy equality.
|
|
*
|
|
* These protect against a class of bugs where Discord's server-side storage
|
|
* normalization causes our desired descriptor to re-compare unequal to the
|
|
* command Discord returns, which leads to a spurious `PATCH` on every
|
|
* gateway startup and, under the per-application rate limit, a cascade of
|
|
* `429` responses that silently drop some commands until the next restart.
|
|
*/
|
|
describe("commandsEqual", () => {
|
|
// Shape of what Discord returns on `GET /applications/{appId}/commands`.
|
|
// Fields like `version`, `dm_permission`, `nsfw`, `application_id` are
|
|
// always present on the server side but absent from our locally-serialized
|
|
// desired descriptors — they must therefore be ignored by the comparator.
|
|
function currentFromDiscord(
|
|
overrides: Partial<APIApplicationCommand> = {},
|
|
): APIApplicationCommand {
|
|
return {
|
|
id: "cmd-1",
|
|
application_id: "app",
|
|
type: 1,
|
|
name: "ping",
|
|
description: "ping the bot",
|
|
version: "v1",
|
|
default_member_permissions: null,
|
|
dm_permission: true,
|
|
nsfw: false,
|
|
...overrides,
|
|
} as APIApplicationCommand;
|
|
}
|
|
|
|
// Shape of what a `BaseCommand.serialize()` produces locally.
|
|
function desiredFromLocal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
name: "ping",
|
|
description: "ping the bot",
|
|
type: 1,
|
|
default_member_permissions: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("ignores Discord server-side default fields (dm_permission, nsfw, version, id, application_id)", () => {
|
|
expect(commandsEqual(currentFromDiscord(), desiredFromLocal())).toBe(true);
|
|
});
|
|
|
|
test("ignores Discord null localization maps when local command omits them", () => {
|
|
const current = currentFromDiscord({
|
|
name_localizations: null,
|
|
description_localizations: null,
|
|
options: [
|
|
{
|
|
type: 3,
|
|
name: "name",
|
|
name_localizations: null,
|
|
description: "Skill name",
|
|
description_localizations: null,
|
|
} as APIApplicationCommandOption,
|
|
],
|
|
});
|
|
const desired = desiredFromLocal({
|
|
options: [{ name: "name", description: "Skill name", type: 3 }],
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("treats `required: false` on an option as equivalent to field absent", () => {
|
|
const current = currentFromDiscord({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [
|
|
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
|
|
],
|
|
});
|
|
const desired = desiredFromLocal({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [{ name: "name", description: "Skill name", type: 3, required: false }],
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("keeps `required: true` meaningful", () => {
|
|
const current = currentFromDiscord({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [
|
|
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
|
|
],
|
|
});
|
|
const desired = desiredFromLocal({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [{ name: "name", description: "Skill name", type: 3, required: true }],
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(false);
|
|
});
|
|
|
|
test("treats CJK descriptions with `\\n` separators as equal to Discord's collapsed form", () => {
|
|
// Discord server collapses whitespace between CJK characters when storing
|
|
// command descriptions, so our local desired `\n`-separated description
|
|
// round-trips back without the newline.
|
|
const current = currentFromDiscord({
|
|
description:
|
|
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。支持直接输入 URL。",
|
|
});
|
|
const desired = desiredFromLocal({
|
|
description:
|
|
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。\n支持直接输入 URL。",
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("treats mixed CJK/ASCII descriptions with consecutive whitespace as equal to collapsed form", () => {
|
|
const current = currentFromDiscord({
|
|
description: "联网操作策略框架。访问需登录站点时触发。",
|
|
});
|
|
const desired = desiredFromLocal({
|
|
description: "联网操作策略框架。\n\n访问需登录站点时触发。",
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("treats localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
|
|
const current = currentFromDiscord({
|
|
description_localizations: {
|
|
"zh-CN": "第一行说明。第二行说明。",
|
|
},
|
|
});
|
|
const desired = desiredFromLocal({
|
|
description_localizations: {
|
|
"zh-CN": "第一行说明。\n第二行说明。",
|
|
},
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("treats option localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
|
|
const current = currentFromDiscord({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [
|
|
{
|
|
type: 3,
|
|
name: "name",
|
|
description: "Skill name",
|
|
description_localizations: { "zh-CN": "技能名称。直接输入。" },
|
|
} as APIApplicationCommandOption,
|
|
],
|
|
});
|
|
const desired = desiredFromLocal({
|
|
name: "skill",
|
|
description: "Run a skill.",
|
|
options: [
|
|
{
|
|
name: "name",
|
|
description: "Skill name",
|
|
description_localizations: { "zh-CN": "技能名称。\n直接输入。" },
|
|
type: 3,
|
|
},
|
|
],
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
|
|
test("keeps localized substantive description differences meaningful", () => {
|
|
const current = currentFromDiscord({
|
|
description_localizations: {
|
|
"zh-CN": "旧说明",
|
|
},
|
|
});
|
|
const desired = desiredFromLocal({
|
|
description_localizations: {
|
|
"zh-CN": "新说明",
|
|
},
|
|
});
|
|
expect(commandsEqual(current, desired)).toBe(false);
|
|
});
|
|
|
|
test("keeps substantive description differences meaningful", () => {
|
|
const current = currentFromDiscord({ description: "old text" });
|
|
const desired = desiredFromLocal({ description: "new text" });
|
|
expect(commandsEqual(current, desired)).toBe(false);
|
|
});
|
|
|
|
test("treats ASCII `\\n` as whitespace and collapses it to space for comparison", () => {
|
|
// For pure ASCII descriptions, `\n` collapses to a single space so
|
|
// "ping the bot" == "ping\nthe bot". The contract is: whitespace
|
|
// differences (ASCII or CJK-boundary) are never substantive after
|
|
// Discord's server normalization.
|
|
const current = currentFromDiscord({ description: "ping the bot" });
|
|
const desired = desiredFromLocal({ description: "ping\nthe bot" });
|
|
expect(commandsEqual(current, desired)).toBe(true);
|
|
});
|
|
});
|
|
/**
|
|
* Regression for #77359: persisted hashes are scoped by Discord application id.
|
|
* Identical command sets for separate bots must never suppress each other's deploy.
|
|
*/
|
|
describe("DiscordCommandDeployer SQLite cache", () => {
|
|
class StaticCommand extends BaseCommand {
|
|
readonly commandKind = "leaf";
|
|
name: string;
|
|
override description = "ping the bot";
|
|
type = ApplicationCommandType.ChatInput;
|
|
|
|
constructor(name: string) {
|
|
super();
|
|
this.name = name;
|
|
}
|
|
|
|
serializeOptions() {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function createRest(): RequestClient {
|
|
return {
|
|
get: vi.fn(async () => []),
|
|
post: vi.fn(async () => undefined),
|
|
patch: vi.fn(async () => undefined),
|
|
put: vi.fn(async () => undefined),
|
|
delete: vi.fn(async () => undefined),
|
|
} as unknown as RequestClient;
|
|
}
|
|
|
|
function createHashStore(initial: Record<string, string> = {}): {
|
|
rows: Map<string, string>;
|
|
store: DiscordCommandDeployHashStore;
|
|
} {
|
|
const rows = new Map(Object.entries(initial));
|
|
return {
|
|
rows,
|
|
store: {
|
|
lookup: vi.fn(async (key: string) => rows.get(key)),
|
|
register: vi.fn(async (key: string, value: string) => {
|
|
rows.set(key, value);
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
test("two applications with identical command sets each reconcile their own application", async () => {
|
|
const { store, rows } = createHashStore();
|
|
const commands = [new StaticCommand("ping")];
|
|
const restA = createRest();
|
|
const restB = createRest();
|
|
|
|
await Promise.all([
|
|
new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands,
|
|
hashStore: store,
|
|
rest: () => restA,
|
|
}).deploy({ mode: "reconcile" }),
|
|
new DiscordCommandDeployer({
|
|
clientId: "app-secondary",
|
|
commands,
|
|
hashStore: store,
|
|
rest: () => restB,
|
|
}).deploy({ mode: "reconcile" }),
|
|
]);
|
|
|
|
expect(restA.get).toHaveBeenCalledTimes(1);
|
|
expect(restA.post).toHaveBeenCalledTimes(1);
|
|
expect(restB.get).toHaveBeenCalledTimes(1);
|
|
expect(restB.post).toHaveBeenCalledTimes(1);
|
|
expect([...rows.keys()].toSorted()).toEqual([
|
|
"app:app-default:global:reconcile",
|
|
"app:app-secondary:global:reconcile",
|
|
]);
|
|
});
|
|
|
|
test("skips unchanged command deploys across deployer restarts", async () => {
|
|
const { store } = createHashStore();
|
|
const commands = [new StaticCommand("ping")];
|
|
const firstRest = createRest();
|
|
|
|
await new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands,
|
|
hashStore: store,
|
|
rest: () => firstRest,
|
|
}).deploy({ mode: "reconcile" });
|
|
|
|
const secondRest = createRest();
|
|
await new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands,
|
|
hashStore: store,
|
|
rest: () => secondRest,
|
|
}).deploy({ mode: "reconcile" });
|
|
|
|
expect(firstRest.get).toHaveBeenCalledTimes(1);
|
|
expect(firstRest.post).toHaveBeenCalledTimes(1);
|
|
expect(secondRest.get).not.toHaveBeenCalled();
|
|
expect(secondRest.post).not.toHaveBeenCalled();
|
|
expect(store.lookup).toHaveBeenLastCalledWith("app:app-default:global:reconcile");
|
|
});
|
|
|
|
test("loads only the exact scoped key needed by a deployment", async () => {
|
|
const { store } = createHashStore({
|
|
"app:other:global:reconcile": "unrelated",
|
|
});
|
|
const rest = createRest();
|
|
|
|
await new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands: [new StaticCommand("ping")],
|
|
hashStore: store,
|
|
rest: () => rest,
|
|
}).deploy({ mode: "reconcile" });
|
|
|
|
expect(store.lookup).toHaveBeenCalledOnce();
|
|
expect(store.lookup).toHaveBeenCalledWith("app:app-default:global:reconcile");
|
|
});
|
|
|
|
test("treats a SQLite lookup failure as a cache miss and repairs the row", async () => {
|
|
const register = vi.fn(async () => undefined);
|
|
const store: DiscordCommandDeployHashStore = {
|
|
lookup: vi.fn(async () => {
|
|
throw new Error("database unavailable");
|
|
}),
|
|
register,
|
|
};
|
|
const rest = createRest();
|
|
|
|
await new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands: [new StaticCommand("ping")],
|
|
hashStore: store,
|
|
rest: () => rest,
|
|
}).deploy({ mode: "reconcile" });
|
|
|
|
expect(rest.get).toHaveBeenCalledTimes(1);
|
|
expect(rest.post).toHaveBeenCalledTimes(1);
|
|
expect(register).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("keeps successful deploys successful when SQLite persistence fails", async () => {
|
|
const store: DiscordCommandDeployHashStore = {
|
|
lookup: vi.fn(async () => undefined),
|
|
register: vi.fn(async () => {
|
|
throw new Error("database unavailable");
|
|
}),
|
|
};
|
|
const rest = createRest();
|
|
const deployer = new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands: [new StaticCommand("ping")],
|
|
hashStore: store,
|
|
rest: () => rest,
|
|
});
|
|
|
|
await deployer.deploy({ mode: "reconcile" });
|
|
await deployer.deploy({ mode: "reconcile" });
|
|
|
|
expect(rest.get).toHaveBeenCalledTimes(1);
|
|
expect(rest.post).toHaveBeenCalledTimes(1);
|
|
expect(store.register).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("does not persist a hash when Discord deployment fails", async () => {
|
|
const { store } = createHashStore();
|
|
const rest = createRest();
|
|
rest.post = vi.fn(async () => {
|
|
throw new Error("Discord rejected deploy");
|
|
}) as RequestClient["post"];
|
|
|
|
await expect(
|
|
new DiscordCommandDeployer({
|
|
clientId: "app-default",
|
|
commands: [new StaticCommand("ping")],
|
|
hashStore: store,
|
|
rest: () => rest,
|
|
}).deploy({ mode: "reconcile" }),
|
|
).rejects.toThrow("Discord rejected deploy");
|
|
|
|
expect(store.register).not.toHaveBeenCalled();
|
|
});
|
|
});
|