mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 15:31:43 +00:00
* refactor(model-picker): centralize session apply contracts * fix(ci): keep model apply helper result internal * fix(model-picker): require channel codec opt-in
1046 lines
30 KiB
TypeScript
1046 lines
30 KiB
TypeScript
// Interactive payload tests cover validation of interactive response payloads.
|
|
import { describe, expect, it } from "vitest";
|
|
import type { MessagePresentationAction, ModelPickerAction } from "./payload.js";
|
|
import {
|
|
hasReplyChannelData,
|
|
hasReplyContent,
|
|
hasReplyPayloadContent,
|
|
normalizeInteractiveReply,
|
|
normalizeMessagePresentation,
|
|
presentationToInteractiveControlsReply,
|
|
presentationToInteractiveReply,
|
|
renderMessagePresentationFallbackText,
|
|
resolveMessagePresentationButtonAction,
|
|
resolveMessagePresentationControlValue,
|
|
resolveMessagePresentationOptionAction,
|
|
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("treats portable locations as content", () => {
|
|
expect(
|
|
hasReplyPayloadContent({
|
|
location: { latitude: 1, longitude: 2 },
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
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("normalizes typed presentation actions and bridges them to legacy values", () => {
|
|
const normalized = normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
},
|
|
{
|
|
label: "Allow once",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval/😀",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
},
|
|
{
|
|
label: "Review",
|
|
action: { type: "url", url: "https://example.com/approve/id" },
|
|
},
|
|
{
|
|
label: "Open app",
|
|
action: { type: "web-app", url: "https://example.com/app" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(normalized).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
},
|
|
{
|
|
label: "Allow once",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval/😀",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
},
|
|
{
|
|
label: "Review",
|
|
action: { type: "url", url: "https://example.com/approve/id" },
|
|
},
|
|
{
|
|
label: "Open app",
|
|
action: { type: "web-app", url: "https://example.com/app" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(presentationToInteractiveReply(normalized!)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
value: "/codex plugins menu",
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
value: "/approve req allow-once",
|
|
},
|
|
{
|
|
label: "Allow once",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval/😀",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
},
|
|
{
|
|
label: "Review",
|
|
action: { type: "url", url: "https://example.com/approve/id" },
|
|
url: "https://example.com/approve/id",
|
|
},
|
|
{
|
|
label: "Open app",
|
|
action: { type: "web-app", url: "https://example.com/app" },
|
|
webApp: { url: "https://example.com/app" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("requires a web-app target and preserves hosted widget ids", () => {
|
|
const normalized = normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Hosted widget",
|
|
action: { type: "web-app", widgetId: " AAAAAAAAAAAAAAAAAAAAAA " },
|
|
},
|
|
{
|
|
label: "Hosted fallback",
|
|
action: {
|
|
type: "web-app",
|
|
widgetId: "BBBBBBBBBBBBBBBBBBBBBB",
|
|
url: " https://example.com/app ",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(normalized).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Hosted widget",
|
|
action: { type: "web-app", widgetId: "AAAAAAAAAAAAAAAAAAAAAA" },
|
|
},
|
|
{
|
|
label: "Hosted fallback",
|
|
action: {
|
|
type: "web-app",
|
|
widgetId: "BBBBBBBBBBBBBBBBBBBBBB",
|
|
url: "https://example.com/app",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const interactive = presentationToInteractiveReply(normalized!);
|
|
expect(interactive?.blocks[0]).toMatchObject({
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Hosted widget",
|
|
action: { type: "web-app", widgetId: "AAAAAAAAAAAAAAAAAAAAAA" },
|
|
},
|
|
{
|
|
label: "Hosted fallback",
|
|
action: {
|
|
type: "web-app",
|
|
widgetId: "BBBBBBBBBBBBBBBBBBBBBB",
|
|
url: "https://example.com/app",
|
|
},
|
|
webApp: { url: "https://example.com/app" },
|
|
},
|
|
],
|
|
});
|
|
expect(interactive?.blocks[0]).not.toHaveProperty("buttons.0.webApp");
|
|
expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe(
|
|
"- Hosted widget\n- Hosted fallback: https://example.com/app",
|
|
);
|
|
expect(
|
|
resolveMessagePresentationButtonAction({
|
|
// Boundary input missing both url and widgetId; the union forbids this statically.
|
|
action: { type: "web-app" } as unknown as MessagePresentationAction,
|
|
}),
|
|
).toBeUndefined();
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Missing", action: { type: "web-app" } }],
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("normalizes every model-picker intent and preserves typed bridges", () => {
|
|
const base = { type: "model-picker", version: 1, snapshotToken: "snapshot_1" } as const;
|
|
const model = { providerToken: "p", modelToken: "m" } as const;
|
|
const actions = [
|
|
{ ...base, intent: "show-providers", cursor: "page_2" },
|
|
{ ...base, intent: "show-models", providerToken: "p", cursor: "page_3" },
|
|
{ ...base, intent: "show-recents" },
|
|
{ ...base, ...model, intent: "choose-model" },
|
|
{ ...base, ...model, intent: "choose-runtime", runtimeToken: "r" },
|
|
{ ...base, intent: "reset" },
|
|
{ ...base, intent: "cancel" },
|
|
] satisfies ModelPickerAction[];
|
|
|
|
for (const action of actions) {
|
|
expect(resolveMessagePresentationOptionAction({ action }, { modelPicker: true })).toEqual(
|
|
action,
|
|
);
|
|
}
|
|
const action = actions[3]!;
|
|
expect(resolveMessagePresentationButtonAction({ action })).toBeUndefined();
|
|
const presentation = normalizeMessagePresentation({
|
|
blocks: [
|
|
{ type: "buttons", buttons: [{ label: "Model", action }] },
|
|
{ type: "select", options: [{ label: "Model", action }] },
|
|
],
|
|
});
|
|
const bridged = presentationToInteractiveReply(presentation!);
|
|
expect(bridged?.blocks[0]).toMatchObject({ buttons: [{ action }] });
|
|
expect(bridged?.blocks[1]).toMatchObject({ options: [{ action }] });
|
|
expect(presentationToInteractiveControlsReply(presentation!)).toEqual(bridged);
|
|
expect(resolveMessagePresentationControlValue({ action })).toBeUndefined();
|
|
const invalid = [
|
|
{ ...base, version: 2, intent: "cancel" },
|
|
{ ...base, intent: "submit" },
|
|
{ type: "model-picker", version: 1, intent: "cancel" },
|
|
{ ...base, intent: "choose-model", providerToken: "p", modelToken: "openai/gpt" },
|
|
{ ...base, intent: "show-models" },
|
|
{ ...base, intent: "choose-runtime", providerToken: "p", modelToken: "m" },
|
|
{ ...base, snapshotToken: "x".repeat(129), intent: "cancel" },
|
|
];
|
|
for (const invalidAction of invalid) {
|
|
const malformed = { action: invalidAction } as never;
|
|
expect(
|
|
resolveMessagePresentationButtonAction(malformed, { modelPicker: true }),
|
|
).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("resolves deprecated button inputs without overriding a canonical action", () => {
|
|
expect(
|
|
resolveMessagePresentationButtonAction({
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval:1",
|
|
approvalKind: "plugin",
|
|
decision: "deny",
|
|
},
|
|
value: "legacy",
|
|
url: "https://ignored.example",
|
|
}),
|
|
).toEqual({
|
|
type: "approval",
|
|
approvalId: "approval:1",
|
|
approvalKind: "plugin",
|
|
decision: "deny",
|
|
});
|
|
expect(
|
|
resolveMessagePresentationButtonAction({
|
|
value: "legacy",
|
|
url: "https://example.com",
|
|
webApp: { url: "https://app.example.com" },
|
|
}),
|
|
).toEqual({ type: "url", url: "https://example.com" });
|
|
expect(
|
|
resolveMessagePresentationButtonAction({
|
|
value: "legacy",
|
|
web_app: { url: "https://app.example.com" },
|
|
}),
|
|
).toEqual({ type: "web-app", url: "https://app.example.com" });
|
|
expect(resolveMessagePresentationButtonAction({ value: "legacy" })).toEqual({
|
|
type: "callback",
|
|
value: "legacy",
|
|
});
|
|
expect(resolveMessagePresentationOptionAction({ value: "option" })).toEqual({
|
|
type: "callback",
|
|
value: "option",
|
|
});
|
|
const invalidButton = {
|
|
action: null,
|
|
value: "legacy",
|
|
url: "https://legacy.example",
|
|
} as unknown as Parameters<typeof resolveMessagePresentationButtonAction>[0];
|
|
const invalidControl = {
|
|
action: null,
|
|
value: "legacy",
|
|
} as unknown as Parameters<typeof resolveMessagePresentationControlValue>[0];
|
|
const invalidOption = {
|
|
action: null,
|
|
value: "legacy",
|
|
} as unknown as Parameters<typeof resolveMessagePresentationOptionAction>[0];
|
|
expect(resolveMessagePresentationButtonAction(invalidButton)).toBeUndefined();
|
|
expect(resolveMessagePresentationControlValue(invalidControl)).toBeUndefined();
|
|
expect(resolveMessagePresentationOptionAction(invalidOption)).toBeUndefined();
|
|
});
|
|
|
|
it("does not restore deprecated select values behind invalid explicit actions", () => {
|
|
const presentation = {
|
|
blocks: [
|
|
{
|
|
type: "select",
|
|
options: [{ label: "Invalid", action: null, value: "legacy" }],
|
|
},
|
|
],
|
|
} as unknown as Parameters<typeof presentationToInteractiveReply>[0];
|
|
|
|
const interactive = presentationToInteractiveReply(presentation);
|
|
expect(interactive?.blocks[0]).toMatchObject({
|
|
type: "select",
|
|
options: [{ label: "Invalid" }],
|
|
});
|
|
expect(interactive?.blocks[0]).not.toHaveProperty("options.0.value");
|
|
});
|
|
|
|
it("never exposes approval data through generic scalar resolution or fallback text", () => {
|
|
const action = {
|
|
type: "approval" as const,
|
|
approvalId: "approval:secret-transport-id",
|
|
approvalKind: "plugin" as const,
|
|
decision: "allow-always" as const,
|
|
};
|
|
expect(resolveMessagePresentationControlValue({ action, value: "legacy-shadow" })).toBe(
|
|
undefined,
|
|
);
|
|
expect(
|
|
renderMessagePresentationFallbackText({
|
|
presentation: {
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action,
|
|
value: "legacy-shadow",
|
|
url: "https://ignored.example",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
).toBe("- Approve");
|
|
expect(
|
|
presentationToInteractiveReply({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action,
|
|
value: "legacy-shadow",
|
|
url: "https://ignored.example",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toEqual({
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Approve", action }] }],
|
|
});
|
|
});
|
|
|
|
it("normalizes question actions without exposing their transport data", () => {
|
|
const action = {
|
|
type: "question" as const,
|
|
questionId: "ask_0123456789abcdef0123456789abcdef",
|
|
optionValue: "Production",
|
|
};
|
|
const presentation = normalizeMessagePresentation({
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }],
|
|
});
|
|
|
|
expect(presentation).toEqual({
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }],
|
|
});
|
|
expect(resolveMessagePresentationControlValue({ action })).toBeUndefined();
|
|
expect(renderMessagePresentationFallbackText({ presentation: presentation ?? undefined })).toBe(
|
|
"- Production",
|
|
);
|
|
expect(presentationToInteractiveReply(presentation ?? { blocks: [] })).toEqual({
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Production", action }] }],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ type: "Question", questionId: "ask_1", optionValue: "Yes" },
|
|
{ type: "question", questionId: "", optionValue: "Yes" },
|
|
{ type: "question", questionId: "ask_\ud800", optionValue: "Yes" },
|
|
{ type: "question", questionId: "ask_1", optionValue: " " },
|
|
])("rejects malformed question action %#", (action) => {
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Yes", action }] }],
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("rejects malformed canonical actions instead of falling back to legacy fields", () => {
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval:1",
|
|
approvalKind: "exec",
|
|
decision: "yes",
|
|
},
|
|
value: "legacy",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "\ud800",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action: {
|
|
type: " APPROVAL ",
|
|
approvalId: " approval:1 ",
|
|
approvalKind: " EXEC ",
|
|
decision: " ALLOW-ONCE ",
|
|
},
|
|
value: "legacy",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: "Approve",
|
|
action: {
|
|
type: "approval",
|
|
approvalId: "approval:1",
|
|
approvalKind: "exec",
|
|
decision: "allow-once",
|
|
},
|
|
value: "legacy",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("preserves protocol-valid boundary whitespace in typed approval actions", () => {
|
|
const approvalId = "\uFEFF";
|
|
|
|
expect(
|
|
normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Deny",
|
|
action: {
|
|
type: "approval",
|
|
approvalId,
|
|
approvalKind: "exec",
|
|
decision: "deny",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toMatchObject({
|
|
blocks: [
|
|
{
|
|
buttons: [
|
|
{
|
|
action: { type: "approval", approvalId, approvalKind: "exec", decision: "deny" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
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("preserves command values in button fallback text while keeping callback values private", () => {
|
|
const presentation = {
|
|
blocks: [
|
|
{
|
|
type: "buttons" as const,
|
|
buttons: [
|
|
{ label: "Approve", value: "/approve req_1 allow-once" },
|
|
{ label: "Deny", action: { type: "command" as const, command: "/approve req_1 deny" } },
|
|
{ label: "Ignore", action: { type: "callback" as const, value: "ignore_123" } },
|
|
{ label: "Docs", url: "https://example.com/docs" },
|
|
{
|
|
label: "Legacy link override",
|
|
action: { type: "command" as const, command: "/approve req_1" },
|
|
url: "https://example.com/review",
|
|
},
|
|
{ label: "Disabled", disabled: true },
|
|
{
|
|
label: "DisabledCmd",
|
|
disabled: true,
|
|
action: { type: "command" as const, command: "/test" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
|
|
[
|
|
"- Approve",
|
|
"- Deny: `/approve req_1 deny`",
|
|
"- Ignore",
|
|
"- Docs: https://example.com/docs",
|
|
"- Legacy link override: `/approve req_1`",
|
|
"- Disabled",
|
|
"- DisabledCmd",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
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("---");
|
|
});
|
|
|
|
it("normalizes chart data and renders deterministic accessible fallback text", () => {
|
|
const presentation = normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "chart",
|
|
chartType: "pie",
|
|
title: "Requests by region",
|
|
segments: [
|
|
{ label: "Americas", value: 52 },
|
|
{ label: "Europe", value: 31 },
|
|
],
|
|
},
|
|
{
|
|
type: "chart",
|
|
chartType: "line",
|
|
title: "Weekly latency",
|
|
categories: ["Mon", "Tue"],
|
|
series: [
|
|
{ name: "p50", values: [120, 110] },
|
|
{ name: "p95", values: [250, 230] },
|
|
],
|
|
xLabel: "Day",
|
|
yLabel: "Milliseconds",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "chart",
|
|
chartType: "pie",
|
|
title: "Requests by region",
|
|
segments: [
|
|
{ label: "Americas", value: 52 },
|
|
{ label: "Europe", value: 31 },
|
|
],
|
|
},
|
|
{
|
|
type: "chart",
|
|
chartType: "line",
|
|
title: "Weekly latency",
|
|
categories: ["Mon", "Tue"],
|
|
series: [
|
|
{ name: "p50", values: [120, 110] },
|
|
{ name: "p95", values: [250, 230] },
|
|
],
|
|
xLabel: "Day",
|
|
yLabel: "Milliseconds",
|
|
},
|
|
],
|
|
});
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
|
|
[
|
|
"Requests by region (pie chart)",
|
|
"- Americas: 52",
|
|
"- Europe: 31",
|
|
"",
|
|
"Weekly latency (line chart)",
|
|
"X axis: Day",
|
|
"Y axis: Milliseconds",
|
|
"- p50: Mon: 120; Tue: 110",
|
|
"- p95: Mon: 250; Tue: 230",
|
|
].join("\n"),
|
|
);
|
|
expect(presentationToInteractiveReply(presentation!)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "text",
|
|
text: "Requests by region (pie chart)\n- Americas: 52\n- Europe: 31",
|
|
},
|
|
{
|
|
type: "text",
|
|
text: [
|
|
"Weekly latency (line chart)",
|
|
"X axis: Day",
|
|
"Y axis: Milliseconds",
|
|
"- p50: Mon: 120; Tue: 110",
|
|
"- p95: Mon: 250; Tue: 230",
|
|
].join("\n"),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "non-positive pie values",
|
|
block: {
|
|
type: "chart",
|
|
chartType: "pie",
|
|
title: "Invalid",
|
|
segments: [{ label: "Zero", value: 0 }],
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate categories",
|
|
block: {
|
|
type: "chart",
|
|
chartType: "bar",
|
|
title: "Invalid",
|
|
categories: ["Q1", "Q1"],
|
|
series: [{ name: "Revenue", values: [1, 2] }],
|
|
},
|
|
},
|
|
{
|
|
name: "mismatched series values",
|
|
block: {
|
|
type: "chart",
|
|
chartType: "area",
|
|
title: "Invalid",
|
|
categories: ["Q1", "Q2"],
|
|
series: [{ name: "Revenue", values: [1] }],
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate series names",
|
|
block: {
|
|
type: "chart",
|
|
chartType: "line",
|
|
title: "Invalid",
|
|
categories: ["Q1"],
|
|
series: [
|
|
{ name: "Revenue", values: [1] },
|
|
{ name: "Revenue", values: [2] },
|
|
],
|
|
},
|
|
},
|
|
])("drops chart blocks with $name instead of changing their data", ({ block }) => {
|
|
expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined();
|
|
});
|
|
|
|
it("normalizes tables and renders deterministic linear fallback text", () => {
|
|
const presentation = normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "table",
|
|
caption: " Pipeline report ",
|
|
headers: [" Account ", "Stage", "ARR"],
|
|
rows: [
|
|
[" Acme\nCorp ", "Won", 125000],
|
|
["Globex", "Review", 82000],
|
|
],
|
|
rowHeaderColumnIndex: 0,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "table",
|
|
caption: "Pipeline report",
|
|
headers: ["Account", "Stage", "ARR"],
|
|
rows: [
|
|
["Acme\nCorp", "Won", 125000],
|
|
["Globex", "Review", 82000],
|
|
],
|
|
rowHeaderColumnIndex: 0,
|
|
},
|
|
],
|
|
});
|
|
const fallback = [
|
|
"Pipeline report (table)",
|
|
"- Account: Acme Corp; Stage: Won; ARR: 125000",
|
|
"- Account: Globex; Stage: Review; ARR: 82000",
|
|
].join("\n");
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe(fallback);
|
|
expect(presentationToInteractiveReply(presentation!)).toEqual({
|
|
blocks: [{ type: "text", text: fallback }],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "missing caption",
|
|
block: { type: "table", headers: ["Name"], rows: [["Acme"]] },
|
|
},
|
|
{
|
|
name: "empty headers",
|
|
block: { type: "table", caption: "Report", headers: [], rows: [["Acme"]] },
|
|
},
|
|
{
|
|
name: "duplicate headers",
|
|
block: {
|
|
type: "table",
|
|
caption: "Report",
|
|
headers: ["Name", "Name"],
|
|
rows: [["Acme", "Won"]],
|
|
},
|
|
},
|
|
{
|
|
name: "empty rows",
|
|
block: { type: "table", caption: "Report", headers: ["Name"], rows: [] },
|
|
},
|
|
{
|
|
name: "mismatched row width",
|
|
block: {
|
|
type: "table",
|
|
caption: "Report",
|
|
headers: ["Name", "Stage"],
|
|
rows: [["Acme"]],
|
|
},
|
|
},
|
|
{
|
|
name: "empty string cell",
|
|
block: { type: "table", caption: "Report", headers: ["Name"], rows: [[" "]] },
|
|
},
|
|
{
|
|
name: "non-finite numeric cell",
|
|
block: { type: "table", caption: "Report", headers: ["ARR"], rows: [[Infinity]] },
|
|
},
|
|
{
|
|
name: "non-scalar cell",
|
|
block: { type: "table", caption: "Report", headers: ["Name"], rows: [[true]] },
|
|
},
|
|
{
|
|
name: "out-of-range row header column",
|
|
block: {
|
|
type: "table",
|
|
caption: "Report",
|
|
headers: ["Name"],
|
|
rows: [["Acme"]],
|
|
rowHeaderColumnIndex: 1,
|
|
},
|
|
},
|
|
])("drops table blocks with $name instead of repairing their data", ({ block }) => {
|
|
expect(normalizeMessagePresentation({ blocks: [block] })).toBeUndefined();
|
|
});
|
|
});
|