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 Chat → Adapter → 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.
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.
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.
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.
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.
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:
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:
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:
| Flow | What to verify |
|---|---|
| Mention | Bot detects @mention, handler fires, mentionMessage is captured |
| Subscribe + follow-up | After thread.subscribe(), subsequent messages trigger onSubscribedMessage |
| Actions | Button clicks fire onAction with correct action ID and user info |
| Reactions | Emoji reactions fire onReaction with correct emoji and message ID |
| Self-message filtering | Messages from the bot itself are ignored |
| DM flow | Direct 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.
-
Enable recording — Set
RECORDING_ENABLED=truein your deployed environment. Recordings are tagged with the current git SHA. -
Interact with the bot — Send messages, click buttons, add reactions — each interaction is recorded.
-
Export recordings
pnpm recording:list
pnpm recording:export session-<id>- Create test fixtures — Extract webhook payloads from the exported recording and save them as JSON fixtures:
{
"botName": "matrix-bot",
"botUserId": "@bot:matrix.org",
"mention": { "type": "m.room.message", "body": "@bot help", "..." : "..." },
"followUp": { "type": "m.room.message", "body": "thanks", "..." : "..." }
}- Write replay tests — Use the fixtures in your test context:
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();
});
});