|
| 1 | +# slack-sim |
| 2 | + |
| 3 | +Simulate Slack events locally. Signed payloads, no workspace, no tunnel. |
| 4 | + |
| 5 | +[](https://github.com/deepunyk/slack-sim/actions/workflows/ci.yml) |
| 6 | +[](LICENSE) |
| 7 | + |
| 8 | +```bash |
| 9 | +npx slack-sim send message --text "hello" --url http://localhost:3000/slack/events |
| 10 | +``` |
| 11 | + |
| 12 | +## The problem |
| 13 | + |
| 14 | +To test a Slack app you run ngrok, install into a sandbox workspace, and then click things in Slack until it produces the event you need. |
| 15 | + |
| 16 | +That works right up until you need an event Slack will not produce on demand: |
| 17 | + |
| 18 | +- **A message from 400 days ago.** Slack will not let you post one. So retention jobs, SLA timers, stale-thread reminders and purge sweeps are untestable. |
| 19 | +- **A tombstone.** What a thread parent becomes when deleted with replies still attached. Arrives as `message_changed` with no text. |
| 20 | +- **A retry.** Slack retries any non-2xx up to 3 times with `x-slack-retry-num`. Your idempotency handling has probably never run. |
| 21 | +- **A workspace you do not have.** Enterprise Grid, a shared channel, a second team id. |
| 22 | +- **CI.** No tunnel, no workspace, no clicking. |
| 23 | + |
| 24 | +`slack-sim` builds those payloads and signs them the way Slack does, so they hit your real endpoint through your real verification middleware. |
| 25 | + |
| 26 | +## Install |
| 27 | + |
| 28 | +```bash |
| 29 | +npm install --save-dev slack-sim # or: npx slack-sim |
| 30 | +``` |
| 31 | + |
| 32 | +Node 18+. One dependency (`yaml`). |
| 33 | + |
| 34 | +## Use it from the CLI |
| 35 | + |
| 36 | +Print a payload: |
| 37 | + |
| 38 | +```bash |
| 39 | +slack-sim send message --pretty |
| 40 | +``` |
| 41 | + |
| 42 | +Send it to your app: |
| 43 | + |
| 44 | +```bash |
| 45 | +export SLACK_SIGNING_SECRET=your-secret |
| 46 | +slack-sim send app_mention --text "deploy prod" --url http://localhost:3000/slack/events |
| 47 | +``` |
| 48 | + |
| 49 | +``` |
| 50 | +ok 200 14ms |
| 51 | +``` |
| 52 | + |
| 53 | +The events that are hard to get any other way: |
| 54 | + |
| 55 | +```bash |
| 56 | +# A message from 400 days ago |
| 57 | +slack-sim send message --age-days 400 --url $URL |
| 58 | + |
| 59 | +# An edit — outer ts is the edit time, inner message.ts is the original id |
| 60 | +slack-sim send message_changed --text "after" --previous-text "before" --url $URL |
| 61 | + |
| 62 | +# A deleted thread parent |
| 63 | +slack-sim send tombstone --url $URL |
| 64 | + |
| 65 | +# Slack's 2nd retry of an event you already processed |
| 66 | +slack-sim send message --retry 2 --retry-reason http_timeout --url $URL |
| 67 | + |
| 68 | +# Another app posting: bot_id set, no user field. The classic echo-loop bug. |
| 69 | +slack-sim send bot_message --url $URL |
| 70 | + |
| 71 | +# A signature 10 minutes stale — a correct verifier must reject this |
| 72 | +slack-sim send message --old-timestamp --url $URL |
| 73 | +``` |
| 74 | + |
| 75 | +Interactivity and slash commands are form-encoded, not JSON. That difference is where handlers quietly break, and `slack-sim` encodes them the way Slack really does: |
| 76 | + |
| 77 | +```bash |
| 78 | +slack-sim send block_actions --action-id approve_button --url $URL/slack/interactive |
| 79 | +slack-sim send view_submission --callback-id ticket_modal --url $URL/slack/interactive |
| 80 | +slack-sim send command --command /deploy --text "prod" --url $URL/slack/commands |
| 81 | +``` |
| 82 | + |
| 83 | +`slack-sim list` prints everything it can build. |
| 84 | + |
| 85 | +### Deterministic workspaces |
| 86 | + |
| 87 | +Pass a seed and you get the same team, channel and user ids on every run, on every machine. That is what makes committed fixtures diffable. |
| 88 | + |
| 89 | +```bash |
| 90 | +slack-sim send message --seed acme-corp # same ids forever |
| 91 | +slack-sim send message --team T123 --channel C456 --user U789 |
| 92 | +``` |
| 93 | + |
| 94 | +## Use it from tests |
| 95 | + |
| 96 | +`signedRequest` gives you the exact bytes and headers Slack would send, so you can drive your handler in-process. No network, no port. |
| 97 | + |
| 98 | +```ts |
| 99 | +import { createContext, message, signedRequest } from 'slack-sim'; |
| 100 | + |
| 101 | +const ctx = createContext({ seed: 'test' }); |
| 102 | + |
| 103 | +it('ignores messages older than a year', async () => { |
| 104 | + const request = signedRequest(message(ctx, { ageDays: 400 }), { |
| 105 | + signingSecret: process.env.SLACK_SIGNING_SECRET |
| 106 | + }); |
| 107 | + |
| 108 | + const response = await app.inject({ |
| 109 | + method: 'POST', |
| 110 | + url: '/slack/events', |
| 111 | + headers: request.headers, |
| 112 | + payload: request.body // send this verbatim — re-serialising breaks the signature |
| 113 | + }); |
| 114 | + |
| 115 | + expect(response.statusCode).toBe(200); |
| 116 | + expect(archiveJob).not.toHaveBeenCalled(); |
| 117 | +}); |
| 118 | +``` |
| 119 | + |
| 120 | +Or go over the wire with `deliver`, which reports elapsed time against Slack's 3-second ack budget: |
| 121 | + |
| 122 | +```ts |
| 123 | +import { deliver, SLACK_ACK_BUDGET_MS } from 'slack-sim'; |
| 124 | + |
| 125 | +const result = await deliver('http://localhost:3000/slack/events', message(ctx)); |
| 126 | +expect(result.durationMs).toBeLessThan(SLACK_ACK_BUDGET_MS); // else Slack retries in prod |
| 127 | +``` |
| 128 | + |
| 129 | +`verifySignature` is exported too, so you can unit-test your own verifier against the same bytes it will see in production. |
| 130 | + |
| 131 | +## Scenarios |
| 132 | + |
| 133 | +The flows worth testing are never one event. Post, reply, react, edit, delete — reproducing that by hand takes minutes and is never quite the same twice. |
| 134 | + |
| 135 | +```yaml |
| 136 | +# support-thread.yml |
| 137 | +context: |
| 138 | + seed: support-flow |
| 139 | + |
| 140 | +steps: |
| 141 | + - event: message |
| 142 | + text: "the printer is on fire again" |
| 143 | + as: parent # remember this message's ts |
| 144 | + |
| 145 | + - event: message |
| 146 | + text: "looking into it" |
| 147 | + threadTs: $parent # reply in the thread |
| 148 | + expect: 200 |
| 149 | + |
| 150 | + - event: reaction_added |
| 151 | + ts: $parent |
| 152 | + emoji: eyes |
| 153 | + |
| 154 | + - event: message_changed |
| 155 | + ts: $parent |
| 156 | + text: "the printer is fine now" |
| 157 | + previousText: "the printer is on fire again" |
| 158 | + |
| 159 | + - event: message_deleted |
| 160 | + ts: $parent |
| 161 | +``` |
| 162 | +
|
| 163 | +```bash |
| 164 | +slack-sim scenario support-thread.yml --url http://localhost:3000/slack/events |
| 165 | +``` |
| 166 | + |
| 167 | +``` |
| 168 | +ok 1. message → 200 12ms |
| 169 | +ok 2. message → 200 (expected 200) 9ms |
| 170 | +ok 3. reaction_added → 200 7ms |
| 171 | +ok 4. message_changed → 200 11ms |
| 172 | +ok 5. message_deleted → 200 8ms |
| 173 | +
|
| 174 | +5/5 steps passed |
| 175 | +``` |
| 176 | + |
| 177 | +Exits non-zero on failure, so it drops straight into CI. More in [`examples/`](examples). |
| 178 | + |
| 179 | +## Record, redact, replay |
| 180 | + |
| 181 | +Generated payloads are close. Payloads your workspace actually produced are exact — but you cannot commit those, because they carry real user ids, channel names, message text and tokens. |
| 182 | + |
| 183 | +```bash |
| 184 | +# capture a real payload however you like (a log line, a debug endpoint), then: |
| 185 | +cat captured.json | slack-sim redact --seed acme --age-days 400 > test/fixtures/stale.json |
| 186 | +slack-sim replay test/fixtures/stale.json --url $URL |
| 187 | +``` |
| 188 | + |
| 189 | +`redact` swaps every Slack id for a stable fake, so the same real id always becomes the same fake id and the payload stays internally consistent: a `thread_ts` still matches its parent's `ts` afterwards. Tokens, emails and workspace URLs are stripped. Message text is replaced unless you pass `--keep-text`. |
| 190 | + |
| 191 | +`--age-days` re-anchors the timestamps so the fixture reads as that old at replay time, preserving the gaps between messages. Without it a committed fixture ages a day per day and any age-sensitive branch eventually flips on its own. |
| 192 | + |
| 193 | +## What it builds |
| 194 | + |
| 195 | +| | | |
| 196 | +|---|---| |
| 197 | +| **Messages** | `message`, `message_changed`, `message_deleted`, `tombstone`, `bot_message`, `file_share`, threaded replies, `thread_broadcast` | |
| 198 | +| **Events** | `app_mention`, `reaction_added`, `reaction_removed`, `member_joined_channel`, `member_left_channel`, `channel_archive`, `channel_unarchive`, `channel_rename`, `channel_deleted`, `app_home_opened` | |
| 199 | +| **Interactivity** | `block_actions`, `view_submission`, `view_closed`, `message_action` | |
| 200 | +| **Other** | slash commands, `url_verification`, `app_uninstalled`, `tokens_revoked` | |
| 201 | +| **Delivery** | v0 request signing, retry headers, stale-timestamp signing, ack-budget timing | |
| 202 | + |
| 203 | +Every payload carries the fields that are easy to omit by hand and load-bearing in practice: `authorizations` (Bolt uses it to resolve the installation), `event_context`, `channel_type`, `client_msg_id`. |
| 204 | + |
| 205 | +## What it is not |
| 206 | + |
| 207 | +Not a Slack Web API mock. `slack-sim` sends events *to* your app; it does not answer the `chat.postMessage` calls your app makes back. Pair it with `nock` or [`@slack/web-api`'s](https://github.com/slackapi/node-slack-sdk) own test helpers for the outbound half. |
| 208 | + |
| 209 | +Not a replacement for a sandbox workspace. Use a real workspace to discover what a payload looks like. Use `slack-sim` to replay it a thousand times, backdated, in CI. |
| 210 | + |
| 211 | +## Contributing |
| 212 | + |
| 213 | +Payload shapes drift, and the ones here come from real traffic. If you have a payload `slack-sim` gets wrong, open an issue with a redacted sample (`slack-sim redact` will do it) and it will get fixed. |
| 214 | + |
| 215 | +```bash |
| 216 | +npm install && npm test |
| 217 | +``` |
| 218 | + |
| 219 | +MIT. |
0 commit comments