Add integration tests for Spike PR→Discord bridge
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
parent
f7684cf141
commit
6b30715fa1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
42
packages/spike/tests/bridge.test.ts
Normal file
42
packages/spike/tests/bridge.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof setTimeout>
|
||||
}
|
||||
|
||||
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<any> {
|
||||
function awaitWebhook(eventType: string, match: (payload: any) => boolean, timeoutMs = 15000): Promise<any> {
|
||||
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<TestPR> {
|
||||
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 = {}) {
|
||||
17
packages/spike/tests/setup.ts
Normal file
17
packages/spike/tests/setup.ts
Normal file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue
Block a user