Skip to content

Commit cd7b6af

Browse files
authored
test(integration-tests): add Emulate.dev-backed tests for the GitHub adapter (#479)
## Summary Add an Emulate.dev-backed integration-test suite for the GitHub adapter, mirroring the structure of the Slack work in #477. Tests drive the adapter against an in-process [`@emulators/github`](https://emulate.dev/docs/github) server and assert on its stateful store (comments, reviews) rather than `mock.calls`, catching wire-format and contract issues that pure Octokit mocks miss. - New private devDeps in `packages/integration-tests`: `@emulators/github`, `@emulators/core`, `@hono/node-server`, plus a workspace dep on `@chat-adapter/github`. (`@emulators/core` and `@hono/node-server` are also declared by #477; this PR is independent of merge order.) - New harness `packages/integration-tests/src/github-emulator-utils.ts` boots the emulator on an ephemeral `127.0.0.1` port, seeds a deterministic user / repo / issue / PR / starter review comment, and exposes a near-passthrough HTTP forwarder. **No re-signing needed** here. `@emulators/core`'s `WebhookDispatcher` already signs deliveries with `X-Hub-Signature-256: sha256=<hex>` exactly as the GitHub adapter expects. The harness also adds a small URL rewriter for Octokit's `pulls.createReplyForReviewComment` shortcut endpoint, translating it into the canonical review-comment POST that the emulator implements. - Four new test files (12 tests), wired to the adapter via its existing `apiUrl` + `webhookSecret` config **zero source changes** to `packages/adapter-github`: - `emulator-github-auth.test.ts` (2) `GET /user` populates `botUserId` during `initialize()`. - `emulator-github-comments.test.ts` (4) `thread.post` / `edit` / `delete` on issue and PR-conversation threads. - `emulator-github-reviews.test.ts` (3) review-comment replies routed through `pulls.createReplyForReviewComment` with the right \`in_reply_to_id\`, plus edit / delete. - `emulator-github-events.test.ts` (3) full inbound `issue_comment` / `pull_request_review_comment` round-trip, including bot self-message filtering. ```mermaid flowchart LR subgraph Test["Vitest test process"] SDK[GitHubAdapter + Chat] Forwarder["HTTP forwarder<br/>passthrough"] Emu["@emulators/github<br/>(in-process Hono)"] end SDK -->|"issues.createComment / pulls.* / GET /user<br/>(apiUrl override)"| Emu Emu -->|"X-Hub-Signature-256 + x-github-event"| Forwarder Forwarder -->|"chat.webhooks.github(request)"| SDK ``` ### Out of scope (deliberate) - **Reactions** `@emulators/github` does not implement the `/reactions` endpoints used by the adapter. Reaction logic is still covered by the existing mock-based tests in `packages/adapter-github/src/index.test.ts`. - **GitHub App auth** (JWT \u2192 installation token via `POST /app/installations/:id/access_tokens`) the adapter and emulator both support it, but PAT-mode was the agreed scope here. - **Multi-tenant install flows** via `installation` webhook events. - Branches/refs, releases, search, actions, checks not used by the adapter. ## Test plan - [x] `pnpm --filter @chat-adapter/integration-tests test` 407 tests pass across 34 files (including the 12 new emulator-github tests, ~480 ms total). - [x] `pnpm check` and `pnpm knip` clean. - [x] CI safety verified: ephemeral ports (`port: 0`), loopback-only binds (`127.0.0.1`), deterministic teardown via `httpServer.close()`, no env vars, no external network egress. ## Checklist - [x] All commits are signed and verified - [x] \`pnpm validate\` passes - [x] Changeset added (or N/A see [CONTRIBUTING.md](./CONTRIBUTING.md)) N/A: \`@chat-adapter/integration-tests\` is \`private: true\` and the change is test-only. - [x] Documentation updated (or N/A) \`packages/integration-tests/README.md\` describes the new emulator-github test category. --------- Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent c889e04 commit cd7b6af

8 files changed

Lines changed: 1131 additions & 1 deletion

File tree

packages/integration-tests/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Integration tests for the Chat SDK that verify real-world webhook payloads are h
66

77
- **Unit tests** (`slack.test.ts`, `teams.test.ts`, `gchat.test.ts`) - Test adapter functionality with mock payloads
88
- **Replay tests** (`replay*.test.ts`) - Replay actual production webhook recordings
9-
- **Emulator tests** (`emulator-*.test.ts`) - Drive the SlackAdapter against an in-process [`@emulators/slack`](https://emulate.dev/docs/slack) server. Assertions read the emulator's stateful store (messages, reactions, installations) instead of mock call records. The emulator is wired in via the adapter's `apiUrl` config, and inbound `event_callback` deliveries are re-signed by a small in-test forwarder before being handed to `chat.webhooks.slack(...)`. Helpers live in [`src/slack-emulator-utils.ts`](./src/slack-emulator-utils.ts).
9+
- **Emulator tests** (`src/emulator/<adapter>/*.test.ts`) - Drive the SDK against an in-process Emulate.dev server, one per supported adapter ([`@emulators/slack`](https://emulate.dev/docs/slack), [`@emulators/github`](https://emulate.dev/docs/github)). Assertions read the emulator's stateful store (messages, comments, reactions, installations) instead of mock call records. Each adapter is wired in via its `apiUrl` config. Inbound deliveries: the Slack flow re-signs `event_callback` payloads with `x-slack-signature` via a small in-test forwarder before handing them to `chat.webhooks.slack(...)`, while the GitHub flow is a near-passthrough because the emulator's `WebhookDispatcher` already signs with `X-Hub-Signature-256` exactly as the adapter expects. Helpers live in [`src/emulator/slack/utils.ts`](./src/emulator/slack/utils.ts) and [`src/emulator/github/utils.ts`](./src/emulator/github/utils.ts).
1010

1111
## Replay Tests
1212

packages/integration-tests/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"devDependencies": {
1313
"@emulators/core": "^0.5.0",
14+
"@emulators/github": "^0.5.0",
1415
"@emulators/slack": "^0.5.0",
1516
"@hono/node-server": "^2.0.2",
1617
"@types/node": "^25.3.2",
@@ -20,6 +21,7 @@
2021
"dependencies": {
2122
"@chat-adapter/discord": "workspace:*",
2223
"@chat-adapter/gchat": "workspace:*",
24+
"@chat-adapter/github": "workspace:*",
2325
"@chat-adapter/messenger": "workspace:*",
2426
"@chat-adapter/slack": "workspace:*",
2527
"@chat-adapter/state-memory": "workspace:*",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Asserts that the GitHubAdapter, when wired against the in-process GitHub
3+
* emulator via `apiUrl`, auto-resolves its bot user id from
4+
* `users.getAuthenticated` (i.e. `GET /user`) during `Chat.initialize()`.
5+
* This proves the Octokit client correctly reaches the emulator and that the
6+
* emulator's `GET /user` returns the seeded bot user.
7+
*/
8+
9+
import { createGitHubAdapter, type GitHubAdapter } from "@chat-adapter/github";
10+
import { createMemoryState } from "@chat-adapter/state-memory";
11+
import { Chat } from "chat";
12+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
13+
import {
14+
createGitHubEmulator,
15+
type GitHubEmulatorHandle,
16+
silentLogger,
17+
} from "./utils";
18+
19+
describe("GitHub emulator: auth", () => {
20+
let emulator: GitHubEmulatorHandle;
21+
let chat: Chat<{ github: GitHubAdapter }> | undefined;
22+
23+
beforeAll(async () => {
24+
emulator = await createGitHubEmulator();
25+
});
26+
27+
afterEach(async () => {
28+
if (chat) {
29+
await chat.shutdown();
30+
chat = undefined;
31+
}
32+
emulator.reset();
33+
});
34+
35+
afterAll(async () => {
36+
await emulator.close();
37+
});
38+
39+
it("populates botUserId from GET /user during initialize()", async () => {
40+
const adapter = createGitHubAdapter({
41+
apiUrl: emulator.apiUrl,
42+
token: emulator.botToken,
43+
webhookSecret: emulator.webhookSecret,
44+
userName: emulator.botLogin,
45+
logger: silentLogger,
46+
});
47+
chat = new Chat({
48+
userName: emulator.botLogin,
49+
adapters: { github: adapter },
50+
state: createMemoryState(),
51+
logger: silentLogger,
52+
});
53+
await chat.initialize();
54+
55+
expect(adapter.botUserId).toBe(String(emulator.botUserId));
56+
});
57+
58+
it("respects an explicit botUserId without calling GET /user", async () => {
59+
const adapter = createGitHubAdapter({
60+
apiUrl: emulator.apiUrl,
61+
token: emulator.botToken,
62+
webhookSecret: emulator.webhookSecret,
63+
botUserId: 99_999,
64+
userName: emulator.botLogin,
65+
logger: silentLogger,
66+
});
67+
chat = new Chat({
68+
userName: emulator.botLogin,
69+
adapters: { github: adapter },
70+
state: createMemoryState(),
71+
logger: silentLogger,
72+
});
73+
await chat.initialize();
74+
75+
expect(adapter.botUserId).toBe("99999");
76+
});
77+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Verifies that outbound `thread.post` / `Message.edit` / `Message.delete` on
3+
* GitHub issue and PR-level conversation threads round-trip through the
4+
* adapter's Octokit client and land in the in-process emulator's stateful
5+
* store.
6+
*
7+
* Drives the adapter via PAT-mode auth pointing at the emulator's `apiUrl`,
8+
* obtains a Thread directly from `chat.thread(...)` (no inbound webhook
9+
* needed), and asserts on the emulator's `comments` collection.
10+
*/
11+
12+
import { createGitHubAdapter, type GitHubAdapter } from "@chat-adapter/github";
13+
import { createMemoryState } from "@chat-adapter/state-memory";
14+
import { Chat } from "chat";
15+
import {
16+
afterAll,
17+
afterEach,
18+
beforeAll,
19+
beforeEach,
20+
describe,
21+
expect,
22+
it,
23+
} from "vitest";
24+
import {
25+
createGitHubEmulator,
26+
type GitHubEmulatorHandle,
27+
silentLogger,
28+
} from "./utils";
29+
30+
describe("GitHub emulator: issue/PR comment round-trip", () => {
31+
let emulator: GitHubEmulatorHandle;
32+
let chat: Chat<{ github: GitHubAdapter }>;
33+
let adapter: GitHubAdapter;
34+
35+
beforeAll(async () => {
36+
emulator = await createGitHubEmulator();
37+
});
38+
39+
afterAll(async () => {
40+
await emulator.close();
41+
});
42+
43+
beforeEach(async () => {
44+
adapter = createGitHubAdapter({
45+
apiUrl: emulator.apiUrl,
46+
token: emulator.botToken,
47+
webhookSecret: emulator.webhookSecret,
48+
userName: emulator.botLogin,
49+
logger: silentLogger,
50+
});
51+
chat = new Chat({
52+
userName: emulator.botLogin,
53+
adapters: { github: adapter },
54+
state: createMemoryState(),
55+
logger: silentLogger,
56+
});
57+
await chat.initialize();
58+
});
59+
60+
afterEach(async () => {
61+
await chat.shutdown();
62+
emulator.reset();
63+
});
64+
65+
function botCommentsForRepo() {
66+
return emulator.ghStore.comments
67+
.all()
68+
.filter((c) => c.user_id === emulator.botUserId);
69+
}
70+
71+
it("posts an issue comment via issues.createComment", async () => {
72+
const threadId = `github:${emulator.owner}/${emulator.repo}:issue:${emulator.issueNumber}`;
73+
const thread = chat.thread(threadId);
74+
75+
await thread.post("Hello from the bot on the issue");
76+
77+
const comments = botCommentsForRepo();
78+
expect(comments).toHaveLength(1);
79+
expect(comments[0].body).toBe("Hello from the bot on the issue");
80+
expect(comments[0].issue_number).toBe(emulator.issueNumber);
81+
expect(comments[0].comment_type).toBe("issue");
82+
});
83+
84+
it("posts a PR-level conversation comment via issues.createComment", async () => {
85+
const threadId = `github:${emulator.owner}/${emulator.repo}:${emulator.prNumber}`;
86+
const thread = chat.thread(threadId);
87+
88+
await thread.post("Hello from the bot on the PR");
89+
90+
const comments = botCommentsForRepo();
91+
expect(comments).toHaveLength(1);
92+
expect(comments[0].body).toBe("Hello from the bot on the PR");
93+
expect(comments[0].issue_number).toBe(emulator.prNumber);
94+
expect(comments[0].comment_type).toBe("issue");
95+
});
96+
97+
it("editMessage updates the stored comment body via issues.updateComment", async () => {
98+
const threadId = `github:${emulator.owner}/${emulator.repo}:${emulator.prNumber}`;
99+
const thread = chat.thread(threadId);
100+
101+
const message = await thread.post("draft");
102+
await message.edit("final");
103+
104+
const comments = botCommentsForRepo();
105+
expect(comments).toHaveLength(1);
106+
expect(comments[0].body).toBe("final");
107+
});
108+
109+
it("deleteMessage removes the comment via issues.deleteComment", async () => {
110+
const threadId = `github:${emulator.owner}/${emulator.repo}:${emulator.prNumber}`;
111+
const thread = chat.thread(threadId);
112+
113+
const message = await thread.post("transient");
114+
await message.delete();
115+
116+
expect(botCommentsForRepo()).toHaveLength(0);
117+
});
118+
});

0 commit comments

Comments
 (0)