Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

slack-sim

Simulate Slack events locally. Signed payloads, no workspace, no tunnel.

CI MIT License

npx slack-sim send message --text "hello" --url http://localhost:3000/slack/events

The problem

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.

That works right up until you need an event Slack will not produce on demand:

  • 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.
  • A tombstone. What a thread parent becomes when deleted with replies still attached. Arrives as message_changed with no text.
  • A retry. Slack retries any non-2xx up to 3 times with x-slack-retry-num. Your idempotency handling has probably never run.
  • A workspace you do not have. Enterprise Grid, a shared channel, a second team id.
  • CI. No tunnel, no workspace, no clicking.

slack-sim builds those payloads and signs them the way Slack does, so they hit your real endpoint through your real verification middleware.

Install

npm install --save-dev slack-sim   # or: npx slack-sim

Node 18+. One dependency (yaml).

Use it from the CLI

Print a payload:

slack-sim send message --pretty

Send it to your app:

export SLACK_SIGNING_SECRET=your-secret
slack-sim send app_mention --text "deploy prod" --url http://localhost:3000/slack/events
ok  200  14ms

The events that are hard to get any other way:

# A message from 400 days ago
slack-sim send message --age-days 400 --url $URL

# An edit — outer ts is the edit time, inner message.ts is the original id
slack-sim send message_changed --text "after" --previous-text "before" --url $URL

# A deleted thread parent
slack-sim send tombstone --url $URL

# Slack's 2nd retry of an event you already processed
slack-sim send message --retry 2 --retry-reason http_timeout --url $URL

# Another app posting: bot_id set, no user field. The classic echo-loop bug.
slack-sim send bot_message --url $URL

# A signature 10 minutes stale — a correct verifier must reject this
slack-sim send message --old-timestamp --url $URL

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:

slack-sim send block_actions --action-id approve_button --url $URL/slack/interactive
slack-sim send view_submission --callback-id ticket_modal --url $URL/slack/interactive
slack-sim send command --command /deploy --text "prod" --url $URL/slack/commands

slack-sim list prints everything it can build. Add --json to any command for a machine-readable result on stdout — see For coding agents.

Deterministic workspaces

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.

slack-sim send message --seed acme-corp     # same ids forever
slack-sim send message --team T123 --channel C456 --user U789

Use it from tests

signedRequest gives you the exact bytes and headers Slack would send, so you can drive your handler in-process. No network, no port.

import { createContext, message, signedRequest } from 'slack-sim';

const ctx = createContext({ seed: 'test' });

it('ignores messages older than a year', async () => {
  const request = signedRequest(message(ctx, { ageDays: 400 }), {
    signingSecret: process.env.SLACK_SIGNING_SECRET
  });

  const response = await app.inject({
    method: 'POST',
    url: '/slack/events',
    headers: request.headers,
    payload: request.body   // send this verbatim — re-serialising breaks the signature
  });

  expect(response.statusCode).toBe(200);
  expect(archiveJob).not.toHaveBeenCalled();
});

Or go over the wire with deliver, which reports elapsed time against Slack's 3-second ack budget:

import { deliver, SLACK_ACK_BUDGET_MS } from 'slack-sim';

const result = await deliver('http://localhost:3000/slack/events', message(ctx));
expect(result.durationMs).toBeLessThan(SLACK_ACK_BUDGET_MS); // else Slack retries in prod

verifySignature is exported too, so you can unit-test your own verifier against the same bytes it will see in production.

For coding agents

An agent writing a Slack handler hits a wall the moment it wants to check its work. It cannot click a button in Slack, complete an OAuth flow, start a tunnel, or look at a thread. The normal way to test a Slack app is precisely the part an agent cannot do — so it writes the handler, never exercises it, and reports "this should work".

slack-sim closes that loop. One command, no browser, no workspace, no human in the middle:

