Files
elizaos--eliza/plugins/plugin-discord/__tests__/interactions.test.ts
T
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

214 lines
7.8 KiB
TypeScript

/**
* Unit tests for `renderDiscordInteractions` — mapping neutral
* `InteractionBlock` output to Discord action-row/button components.
* Pure-function assertions.
*/
import type { Content } from "@elizaos/core";
import {
buildInteractionUrlResolver,
decodeCallback,
FORM_FREE_TEXT_INVITE,
} from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
buildDiscordReplyPayload,
renderDiscordInteractions,
} from "../interactions";
describe("renderDiscordInteractions", () => {
it("passes plain replies through with no components", () => {
const out = renderDiscordInteractions({
text: "a normal reply",
} as Content);
expect(out.text).toBe("a normal reply");
expect(out.components).toHaveLength(0);
});
it("renders a choice block as a button action row and strips the marker", () => {
const content: Content = {
text: "Approve?\n[CHOICE:approve id=c1]\nyes=Yes, ship it\nno=Cancel\n[/CHOICE]",
};
const out = renderDiscordInteractions(content);
expect(out.text).toBe("Approve?");
expect(out.components).toHaveLength(1);
const row = out.components[0];
expect(row.type).toBe(1);
expect(row.components).toHaveLength(2);
const first = row.components[0];
expect(first.type).toBe(2);
expect(first.label).toBe("Yes, ship it");
expect(decodeCallback(first.custom_id)).toEqual({
kind: "reply",
value: "yes",
});
});
it("uses Discord's 100-byte custom_id budget for longer callback values", () => {
const value = "x".repeat(74);
const content: Content = {
text: `Approve?\n[CHOICE:approve id=c1]\n${value}=Long callback\n[/CHOICE]`,
};
const out = renderDiscordInteractions(content);
expect(out.needsFreeTextReply).toBe(false);
expect(out.components).toHaveLength(1);
const button = out.components[0]?.components[0];
expect(button?.label).toBe("Long callback");
expect(button?.custom_id.length).toBe(78);
expect(decodeCallback(button?.custom_id)).toEqual({
kind: "reply",
value,
});
});
it("renders a task card as a link button when a url resolver is provided", () => {
const id = "abc12345-def6-7890-abcd-ef1234567890";
const out = renderDiscordInteractions(
{ text: `[TASK:${id}]Ship it[/TASK]` } as Content,
{
resolveUrl: (b) =>
b.kind === "task"
? `https://app/tasks?taskId=${b.threadId}`
: undefined,
},
);
const button = out.components[0]?.components[0];
expect(button?.style).toBe(5); // Link
expect(button?.url).toContain(id);
expect(button?.custom_id).toBe("");
});
it("caps action rows at the Discord limit of 5", () => {
const options = Array.from(
{ length: 30 },
(_, i) => `o${i}=Option ${i}`,
).join("\n");
const out = renderDiscordInteractions({
text: `[CHOICE:s id=c]\n${options}\n[/CHOICE]`,
} as Content);
expect(out.components.length).toBeLessThanOrEqual(5);
});
// #14527 — Discord's 5-row cap truncates overflow, but the dropped options
// must surface as prose + a free-text invite, never disappear silently.
it("surfaces options dropped past the 5-row cap as prose (#14527)", () => {
const options = Array.from(
{ length: 30 },
(_, i) => `o${i}=Option ${i}`,
).join("\n");
const out = renderDiscordInteractions({
text: `Pick one\n[CHOICE:s id=c]\n${options}\n[/CHOICE]`,
} as Content);
// 5 rows x 5 buttons render natively; options 25-29 overflow to prose.
expect(out.components).toHaveLength(5);
expect(out.needsFreeTextReply).toBe(true);
expect(out.text).toContain("More options (reply with one):");
for (let i = 25; i < 30; i++) {
expect(out.text).toContain(`Option ${i}`);
}
expect(out.text).not.toContain("Option 24");
});
// #14527 — Discord's custom_id budget is 100 chars, not Telegram's 64
// bytes. A value that fits Discord's own cap must render as a real button
// and round-trip through the callback decoder.
it("renders option values up to Discord's 100-char custom_id cap (#14527)", () => {
const value = "v".repeat(80);
const out = renderDiscordInteractions({
text: `Pick\n[CHOICE:s id=c1]\n${value}=Long option\n[/CHOICE]`,
} as Content);
const button = out.components[0]?.components[0];
expect(button?.label).toBe("Long option");
expect(decodeCallback(button?.custom_id)).toEqual({
kind: "reply",
value,
});
expect(out.needsFreeTextReply).toBe(false);
});
it("renders a navigate followup as a link button via resolveNavigateUrl (#8908)", () => {
const out = renderDiscordInteractions(
{
text: "Done.\n[FOLLOWUPS id=f1]\nnavigate:/orchestrator=Open tasks\nreply:thanks=Thanks\n[/FOLLOWUPS]",
} as Content,
{ resolveNavigateUrl: (p) => `https://app.test${p}` },
);
const buttons = out.components.flatMap((row) => row.components);
const nav = buttons.find((b) => b.label === "Open tasks");
const reply = buttons.find((b) => b.label === "Thanks");
expect(nav?.style).toBe(5); // Link
expect(nav?.url).toBe("https://app.test/orchestrator");
expect(reply?.url).toBeUndefined();
expect(reply?.custom_id).toBeTruthy();
});
// #14321 — no hosted /forms/:id page exists; the canonical resolver must not
// mint a dead link-out. The form renders as prose + a free-text invite.
it("renders a form as prose + free-text fallback, never a dead link (#14321)", () => {
const out = renderDiscordInteractions(
{
text: 'Happy to set that up.\n[FORM]\n{"id":"f1","title":"Set your reminder","fields":[{"name":"when","type":"text"}]}\n[/FORM]',
} as Content,
buildInteractionUrlResolver("https://app.test"),
);
expect(out.components).toHaveLength(0);
expect(out.needsFreeTextReply).toBe(true);
expect(out.text).toContain("Set your reminder");
expect(out.text).toContain(FORM_FREE_TEXT_INVITE);
expect(out.text).not.toContain("/forms/");
});
});
// #14527 — every Discord send path must resolve link-out blocks (task cards,
// navigate chips) identically. `buildDiscordReplyPayload` is the one place that
// derives the app-origin resolver from settings, so both the message-manager
// reply path and the button-tap replay path get the same "Open task" button.
// A bare `renderDiscordInteractions(content)` without the resolver drops it.
describe("buildDiscordReplyPayload — canonical resolver derivation (#14527)", () => {
const TASK_ID = "abc12345def67890abcdef1234567890";
function makeRuntime(settings: Record<string, string>) {
return {
getSetting: (key: string): string | undefined => settings[key],
};
}
it("resolves a task card to an Open task link button from ELIZA_APP_URL", () => {
const out = buildDiscordReplyPayload(
makeRuntime({ ELIZA_APP_URL: "https://app.test" }),
{ text: `[TASK:${TASK_ID}]Ship it[/TASK]` } as Content,
);
const button = out.components[0]?.components[0];
expect(button?.style).toBe(5); // Link
expect(button?.url).toBe(`https://app.test/orchestrator?taskId=${TASK_ID}`);
expect(out.needsFreeTextReply).toBe(false);
});
it("falls back to ELIZA_CLOUD_URL when ELIZA_APP_URL is unset", () => {
const out = buildDiscordReplyPayload(
makeRuntime({ ELIZA_CLOUD_URL: "https://cloud.test" }),
{ text: `[TASK:${TASK_ID}]Ship it[/TASK]` } as Content,
);
const button = out.components[0]?.components[0];
expect(button?.url).toBe(
`https://cloud.test/orchestrator?taskId=${TASK_ID}`,
);
});
it("degrades to prose (no dropped-silent button) when no app origin is set", () => {
const out = buildDiscordReplyPayload(makeRuntime({}), {
text: `[TASK:${TASK_ID}]Ship it[/TASK]`,
} as Content);
expect(out.components).toHaveLength(0);
expect(out.needsFreeTextReply).toBe(true);
expect(out.text).toContain("Ship it");
});
it("still renders choice buttons regardless of app-origin config", () => {
const out = buildDiscordReplyPayload(makeRuntime({}), {
text: "Approve?\n[CHOICE:approve id=c1]\nyes=Yes\nno=No\n[/CHOICE]",
} as Content);
expect(out.components).toHaveLength(1);
expect(out.components[0].components).toHaveLength(2);
});
});