@assistant-ui/react-generative-ui 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +4 -2
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +9 -2
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.server.d.ts +0 -1
  6. package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.d.ts +8 -1
  8. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  9. package/dist/JSONGenerativeUI.shared.js.map +1 -1
  10. package/dist/actionRegistry.d.ts +32 -0
  11. package/dist/actionRegistry.d.ts.map +1 -0
  12. package/dist/actionRegistry.js +41 -0
  13. package/dist/actionRegistry.js.map +1 -0
  14. package/dist/buildPresentParameters.d.ts +5 -5
  15. package/dist/buildPresentParameters.d.ts.map +1 -1
  16. package/dist/buildPresentParameters.js +20 -6
  17. package/dist/buildPresentParameters.js.map +1 -1
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/defineGenerativeComponents.d.ts +0 -1
  20. package/dist/defineGenerativeComponents.d.ts.map +1 -1
  21. package/dist/generativeUIToJSX.d.ts +15 -10
  22. package/dist/generativeUIToJSX.d.ts.map +1 -1
  23. package/dist/generativeUIToJSX.js +47 -17
  24. package/dist/generativeUIToJSX.js.map +1 -1
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +5 -2
  27. package/dist/index.server.d.ts +13 -0
  28. package/dist/index.server.js +10 -0
  29. package/dist/index.shared.d.ts +11 -0
  30. package/dist/index.shared.js +9 -0
  31. package/dist/ir.d.ts +115 -0
  32. package/dist/ir.d.ts.map +1 -0
  33. package/dist/ir.js +175 -0
  34. package/dist/ir.js.map +1 -0
  35. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
  36. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
  37. package/dist/renderGenerativeUI.d.ts +5 -5
  38. package/dist/renderGenerativeUI.d.ts.map +1 -1
  39. package/dist/renderGenerativeUI.js +18 -45
  40. package/dist/renderGenerativeUI.js.map +1 -1
  41. package/dist/slack/constants.d.ts +111 -0
  42. package/dist/slack/constants.d.ts.map +1 -0
  43. package/dist/slack/constants.js +168 -0
  44. package/dist/slack/constants.js.map +1 -0
  45. package/dist/slack/decodeBlockAction.d.ts +11 -0
  46. package/dist/slack/decodeBlockAction.d.ts.map +1 -0
  47. package/dist/slack/decodeBlockAction.js +40 -0
  48. package/dist/slack/decodeBlockAction.js.map +1 -0
  49. package/dist/slack/fromSlackBlocks.d.ts +30 -0
  50. package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
  51. package/dist/slack/fromSlackBlocks.js +447 -0
  52. package/dist/slack/fromSlackBlocks.js.map +1 -0
  53. package/dist/slack/toSlackBlocks.d.ts +7 -0
  54. package/dist/slack/toSlackBlocks.d.ts.map +1 -0
  55. package/dist/slack/toSlackBlocks.js +649 -0
  56. package/dist/slack/toSlackBlocks.js.map +1 -0
  57. package/dist/slack/types.d.ts +178 -0
  58. package/dist/slack/types.d.ts.map +1 -0
  59. package/dist/slack/types.js +0 -0
  60. package/dist/slack.d.ts +5 -0
  61. package/dist/slack.js +4 -0
  62. package/dist/teams/boundSpec.d.ts +20 -0
  63. package/dist/teams/boundSpec.d.ts.map +1 -0
  64. package/dist/teams/boundSpec.js +67 -0
  65. package/dist/teams/boundSpec.js.map +1 -0
  66. package/dist/teams/constants.d.ts +57 -0
  67. package/dist/teams/constants.d.ts.map +1 -0
  68. package/dist/teams/constants.js +92 -0
  69. package/dist/teams/constants.js.map +1 -0
  70. package/dist/teams/decodeSubmitData.d.ts +25 -0
  71. package/dist/teams/decodeSubmitData.d.ts.map +1 -0
  72. package/dist/teams/decodeSubmitData.js +44 -0
  73. package/dist/teams/decodeSubmitData.js.map +1 -0
  74. package/dist/teams/toAdaptiveCard.d.ts +39 -0
  75. package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
  76. package/dist/teams/toAdaptiveCard.js +496 -0
  77. package/dist/teams/toAdaptiveCard.js.map +1 -0
  78. package/dist/teams/toTeamsAttachments.d.ts +13 -0
  79. package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
  80. package/dist/teams/toTeamsAttachments.js +95 -0
  81. package/dist/teams/toTeamsAttachments.js.map +1 -0
  82. package/dist/teams/types.d.ts +196 -0
  83. package/dist/teams/types.d.ts.map +1 -0
  84. package/dist/teams/types.js +0 -0
  85. package/dist/teams.d.ts +5 -0
  86. package/dist/teams.js +4 -0
  87. package/dist/types.d.ts +41 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/vocabulary/alert.d.ts +28 -0
  90. package/dist/vocabulary/alert.d.ts.map +1 -0
  91. package/dist/vocabulary/alert.js +58 -0
  92. package/dist/vocabulary/alert.js.map +1 -0
  93. package/dist/vocabulary/collectFormValues.d.ts +23 -0
  94. package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
  95. package/dist/vocabulary/collectFormValues.js +32 -0
  96. package/dist/vocabulary/collectFormValues.js.map +1 -0
  97. package/dist/vocabulary/data.d.ts +52 -0
  98. package/dist/vocabulary/data.d.ts.map +1 -0
  99. package/dist/vocabulary/data.js +294 -0
  100. package/dist/vocabulary/data.js.map +1 -0
  101. package/dist/vocabulary/dispatch.d.ts +11 -0
  102. package/dist/vocabulary/dispatch.d.ts.map +1 -0
  103. package/dist/vocabulary/dispatch.js +21 -0
  104. package/dist/vocabulary/dispatch.js.map +1 -0
  105. package/dist/vocabulary/fact.d.ts +15 -0
  106. package/dist/vocabulary/fact.d.ts.map +1 -0
  107. package/dist/vocabulary/fact.js +24 -0
  108. package/dist/vocabulary/fact.js.map +1 -0
  109. package/dist/vocabulary/form.d.ts +14 -0
  110. package/dist/vocabulary/form.d.ts.map +1 -0
  111. package/dist/vocabulary/form.js +23 -0
  112. package/dist/vocabulary/form.js.map +1 -0
  113. package/dist/vocabulary/icon.d.ts +44 -0
  114. package/dist/vocabulary/icon.d.ts.map +1 -0
  115. package/dist/vocabulary/icon.js +272 -0
  116. package/dist/vocabulary/icon.js.map +1 -0
  117. package/dist/vocabulary/index.d.ts +15 -0
  118. package/dist/vocabulary/index.d.ts.map +1 -0
  119. package/dist/vocabulary/index.js +36 -0
  120. package/dist/vocabulary/index.js.map +1 -0
  121. package/dist/vocabulary/interactive.d.ts +93 -0
  122. package/dist/vocabulary/interactive.d.ts.map +1 -0
  123. package/dist/vocabulary/interactive.js +172 -0
  124. package/dist/vocabulary/interactive.js.map +1 -0
  125. package/dist/vocabulary/layout.d.ts +82 -0
  126. package/dist/vocabulary/layout.d.ts.map +1 -0
  127. package/dist/vocabulary/layout.js +136 -0
  128. package/dist/vocabulary/layout.js.map +1 -0
  129. package/dist/vocabulary/list.d.ts +17 -0
  130. package/dist/vocabulary/list.d.ts.map +1 -0
  131. package/dist/vocabulary/list.js +48 -0
  132. package/dist/vocabulary/list.js.map +1 -0
  133. package/dist/vocabulary/media.d.ts +28 -0
  134. package/dist/vocabulary/media.d.ts.map +1 -0
  135. package/dist/vocabulary/media.js +42 -0
  136. package/dist/vocabulary/media.js.map +1 -0
  137. package/dist/vocabulary/text.d.ts +60 -0
  138. package/dist/vocabulary/text.d.ts.map +1 -0
  139. package/dist/vocabulary/text.js +48 -0
  140. package/dist/vocabulary/text.js.map +1 -0
  141. package/package.json +21 -13
  142. package/src/JSONGenerativeUI.client.tsx +10 -2
  143. package/src/JSONGenerativeUI.shared.ts +8 -0
  144. package/src/actionRegistry.test.ts +90 -0
  145. package/src/actionRegistry.ts +88 -0
  146. package/src/buildPresentParameters.ts +38 -16
  147. package/src/generativeUIToJSX.test.ts +186 -0
  148. package/src/generativeUIToJSX.ts +112 -21
  149. package/src/index.server.ts +2 -0
  150. package/src/index.shared.ts +61 -0
  151. package/src/index.ts +2 -26
  152. package/src/ir.test.ts +203 -0
  153. package/src/ir.ts +279 -0
  154. package/src/renderGenerativeUI.test.tsx +69 -2
  155. package/src/renderGenerativeUI.tsx +49 -82
  156. package/src/slack/constants.ts +217 -0
  157. package/src/slack/decodeBlockAction.test.ts +135 -0
  158. package/src/slack/decodeBlockAction.ts +66 -0
  159. package/src/slack/fromSlackBlocks.test.ts +1145 -0
  160. package/src/slack/fromSlackBlocks.ts +709 -0
  161. package/src/slack/toSlackBlocks.test.ts +1936 -0
  162. package/src/slack/toSlackBlocks.ts +1319 -0
  163. package/src/slack/types.ts +230 -0
  164. package/src/slack.ts +37 -0
  165. package/src/teams/boundSpec.ts +102 -0
  166. package/src/teams/constants.ts +127 -0
  167. package/src/teams/decodeSubmitData.test.ts +148 -0
  168. package/src/teams/decodeSubmitData.ts +57 -0
  169. package/src/teams/toAdaptiveCard.test.ts +1410 -0
  170. package/src/teams/toAdaptiveCard.ts +762 -0
  171. package/src/teams/toTeamsAttachments.test.ts +29 -0
  172. package/src/teams/toTeamsAttachments.ts +137 -0
  173. package/src/teams/types.ts +237 -0
  174. package/src/teams.ts +34 -0
  175. package/src/types.ts +57 -42
  176. package/src/vocabulary/alert.test.tsx +136 -0
  177. package/src/vocabulary/alert.tsx +66 -0
  178. package/src/vocabulary/collectFormValues.test.ts +127 -0
  179. package/src/vocabulary/collectFormValues.ts +58 -0
  180. package/src/vocabulary/data.test.tsx +567 -0
  181. package/src/vocabulary/data.tsx +494 -0
  182. package/src/vocabulary/dispatch.ts +22 -0
  183. package/src/vocabulary/fact.test.tsx +15 -0
  184. package/src/vocabulary/fact.tsx +22 -0
  185. package/src/vocabulary/form.test.tsx +112 -0
  186. package/src/vocabulary/form.tsx +34 -0
  187. package/src/vocabulary/icon.test.tsx +64 -0
  188. package/src/vocabulary/icon.tsx +174 -0
  189. package/src/vocabulary/index.test.ts +63 -0
  190. package/src/vocabulary/index.ts +33 -0
  191. package/src/vocabulary/interactive.dispatch.test.tsx +352 -0
  192. package/src/vocabulary/interactive.test.tsx +248 -0
  193. package/src/vocabulary/interactive.tsx +269 -0
  194. package/src/vocabulary/layout.test.tsx +289 -0
  195. package/src/vocabulary/layout.tsx +229 -0
  196. package/src/vocabulary/list.test.tsx +195 -0
  197. package/src/vocabulary/list.tsx +48 -0
  198. package/src/vocabulary/media.test.tsx +85 -0
  199. package/src/vocabulary/media.tsx +56 -0
  200. package/src/vocabulary/text.test.tsx +73 -0
  201. package/src/vocabulary/text.tsx +59 -0