slack-sim send message --age-days 400 --url http://localhost:3000/slack/events --json
{
  "ok": false,
  "status": 500,
  "durationMs": 41,
  "exceededAckBudget": false,
  "ackBudgetMs": 3000,
  "responseBody": "Cannot read properties of undefined (reading 'ts')",
  "event": "message",
  "sent": { "...": "the exact payload that caused it" }
}

That is a bug the agent can now actually see, with the payload that triggered it attached.

The pieces that make it work unattended:

  • --json puts a machine-readable result on stdout. Human progress goes to stderr, so stdout stays clean to pipe or parse.
  • Exit codes mean something. 0 pass, 1 fail, on non-2xx responses, failed scenario steps, and unreachable servers alike. Drop it into a verification step and trust the result.
  • slack-sim list --json enumerates every event, interactivity type and flag, so an agent discovers the surface in one call rather than guessing at option names.
  • --seed makes runs reproducible. A failure seen once reproduces byte-for-byte, on the next run and on someone else's machine.
  • Errors say what to do. Could not reach http://localhost:3000: fetch failed. Is the server running and the path correct? rather than fetch failed.
  • Scenarios are a single verification command covering a whole flow, with failures broken out in the JSON report so nothing has to be filtered out of a step list.
slack-sim scenario examples/support-thread.yml --url $URL --json
{ "passed": false, "total": 5, "failedCount": 1, "failures": [{ "step": 4, "event": "message_changed", "status": 500, "passed": false }], "steps": ["..."] }

Worth pasting into your AGENTS.md or CLAUDE.md:

## Testing Slack handlers

Do not ask me to click in Slack. Exercise handlers with slack-sim:
  npx slack-sim list --json                       # every event and flag
  npx slack-sim send <event> --url $URL --json    # one event, structured result
  npx slack-sim scenario <file.yml> --url $URL --json  # a full flow

Exit 0 pass, 1 fail. Always pass --seed so a failure reproduces.
Reach for --age-days, tombstone, --retry and bot_message: those are the
payloads a real workspace will not produce, and where the bugs live.

Scenarios

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.

# support-thread.yml
context:
  seed: support-flow

steps:
  - event: message
    text: "the printer is on fire again"
    as: parent              # remember this message's ts

  - event: message
    text: "looking into it"
    threadTs: $parent       # reply in the thread
    expect: 200

  - event: reaction_added
    ts: $parent
    emoji: eyes

  - event: message_changed
    ts: $parent
    text: "the printer is fine now"
    previousText: "the printer is on fire again"

  - event: message_deleted
    ts: $parent
slack-sim scenario support-thread.yml --url http://localhost:3000/slack/events
ok   1. message → 200  12ms
ok   2. message → 200 (expected 200)  9ms
ok   3. reaction_added → 200  7ms
ok   4. message_changed → 200  11ms
ok   5. message_deleted → 200  8ms

5/5 steps passed

Exits non-zero on failure, so it drops straight into CI. More in examples/.

Record, redact, replay

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.

# capture a real payload however you like (a log line, a debug endpoint), then:
cat captured.json | slack-sim redact --seed acme --age-days 400 > test/fixtures/stale.json
slack-sim replay test/fixtures/stale.json --url $URL

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.

--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.

What it builds

Messages message, message_changed, message_deleted, tombstone, bot_message, file_share, threaded replies, thread_broadcast
Events app_mention, reaction_added, reaction_removed, member_joined_channel, member_left_channel, channel_archive, channel_unarchive, channel_rename, channel_deleted, app_home_opened
Interactivity block_actions, view_submission, view_closed, message_action
Other slash commands, url_verification, app_uninstalled, tokens_revoked
Delivery v0 request signing, retry headers, stale-timestamp signing, ack-budget timing

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.

What it is not

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 own test helpers for the outbound half.

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.

Contributing

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.

npm install && npm test

MIT.

About

Simulate Slack events locally. Signed payloads, no workspace, no tunnel.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages