CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-changelog.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3cbe3d6 | 1 | /** |
| 2 | * Block D7 — AI changelog route tests. | |
| 3 | * | |
| 4 | * The route depends on a real git repo on disk, so these tests drive the | |
| 5 | * route via the exported Hono app directly. A temporary bare repository is | |
| 6 | * spun up per test run under a unique GIT_REPOS_PATH so we don't collide | |
| 7 | * with other tests or the developer's local `./repos` tree. | |
| 8 | * | |
| 9 | * Tests here are deliberately scoped to the guarantees the Block D7 spec | |
| 10 | * calls out: | |
| 11 | * - Module imports cleanly. | |
| 12 | * - GET without query params renders the picker form (HTML). | |
| 13 | * - GET with nonsense from/to strings renders an error banner, NOT a 500. | |
| 14 | * - GET for a non-existent repo returns 404. | |
| 15 | */ | |
| 16 | ||
| 17 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 18 | import { mkdtemp, rm, mkdir, writeFile } from "fs/promises"; | |
| 19 | import { tmpdir } from "os"; | |
| 20 | import { join } from "path"; | |
| 21 | ||
| 22 | // Pin GIT_REPOS_PATH BEFORE importing the route so `config.gitReposPath` | |
| 23 | // (which is a lazy getter) reads our scratch dir on first access. | |
| 24 | const SCRATCH = await mkdtemp(join(tmpdir(), "gluecron-ai-changelog-")); | |
| 25 | process.env.GIT_REPOS_PATH = SCRATCH; | |
| 26 | ||
| 27 | // eslint-disable-next-line import/first | |
| 28 | const { default: aiChangelog } = await import("../routes/ai-changelog"); | |
| 29 | ||
| 30 | const OWNER = "alice"; | |
| 31 | const REPO = "demo"; | |
| 32 | ||
| 33 | async function run(cmd: string[], cwd: string) { | |
| 34 | const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" }); | |
| 35 | await proc.exited; | |
| 36 | } | |
| 37 | ||
| 38 | async function seedRepo(): Promise<void> { | |
| 39 | // Bare repo lives where getRepoPath expects: <root>/<owner>/<repo>.git | |
| 40 | const bareDir = join(SCRATCH, OWNER, `${REPO}.git`); | |
| 41 | await mkdir(bareDir, { recursive: true }); | |
| 42 | await run(["git", "init", "--bare", bareDir], SCRATCH); | |
| 43 | await run( | |
| 44 | ["git", "symbolic-ref", "HEAD", "refs/heads/main"], | |
| 45 | bareDir | |
| 46 | ); | |
| 47 | ||
| 48 | // Push one real commit from a scratch working tree so branches/refs exist. | |
| 49 | const workDir = await mkdtemp(join(tmpdir(), "gluecron-ai-changelog-work-")); | |
| 50 | await run(["git", "init", workDir], SCRATCH); | |
| 51 | await run( | |
| 52 | ["git", "-C", workDir, "config", "user.email", "test@example.com"], | |
| 53 | SCRATCH | |
| 54 | ); | |
| 55 | await run( | |
| 56 | ["git", "-C", workDir, "config", "user.name", "Test"], | |
| 57 | SCRATCH | |
| 58 | ); | |
| 59 | await writeFile(join(workDir, "README.md"), "# hello\n"); | |
| 60 | await run(["git", "-C", workDir, "add", "README.md"], SCRATCH); | |
| 61 | await run( | |
| 62 | ["git", "-C", workDir, "commit", "-m", "initial commit"], | |
| 63 | SCRATCH | |
| 64 | ); | |
| 65 | // Ensure the local branch is called main regardless of git defaults. | |
| 66 | await run( | |
| 67 | ["git", "-C", workDir, "branch", "-M", "main"], | |
| 68 | SCRATCH | |
| 69 | ); | |
| 70 | await run( | |
| 71 | ["git", "-C", workDir, "remote", "add", "origin", bareDir], | |
| 72 | SCRATCH | |
| 73 | ); | |
| 74 | await run( | |
| 75 | ["git", "-C", workDir, "push", "origin", "main"], | |
| 76 | SCRATCH | |
| 77 | ); | |
| 78 | ||
| 79 | await rm(workDir, { recursive: true, force: true }); | |
| 80 | } | |
| 81 | ||
| 82 | describe("routes/ai-changelog — module", () => { | |
| 83 | it("imports cleanly and exports a Hono app", () => { | |
| 84 | expect(aiChangelog).toBeDefined(); | |
| 85 | expect(typeof aiChangelog.request).toBe("function"); | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("routes/ai-changelog — repo present", () => { | |
| 90 | beforeAll(async () => { | |
| 91 | await seedRepo(); | |
| 92 | }); | |
| 93 | ||
| 94 | afterAll(async () => { | |
| 95 | await rm(SCRATCH, { recursive: true, force: true }).catch(() => {}); | |
| 96 | }); | |
| 97 | ||
| 98 | it("GET without query params renders the picker form", async () => { | |
| 99 | const res = await aiChangelog.request( | |
| 100 | `/${OWNER}/${REPO}/ai/changelog` | |
| 101 | ); | |
| 102 | expect(res.status).toBe(200); | |
| 103 | const body = await res.text(); | |
| 104 | expect(body).toContain("AI Changelog"); | |
| 105 | // The form exposes from/to inputs and a submit button. | |
| 106 | expect(body).toContain('name="from"'); | |
| 107 | expect(body).toContain('name="to"'); | |
| 108 | expect(body.toLowerCase()).toContain("generate"); | |
| 109 | }); | |
| 110 | ||
| 111 | it("GET with nonsense from/to renders an error banner, not a 500", async () => { | |
| 112 | const res = await aiChangelog.request( | |
| 113 | `/${OWNER}/${REPO}/ai/changelog?from=not-a-real-ref-xyz&to=also-not-real-abc` | |
| 114 | ); | |
| 115 | // Must NOT be a 500 — the route should handle unresolvable refs gracefully. | |
| 116 | expect(res.status).toBe(200); | |
| 117 | const body = await res.text(); | |
| 118 | // Error banner class used by the Layout for form errors. | |
| 119 | expect(body).toContain("auth-error"); | |
| 120 | // And mentions the offending ref(s). | |
| 121 | expect(body).toMatch(/Could not resolve/i); | |
| 122 | }); | |
| 123 | }); | |
| 124 | ||
| 125 | describe("routes/ai-changelog — missing repo", () => { | |
| 126 | it("returns 404 for a non-existent repo", async () => { | |
| 127 | const res = await aiChangelog.request( | |
| 128 | "/nobody-xyz/nothing-xyz/ai/changelog" | |
| 129 | ); | |
| 130 | expect(res.status).toBe(404); | |
| 131 | const body = await res.text(); | |
| 132 | expect(body.toLowerCase()).toContain("repository not found"); | |
| 133 | }); | |
| 134 | }); |