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('admin can delete issue', async () => {
344 const issueNum = issueUrl.split('/issues/')[1];
345 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
346 maxRedirects: 0,
347 });
348 expect(resp.status()).toBe(302);
349 // Issue should be gone
350 const page = await adminCtx.newPage();
351 try {
352 const checkResp = await page.request.get(issueUrl);
353 expect(checkResp.status()).toBe(404);
354 } finally { await page.close(); }
355 });
356});
357
358// ─── Repository description update ───────────────────────────────────────────
359
360describe('repo description', () => {
361 let adminCtx: BrowserContext;
362
363 beforeAll(async () => { adminCtx = await loggedInContext(); });
364 afterAll(async () => { await adminCtx.close(); });
365
366 test('updating repo description is reflected on list page', async () => {
367 const page = await adminCtx.newPage();
368 try {
369 await page.goto(`${BASE}/my-repo/settings`);
370 await page.fill('[name=description]', 'A freshly updated description');
371 await page.click('form[action$="/settings"] button[type=submit]');
372 expect(await page.locator('.form-success').isVisible()).toBe(true);
373
374 await page.goto(BASE);
375 const desc = await page.locator('.repo-description').allTextContents();
376 expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
377 } finally { await page.close(); }
378 });
379});
380
381// ─── Issue and patch templates ────────────────────────────────────────────────
382
383describe('issue and patch templates', () => {
384 let adminCtx: BrowserContext;
385
386 beforeAll(async () => { adminCtx = await loggedInContext(); });
387 afterAll(async () => { await adminCtx.close(); });
388
389 test('issue template can be saved and is prefilled on new issue form', async () => {
390 const page = await adminCtx.newPage();
391 try {
392 await page.goto(`${BASE}/my-repo/settings`);
393 await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
394 await page.click('form[action$="/settings"] button[type=submit]');
395 expect(await page.locator('.form-success').isVisible()).toBe(true);
396
397 await page.goto(`${BASE}/my-repo/issues/new`);
398 const body = await page.locator('[name=body]').inputValue();
399 expect(body).toContain('## Steps to reproduce');
400 expect(body).toContain('## Expected behavior');
401 } finally { await page.close(); }
402 });
403
404 test('patch template can be saved and is prefilled on new patch form', async () => {
405 const page = await adminCtx.newPage();
406 try {
407 await page.goto(`${BASE}/my-repo/settings`);
408 await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
409 await page.click('form[action$="/settings"] button[type=submit]');
410 expect(await page.locator('.form-success').isVisible()).toBe(true);
411
412 await page.goto(`${BASE}/my-repo/patches/new`);
413 const desc = await page.locator('[name=description]').inputValue();
414 expect(desc).toContain('## Summary');
415 expect(desc).toContain('## Testing');
416 } finally { await page.close(); }
417 });
418
419 test('clearing the issue template removes prefill', async () => {
420 const page = await adminCtx.newPage();
421 try {
422 await page.goto(`${BASE}/my-repo/settings`);
423 await page.fill('[name=issue_template]', '');
424 await page.click('form[action$="/settings"] button[type=submit]');
425 expect(await page.locator('.form-success').isVisible()).toBe(true);
426
427 await page.goto(`${BASE}/my-repo/issues/new`);
428 const body = await page.locator('[name=body]').inputValue();
429 expect(body).toBe('');
430 } finally { await page.close(); }
431 });
432
433 test('clearing the patch template removes prefill', async () => {
434 const page = await adminCtx.newPage();
435 try {
436 await page.goto(`${BASE}/my-repo/settings`);
437 await page.fill('[name=patch_template]', '');
438 await page.click('form[action$="/settings"] button[type=submit]');
439 expect(await page.locator('.form-success').isVisible()).toBe(true);
440
441 await page.goto(`${BASE}/my-repo/patches/new`);
442 const desc = await page.locator('[name=description]').inputValue();
443 expect(desc).toBe('');
444 } finally { await page.close(); }
445 });
446});
447