From 6b30715fa11f13e5677347045209a7f8e1e18e74 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Tue, 10 Mar 2026 09:16:52 -0700 Subject: [PATCH] =?UTF-8?q?Add=20integration=20tests=20for=20Spike=20PR?= =?UTF-8?q?=E2=86=92Discord=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move webhook tests to root tests/ dir and restructure to be non-flaky. Setup webhook capture server once via preload, tear down on process exit. Tests now clean up their own PRs in afterEach. Added bridge test to verify Spike PRs create Discord threads. Made Discord client login explicit via startDiscord(). Updated config with new dev Discord channel and Spike username mappings. Co-Authored-By: Claude Haiku 4.5 --- packages/spike/package.json | 4 +- packages/spike/src/config.ts | 4 +- packages/spike/src/discord/README.md | 13 ++---- packages/spike/src/discord/index.ts | 28 +++++------ .../gitea/{test/api.test.ts => utils.test.ts} | 4 +- packages/spike/src/server.tsx | 4 +- packages/spike/tests/bridge.test.ts | 42 +++++++++++++++++ .../{src/gitea/test => tests}/helpers.ts | 36 +++++++-------- packages/spike/tests/setup.ts | 17 +++++++ .../gitea/test => tests}/webhooks.test.ts | 46 +++++++++---------- 10 files changed, 127 insertions(+), 71 deletions(-) rename packages/spike/src/gitea/{test/api.test.ts => utils.test.ts} (95%) create mode 100644 packages/spike/tests/bridge.test.ts rename packages/spike/{src/gitea/test => tests}/helpers.ts (93%) create mode 100644 packages/spike/tests/setup.ts rename packages/spike/{src/gitea/test => tests}/webhooks.test.ts (82%) diff --git a/packages/spike/package.json b/packages/spike/package.json index 94f8267..4f481ff 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -9,7 +9,9 @@ "authServer": "bun run --watch src/discord/auth.ts", "subdomain:start": "bun run src/server.tsx", "subdomain:dev": "bun run --hot src/server.tsx", - "test": "bun test src/gitea/test/" + "test:unit": "bun test src/", + "test:integration": "bun test --preload ./tests/setup.ts tests/", + "test": "bun test src/ && bun test --preload ./tests/setup.ts tests/" }, "prettier": { "printWidth": 110, diff --git a/packages/spike/src/config.ts b/packages/spike/src/config.ts index 98fa81a..12b0cde 100644 --- a/packages/spike/src/config.ts +++ b/packages/spike/src/config.ts @@ -8,11 +8,12 @@ type Config = { const devConfig: Config = { dbPath: ".//local-spike.db", - channelId: "1384275245174620370", + channelId: "1480720354325561505", discordClientId: "1384271480119885977", dataDir: "/Users/corey/code/tmp/data", giteaToDiscordUserMappings: { probablycorey: "corey", + Spike: "Spike", }, } @@ -24,6 +25,7 @@ const prodConfig: Config = { giteaToDiscordUserMappings: { probablycorey: "corey", defunkt: "defunkt", + Spike: "Spike", }, } diff --git a/packages/spike/src/discord/README.md b/packages/spike/src/discord/README.md index 74496bf..ef309d3 100644 --- a/packages/spike/src/discord/README.md +++ b/packages/spike/src/discord/README.md @@ -7,14 +7,9 @@ Discord bot client for the Gitea-Discord bridge. Initializes the bot, registers ```ts import { Client } from "discord.js" -/** - * The logged-in Discord bot client. - * - * Importing this module has side effects: - * 1. Creates and logs in the Discord client - * 2. Registers event listeners (message relay to Gitea, slash commands, status display) - * 3. Sets up crash logging (unhandled rejections/exceptions → crash.log) - * 4. Alerts the channel about any previous crash log - */ +// The Discord bot client instance. Not logged in until startDiscord() is called. const client: Client + +// Logs in the bot, registers event listeners and slash commands, sets up crash logging. +async function startDiscord(): Promise ``` diff --git a/packages/spike/src/discord/index.ts b/packages/spike/src/discord/index.ts index 37f603b..a2f4d29 100644 --- a/packages/spike/src/discord/index.ts +++ b/packages/spike/src/discord/index.ts @@ -15,20 +15,22 @@ export const client = new Client({ partials: [Partials.Channel, Partials.Message, Partials.Reaction], }) -await client.login(process.env.DISCORD_TOKEN) +export async function startDiscord() { + await client.login(process.env.DISCORD_TOKEN) -listenForEvents(client) -await registerCommands(client) + listenForEvents(client) + await registerCommands(client) -process.on("unhandledRejection", async (error) => { - console.error("💥 Unhandled promise rejection:", error) - await logCrash(error) -}) + process.on("unhandledRejection", async (error) => { + console.error("💥 Unhandled promise rejection:", error) + await logCrash(error) + }) -process.on("uncaughtException", async (error) => { - console.error("💥 Uncaught exception:", error) - await logCrash(error) -}) + process.on("uncaughtException", async (error) => { + console.error("💥 Uncaught exception:", error) + await logCrash(error) + }) -await alertAboutCrashLog(client) -// startAuthServer() this is handy if you make a new bot + await alertAboutCrashLog(client) + // startAuthServer() this is handy if you make a new bot +} diff --git a/packages/spike/src/gitea/test/api.test.ts b/packages/spike/src/gitea/utils.test.ts similarity index 95% rename from packages/spike/src/gitea/test/api.test.ts rename to packages/spike/src/gitea/utils.test.ts index 6834b4d..d8ebeb2 100644 --- a/packages/spike/src/gitea/test/api.test.ts +++ b/packages/spike/src/gitea/utils.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test" -import { convertUsername, threadName } from "../utils" -import type { Gitea } from "../types" +import { convertUsername, threadName } from "./utils" +import type { Gitea } from "./types" describe("convertUsername", () => { test("converts gitea username to discord username", () => { diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server.tsx index 8e1d8a5..33158b8 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server.tsx @@ -1,6 +1,8 @@ import { serve } from "bun" import { handleGiteaWebhook } from "./bridge" -import "./discord/index" // Make suer the discord client is initialized +import { startDiscord } from "./discord" + +await startDiscord() import { getConfig } from "./config" interface ErrorLog { diff --git a/packages/spike/tests/bridge.test.ts b/packages/spike/tests/bridge.test.ts new file mode 100644 index 0000000..fc46a8f --- /dev/null +++ b/packages/spike/tests/bridge.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect, beforeAll, afterEach, setDefaultTimeout } from "bun:test" +import { spike, spikeRepo } from "./setup" +import { type TestPR, expectPullRequestWebhook, openTestPR, cleanupTestPR } from "./helpers" +import { handleGiteaWebhook } from "../src/bridge" +import { client, startDiscord } from "../src/discord" + +setDefaultTimeout(30_000) + +beforeAll(async () => { + await startDiscord() +}) + +const prsToCleanup: TestPR[] = [] +afterEach(async () => { + while (prsToCleanup.length) { + await cleanupTestPR(prsToCleanup.pop()!) + } +}) + +describe("bridge: Spike-owned repo", () => { + test("Spike creating a PR on their own repo creates a Discord thread", async () => { + const branch = `test-${crypto.randomUUID().slice(0, 8)}-bridge` + + await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => { + const pr = await openTestPR(spikeRepo, spike, branch) + prsToCleanup.push(pr) + const payload = await webhook + + // Process the webhook through the bridge (Gitea → Discord) + await handleGiteaWebhook(payload, "pull_request") + + // Verify a Discord thread was created + const channelId = (await import("../src/config")).getConfig("channelId") + const channel = await client.channels.fetch(channelId) + if (!channel || !("threads" in channel)) throw new Error("Could not fetch channel") + + const threads = await channel.threads.fetchActive() + const thread = threads.threads.find((t) => t.name.includes(branch)) + expect(thread).toBeDefined() + }) + }) +}) diff --git a/packages/spike/src/gitea/test/helpers.ts b/packages/spike/tests/helpers.ts similarity index 93% rename from packages/spike/src/gitea/test/helpers.ts rename to packages/spike/tests/helpers.ts index 098fbb0..272fafe 100644 --- a/packages/spike/src/gitea/test/helpers.ts +++ b/packages/spike/tests/helpers.ts @@ -1,5 +1,4 @@ import { serve } from "bun" -import { afterAll } from "bun:test" import { ensure } from "@workshop/shared/utils" import { mkdtemp, rm } from "fs/promises" import { join } from "path" @@ -18,7 +17,6 @@ type Expectation = { timer: ReturnType } -const captured: any[] = [] const expectations: Expectation[] = [] function matchIncoming(eventType: string, payload: any) { @@ -37,7 +35,6 @@ const captureServer = serve({ POST: async (req) => { const payload = await req.json() const eventType = req.headers.get("X-Gitea-Event") || "unknown" - captured.push(payload) matchIncoming(eventType, payload) return new Response("OK") }, @@ -45,14 +42,7 @@ const captureServer = serve({ }, }) -// Registers an expectation before calling the callback, so no webhook can -// slip through the crack. The callback receives a promise that resolves -// to the webhook payload once it arrives. -function awaitWebhook( - eventType: string, - match: (payload: any) => boolean, - timeoutMs = 15000, -): Promise { +function awaitWebhook(eventType: string, match: (payload: any) => boolean, timeoutMs = 15000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { const idx = expectations.indexOf(expectation) @@ -103,18 +93,28 @@ export async function setupWebhooks(corey: User, spike: User, coreyRepo: string, ensure(tsResult.success, `Failed to get tailscale status: ${tsResult.stderr.toString()}`) const tailscaleUrl = `https://${JSON.parse(tsResult.stdout.toString()).Self.DNSName.replace(/\.$/, "")}` + // Clear any stale funnel/serve config from previous test runs + Bun.spawnSync(["pkill", "-f", "tailscale funnel"]) + Bun.spawnSync(["tailscale", "serve", "reset"]) + await Bun.sleep(500) + const funnel = Bun.spawn(["tailscale", "funnel", String(captureServer.port)], { stdout: "ignore", stderr: "ignore" }) const funnelUrl = `${tailscaleUrl}/gitea/webhook` // Wait for funnel to be reachable + let funnelReachable = false const start = Date.now() while (Date.now() - start < 10000) { try { const res = await fetch(funnelUrl, { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } }) - if (res.ok) break + if (res.ok) { + funnelReachable = true + break + } } catch {} await Bun.sleep(500) } + ensure(funnelReachable, `Tailscale funnel not reachable at ${funnelUrl} after 10s`) await ensureRepo(spikeRepo, spike) await addCollaborator(spikeRepo, spike, corey.username) @@ -124,7 +124,6 @@ export async function setupWebhooks(corey: User, spike: User, coreyRepo: string, // Flush stale webhook deliveries that Gitea retried when our funnel came // back up. Even with branch-scoped matching these can saturate the queue. await Bun.sleep(3000) - captured.length = 0 expectations.length = 0 return function teardown() { @@ -147,15 +146,14 @@ export type TestPR = { export async function openTestPR(repo: string, author: User, branch = `test-${Date.now()}`): Promise { const { dir, filename } = await pushBranch(repo, branch, author) const pr = await createPR(repo, `Test PR (${branch})`, branch, author) - - afterAll(async () => { - try { git(["push", "origin", "--delete", branch], dir) } catch {} - await rm(dir, { recursive: true, force: true }) - }) - return { repo, number: pr.number, branch, filename, dir, author } } +export async function cleanupTestPR(pr: TestPR) { + try { git(["push", "origin", "--delete", pr.branch], pr.dir) } catch {} + await rm(pr.dir, { recursive: true, force: true }) +} + // --- Gitea API --- async function giteaFetch(path: string, user: User, options: RequestInit = {}) { diff --git a/packages/spike/tests/setup.ts b/packages/spike/tests/setup.ts new file mode 100644 index 0000000..51971f2 --- /dev/null +++ b/packages/spike/tests/setup.ts @@ -0,0 +1,17 @@ +import { ensure } from "@workshop/shared/utils" +import { setupWebhooks, type User } from "./helpers" + +function getEnv(name: string): string { + const value = process.env[name] + ensure(value, `Missing env var: ${name}. See .env.example`) + return value +} + +export const corey: User = { token: getEnv("TEST_GITEA_API_TOKEN_COREY"), username: "probablycorey" } +export const spike: User = { token: getEnv("TEST_GITEA_API_TOKEN_SPIKE"), username: "Spike" } +export const coreyRepo = getEnv("TEST_REPO_COREY") +export const spikeRepo = getEnv("TEST_REPO_SPIKE") + +const teardown = await setupWebhooks(corey, spike, coreyRepo, spikeRepo) + +process.on("beforeExit", () => teardown()) diff --git a/packages/spike/src/gitea/test/webhooks.test.ts b/packages/spike/tests/webhooks.test.ts similarity index 82% rename from packages/spike/src/gitea/test/webhooks.test.ts rename to packages/spike/tests/webhooks.test.ts index 6d31975..efef309 100644 --- a/packages/spike/src/gitea/test/webhooks.test.ts +++ b/packages/spike/tests/webhooks.test.ts @@ -1,49 +1,38 @@ -import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test" - -setDefaultTimeout(30_000) -import { ensure } from "@workshop/shared/utils" +import { describe, test, expect, afterEach, setDefaultTimeout } from "bun:test" +import { corey, spike, coreyRepo, spikeRepo } from "./setup" import { - type User, - setupWebhooks, + type TestPR, expectPullRequestWebhook, expectIssueCommentWebhook, expectPullRequestCommentWebhook, openTestPR, + cleanupTestPR, commentOnPR, createReview, mergePR, } from "./helpers" +setDefaultTimeout(30_000) + function testBranch(label: string) { return `test-${crypto.randomUUID().slice(0, 8)}-${label}` } -function getEnv(name: string): string { - const value = process.env[name] - ensure(value, `Missing env var: ${name}. See .env.example`) - return value -} - -const corey: User = { token: getEnv("TEST_GITEA_API_TOKEN_COREY"), username: "probablycorey" } -const spike: User = { token: getEnv("TEST_GITEA_API_TOKEN_SPIKE"), username: "Spike" } -const coreyRepo = getEnv("TEST_REPO_COREY") -const spikeRepo = getEnv("TEST_REPO_SPIKE") - -let teardown: () => void - -beforeAll(async () => { - teardown = await setupWebhooks(corey, spike, coreyRepo, spikeRepo) +const prsToCleanup: TestPR[] = [] +afterEach(async () => { + while (prsToCleanup.length) { + await cleanupTestPR(prsToCleanup.pop()!) + } }) -afterAll(() => teardown()) - // --- Tests for Corey's repo (Spike opens PR, Corey comments/reviews/merges) --- describe(`${coreyRepo}`, () => { test("opening a PR sends pull_request webhook with correct author and repo", async () => { const branch = testBranch("open") await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => { - await openTestPR(coreyRepo, spike, branch) + const pr = await openTestPR(coreyRepo, spike, branch) + prsToCleanup.push(pr) const payload = await webhook expect(payload.pull_request.user.login).toBe(spike.username) expect(payload.repository.full_name).toBe(coreyRepo) @@ -53,6 +42,7 @@ describe(`${coreyRepo}`, () => { test("commenting sends issue_comment webhook with correct user", async () => { const branch = testBranch("comment") const pr = await openTestPR(coreyRepo, spike, branch) + prsToCleanup.push(pr) await expectIssueCommentWebhook({ action: "created", username: corey.username, prNumber: pr.number }, async (webhook) => { await commentOnPR(coreyRepo, pr.number, "Looks good!", corey) const payload = await webhook @@ -63,6 +53,7 @@ describe(`${coreyRepo}`, () => { test("review with line comment sends pull_request_comment webhook", async () => { const branch = testBranch("review") const pr = await openTestPR(coreyRepo, spike, branch) + prsToCleanup.push(pr) await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => { await createReview(coreyRepo, pr.number, corey, pr.filename) const payload = await webhook @@ -74,6 +65,7 @@ describe(`${coreyRepo}`, () => { test("merging sends pull_request closed webhook", async () => { const branch = testBranch("merge") const pr = await openTestPR(coreyRepo, spike, branch) + prsToCleanup.push(pr) await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => { await mergePR(coreyRepo, pr.number, corey) const payload = await webhook @@ -88,7 +80,8 @@ describe(`${spikeRepo}`, () => { test("opening a PR sends pull_request webhook with correct author and repo", async () => { const branch = testBranch("open") await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => { - await openTestPR(spikeRepo, corey, branch) + const pr = await openTestPR(spikeRepo, corey, branch) + prsToCleanup.push(pr) const payload = await webhook expect(payload.pull_request.user.login).toBe(corey.username) expect(payload.repository.full_name).toBe(spikeRepo) @@ -98,6 +91,7 @@ describe(`${spikeRepo}`, () => { test("commenting sends issue_comment webhook with correct user", async () => { const branch = testBranch("comment") const pr = await openTestPR(spikeRepo, corey, branch) + prsToCleanup.push(pr) await expectIssueCommentWebhook({ action: "created", username: spike.username, prNumber: pr.number }, async (webhook) => { await commentOnPR(spikeRepo, pr.number, "On it!", spike) const payload = await webhook @@ -108,6 +102,7 @@ describe(`${spikeRepo}`, () => { test("review with line comment sends pull_request_comment webhook", async () => { const branch = testBranch("review") const pr = await openTestPR(spikeRepo, corey, branch) + prsToCleanup.push(pr) await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => { await createReview(spikeRepo, pr.number, spike, pr.filename) const payload = await webhook @@ -119,6 +114,7 @@ describe(`${spikeRepo}`, () => { test("merging sends pull_request closed webhook", async () => { const branch = testBranch("merge") const pr = await openTestPR(spikeRepo, corey, branch) + prsToCleanup.push(pr) await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => { await mergePR(spikeRepo, pr.number, spike) const payload = await webhook