digital-workers 2.1.3 → 2.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +33 -21
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts.map +1 -1
- package/dist/agent-comms.js +36 -25
- package/dist/agent-comms.js.map +1 -1
- package/dist/approve.d.ts +40 -8
- package/dist/approve.d.ts.map +1 -1
- package/dist/approve.js +86 -20
- package/dist/approve.js.map +1 -1
- package/dist/ask.d.ts +38 -7
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +85 -25
- package/dist/ask.js.map +1 -1
- package/dist/browse.d.ts +223 -0
- package/dist/browse.d.ts.map +1 -0
- package/dist/browse.js +392 -0
- package/dist/browse.js.map +1 -0
- package/dist/capability-tiers.js +3 -3
- package/dist/capability-tiers.js.map +1 -1
- package/dist/cascade-context.d.ts +28 -28
- package/dist/client.d.ts +162 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +64 -0
- package/dist/client.js.map +1 -0
- package/dist/decide.d.ts +42 -6
- package/dist/decide.d.ts.map +1 -1
- package/dist/decide.js +54 -11
- package/dist/decide.js.map +1 -1
- package/dist/do.d.ts +36 -7
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +82 -39
- package/dist/do.js.map +1 -1
- package/dist/error-escalation.d.ts.map +1 -1
- package/dist/error-escalation.js +38 -38
- package/dist/error-escalation.js.map +1 -1
- package/dist/generate.d.ts +48 -7
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +49 -8
- package/dist/generate.js.map +1 -1
- package/dist/goals.d.ts +10 -9
- package/dist/goals.d.ts.map +1 -1
- package/dist/goals.js +30 -24
- package/dist/goals.js.map +1 -1
- package/dist/image.d.ts +189 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +528 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -2
- package/dist/index.js.map +1 -1
- package/dist/is.d.ts +45 -10
- package/dist/is.d.ts.map +1 -1
- package/dist/is.js +56 -21
- package/dist/is.js.map +1 -1
- package/dist/kpis.d.ts +24 -15
- package/dist/kpis.d.ts.map +1 -1
- package/dist/kpis.js +16 -14
- package/dist/kpis.js.map +1 -1
- package/dist/load-balancing.d.ts.map +1 -1
- package/dist/load-balancing.js +124 -38
- package/dist/load-balancing.js.map +1 -1
- package/dist/logger.d.ts +76 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/notify.d.ts +38 -9
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +72 -17
- package/dist/notify.js.map +1 -1
- package/dist/role.d.ts +5 -4
- package/dist/role.d.ts.map +1 -1
- package/dist/role.js +13 -10
- package/dist/role.js.map +1 -1
- package/dist/runtime.d.ts +310 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +510 -0
- package/dist/runtime.js.map +1 -0
- package/dist/team.d.ts +11 -6
- package/dist/team.d.ts.map +1 -1
- package/dist/team.js +22 -15
- package/dist/team.js.map +1 -1
- package/dist/transports/email.d.ts +318 -0
- package/dist/transports/email.d.ts.map +1 -0
- package/dist/transports/email.js +779 -0
- package/dist/transports/email.js.map +1 -0
- package/dist/transports/slack.d.ts +515 -0
- package/dist/transports/slack.d.ts.map +1 -0
- package/dist/transports/slack.js +844 -0
- package/dist/transports/slack.js.map +1 -0
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +44 -25
- package/dist/transports.js.map +1 -1
- package/dist/types.d.ts +141 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/id.d.ts +19 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +21 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/video.d.ts +203 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +528 -0
- package/dist/video.js.map +1 -0
- package/dist/worker.d.ts +343 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +698 -0
- package/dist/worker.js.map +1 -0
- package/package.json +32 -14
- package/src/actions.ts +39 -30
- package/src/agent-comms.ts +54 -92
- package/src/approve.ts +91 -20
- package/src/ask.ts +99 -25
- package/src/browse.ts +627 -0
- package/src/capability-tiers.ts +5 -5
- package/src/client.ts +221 -0
- package/src/decide.ts +81 -35
- package/src/do.ts +98 -52
- package/src/error-escalation.ts +55 -67
- package/src/generate.ts +52 -18
- package/src/goals.ts +36 -27
- package/src/image.ts +816 -0
- package/src/index.ts +187 -2
- package/src/is.ts +59 -25
- package/src/kpis.ts +41 -36
- package/src/load-balancing.ts +132 -46
- package/src/logger.ts +93 -0
- package/src/notify.ts +78 -17
- package/src/role.ts +30 -20
- package/src/runtime.ts +796 -0
- package/src/team.ts +24 -19
- package/src/transports/email.ts +1160 -0
- package/src/transports/slack.ts +1320 -0
- package/src/transports.ts +58 -43
- package/src/types.ts +174 -46
- package/src/utils/id.ts +21 -0
- package/src/video.ts +906 -0
- package/src/worker.ts +1007 -0
- package/test/approve.test.ts +305 -0
- package/test/ask.test.ts +274 -0
- package/test/browse.test.ts +361 -0
- package/test/decide.test.ts +252 -0
- package/test/do.test.ts +144 -0
- package/test/error-logging.test.ts +357 -0
- package/test/generate.test.ts +319 -0
- package/test/image.test.ts +398 -0
- package/test/is.test.ts +287 -0
- package/test/load-balancing-safety.test.ts +404 -0
- package/test/notify.test.ts +434 -0
- package/test/primitives.test.ts +320 -0
- package/test/runtime-integration.test.ts +892 -0
- package/test/transports/crypto.test.ts +230 -0
- package/test/transports/email.test.ts +866 -0
- package/test/transports/id-generation.test.ts +91 -0
- package/test/transports/slack.test.ts +760 -0
- package/test/type-safety.test.ts +834 -0
- package/test/types.test.ts +60 -2
- package/test/video.test.ts +530 -0
- package/test/worker.test.ts +1433 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +42 -0
- package/wrangler.jsonc +36 -0
- package/LICENSE +0 -21
- package/src/actions.js +0 -436
- package/src/approve.js +0 -234
- package/src/ask.js +0 -226
- package/src/decide.js +0 -244
- package/src/do.js +0 -227
- package/src/generate.js +0 -298
- package/src/goals.js +0 -205
- package/src/index.js +0 -68
- package/src/is.js +0 -317
- package/src/kpis.js +0 -270
- package/src/notify.js +0 -219
- package/src/role.js +0 -110
- package/src/team.js +0 -130
- package/src/transports.js +0 -357
- 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
|
+
})
|