@@ -0,0 +1,1936 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toSlackBlocks } from "./toSlackBlocks";
3
+ import {
4
+ ACTION_ID_CAP,
5
+ ACTIONS_ELEMENT_CAP,
6
+ ALERT_TEXT_CAP,
7
+ BUTTON_VALUE_CAP,
8
+ CARD_BODY_CAP,
9
+ CARD_SUBTEXT_CAP,
10
+ CARD_TITLE_CAP,
11
+ CAROUSEL_CARD_CAP,
12
+ CHILDREN_CAP,
13
+ CONTEXT_ELEMENT_CAP,
14
+ CONTEXT_TEXT_CAP,
15
+ DATA_TABLE_CHAR_BUDGET,
16
+ DATA_TABLE_COLUMN_CAP,
17
+ DATA_TABLE_ROW_CAP,
18
+ FACT_FIELD_CAP,
19
+ FACT_FIELD_TEXT_CAP,
20
+ HEADER_TEXT_CAP,
21
+ INPUT_LABEL_CAP,
22
+ INTERACTIVE_TEXT_CAP,
23
+ MARKDOWN_TEXT_BUDGET,
24
+ MESSAGE_BLOCK_CAP,
25
+ MODAL_BLOCK_CAP,
26
+ NODE_BUDGET,
27
+ PLACEHOLDER_TEXT_CAP,
28
+ RADIO_OPTION_CAP,
29
+ SECTION_TEXT_CAP,
30
+ SELECT_OPTION_CAP,
31
+ TABLE_CAPTION,
32
+ } from "./constants";
33
+ import type {
34
+ SlackActionsBlock,
35
+ SlackAlertBlock,
36
+ SlackCardBlock,
37
+ SlackCarouselBlock,
38
+ SlackCheckboxesElement,
39
+ SlackContextBlock,
40
+ SlackDataTableBlock,
41
+ SlackInputBlock,
42
+ SlackRadioButtonsElement,
43
+ SlackSectionBlock,
44
+ SlackStaticSelectElement,
45
+ } from "./types";
46
+
47
+ describe("toSlackBlocks", () => {
48
+ describe("Header", () => {
49
+ it("renders a header block", () => {
50
+ const { blocks } = toSlackBlocks({ $type: "Header", text: "Title" });
51
+ expect(blocks).toEqual([
52
+ { type: "header", text: { type: "plain_text", text: "Title" } },
53
+ ]);
54
+ });
55
+
56
+ it(`clamps text to ${HEADER_TEXT_CAP} characters and warns`, () => {
57
+ const text = "h".repeat(200);
58
+ const { blocks, warnings } = toSlackBlocks({ $type: "Header", text });
59
+ expect(blocks).toEqual([
60
+ {
61
+ type: "header",
62
+ text: { type: "plain_text", text: "h".repeat(HEADER_TEXT_CAP) },
63
+ },
64
+ ]);
65
+ expect(warnings).toEqual([
66
+ {
67
+ code: "clamped",
68
+ component: "Header",
69
+ detail: `text was clamped to ${HEADER_TEXT_CAP} characters.`,
70
+ },
71
+ ]);
72
+ });
73
+ });
74
+
75
+ describe("Text", () => {
76
+ it("renders a section with mrkdwn text", () => {
77
+ const { blocks } = toSlackBlocks({ $type: "Text", value: "Hello" });
78
+ expect(blocks).toEqual([
79
+ { type: "section", text: { type: "mrkdwn", text: "Hello" } },
80
+ ]);
81
+ });
82
+
83
+ it(`clamps value to ${SECTION_TEXT_CAP} characters and warns`, () => {
84
+ const value = "t".repeat(4000);
85
+ const { blocks, warnings } = toSlackBlocks({ $type: "Text", value });
86
+ expect((blocks[0] as SlackSectionBlock).text?.text).toHaveLength(
87
+ SECTION_TEXT_CAP,
88
+ );
89
+ expect(warnings).toEqual([
90
+ {
91
+ code: "clamped",
92
+ component: "Text",
93
+ detail: `value was clamped to ${SECTION_TEXT_CAP} characters.`,
94
+ },
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe("Caption", () => {
100
+ it("renders a context block with one mrkdwn element", () => {
101
+ const { blocks } = toSlackBlocks({
102
+ $type: "Caption",
103
+ value: "Fine print",
104
+ });
105
+ expect(blocks).toEqual([
106
+ { type: "context", elements: [{ type: "mrkdwn", text: "Fine print" }] },
107
+ ]);
108
+ });
109
+ });
110
+
111
+ describe("Badge", () => {
112
+ it("renders standalone as a context block", () => {
113
+ const { blocks } = toSlackBlocks({ $type: "Badge", value: "New" });
114
+ expect(blocks).toEqual([
115
+ { type: "context", elements: [{ type: "mrkdwn", text: "New" }] },
116
+ ]);
117
+ });
118
+ });
119
+
120
+ describe("Fact", () => {
121
+ it("renders a single fact as one section field", () => {
122
+ const { blocks } = toSlackBlocks({
123
+ $type: "Fact",
124
+ label: "Status",
125
+ value: "Active",
126
+ });
127
+ expect(blocks).toEqual([
128
+ {
129
+ type: "section",
130
+ fields: [{ type: "mrkdwn", text: "*Status*\nActive" }],
131
+ },
132
+ ]);
133
+ });
134
+
135
+ it("merges consecutive Fact siblings into one section, starting a new section past the field cap", () => {
136
+ const facts = Array.from({ length: FACT_FIELD_CAP + 2 }, (_, i) => ({
137
+ $type: "Fact",
138
+ label: `L${i}`,
139
+ value: `V${i}`,
140
+ }));
141
+ const { blocks, warnings } = toSlackBlocks(facts);
142
+ expect(blocks).toHaveLength(2);
143
+ expect((blocks[0] as SlackSectionBlock).fields).toHaveLength(
144
+ FACT_FIELD_CAP,
145
+ );
146
+ expect((blocks[1] as SlackSectionBlock).fields).toHaveLength(2);
147
+ expect(warnings).toEqual([]);
148
+ });
149
+
150
+ it("breaks Fact merging on a non-Fact sibling", () => {
151
+ const root = [
152
+ { $type: "Fact", label: "A", value: "1" },
153
+ { $type: "Fact", label: "B", value: "2" },
154
+ { $type: "Text", value: "between" },
155
+ { $type: "Fact", label: "C", value: "3" },
156
+ ];
157
+ const { blocks } = toSlackBlocks(root);
158
+ expect(blocks).toHaveLength(3);
159
+ expect((blocks[0] as SlackSectionBlock).fields).toHaveLength(2);
160
+ expect((blocks[1] as SlackSectionBlock).text).toEqual({
161
+ type: "mrkdwn",
162
+ text: "between",
163
+ });
164
+ expect((blocks[2] as SlackSectionBlock).fields).toHaveLength(1);
165
+ });
166
+
167
+ it(`clamps a fact field to ${FACT_FIELD_TEXT_CAP} characters and warns`, () => {
168
+ const value = "v".repeat(FACT_FIELD_TEXT_CAP + 100);
169
+ const { blocks, warnings } = toSlackBlocks({
170
+ $type: "Fact",
171
+ label: "L",
172
+ value,
173
+ });
174
+ const field = (blocks[0] as SlackSectionBlock).fields?.[0];
175
+ expect(field?.text).toHaveLength(FACT_FIELD_TEXT_CAP);
176
+ expect(warnings).toEqual([
177
+ {
178
+ code: "clamped",
179
+ component: "Fact",
180
+ detail: `field was clamped to ${FACT_FIELD_TEXT_CAP} characters.`,
181
+ },
182
+ ]);
183
+ });
184
+ });
185
+
186
+ describe("Image", () => {
187
+ it("renders image_url and alt_text, dropping other props silently", () => {
188
+ const { blocks, warnings } = toSlackBlocks({
189
+ $type: "Image",
190
+ src: "https://x/y.png",
191
+ alt: "A photo",
192
+ size: "lg",
193
+ round: true,
194
+ });
195
+ expect(blocks).toEqual([
196
+ { type: "image", image_url: "https://x/y.png", alt_text: "A photo" },
197
+ ]);
198
+ expect(warnings).toEqual([]);
199
+ });
200
+ });
201
+
202
+ describe("Divider", () => {
203
+ it("renders a divider block", () => {
204
+ const { blocks } = toSlackBlocks({ $type: "Divider" });
205
+ expect(blocks).toEqual([{ type: "divider" }]);
206
+ });
207
+ });
208
+
209
+ describe("Button", () => {
210
+ it("maps buttonStyle primary/danger to Slack style, omitting style otherwise", () => {
211
+ const { blocks: primaryBlocks } = toSlackBlocks({
212
+ $type: "Button",
213
+ label: "Go",
214
+ buttonStyle: "primary",
215
+ $action: { type: "go" },
216
+ });
217
+ expect((primaryBlocks[0] as SlackActionsBlock).elements[0]).toMatchObject(
218
+ { style: "primary" },
219
+ );
220
+
221
+ const { blocks: dangerBlocks } = toSlackBlocks({
222
+ $type: "Button",
223
+ label: "Delete",
224
+ buttonStyle: "danger",
225
+ $action: { type: "del" },
226
+ });
227
+ expect((dangerBlocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
228
+ style: "danger",
229
+ });
230
+
231
+ const { blocks: plainBlocks } = toSlackBlocks({
232
+ $type: "Button",
233
+ label: "Neutral",
234
+ buttonStyle: "secondary",
235
+ $action: { type: "noop" },
236
+ });
237
+ expect(
238
+ (plainBlocks[0] as SlackActionsBlock).elements[0],
239
+ ).not.toHaveProperty("style");
240
+ });
241
+
242
+ it('derives action_id from $action.type, falling back to "action", and serializes the remaining payload as value', () => {
243
+ const { blocks } = toSlackBlocks({
244
+ $type: "Button",
245
+ label: "Buy",
246
+ $action: { type: "buy", sku: "abc" },
247
+ });
248
+ expect(blocks[0]).toEqual({
249
+ type: "actions",
250
+ elements: [
251
+ {
252
+ type: "button",
253
+ text: { type: "plain_text", text: "Buy" },
254
+ action_id: "buy",
255
+ value: JSON.stringify({ sku: "abc" }),
256
+ },
257
+ ],
258
+ });
259
+
260
+ const { blocks: fallbackBlocks } = toSlackBlocks({
261
+ $type: "Button",
262
+ label: "Go",
263
+ });
264
+ expect(fallbackBlocks[0]).toEqual({
265
+ type: "actions",
266
+ elements: [
267
+ {
268
+ type: "button",
269
+ text: { type: "plain_text", text: "Go" },
270
+ action_id: "action",
271
+ },
272
+ ],
273
+ });
274
+ });
275
+
276
+ it(`clamps the label to ${INTERACTIVE_TEXT_CAP} characters and warns`, () => {
277
+ const label = "x".repeat(100);
278
+ const { blocks, warnings } = toSlackBlocks({
279
+ $type: "Button",
280
+ label,
281
+ $action: { type: "go" },
282
+ });
283
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
284
+ text: { type: "plain_text", text: "x".repeat(INTERACTIVE_TEXT_CAP) },
285
+ });
286
+ expect(warnings).toEqual([
287
+ {
288
+ code: "clamped",
289
+ component: "Button",
290
+ detail: `label was clamped to ${INTERACTIVE_TEXT_CAP} characters.`,
291
+ },
292
+ ]);
293
+ });
294
+
295
+ it(`clamps action_id to ${ACTION_ID_CAP} characters and warns`, () => {
296
+ const longType = "a".repeat(ACTION_ID_CAP + 20);
297
+ const { blocks, warnings } = toSlackBlocks({
298
+ $type: "Button",
299
+ label: "Go",
300
+ $action: { type: longType },
301
+ });
302
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
303
+ action_id: "a".repeat(ACTION_ID_CAP),
304
+ });
305
+ expect(warnings).toContainEqual({
306
+ code: "clamped",
307
+ component: "Button",
308
+ detail: `action_id was clamped to ${ACTION_ID_CAP} characters.`,
309
+ });
310
+ });
311
+
312
+ it(`omits value instead of truncating and warns when the serialized action payload exceeds ${BUTTON_VALUE_CAP} characters`, () => {
313
+ const { blocks, warnings } = toSlackBlocks({
314
+ $type: "Button",
315
+ label: "Go",
316
+ $action: { type: "go", blob: "z".repeat(BUTTON_VALUE_CAP) },
317
+ });
318
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
319
+ "value",
320
+ );
321
+ expect(warnings).toContainEqual({
322
+ code: "dropped",
323
+ component: "Button",
324
+ detail:
325
+ "value was dropped because the action payload was too large to round-trip.",
326
+ });
327
+ });
328
+ });
329
+
330
+ describe("Select", () => {
331
+ it("converts options and placeholder", () => {
332
+ const { blocks } = toSlackBlocks({
333
+ $type: "Select",
334
+ placeholder: "Pick one",
335
+ options: [
336
+ { label: "Alpha", value: "a" },
337
+ { label: "Beta", value: "b" },
338
+ ],
339
+ $action: { type: "pick" },
340
+ });
341
+ expect(blocks[0]).toEqual({
342
+ type: "actions",
343
+ elements: [
344
+ {
345
+ type: "static_select",
346
+ action_id: "pick",
347
+ options: [
348
+ { text: { type: "plain_text", text: "Alpha" }, value: "a" },
349
+ { text: { type: "plain_text", text: "Beta" }, value: "b" },
350
+ ],
351
+ placeholder: { type: "plain_text", text: "Pick one" },
352
+ },
353
+ ],
354
+ });
355
+ });
356
+
357
+ it(`clamps options past ${SELECT_OPTION_CAP} entries and warns`, () => {
358
+ const options = Array.from({ length: SELECT_OPTION_CAP + 5 }, (_, i) => ({
359
+ label: `L${i}`,
360
+ value: `v${i}`,
361
+ }));
362
+ const { blocks, warnings } = toSlackBlocks({
363
+ $type: "Select",
364
+ options,
365
+ $action: { type: "pick" },
366
+ });
367
+ const element = (blocks[0] as SlackActionsBlock)
368
+ .elements[0] as SlackStaticSelectElement;
369
+ expect(element.options).toHaveLength(SELECT_OPTION_CAP);
370
+ expect(warnings).toContainEqual({
371
+ code: "clamped",
372
+ component: "Select",
373
+ detail: `options were clamped to ${SELECT_OPTION_CAP} entries.`,
374
+ });
375
+ });
376
+
377
+ it(`clamps an option label to ${INTERACTIVE_TEXT_CAP} characters and warns`, () => {
378
+ const longLabel = "y".repeat(90);
379
+ const { blocks, warnings } = toSlackBlocks({
380
+ $type: "Select",
381
+ options: [{ label: longLabel, value: "v" }],
382
+ $action: { type: "pick" },
383
+ });
384
+ const element = (blocks[0] as SlackActionsBlock)
385
+ .elements[0] as SlackStaticSelectElement;
386
+ expect(element.options[0]?.text.text).toBe(
387
+ "y".repeat(INTERACTIVE_TEXT_CAP),
388
+ );
389
+ expect(warnings).toContainEqual({
390
+ code: "clamped",
391
+ component: "Select",
392
+ detail: `option label was clamped to ${INTERACTIVE_TEXT_CAP} characters.`,
393
+ });
394
+ });
395
+
396
+ it(`clamps the placeholder to ${PLACEHOLDER_TEXT_CAP} characters and warns`, () => {
397
+ const placeholder = "p".repeat(PLACEHOLDER_TEXT_CAP + 20);
398
+ const { blocks, warnings } = toSlackBlocks({
399
+ $type: "Select",
400
+ placeholder,
401
+ options: [{ label: "A", value: "a" }],
402
+ $action: { type: "pick" },
403
+ });
404
+ const element = (blocks[0] as SlackActionsBlock)
405
+ .elements[0] as SlackStaticSelectElement;
406
+ expect(element.placeholder?.text).toHaveLength(PLACEHOLDER_TEXT_CAP);
407
+ expect(warnings).toContainEqual({
408
+ code: "clamped",
409
+ component: "Select",
410
+ detail: `placeholder was clamped to ${PLACEHOLDER_TEXT_CAP} characters.`,
411
+ });
412
+ });
413
+ });
414
+
415
+ describe("Input", () => {
416
+ it("wraps a plain_text_input element, passing multiline through", () => {
417
+ const { blocks } = toSlackBlocks({
418
+ $type: "Input",
419
+ label: "Notes",
420
+ placeholder: "Type here",
421
+ multiline: true,
422
+ $action: { type: "notes" },
423
+ });
424
+ expect(blocks[0]).toEqual({
425
+ type: "input",
426
+ label: { type: "plain_text", text: "Notes" },
427
+ element: {
428
+ type: "plain_text_input",
429
+ action_id: "notes",
430
+ multiline: true,
431
+ placeholder: { type: "plain_text", text: "Type here" },
432
+ },
433
+ });
434
+ });
435
+
436
+ it("omits multiline when not set", () => {
437
+ const { blocks } = toSlackBlocks({
438
+ $type: "Input",
439
+ label: "Name",
440
+ $action: { type: "name" },
441
+ });
442
+ expect((blocks[0] as SlackInputBlock).element).not.toHaveProperty(
443
+ "multiline",
444
+ );
445
+ });
446
+
447
+ it(`clamps label to ${INPUT_LABEL_CAP} and placeholder to ${PLACEHOLDER_TEXT_CAP} characters, warning on each`, () => {
448
+ const label = "l".repeat(INPUT_LABEL_CAP + 10);
449
+ const placeholder = "p".repeat(PLACEHOLDER_TEXT_CAP + 10);
450
+ const { blocks, warnings } = toSlackBlocks({
451
+ $type: "Input",
452
+ label,
453
+ placeholder,
454
+ $action: { type: "notes" },
455
+ });
456
+ const input = blocks[0] as SlackInputBlock;
457
+ expect(input.label.text).toHaveLength(INPUT_LABEL_CAP);
458
+ expect(input.element.placeholder?.text).toHaveLength(
459
+ PLACEHOLDER_TEXT_CAP,
460
+ );
461
+ expect(warnings).toContainEqual({
462
+ code: "clamped",
463
+ component: "Input",
464
+ detail: `label was clamped to ${INPUT_LABEL_CAP} characters.`,
465
+ });
466
+ expect(warnings).toContainEqual({
467
+ code: "clamped",
468
+ component: "Input",
469
+ detail: `placeholder was clamped to ${PLACEHOLDER_TEXT_CAP} characters.`,
470
+ });
471
+ });
472
+ });
473
+
474
+ describe("DatePicker", () => {
475
+ it("sets initial_date from value", () => {
476
+ const { blocks } = toSlackBlocks({
477
+ $type: "DatePicker",
478
+ value: "2026-07-15",
479
+ $action: { type: "pick_date" },
480
+ });
481
+ expect(blocks[0]).toEqual({
482
+ type: "actions",
483
+ elements: [
484
+ {
485
+ type: "datepicker",
486
+ action_id: "pick_date",
487
+ initial_date: "2026-07-15",
488
+ },
489
+ ],
490
+ });
491
+ });
492
+
493
+ it("omits initial_date when value is absent", () => {
494
+ const { blocks, warnings } = toSlackBlocks({
495
+ $type: "DatePicker",
496
+ $action: { type: "pick_date" },
497
+ });
498
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
499
+ "initial_date",
500
+ );
501
+ expect(warnings).toEqual([]);
502
+ });
503
+
504
+ it("drops a value that does not match YYYY-MM-DD and warns", () => {
505
+ const { blocks, warnings } = toSlackBlocks({
506
+ $type: "DatePicker",
507
+ value: "07/15/2026",
508
+ $action: { type: "pick_date" },
509
+ });
510
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
511
+ "initial_date",
512
+ );
513
+ expect(warnings).toContainEqual({
514
+ code: "dropped",
515
+ component: "DatePicker",
516
+ detail: "value did not match the YYYY-MM-DD format and was dropped.",
517
+ });
518
+ });
519
+ });
520
+
521
+ describe("Checkbox", () => {
522
+ it("emits a single-option checkboxes element", () => {
523
+ const { blocks } = toSlackBlocks({
524
+ $type: "Checkbox",
525
+ label: "Agree",
526
+ name: "agree",
527
+ $action: { type: "toggle" },
528
+ });
529
+ expect(blocks[0]).toEqual({
530
+ type: "actions",
531
+ elements: [
532
+ {
533
+ type: "checkboxes",
534
+ action_id: "toggle",
535
+ options: [
536
+ { text: { type: "plain_text", text: "Agree" }, value: "agree" },
537
+ ],
538
+ },
539
+ ],
540
+ });
541
+ });
542
+
543
+ it("falls back to the label as the option value when name is absent", () => {
544
+ const { blocks } = toSlackBlocks({
545
+ $type: "Checkbox",
546
+ label: "Agree",
547
+ $action: { type: "toggle" },
548
+ });
549
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
550
+ options: [{ value: "Agree" }],
551
+ });
552
+ });
553
+
554
+ it("emits initial_options with its single option when defaultChecked is true", () => {
555
+ const { blocks } = toSlackBlocks({
556
+ $type: "Checkbox",
557
+ label: "Agree",
558
+ name: "agree",
559
+ defaultChecked: true,
560
+ $action: { type: "toggle" },
561
+ });
562
+ const element = (blocks[0] as SlackActionsBlock)
563
+ .elements[0] as SlackCheckboxesElement;
564
+ expect(element.initial_options).toEqual([
565
+ { text: { type: "plain_text", text: "Agree" }, value: "agree" },
566
+ ]);
567
+ });
568
+
569
+ it("omits initial_options when defaultChecked is absent or false", () => {
570
+ const { blocks } = toSlackBlocks({
571
+ $type: "Checkbox",
572
+ label: "Agree",
573
+ $action: { type: "toggle" },
574
+ });
575
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
576
+ "initial_options",
577
+ );
578
+ });
579
+ });
580
+
581
+ describe("RadioGroup", () => {
582
+ const options = [
583
+ { label: "Small", value: "s" },
584
+ { label: "Large", value: "l" },
585
+ ];
586
+
587
+ it("sets initial_option from a matching value prop", () => {
588
+ const { blocks } = toSlackBlocks({
589
+ $type: "RadioGroup",
590
+ options,
591
+ value: "l",
592
+ $action: { type: "size" },
593
+ });
594
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
595
+ initial_option: { value: "l" },
596
+ });
597
+ });
598
+
599
+ it("sets initial_option from a matching defaultValue prop", () => {
600
+ const { blocks } = toSlackBlocks({
601
+ $type: "RadioGroup",
602
+ options,
603
+ defaultValue: "s",
604
+ $action: { type: "size" },
605
+ });
606
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
607
+ initial_option: { value: "s" },
608
+ });
609
+ });
610
+
611
+ it("omits initial_option when nothing matches", () => {
612
+ const { blocks } = toSlackBlocks({
613
+ $type: "RadioGroup",
614
+ options,
615
+ $action: { type: "size" },
616
+ });
617
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
618
+ "initial_option",
619
+ );
620
+ });
621
+
622
+ it(`clamps options past ${RADIO_OPTION_CAP} entries and warns`, () => {
623
+ const manyOptions = Array.from(
624
+ { length: RADIO_OPTION_CAP + 3 },
625
+ (_, i) => ({ label: `L${i}`, value: `v${i}` }),
626
+ );
627
+ const { blocks, warnings } = toSlackBlocks({
628
+ $type: "RadioGroup",
629
+ options: manyOptions,
630
+ $action: { type: "size" },
631
+ });
632
+ const element = (blocks[0] as SlackActionsBlock)
633
+ .elements[0] as SlackRadioButtonsElement;
634
+ expect(element.options).toHaveLength(RADIO_OPTION_CAP);
635
+ expect(warnings).toContainEqual({
636
+ code: "clamped",
637
+ component: "RadioGroup",
638
+ detail: `options were clamped to ${RADIO_OPTION_CAP} entries.`,
639
+ });
640
+ });
641
+ });
642
+
643
+ describe("interactive grouping", () => {
644
+ it("groups consecutive interactive siblings into one actions block, split by a non-interactive sibling", () => {
645
+ const root = [
646
+ { $type: "Button", label: "A", $action: { type: "a" } },
647
+ {
648
+ $type: "Select",
649
+ options: [{ label: "X", value: "x" }],
650
+ $action: { type: "b" },
651
+ },
652
+ { $type: "Text", value: "gap" },
653
+ { $type: "Button", label: "C", $action: { type: "c" } },
654
+ ];
655
+ const { blocks } = toSlackBlocks(root);
656
+ expect(blocks).toHaveLength(3);
657
+ expect(blocks[0]).toMatchObject({
658
+ type: "actions",
659
+ elements: [{ type: "button" }, { type: "static_select" }],
660
+ });
661
+ expect(blocks[1]).toMatchObject({ type: "section" });
662
+ expect(blocks[2]).toMatchObject({
663
+ type: "actions",
664
+ elements: [{ type: "button" }],
665
+ });
666
+ });
667
+
668
+ it(`splits an actions block past the ${ACTIONS_ELEMENT_CAP}-element cap`, () => {
669
+ const total = ACTIONS_ELEMENT_CAP + 5;
670
+ const buttons = Array.from({ length: total }, (_, i) => ({
671
+ $type: "Button",
672
+ label: `B${i}`,
673
+ $action: { type: `b${i}` },
674
+ }));
675
+ const { blocks, warnings } = toSlackBlocks(buttons);
676
+ expect(blocks).toHaveLength(2);
677
+ expect((blocks[0] as SlackActionsBlock).elements).toHaveLength(
678
+ ACTIONS_ELEMENT_CAP,
679
+ );
680
+ expect((blocks[1] as SlackActionsBlock).elements).toHaveLength(
681
+ total - ACTIONS_ELEMENT_CAP,
682
+ );
683
+ expect(warnings).toEqual([]);
684
+ });
685
+ });
686
+
687
+ describe("Alert", () => {
688
+ it("renders the info tone by default", () => {
689
+ const { blocks, warnings } = toSlackBlocks({
690
+ $type: "Alert",
691
+ title: "Heads up",
692
+ description: "Something happened.",
693
+ });
694
+ expect(blocks).toEqual([
695
+ {
696
+ type: "context",
697
+ elements: [{ type: "mrkdwn", text: "ℹ️ *Heads up*" }],
698
+ },
699
+ {
700
+ type: "section",
701
+ text: { type: "mrkdwn", text: "Something happened." },
702
+ },
703
+ ]);
704
+ expect(warnings).toEqual([
705
+ {
706
+ code: "fallback",
707
+ component: "Alert",
708
+ detail: "Alert was rendered with message-surface fallback blocks.",
709
+ },
710
+ ]);
711
+ });
712
+
713
+ it("renders the success tone emoji", () => {
714
+ const { blocks } = toSlackBlocks({
715
+ $type: "Alert",
716
+ title: "Done",
717
+ tone: "success",
718
+ });
719
+ expect(blocks[0]).toEqual({
720
+ type: "context",
721
+ elements: [{ type: "mrkdwn", text: "✅ *Done*" }],
722
+ });
723
+ });
724
+
725
+ it("renders the warning tone emoji", () => {
726
+ const { blocks } = toSlackBlocks({
727
+ $type: "Alert",
728
+ title: "Careful",
729
+ tone: "warning",
730
+ });
731
+ expect(blocks[0]).toEqual({
732
+ type: "context",
733
+ elements: [{ type: "mrkdwn", text: "⚠️ *Careful*" }],
734
+ });
735
+ });
736
+
737
+ it("renders the danger tone emoji", () => {
738
+ const { blocks } = toSlackBlocks({
739
+ $type: "Alert",
740
+ title: "Stop",
741
+ tone: "danger",
742
+ });
743
+ expect(blocks[0]).toEqual({
744
+ type: "context",
745
+ elements: [{ type: "mrkdwn", text: "🛑 *Stop*" }],
746
+ });
747
+ });
748
+
749
+ it("renders just the emoji when no title is given", () => {
750
+ const { blocks } = toSlackBlocks({
751
+ $type: "Alert",
752
+ description: "No title here.",
753
+ });
754
+ expect(blocks[0]).toEqual({
755
+ type: "context",
756
+ elements: [{ type: "mrkdwn", text: "ℹ️" }],
757
+ });
758
+ });
759
+
760
+ it("renders a native alert block on the modal surface, joining title and description", () => {
761
+ const { blocks, warnings } = toSlackBlocks(
762
+ {
763
+ $type: "Alert",
764
+ title: "Heads up",
765
+ description: "Something happened.",
766
+ },
767
+ { surface: "modal" },
768
+ );
769
+ expect(blocks).toEqual([
770
+ {
771
+ type: "alert",
772
+ text: { type: "mrkdwn", text: "Heads up\nSomething happened." },
773
+ level: "info",
774
+ },
775
+ ]);
776
+ expect(warnings).toEqual([]);
777
+ });
778
+
779
+ it("maps the danger tone to the error level on the modal surface", () => {
780
+ const { blocks } = toSlackBlocks(
781
+ { $type: "Alert", title: "Stop", tone: "danger" },
782
+ { surface: "modal" },
783
+ );
784
+ expect(blocks).toEqual([
785
+ {
786
+ type: "alert",
787
+ text: { type: "mrkdwn", text: "Stop" },
788
+ level: "error",
789
+ },
790
+ ]);
791
+ });
792
+
793
+ it("maps the success and warning tones to like-named levels on the modal surface", () => {
794
+ const { blocks: successBlocks } = toSlackBlocks(
795
+ { $type: "Alert", title: "Done", tone: "success" },
796
+ { surface: "modal" },
797
+ );
798
+ expect((successBlocks[0] as SlackAlertBlock).level).toBe("success");
799
+
800
+ const { blocks: warningBlocks } = toSlackBlocks(
801
+ { $type: "Alert", title: "Careful", tone: "warning" },
802
+ { surface: "modal" },
803
+ );
804
+ expect((warningBlocks[0] as SlackAlertBlock).level).toBe("warning");
805
+ });
806
+
807
+ it(`clamps modal alert text to ${ALERT_TEXT_CAP} characters and warns`, () => {
808
+ const description = "d".repeat(ALERT_TEXT_CAP + 50);
809
+ const { blocks, warnings } = toSlackBlocks(
810
+ { $type: "Alert", description },
811
+ { surface: "modal" },
812
+ );
813
+ expect((blocks[0] as SlackAlertBlock).text.text).toHaveLength(
814
+ ALERT_TEXT_CAP,
815
+ );
816
+ expect(warnings).toContainEqual({
817
+ code: "clamped",
818
+ component: "Alert",
819
+ detail: `text was clamped to ${ALERT_TEXT_CAP} characters.`,
820
+ });
821
+ });
822
+
823
+ it("keeps the message-surface fallback unchanged when the surface is explicitly set to message", () => {
824
+ const { blocks, warnings } = toSlackBlocks(
825
+ {
826
+ $type: "Alert",
827
+ title: "Heads up",
828
+ description: "Something happened.",
829
+ },
830
+ { surface: "message" },
831
+ );
832
+ expect(blocks).toEqual([
833
+ {
834
+ type: "context",
835
+ elements: [{ type: "mrkdwn", text: "ℹ️ *Heads up*" }],
836
+ },
837
+ {
838
+ type: "section",
839
+ text: { type: "mrkdwn", text: "Something happened." },
840
+ },
841
+ ]);
842
+ expect(warnings).toEqual([
843
+ {
844
+ code: "fallback",
845
+ component: "Alert",
846
+ detail: "Alert was rendered with message-surface fallback blocks.",
847
+ },
848
+ ]);
849
+ });
850
+ });
851
+
852
+ describe("Card", () => {
853
+ it("maps a clean card into a native card block with title, hero image, body, subtext, and actions", () => {
854
+ const { blocks, warnings } = toSlackBlocks({
855
+ $type: "Card",
856
+ title: "Order #42",
857
+ confirm: { label: "Ship", $action: { type: "ship" } },
858
+ cancel: { label: "Cancel", $action: { type: "cancel" } },
859
+ children: [
860
+ { $type: "Image", src: "https://x/y.png", alt: "Package" },
861
+ { $type: "Text", value: "Shipped" },
862
+ { $type: "Caption", value: "Updated just now" },
863
+ ],
864
+ });
865
+ expect(blocks).toEqual([
866
+ {
867
+ type: "card",
868
+ hero_image: {
869
+ type: "image",
870
+ image_url: "https://x/y.png",
871
+ alt_text: "Package",
872
+ },
873
+ title: { type: "mrkdwn", text: "Order #42" },
874
+ body: { type: "mrkdwn", text: "Shipped" },
875
+ subtext: { type: "mrkdwn", text: "Updated just now" },
876
+ actions: [
877
+ {
878
+ type: "button",
879
+ text: { type: "plain_text", text: "Ship" },
880
+ action_id: "ship",
881
+ style: "primary",
882
+ },
883
+ {
884
+ type: "button",
885
+ text: { type: "plain_text", text: "Cancel" },
886
+ action_id: "cancel",
887
+ },
888
+ ],
889
+ },
890
+ ]);
891
+ expect(warnings).toEqual([]);
892
+ });
893
+
894
+ it("maps confirm/cancel to the actions array directly, without a title or body", () => {
895
+ const { blocks } = toSlackBlocks({
896
+ $type: "Card",
897
+ confirm: { label: "Yes", $action: { type: "confirm_yes" } },
898
+ cancel: { label: "No", $action: { type: "confirm_no" } },
899
+ });
900
+ expect(blocks).toEqual([
901
+ {
902
+ type: "card",
903
+ actions: [
904
+ {
905
+ type: "button",
906
+ text: { type: "plain_text", text: "Yes" },
907
+ action_id: "confirm_yes",
908
+ style: "primary",
909
+ },
910
+ {
911
+ type: "button",
912
+ text: { type: "plain_text", text: "No" },
913
+ action_id: "confirm_no",
914
+ },
915
+ ],
916
+ },
917
+ ]);
918
+ });
919
+
920
+ it("falls back to a header and inline sequence when content does not fit the card shape", () => {
921
+ const { blocks, warnings } = toSlackBlocks({
922
+ $type: "Card",
923
+ title: "Order #42",
924
+ children: [
925
+ { $type: "Text", value: "Shipped" },
926
+ { $type: "Fact", label: "Carrier", value: "UPS" },
927
+ ],
928
+ });
929
+ expect(blocks).toEqual([
930
+ { type: "header", text: { type: "plain_text", text: "Order #42" } },
931
+ { type: "section", text: { type: "mrkdwn", text: "Shipped" } },
932
+ {
933
+ type: "section",
934
+ fields: [{ type: "mrkdwn", text: "*Carrier*\nUPS" }],
935
+ },
936
+ ]);
937
+ expect(warnings).toEqual([
938
+ {
939
+ code: "fallback",
940
+ component: "Card",
941
+ detail:
942
+ "A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
943
+ },
944
+ ]);
945
+ });
946
+
947
+ it("falls back when a second Image cannot fit the single hero_image slot", () => {
948
+ const { blocks, warnings } = toSlackBlocks({
949
+ $type: "Card",
950
+ title: "Gallery",
951
+ children: [
952
+ { $type: "Image", src: "https://x/1.png", alt: "One" },
953
+ { $type: "Image", src: "https://x/2.png", alt: "Two" },
954
+ ],
955
+ });
956
+ expect(blocks[0]).toMatchObject({ type: "header" });
957
+ expect(warnings).toContainEqual({
958
+ code: "fallback",
959
+ component: "Card",
960
+ detail:
961
+ "A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
962
+ });
963
+ });
964
+
965
+ it("falls back inline when the converted card content overflows the surface block budget", () => {
966
+ const headers = Array.from({ length: MESSAGE_BLOCK_CAP + 1 }, (_, i) => ({
967
+ $type: "Header",
968
+ text: `H${i}`,
969
+ }));
970
+ const { blocks, warnings } = toSlackBlocks({
971
+ $type: "Card",
972
+ title: "Big",
973
+ children: headers,
974
+ });
975
+ expect(blocks[0]).toEqual({
976
+ type: "header",
977
+ text: { type: "plain_text", text: "Big" },
978
+ });
979
+ expect(blocks).toHaveLength(MESSAGE_BLOCK_CAP);
980
+ expect(blocks[blocks.length - 1]).toMatchObject({ type: "context" });
981
+ expect(warnings).toContainEqual({
982
+ code: "fallback",
983
+ component: "Card",
984
+ detail:
985
+ "A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
986
+ });
987
+ expect(
988
+ warnings.some((w) => w.code === "clamped" && w.component === "Root"),
989
+ ).toBe(true);
990
+ });
991
+
992
+ it(`clamps the title to ${CARD_TITLE_CAP} characters and warns`, () => {
993
+ const title = "c".repeat(200);
994
+ const { blocks, warnings } = toSlackBlocks({ $type: "Card", title });
995
+ expect(blocks).toEqual([
996
+ {
997
+ type: "card",
998
+ title: { type: "mrkdwn", text: "c".repeat(CARD_TITLE_CAP) },
999
+ },
1000
+ ]);
1001
+ expect(warnings).toContainEqual({
1002
+ code: "clamped",
1003
+ component: "Card",
1004
+ detail: `title was clamped to ${CARD_TITLE_CAP} characters.`,
1005
+ });
1006
+ });
1007
+
1008
+ it(`clamps body to ${CARD_BODY_CAP} characters and warns`, () => {
1009
+ const value = "b".repeat(CARD_BODY_CAP + 50);
1010
+ const { blocks, warnings } = toSlackBlocks({
1011
+ $type: "Card",
1012
+ children: [{ $type: "Text", value }],
1013
+ });
1014
+ const card = blocks[0] as SlackCardBlock;
1015
+ expect(card.body?.text).toHaveLength(CARD_BODY_CAP);
1016
+ expect(warnings).toContainEqual({
1017
+ code: "clamped",
1018
+ component: "Card",
1019
+ detail: `body was clamped to ${CARD_BODY_CAP} characters.`,
1020
+ });
1021
+ });
1022
+
1023
+ it(`clamps subtext to ${CARD_SUBTEXT_CAP} characters and warns`, () => {
1024
+ const value = "s".repeat(CARD_SUBTEXT_CAP + 50);
1025
+ const { blocks, warnings } = toSlackBlocks({
1026
+ $type: "Card",
1027
+ title: "Order",
1028
+ children: [{ $type: "Caption", value }],
1029
+ });
1030
+ const card = blocks[0] as SlackCardBlock;
1031
+ expect(card.subtext?.text).toHaveLength(CARD_SUBTEXT_CAP);
1032
+ expect(warnings).toContainEqual({
1033
+ code: "clamped",
1034
+ component: "Card",
1035
+ detail: `subtext was clamped to ${CARD_SUBTEXT_CAP} characters.`,
1036
+ });
1037
+ });
1038
+
1039
+ it("drops a card with none of hero_image, title, body, or actions instead of emitting an invalid block", () => {
1040
+ const { blocks, warnings } = toSlackBlocks({ $type: "Card" });
1041
+ expect(blocks).toEqual([]);
1042
+ expect(warnings).toContainEqual({
1043
+ code: "dropped",
1044
+ component: "Card",
1045
+ detail:
1046
+ "A card with none of hero_image, title, body, or actions was dropped.",
1047
+ });
1048
+ });
1049
+ });
1050
+
1051
+ describe("Carousel", () => {
1052
+ it("converts clean Card children into carousel elements", () => {
1053
+ const { blocks, warnings } = toSlackBlocks({
1054
+ $type: "Carousel",
1055
+ children: [
1056
+ {
1057
+ $type: "Card",
1058
+ title: "One",
1059
+ children: [{ $type: "Text", value: "First" }],
1060
+ },
1061
+ {
1062
+ $type: "Card",
1063
+ title: "Two",
1064
+ children: [{ $type: "Text", value: "Second" }],
1065
+ },
1066
+ ],
1067
+ });
1068
+ expect(blocks).toEqual([
1069
+ {
1070
+ type: "carousel",
1071
+ elements: [
1072
+ {
1073
+ type: "card",
1074
+ title: { type: "mrkdwn", text: "One" },
1075
+ body: { type: "mrkdwn", text: "First" },
1076
+ },
1077
+ {
1078
+ type: "card",
1079
+ title: { type: "mrkdwn", text: "Two" },
1080
+ body: { type: "mrkdwn", text: "Second" },
1081
+ },
1082
+ ],
1083
+ },
1084
+ ]);
1085
+ expect(warnings).toEqual([]);
1086
+ });
1087
+
1088
+ it(`clamps to ${CAROUSEL_CARD_CAP} elements and warns`, () => {
1089
+ const cards = Array.from({ length: CAROUSEL_CARD_CAP + 1 }, (_, i) => ({
1090
+ $type: "Card",
1091
+ title: `Card ${i}`,
1092
+ }));
1093
+ const { blocks, warnings } = toSlackBlocks({
1094
+ $type: "Carousel",
1095
+ children: cards,
1096
+ });
1097
+ expect((blocks[0] as SlackCarouselBlock).elements).toHaveLength(
1098
+ CAROUSEL_CARD_CAP,
1099
+ );
1100
+ expect(warnings).toContainEqual({
1101
+ code: "clamped",
1102
+ component: "Carousel",
1103
+ detail: `cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
1104
+ });
1105
+ });
1106
+
1107
+ it("drops non-card children with a warning, keeping valid cards", () => {
1108
+ const { blocks, warnings } = toSlackBlocks({
1109
+ $type: "Carousel",
1110
+ children: [
1111
+ { $type: "Card", title: "Kept" },
1112
+ { $type: "Text", value: "not a card" },
1113
+ ],
1114
+ });
1115
+ expect((blocks[0] as SlackCarouselBlock).elements).toEqual([
1116
+ { type: "card", title: { type: "mrkdwn", text: "Kept" } },
1117
+ ]);
1118
+ expect(warnings).toContainEqual({
1119
+ code: "dropped",
1120
+ component: "Carousel",
1121
+ detail: "A non-card child was dropped.",
1122
+ });
1123
+ });
1124
+
1125
+ it("degrades a child card that cannot map cleanly instead of falling back to a block sequence", () => {
1126
+ const { blocks, warnings } = toSlackBlocks({
1127
+ $type: "Carousel",
1128
+ children: [
1129
+ {
1130
+ $type: "Card",
1131
+ title: "Order #1",
1132
+ children: [
1133
+ { $type: "Text", value: "Shipped" },
1134
+ { $type: "Fact", label: "Carrier", value: "UPS" },
1135
+ ],
1136
+ },
1137
+ ],
1138
+ });
1139
+ expect(blocks).toEqual([
1140
+ {
1141
+ type: "carousel",
1142
+ elements: [
1143
+ {
1144
+ type: "card",
1145
+ title: { type: "mrkdwn", text: "Order #1" },
1146
+ body: { type: "mrkdwn", text: "Shipped\nUPS\nCarrier" },
1147
+ },
1148
+ ],
1149
+ },
1150
+ ]);
1151
+ expect(warnings).toContainEqual({
1152
+ code: "clamped",
1153
+ component: "Card",
1154
+ detail: "A card inside a carousel was degraded to title and body.",
1155
+ });
1156
+ });
1157
+
1158
+ it("uses the first text chunk as the title when a degraded card has no title of its own", () => {
1159
+ const { blocks } = toSlackBlocks({
1160
+ $type: "Carousel",
1161
+ children: [
1162
+ {
1163
+ $type: "Card",
1164
+ children: [
1165
+ { $type: "Fact", label: "Carrier", value: "UPS" },
1166
+ { $type: "Fact", label: "Status", value: "Delivered" },
1167
+ ],
1168
+ },
1169
+ ],
1170
+ });
1171
+ const carousel = blocks[0] as SlackCarouselBlock;
1172
+ expect(carousel.elements[0]).toEqual({
1173
+ type: "card",
1174
+ title: { type: "mrkdwn", text: "UPS" },
1175
+ body: { type: "mrkdwn", text: "Carrier\nDelivered\nStatus" },
1176
+ });
1177
+ });
1178
+
1179
+ it("is dropped when it has no renderable cards", () => {
1180
+ const { blocks, warnings } = toSlackBlocks({
1181
+ $type: "Carousel",
1182
+ children: [{ $type: "Text", value: "not a card" }],
1183
+ });
1184
+ expect(blocks).toEqual([]);
1185
+ expect(warnings).toContainEqual({
1186
+ code: "dropped",
1187
+ component: "Carousel",
1188
+ detail: "A carousel without renderable cards was dropped.",
1189
+ });
1190
+ });
1191
+
1192
+ it("drops a child card that degrades to empty, which then trips the zero-renderable-cards carousel drop", () => {
1193
+ const { blocks, warnings } = toSlackBlocks({
1194
+ $type: "Carousel",
1195
+ children: [{ $type: "Card", children: [{ $type: "Divider" }] }],
1196
+ });
1197
+ expect(blocks).toEqual([]);
1198
+ expect(warnings).toContainEqual({
1199
+ code: "dropped",
1200
+ component: "Card",
1201
+ detail:
1202
+ "A card with none of hero_image, title, body, or actions was dropped.",
1203
+ });
1204
+ expect(warnings).toContainEqual({
1205
+ code: "dropped",
1206
+ component: "Carousel",
1207
+ detail: "A carousel without renderable cards was dropped.",
1208
+ });
1209
+ });
1210
+ });
1211
+
1212
+ describe("Col and Box", () => {
1213
+ it("flatten children in order", () => {
1214
+ const { blocks } = toSlackBlocks({
1215
+ $type: "Col",
1216
+ children: [
1217
+ { $type: "Header", text: "Hi" },
1218
+ { $type: "Text", value: "There" },
1219
+ ],
1220
+ });
1221
+ expect(blocks).toEqual([
1222
+ { type: "header", text: { type: "plain_text", text: "Hi" } },
1223
+ { type: "section", text: { type: "mrkdwn", text: "There" } },
1224
+ ]);
1225
+ });
1226
+
1227
+ it("Box flattens the same way", () => {
1228
+ const { blocks } = toSlackBlocks({
1229
+ $type: "Box",
1230
+ children: { $type: "Divider" },
1231
+ });
1232
+ expect(blocks).toEqual([{ type: "divider" }]);
1233
+ });
1234
+ });
1235
+
1236
+ describe("Row", () => {
1237
+ it("flattens children in order when they are not all Badge/Caption", () => {
1238
+ const { blocks } = toSlackBlocks({
1239
+ $type: "Row",
1240
+ children: [
1241
+ { $type: "Header", text: "Hi" },
1242
+ { $type: "Text", value: "There" },
1243
+ ],
1244
+ });
1245
+ expect(blocks).toEqual([
1246
+ { type: "header", text: { type: "plain_text", text: "Hi" } },
1247
+ { type: "section", text: { type: "mrkdwn", text: "There" } },
1248
+ ]);
1249
+ });
1250
+
1251
+ it("merges an all-Badge/Caption row into one context block", () => {
1252
+ const { blocks } = toSlackBlocks({
1253
+ $type: "Row",
1254
+ children: [
1255
+ { $type: "Badge", value: "New" },
1256
+ { $type: "Caption", value: "since today" },
1257
+ ],
1258
+ });
1259
+ expect(blocks).toEqual([
1260
+ {
1261
+ type: "context",
1262
+ elements: [
1263
+ { type: "mrkdwn", text: "New" },
1264
+ { type: "mrkdwn", text: "since today" },
1265
+ ],
1266
+ },
1267
+ ]);
1268
+ });
1269
+
1270
+ it("does not merge into context when a non-Badge/Caption child is present", () => {
1271
+ const { blocks } = toSlackBlocks({
1272
+ $type: "Row",
1273
+ children: [{ $type: "Badge", value: "New" }, "plain text"],
1274
+ });
1275
+ expect(blocks).toEqual([
1276
+ { type: "context", elements: [{ type: "mrkdwn", text: "New" }] },
1277
+ { type: "section", text: { type: "mrkdwn", text: "plain text" } },
1278
+ ]);
1279
+ });
1280
+
1281
+ it(`clamps a Badge/Caption row to ${CONTEXT_ELEMENT_CAP} elements and warns`, () => {
1282
+ const badges = Array.from(
1283
+ { length: CONTEXT_ELEMENT_CAP + 2 },
1284
+ (_, i) => ({ $type: "Badge", value: `B${i}` }),
1285
+ );
1286
+ const { blocks, warnings } = toSlackBlocks({
1287
+ $type: "Row",
1288
+ children: badges,
1289
+ });
1290
+ expect((blocks[0] as SlackContextBlock).elements).toHaveLength(
1291
+ CONTEXT_ELEMENT_CAP,
1292
+ );
1293
+ expect(warnings).toEqual([
1294
+ {
1295
+ code: "clamped",
1296
+ component: "Row",
1297
+ detail: `context elements were clamped to ${CONTEXT_ELEMENT_CAP} entries.`,
1298
+ },
1299
+ ]);
1300
+ });
1301
+ });
1302
+
1303
+ describe("context text cap", () => {
1304
+ it(`clamps Caption, standalone Badge, and Row-merged badge/caption text to ${CONTEXT_TEXT_CAP} characters, warning on each`, () => {
1305
+ const long = "c".repeat(CONTEXT_TEXT_CAP + 50);
1306
+
1307
+ const caption = toSlackBlocks({ $type: "Caption", value: long });
1308
+ expect(
1309
+ (caption.blocks[0] as SlackContextBlock).elements[0]?.text,
1310
+ ).toHaveLength(CONTEXT_TEXT_CAP);
1311
+ expect(caption.warnings).toContainEqual({
1312
+ code: "clamped",
1313
+ component: "Caption",
1314
+ detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
1315
+ });
1316
+
1317
+ const badge = toSlackBlocks({ $type: "Badge", value: long });
1318
+ expect(
1319
+ (badge.blocks[0] as SlackContextBlock).elements[0]?.text,
1320
+ ).toHaveLength(CONTEXT_TEXT_CAP);
1321
+ expect(badge.warnings).toContainEqual({
1322
+ code: "clamped",
1323
+ component: "Badge",
1324
+ detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
1325
+ });
1326
+
1327
+ const row = toSlackBlocks({
1328
+ $type: "Row",
1329
+ children: [
1330
+ { $type: "Badge", value: long },
1331
+ { $type: "Caption", value: "short" },
1332
+ ],
1333
+ });
1334
+ expect(
1335
+ (row.blocks[0] as SlackContextBlock).elements[0]?.text,
1336
+ ).toHaveLength(CONTEXT_TEXT_CAP);
1337
+ expect(row.warnings).toContainEqual({
1338
+ code: "clamped",
1339
+ component: "Badge",
1340
+ detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
1341
+ });
1342
+ });
1343
+ });
1344
+
1345
+ describe("Spacer and Icon", () => {
1346
+ it("are dropped silently", () => {
1347
+ const { blocks, warnings } = toSlackBlocks({
1348
+ $type: "Col",
1349
+ children: [
1350
+ { $type: "Spacer" },
1351
+ { $type: "Icon", name: "star" },
1352
+ { $type: "Text", value: "kept" },
1353
+ ],
1354
+ });
1355
+ expect(blocks).toEqual([
1356
+ { type: "section", text: { type: "mrkdwn", text: "kept" } },
1357
+ ]);
1358
+ expect(warnings).toEqual([]);
1359
+ });
1360
+ });
1361
+
1362
+ describe("Table", () => {
1363
+ it("converts columns and rows into a header-first rows array with typed cells", () => {
1364
+ const { blocks } = toSlackBlocks({
1365
+ $type: "Table",
1366
+ columns: [{ label: "Name" }, { label: "Age" }],
1367
+ rows: [
1368
+ ["Ada", 36],
1369
+ ["Bob", 24],
1370
+ ],
1371
+ });
1372
+ expect(blocks).toEqual([
1373
+ {
1374
+ type: "data_table",
1375
+ caption: TABLE_CAPTION,
1376
+ rows: [
1377
+ [
1378
+ { type: "raw_text", text: "Name" },
1379
+ { type: "raw_text", text: "Age" },
1380
+ ],
1381
+ [
1382
+ { type: "raw_text", text: "Ada" },
1383
+ { type: "raw_number", text: "36" },
1384
+ ],
1385
+ [
1386
+ { type: "raw_text", text: "Bob" },
1387
+ { type: "raw_number", text: "24" },
1388
+ ],
1389
+ ],
1390
+ },
1391
+ ]);
1392
+ });
1393
+
1394
+ it("requires a caption on every emitted table", () => {
1395
+ const { blocks } = toSlackBlocks({
1396
+ $type: "Table",
1397
+ columns: [{ label: "Name" }],
1398
+ rows: [["Ada"]],
1399
+ });
1400
+ expect((blocks[0] as SlackDataTableBlock).caption).toBe(TABLE_CAPTION);
1401
+ });
1402
+
1403
+ it(`clamps data rows to ${DATA_TABLE_ROW_CAP} (plus the header row) and columns to ${DATA_TABLE_COLUMN_CAP}, warning on each`, () => {
1404
+ const columnCount = DATA_TABLE_COLUMN_CAP + 3;
1405
+ const columns = Array.from({ length: columnCount }, () => ({
1406
+ label: "C",
1407
+ }));
1408
+ const rows = Array.from({ length: DATA_TABLE_ROW_CAP + 5 }, () =>
1409
+ Array.from({ length: columnCount }, () => "x"),
1410
+ );
1411
+ const { blocks, warnings } = toSlackBlocks({
1412
+ $type: "Table",
1413
+ columns,
1414
+ rows,
1415
+ });
1416
+ const table = blocks[0] as SlackDataTableBlock;
1417
+ expect(table.rows).toHaveLength(DATA_TABLE_ROW_CAP + 1);
1418
+ expect(table.rows[0]).toHaveLength(DATA_TABLE_COLUMN_CAP);
1419
+ expect(table.rows[1]).toHaveLength(DATA_TABLE_COLUMN_CAP);
1420
+ expect(warnings).toContainEqual({
1421
+ code: "clamped",
1422
+ component: "Table",
1423
+ detail: `columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
1424
+ });
1425
+ expect(warnings).toContainEqual({
1426
+ code: "clamped",
1427
+ component: "Table",
1428
+ detail: `rows were clamped to ${DATA_TABLE_ROW_CAP} entries.`,
1429
+ });
1430
+ expect(warnings.some((w) => w.detail.includes("table budget"))).toBe(
1431
+ false,
1432
+ );
1433
+ });
1434
+
1435
+ it(`clamps rows to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget, always keeping the header row`, () => {
1436
+ const bigCell = "z".repeat(2000);
1437
+ const rows = Array.from({ length: 6 }, (_, r) => [`row${r}`, bigCell]);
1438
+ const { blocks, warnings } = toSlackBlocks({
1439
+ $type: "Table",
1440
+ columns: [{ label: "Id" }, { label: "Blob" }],
1441
+ rows,
1442
+ });
1443
+ const table = blocks[0] as SlackDataTableBlock;
1444
+ expect(table.rows.length).toBeLessThan(rows.length + 1);
1445
+ expect(table.rows[0]).toEqual([
1446
+ { type: "raw_text", text: "Id" },
1447
+ { type: "raw_text", text: "Blob" },
1448
+ ]);
1449
+ expect(warnings).toContainEqual({
1450
+ code: "clamped",
1451
+ component: "Table",
1452
+ detail: `rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
1453
+ });
1454
+ });
1455
+
1456
+ it("shares the character budget across multiple data-table blocks in one payload", () => {
1457
+ const filler = "f".repeat(9994);
1458
+ const { blocks, warnings } = toSlackBlocks([
1459
+ { $type: "Table", columns: [{ label: "A" }], rows: [[filler]] },
1460
+ { $type: "Table", columns: [{ label: "B" }], rows: [["second"]] },
1461
+ ]);
1462
+ const first = blocks[0] as SlackDataTableBlock;
1463
+ const second = blocks[1] as SlackDataTableBlock;
1464
+ expect(first.rows).toEqual([
1465
+ [{ type: "raw_text", text: "A" }],
1466
+ [{ type: "raw_text", text: filler }],
1467
+ ]);
1468
+ expect(second.rows).toEqual([[{ type: "raw_text", text: "B" }]]);
1469
+ expect(warnings).toContainEqual({
1470
+ code: "clamped",
1471
+ component: "Table",
1472
+ detail: `rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
1473
+ });
1474
+ });
1475
+
1476
+ it("synthesizes an empty-cell header row matching the data width when rows are given without columns", () => {
1477
+ const { blocks } = toSlackBlocks({
1478
+ $type: "Table",
1479
+ rows: [
1480
+ ["Ada", 36],
1481
+ ["Bob", 24],
1482
+ ],
1483
+ });
1484
+ const table = blocks[0] as SlackDataTableBlock;
1485
+ expect(table.rows[0]).toEqual([
1486
+ { type: "raw_text", text: "" },
1487
+ { type: "raw_text", text: "" },
1488
+ ]);
1489
+ expect(table.rows).toEqual([
1490
+ table.rows[0],
1491
+ [
1492
+ { type: "raw_text", text: "Ada" },
1493
+ { type: "raw_number", text: "36" },
1494
+ ],
1495
+ [
1496
+ { type: "raw_text", text: "Bob" },
1497
+ { type: "raw_number", text: "24" },
1498
+ ],
1499
+ ]);
1500
+ });
1501
+
1502
+ it(`counts the header row's own cell text toward the ${DATA_TABLE_CHAR_BUDGET}-character budget`, () => {
1503
+ const bigHeader = "H".repeat(DATA_TABLE_CHAR_BUDGET - 1);
1504
+ const rows = Array.from({ length: 50 }, () => ["y"]);
1505
+ const { blocks } = toSlackBlocks({
1506
+ $type: "Table",
1507
+ columns: [{ label: bigHeader }],
1508
+ rows,
1509
+ });
1510
+ const table = blocks[0] as SlackDataTableBlock;
1511
+ expect(table.rows).toHaveLength(2);
1512
+ });
1513
+ });
1514
+
1515
+ describe("Markdown", () => {
1516
+ it("converts to a markdown block", () => {
1517
+ const { blocks, warnings } = toSlackBlocks({
1518
+ $type: "Markdown",
1519
+ value: "# Title",
1520
+ });
1521
+ expect(blocks).toEqual([{ type: "markdown", text: "# Title" }]);
1522
+ expect(warnings).toEqual([]);
1523
+ });
1524
+
1525
+ it(`falls back to a section once the cumulative ${MARKDOWN_TEXT_BUDGET}-character budget is exceeded`, () => {
1526
+ const first = "a".repeat(MARKDOWN_TEXT_BUDGET - 1000);
1527
+ const second = "b".repeat(2000);
1528
+ const third = "c".repeat(10);
1529
+ const { blocks, warnings } = toSlackBlocks([
1530
+ { $type: "Markdown", value: first },
1531
+ { $type: "Markdown", value: second },
1532
+ { $type: "Markdown", value: third },
1533
+ ]);
1534
+ expect(blocks[0]).toEqual({ type: "markdown", text: first });
1535
+ expect(blocks[1]).toEqual({
1536
+ type: "section",
1537
+ text: { type: "mrkdwn", text: second },
1538
+ });
1539
+ expect(blocks[2]).toEqual({
1540
+ type: "section",
1541
+ text: { type: "mrkdwn", text: third },
1542
+ });
1543
+ expect(warnings.filter((w) => w.component === "Markdown")).toHaveLength(
1544
+ 2,
1545
+ );
1546
+ expect(warnings).toContainEqual({
1547
+ code: "fallback",
1548
+ component: "Markdown",
1549
+ detail: `The ${MARKDOWN_TEXT_BUDGET}-character markdown budget was exceeded.`,
1550
+ });
1551
+ });
1552
+ });
1553
+
1554
+ describe("Chart", () => {
1555
+ it("is dropped and replaced by an omission note", () => {
1556
+ const { blocks, warnings } = toSlackBlocks({
1557
+ $type: "Chart",
1558
+ variant: "bar",
1559
+ data: [{ value: 1 }],
1560
+ });
1561
+ expect(blocks).toEqual([
1562
+ {
1563
+ type: "context",
1564
+ elements: [{ type: "mrkdwn", text: "Chart omitted on Slack." }],
1565
+ },
1566
+ ]);
1567
+ expect(warnings).toEqual([
1568
+ {
1569
+ code: "dropped",
1570
+ component: "Chart",
1571
+ detail: "Chart was replaced by a Slack omission note.",
1572
+ },
1573
+ ]);
1574
+ });
1575
+ });
1576
+
1577
+ describe("ListView and ListViewItem", () => {
1578
+ it("renders each item as a section, separated by dividers", () => {
1579
+ const { blocks } = toSlackBlocks({
1580
+ $type: "ListView",
1581
+ children: [
1582
+ {
1583
+ $type: "ListViewItem",
1584
+ children: { $type: "Text", value: "Row 1" },
1585
+ },
1586
+ {
1587
+ $type: "ListViewItem",
1588
+ children: { $type: "Text", value: "Row 2" },
1589
+ },
1590
+ ],
1591
+ });
1592
+ expect(blocks).toEqual([
1593
+ { type: "section", text: { type: "mrkdwn", text: "Row 1" } },
1594
+ { type: "divider" },
1595
+ { type: "section", text: { type: "mrkdwn", text: "Row 2" } },
1596
+ ]);
1597
+ });
1598
+
1599
+ it("turns an item's $action into a button accessory", () => {
1600
+ const { blocks } = toSlackBlocks({
1601
+ $type: "ListView",
1602
+ children: [
1603
+ {
1604
+ $type: "ListViewItem",
1605
+ $action: { type: "open_row" },
1606
+ children: { $type: "Text", value: "Clickable" },
1607
+ },
1608
+ ],
1609
+ });
1610
+ expect(blocks).toEqual([
1611
+ {
1612
+ type: "section",
1613
+ text: { type: "mrkdwn", text: "Clickable" },
1614
+ accessory: {
1615
+ type: "button",
1616
+ text: { type: "plain_text", text: "Open" },
1617
+ action_id: "open_row",
1618
+ },
1619
+ },
1620
+ ]);
1621
+ });
1622
+ });
1623
+
1624
+ describe("Form", () => {
1625
+ it("converts children normally and appends a submit actions block", () => {
1626
+ const { blocks } = toSlackBlocks({
1627
+ $type: "Form",
1628
+ $action: { type: "submit_signup" },
1629
+ children: [{ $type: "Input", label: "Name", name: "name" }],
1630
+ });
1631
+ expect(blocks).toEqual([
1632
+ {
1633
+ type: "input",
1634
+ label: { type: "plain_text", text: "Name" },
1635
+ element: { type: "plain_text_input", action_id: "action" },
1636
+ },
1637
+ {
1638
+ type: "actions",
1639
+ elements: [
1640
+ {
1641
+ type: "button",
1642
+ text: { type: "plain_text", text: "Submit" },
1643
+ action_id: "submit_signup",
1644
+ style: "primary",
1645
+ },
1646
+ ],
1647
+ },
1648
+ ]);
1649
+ });
1650
+ });
1651
+
1652
+ describe("Root", () => {
1653
+ it("converts a root array of mixed components sequentially", () => {
1654
+ const { blocks } = toSlackBlocks([
1655
+ { $type: "Header", text: "Title" },
1656
+ { $type: "Divider" },
1657
+ { $type: "Text", value: "Body" },
1658
+ ]);
1659
+ expect(blocks).toEqual([
1660
+ { type: "header", text: { type: "plain_text", text: "Title" } },
1661
+ { type: "divider" },
1662
+ { type: "section", text: { type: "mrkdwn", text: "Body" } },
1663
+ ]);
1664
+ });
1665
+
1666
+ it("clamps to the surface cap and appends an omission note", () => {
1667
+ const count = MESSAGE_BLOCK_CAP + 5;
1668
+ const nodes = Array.from({ length: count }, (_, i) => ({
1669
+ $type: "Header",
1670
+ text: `H${i}`,
1671
+ }));
1672
+ const { blocks, warnings } = toSlackBlocks(nodes);
1673
+ expect(blocks).toHaveLength(MESSAGE_BLOCK_CAP);
1674
+ const omitted = count - (MESSAGE_BLOCK_CAP - 1);
1675
+ expect(blocks[blocks.length - 1]).toEqual({
1676
+ type: "context",
1677
+ elements: [{ type: "mrkdwn", text: `${omitted} blocks were omitted.` }],
1678
+ });
1679
+ expect(warnings).toContainEqual({
1680
+ code: "clamped",
1681
+ component: "Root",
1682
+ detail: `${omitted} blocks were omitted.`,
1683
+ });
1684
+ });
1685
+
1686
+ it("uses a 100-block cap for the modal surface", () => {
1687
+ const count = MODAL_BLOCK_CAP + 3;
1688
+ const nodes = Array.from({ length: count }, (_, i) => ({
1689
+ $type: "Header",
1690
+ text: `H${i}`,
1691
+ }));
1692
+ const { blocks } = toSlackBlocks(nodes, { surface: "modal" });
1693
+ expect(blocks).toHaveLength(MODAL_BLOCK_CAP);
1694
+ });
1695
+
1696
+ it("does not clamp when under budget", () => {
1697
+ const { blocks, warnings } = toSlackBlocks([
1698
+ { $type: "Header", text: "One" },
1699
+ ]);
1700
+ expect(blocks).toHaveLength(1);
1701
+ expect(warnings).toEqual([]);
1702
+ });
1703
+ });
1704
+
1705
+ describe("bare children", () => {
1706
+ it("strings and numbers inside a flattened container become section mrkdwn blocks", () => {
1707
+ const { blocks } = toSlackBlocks({
1708
+ $type: "Col",
1709
+ children: ["hello", 42],
1710
+ });
1711
+ expect(blocks).toEqual([
1712
+ { type: "section", text: { type: "mrkdwn", text: "hello" } },
1713
+ { type: "section", text: { type: "mrkdwn", text: "42" } },
1714
+ ]);
1715
+ });
1716
+ });
1717
+
1718
+ describe("unknown component types", () => {
1719
+ it("are skipped with a dropped warning", () => {
1720
+ const { blocks, warnings } = toSlackBlocks({
1721
+ $type: "TotallyMadeUp",
1722
+ foo: "bar",
1723
+ });
1724
+ expect(blocks).toEqual([]);
1725
+ expect(warnings).toEqual([
1726
+ {
1727
+ code: "dropped",
1728
+ component: "TotallyMadeUp",
1729
+ detail: "Unknown component type was dropped.",
1730
+ },
1731
+ ]);
1732
+ });
1733
+ });
1734
+
1735
+ describe("depth bound", () => {
1736
+ it("does not throw on 65 levels of nesting", () => {
1737
+ let node: unknown = { $type: "Text", value: "deep" };
1738
+ for (let i = 0; i < 65; i++) {
1739
+ node = { $type: "Col", children: node };
1740
+ }
1741
+ expect(() => toSlackBlocks(node)).not.toThrow();
1742
+ const { blocks } = toSlackBlocks(node);
1743
+ expect(Array.isArray(blocks)).toBe(true);
1744
+ });
1745
+ });
1746
+
1747
+ describe("never throws", () => {
1748
+ it("returns a well-formed result instead of throwing for degenerate input", () => {
1749
+ const degenerateInputs: unknown[] = [
1750
+ null,
1751
+ undefined,
1752
+ 42,
1753
+ "just text",
1754
+ true,
1755
+ false,
1756
+ [],
1757
+ {},
1758
+ () => {},
1759
+ Symbol("x"),
1760
+ ];
1761
+ for (const input of degenerateInputs) {
1762
+ const result = toSlackBlocks(input);
1763
+ expect(Array.isArray(result.blocks)).toBe(true);
1764
+ expect(Array.isArray(result.warnings)).toBe(true);
1765
+ }
1766
+ });
1767
+
1768
+ it("bounds a hostile root-level Proxy array with a fabricated length instead of hanging", () => {
1769
+ const hostileRoot = new Proxy([], {
1770
+ get(target, prop) {
1771
+ if (prop === "length") return 2 ** 31;
1772
+ if (typeof prop === "string" && /^\d+$/.test(prop)) {
1773
+ return { $type: "Text", value: "x" };
1774
+ }
1775
+ return Reflect.get(target, prop);
1776
+ },
1777
+ has(target, prop) {
1778
+ if (prop === "length") return true;
1779
+ if (typeof prop === "string" && /^\d+$/.test(prop)) return true;
1780
+ return Reflect.has(target, prop);
1781
+ },
1782
+ });
1783
+ const { blocks, warnings } = toSlackBlocks(hostileRoot);
1784
+ expect(Array.isArray(blocks)).toBe(true);
1785
+ expect(warnings).toContainEqual(
1786
+ expect.objectContaining({ code: "clamped", component: "Root" }),
1787
+ );
1788
+ });
1789
+
1790
+ it("omits the button value instead of throwing when the action payload is circular", () => {
1791
+ const circular: Record<string, unknown> = { type: "loop" };
1792
+ circular["self"] = circular;
1793
+ expect(() =>
1794
+ toSlackBlocks({ $type: "Button", label: "Go", $action: circular }),
1795
+ ).not.toThrow();
1796
+ const { blocks } = toSlackBlocks({
1797
+ $type: "Button",
1798
+ label: "Go",
1799
+ $action: circular,
1800
+ });
1801
+ expect(blocks[0]).toEqual({
1802
+ type: "actions",
1803
+ elements: [
1804
+ {
1805
+ type: "button",
1806
+ text: { type: "plain_text", text: "Go" },
1807
+ action_id: "loop",
1808
+ },
1809
+ ],
1810
+ });
1811
+ });
1812
+ });
1813
+
1814
+ describe("node budget and cycle guard", () => {
1815
+ it("bounds a self-referential children array instead of looping forever", () => {
1816
+ const arr: unknown[] = [];
1817
+ const el = { $type: "Card", children: arr };
1818
+ arr.push(el);
1819
+ const { warnings } = toSlackBlocks(el);
1820
+ expect(warnings).toContainEqual({
1821
+ code: "clamped",
1822
+ component: "Root",
1823
+ detail: "a self-referencing node was dropped.",
1824
+ });
1825
+ });
1826
+
1827
+ it("bounds a shared-reference fan-out across nested levels with a budget warning instead of doing exponential work", () => {
1828
+ let shared: unknown = { $type: "Text", value: "leaf" };
1829
+ for (let level = 0; level < 3; level++) {
1830
+ shared = {
1831
+ $type: "Card",
1832
+ children: Array.from({ length: 200 }, () => shared),
1833
+ };
1834
+ }
1835
+ const { warnings } = toSlackBlocks(shared);
1836
+ expect(warnings).toContainEqual({
1837
+ code: "clamped",
1838
+ component: "Root",
1839
+ detail: `the tree was truncated after ${NODE_BUDGET} nodes.`,
1840
+ });
1841
+ });
1842
+
1843
+ it("clamps a benign tree past the node budget with exactly one budget warning", () => {
1844
+ const rows = Array.from({ length: 60 }, (_, r) => ({
1845
+ $type: "Col",
1846
+ children: Array.from({ length: 100 }, (_, c) => ({
1847
+ $type: "Caption",
1848
+ value: `r${r}c${c}`,
1849
+ })),
1850
+ }));
1851
+ const { blocks, warnings } = toSlackBlocks(rows);
1852
+ expect(blocks.length).toBeLessThan(6000);
1853
+ const budgetWarnings = warnings.filter(
1854
+ (warning) =>
1855
+ warning.code === "clamped" &&
1856
+ warning.component === "Root" &&
1857
+ warning.detail ===
1858
+ `the tree was truncated after ${NODE_BUDGET} nodes.`,
1859
+ );
1860
+ expect(budgetWarnings).toHaveLength(1);
1861
+ });
1862
+
1863
+ it("clamps a shared-reference array past the children cap without treating the reuse as a cycle", () => {
1864
+ const card = { $type: "Card", title: "shared" };
1865
+ let node: unknown = Array.from({ length: 201 }, () => card);
1866
+ for (let level = 0; level < 3; level++) {
1867
+ node = { $type: "Col", children: node };
1868
+ }
1869
+ const { warnings } = toSlackBlocks(node);
1870
+ expect(warnings).toContainEqual({
1871
+ code: "clamped",
1872
+ component: "Root",
1873
+ detail: `children were clamped to ${CHILDREN_CAP} entries.`,
1874
+ });
1875
+ expect(
1876
+ warnings.some((warning) => warning.detail.includes("self-referencing")),
1877
+ ).toBe(false);
1878
+ });
1879
+
1880
+ it("converts a 70-level-deep chain successfully and reports the depth detail", () => {
1881
+ let node: unknown = { $type: "Caption", value: "x" };
1882
+ for (let i = 0; i < 70; i++) {
1883
+ node = { $type: "Card", children: [node] };
1884
+ }
1885
+ expect(() => toSlackBlocks(node)).not.toThrow();
1886
+ const { warnings } = toSlackBlocks(node);
1887
+ expect(warnings).toContainEqual({
1888
+ code: "clamped",
1889
+ component: "Root",
1890
+ detail: "nodes deeper than 64 levels were dropped.",
1891
+ });
1892
+ });
1893
+ });
1894
+ });
1895
+
1896
+ describe("toSlackBlocks data_table integrity", () => {
1897
+ it("preserves cell positions when a value is unsupported", () => {
1898
+ const { blocks } = toSlackBlocks({
1899
+ $type: "Table",
1900
+ columns: [{ label: "A" }, { label: "B" }],
1901
+ rows: [[{ nested: true }, "kept"]],
1902
+ });
1903
+ const table = blocks[0] as {
1904
+ rows: { type: string; text: string }[][];
1905
+ };
1906
+ expect(table.rows[1]).toEqual([
1907
+ { type: "raw_text", text: "" },
1908
+ { type: "raw_text", text: "kept" },
1909
+ ]);
1910
+ });
1911
+
1912
+ it("pads ragged rows to a uniform width", () => {
1913
+ const { blocks } = toSlackBlocks({
1914
+ $type: "Table",
1915
+ columns: [{ label: "A" }, { label: "B" }, { label: "C" }],
1916
+ rows: [["x"], ["x", "y", "z"]],
1917
+ });
1918
+ const table = blocks[0] as { rows: unknown[][] };
1919
+ expect(new Set(table.rows.map((row) => row.length))).toEqual(new Set([3]));
1920
+ });
1921
+
1922
+ it("drops a table whose header row alone exceeds the character budget", () => {
1923
+ const { blocks, warnings } = toSlackBlocks({
1924
+ $type: "Table",
1925
+ columns: [{ label: "h".repeat(10001) }],
1926
+ rows: [["x"]],
1927
+ });
1928
+ expect(blocks).toEqual([]);
1929
+ expect(
1930
+ warnings.some(
1931
+ (warning) =>
1932
+ warning.component === "Table" && warning.code === "dropped",
1933
+ ),
1934
+ ).toBe(true);
1935
+ });
1936
+ });