Download src/interactive/payload.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 30.7 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/interactive/payload.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/interactive/payload.test.ts
-
curl -L -o payload.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/interactive/payload.test.ts
30.7 kB
| // 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(); | |
| }); | |
| }); | |