CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
pulls.spec.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.
| a014def | 1 | /** |
| 2 | * E2E — Pull request flows | |
| 3 | * | |
| 4 | * Covers: create PR, add comment, merge PR, close PR. | |
| 5 | * | |
| 6 | * Strategy: beforeAll pushes an initial commit to main, then pushes a | |
| 7 | * feature branch. We create a PR from the feature branch against main | |
| 8 | * and exercise the PR lifecycle within a single spec run. | |
| 9 | */ | |
| 10 | ||
| 11 | import { test, expect } from "@playwright/test"; | |
| 9b2d870 | 12 | import * as path from "path"; |
| 13 | import * as fs from "fs/promises"; | |
| a014def | 14 | import { |
| 15 | uid, | |
| 16 | TEST_PASSWORD, | |
| 17 | pushTestCommit, | |
| 18 | pushFeatureBranch, | |
| 19 | cleanupDir, | |
| 20 | } from "./fixtures"; | |
| 21 | ||
| 22 | // --------------------------------------------------------------------------- | |
| 23 | // Shared state | |
| 24 | // --------------------------------------------------------------------------- | |
| 25 | ||
| 26 | let owner: string; | |
| 27 | let repoName: string; | |
| 28 | let featureBranch: string; | |
| 29 | let tmpDir: string; | |
| 30 | ||
| 31 | test.beforeAll(async ({ browser }) => { | |
| 32 | owner = uid("pruser"); | |
| 33 | repoName = uid("prerepo"); | |
| 34 | featureBranch = uid("feat-"); | |
| 35 | ||
| 36 | const page = await browser.newPage(); | |
| 37 | ||
| 38 | // Register | |
| 39 | await page.goto("/register"); | |
| 40 | await page.fill('input[name="username"]', owner); | |
| 41 | await page.fill('input[name="email"]', `${owner}@test.example`); | |
| 42 | await page.fill('input[name="password"]', TEST_PASSWORD); | |
| 43 | await page.click('button[type="submit"]'); | |
| 44 | await page.waitForURL(/\/(dashboard|[a-z])/); | |
| 45 | ||
| 46 | // Create repo | |
| 47 | await page.goto("/new"); | |
| 48 | await page.fill('input[name="name"]', repoName); | |
| 49 | await page.click('button[type="submit"]'); | |
| 50 | await page.waitForURL(new RegExp(`/${owner}/${repoName}`)); | |
| 51 | ||
| 52 | await page.close(); | |
| 53 | ||
| 54 | // Push initial commit to main | |
| 55 | tmpDir = await pushTestCommit({ | |
| 56 | owner, | |
| 57 | repo: repoName, | |
| 58 | username: owner, | |
| 59 | password: TEST_PASSWORD, | |
| 60 | fileName: "README.md", | |
| 61 | fileContent: `# ${repoName}\n`, | |
| 62 | commitMsg: "Initial commit", | |
| 63 | }); | |
| 64 | ||
| 65 | // Push feature branch | |
| 66 | featureBranch = await pushFeatureBranch({ | |
| 67 | repoDir: tmpDir, | |
| 68 | username: owner, | |
| 69 | branchName: featureBranch, | |
| 70 | fileName: "feature.md", | |
| 71 | fileContent: "Feature branch content.\n", | |
| 72 | commitMsg: "Add feature file", | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | test.afterAll(async () => { | |
| 77 | await cleanupDir(tmpDir); | |
| 78 | }); | |
| 79 | ||
| 80 | // --------------------------------------------------------------------------- | |
| 81 | // Helper: log in the page as owner | |
| 82 | // --------------------------------------------------------------------------- | |
| 83 | ||
| 84 | async function loginAsOwner(page: Parameters<typeof test>[1] extends never ? never : any): Promise<void> { | |
| 85 | await page.goto("/login"); | |
| 86 | await page.fill('input[name="username"]', owner); | |
| 87 | await page.fill('input[name="password"]', TEST_PASSWORD); | |
| 88 | await page.click('button[type="submit"]'); | |
| 89 | await page.waitForURL(/\/(dashboard|[a-z])/); | |
| 90 | } | |
| 91 | ||
| 92 | // --------------------------------------------------------------------------- | |
| 93 | // Create PR | |
| 94 | // --------------------------------------------------------------------------- | |
| 95 | ||
| 96 | test.describe("Pull request creation", () => { | |
| 97 | test("PR list page is accessible", async ({ page }) => { | |
| 98 | await page.goto(`/${owner}/${repoName}/pulls`); | |
| 99 | await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/pulls`)); | |
| 100 | // Even if empty, the page should render without error | |
| 101 | await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i); | |
| 102 | }); | |
| 103 | ||
| 104 | test("can open a new PR from the compare page", async ({ page }) => { | |
| 105 | await loginAsOwner(page); | |
| 106 | ||
| 107 | // Navigate to compare page with the feature branch | |
| 108 | await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`); | |
| 109 | ||
| 110 | // Should show a PR creation form | |
| 111 | const titleInput = page.locator('input[name="title"]'); | |
| 112 | await expect(titleInput).toBeVisible({ timeout: 8_000 }); | |
| 113 | ||
| 114 | // Fill in PR details | |
| 115 | const prTitle = `E2E PR ${uid("pr")}`; | |
| 116 | await titleInput.fill(prTitle); | |
| 117 | ||
| 118 | await page.click('button[type="submit"]'); | |
| 119 | ||
| 120 | // Should redirect to the new PR | |
| 121 | await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`)); | |
| 122 | await expect(page.locator("body")).toContainText(prTitle); | |
| 123 | }); | |
| 124 | }); | |
| 125 | ||
| 126 | // --------------------------------------------------------------------------- | |
| 127 | // PR comment | |
| 128 | // --------------------------------------------------------------------------- | |
| 129 | ||
| 130 | test.describe("Pull request comment", () => { | |
| 131 | let prUrl: string; | |
| 132 | ||
| 133 | test.beforeAll(async ({ browser }) => { | |
| 134 | // Create a PR programmatically so we have a URL to comment on | |
| 135 | const page = await browser.newPage(); | |
| 136 | await loginAsOwner(page); | |
| 137 | ||
| 138 | await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`); | |
| 139 | const titleInput = page.locator('input[name="title"]'); | |
| 140 | await titleInput.waitFor({ timeout: 8_000 }); | |
| 141 | await titleInput.fill(`Comment-test PR ${uid("c")}`); | |
| 142 | await page.click('button[type="submit"]'); | |
| 143 | await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`)); | |
| 144 | prUrl = page.url(); | |
| 145 | await page.close(); | |
| 146 | }); | |
| 147 | ||
| 148 | test("can post a comment on a PR", async ({ page }) => { | |
| 149 | await loginAsOwner(page); | |
| 150 | await page.goto(prUrl); | |
| 151 | ||
| 152 | const commentBox = page.locator( | |
| 153 | 'textarea[name="body"], textarea[name="comment"]' | |
| 154 | ).first(); | |
| 155 | await expect(commentBox).toBeVisible({ timeout: 8_000 }); | |
| 156 | ||
| 157 | const commentText = `Test comment ${uid("cmt")}`; | |
| 158 | await commentBox.fill(commentText); | |
| 159 | await page.click('button[type="submit"]'); | |
| 160 | ||
| 161 | await expect(page.locator("body")).toContainText(commentText, { | |
| 162 | timeout: 8_000, | |
| 163 | }); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 167 | // --------------------------------------------------------------------------- | |
| 168 | // Merge PR | |
| 169 | // --------------------------------------------------------------------------- | |
| 170 | ||
| 171 | test.describe("Pull request merge", () => { | |
| 172 | let prUrl: string; | |
| 173 | ||
| 174 | test.beforeAll(async ({ browser }) => { | |
| 175 | const page = await browser.newPage(); | |
| 176 | await loginAsOwner(page); | |
| 177 | ||
| 178 | await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`); | |
| 179 | const titleInput = page.locator('input[name="title"]'); | |
| 180 | await titleInput.waitFor({ timeout: 8_000 }); | |
| 181 | await titleInput.fill(`Merge-test PR ${uid("m")}`); | |
| 182 | await page.click('button[type="submit"]'); | |
| 183 | await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`)); | |
| 184 | prUrl = page.url(); | |
| 185 | await page.close(); | |
| 186 | }); | |
| 187 | ||
| 188 | test("merge button is visible on an open PR", async ({ page }) => { | |
| 189 | await loginAsOwner(page); | |
| 190 | await page.goto(prUrl); | |
| 191 | ||
| 192 | // The merge button may be disabled if checks are pending — just check visible | |
| 193 | const mergeBtn = page.locator( | |
| 194 | 'button:has-text("Merge"), button[name="action"][value="merge"], form[action*="merge"] button' | |
| 195 | ).first(); | |
| 196 | await expect(mergeBtn).toBeVisible({ timeout: 8_000 }); | |
| 197 | }); | |
| 198 | ||
| 199 | test("can merge an open PR", async ({ page }) => { | |
| 200 | await loginAsOwner(page); | |
| 201 | await page.goto(prUrl); | |
| 202 | ||
| 203 | const mergeBtn = page.locator( | |
| 204 | 'button:has-text("Merge"), button[name="action"][value="merge"], form[action*="merge"] button' | |
| 205 | ).first(); | |
| 206 | ||
| 207 | // Only click if enabled — if branch-protection blocks it, skip gracefully | |
| 208 | if (await mergeBtn.isEnabled()) { | |
| 209 | await mergeBtn.click(); | |
| 210 | await expect(page.locator("body")).toContainText(/merged/i, { | |
| 211 | timeout: 10_000, | |
| 212 | }); | |
| 213 | } else { | |
| 214 | test.skip(); | |
| 215 | } | |
| 216 | }); | |
| 217 | }); | |
| 218 | ||
| 219 | // --------------------------------------------------------------------------- | |
| 220 | // Close PR | |
| 221 | // --------------------------------------------------------------------------- | |
| 222 | ||
| 223 | test.describe("Pull request close", () => { | |
| 224 | let prUrl: string; | |
| 225 | ||
| 226 | test.beforeAll(async ({ browser }) => { | |
| 227 | // Push another unique branch so we have an un-merged PR to close | |
| 228 | const closeBranch = uid("close-"); | |
| 229 | await pushFeatureBranch({ | |
| 230 | repoDir: tmpDir, | |
| 231 | username: owner, | |
| 232 | branchName: closeBranch, | |
| 233 | fileName: `close-${uid()}.md`, | |
| 234 | fileContent: "Close branch file.\n", | |
| 235 | commitMsg: "Add close-branch file", | |
| 236 | }); | |
| 237 | ||
| 238 | const page = await browser.newPage(); | |
| 239 | await loginAsOwner(page); | |
| 240 | ||
| 241 | await page.goto(`/${owner}/${repoName}/compare/main...${closeBranch}`); | |
| 242 | const titleInput = page.locator('input[name="title"]'); | |
| 243 | await titleInput.waitFor({ timeout: 8_000 }); | |
| 244 | await titleInput.fill(`Close-test PR ${uid("cl")}`); | |
| 245 | await page.click('button[type="submit"]'); | |
| 246 | await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`)); | |
| 247 | prUrl = page.url(); | |
| 248 | await page.close(); | |
| 249 | }); | |
| 250 | ||
| 251 | test("can close an open PR", async ({ page }) => { | |
| 252 | await loginAsOwner(page); | |
| 253 | await page.goto(prUrl); | |
| 254 | ||
| 255 | // Close button — server may render it as a form submit or link | |
| 256 | const closeBtn = page.locator( | |
| 257 | 'button:has-text("Close"), button[value="close"], form[action*="close"] button' | |
| 258 | ).first(); | |
| 259 | await expect(closeBtn).toBeVisible({ timeout: 8_000 }); | |
| 260 | await closeBtn.click(); | |
| 261 | ||
| 262 | await expect(page.locator("body")).toContainText(/closed/i, { | |
| 263 | timeout: 8_000, | |
| 264 | }); | |
| 265 | }); | |
| 266 | }); | |
| 9b2d870 | 267 | |
| 268 | // --------------------------------------------------------------------------- | |
| 269 | // PR command center — /pulls smoke test | |
| 270 | // --------------------------------------------------------------------------- | |
| 271 | ||
| 272 | test.describe("PR command center (/pulls)", () => { | |
| 273 | test("page loads without 500 when unauthenticated", async ({ page }) => { | |
| 274 | const res = await page.goto("/pulls"); | |
| 275 | // Unauthenticated users get redirected to login — either way, no 500 | |
| 276 | expect(res?.status()).not.toBe(500); | |
| 277 | await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i); | |
| 278 | }); | |
| 279 | ||
| 280 | test("page loads without 500 when authenticated", async ({ page }) => { | |
| 281 | const user = uid("pdash"); | |
| 282 | await page.goto("/register"); | |
| 283 | await page.fill('input[name="username"]', user); | |
| 284 | await page.fill('input[name="email"]', `${user}@test.example`); | |
| 285 | await page.fill('input[name="password"]', TEST_PASSWORD); | |
| 286 | await page.click('button[type="submit"]'); | |
| 287 | await page.waitForURL(/\/(dashboard|[a-z])/); | |
| 288 | ||
| 289 | const res = await page.goto("/pulls"); | |
| 290 | expect(res?.status()).not.toBe(500); | |
| 291 | await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i); | |
| 292 | }); | |
| 293 | }); | |
| 294 | ||
| 295 | // --------------------------------------------------------------------------- | |
| 296 | // Regression: PR detail must not 500 when git repo dir is missing | |
| 297 | // (Fixes: 500 on McCracken/Gluecron.com #1 — bare repo missing from FS) | |
| 298 | // --------------------------------------------------------------------------- | |
| 299 | ||
| 300 | test.describe("PR detail — missing git repo (regression)", () => { | |
| 301 | let prUrl: string; | |
| 302 | let ghostOwner: string; | |
| 303 | let ghostRepo: string; | |
| 304 | let gitRepoPath: string; | |
| 305 | let backupPath: string; | |
| 306 | let ghostTmpDir: string; | |
| 307 | ||
| 308 | test.beforeAll(async ({ browser }) => { | |
| 309 | ghostOwner = uid("ghostuser"); | |
| 310 | ghostRepo = uid("ghostrepo"); | |
| 311 | const branch = uid("ghost-feat-"); | |
| 312 | ||
| 313 | const page = await browser.newPage(); | |
| 314 | ||
| 315 | // Register | |
| 316 | await page.goto("/register"); | |
| 317 | await page.fill('input[name="username"]', ghostOwner); | |
| 318 | await page.fill('input[name="email"]', `${ghostOwner}@test.example`); | |
| 319 | await page.fill('input[name="password"]', TEST_PASSWORD); | |
| 320 | await page.click('button[type="submit"]'); | |
| 321 | await page.waitForURL(/\/(dashboard|[a-z])/); | |
| 322 | ||
| 323 | // Create repo | |
| 324 | await page.goto("/new"); | |
| 325 | await page.fill('input[name="name"]', ghostRepo); | |
| 326 | await page.click('button[type="submit"]'); | |
| 327 | await page.waitForURL(new RegExp(`/${ghostOwner}/${ghostRepo}`)); | |
| 328 | await page.close(); | |
| 329 | ||
| 330 | // Push initial commit + feature branch | |
| 331 | ghostTmpDir = await pushTestCommit({ | |
| 332 | owner: ghostOwner, | |
| 333 | repo: ghostRepo, | |
| 334 | username: ghostOwner, | |
| 335 | password: TEST_PASSWORD, | |
| 336 | fileName: "README.md", | |
| 337 | fileContent: `# ${ghostRepo}\n`, | |
| 338 | commitMsg: "Initial commit", | |
| 339 | }); | |
| 340 | await pushFeatureBranch({ | |
| 341 | repoDir: ghostTmpDir, | |
| 342 | username: ghostOwner, | |
| 343 | branchName: branch, | |
| 344 | fileName: "ghost.md", | |
| 345 | fileContent: "Ghost feature.\n", | |
| 346 | commitMsg: "Add ghost feature", | |
| 347 | }); | |
| 348 | ||
| 349 | // Create the PR via UI | |
| 350 | const prPage = await browser.newPage(); | |
| 351 | await prPage.goto("/login"); | |
| 352 | await prPage.fill('input[name="username"]', ghostOwner); | |
| 353 | await prPage.fill('input[name="password"]', TEST_PASSWORD); | |
| 354 | await prPage.click('button[type="submit"]'); | |
| 355 | await prPage.waitForURL(/\/(dashboard|[a-z])/); | |
| 356 | ||
| 357 | await prPage.goto(`/${ghostOwner}/${ghostRepo}/compare/main...${branch}`); | |
| 358 | const titleInput = prPage.locator('input[name="title"]'); | |
| 359 | await titleInput.waitFor({ timeout: 8_000 }); | |
| 360 | await titleInput.fill(`Ghost PR ${uid("gp")}`); | |
| 361 | await prPage.click('button[type="submit"]'); | |
| 362 | await prPage.waitForURL(new RegExp(`/${ghostOwner}/${ghostRepo}/pulls/\\d+`)); | |
| 363 | prUrl = prPage.url(); | |
| 364 | await prPage.close(); | |
| 365 | ||
| 366 | // Simulate missing git repo directory — rename it out of the way | |
| 367 | const reposRoot = | |
| 368 | process.env.GIT_REPOS_PATH ?? path.join(process.cwd(), "repos"); | |
| 369 | gitRepoPath = path.join(reposRoot, ghostOwner, `${ghostRepo}.git`); | |
| 370 | backupPath = `${gitRepoPath}.missing`; | |
| 371 | await fs.rename(gitRepoPath, backupPath).catch(() => { | |
| 372 | // If rename fails (e.g. repo dir doesn't exist for some reason), continue | |
| 373 | }); | |
| 374 | }); | |
| 375 | ||
| 376 | test.afterAll(async () => { | |
| 377 | // Restore the git repo directory so the test environment stays clean | |
| 378 | if (backupPath && gitRepoPath) { | |
| 379 | await fs.rename(backupPath, gitRepoPath).catch(() => {}); | |
| 380 | } | |
| 381 | await cleanupDir(ghostTmpDir); | |
| 382 | }); | |
| 383 | ||
| 384 | test("PR detail renders without 500 when git repo is missing from disk", async ({ | |
| 385 | page, | |
| 386 | }) => { | |
| 387 | const res = await page.goto(prUrl); | |
| 388 | // Must not be a 500 | |
| 389 | expect(res?.status()).not.toBe(500); | |
| 390 | await expect(page.locator("body")).not.toContainText( | |
| 391 | /500|Internal Server Error/i | |
| 392 | ); | |
| 393 | // PR data from DB should still be visible (title / state) | |
| 394 | await expect(page.locator("body")).toContainText(/open|ghost/i, { | |
| 395 | timeout: 8_000, | |
| 396 | }); | |
| 397 | }); | |
| 398 | }); |