Looking for the chatbot template? It's now here.

Testing adapters

Write unit tests, integration tests, and replay tests for community Chat SDK adapters.

Testing philosophy

Chat SDK adapters are the trust boundary between your application and a platform. High test coverage is expected: unit tests for every public method, and integration tests that wire up the full ChatAdapter → handler pipeline.

All adapters in this repo use vitest with @vitest/coverage-v8. Community adapters should follow the same convention.

Unit tests

Factory function

Verify that the factory validates config, reads environment variables, and sets defaults.

src/factory.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createMatrixAdapter } from "./factory";

describe("createMatrixAdapter", () => {
  const originalEnv = process.env;

  beforeEach(() => {
    process.env = { ...originalEnv };
  });

  afterEach(() => {
    process.env = originalEnv;
  });

  it("creates adapter from explicit config", () => {
    const adapter = createMatrixAdapter({
      homeserverUrl: "https://matrix.example.com",
      accessToken: "syt_test_token",
    });

    expect(adapter.name).toBe("matrix");
  });

  it("reads environment variables as fallback", () => {
    process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
    process.env.MATRIX_ACCESS_TOKEN = "syt_test_token";

    const adapter = createMatrixAdapter();
    expect(adapter.name).toBe("matrix");
  });

  it("throws when homeserver URL is missing", () => {
    expect(() => createMatrixAdapter({ accessToken: "tok" } as never))
      .toThrow("homeserver URL");
  });

  it("throws when access token is missing", () => {
    expect(() =>
      createMatrixAdapter({
        homeserverUrl: "https://matrix.example.com",
      } as never)
    ).toThrow("access token");
  });
});

Thread ID encode/decode

Verify that encodeThreadId and decodeThreadId roundtrip consistently and reject invalid formats.

src/thread-id.test.ts
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("thread ID encoding", () => {
  it("roundtrips room-only thread ID", () => {
    const data = { roomId: "!abc123:matrix.org" };
    const encoded = adapter.encodeThreadId(data);
    const decoded = adapter.decodeThreadId(encoded);

    expect(decoded.roomId).toBe(data.roomId);
    expect(encoded).toMatch(/^matrix:/);
  });

  it("roundtrips thread ID with event", () => {
    const data = {
      roomId: "!abc123:matrix.org",
      eventId: "$event456",
    };
    const encoded = adapter.encodeThreadId(data);
    const decoded = adapter.decodeThreadId(encoded);

    expect(decoded.roomId).toBe(data.roomId);
    expect(decoded.eventId).toBe(data.eventId);
  });

  it("throws on invalid format", () => {
    expect(() => adapter.decodeThreadId("invalid")).toThrow();
    expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow();
  });
});

Webhook signature verification

Test the three key scenarios: missing headers, invalid signature, and valid signature.

src/webhook.test.ts
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("handleWebhook", () => {
  it("returns 401 when signature header is missing", async () => {
    const request = new Request("https://example.com/webhook", {
      method: "POST",
      body: "{}",
    });

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(401);
  });

  it("returns 401 when signature is invalid", async () => {
    const request = new Request("https://example.com/webhook", {
      method: "POST",
      headers: { "x-matrix-signature": "invalid" },
      body: "{}",
    });

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(401);
  });

  it("returns 200 for valid signed request", async () => {
    const body = JSON.stringify({ type: "m.room.message" });
    const request = createSignedRequest(body); // Your signing helper

    const response = await adapter.handleWebhook(request);
    expect(response.status).toBe(200);
  });
});

Message parsing

Cover the main message types: plain text, bot messages, DMs, edited messages, attachments, and formatted text.

src/parse-message.test.ts
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";

const adapter = new MatrixAdapter({
  homeserverUrl: "https://matrix.example.com",
  accessToken: "syt_test_token",
});

