Skip to content

Commit cf4b544

Browse files
authored
Add Slack parity baseline tests (#152)
* Add Slack parity baseline tests - Add shared Slack test helpers and a baseline coverage matrix. - Add Slack WebClient conformance and event dispatch baseline tests. - Alias Slack Vitest runs to the TypeScript core runtime. * Cover Slack OAuth callback route * Make Slack SDK tests bind loopback
1 parent 1be3f8a commit cf4b544

9 files changed

Lines changed: 951 additions & 461 deletions

File tree

packages/@emulators/slack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@emulators/core": "workspace:*"
3939
},
4040
"devDependencies": {
41+
"@slack/web-api": "^7.16.0",
4142
"tsup": "^8",
4243
"typescript": "^5.7",
4344
"vitest": "^4.1.0"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
import { slackCoverageMatrix } from "./slack-coverage.js";
3+
import { createSlackTestApp } from "./helpers.js";
4+
5+
interface RegisteredRoute {
6+
method: string;
7+
compiled: {
8+
pattern: string;
9+
};
10+
}
11+
12+
function registeredSlackRoutes(): string[] {
13+
const { app } = createSlackTestApp();
14+
const routes = (app as unknown as { routes: RegisteredRoute[] }).routes;
15+
return routes.map((route) => `${route.method} ${route.compiled.pattern}`).sort();
16+
}
17+
18+
describe("Slack coverage matrix", () => {
19+
it("has unique method entries", () => {
20+
const methods = slackCoverageMatrix.map((entry) => entry.method);
21+
expect(new Set(methods).size).toBe(methods.length);
22+
});
23+
24+
it("maps every registered endpoint to at least one test file", () => {
25+
const currentEntries = slackCoverageMatrix.filter((entry) => entry.status !== "not_started");
26+
expect(currentEntries.length).toBeGreaterThan(0);
27+
expect(currentEntries.map((entry) => entry.route).sort()).toEqual(registeredSlackRoutes());
28+
29+
for (const entry of currentEntries) {
30+
expect(entry.route).toMatch(/^(GET|POST) /);
31+
expect(entry.testedBy.length, entry.method).toBeGreaterThan(0);
32+
}
33+
});
34+
35+
it("keeps planned gaps explicit", () => {
36+
const planned = slackCoverageMatrix.filter((entry) => entry.status === "not_started");
37+
expect(planned.map((entry) => entry.method)).toEqual(
38+
expect.arrayContaining(["chat.postEphemeral", "files.getUploadURLExternal", "views.publish"]),
39+
);
40+
for (const entry of planned) {
41+
expect(entry.notes).toMatch(/Planned|future/i);
42+
}
43+
});
44+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { AddressInfo } from "node:net";
2+
import {
3+
Hono,
4+
Store,
5+
WebhookDispatcher,
6+
authMiddleware,
7+
createApiErrorHandler,
8+
createErrorHandler,
9+
serve,
10+
} from "@emulators/core";
11+
import type { AppEnv, TokenMap } from "@emulators/core";
12+
import type { Server } from "node:http";
13+
import { vi } from "vitest";
14+
import { getSlackStore, slackPlugin } from "../index.js";
15+
16+
export const slackTestBaseUrl = "http://localhost:4000";
17+
export const slackTestToken = "xoxb-test-token";
18+
19+
export interface SlackTestHttpApp {
20+
fetch: (request: Request) => Promise<Response>;
21+
request: (input: string | Request, init?: RequestInit) => Promise<Response>;
22+
}
23+
24+
export interface SlackTestApp {
25+
app: SlackTestHttpApp;
26+
store: Store;
27+
webhooks: WebhookDispatcher;
28+
tokenMap: TokenMap;
29+
}
30+
31+
export interface SlackTestEmulator extends SlackTestApp {
32+
url: string;
33+
close: () => Promise<void>;
34+
}
35+
36+
export function createSlackTestApp(): SlackTestApp {
37+
const store = new Store();
38+
const webhooks = new WebhookDispatcher();
39+
const tokenMap: TokenMap = new Map();
40+
tokenMap.set(slackTestToken, {
41+
login: "U000000001",
42+
id: 1,
43+
scopes: ["chat:write", "channels:read", "users:read", "reactions:write"],
44+
});
45+
46+
const app = new Hono<AppEnv>() as Hono<AppEnv> & SlackTestHttpApp;
47+
app.onError(createApiErrorHandler());
48+
app.use("*", createErrorHandler());
49+
app.use("*", (authMiddleware as (tokens: TokenMap) => ReturnType<typeof authMiddleware>)(tokenMap));
50+
slackPlugin.register!(app, store, webhooks, slackTestBaseUrl, tokenMap);
51+
slackPlugin.seed?.(store, slackTestBaseUrl);
52+
53+
const ss = getSlackStore(store);
54+
const firstUser = ss.users.all()[0];
55+
if (firstUser) {
56+
ss.users.update(firstUser.id, { user_id: "U000000001" });
57+
}
58+
59+
return { app, store, webhooks, tokenMap };
60+
}
61+
62+
export function authHeaders(contentType = "application/json"): Record<string, string> {
63+
return { Authorization: `Bearer ${slackTestToken}`, "Content-Type": contentType };
64+
}
65+
66+
export async function startSlackTestEmulator(
67+
customize?: (setup: SlackTestApp) => void | Promise<void>,
68+
): Promise<SlackTestEmulator> {
69+
const setup = createSlackTestApp();
70+
await customize?.(setup);
71+
72+
const server = serve({ fetch: setup.app.fetch, port: 0, hostname: "127.0.0.1" }) as unknown as Server;
73+
await new Promise<void>((resolve, reject) => {
74+
server.once("listening", () => resolve());
75+
server.once("error", reject);
76+
});
77+
78+
const { port } = server.address() as AddressInfo;
79+
return {
80+
...setup,
81+
url: `http://127.0.0.1:${port}`,
82+
close: () =>
83+
new Promise<void>((resolve, reject) => {
84+
server.close((err) => (err ? reject(err) : resolve()));
85+
}),
86+
};
87+
}
88+
89+
export interface CapturedFetchRequest {
90+
url: string;
91+
init: RequestInit;
92+
}
93+
94+
export function captureFetchRequests(status = 200): {
95+
requests: CapturedFetchRequest[];
96+
jsonBodies: () => unknown[];
97+
} {
98+
const requests: CapturedFetchRequest[] = [];
99+
100+
vi.stubGlobal(
101+
"fetch",
102+
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
103+
requests.push({ url: String(input), init: init ?? {} });
104+
return { ok: status >= 200 && status < 300, status };
105+
}),
106+
);
107+
108+
return {
109+
requests,
110+
jsonBodies: () =>
111+
requests.map((request) => {
112+
const body = request.init.body;
113+
if (typeof body !== "string") return body;
114+
return JSON.parse(body);
115+
}),
116+
};
117+
}
118+
119+
export function registerSlackEventSubscription(webhooks: WebhookDispatcher, events: string[] = ["*"]): void {
120+
webhooks.register({
121+
url: "https://hooks.example/slack",
122+
events,
123+
active: true,
124+
owner: "slack",
125+
});
126+
}

0 commit comments

Comments
 (0)