Files
elizaos--eliza/plugins/plugin-discord/__tests__/slash-command-registration-scope.test.ts
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

284 lines
8.5 KiB
TypeScript

/**
* Exercises the real Discord service registration method against an in-memory
* Discord API boundary so global and guild command scopes cannot overlap.
*/
import type { IAgentRuntime } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { DiscordService } from "../service";
import type { DiscordSlashCommand } from "../types";
const AGENT_ID = "11111111-1111-1111-1111-111111111111";
function command(
name: string,
scope: { guildOnly?: boolean; guildIds?: string[] } = {},
): DiscordSlashCommand {
return {
name,
description: `${name} command`,
...scope,
execute: vi.fn(async () => undefined),
} as unknown as DiscordSlashCommand;
}
function makeService() {
const globalAndGuildSet = vi.fn(async () => undefined);
const targetedCreate = vi.fn(async () => undefined);
const targetedFetch = vi.fn(async () => ({ find: () => undefined }));
const guild = {
id: "guild-a",
name: "Guild A",
fetch: vi.fn(async () => ({
commands: { fetch: targetedFetch, create: targetedCreate },
})),
};
const client = {
application: { commands: { set: globalAndGuildSet } },
guilds: { cache: new Map([[guild.id, guild]]) },
};
const state = {
accountId: "default",
clientReadyPromise: Promise.resolve(),
client,
};
const runtime = {
agentId: AGENT_ID,
getSetting: vi.fn(() => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
} as unknown as IAgentRuntime;
const service = Object.assign(Object.create(DiscordService.prototype), {
runtime,
slashCommands: [],
allowAllSlashCommands: new Set<string>(),
commandRegistrationQueue: Promise.resolve(),
requireAccountState: () => state,
}) as DiscordService;
return { globalAndGuildSet, service, targetedCreate };
}
describe("Discord slash-command registration scopes", () => {
// Regression (#deadlock): the account ready promise resolves only AFTER
// onReady returns, but onReady itself awaits this registration — waiting on
// the promise from that path hung forever and no command set ever reached
// Discord (live: registered commands frozen at a 2-day-old snapshot). With
// a ready client the push must proceed without touching the promise.
it("pushes commands while the ready promise is still pending when the client is already ready", async () => {
const { globalAndGuildSet, service } = makeService();
const state = service.requireAccountState() as unknown as {
clientReadyPromise: Promise<void>;
client: { isReady?: () => boolean };
};
state.clientReadyPromise = new Promise<void>(() => {}); // never resolves
state.client.isReady = () => true;
await service.registerSlashCommands([command("global")]);
expect(globalAndGuildSet).toHaveBeenCalled();
});
it("keeps global commands out of guild scope while retaining guild-only and targeted commands", async () => {
const { globalAndGuildSet, service, targetedCreate } = makeService();
await service.registerSlashCommands([
command("global"),
command("guild-only", { guildOnly: true }),
command("targeted", { guildIds: ["guild-a"] }),
]);
expect(globalAndGuildSet).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([expect.objectContaining({ name: "global" })]),
);
expect(globalAndGuildSet.mock.calls[0]?.[0]).toHaveLength(1);
expect(globalAndGuildSet).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ name: "guild-only" })],
"guild-a",
);
expect(targetedCreate).toHaveBeenCalledWith(
expect.objectContaining({ name: "targeted" }),
);
});
it("writes an empty guild scope to clear stale copies of global commands", async () => {
const { globalAndGuildSet, service } = makeService();
await service.registerSlashCommands([command("global")]);
expect(globalAndGuildSet).toHaveBeenNthCalledWith(1, [
expect.objectContaining({ name: "global" }),
]);
expect(globalAndGuildSet).toHaveBeenNthCalledWith(2, [], "guild-a");
});
it("surfaces a failed guild write via reportError and still syncs the other guilds", async () => {
const guildSet = vi.fn(async (_cmds: unknown, guildId?: string) => {
if (guildId === "guild-a") throw new Error("50001: Missing Access");
return undefined;
});
const reportError = vi.fn();
const guildB = {
id: "guild-b",
name: "Guild B",
fetch: vi.fn(async () => ({
commands: { fetch: vi.fn(), create: vi.fn() },
})),
};
const guildA = { ...guildB, id: "guild-a", name: "Guild A" };
const client = {
application: { commands: { set: guildSet } },
guilds: {
cache: new Map([
["guild-a", guildA],
["guild-b", guildB],
]),
},
};
const state = {
accountId: "default",
clientReadyPromise: Promise.resolve(),
client,
};
const runtime = {
agentId: AGENT_ID,
getSetting: vi.fn(() => undefined),
reportError,
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
} as unknown as IAgentRuntime;
const service = Object.assign(Object.create(DiscordService.prototype), {
runtime,
slashCommands: [],
allowAllSlashCommands: new Set<string>(),
commandRegistrationQueue: Promise.resolve(),
requireAccountState: () => state,
}) as DiscordService;
await service.registerSlashCommands([command("global")]);
// Both guilds were attempted — one guild's Missing Access must not
// abort the fan-out.
const guildScopeCalls = guildSet.mock.calls.filter(
(call) => typeof call[1] === "string",
);
expect(guildScopeCalls.map((call) => call[1]).sort()).toEqual([
"guild-a",
"guild-b",
]);
// The partial sync is observable, not a healthy-looking startup.
expect(reportError).toHaveBeenCalledWith(
"DiscordService.commandSync",
expect.any(Error),
expect.objectContaining({ guildId: "guild-a" }),
);
});
});
describe("handleGuildCreate registration scopes", () => {
async function runGuildCreate(opts: {
commands: DiscordSlashCommand[];
setImpl?: (cmds: unknown, guildId?: string) => Promise<unknown>;
}) {
const { handleGuildCreate } = await import("../discord-commands");
const guildScopeSet = vi.fn(opts.setImpl ?? (async () => undefined));
const reportError = vi.fn();
const fullGuild = {
id: "guild-a",
name: "Guild A",
ownerId: "owner-1",
channels: { cache: new Map() },
members: { cache: new Map() },
};
const guild = {
id: "guild-a",
name: "Guild A",
fetch: vi.fn(async () => fullGuild),
};
const runtime = {
agentId: AGENT_ID,
getSetting: vi.fn(() => undefined),
reportError,
emitEvent: vi.fn(async () => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
};
const service = {
runtime,
accountId: "default",
slashCommands: opts.commands,
client: { application: { commands: { set: guildScopeSet } } },
};
await handleGuildCreate(service as never, guild as never);
return { guildScopeSet, reportError, runtime };
}
it("registers guild-only and targeted commands, never globals, on guild join", async () => {
const { guildScopeSet, runtime } = await runGuildCreate({
commands: [
command("global"),
command("guild-only", { guildOnly: true }),
command("targeted", { guildIds: ["guild-a"] }),
],
});
expect(guildScopeSet).toHaveBeenCalledWith(
[
expect.objectContaining({ name: "guild-only" }),
expect.objectContaining({ name: "targeted" }),
],
"guild-a",
);
// Guild onboarding continued: the joined world is announced.
expect(runtime.emitEvent).toHaveBeenCalled();
});
it("writes an empty guild scope on join to clear stale global copies", async () => {
const { guildScopeSet } = await runGuildCreate({
commands: [command("global")],
});
expect(guildScopeSet).toHaveBeenCalledWith([], "guild-a");
});
it("keeps a targeted command for another guild out of this guild's scope", async () => {
const { guildScopeSet } = await runGuildCreate({
commands: [command("elsewhere", { guildIds: ["guild-z"] })],
});
expect(guildScopeSet).toHaveBeenCalledWith([], "guild-a");
});
it("surfaces a failed join-sync via reportError and still finishes onboarding", async () => {
const { reportError, runtime } = await runGuildCreate({
commands: [command("guild-only", { guildOnly: true })],
setImpl: async () => {
throw new Error("50001: Missing Access");
},
});
expect(reportError).toHaveBeenCalledWith(
"DiscordService.guildCreateCommandSync",
expect.any(Error),
expect.objectContaining({ guildId: "guild-a" }),
);
// The world-joined events still fire — a failed command write does not
// abort guild onboarding.
expect(runtime.emitEvent).toHaveBeenCalled();
});
});