e2e.issues.test.ts
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import {
5 BASE,
6 ADMIN_PASS,
7 setupTestEnv,
8 spawnServer,
9 killServer,
10 login,
11 seedRepo,
12} from './helpers.ts';
13
14let browser: Browser;
15let server: Awaited<ReturnType<typeof spawnServer>>;
16
17beforeAll(async () => {
18 await setupTestEnv();
19 server = await spawnServer();
20 browser = await chromium.launch();
21
22 // Register alice
23 const regCtx = await browser.newContext();
24 const regPage = await regCtx.newPage();
25 try {
26 await regPage.goto(`${BASE}/register`);
27 await regPage.fill('[name=username]', 'alice');
28 await regPage.fill('[name=password]', 'password123');
29 await regPage.fill('[name=password2]', 'password123');
30 await regPage.click('button[type=submit]');
31 await regPage.waitForURL(BASE + '/');
32 } finally { await regCtx.close(); }
33
34 // Create my-repo
35 const adminCtx = await browser.newContext();
36 const adminPage = await adminCtx.newPage();
37 try {
38 await login(adminPage);
39 await adminPage.goto(`${BASE}/new`);
40 await adminPage.fill('[name=name]', 'my-repo');
41 await adminPage.click('form[action="/new"] button[type=submit]');
42 await adminPage.waitForURL(`${BASE}/my-repo`);
43 } finally { await adminCtx.close(); }
44
45 await seedRepo('my-repo');
46});
47
48afterAll(async () => {
49 await browser.close();
50 await killServer(server);
51});
52
53async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
54 const ctx = await browser.newContext();
55 const page = await ctx.newPage();
56 await login(page, username, password);
57 await page.close();
58 return ctx;
59}
60
61// ─── Issues ───────────────────────────────────────────────────────────────────
62
63describe('issues', () => {
64 let adminCtx: BrowserContext;
65 let issueUrl: string;
66 let completedIssueUrl: string;
67
68 beforeAll(async () => {
69 adminCtx = await loggedInContext();
70 });
71
72 afterAll(async () => { await adminCtx.close(); });
73
74 test('create issue', async () => {
75 const page = await adminCtx.newPage();
76 try {
77 await page.goto(`${BASE}/my-repo/issues/new`);
78 await page.fill('[name=title]', 'First issue');
79 await page.fill('[name=body]', 'Body with **markdown**.');
80 await page.click('form[action$="/issues"] button[type=submit]');
81 await page.waitForURL(/\/my-repo\/issues\/\d+/);
82 issueUrl = page.url();
83 expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
84 } finally { await page.close(); }
85 });
86
87 test('issue body renders markdown', async () => {
88 const page = await adminCtx.newPage();
89 try {
90 await page.goto(issueUrl);
91 expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
92 } finally { await page.close(); }
93 });
94
95 test('issue appears in open list', async () => {
96 const page = await adminCtx.newPage();
97 try {
98 await page.goto(`${BASE}/my-repo/issues`);
99 const titles = await page.locator('.issue-title').allTextContents();
100 expect(titles.some(t => t.includes('First issue'))).toBe(true);
101 } finally { await page.close(); }
102 });
103
104 test('unauthenticated user is redirected to login from new issue form', async () => {
105 const ctx = await browser.newContext();
106 const page = await ctx.newPage();
107 try {
108 await page.goto(`${BASE}/my-repo/issues/new`);
109 expect(page.url()).toContain('/login');
110 } finally { await ctx.close(); }
111 });
112
113 test('add comment', async () => {
114 const page = await adminCtx.newPage();
115 try {
116 await page.goto(issueUrl);
117 const beforeCount = await page.locator('.timeline-item').count();
118 await page.fill('textarea[name=body]', 'A follow-up comment.');
119 await page.click('form[action*="/comments"] button[type=submit]');
120 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
121 expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
122 } finally { await page.close(); }
123 });
124
125 test('react to issue', async () => {
126 const page = await adminCtx.newPage();
127 try {
128 await page.goto(issueUrl);
129 await page.locator('.reaction-picker').first().click();
130 await page.locator('.reaction-picker-btn').first().click();
131 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
132 expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
133 } finally { await page.close(); }
134 });
135
136 test('close issue changes status badge', async () => {
137 const page = await adminCtx.newPage();
138 try {
139 await page.goto(issueUrl);
140 await page.click('form[action*="/close"] button');
141 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
142 expect(await page.locator('.issue-badge').textContent()).toBe('closed');
143 } finally { await page.close(); }
144 });
145
146 test('closed issue appears in closed list', async () => {
147 const page = await adminCtx.newPage();
148 try {
149 await page.goto(`${BASE}/my-repo/issues?status=closed`);
150 const titles = await page.locator('.issue-title').allTextContents();
151 expect(titles.some(t => t.includes('First issue'))).toBe(true);
152 } finally { await page.close(); }
153 });
154
155 test('reopen issue', async () => {
156 const page = await adminCtx.newPage();
157 try {
158 await page.goto(issueUrl);
159 await page.click('.issue-detail-meta-actions button');
160 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
161 expect(await page.locator('.issue-badge').textContent()).toBe('open');
162 } finally { await page.close(); }
163 });
164
165 test('completed button marks issue as completed', async () => {
166 const page = await adminCtx.newPage();
167 try {
168 await page.goto(`${BASE}/my-repo/issues/new`);
169 await page.fill('[name=title]', 'To be completed');
170 await page.click('form[action$="/issues"] button[type=submit]');
171 await page.waitForURL(/\/my-repo\/issues\/\d+/);
172 completedIssueUrl = page.url();
173 await page.click('form[action*="/complete"] button');
174 await page.waitForURL(new RegExp(completedIssueUrl.replace(BASE, '')));
175 expect(await page.locator('.issue-badge').textContent()).toBe('completed');
176 } finally { await page.close(); }
177 });
178
179 test('completed issue appears in completed list', async () => {
180 const page = await adminCtx.newPage();
181 try {
182 await page.goto(`${BASE}/my-repo/issues?status=completed`);
183 const titles = await page.locator('.issue-title').allTextContents();
184 expect(titles.some(t => t.includes('To be completed'))).toBe(true);
185 } finally { await page.close(); }
186 });
187
188 test('non-admin cannot complete or close issue', async () => {
189 const issueNum = issueUrl.split('/issues/')[1];
190 const ctx = await browser.newContext();
191 try {
192 const completeResp = await ctx.request.post(
193 `${BASE}/my-repo/issues/${issueNum}/complete`,
194 { maxRedirects: 0 },
195 );
196 expect(completeResp.status()).toBe(302);
197 expect(completeResp.headers()['location']).toContain('/login');
198 } finally { await ctx.close(); }
199 });
200
201 test('reacting with same emoji toggles it off', async () => {
202 const page = await adminCtx.newPage();
203 try {
204 await page.goto(issueUrl);
205 // Reaction was added by the earlier 'react to issue' test
206 expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
207 await page.locator('.reaction-btn').first().click();
208 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
209 expect(await page.locator('.reaction-btn').count()).toBe(0);
210 } finally { await page.close(); }
211 });
212
213 test('react to issue comment', async () => {
214 const page = await adminCtx.newPage();
215 try {
216 await page.goto(issueUrl);
217 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)')
218 .filter({ hasText: 'A follow-up comment.' });
219 await commentItem.locator('.reaction-add-btn').click();
220 await commentItem.locator('.reaction-picker-btn').first().click();
221 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
222 expect(await commentItem.locator('.reaction-btn').count()).toBeGreaterThan(0);
223 } finally { await page.close(); }
224 });
225});
226
227// ─── Issue editing and deletion ───────────────────────────────────────────────
228
229describe('issue editing', () => {
230 let adminCtx: BrowserContext;
231 let aliceCtx: BrowserContext;
232 let issueUrl: string;
233
234 beforeAll(async () => {
235 adminCtx = await loggedInContext();
236 aliceCtx = await loggedInContext('alice', 'password123');
237
238 // Create an issue to edit
239 const page = await adminCtx.newPage();
240 try {
241 await page.goto(`${BASE}/my-repo/issues/new`);
242 await page.fill('[name=title]', 'Issue to edit');
243 await page.fill('[name=body]', 'Original body.');
244 await page.click('form[action$="/issues"] button[type=submit]');
245 await page.waitForURL(/\/my-repo\/issues\/\d+/);
246 issueUrl = page.url();
247 } finally { await page.close(); }
248 });
249
250 afterAll(async () => {
251 await adminCtx.close();
252 await aliceCtx.close();
253 });
254
255 test('author can edit issue title and body', async () => {
256 const page = await adminCtx.newPage();
257 try {
258 await page.goto(issueUrl);
259 // Edit title via title form
260 await page.click('.title-edit-open');
261 await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
262 await page.click('.title-edit-form-area [type=submit]');
263 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
264 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
265 // Edit body via inline form
266 await page.click('.timeline-author .inline-edit-details summary');
267 await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
268 await page.click('.inline-edit-form-area [type=submit]');
269 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
270 } finally { await page.close(); }
271 });
272
273 test('edited marker appears after editing', async () => {
274 const page = await adminCtx.newPage();
275 try {
276 await page.goto(issueUrl);
277 expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
278 } finally { await page.close(); }
279 });
280
281 test('non-author non-admin cannot edit issue', async () => {
282 const page = await aliceCtx.newPage();
283 try {
284 const issueNum = issueUrl.split('/issues/')[1];
285 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
286 form: { title: 'Hacked title', edit_body: '' },
287 maxRedirects: 0,
288 });
289 expect(resp.status()).toBe(403);
290 } finally { await page.close(); }
291 });
292
293 test('author can edit issue comment', async () => {
294 const page = await adminCtx.newPage();
295 try {
296 await page.goto(issueUrl);
297 // Add a comment first
298 await page.fill('textarea[name=body]', 'Comment to edit.');
299 await page.click('form[action*="/comments"] button[type=submit]');
300 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
301
302 // Edit the comment
303 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
304 await commentItem.locator('.inline-edit-details summary').click();
305 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
306 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
307 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
308 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
309 } finally { await page.close(); }
310 });
311
312 test('non-admin user can create an issue', async () => {
313 const page = await aliceCtx.newPage();
314 try {
315 await page.goto(`${BASE}/my-repo/issues/new`);
316 await page.fill('[name=title]', "Alice's issue");
317 await page.click('form[action$="/issues"] button[type=submit]');
318 await page.waitForURL(/\/my-repo\/issues\/\d+/);
319 expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
320 } finally { await page.close(); }
321 });
322
323 test('non-admin cannot comment on a closed issue', async () => {
324 // Close the issue as admin first
325 const issueNum = issueUrl.split('/issues/')[1];
326 await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
327
328 const page = await aliceCtx.newPage();
329 try {
330 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
331 form: { body: 'comment on closed issue' },
332 maxRedirects: 0,
333 });
334 // Non-admin gets redirected (silently ignored), not an error
335 expect(resp.status()).toBe(302);
336 // The comment should NOT appear
337 await page.goto(issueUrl);
338 const bodies = await page.locator('.timeline-body').allTextContents();
339 expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
340 } finally { await page.close(); }
341 });
342
343 test('cannot edit comment via wrong repo url (cross-repo bypass)', async () => {
344 // Create a second repo
345 const setupPage = await adminCtx.newPage();
346 try {
347 await setupPage.goto(`${BASE}/new`);
348 await setupPage.fill('[name=name]', 'other-repo');
349 await setupPage.click('form[action="/new"] button[type=submit]');
350 await setupPage.waitForURL(`${BASE}/other-repo`);
351 } finally { await setupPage.close(); }
352
353 // Pull a comment id from the existing my-repo issue
354 const issueNum = issueUrl.split('/issues/')[1];
355 const page = await adminCtx.newPage();
356 try {
357 await page.goto(issueUrl);
358 const formAction = await page
359 .locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`)
360 .first()
361 .getAttribute('action');
362 expect(formAction).toBeTruthy();
363 const commentId = formAction!.split('/comments/')[1]!.split('/')[0];
364
365 // Edit the same comment via /other-repo/... — must 404, not 200/302
366 const resp = await page.request.post(
367 `${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`,
368 { form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 },
369 );
370 expect(resp.status()).toBe(404);
371
372 // And the original comment must be unchanged
373 await page.goto(issueUrl);
374 const bodies = await page.locator('.timeline-body').allTextContents();
375 expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).toBe(true);
376 } finally { await page.close(); }
377 });
378
379 test('admin can delete issue', async () => {
380 const issueNum = issueUrl.split('/issues/')[1];
381 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
382 maxRedirects: 0,
383 });
384 expect(resp.status()).toBe(302);
385 // Issue should be gone
386 const page = await adminCtx.newPage();
387 try {
388 const checkResp = await page.request.get(issueUrl);
389 expect(checkResp.status()).toBe(404);
390 } finally { await page.close(); }
391 });
392});
393
394// ─── Repository description update ───────────────────────────────────────────
395
396describe('repo description', () => {
397 let adminCtx: BrowserContext;
398
399 beforeAll(async () => { adminCtx = await loggedInContext(); });
400 afterAll(async () => { await adminCtx.close(); });
401
402 test('updating repo description is reflected on list page', async () => {
403 const page = await adminCtx.newPage();
404 try {
405 await page.goto(`${BASE}/my-repo/settings`);
406 await page.fill('[name=description]', 'A freshly updated description');
407 await page.click('form[action$="/settings"] button[type=submit]');
408 expect(await page.locator('.form-success').isVisible()).toBe(true);
409
410 await page.goto(BASE);
411 const desc = await page.locator('.repo-description').allTextContents();
412 expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
413 } finally { await page.close(); }
414 });
415});
416
417// ─── Issue and patch templates ────────────────────────────────────────────────
418
419describe('issue and patch templates', () => {
420 let adminCtx: BrowserContext;
421
422 beforeAll(async () => { adminCtx = await loggedInContext(); });
423 afterAll(async () => { await adminCtx.close(); });
424
425 test('issue template can be saved and is prefilled on new issue form', async () => {
426 const page = await adminCtx.newPage();
427 try {
428 await page.goto(`${BASE}/my-repo/settings`);
429 await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
430 await page.click('form[action$="/settings"] button[type=submit]');
431 expect(await page.locator('.form-success').isVisible()).toBe(true);
432
433 await page.goto(`${BASE}/my-repo/issues/new`);
434 const body = await page.locator('[name=body]').inputValue();
435 expect(body).toContain('## Steps to reproduce');
436 expect(body).toContain('## Expected behavior');
437 } finally { await page.close(); }
438 });
439
440 test('patch template can be saved and is prefilled on new patch form', async () => {
441 const page = await adminCtx.newPage();
442 try {
443 await page.goto(`${BASE}/my-repo/settings`);
444 await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
445 await page.click('form[action$="/settings"] button[type=submit]');
446 expect(await page.locator('.form-success').isVisible()).toBe(true);
447
448 await page.goto(`${BASE}/my-repo/patches/new`);
449 const desc = await page.locator('[name=description]').inputValue();
450 expect(desc).toContain('## Summary');
451 expect(desc).toContain('## Testing');
452 } finally { await page.close(); }
453 });
454
455 test('clearing the issue template removes prefill', async () => {
456 const page = await adminCtx.newPage();
457 try {
458 await page.goto(`${BASE}/my-repo/settings`);
459 await page.fill('[name=issue_template]', '');
460 await page.click('form[action$="/settings"] button[type=submit]');
461 expect(await page.locator('.form-success').isVisible()).toBe(true);
462
463 await page.goto(`${BASE}/my-repo/issues/new`);
464 const body = await page.locator('[name=body]').inputValue();
465 expect(body).toBe('');
466 } finally { await page.close(); }
467 });
468
469 test('clearing the patch template removes prefill', async () => {
470 const page = await adminCtx.newPage();
471 try {
472 await page.goto(`${BASE}/my-repo/settings`);
473 await page.fill('[name=patch_template]', '');
474 await page.click('form[action$="/settings"] button[type=submit]');
475 expect(await page.locator('.form-success').isVisible()).toBe(true);
476
477 await page.goto(`${BASE}/my-repo/patches/new`);
478 const desc = await page.locator('[name=description]').inputValue();
479 expect(desc).toBe('');
480 } finally { await page.close(); }
481 });
482});
483