Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

pulls.spec.tsBlame398 lines · 1 contributor
a014defClaude1/**
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
11import { test, expect } from "@playwright/test";
9b2d870Claude12import * as path from "path";
13import * as fs from "fs/promises";
a014defClaude14import {
15 uid,
16 TEST_PASSWORD,
17 pushTestCommit,
18 pushFeatureBranch,
19 cleanupDir,
20} from "./fixtures";
21
22// ---------------------------------------------------------------------------
23// Shared state
24// ---------------------------------------------------------------------------
25
26let owner: string;
27let repoName: string;
28let featureBranch: string;
29let tmpDir: string;
30
31test.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
76test.afterAll(async () => {
77 await cleanupDir(tmpDir);
78});
79
80// ---------------------------------------------------------------------------
81// Helper: log in the page as owner
82// ---------------------------------------------------------------------------
83
84async 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
96test.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
130test.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
171test.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
223test.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});
9b2d870Claude267
268// ---------------------------------------------------------------------------
269// PR command center — /pulls smoke test
270// ---------------------------------------------------------------------------
271
272test.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
300test.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});