digital-workers 2.1.1 → 2.3.0

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 (197) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +136 -180
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +34 -21
  5. package/dist/actions.js.map +1 -1
  6. package/dist/agent-comms.d.ts +438 -0
  7. package/dist/agent-comms.d.ts.map +1 -0
  8. package/dist/agent-comms.js +677 -0
  9. package/dist/agent-comms.js.map +1 -0
  10. package/dist/approve.d.ts +40 -8
  11. package/dist/approve.d.ts.map +1 -1
  12. package/dist/approve.js +86 -20
  13. package/dist/approve.js.map +1 -1
  14. package/dist/ask.d.ts +38 -7
  15. package/dist/ask.d.ts.map +1 -1
  16. package/dist/ask.js +85 -25
  17. package/dist/ask.js.map +1 -1
  18. package/dist/browse.d.ts +223 -0
  19. package/dist/browse.d.ts.map +1 -0
  20. package/dist/browse.js +392 -0
  21. package/dist/browse.js.map +1 -0
  22. package/dist/capability-tiers.d.ts +230 -0
  23. package/dist/capability-tiers.d.ts.map +1 -0
  24. package/dist/capability-tiers.js +388 -0
  25. package/dist/capability-tiers.js.map +1 -0
  26. package/dist/cascade-context.d.ts +523 -0
  27. package/dist/cascade-context.d.ts.map +1 -0
  28. package/dist/cascade-context.js +494 -0
  29. package/dist/cascade-context.js.map +1 -0
  30. package/dist/client.d.ts +162 -0
  31. package/dist/client.d.ts.map +1 -0
  32. package/dist/client.js +64 -0
  33. package/dist/client.js.map +1 -0
  34. package/dist/decide.d.ts +42 -6
  35. package/dist/decide.d.ts.map +1 -1
  36. package/dist/decide.js +54 -11
  37. package/dist/decide.js.map +1 -1
  38. package/dist/do.d.ts +36 -7
  39. package/dist/do.d.ts.map +1 -1
  40. package/dist/do.js +82 -39
  41. package/dist/do.js.map +1 -1
  42. package/dist/error-escalation.d.ts +416 -0
  43. package/dist/error-escalation.d.ts.map +1 -0
  44. package/dist/error-escalation.js +656 -0
  45. package/dist/error-escalation.js.map +1 -0
  46. package/dist/generate.d.ts +48 -7
  47. package/dist/generate.d.ts.map +1 -1
  48. package/dist/generate.js +49 -8
  49. package/dist/generate.js.map +1 -1
  50. package/dist/goals.d.ts +10 -9
  51. package/dist/goals.d.ts.map +1 -1
  52. package/dist/goals.js +30 -24
  53. package/dist/goals.js.map +1 -1
  54. package/dist/image.d.ts +189 -0
  55. package/dist/image.d.ts.map +1 -0
  56. package/dist/image.js +528 -0
  57. package/dist/image.js.map +1 -0
  58. package/dist/index.d.ts +59 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +92 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/is.d.ts +45 -10
  63. package/dist/is.d.ts.map +1 -1
  64. package/dist/is.js +56 -21
  65. package/dist/is.js.map +1 -1
  66. package/dist/kpis.d.ts +24 -15
  67. package/dist/kpis.d.ts.map +1 -1
  68. package/dist/kpis.js +16 -14
  69. package/dist/kpis.js.map +1 -1
  70. package/dist/load-balancing.d.ts +395 -0
  71. package/dist/load-balancing.d.ts.map +1 -0
  72. package/dist/load-balancing.js +991 -0
  73. package/dist/load-balancing.js.map +1 -0
  74. package/dist/logger.d.ts +76 -0
  75. package/dist/logger.d.ts.map +1 -0
  76. package/dist/logger.js +39 -0
  77. package/dist/logger.js.map +1 -0
  78. package/dist/notify.d.ts +38 -9
  79. package/dist/notify.d.ts.map +1 -1
  80. package/dist/notify.js +72 -17
  81. package/dist/notify.js.map +1 -1
  82. package/dist/role.d.ts +5 -4
  83. package/dist/role.d.ts.map +1 -1
  84. package/dist/role.js +13 -10
  85. package/dist/role.js.map +1 -1
  86. package/dist/runtime.d.ts +310 -0
  87. package/dist/runtime.d.ts.map +1 -0
  88. package/dist/runtime.js +510 -0
  89. package/dist/runtime.js.map +1 -0
  90. package/dist/team.d.ts +11 -6
  91. package/dist/team.d.ts.map +1 -1
  92. package/dist/team.js +22 -15
  93. package/dist/team.js.map +1 -1
  94. package/dist/transports/email.d.ts +318 -0
  95. package/dist/transports/email.d.ts.map +1 -0
  96. package/dist/transports/email.js +779 -0
  97. package/dist/transports/email.js.map +1 -0
  98. package/dist/transports/slack.d.ts +515 -0
  99. package/dist/transports/slack.d.ts.map +1 -0
  100. package/dist/transports/slack.js +844 -0
  101. package/dist/transports/slack.js.map +1 -0
  102. package/dist/transports.d.ts.map +1 -1
  103. package/dist/transports.js +44 -25
  104. package/dist/transports.js.map +1 -1
  105. package/dist/types.d.ts +149 -19
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +6 -0
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/id.d.ts +19 -0
  110. package/dist/utils/id.d.ts.map +1 -0
  111. package/dist/utils/id.js +21 -0
  112. package/dist/utils/id.js.map +1 -0
  113. package/dist/video.d.ts +203 -0
  114. package/dist/video.d.ts.map +1 -0
  115. package/dist/video.js +528 -0
  116. package/dist/video.js.map +1 -0
  117. package/dist/worker.d.ts +343 -0
  118. package/dist/worker.d.ts.map +1 -0
  119. package/dist/worker.js +698 -0
  120. package/dist/worker.js.map +1 -0
  121. package/package.json +24 -5
  122. package/src/actions.ts +48 -38
  123. package/src/agent-comms.ts +1200 -0
  124. package/src/approve.ts +91 -20
  125. package/src/ask.ts +99 -25
  126. package/src/browse.ts +627 -0
  127. package/src/capability-tiers.ts +545 -0
  128. package/src/cascade-context.ts +648 -0
  129. package/src/client.ts +221 -0
  130. package/src/decide.ts +81 -35
  131. package/src/do.ts +98 -52
  132. package/src/error-escalation.ts +1123 -0
  133. package/src/generate.ts +52 -18
  134. package/src/goals.ts +36 -27
  135. package/src/image.ts +816 -0
  136. package/src/index.ts +410 -2
  137. package/src/is.ts +59 -25
  138. package/src/kpis.ts +41 -36
  139. package/src/load-balancing.ts +1467 -0
  140. package/src/logger.ts +93 -0
  141. package/src/notify.ts +78 -17
  142. package/src/role.ts +30 -20
  143. package/src/runtime.ts +796 -0
  144. package/src/team.ts +24 -19
  145. package/src/transports/email.ts +1160 -0
  146. package/src/transports/slack.ts +1320 -0
  147. package/src/transports.ts +58 -43
  148. package/src/types.ts +182 -46
  149. package/src/utils/id.ts +21 -0
  150. package/src/video.ts +906 -0
  151. package/src/worker.ts +1007 -0
  152. package/test/agent-comms.test.ts +1397 -0
  153. package/test/approve.test.ts +305 -0
  154. package/test/ask.test.ts +274 -0
  155. package/test/browse.test.ts +361 -0
  156. package/test/capability-tiers.test.ts +631 -0
  157. package/test/cascade-context.test.ts +692 -0
  158. package/test/decide.test.ts +252 -0
  159. package/test/do.test.ts +144 -0
  160. package/test/error-escalation.test.ts +1205 -0
  161. package/test/error-logging.test.ts +357 -0
  162. package/test/generate.test.ts +319 -0
  163. package/test/image.test.ts +398 -0
  164. package/test/is.test.ts +287 -0
  165. package/test/load-balancing-safety.test.ts +404 -0
  166. package/test/load-balancing-thread-safety.test.ts +464 -0
  167. package/test/load-balancing.test.ts +1145 -0
  168. package/test/notify.test.ts +434 -0
  169. package/test/primitives.test.ts +320 -0
  170. package/test/runtime-integration.test.ts +892 -0
  171. package/test/transports/crypto.test.ts +230 -0
  172. package/test/transports/email.test.ts +866 -0
  173. package/test/transports/id-generation.test.ts +91 -0
  174. package/test/transports/slack.test.ts +760 -0
  175. package/test/type-safety.test.ts +834 -0
  176. package/test/types.test.ts +95 -2
  177. package/test/video.test.ts +530 -0
  178. package/test/worker.test.ts +1433 -0
  179. package/tsconfig.json +4 -1
  180. package/vitest.config.ts +42 -0
  181. package/wrangler.jsonc +36 -0
  182. package/.turbo/turbo-build.log +0 -5
  183. package/src/actions.js +0 -436
  184. package/src/approve.js +0 -234
  185. package/src/ask.js +0 -226
  186. package/src/decide.js +0 -244
  187. package/src/do.js +0 -227
  188. package/src/generate.js +0 -298
  189. package/src/goals.js +0 -205
  190. package/src/index.js +0 -68
  191. package/src/is.js +0 -317
  192. package/src/kpis.js +0 -270
  193. package/src/notify.js +0 -219
  194. package/src/role.js +0 -110
  195. package/src/team.js +0 -130
  196. package/src/transports.js +0 -357
  197. package/src/types.js +0 -71
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Tests for Slack Transport Adapter
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6
+ import {
7
+ SlackTransport,
8
+ createSlackTransport,
9
+ registerSlackTransport,
10
+ slackSection,
11
+ slackHeader,
12
+ slackDivider,
13
+ slackContext,
14
+ slackButton,
15
+ slackActions,
16
+ } from '../../src/transports/slack.js'
17
+ import type {
18
+ SlackTransportConfig,
19
+ SlackWebhookRequest,
20
+ SlackInteractionPayload,
21
+ SlackPostMessageResponse,
22
+ SlackBlock,
23
+ } from '../../src/transports/slack.js'
24
+
25
+ // Mock fetch globally
26
+ const mockFetch = vi.fn()
27
+ global.fetch = mockFetch
28
+
29
+ // Test configuration
30
+ const testConfig: Omit<SlackTransportConfig, 'transport'> = {
31
+ botToken: 'xoxb-test-token',
32
+ signingSecret: 'test-signing-secret',
33
+ apiUrl: 'https://slack.test/api',
34
+ }
35
+
36
+ // Mock successful Slack API response
37
+ const mockPostMessageResponse: SlackPostMessageResponse = {
38
+ ok: true,
39
+ ts: '1234567890.123456',
40
+ channel: 'C123456',
41
+ message: {
42
+ type: 'message',
43
+ text: 'Test message',
44
+ ts: '1234567890.123456',
45
+ bot_id: 'B123456',
46
+ },
47
+ }
48
+
49
+ // Mock DM open response
50
+ const mockDMOpenResponse = {
51
+ ok: true,
52
+ channel: {
53
+ id: 'D123456',
54
+ },
55
+ }
56
+
57
+ describe('SlackTransport', () => {
58
+ let transport: SlackTransport
59
+
60
+ beforeEach(() => {
61
+ vi.clearAllMocks()
62
+ transport = createSlackTransport(testConfig)
63
+
64
+ // Default successful response
65
+ mockFetch.mockResolvedValue({
66
+ ok: true,
67
+ json: () => Promise.resolve(mockPostMessageResponse),
68
+ })
69
+ })
70
+
71
+ afterEach(() => {
72
+ vi.restoreAllMocks()
73
+ })
74
+
75
+ describe('constructor', () => {
76
+ it('should create transport with config', () => {
77
+ expect(transport).toBeInstanceOf(SlackTransport)
78
+ })
79
+
80
+ it('should use default API URL when not provided', () => {
81
+ const transportWithoutUrl = createSlackTransport({
82
+ botToken: 'xoxb-test',
83
+ signingSecret: 'secret',
84
+ })
85
+ expect(transportWithoutUrl).toBeInstanceOf(SlackTransport)
86
+ })
87
+ })
88
+
89
+ describe('sendNotification', () => {
90
+ it('should send notification to channel', async () => {
91
+ const result = await transport.sendNotification('#engineering', 'Hello team!')
92
+
93
+ expect(result.success).toBe(true)
94
+ expect(result.transport).toBe('slack')
95
+ expect(result.messageId).toBe('1234567890.123456')
96
+
97
+ expect(mockFetch).toHaveBeenCalledWith(
98
+ 'https://slack.test/api/chat.postMessage',
99
+ expect.objectContaining({
100
+ method: 'POST',
101
+ headers: expect.objectContaining({
102
+ Authorization: 'Bearer xoxb-test-token',
103
+ }),
104
+ })
105
+ )
106
+
107
+ // Verify the body
108
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
109
+ expect(callBody.channel).toBe('engineering')
110
+ expect(callBody.text).toBe('Hello team!')
111
+ })
112
+
113
+ it('should send notification to channel ID', async () => {
114
+ const result = await transport.sendNotification('C123456', 'Direct channel message')
115
+
116
+ expect(result.success).toBe(true)
117
+
118
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
119
+ expect(callBody.channel).toBe('C123456')
120
+ })
121
+
122
+ it('should format high priority messages with header', async () => {
123
+ await transport.sendNotification('#alerts', 'Critical issue!', {
124
+ priority: 'high',
125
+ })
126
+
127
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
128
+ expect(callBody.blocks).toBeDefined()
129
+ expect(callBody.blocks[0].type).toBe('header')
130
+ expect(callBody.blocks[0].text.text).toContain('HIGH')
131
+ })
132
+
133
+ it('should format urgent priority messages with header', async () => {
134
+ await transport.sendNotification('#alerts', 'Server down!', {
135
+ priority: 'urgent',
136
+ })
137
+
138
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
139
+ expect(callBody.blocks[0].text.text).toContain('URGENT')
140
+ })
141
+
142
+ it('should include metadata in context block', async () => {
143
+ await transport.sendNotification('#deploys', 'Deployment complete', {
144
+ metadata: {
145
+ version: '2.1.0',
146
+ environment: 'production',
147
+ },
148
+ })
149
+
150
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
151
+ const contextBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'context')
152
+ expect(contextBlock).toBeDefined()
153
+ })
154
+
155
+ it('should include thread_ts when provided', async () => {
156
+ await transport.sendNotification('#channel', 'Reply message', {
157
+ threadTs: '1234567890.000001',
158
+ })
159
+
160
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
161
+ expect(callBody.thread_ts).toBe('1234567890.000001')
162
+ })
163
+
164
+ it('should handle API errors', async () => {
165
+ mockFetch.mockResolvedValueOnce({
166
+ ok: true,
167
+ json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }),
168
+ })
169
+
170
+ const result = await transport.sendNotification('#nonexistent', 'Test')
171
+
172
+ expect(result.success).toBe(false)
173
+ expect(result.error).toContain('channel_not_found')
174
+ })
175
+
176
+ it('should handle network errors', async () => {
177
+ mockFetch.mockRejectedValueOnce(new Error('Network error'))
178
+
179
+ const result = await transport.sendNotification('#channel', 'Test')
180
+
181
+ expect(result.success).toBe(false)
182
+ expect(result.error).toContain('Network error')
183
+ })
184
+ })
185
+
186
+ describe('sendNotification to DMs', () => {
187
+ beforeEach(() => {
188
+ // Mock DM open followed by message send
189
+ mockFetch
190
+ .mockResolvedValueOnce({
191
+ ok: true,
192
+ json: () => Promise.resolve(mockDMOpenResponse),
193
+ })
194
+ .mockResolvedValueOnce({
195
+ ok: true,
196
+ json: () => Promise.resolve(mockPostMessageResponse),
197
+ })
198
+ })
199
+
200
+ it('should handle @user reference when user not found', async () => {
201
+ // Mock user lookup to return a user ID
202
+ const transportWithUserLookup = createSlackTransport(testConfig)
203
+
204
+ // For @user, it will try to find user (returns null), then return error
205
+ const result = await transportWithUserLookup.sendNotification('@alice', 'Hello Alice!')
206
+
207
+ expect(result.success).toBe(false)
208
+ expect(result.error).toContain('User not found: alice')
209
+ })
210
+
211
+ it('should send to user ID directly', async () => {
212
+ // Reset mock for direct user ID
213
+ mockFetch.mockReset()
214
+ mockFetch
215
+ .mockResolvedValueOnce({
216
+ ok: true,
217
+ json: () => Promise.resolve(mockDMOpenResponse),
218
+ })
219
+ .mockResolvedValueOnce({
220
+ ok: true,
221
+ json: () => Promise.resolve(mockPostMessageResponse),
222
+ })
223
+
224
+ const result = await transport.sendNotification('U123456', 'Hello!')
225
+
226
+ expect(result.success).toBe(true)
227
+ // Should call conversations.open first, then chat.postMessage
228
+ expect(mockFetch).toHaveBeenCalledTimes(1) // User IDs go directly to postMessage
229
+ })
230
+ })
231
+
232
+ describe('sendApprovalRequest', () => {
233
+ it('should send approval request with buttons', async () => {
234
+ const result = await transport.sendApprovalRequest('#approvals', 'Approve deployment?')
235
+
236
+ expect(result.success).toBe(true)
237
+ expect(result.metadata?.requestId).toBeDefined()
238
+
239
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
240
+
241
+ // Should have approval blocks
242
+ expect(callBody.blocks).toBeDefined()
243
+
244
+ // Find actions block with buttons
245
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
246
+ expect(actionsBlock).toBeDefined()
247
+ expect(actionsBlock.elements).toHaveLength(2)
248
+
249
+ // Approve button
250
+ expect(actionsBlock.elements[0].style).toBe('primary')
251
+ expect(actionsBlock.elements[0].action_id).toContain('approve_')
252
+
253
+ // Reject button
254
+ expect(actionsBlock.elements[1].style).toBe('danger')
255
+ expect(actionsBlock.elements[1].action_id).toContain('reject_')
256
+ })
257
+
258
+ it('should include context in approval request', async () => {
259
+ await transport.sendApprovalRequest('#approvals', 'Approve expense?', {
260
+ context: {
261
+ amount: 500,
262
+ category: 'Infrastructure',
263
+ },
264
+ })
265
+
266
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
267
+
268
+ // Should have context fields
269
+ const sectionWithFields = callBody.blocks.find(
270
+ (b: SlackBlock) => b.type === 'section' && 'fields' in b
271
+ )
272
+ expect(sectionWithFields).toBeDefined()
273
+ })
274
+
275
+ it('should use custom button labels', async () => {
276
+ await transport.sendApprovalRequest('#approvals', 'Accept request?', {
277
+ approveLabel: 'Accept',
278
+ rejectLabel: 'Decline',
279
+ })
280
+
281
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
282
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
283
+
284
+ expect(actionsBlock.elements[0].text.text).toBe('Accept')
285
+ expect(actionsBlock.elements[1].text.text).toBe('Decline')
286
+ })
287
+
288
+ it('should use provided requestId', async () => {
289
+ await transport.sendApprovalRequest('#approvals', 'Test', {
290
+ requestId: 'custom-request-123',
291
+ })
292
+
293
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
294
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
295
+
296
+ expect(actionsBlock.block_id).toContain('custom-request-123')
297
+ expect(actionsBlock.elements[0].action_id).toContain('custom-request-123')
298
+ })
299
+ })
300
+
301
+ describe('sendQuestion', () => {
302
+ it('should send question without choices', async () => {
303
+ const result = await transport.sendQuestion('#channel', 'What is the status?')
304
+
305
+ expect(result.success).toBe(true)
306
+
307
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
308
+ expect(callBody.text).toBe('What is the status?')
309
+ })
310
+
311
+ it('should send question with choice buttons', async () => {
312
+ await transport.sendQuestion('#channel', 'Choose an option:', {
313
+ choices: ['Option A', 'Option B', 'Option C'],
314
+ })
315
+
316
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
317
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
318
+
319
+ expect(actionsBlock).toBeDefined()
320
+ expect(actionsBlock.elements).toHaveLength(3)
321
+ expect(actionsBlock.elements[0].text.text).toBe('Option A')
322
+ })
323
+
324
+ it('should limit choices to 5 buttons', async () => {
325
+ await transport.sendQuestion('#channel', 'Pick one:', {
326
+ choices: ['A', 'B', 'C', 'D', 'E', 'F', 'G'],
327
+ })
328
+
329
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
330
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
331
+
332
+ expect(actionsBlock.elements).toHaveLength(5)
333
+ })
334
+ })
335
+
336
+ describe('handleWebhook', () => {
337
+ it('should reject invalid or fail on signature verification', async () => {
338
+ const timestamp = String(Math.floor(Date.now() / 1000))
339
+ const body = JSON.stringify({ type: 'block_actions' })
340
+
341
+ const request: SlackWebhookRequest = {
342
+ headers: {
343
+ 'x-slack-signature': 'v0=invalid_signature',
344
+ 'x-slack-request-timestamp': timestamp,
345
+ },
346
+ body,
347
+ rawBody: body,
348
+ }
349
+
350
+ const result = await transport.handleWebhook(request)
351
+
352
+ // Signature verification should fail (either invalid signature or crypto not available)
353
+ expect(result.success).toBe(false)
354
+ // Error could be about signature or crypto depending on environment
355
+ expect(result.error).toBeDefined()
356
+ })
357
+
358
+ it('should reject expired timestamp', async () => {
359
+ const oldTimestamp = Math.floor(Date.now() / 1000) - 400 // 6+ minutes ago
360
+
361
+ const request: SlackWebhookRequest = {
362
+ headers: {
363
+ 'x-slack-signature': 'v0=abc123',
364
+ 'x-slack-request-timestamp': String(oldTimestamp),
365
+ },
366
+ body: JSON.stringify({ type: 'block_actions' }),
367
+ }
368
+
369
+ const result = await transport.handleWebhook(request)
370
+
371
+ expect(result.success).toBe(false)
372
+ expect(result.error).toContain('signature')
373
+ })
374
+
375
+ it('should reject missing signature headers', async () => {
376
+ const request: SlackWebhookRequest = {
377
+ headers: {},
378
+ body: JSON.stringify({ type: 'block_actions' }),
379
+ } as SlackWebhookRequest
380
+
381
+ const result = await transport.handleWebhook(request)
382
+
383
+ expect(result.success).toBe(false)
384
+ })
385
+ })
386
+
387
+ describe('updateMessage', () => {
388
+ it('should update existing message', async () => {
389
+ mockFetch.mockResolvedValueOnce({
390
+ ok: true,
391
+ json: () => Promise.resolve({ ok: true, ts: '1234567890.123456' }),
392
+ })
393
+
394
+ const result = await transport.updateMessage(
395
+ 'C123456',
396
+ '1234567890.123456',
397
+ 'Updated message'
398
+ )
399
+
400
+ expect(result.ok).toBe(true)
401
+ expect(mockFetch).toHaveBeenCalledWith(
402
+ 'https://slack.test/api/chat.update',
403
+ expect.any(Object)
404
+ )
405
+ })
406
+
407
+ it('should include blocks in update', async () => {
408
+ mockFetch.mockResolvedValueOnce({
409
+ ok: true,
410
+ json: () => Promise.resolve({ ok: true }),
411
+ })
412
+
413
+ const blocks: SlackBlock[] = [
414
+ { type: 'section', text: { type: 'mrkdwn', text: 'Updated content' } },
415
+ ]
416
+
417
+ await transport.updateMessage('C123456', '1234567890.123456', 'Updated', blocks)
418
+
419
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
420
+ expect(callBody.blocks).toEqual(blocks)
421
+ })
422
+ })
423
+
424
+ describe('openDM', () => {
425
+ it('should open DM with user', async () => {
426
+ mockFetch.mockResolvedValueOnce({
427
+ ok: true,
428
+ json: () => Promise.resolve(mockDMOpenResponse),
429
+ })
430
+
431
+ const channelId = await transport.openDM('U123456')
432
+
433
+ expect(channelId).toBe('D123456')
434
+ expect(mockFetch).toHaveBeenCalledWith(
435
+ 'https://slack.test/api/conversations.open',
436
+ expect.any(Object)
437
+ )
438
+ })
439
+
440
+ it('should throw on error', async () => {
441
+ mockFetch.mockResolvedValueOnce({
442
+ ok: true,
443
+ json: () => Promise.resolve({ ok: false, error: 'user_not_found' }),
444
+ })
445
+
446
+ await expect(transport.openDM('U999999')).rejects.toThrow('user_not_found')
447
+ })
448
+ })
449
+
450
+ describe('lookupUserByEmail', () => {
451
+ it('should return user ID when found', async () => {
452
+ mockFetch.mockResolvedValueOnce({
453
+ ok: true,
454
+ json: () =>
455
+ Promise.resolve({
456
+ ok: true,
457
+ user: { id: 'U123456', name: 'alice', real_name: 'Alice' },
458
+ }),
459
+ })
460
+
461
+ const userId = await transport.lookupUserByEmail('alice@company.com')
462
+
463
+ expect(userId).toBe('U123456')
464
+ })
465
+
466
+ it('should return null when not found', async () => {
467
+ mockFetch.mockResolvedValueOnce({
468
+ ok: true,
469
+ json: () => Promise.resolve({ ok: false, error: 'users_not_found' }),
470
+ })
471
+
472
+ const userId = await transport.lookupUserByEmail('nobody@company.com')
473
+
474
+ expect(userId).toBeNull()
475
+ })
476
+ })
477
+
478
+ describe('getHandler', () => {
479
+ it('should return a transport handler', () => {
480
+ const handler = transport.getHandler()
481
+
482
+ expect(typeof handler).toBe('function')
483
+ })
484
+
485
+ it('should handle notification payload', async () => {
486
+ const handler = transport.getHandler()
487
+
488
+ const result = await handler(
489
+ {
490
+ to: '#channel',
491
+ body: 'Test notification',
492
+ type: 'notification',
493
+ },
494
+ testConfig as any
495
+ )
496
+
497
+ expect(result.transport).toBe('slack')
498
+ })
499
+
500
+ it('should handle approval payload', async () => {
501
+ const handler = transport.getHandler()
502
+
503
+ const result = await handler(
504
+ {
505
+ to: '#channel',
506
+ body: 'Approve this?',
507
+ type: 'approval',
508
+ actions: [
509
+ { id: 'approve', label: 'Approve', style: 'primary' },
510
+ { id: 'reject', label: 'Reject', style: 'danger' },
511
+ ],
512
+ },
513
+ testConfig as any
514
+ )
515
+
516
+ expect(result.transport).toBe('slack')
517
+ })
518
+
519
+ it('should handle question payload', async () => {
520
+ const handler = transport.getHandler()
521
+
522
+ const result = await handler(
523
+ {
524
+ to: '#channel',
525
+ body: 'Choose one:',
526
+ type: 'question',
527
+ actions: [
528
+ { id: 'a', label: 'Option A' },
529
+ { id: 'b', label: 'Option B' },
530
+ ],
531
+ },
532
+ testConfig as any
533
+ )
534
+
535
+ expect(result.transport).toBe('slack')
536
+ })
537
+
538
+ it('should handle missing target', async () => {
539
+ const handler = transport.getHandler()
540
+
541
+ const result = await handler(
542
+ {
543
+ to: [],
544
+ body: 'Test',
545
+ type: 'notification',
546
+ },
547
+ testConfig as any
548
+ )
549
+
550
+ expect(result.success).toBe(false)
551
+ expect(result.error).toContain('No target')
552
+ })
553
+ })
554
+
555
+ describe('register', () => {
556
+ it('should register transport handler', () => {
557
+ // This test just verifies no errors are thrown
558
+ transport.register()
559
+ })
560
+ })
561
+ })
562
+
563
+ describe('Factory Functions', () => {
564
+ beforeEach(() => {
565
+ vi.clearAllMocks()
566
+ mockFetch.mockResolvedValue({
567
+ ok: true,
568
+ json: () => Promise.resolve(mockPostMessageResponse),
569
+ })
570
+ })
571
+
572
+ describe('createSlackTransport', () => {
573
+ it('should create transport instance', () => {
574
+ const transport = createSlackTransport(testConfig)
575
+ expect(transport).toBeInstanceOf(SlackTransport)
576
+ })
577
+ })
578
+
579
+ describe('registerSlackTransport', () => {
580
+ it('should create and register transport', () => {
581
+ const transport = registerSlackTransport(testConfig)
582
+ expect(transport).toBeInstanceOf(SlackTransport)
583
+ })
584
+ })
585
+ })
586
+
587
+ describe('Block Kit Helpers', () => {
588
+ describe('slackSection', () => {
589
+ it('should create section block with text', () => {
590
+ const block = slackSection('Hello world')
591
+
592
+ expect(block.type).toBe('section')
593
+ expect(block.text?.type).toBe('mrkdwn')
594
+ expect(block.text?.text).toBe('Hello world')
595
+ })
596
+
597
+ it('should include fields when provided', () => {
598
+ const block = slackSection('Main text', {
599
+ fields: ['*Field 1:* Value 1', '*Field 2:* Value 2'],
600
+ })
601
+
602
+ expect(block.fields).toHaveLength(2)
603
+ expect(block.fields?.[0].text).toBe('*Field 1:* Value 1')
604
+ })
605
+ })
606
+
607
+ describe('slackHeader', () => {
608
+ it('should create header block', () => {
609
+ const block = slackHeader('Important Header')
610
+
611
+ expect(block.type).toBe('header')
612
+ expect(block.text.type).toBe('plain_text')
613
+ expect(block.text.text).toBe('Important Header')
614
+ expect(block.text.emoji).toBe(true)
615
+ })
616
+ })
617
+
618
+ describe('slackDivider', () => {
619
+ it('should create divider block', () => {
620
+ const block = slackDivider()
621
+
622
+ expect(block.type).toBe('divider')
623
+ })
624
+ })
625
+
626
+ describe('slackContext', () => {
627
+ it('should create context block with single element', () => {
628
+ const block = slackContext('Context info')
629
+
630
+ expect(block.type).toBe('context')
631
+ expect(block.elements).toHaveLength(1)
632
+ expect(block.elements[0].text).toBe('Context info')
633
+ })
634
+
635
+ it('should create context block with multiple elements', () => {
636
+ const block = slackContext('Info 1', 'Info 2', 'Info 3')
637
+
638
+ expect(block.elements).toHaveLength(3)
639
+ })
640
+ })
641
+
642
+ describe('slackButton', () => {
643
+ it('should create basic button', () => {
644
+ const button = slackButton('Click me', 'button_click')
645
+
646
+ expect(button.type).toBe('button')
647
+ expect(button.text.text).toBe('Click me')
648
+ expect(button.action_id).toBe('button_click')
649
+ })
650
+
651
+ it('should include optional properties', () => {
652
+ const button = slackButton('Submit', 'submit_action', {
653
+ value: 'submit_value',
654
+ style: 'primary',
655
+ url: 'https://example.com',
656
+ })
657
+
658
+ expect(button.value).toBe('submit_value')
659
+ expect(button.style).toBe('primary')
660
+ expect(button.url).toBe('https://example.com')
661
+ })
662
+
663
+ it('should not include undefined optional properties', () => {
664
+ const button = slackButton('Simple', 'simple_action')
665
+
666
+ expect(button).not.toHaveProperty('value')
667
+ expect(button).not.toHaveProperty('style')
668
+ expect(button).not.toHaveProperty('url')
669
+ })
670
+ })
671
+
672
+ describe('slackActions', () => {
673
+ it('should create actions block with buttons', () => {
674
+ const button1 = slackButton('Button 1', 'action_1')
675
+ const button2 = slackButton('Button 2', 'action_2')
676
+
677
+ const block = slackActions('my_actions', button1, button2)
678
+
679
+ expect(block.type).toBe('actions')
680
+ expect(block.block_id).toBe('my_actions')
681
+ expect(block.elements).toHaveLength(2)
682
+ })
683
+
684
+ it('should handle single button', () => {
685
+ const button = slackButton('Only Button', 'only_action')
686
+ const block = slackActions('single_action', button)
687
+
688
+ expect(block.elements).toHaveLength(1)
689
+ })
690
+ })
691
+ })
692
+
693
+ describe('Integration Scenarios', () => {
694
+ let transport: SlackTransport
695
+
696
+ beforeEach(() => {
697
+ vi.clearAllMocks()
698
+ transport = createSlackTransport(testConfig)
699
+ mockFetch.mockResolvedValue({
700
+ ok: true,
701
+ json: () => Promise.resolve(mockPostMessageResponse),
702
+ })
703
+ })
704
+
705
+ it('should handle deployment approval workflow', async () => {
706
+ // Send approval request
707
+ const approvalResult = await transport.sendApprovalRequest(
708
+ '#deployments',
709
+ 'Deploy v2.1.0 to production?',
710
+ {
711
+ context: {
712
+ version: '2.1.0',
713
+ environment: 'production',
714
+ requestedBy: 'alice@company.com',
715
+ },
716
+ requestId: 'deploy-123',
717
+ }
718
+ )
719
+
720
+ expect(approvalResult.success).toBe(true)
721
+ expect(approvalResult.metadata?.requestId).toBe('deploy-123')
722
+ })
723
+
724
+ it('should handle incident notification', async () => {
725
+ const result = await transport.sendNotification(
726
+ '#incidents',
727
+ 'Production database connection pool exhausted',
728
+ {
729
+ priority: 'urgent',
730
+ metadata: {
731
+ severity: 'P1',
732
+ service: 'api-gateway',
733
+ timestamp: new Date().toISOString(),
734
+ },
735
+ }
736
+ )
737
+
738
+ expect(result.success).toBe(true)
739
+
740
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
741
+ expect(callBody.blocks[0].text.text).toContain('URGENT')
742
+ })
743
+
744
+ it('should handle team poll', async () => {
745
+ const result = await transport.sendQuestion(
746
+ '#engineering',
747
+ 'When should we schedule the sprint review?',
748
+ {
749
+ choices: ['Monday 10am', 'Tuesday 2pm', 'Wednesday 3pm', 'Thursday 11am'],
750
+ requestId: 'poll-456',
751
+ }
752
+ )
753
+
754
+ expect(result.success).toBe(true)
755
+
756
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body)
757
+ const actionsBlock = callBody.blocks.find((b: SlackBlock) => b.type === 'actions')
758
+ expect(actionsBlock.elements).toHaveLength(4)
759
+ })
760
+ })