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