describe("parseMessage", () => {
  it("parses a plain text message", () => {
    const raw = {
      event_id: "$evt1",
      room_id: "!room1:matrix.org",
      body: "Hello world",
      sender: "@alice:matrix.org",
      sender_display_name: "Alice",
      origin_server_ts: 1700000000000,
    };

    const message = adapter.parseMessage(raw);
    expect(message.text).toBe("Hello world");
    expect(message.author.userId).toBe("@alice:matrix.org");
    expect(message.author.isBot).toBe(false);
  });

  it("detects bot messages", () => {
    const raw = {
      event_id: "$evt2",
      room_id: "!room1:matrix.org",
      body: "Automated response",
      sender: "@bot:matrix.org",
      origin_server_ts: 1700000000000,
    };

    const message = adapter.parseMessage(raw);
    expect(message.author.isBot).toBe(true);
  });
});

Format converter

Test toAst() and fromAst() for each node type your platform supports, plus renderPostable() for all message variants.

src/format-converter.test.ts
import { describe, it, expect } from "vitest";
import { MatrixFormatConverter } from "./format-converter";

const converter = new MatrixFormatConverter();

describe("MatrixFormatConverter", () => {
  describe("toAst", () => {
    it("parses plain text", () => {
      const ast = converter.toAst("Hello world");
      expect(ast.type).toBe("root");
    });

    it("parses bold text", () => {
      const ast = converter.toAst("**bold**");
      // Verify the AST contains a strong node
      const paragraph = ast.children[0];
      expect(paragraph.children[0].type).toBe("strong");
    });
  });

  describe("fromAst", () => {
    it("renders bold text", () => {
      const ast = converter.toAst("**bold**");
      const result = converter.fromAst(ast);
      expect(result).toContain("**bold**");
    });
  });

  describe("renderPostable", () => {
    it("renders plain text", () => {
      const result = converter.renderPostable({ text: "Hello" });
      expect(result).toBe("Hello");
    });

    it("renders card fallback", () => {
      const result = converter.renderPostable({
        card: {
          type: "card",
          title: "Test Card",
          children: [],
        },
      });
      expect(result).toContain("Test Card");
    });
  });
});

Integration testing

Integration tests wire your adapter into a full Chat instance and verify end-to-end message handling.

Test context factory

Create a factory that sets up the full test environment:

src/test-utils.ts
import { Chat, type Message, type Thread, type ActionEvent, type ReactionEvent } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createMatrixAdapter } from "./factory";

interface CapturedMessages {
  mentionMessage: Message | null;
  mentionThread: Thread | null;
  followUpMessage: Message | null;
  followUpThread: Thread | null;
}

interface WaitUntilTracker {
  waitUntil: (task: Promise<unknown>) => void;
  waitForAll: () => Promise<void>;
}

function createWaitUntilTracker(): WaitUntilTracker {
  const tasks: Promise<unknown>[] = [];

  return {
    waitUntil: (task) => {
      tasks.push(task);
    },
    waitForAll: async () => {
      await Promise.all(tasks);
      tasks.length = 0;
    },
  };
}

export function createMatrixTestContext(handlers: {
  onMention?: (thread: Thread, message: Message) => void | Promise<void>;
  onSubscribed?: (thread: Thread, message: Message) => void | Promise<void>;
  onAction?: (event: ActionEvent) => void | Promise<void>;
  onReaction?: (event: ReactionEvent) => void | Promise<void>;
}) {
  const adapter = createMatrixAdapter({
    homeserverUrl: "https://matrix.example.com",
    accessToken: "syt_test_token",
  });

  const state = createMemoryState();
  const chat = new Chat({
    userName: "matrix-bot",
    adapters: { matrix: adapter },
    state,
    logger: "error",
  });

  const captured: CapturedMessages = {
    mentionMessage: null,
    mentionThread: null,
    followUpMessage: null,
    followUpThread: null,
  };

  if (handlers.onMention) {
    const handler = handlers.onMention;
    chat.onNewMention(async (thread, message) => {
      captured.mentionMessage = message;
      captured.mentionThread = thread;
      await handler(thread, message);
    });
  }

  if (handlers.onSubscribed) {
    const handler = handlers.onSubscribed;
    chat.onSubscribedMessage(async (thread, message) => {
      captured.followUpMessage = message;
      captured.followUpThread = thread;
      await handler(thread, message);
    });
  }

  if (handlers.onAction) {
    chat.onAction(handlers.onAction);
  }

  if (handlers.onReaction) {
    chat.onReaction(handlers.onReaction);
  }

  const tracker = createWaitUntilTracker();

  return {
    chat,
    adapter,
    state,
    tracker,
    captured,
    sendWebhook: async (fixture: unknown) => {
      const request = createSignedMatrixRequest(fixture); // Your signing helper
      await chat.webhooks.matrix(request, {
        waitUntil: tracker.waitUntil,
      });
      await tracker.waitForAll();
    },
  };
}

