Files
openclaw/src/interactive/payload.test.ts
100menotu001 0212188cb6 fix(discord): preserve reusable presentation buttons
Preserve `reusable` for portable message presentation buttons and carry it through Discord component registration so repeatable callbacks stay available after a successful interaction.

Also keeps `reusable` through legacy presentation-to-interactive conversion and documents the user-visible change in the changelog.

Verification:
- `pnpm test src/interactive/payload.test.ts extensions/discord/src/shared-interactive.test.ts extensions/discord/src/components.test.ts -- --reporter=verbose`
- `git diff --check`
- `AUTOREVIEW_AUTO_TESTS=0 .agents/skills/autoreview/scripts/autoreview --mode local`
- PR CI at `52f25221b3e01f3255d8df37df73d0357ab7410b`: all completed checks green/skipped/neutral except pending CodeQL `Security High (mcp-process-tool-boundary)` at time auto-merge was armed.

Co-authored-by: OpenClaw Contributor <100menotu001@users.noreply.github.com>
2026-05-21 22:02:16 +01:00

237 lines
6.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
hasReplyChannelData,
hasReplyContent,
hasReplyPayloadContent,
normalizeInteractiveReply,
normalizeMessagePresentation,
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
} from "./payload.js";
describe("hasReplyChannelData", () => {
it.each([
{ value: undefined, expected: false },
{ value: {}, expected: false },
{ value: [], expected: false },
{ value: { slack: { blocks: [] } }, expected: true },
] as const)("accepts non-empty objects only: %j", ({ value, expected }) => {
expect(hasReplyChannelData(value)).toBe(expected);
});
});
describe("hasReplyContent", () => {
it("treats whitespace-only text and empty structured payloads as empty", () => {
expect(
hasReplyContent({
text: " ",
mediaUrls: ["", " "],
interactive: { blocks: [] },
hasChannelData: false,
}),
).toBe(false);
});
it.each([
{
name: "shared interactive blocks",
input: {
interactive: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }],
},
},
},
{
name: "explicit extra content",
input: {
text: " ",
extraContent: true,
},
},
] as const)("accepts $name", ({ input }) => {
expect(hasReplyContent(input)).toBe(true);
});
});
describe("hasReplyPayloadContent", () => {
it("trims text and falls back to channel data by default", () => {
expect(
hasReplyPayloadContent({
text: " ",
channelData: { slack: { blocks: [] } },
}),
).toBe(true);
});
it.each([
{
name: "explicit channel-data overrides",
payload: {
text: " ",
channelData: {},
},
options: {
hasChannelData: true,
},
},
{
name: "extra content",
payload: {
text: " ",
},
options: {
extraContent: true,
},
},
] as const)("accepts $name", ({ payload, options }) => {
expect(hasReplyPayloadContent(payload, options)).toBe(true);
});
});
describe("interactive payload helpers", () => {
it("normalizes interactive replies and resolves text fallbacks", () => {
const interactive = normalizeInteractiveReply({
blocks: [
{ type: "text", text: "First" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
{ type: "text", text: "Second" },
],
});
expect(interactive).toEqual({
blocks: [
{ type: "text", text: "First" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
{ type: "text", text: "Second" },
],
});
expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond");
});
it("preserves URL-only presentation buttons for native link renderers and fallback text", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
};
expect(presentationToInteractiveReply(presentation)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
},
],
});
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
"- Docs: https://example.com/docs",
);
});
it("preserves web app presentation buttons for channel-native renderers", () => {
const presentation = {
blocks: [
{
type: "buttons" as const,
buttons: [{ label: "Launch", web_app: { url: "https://example.com/app" } }],
},
],
};
const normalized = normalizeMessagePresentation(presentation);
expect(normalized).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
},
],
});
expect(presentationToInteractiveReply(normalized!)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
},
],
});
expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe(
"- Launch: https://example.com/app",
);
});
it("converts only presentation controls for native component renderers", () => {
const presentation = {
title: "Deploy approval",
blocks: [
{ type: "text" as const, text: "Canary is ready." },
{ type: "divider" as const },
{
type: "buttons" as const,
buttons: [
{
label: "Approve",
value: "approve",
style: "success" as const,
reusable: true,
},
],
},
{
type: "select" as const,
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
};
expect(presentationToInteractiveReply(presentation)).toEqual({
blocks: [
{ type: "text", text: "Deploy approval" },
{ type: "text", text: "Canary is ready." },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
},
{
type: "select",
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
});
expect(presentationToInteractiveControlsReply(presentation)).toEqual({
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
},
{
type: "select",
placeholder: "Rollback target",
options: [{ label: "Previous", value: "previous" }],
},
],
});
});
it("keeps divider-only fallback empty unless a send transport fallback is requested", () => {
const presentation = {
blocks: [{ type: "divider" as const }],
};
expect(renderMessagePresentationFallbackText({ presentation })).toBe("");
expect(
renderMessagePresentationFallbackText({
presentation,
emptyFallback: "---",
}),
).toBe("---");
});
});