function createSignedMatrixRequest(payload: unknown): Request {
  const body = JSON.stringify(payload);
  // Add platform-specific signature headers
  return new Request("https://example.com/webhook/matrix", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-matrix-signature": computeSignature(body),
    },
    body,
  });
}

function computeSignature(body: string): string {
  // Platform-specific signature computation
  return "valid-signature";
}

Writing integration tests

Use the test context to verify the full message flow:

src/integration.test.ts
import { describe, it, expect } from "vitest";
import { createMatrixTestContext } from "./test-utils";

describe("Matrix adapter integration", () => {
  it("handles mention → subscribe → follow-up flow", async () => {
    const ctx = createMatrixTestContext({
      onMention: async (thread) => {
        await thread.subscribe();
        await thread.post("Got it!");
      },
      onSubscribed: async (thread, message) => {
        await thread.post(`Echo: ${message.text}`);
      },
    });

    // Send a mention
    await ctx.sendWebhook({
      type: "m.room.message",
      room_id: "!room1:matrix.org",
      event_id: "$evt1",
      body: "@bot hello",
      sender: "@alice:matrix.org",
      origin_server_ts: Date.now(),
    });

    expect(ctx.captured.mentionMessage).not.toBeNull();
    expect(ctx.captured.mentionMessage?.text).toBe("@bot hello");

    // Send a follow-up in the same thread
    await ctx.sendWebhook({
      type: "m.room.message",
      room_id: "!room1:matrix.org",
      thread_root_id: "$evt1",
      event_id: "$evt2",
      body: "follow up",
      sender: "@alice:matrix.org",
      origin_server_ts: Date.now(),
    });

    expect(ctx.captured.followUpMessage).not.toBeNull();
    expect(ctx.captured.followUpMessage?.text).toBe("follow up");
  });
});

What to test end-to-end

Cover these flows in your integration tests:

FlowWhat to verify
MentionBot detects @mention, handler fires, mentionMessage is captured
Subscribe + follow-upAfter thread.subscribe(), subsequent messages trigger onSubscribedMessage
ActionsButton clicks fire onAction with correct action ID and user info
ReactionsEmoji reactions fire onReaction with correct emoji and message ID
Self-message filteringMessages from the bot itself are ignored
DM flowDirect messages are detected and routed correctly

Recording and replay tests (advanced)

For production debugging, Chat SDK supports recording webhook interactions and replaying them as test fixtures.

  1. Enable recording — Set RECORDING_ENABLED=true in your deployed environment. Recordings are tagged with the current git SHA.

  2. Interact with the bot — Send messages, click buttons, add reactions — each interaction is recorded.

  3. Export recordings

Terminal
pnpm recording:list
pnpm recording:export session-<id>
  1. Create test fixtures — Extract webhook payloads from the exported recording and save them as JSON fixtures:
fixtures/replay/matrix-mention.json
{
  "botName": "matrix-bot",
  "botUserId": "@bot:matrix.org",
  "mention": { "type": "m.room.message", "body": "@bot help", "..." : "..." },
  "followUp": { "type": "m.room.message", "body": "thanks", "..." : "..." }
}
  1. Write replay tests — Use the fixtures in your test context:
src/replay.test.ts
import { describe, it, expect } from "vitest";
import fixture from "../fixtures/replay/matrix-mention.json";
import { createMatrixTestContext } from "./test-utils";

describe("replay: mention flow", () => {
  it("handles recorded mention interaction", async () => {
    const ctx = createMatrixTestContext({
      onMention: async (thread) => {
        await thread.subscribe();
      },
    });

    await ctx.sendWebhook(fixture.mention);
    expect(ctx.captured.mentionMessage).not.toBeNull();

    await ctx.sendWebhook(fixture.followUp);
    expect(ctx.captured.followUpMessage).not.toBeNull();
  });
});