e2e.test.ts
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import { chromium } from 'playwright';
3import type { Browser, BrowserContext } from 'playwright';
4import { $ } from 'bun';
5import {
6 BASE,
7 ADMIN_PASS,
8 setupTestEnv,
9 spawnServer,
10 waitForServer,
11 login,
12 logout,
13 seedRepo,
14 seedBranch,
15 seedSubdir,
16 writeTempFile,
17 getHeadCommit,
18 PORT,
19} from './helpers.ts';
20
21// ─── Global setup ────────────────────────────────────────────────────────────
22
23let browser: Browser;
24let server: ReturnType<typeof spawnServer>;
25
26beforeAll(async () => {
27 await setupTestEnv();
28 server = spawnServer();
29 await waitForServer();
30 browser = await chromium.launch();
31});
32
33afterAll(async () => {
34 await browser.close();
35 server.kill();
36});
37
38// Helper: create a context already logged in as a given user.
39async function loggedInContext(username = 'admin', password = ADMIN_PASS) {
40 const ctx = await browser.newContext();
41 const page = await ctx.newPage();
42 await login(page, username, password);
43 await page.close();
44 return ctx;
45}
46
47// Helper: create N issues in a repo using the server API (no browser rendering)
48async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number) {
49 for (let i = 1; i <= count; i++) {
50 await ctx.request.fetch(`${BASE}/${repo}/issues`, {
51 method: 'POST',
52 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53 data: `title=Issue+number+${i}&body=`,
54 maxRedirects: 0,
55 }).catch(() => {}); // 302 redirect throws; that's fine
56 }
57}
58
59// Helper: create N patches in a repo using the server API
60async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
61 const VALID_PATCH = [
62 'diff --git a/f.txt b/f.txt',
63 'new file mode 100644',
64 'index 0000000..9daeafb',
65 '--- /dev/null',
66 '+++ b/f.txt',
67 '@@ -0,0 +1 @@',
68 '+x',
69 '',
70 ].join('\n');
71
72 writeTempFile('/tmp/bulk.patch', VALID_PATCH);
73 for (let i = 1; i <= count; i++) {
74 const page = await ctx.newPage();
75 try {
76 await page.goto(`${BASE}/${repo}/patches/new`);
77 await page.fill('[name=title]', `Patch number ${i}`);
78 await page.locator('[name=patch_file]').setInputFiles('/tmp/bulk.patch');
79 await page.click('form[action$="/patches"] button[type=submit]');
80 await page.waitForURL(new RegExp(`/${repo}/patches/\\d+`));
81 } finally { await page.close(); }
82 }
83}
84
85// ─── Auth ─────────────────────────────────────────────────────────────────────
86
87describe('auth', () => {
88 test('homepage loads', async () => {
89 const ctx = await browser.newContext();
90 const page = await ctx.newPage();
91 try {
92 await page.goto(BASE);
93 expect(await page.title()).toContain('Hearthforge');
94 } finally { await ctx.close(); }
95 });
96
97 test('wrong password shows error', async () => {
98 const ctx = await browser.newContext();
99 const page = await ctx.newPage();
100 try {
101 await page.goto(`${BASE}/login`);
102 await page.fill('[name=username]', 'admin');
103 await page.fill('[name=password]', 'wrongpassword');
104 await page.click('button[type=submit]');
105 expect(await page.locator('.form-error').textContent()).toContain('Invalid');
106 } finally { await ctx.close(); }
107 });
108
109 test('correct credentials redirect to homepage', async () => {
110 const ctx = await browser.newContext();
111 const page = await ctx.newPage();
112 try {
113 await login(page);
114 expect(page.url()).toBe(BASE + '/');
115 expect(await page.locator('.nav-user').isVisible()).toBe(true);
116 } finally { await ctx.close(); }
117 });
118
119 test('register new user', async () => {
120 const ctx = await browser.newContext();
121 const page = await ctx.newPage();
122 try {
123 await page.goto(`${BASE}/register`);
124 await page.fill('[name=username]', 'alice');
125 await page.fill('[name=password]', 'password123');
126 await page.fill('[name=password2]', 'password123');
127 await page.click('button[type=submit]');
128 await page.waitForURL(BASE + '/');
129 expect(await page.locator('.nav-user').textContent()).toBe('alice');
130 } finally { await ctx.close(); }
131 });
132
133 test('register with mismatched passwords shows error', async () => {
134 const ctx = await browser.newContext();
135 const page = await ctx.newPage();
136 try {
137 await page.goto(`${BASE}/register`);
138 await page.fill('[name=username]', 'bob');
139 await page.fill('[name=password]', 'password123');
140 await page.fill('[name=password2]', 'different456');
141 await page.click('button[type=submit]');
142 expect(await page.locator('.form-error').textContent()).toContain('match');
143 } finally { await ctx.close(); }
144 });
145
146 test('register with duplicate username shows error', async () => {
147 const ctx = await browser.newContext();
148 const page = await ctx.newPage();
149 try {
150 await page.goto(`${BASE}/register`);
151 await page.fill('[name=username]', 'alice'); // already registered above
152 await page.fill('[name=password]', 'password123');
153 await page.fill('[name=password2]', 'password123');
154 await page.click('button[type=submit]');
155 expect(await page.locator('.form-error').textContent()).toContain('taken');
156 } finally { await ctx.close(); }
157 });
158
159 test('logout clears session', async () => {
160 const ctx = await browser.newContext();
161 const page = await ctx.newPage();
162 try {
163 await login(page);
164 await logout(page);
165 expect(await page.locator('.nav-user').count()).toBe(0);
166 expect(await page.locator('a[href="/login"]').isVisible()).toBe(true);
167 } finally { await ctx.close(); }
168 });
169});
170
171// ─── Repos ────────────────────────────────────────────────────────────────────
172
173describe('repos', () => {
174 // Shared admin context — cookies persist across tests in this block.
175 let adminCtx: BrowserContext;
176 // Alice's context, created after alice is registered in auth tests.
177 let aliceCtx: BrowserContext;
178 // Set after 'commit log' test; used by all commit-detail tests below.
179 let commitUrl: string;
180
181 beforeAll(async () => {
182 adminCtx = await loggedInContext();
183 aliceCtx = await loggedInContext('alice', 'password123');
184 });
185
186 afterAll(async () => {
187 await adminCtx.close();
188 await aliceCtx.close();
189 });
190
191 test('non-admin gets 403 on /new', async () => {
192 const page = await aliceCtx.newPage();
193 try {
194 const resp = await page.request.get(`${BASE}/new`);
195 expect(resp.status()).toBe(403);
196 } finally { await page.close(); }
197 });
198
199 test('create repository', async () => {
200 const page = await adminCtx.newPage();
201 try {
202 await page.goto(`${BASE}/new`);
203 await page.fill('[name=name]', 'my-repo');
204 await page.fill('[name=description]', 'A test repo');
205 await page.click('form[action="/new"] button[type=submit]');
206 await page.waitForURL(`${BASE}/my-repo`);
207 expect(await page.locator('.empty-state').isVisible()).toBe(true);
208 } finally { await page.close(); }
209 });
210
211 test('repository appears in list', async () => {
212 const page = await adminCtx.newPage();
213 try {
214 await page.goto(BASE);
215 expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
216 } finally { await page.close(); }
217 });
218
219 test('search finds matching repo', async () => {
220 const page = await adminCtx.newPage();
221 try {
222 await page.goto(BASE);
223 await page.fill('[name=q]', 'my-repo');
224 await page.click('.search-form button[type=submit]');
225 expect(await page.locator('.repo-name').allTextContents()).toContain('my-repo');
226 } finally { await page.close(); }
227 });
228
229 test('search returns empty for unknown term', async () => {
230 const page = await adminCtx.newPage();
231 try {
232 await page.goto(BASE);
233 await page.fill('[name=q]', 'zzz-nothing-here');
234 await page.click('.search-form button[type=submit]');
235 expect(await page.locator('.empty-state').isVisible()).toBe(true);
236 } finally { await page.close(); }
237 });
238
239 test('browse file tree after seeding content', async () => {
240 await seedRepo('my-repo');
241 const page = await adminCtx.newPage();
242 try {
243 await page.goto(`${BASE}/my-repo/tree/main`);
244 const files = await page.locator('.file-name a').allTextContents();
245 expect(files).toContain('README.md');
246 expect(files).toContain('index.js');
247 } finally { await page.close(); }
248 });
249
250 test('view file blob with syntax highlighting', async () => {
251 const page = await adminCtx.newPage();
252 try {
253 await page.goto(`${BASE}/my-repo/blob/main/index.js`);
254 expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
255 expect(await page.locator('.file-blob-body').isVisible()).toBe(true);
256 } finally { await page.close(); }
257 });
258
259 test('raw file download responds 200', async () => {
260 const page = await adminCtx.newPage();
261 try {
262 const resp = await page.request.get(`${BASE}/my-repo/raw/main/README.md`);
263 expect(resp.status()).toBe(200);
264 expect(resp.headers()['content-disposition']).toContain('README.md');
265 } finally { await page.close(); }
266 });
267
268 test('commit log shows initial commit', async () => {
269 const page = await adminCtx.newPage();
270 try {
271 await page.goto(`${BASE}/my-repo/commits/main`);
272 const subjects = await page.locator('.commit-subject').allTextContents();
273 expect(subjects.some(s => s.includes('Initial commit'))).toBe(true);
274 // Navigate to the commit page and capture the URL for subsequent tests
275 await page.locator('.commit-hash').first().click();
276 await page.waitForURL(/\/my-repo\/commit\//);
277 commitUrl = page.url();
278 } finally { await page.close(); }
279 });
280
281 test('commit detail shows metadata card', async () => {
282 const page = await adminCtx.newPage();
283 try {
284 await page.goto(commitUrl);
285 expect(await page.locator('.commit-card').isVisible()).toBe(true);
286 expect(await page.locator('.commit-card-subject').textContent()).toContain('Initial commit');
287 // Author, date and SHA rows are all present
288 const metaText = await page.locator('.commit-card-meta').textContent();
289 expect(metaText).toContain('Test'); // author name set by seedRepo
290 expect(metaText).toContain('Author'); // label (CSS uppercases visually)
291 expect(metaText).toContain('Date');
292 expect(metaText).toContain('Commit');
293 } finally { await page.close(); }
294 });
295
296 test('commit detail full SHA is shown', async () => {
297 const page = await adminCtx.newPage();
298 try {
299 await page.goto(commitUrl);
300 const sha = commitUrl.split('/commit/')[1] ?? null;
301 expect(await page.locator('.commit-sha-full').textContent()).toBe(sha);
302 } finally { await page.close(); }
303 });
304
305 test('commit detail shows file nav sidebar', async () => {
306 const page = await adminCtx.newPage();
307 try {
308 await page.goto(commitUrl);
309 expect(await page.locator('.file-nav-details').isVisible()).toBe(true);
310 const navItems = await page.locator('.file-nav-item').allTextContents();
311 // seedRepo adds README.md and index.js
312 expect(navItems.some(t => t.includes('README.md'))).toBe(true);
313 expect(navItems.some(t => t.includes('index.js'))).toBe(true);
314 } finally { await page.close(); }
315 });
316
317 test('commit detail file nav items are anchor links to diff sections', async () => {
318 const page = await adminCtx.newPage();
319 try {
320 await page.goto(commitUrl);
321 const hrefs = await page.locator('.file-nav-item').evaluateAll(
322 els => els.map(el => el.getAttribute('href') ?? ''),
323 );
324 expect(hrefs.every(h => h.startsWith('#'))).toBe(true);
325 } finally { await page.close(); }
326 });
327
328 test('commit detail shows diff table with added lines', async () => {
329 const page = await adminCtx.newPage();
330 try {
331 await page.goto(commitUrl);
332 // Initial commit only adds lines
333 expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
334 expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
335 expect(await page.locator('.diff-row-del').count()).toBe(0);
336 } finally { await page.close(); }
337 });
338
339 test('commit detail diff table has line numbers', async () => {
340 const page = await adminCtx.newPage();
341 try {
342 await page.goto(commitUrl);
343 // New-side line numbers (column 2) on add rows start at 1
344 const firstNewLn = await page.locator('.diff-row-add .diff-ln-new').first().textContent();
345 expect(firstNewLn?.trim()).toBe('1');
346 } finally { await page.close(); }
347 });
348
349 test('commit detail shows added stats on file header', async () => {
350 const page = await adminCtx.newPage();
351 try {
352 await page.goto(commitUrl);
353 const addStats = await page.locator('.diff-stat-add').allTextContents();
354 expect(addStats.length).toBeGreaterThan(0);
355 expect(addStats.every(s => s.startsWith('+'))).toBe(true);
356 } finally { await page.close(); }
357 });
358
359 test('commit detail view-at-sha button links to blob at that commit', async () => {
360 const page = await adminCtx.newPage();
361 try {
362 await page.goto(commitUrl);
363 const sha = commitUrl.split('/commit/')[1];
364 const btn = page.locator('.btn-xs').first();
365 const href = await btn.getAttribute('href');
366 expect(href).toContain(`/blob/${sha}/`);
367 } finally { await page.close(); }
368 });
369
370 test('commit detail view-at-branch button links to blob at default branch', async () => {
371 const page = await adminCtx.newPage();
372 try {
373 await page.goto(commitUrl);
374 const btns = await page.locator('.btn-xs').allTextContents();
375 expect(btns.some(t => t.includes('@ main'))).toBe(true);
376 const branchBtns = await page.locator('.btn-xs').evaluateAll(
377 els => els.filter(el => el.textContent?.includes('@ main')).map(el => el.getAttribute('href') ?? ''),
378 );
379 expect(branchBtns.every(h => h.includes('/blob/main/'))).toBe(true);
380 } finally { await page.close(); }
381 });
382
383 test('commit detail file diff can be collapsed', async () => {
384 const page = await adminCtx.newPage();
385 try {
386 await page.goto(commitUrl);
387 // File body is visible when details is open
388 const diffFile = page.locator('.diff-file').first();
389 expect(await diffFile.getAttribute('open')).not.toBeNull();
390 // Click the summary to collapse
391 await diffFile.locator('.diff-file-header').click();
392 expect(await diffFile.getAttribute('open')).toBeNull();
393 } finally { await page.close(); }
394 });
395
396 test('commit detail file nav sidebar can be collapsed', async () => {
397 const page = await adminCtx.newPage();
398 try {
399 await page.goto(commitUrl);
400 const nav = page.locator('.file-nav-details');
401 expect(await nav.getAttribute('open')).not.toBeNull();
402 await nav.locator('.file-nav-toggle').click();
403 expect(await nav.getAttribute('open')).toBeNull();
404 } finally { await page.close(); }
405 });
406
407 test('readme renders on repo home', async () => {
408 const page = await adminCtx.newPage();
409 try {
410 await page.goto(`${BASE}/my-repo`);
411 expect(await page.locator('.readme-header').isVisible()).toBe(true);
412 expect(await page.locator('.readme-section .markdown-body').innerHTML()).toContain('my-repo');
413 } finally { await page.close(); }
414 });
415
416 test('private repo hidden from other users', async () => {
417 // Make private
418 const adminPage = await adminCtx.newPage();
419 try {
420 await adminPage.goto(`${BASE}/my-repo/settings`);
421 await adminPage.check('[name=is_private]');
422 await adminPage.click('form[action$="/settings"] button[type=submit]');
423 expect(await adminPage.locator('.form-success').isVisible()).toBe(true);
424 } finally { await adminPage.close(); }
425
426 // Alice should get 404
427 const alicePage = await aliceCtx.newPage();
428 try {
429 const resp = await alicePage.request.get(`${BASE}/my-repo`);
430 expect(resp.status()).toBe(404);
431 await alicePage.goto(BASE);
432 expect(await alicePage.locator('.repo-name').allTextContents()).not.toContain('my-repo');
433 } finally { await alicePage.close(); }
434
435 // Restore to public
436 const adminPage2 = await adminCtx.newPage();
437 try {
438 await adminPage2.goto(`${BASE}/my-repo/settings`);
439 await adminPage2.uncheck('[name=is_private]');
440 await adminPage2.click('form[action$="/settings"] button[type=submit]');
441 } finally { await adminPage2.close(); }
442 });
443
444 test('settings tab visible for admin, hidden for others', async () => {
445 const adminPage = await adminCtx.newPage();
446 try {
447 await adminPage.goto(`${BASE}/my-repo`);
448 expect(await adminPage.locator('.repo-tab[href$="/settings"]').isVisible()).toBe(true);
449 } finally { await adminPage.close(); }
450
451 const alicePage = await aliceCtx.newPage();
452 try {
453 await alicePage.goto(`${BASE}/my-repo`);
454 expect(await alicePage.locator('.repo-tab[href$="/settings"]').count()).toBe(0);
455 } finally { await alicePage.close(); }
456 });
457});
458
459// ─── Issues ───────────────────────────────────────────────────────────────────
460
461describe('issues', () => {
462 let adminCtx: BrowserContext;
463 let issueUrl: string;
464
465 beforeAll(async () => {
466 adminCtx = await loggedInContext();
467 });
468
469 afterAll(async () => { await adminCtx.close(); });
470
471 test('create issue', async () => {
472 const page = await adminCtx.newPage();
473 try {
474 await page.goto(`${BASE}/my-repo/issues/new`);
475 await page.fill('[name=title]', 'First issue');
476 await page.fill('[name=body]', 'Body with **markdown**.');
477 await page.click('form[action$="/issues"] button[type=submit]');
478 await page.waitForURL(/\/my-repo\/issues\/\d+/);
479 issueUrl = page.url();
480 expect(await page.locator('.issue-detail-title').textContent()).toBe('First issue');
481 } finally { await page.close(); }
482 });
483
484 test('issue body renders markdown', async () => {
485 const page = await adminCtx.newPage();
486 try {
487 await page.goto(issueUrl);
488 expect(await page.locator('.timeline-body.markdown-body').first().innerHTML()).toContain('<strong>');
489 } finally { await page.close(); }
490 });
491
492 test('issue appears in open list', async () => {
493 const page = await adminCtx.newPage();
494 try {
495 await page.goto(`${BASE}/my-repo/issues`);
496 const titles = await page.locator('.issue-title').allTextContents();
497 expect(titles.some(t => t.includes('First issue'))).toBe(true);
498 } finally { await page.close(); }
499 });
500
501 test('unauthenticated user is redirected to login from new issue form', async () => {
502 const ctx = await browser.newContext();
503 const page = await ctx.newPage();
504 try {
505 await page.goto(`${BASE}/my-repo/issues/new`);
506 expect(page.url()).toContain('/login');
507 } finally { await ctx.close(); }
508 });
509
510 test('add comment', async () => {
511 const page = await adminCtx.newPage();
512 try {
513 await page.goto(issueUrl);
514 const beforeCount = await page.locator('.timeline-item').count();
515 await page.fill('textarea[name=body]', 'A follow-up comment.');
516 await page.click('form[action*="/comments"] button[type=submit]');
517 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
518 expect(await page.locator('.timeline-item').count()).toBeGreaterThan(beforeCount);
519 } finally { await page.close(); }
520 });
521
522 test('react to issue', async () => {
523 const page = await adminCtx.newPage();
524 try {
525 await page.goto(issueUrl);
526 await page.locator('.reaction-picker').first().click();
527 await page.locator('.reaction-picker-btn').first().click();
528 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
529 expect(await page.locator('.reaction-btn').count()).toBeGreaterThan(0);
530 } finally { await page.close(); }
531 });
532
533 test('close issue changes status badge', async () => {
534 const page = await adminCtx.newPage();
535 try {
536 await page.goto(issueUrl);
537 await page.click('.issue-detail-meta-actions button');
538 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
539 expect(await page.locator('.issue-badge').textContent()).toBe('closed');
540 } finally { await page.close(); }
541 });
542
543 test('closed issue appears in closed list', async () => {
544 const page = await adminCtx.newPage();
545 try {
546 await page.goto(`${BASE}/my-repo/issues?status=closed`);
547 const titles = await page.locator('.issue-title').allTextContents();
548 expect(titles.some(t => t.includes('First issue'))).toBe(true);
549 } finally { await page.close(); }
550 });
551
552 test('reopen issue', async () => {
553 const page = await adminCtx.newPage();
554 try {
555 await page.goto(issueUrl);
556 await page.click('.issue-detail-meta-actions button');
557 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
558 expect(await page.locator('.issue-badge').textContent()).toBe('open');
559 } finally { await page.close(); }
560 });
561});
562
563// ─── Patches ──────────────────────────────────────────────────────────────────
564
565describe('patches', () => {
566 let adminCtx: BrowserContext;
567 let cleanPatchUrl: string;
568 let conflictPatchUrl: string;
569 let closePatchUrl: string;
570
571 // Adds a new file — applies cleanly to my-repo
572 const CLEAN_PATCH = [
573 'diff --git a/patch-test.txt b/patch-test.txt',
574 'new file mode 100644',
575 'index 0000000..9daeafb',
576 '--- /dev/null',
577 '+++ b/patch-test.txt',
578 '@@ -0,0 +1 @@',
579 '+patch test content',
580 '',
581 ].join('\n');
582
583 // References non-existent lines in README.md — always conflicts
584 const CONFLICT_PATCH = [
585 'diff --git a/README.md b/README.md',
586 'index abc1234..def5678 100644',
587 '--- a/README.md',
588 '+++ b/README.md',
589 '@@ -50,3 +50,3 @@',
590 ' nonexistent context line',
591 '-nonexistent old line',
592 '+nonexistent new line',
593 '',
594 ].join('\n');
595
596 // Adds another new file — for testing close flow
597 const CLOSE_PATCH = [
598 'diff --git a/patch-close.txt b/patch-close.txt',
599 'new file mode 100644',
600 'index 0000000..9daeafb',
601 '--- /dev/null',
602 '+++ b/patch-close.txt',
603 '@@ -0,0 +1 @@',
604 '+close test',
605 '',
606 ].join('\n');
607
608 beforeAll(async () => {
609 adminCtx = await loggedInContext();
610 });
611
612 afterAll(async () => { await adminCtx.close(); });
613
614 test('reject file without patch markers', async () => {
615 writeTempFile('/tmp/not-a-patch.txt', 'this is just plain text');
616 const page = await adminCtx.newPage();
617 try {
618 await page.goto(`${BASE}/my-repo/patches/new`);
619 await page.fill('[name=title]', 'Bad patch');
620 await page.locator('[name=patch_file]').setInputFiles('/tmp/not-a-patch.txt');
621 await page.click('form[action$="/patches"] button[type=submit]');
622 expect(await page.locator('.form-error').textContent()).toContain('valid patch');
623 } finally { await page.close(); }
624 });
625
626 test('upload clean patch', async () => {
627 writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
628 const page = await adminCtx.newPage();
629 try {
630 await page.goto(`${BASE}/my-repo/patches/new`);
631 await page.fill('[name=title]', 'Add patch-test.txt');
632 await page.fill('[name=description]', 'Adds a file with **markdown** desc.');
633 await page.locator('[name=patch_file]').setInputFiles('/tmp/clean.patch');
634 await page.click('form[action$="/patches"] button[type=submit]');
635 await page.waitForURL(/\/my-repo\/patches\/\d+/);
636 cleanPatchUrl = page.url();
637 expect(await page.locator('.issue-detail-title').textContent()).toBe('Add patch-test.txt');
638 } finally { await page.close(); }
639 });
640
641 test('patch description renders markdown', async () => {
642 const page = await adminCtx.newPage();
643 try {
644 await page.goto(cleanPatchUrl);
645 expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
646 } finally { await page.close(); }
647 });
648
649 test('clean patch shows apply-clean status immediately', async () => {
650 const page = await adminCtx.newPage();
651 try {
652 await page.goto(cleanPatchUrl);
653 expect(await page.locator('.apply-result').isVisible()).toBe(true);
654 expect(await page.locator('.apply-clean').isVisible()).toBe(true);
655 } finally { await page.close(); }
656 });
657
658 test('merge button appears for clean patch', async () => {
659 const page = await adminCtx.newPage();
660 try {
661 await page.goto(cleanPatchUrl);
662 expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
663 } finally { await page.close(); }
664 });
665
666 test('patch diff is displayed with highlighted table', async () => {
667 const page = await adminCtx.newPage();
668 try {
669 await page.goto(cleanPatchUrl + '?tab=changes');
670 expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
671 expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
672 } finally { await page.close(); }
673 });
674
675 test('patch appears in open list', async () => {
676 const page = await adminCtx.newPage();
677 try {
678 await page.goto(`${BASE}/my-repo/patches`);
679 const titles = await page.locator('.issue-title').allTextContents();
680 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
681 } finally { await page.close(); }
682 });
683
684 test('unauthenticated user is redirected to login from patch upload', async () => {
685 const ctx = await browser.newContext();
686 const page = await ctx.newPage();
687 try {
688 await page.goto(`${BASE}/my-repo/patches/new`);
689 expect(page.url()).toContain('/login');
690 } finally { await ctx.close(); }
691 });
692
693 test('upload conflict patch', async () => {
694 writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
695 const page = await adminCtx.newPage();
696 try {
697 await page.goto(`${BASE}/my-repo/patches/new`);
698 await page.fill('[name=title]', 'Conflict patch');
699 await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
700 await page.click('form[action$="/patches"] button[type=submit]');
701 await page.waitForURL(/\/my-repo\/patches\/\d+/);
702 conflictPatchUrl = page.url();
703 } finally { await page.close(); }
704 });
705
706 test('conflict patch shows apply-conflict status', async () => {
707 const page = await adminCtx.newPage();
708 try {
709 await page.goto(conflictPatchUrl);
710 expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
711 } finally { await page.close(); }
712 });
713
714 test('merge button absent for conflict patch', async () => {
715 const page = await adminCtx.newPage();
716 try {
717 await page.goto(conflictPatchUrl);
718 expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
719 } finally { await page.close(); }
720 });
721
722 test('merge clean patch changes status to merged', async () => {
723 const page = await adminCtx.newPage();
724 try {
725 await page.goto(cleanPatchUrl);
726 await page.click('form[action*="/merge"] button');
727 await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
728 expect(await page.locator('.patch-badge').textContent()).toBe('merged');
729 } finally { await page.close(); }
730 });
731
732 test('merged patch appears in merged list', async () => {
733 const page = await adminCtx.newPage();
734 try {
735 await page.goto(`${BASE}/my-repo/patches?status=merged`);
736 const titles = await page.locator('.issue-title').allTextContents();
737 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
738 } finally { await page.close(); }
739 });
740
741 test('upload and close a patch', async () => {
742 writeTempFile('/tmp/close.patch', CLOSE_PATCH);
743 const page = await adminCtx.newPage();
744 try {
745 await page.goto(`${BASE}/my-repo/patches/new`);
746 await page.fill('[name=title]', 'Close me');
747 await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
748 await page.click('form[action$="/patches"] button[type=submit]');
749 await page.waitForURL(/\/my-repo\/patches\/\d+/);
750 closePatchUrl = page.url();
751 await page.click('form[action*="/close"] button');
752 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
753 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
754 } finally { await page.close(); }
755 });
756
757 test('closed patch appears in closed list', async () => {
758 const page = await adminCtx.newPage();
759 try {
760 await page.goto(`${BASE}/my-repo/patches?status=closed`);
761 const titles = await page.locator('.issue-title').allTextContents();
762 expect(titles.some(t => t.includes('Close me'))).toBe(true);
763 } finally { await page.close(); }
764 });
765
766 test('closed patch can be reopened', async () => {
767 const page = await adminCtx.newPage();
768 try {
769 await page.goto(closePatchUrl);
770 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
771 await page.click('form[action*="/close"] button');
772 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
773 expect(await page.locator('.patch-badge').textContent()).toBe('open');
774 } finally { await page.close(); }
775 });
776
777 test('patch title and description can be edited', async () => {
778 const page = await adminCtx.newPage();
779 try {
780 await page.goto(conflictPatchUrl);
781 await page.click('.timeline-author .inline-edit-details summary');
782 await page.fill('.inline-edit-form-area [name=title]', 'Edited Conflict Patch');
783 await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
784 await page.click('.inline-edit-form-area [type=submit]');
785 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
786 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
787 } finally { await page.close(); }
788 });
789
790 test('patch comment: add and edit', async () => {
791 const page = await adminCtx.newPage();
792 try {
793 await page.goto(conflictPatchUrl);
794 await page.fill('[name=body]', 'My patch comment');
795 await page.click('form[action$="/comments"] button[type=submit]');
796 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
797 expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
798
799 // Edit the comment — scope to the timeline-item containing the comment text
800 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
801 await commentItem.locator('.inline-edit-details summary').click();
802 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
803 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
804 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
805 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
806 } finally { await page.close(); }
807 });
808
809 test('patch reaction on description', async () => {
810 const page = await adminCtx.newPage();
811 try {
812 await page.goto(conflictPatchUrl);
813 // Open reaction picker on the first timeline-item (description)
814 await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
815 await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
816 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
817 expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
818 } finally { await page.close(); }
819 });
820});
821
822// ─── Pagination ───────────────────────────────────────────────────────────────
823
824describe('pagination', () => {
825 let adminCtx: BrowserContext;
826
827 beforeAll(async () => {
828 adminCtx = await loggedInContext();
829
830 // Create a repo dedicated to pagination testing
831 const page = await adminCtx.newPage();
832 try {
833 await page.goto(`${BASE}/new`);
834 await page.fill('[name=name]', 'paged-repo');
835 await page.click('form[action="/new"] button[type=submit]');
836 await page.waitForURL(`${BASE}/paged-repo`);
837 } finally { await page.close(); }
838
839 // Create 21 issues via the API (triggers page 2 at 20 per page)
840 await bulkCreateIssues(adminCtx, 'paged-repo', 21);
841
842 // Create 21 patches via browser (patch upload requires multipart)
843 await bulkCreatePatches(adminCtx, 'paged-repo', 21);
844 });
845
846 afterAll(async () => { await adminCtx.close(); });
847
848 // ── Repo list pagination ──────────────────────────────────────────────────
849
850 test('repo list page 1 shows repos and no pagination when few repos', async () => {
851 // With only a handful of test repos (< 20), there should be no pagination nav
852 const page = await adminCtx.newPage();
853 try {
854 await page.goto(BASE);
855 // Repos are shown
856 expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
857 } finally { await page.close(); }
858 });
859
860 // ── Issue pagination ──────────────────────────────────────────────────────
861
862 test('issue list page 1 shows at most 20 items', async () => {
863 const page = await adminCtx.newPage();
864 try {
865 await page.goto(`${BASE}/paged-repo/issues`);
866 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
867 } finally { await page.close(); }
868 });
869
870 test('issue list pagination nav appears when more than 20 issues', async () => {
871 const page = await adminCtx.newPage();
872 try {
873 await page.goto(`${BASE}/paged-repo/issues`);
874 expect(await page.locator('.pagination').isVisible()).toBe(true);
875 } finally { await page.close(); }
876 });
877
878 test('issue list page 2 shows remaining issues', async () => {
879 const page = await adminCtx.newPage();
880 try {
881 await page.goto(`${BASE}/paged-repo/issues?page=2`);
882 const count = await page.locator('.issue-item').count();
883 expect(count).toBeGreaterThan(0);
884 expect(count).toBeLessThanOrEqual(20);
885 } finally { await page.close(); }
886 });
887
888 test('issue list page 2 prev link goes to page 1', async () => {
889 const page = await adminCtx.newPage();
890 try {
891 await page.goto(`${BASE}/paged-repo/issues?page=2`);
892 const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
893 expect(prevHref).toContain('page=1');
894 } finally { await page.close(); }
895 });
896
897 test('issue list page 1 next link goes to page 2', async () => {
898 const page = await adminCtx.newPage();
899 try {
900 await page.goto(`${BASE}/paged-repo/issues`);
901 const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
902 expect(nextHref).toContain('page=2');
903 } finally { await page.close(); }
904 });
905
906 // ── Patch pagination ──────────────────────────────────────────────────────
907
908 test('patch list page 1 shows at most 20 items', async () => {
909 const page = await adminCtx.newPage();
910 try {
911 await page.goto(`${BASE}/paged-repo/patches`);
912 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
913 } finally { await page.close(); }
914 });
915
916 test('patch list pagination nav appears when more than 20 patches', async () => {
917 const page = await adminCtx.newPage();
918 try {
919 await page.goto(`${BASE}/paged-repo/patches`);
920 expect(await page.locator('.pagination').isVisible()).toBe(true);
921 } finally { await page.close(); }
922 });
923
924 test('patch list page 2 shows remaining patches', async () => {
925 const page = await adminCtx.newPage();
926 try {
927 await page.goto(`${BASE}/paged-repo/patches?page=2`);
928 const count = await page.locator('.issue-item').count();
929 expect(count).toBeGreaterThan(0);
930 } finally { await page.close(); }
931 });
932
933 // ── Commit log pagination ─────────────────────────────────────────────────
934
935 test('commit log with few commits shows no cursor nav', async () => {
936 // my-repo has 1 commit — both newer and older links should be absent
937 const page = await adminCtx.newPage();
938 try {
939 await page.goto(`${BASE}/my-repo/commits/main`);
940 expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
941 } finally { await page.close(); }
942 });
943});
944
945// ─── Branch selector ──────────────────────────────────────────────────────────
946
947describe('branch selector', () => {
948 let adminCtx: BrowserContext;
949
950 beforeAll(async () => {
951 adminCtx = await loggedInContext();
952 // Add a second branch so the selector is meaningful
953 await seedBranch('my-repo', 'dev');
954 });
955
956 afterAll(async () => { await adminCtx.close(); });
957
958 test('branch selector appears on repo home', async () => {
959 const page = await adminCtx.newPage();
960 try {
961 await page.goto(`${BASE}/my-repo`);
962 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
963 expect(await page.locator('.branch-select').inputValue()).toBe('main');
964 } finally { await page.close(); }
965 });
966
967 test('branch selector shows all branches on repo home', async () => {
968 const page = await adminCtx.newPage();
969 try {
970 await page.goto(`${BASE}/my-repo`);
971 const options = await page.locator('.branch-select option').allTextContents();
972 expect(options).toContain('main');
973 expect(options).toContain('dev');
974 } finally { await page.close(); }
975 });
976
977 test('branch selector appears on file tree with current ref selected', async () => {
978 const page = await adminCtx.newPage();
979 try {
980 await page.goto(`${BASE}/my-repo/tree/main`);
981 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
982 expect(await page.locator('.branch-select').inputValue()).toBe('main');
983 } finally { await page.close(); }
984 });
985
986 test('branch selector appears on commit log with current ref selected', async () => {
987 const page = await adminCtx.newPage();
988 try {
989 await page.goto(`${BASE}/my-repo/commits/main`);
990 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
991 expect(await page.locator('.branch-select').inputValue()).toBe('main');
992 } finally { await page.close(); }
993 });
994
995 test('branch selector appears on file blob', async () => {
996 const page = await adminCtx.newPage();
997 try {
998 await page.goto(`${BASE}/my-repo/blob/main/README.md`);
999 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1000 expect(await page.locator('.branch-select').inputValue()).toBe('main');
1001 } finally { await page.close(); }
1002 });
1003
1004 test('switching branch on commit log navigates to the selected branch', async () => {
1005 const page = await adminCtx.newPage();
1006 try {
1007 await page.goto(`${BASE}/my-repo/commits/main`);
1008 await page.locator('.branch-select').selectOption('dev');
1009 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1010 await page.waitForURL(`${BASE}/my-repo/commits/dev`);
1011 expect(page.url()).toContain('/commits/dev');
1012 } finally { await page.close(); }
1013 });
1014
1015 test('switching branch on file tree navigates to the selected branch', async () => {
1016 const page = await adminCtx.newPage();
1017 try {
1018 await page.goto(`${BASE}/my-repo/tree/main`);
1019 await page.locator('.branch-select').selectOption('dev');
1020 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1021 await page.waitForURL(`${BASE}/my-repo/tree/dev`);
1022 expect(page.url()).toContain('/tree/dev');
1023 } finally { await page.close(); }
1024 });
1025
1026 test('branch-switch route preserves subpath when switching tree', async () => {
1027 const page = await adminCtx.newPage();
1028 try {
1029 const resp = await page.request.get(
1030 `${BASE}/my-repo/branch-switch?view=tree&ref=dev&path=src/foo`,
1031 { maxRedirects: 0 },
1032 ).catch(r => r);
1033 // 302 redirect to /my-repo/tree/dev/src/foo
1034 const loc = (resp as any).headers()?.['location'] ?? '';
1035 expect(loc).toContain('/tree/dev/src/foo');
1036 } finally { await page.close(); }
1037 });
1038
1039 test('branch-switch route redirects commits view correctly', async () => {
1040 const page = await adminCtx.newPage();
1041 try {
1042 const resp = await page.request.get(
1043 `${BASE}/my-repo/branch-switch?view=commits&ref=dev`,
1044 { maxRedirects: 0 },
1045 ).catch(r => r);
1046 const loc = (resp as any).headers()?.['location'] ?? '';
1047 expect(loc).toContain('/commits/dev');
1048 } finally { await page.close(); }
1049 });
1050
1051 test('branch-switch route redirects blob view correctly', async () => {
1052 const page = await adminCtx.newPage();
1053 try {
1054 const resp = await page.request.get(
1055 `${BASE}/my-repo/branch-switch?view=blob&ref=dev&path=README.md`,
1056 { maxRedirects: 0 },
1057 ).catch(r => r);
1058 const loc = (resp as any).headers()?.['location'] ?? '';
1059 expect(loc).toContain('/blob/dev/README.md');
1060 } finally { await page.close(); }
1061 });
1062});
1063
1064// ─── Default branch settings ──────────────────────────────────────────────────
1065
1066describe('default branch settings', () => {
1067 // Runs after 'branch selector', so my-repo already has both main and dev branches.
1068 let adminCtx: BrowserContext;
1069
1070 beforeAll(async () => { adminCtx = await loggedInContext(); });
1071 afterAll(async () => { await adminCtx.close(); });
1072
1073 test('settings page shows default branch select', async () => {
1074 const page = await adminCtx.newPage();
1075 try {
1076 await page.goto(`${BASE}/my-repo/settings`);
1077 expect(await page.locator('#default_branch').isVisible()).toBe(true);
1078 const options = await page.locator('#default_branch option').allTextContents();
1079 expect(options).toContain('main');
1080 expect(options).toContain('dev');
1081 } finally { await page.close(); }
1082 });
1083
1084 test('current default branch is pre-selected', async () => {
1085 const page = await adminCtx.newPage();
1086 try {
1087 await page.goto(`${BASE}/my-repo/settings`);
1088 expect(await page.locator('#default_branch').inputValue()).toBe('main');
1089 } finally { await page.close(); }
1090 });
1091
1092 test('changing default branch saves and is reflected in the repo home', async () => {
1093 const page = await adminCtx.newPage();
1094 try {
1095 await page.goto(`${BASE}/my-repo/settings`);
1096 await page.locator('#default_branch').selectOption('dev');
1097 await page.click('form[action$="/settings"] button[type=submit]');
1098 expect(await page.locator('.form-success').isVisible()).toBe(true);
1099 // The select now shows dev as current
1100 expect(await page.locator('#default_branch').inputValue()).toBe('dev');
1101
1102 // Repo home branch selector should reflect the new default
1103 await page.goto(`${BASE}/my-repo`);
1104 expect(await page.locator('.branch-select').inputValue()).toBe('dev');
1105 } finally { await page.close(); }
1106 });
1107
1108 test('commit log link in repo nav uses the new default branch', async () => {
1109 const page = await adminCtx.newPage();
1110 try {
1111 await page.goto(`${BASE}/my-repo`);
1112 const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
1113 expect(commitsHref).toContain('/commits/dev');
1114 } finally { await page.close(); }
1115 });
1116
1117 test('changing default branch back to main restores original state', async () => {
1118 const page = await adminCtx.newPage();
1119 try {
1120 await page.goto(`${BASE}/my-repo/settings`);
1121 await page.locator('#default_branch').selectOption('main');
1122 await page.click('form[action$="/settings"] button[type=submit]');
1123 expect(await page.locator('.form-success').isVisible()).toBe(true);
1124 expect(await page.locator('#default_branch').inputValue()).toBe('main');
1125 } finally { await page.close(); }
1126 });
1127
1128 test('settings page shows hint instead of select when repo has no branches', async () => {
1129 // Create an empty repo (no commits → no branches)
1130 const page = await adminCtx.newPage();
1131 try {
1132 await page.goto(`${BASE}/new`);
1133 await page.fill('[name=name]', 'empty-for-branch-test');
1134 await page.click('form[action="/new"] button[type=submit]');
1135 await page.waitForURL(`${BASE}/empty-for-branch-test`);
1136
1137 await page.goto(`${BASE}/empty-for-branch-test/settings`);
1138 expect(await page.locator('#default_branch').count()).toBe(0);
1139 expect(await page.locator('.form-hint').isVisible()).toBe(true);
1140 } finally { await page.close(); }
1141 });
1142});
1143
1144// ─── File browser ─────────────────────────────────────────────────────────────
1145
1146describe('file browser', () => {
1147 // my-repo already has README.md + index.js from the 'repos' describe block.
1148 // We add a subdirectory here so we can test directory navigation.
1149 let adminCtx: BrowserContext;
1150
1151 beforeAll(async () => {
1152 adminCtx = await loggedInContext();
1153 await seedSubdir('my-repo', 'src', {
1154 'app.ts': 'export {};\n',
1155 'README.md': '# src readme\n',
1156 });
1157 });
1158
1159 afterAll(async () => { await adminCtx.close(); });
1160
1161 test('repo home shows file tree instead of recent commits', async () => {
1162 const page = await adminCtx.newPage();
1163 try {
1164 await page.goto(`${BASE}/my-repo`);
1165 expect(await page.locator('.file-tree').isVisible()).toBe(true);
1166 expect(await page.locator('.repo-commits-section').count()).toBe(0);
1167 } finally { await page.close(); }
1168 });
1169
1170 test('repo home file tree lists files and directories', async () => {
1171 const page = await adminCtx.newPage();
1172 try {
1173 await page.goto(`${BASE}/my-repo`);
1174 const names = await page.locator('.file-name a').allTextContents();
1175 expect(names).toContain('README.md');
1176 expect(names).toContain('index.js');
1177 expect(names).toContain('src');
1178 } finally { await page.close(); }
1179 });
1180
1181 test('directories appear before files in file tree', async () => {
1182 const page = await adminCtx.newPage();
1183 try {
1184 await page.goto(`${BASE}/my-repo`);
1185 const names = await page.locator('.file-name a').allTextContents();
1186 const srcIdx = names.indexOf('src');
1187 const readmeIdx = names.indexOf('README.md');
1188 expect(srcIdx).toBeGreaterThanOrEqual(0);
1189 expect(readmeIdx).toBeGreaterThanOrEqual(0);
1190 expect(srcIdx).toBeLessThan(readmeIdx);
1191 } finally { await page.close(); }
1192 });
1193
1194 test('no ".." entry at repository root', async () => {
1195 const page = await adminCtx.newPage();
1196 try {
1197 await page.goto(`${BASE}/my-repo`);
1198 const names = await page.locator('.file-name a').allTextContents();
1199 expect(names).not.toContain('..');
1200 } finally { await page.close(); }
1201 });
1202
1203 test('clicking directory navigates into it', async () => {
1204 const page = await adminCtx.newPage();
1205 try {
1206 await page.goto(`${BASE}/my-repo`);
1207 await page.locator('.file-name a', { hasText: 'src' }).click();
1208 await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
1209 expect(page.url()).toContain('/tree/main/src');
1210 } finally { await page.close(); }
1211 });
1212
1213 test('".." entry appears in subdirectory', async () => {
1214 const page = await adminCtx.newPage();
1215 try {
1216 await page.goto(`${BASE}/my-repo/tree/main/src`);
1217 const names = await page.locator('.file-name a').allTextContents();
1218 expect(names).toContain('..');
1219 } finally { await page.close(); }
1220 });
1221
1222 test('".." at one level deep links to tree root', async () => {
1223 const page = await adminCtx.newPage();
1224 try {
1225 await page.goto(`${BASE}/my-repo/tree/main/src`);
1226 const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
1227 expect(upHref).toBe('/my-repo/tree/main');
1228 } finally { await page.close(); }
1229 });
1230
1231 test('files in subdirectory show plain names, not full paths', async () => {
1232 const page = await adminCtx.newPage();
1233 try {
1234 await page.goto(`${BASE}/my-repo/tree/main/src`);
1235 const names = await page.locator('.file-name a').allTextContents();
1236 expect(names).toContain('app.ts');
1237 // Must NOT contain the full path with prefix
1238 expect(names).not.toContain('src/app.ts');
1239 expect(names).not.toContain('src/README.md');
1240 } finally { await page.close(); }
1241 });
1242
1243 test('readme is shown below file tree on repo home', async () => {
1244 const page = await adminCtx.newPage();
1245 try {
1246 await page.goto(`${BASE}/my-repo`);
1247 const treeBox = await page.locator('.file-tree').boundingBox();
1248 const readmeBox = await page.locator('.readme-section').boundingBox();
1249 expect(treeBox).not.toBeNull();
1250 expect(readmeBox).not.toBeNull();
1251 expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
1252 } finally { await page.close(); }
1253 });
1254
1255 test('readme in subdirectory is shown when present', async () => {
1256 const page = await adminCtx.newPage();
1257 try {
1258 await page.goto(`${BASE}/my-repo/tree/main/src`);
1259 expect(await page.locator('.readme-section').isVisible()).toBe(true);
1260 expect(await page.locator('.readme-section .markdown-body').innerHTML())
1261 .toContain('src readme');
1262 } finally { await page.close(); }
1263 });
1264
1265 test('file tree on /tree/:ref also shows readme', async () => {
1266 const page = await adminCtx.newPage();
1267 try {
1268 await page.goto(`${BASE}/my-repo/tree/main`);
1269 expect(await page.locator('.file-tree').isVisible()).toBe(true);
1270 expect(await page.locator('.readme-section').isVisible()).toBe(true);
1271 } finally { await page.close(); }
1272 });
1273});
1274
1275// ─── Releases ─────────────────────────────────────────────────────────────────
1276
1277describe('releases', () => {
1278 let adminCtx: BrowserContext;
1279 let aliceCtx: BrowserContext;
1280 let commitHash: string;
1281 let releaseUrl: string;
1282 let srcReleaseUrl: string;
1283 let releaseWithAssetsUrl: string;
1284
1285 beforeAll(async () => {
1286 adminCtx = await loggedInContext();
1287 aliceCtx = await loggedInContext('alice', 'password123');
1288
1289 // Create a dedicated repo with at least one commit
1290 const page = await adminCtx.newPage();
1291 try {
1292 await page.goto(`${BASE}/new`);
1293 await page.fill('[name=name]', 'releases-repo');
1294 await page.click('form[action="/new"] button[type=submit]');
1295 await page.waitForURL(`${BASE}/releases-repo`);
1296 } finally { await page.close(); }
1297
1298 await seedRepo('releases-repo');
1299 commitHash = await getHeadCommit('releases-repo');
1300 });
1301
1302 afterAll(async () => {
1303 await adminCtx.close();
1304 await aliceCtx.close();
1305 });
1306
1307 // ── Navigation ──────────────────────────────────────────────────────────────
1308
1309 test('releases tab visible in repo nav', async () => {
1310 const page = await adminCtx.newPage();
1311 try {
1312 await page.goto(`${BASE}/releases-repo`);
1313 expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
1314 } finally { await page.close(); }
1315 });
1316
1317 test('releases list shows empty state when no releases', async () => {
1318 const page = await adminCtx.newPage();
1319 try {
1320 await page.goto(`${BASE}/releases-repo/releases`);
1321 expect(await page.locator('.empty-state').isVisible()).toBe(true);
1322 } finally { await page.close(); }
1323 });
1324
1325 // ── Access control ──────────────────────────────────────────────────────────
1326
1327 test('non-admin cannot access /releases/new', async () => {
1328 const page = await aliceCtx.newPage();
1329 try {
1330 const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
1331 expect(resp.status()).toBe(403);
1332 } finally { await page.close(); }
1333 });
1334
1335 test('non-admin POST to /releases returns 403', async () => {
1336 const page = await aliceCtx.newPage();
1337 try {
1338 const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
1339 multipart: { tag_name: 'v0.1.0', commit_hash: commitHash },
1340 maxRedirects: 0,
1341 });
1342 expect(resp.status()).toBe(403);
1343 } finally { await page.close(); }
1344 });
1345
1346 test('unauthenticated user is redirected to login from /releases/new', async () => {
1347 const ctx = await browser.newContext();
1348 const page = await ctx.newPage();
1349 try {
1350 await page.goto(`${BASE}/releases-repo/releases/new`);
1351 expect(page.url()).toContain('/login');
1352 } finally { await ctx.close(); }
1353 });
1354
1355 // ── Validation ──────────────────────────────────────────────────────────────
1356
1357 test('empty tag name shows error', async () => {
1358 // Omit tag_name entirely — route now uses t.Optional so app validation runs
1359 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1360 multipart: { commit_hash: commitHash },
1361 });
1362 expect(await resp.text()).toContain('Tag name is required');
1363 });
1364
1365 test('invalid commit hash shows error', async () => {
1366 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1367 multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' },
1368 });
1369 expect(await resp.text()).toContain('Invalid commit');
1370 });
1371
1372 // ── Create ──────────────────────────────────────────────────────────────────
1373
1374 test('create a basic release', async () => {
1375 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1376 multipart: {
1377 tag_name: 'v1.0.0',
1378 name: 'First release',
1379 commit_hash: commitHash,
1380 notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
1381 },
1382 maxRedirects: 0,
1383 });
1384 expect(resp.status()).toBe(302);
1385 const location = resp.headers()['location']!;
1386 expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
1387 releaseUrl = `${BASE}${location}`;
1388 });
1389
1390 test('duplicate tag name shows error', async () => {
1391 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1392 multipart: { tag_name: 'v1.0.0', commit_hash: commitHash },
1393 });
1394 expect(await resp.text()).toContain('already exists');
1395 });
1396
1397 // ── List ────────────────────────────────────────────────────────────────────
1398
1399 test('release appears in list with tag badge', async () => {
1400 const page = await adminCtx.newPage();
1401 try {
1402 await page.goto(`${BASE}/releases-repo/releases`);
1403 expect(await page.locator('.release-item-title').textContent()).toContain('First release');
1404 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1405 } finally { await page.close(); }
1406 });
1407
1408 test('release list shows commit hash link', async () => {
1409 const page = await adminCtx.newPage();
1410 try {
1411 await page.goto(`${BASE}/releases-repo/releases`);
1412 expect(await page.locator('.release-item-meta a.monospace').textContent())
1413 .toBe(commitHash.slice(0, 8));
1414 } finally { await page.close(); }
1415 });
1416
1417 test('new release button hidden for non-admin', async () => {
1418 const page = await aliceCtx.newPage();
1419 try {
1420 await page.goto(`${BASE}/releases-repo/releases`);
1421 expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
1422 } finally { await page.close(); }
1423 });
1424
1425 // ── Detail ──────────────────────────────────────────────────────────────────
1426
1427 test('release detail shows title, tag badge, and commit link', async () => {
1428 const page = await adminCtx.newPage();
1429 try {
1430 await page.goto(releaseUrl);
1431 expect(await page.locator('h2.page-title').textContent()).toBe('First release');
1432 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1433 expect(await page.locator('.release-item-meta a.monospace').textContent())
1434 .toBe(commitHash.slice(0, 8));
1435 } finally { await page.close(); }
1436 });
1437
1438 test('release notes rendered in detail view', async () => {
1439 const page = await adminCtx.newPage();
1440 try {
1441 await page.goto(releaseUrl);
1442 expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
1443 } finally { await page.close(); }
1444 });
1445
1446 // ── Source archives ─────────────────────────────────────────────────────────
1447
1448 test('create release with source code archives', async () => {
1449 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1450 multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' },
1451 maxRedirects: 0,
1452 });
1453 expect(resp.status()).toBe(302);
1454 const location = resp.headers()['location']!;
1455 srcReleaseUrl = `${BASE}${location}`;
1456 });
1457
1458 test('zip and tar.gz archives appear in downloads', async () => {
1459 const page = await adminCtx.newPage();
1460 try {
1461 await page.goto(srcReleaseUrl);
1462 const assetNames = await page.locator('.asset-name').allTextContents();
1463 expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
1464 expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
1465 } finally { await page.close(); }
1466 });
1467
1468 test('source archive download responds with 200', async () => {
1469 const page = await adminCtx.newPage();
1470 try {
1471 await page.goto(srcReleaseUrl);
1472 const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
1473 const resp = await page.request.get(`${BASE}${zipLink}`);
1474 expect(resp.status()).toBe(200);
1475 } finally { await page.close(); }
1476 });
1477
1478 // ── File upload ─────────────────────────────────────────────────────────────
1479
1480 test('create release with attached file', async () => {
1481 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1482 multipart: {
1483 tag_name: 'v1.2.0',
1484 commit_hash: commitHash,
1485 files: {
1486 name: 'release-asset.txt',
1487 mimeType: 'text/plain',
1488 buffer: Buffer.from('binary-like content for testing\n'),
1489 },
1490 },
1491 maxRedirects: 0,
1492 });
1493 expect(resp.status()).toBe(302);
1494 const location = resp.headers()['location']!;
1495 releaseWithAssetsUrl = `${BASE}${location}`;
1496 });
1497
1498 test('uploaded asset appears in downloads with filename and size', async () => {
1499 const page = await adminCtx.newPage();
1500 try {
1501 await page.goto(releaseWithAssetsUrl);
1502 const names = await page.locator('.asset-name').allTextContents();
1503 expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
1504 expect(await page.locator('.asset-size').isVisible()).toBe(true);
1505 } finally { await page.close(); }
1506 });
1507
1508 test('asset download responds with 200', async () => {
1509 const page = await adminCtx.newPage();
1510 try {
1511 await page.goto(releaseWithAssetsUrl);
1512 const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
1513 const resp = await page.request.get(`${BASE}${link}`);
1514 expect(resp.status()).toBe(200);
1515 } finally { await page.close(); }
1516 });
1517
1518 // ── Delete ──────────────────────────────────────────────────────────────────
1519
1520 test('non-admin cannot delete a release', async () => {
1521 const page = await aliceCtx.newPage();
1522 try {
1523 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1524 const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
1525 maxRedirects: 0,
1526 });
1527 expect(resp.status()).toBe(403);
1528 } finally { await page.close(); }
1529 });
1530
1531 test('admin can delete a release', async () => {
1532 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1533 const resp = await adminCtx.request.post(
1534 `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
1535 { maxRedirects: 0 },
1536 );
1537 expect(resp.status()).toBe(302);
1538 // Verify it's gone from the list
1539 const page = await adminCtx.newPage();
1540 try {
1541 await page.goto(`${BASE}/releases-repo/releases`);
1542 const titles = await page.locator('.release-item-title').allTextContents();
1543 expect(titles.some(t => t.includes('First release'))).toBe(false);
1544 } finally { await page.close(); }
1545 });
1546
1547 // ── Pagination ──────────────────────────────────────────────────────────────
1548
1549 test('release list shows at most 20 per page', async () => {
1550 // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
1551 // regardless of which browser-based tests above succeeded
1552 for (let i = 1; i <= 25; i++) {
1553 await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1554 multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash },
1555 maxRedirects: 0,
1556 }).catch(() => {});
1557 }
1558 const page = await adminCtx.newPage();
1559 try {
1560 await page.goto(`${BASE}/releases-repo/releases`);
1561 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1562 } finally { await page.close(); }
1563 });
1564
1565 test('pagination nav appears with more than 20 releases', async () => {
1566 const page = await adminCtx.newPage();
1567 try {
1568 await page.goto(`${BASE}/releases-repo/releases`);
1569 expect(await page.locator('.pagination').isVisible()).toBe(true);
1570 } finally { await page.close(); }
1571 });
1572
1573 test('release list page 2 shows remaining releases', async () => {
1574 const page = await adminCtx.newPage();
1575 try {
1576 await page.goto(`${BASE}/releases-repo/releases?page=2`);
1577 const count = await page.locator('.issue-item').count();
1578 expect(count).toBeGreaterThan(0);
1579 expect(count).toBeLessThanOrEqual(20);
1580 } finally { await page.close(); }
1581 });
1582
1583 test('source archive tar.zst appears in downloads', async () => {
1584 const page = await adminCtx.newPage();
1585 try {
1586 await page.goto(srcReleaseUrl);
1587 const assetNames = await page.locator('.asset-name').allTextContents();
1588 expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
1589 } finally { await page.close(); }
1590 });
1591});
1592
1593// ─── Settings ─────────────────────────────────────────────────────────────────
1594
1595describe('settings', () => {
1596 let adminCtx: BrowserContext;
1597 let aliceCtx: BrowserContext;
1598 // Public key generated once in beforeAll, reused across SSH key tests
1599 let testPubKey: string;
1600
1601 beforeAll(async () => {
1602 adminCtx = await loggedInContext();
1603 aliceCtx = await loggedInContext('alice', 'password123');
1604
1605 // Generate a throwaway ed25519 key for SSH key tests.
1606 // Use Bun.spawn so the empty passphrase arg is passed correctly.
1607 const keyPath = '/tmp/hf-e2e-sshkey';
1608 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1609 const keygen = Bun.spawn(
1610 ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
1611 { stdout: 'ignore', stderr: 'ignore' },
1612 );
1613 await keygen.exited;
1614 testPubKey = await Bun.file(`${keyPath}.pub`).text();
1615 testPubKey = testPubKey.trim();
1616 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1617 });
1618
1619 afterAll(async () => {
1620 await adminCtx.close();
1621 await aliceCtx.close();
1622 });
1623
1624 test('settings page requires auth', async () => {
1625 const ctx = await browser.newContext();
1626 const page = await ctx.newPage();
1627 try {
1628 await page.goto(`${BASE}/settings`);
1629 expect(page.url()).toContain('/login');
1630 } finally { await ctx.close(); }
1631 });
1632
1633 test('settings page loads for logged-in user', async () => {
1634 const page = await adminCtx.newPage();
1635 try {
1636 await page.goto(`${BASE}/settings`);
1637 expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
1638 } finally { await page.close(); }
1639 });
1640
1641 // ── Password ──────────────────────────────────────────────────────────────
1642
1643 test('password change with mismatched passwords shows error', async () => {
1644 const page = await aliceCtx.newPage();
1645 try {
1646 await page.goto(`${BASE}/settings`);
1647 await page.fill('[name=new_password]', 'newpass123');
1648 await page.fill('[name=confirm_password]', 'different456');
1649 await page.click('form[action="/settings/password"] button[type=submit]');
1650 await page.waitForURL(/\/settings/);
1651 expect(page.url()).toContain('error');
1652 } finally { await page.close(); }
1653 });
1654
1655 test('password change with wrong current password shows error', async () => {
1656 const page = await aliceCtx.newPage();
1657 try {
1658 await page.goto(`${BASE}/settings`);
1659 await page.fill('[name=current_password]', 'wrongpassword');
1660 await page.fill('[name=new_password]', 'newpass123');
1661 await page.fill('[name=confirm_password]', 'newpass123');
1662 await page.click('form[action="/settings/password"] button[type=submit]');
1663 await page.waitForURL(/\/settings/);
1664 expect(page.url()).toContain('error');
1665 } finally { await page.close(); }
1666 });
1667
1668 test('password change too short shows error', async () => {
1669 const page = await aliceCtx.newPage();
1670 try {
1671 await page.goto(`${BASE}/settings`);
1672 await page.fill('[name=current_password]', 'password123');
1673 await page.fill('[name=new_password]', 'short');
1674 await page.fill('[name=confirm_password]', 'short');
1675 await page.click('form[action="/settings/password"] button[type=submit]');
1676 await page.waitForURL(/\/settings/);
1677 expect(page.url()).toContain('error');
1678 } finally { await page.close(); }
1679 });
1680
1681 // ── SSH keys ──────────────────────────────────────────────────────────────
1682
1683 test('add SSH key with unsupported key type shows error', async () => {
1684 const page = await adminCtx.newPage();
1685 try {
1686 await page.goto(`${BASE}/settings`);
1687 await page.fill('#ssh_key_name', 'Bad key');
1688 await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
1689 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1690 await page.waitForURL(/\/settings/);
1691 expect(page.url()).toContain('error');
1692 } finally { await page.close(); }
1693 });
1694
1695 test('add valid SSH key shows success and key appears in list', async () => {
1696 const page = await adminCtx.newPage();
1697 try {
1698 await page.goto(`${BASE}/settings`);
1699 await page.fill('#ssh_key_name', 'My Laptop');
1700 await page.fill('#ssh_public_key', testPubKey);
1701 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1702 await page.waitForURL(/\/settings/);
1703 expect(page.url()).toContain('success=ssh_key_added');
1704 await page.goto(`${BASE}/settings`);
1705 expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
1706 } finally { await page.close(); }
1707 });
1708
1709 test('add duplicate SSH key shows error', async () => {
1710 const page = await adminCtx.newPage();
1711 try {
1712 await page.goto(`${BASE}/settings`);
1713 await page.fill('#ssh_key_name', 'Duplicate');
1714 await page.fill('#ssh_public_key', testPubKey);
1715 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1716 await page.waitForURL(/\/settings/);
1717 expect(page.url()).toContain('error');
1718 } finally { await page.close(); }
1719 });
1720
1721 test('delete SSH key removes it from list', async () => {
1722 const page = await adminCtx.newPage();
1723 try {
1724 await page.goto(`${BASE}/settings`);
1725 // Click the Remove button for the key added above
1726 await page.click('form[action="/settings/ssh-keys/delete"] button');
1727 await page.waitForURL(/\/settings/);
1728 expect(page.url()).toContain('success=ssh_key_deleted');
1729 await page.goto(`${BASE}/settings`);
1730 expect(await page.locator('.ssh-key-name').count()).toBe(0);
1731 } finally { await page.close(); }
1732 });
1733
1734 // ── Admin user management ────────────────────────────────────────────────
1735
1736 test('admin can create a new user account', async () => {
1737 const page = await adminCtx.newPage();
1738 try {
1739 await page.goto(`${BASE}/settings`);
1740 await page.fill('#new_username', 'charlie');
1741 await page.fill('#new_user_password', 'charliepw1');
1742 await page.click('form[action="/admin/users"] button[type=submit]');
1743 await page.waitForURL(/\/settings/);
1744 expect(page.url()).toContain('success=user_created');
1745 } finally { await page.close(); }
1746 });
1747
1748 test('admin cannot create duplicate username', async () => {
1749 const page = await adminCtx.newPage();
1750 try {
1751 await page.goto(`${BASE}/settings`);
1752 await page.fill('#new_username', 'charlie');
1753 await page.fill('#new_user_password', 'charliepw1');
1754 await page.click('form[action="/admin/users"] button[type=submit]');
1755 await page.waitForURL(/\/settings/);
1756 expect(page.url()).toContain('error');
1757 } finally { await page.close(); }
1758 });
1759
1760 test('admin cannot create user with invalid username characters', async () => {
1761 const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
1762 form: { username: 'bad user!', password: 'password123' },
1763 maxRedirects: 0,
1764 });
1765 expect(resp.status()).toBe(302);
1766 const location = resp.headers()['location'] ?? '';
1767 expect(location).toContain('error');
1768 });
1769
1770 test('non-admin gets 403 when creating user', async () => {
1771 const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
1772 form: { username: 'hacker', password: 'password123' },
1773 maxRedirects: 0,
1774 });
1775 expect(resp.status()).toBe(403);
1776 });
1777
1778 test('admin can delete user account', async () => {
1779 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1780 form: { username: 'charlie' },
1781 maxRedirects: 0,
1782 });
1783 expect(resp.status()).toBe(302);
1784 expect(resp.headers()['location']).toContain('success=user_deleted');
1785 });
1786
1787 test('admin cannot delete the admin account', async () => {
1788 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1789 form: { username: 'admin' },
1790 maxRedirects: 0,
1791 });
1792 expect(resp.status()).toBe(302);
1793 expect(resp.headers()['location']).toContain('error');
1794 });
1795});
1796
1797// ─── Repository deletion ──────────────────────────────────────────────────────
1798
1799describe('repository deletion', () => {
1800 let adminCtx: BrowserContext;
1801
1802 beforeAll(async () => {
1803 adminCtx = await loggedInContext();
1804
1805 // Create a repo to delete
1806 const page = await adminCtx.newPage();
1807 try {
1808 await page.goto(`${BASE}/new`);
1809 await page.fill('[name=name]', 'deleteme-repo');
1810 await page.click('form[action="/new"] button[type=submit]');
1811 await page.waitForURL(`${BASE}/deleteme-repo`);
1812 } finally { await page.close(); }
1813 });
1814
1815 afterAll(async () => { await adminCtx.close(); });
1816
1817 test('admin can delete repository', async () => {
1818 const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
1819 maxRedirects: 0,
1820 });
1821 expect(resp.status()).toBe(302);
1822 expect(resp.headers()['location']).toBe('/');
1823 });
1824
1825 test('deleted repository returns 404', async () => {
1826 const page = await adminCtx.newPage();
1827 try {
1828 const resp = await page.request.get(`${BASE}/deleteme-repo`);
1829 expect(resp.status()).toBe(404);
1830 } finally { await page.close(); }
1831 });
1832
1833 test('deleted repository no longer appears in list', async () => {
1834 const page = await adminCtx.newPage();
1835 try {
1836 await page.goto(BASE);
1837 expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
1838 } finally { await page.close(); }
1839 });
1840
1841 test('non-admin cannot delete repository', async () => {
1842 const aliceCtx = await loggedInContext('alice', 'password123');
1843 const page = await aliceCtx.newPage();
1844 try {
1845 const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
1846 maxRedirects: 0,
1847 });
1848 expect(resp.status()).toBe(403);
1849 } finally {
1850 await page.close();
1851 await aliceCtx.close();
1852 }
1853 });
1854});
1855
1856// ─── 404 handling ─────────────────────────────────────────────────────────────
1857
1858describe('404 handling', () => {
1859 let adminCtx: BrowserContext;
1860
1861 beforeAll(async () => { adminCtx = await loggedInContext(); });
1862 afterAll(async () => { await adminCtx.close(); });
1863
1864 test('non-existent repository returns 404', async () => {
1865 const page = await adminCtx.newPage();
1866 try {
1867 const resp = await page.request.get(`${BASE}/no-such-repo`);
1868 expect(resp.status()).toBe(404);
1869 } finally { await page.close(); }
1870 });
1871
1872 test('non-existent issue returns 404', async () => {
1873 const page = await adminCtx.newPage();
1874 try {
1875 const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
1876 expect(resp.status()).toBe(404);
1877 } finally { await page.close(); }
1878 });
1879
1880 test('non-existent commit returns 404', async () => {
1881 const page = await adminCtx.newPage();
1882 try {
1883 const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
1884 expect(resp.status()).toBe(404);
1885 } finally { await page.close(); }
1886 });
1887
1888 test('non-existent file blob returns 404', async () => {
1889 const page = await adminCtx.newPage();
1890 try {
1891 const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
1892 expect(resp.status()).toBe(404);
1893 } finally { await page.close(); }
1894 });
1895});
1896
1897// ─── Issue editing and deletion ───────────────────────────────────────────────
1898
1899describe('issue editing', () => {
1900 let adminCtx: BrowserContext;
1901 let aliceCtx: BrowserContext;
1902 let issueUrl: string;
1903
1904 beforeAll(async () => {
1905 adminCtx = await loggedInContext();
1906 aliceCtx = await loggedInContext('alice', 'password123');
1907
1908 // Create an issue to edit
1909 const page = await adminCtx.newPage();
1910 try {
1911 await page.goto(`${BASE}/my-repo/issues/new`);
1912 await page.fill('[name=title]', 'Issue to edit');
1913 await page.fill('[name=body]', 'Original body.');
1914 await page.click('form[action$="/issues"] button[type=submit]');
1915 await page.waitForURL(/\/my-repo\/issues\/\d+/);
1916 issueUrl = page.url();
1917 } finally { await page.close(); }
1918 });
1919
1920 afterAll(async () => {
1921 await adminCtx.close();
1922 await aliceCtx.close();
1923 });
1924
1925 test('author can edit issue title and body', async () => {
1926 const page = await adminCtx.newPage();
1927 try {
1928 await page.goto(issueUrl);
1929 await page.locator('.timeline-author .inline-edit-details').first().locator('summary').click();
1930 await page.fill('.inline-edit-form-area [name=title]', 'Edited issue title');
1931 await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
1932 await page.click('.inline-edit-form-area [type=submit]');
1933 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1934 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
1935 } finally { await page.close(); }
1936 });
1937
1938 test('non-author non-admin cannot edit issue', async () => {
1939 const page = await aliceCtx.newPage();
1940 try {
1941 const issueNum = issueUrl.split('/issues/')[1];
1942 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
1943 form: { title: 'Hacked title', edit_body: '' },
1944 maxRedirects: 0,
1945 });
1946 expect(resp.status()).toBe(403);
1947 } finally { await page.close(); }
1948 });
1949
1950 test('author can edit issue comment', async () => {
1951 const page = await adminCtx.newPage();
1952 try {
1953 await page.goto(issueUrl);
1954 // Add a comment first
1955 await page.fill('textarea[name=body]', 'Comment to edit.');
1956 await page.click('form[action*="/comments"] button[type=submit]');
1957 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1958
1959 // Edit the comment
1960 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
1961 await commentItem.locator('.inline-edit-details summary').click();
1962 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
1963 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
1964 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
1965 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
1966 } finally { await page.close(); }
1967 });
1968
1969 test('non-admin user can create an issue', async () => {
1970 const page = await aliceCtx.newPage();
1971 try {
1972 await page.goto(`${BASE}/my-repo/issues/new`);
1973 await page.fill('[name=title]', "Alice's issue");
1974 await page.click('form[action$="/issues"] button[type=submit]');
1975 await page.waitForURL(/\/my-repo\/issues\/\d+/);
1976 expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
1977 } finally { await page.close(); }
1978 });
1979
1980 test('non-admin cannot comment on a closed issue', async () => {
1981 // Close the issue as admin first
1982 const issueNum = issueUrl.split('/issues/')[1];
1983 await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
1984
1985 const page = await aliceCtx.newPage();
1986 try {
1987 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
1988 form: { body: 'comment on closed issue' },
1989 maxRedirects: 0,
1990 });
1991 // Non-admin gets redirected (silently ignored), not an error
1992 expect(resp.status()).toBe(302);
1993 // The comment should NOT appear
1994 await page.goto(issueUrl);
1995 const bodies = await page.locator('.timeline-body').allTextContents();
1996 expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
1997 } finally { await page.close(); }
1998 });
1999
2000 test('admin can delete issue', async () => {
2001 const issueNum = issueUrl.split('/issues/')[1];
2002 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
2003 maxRedirects: 0,
2004 });
2005 expect(resp.status()).toBe(302);
2006 // Issue should be gone
2007 const page = await adminCtx.newPage();
2008 try {
2009 const checkResp = await page.request.get(issueUrl);
2010 expect(checkResp.status()).toBe(404);
2011 } finally { await page.close(); }
2012 });
2013});
2014
2015// ─── Repository description update ───────────────────────────────────────────
2016
2017describe('repo description', () => {
2018 let adminCtx: BrowserContext;
2019
2020 beforeAll(async () => { adminCtx = await loggedInContext(); });
2021 afterAll(async () => { await adminCtx.close(); });
2022
2023 test('updating repo description is reflected on list page', async () => {
2024 const page = await adminCtx.newPage();
2025 try {
2026 await page.goto(`${BASE}/my-repo/settings`);
2027 await page.fill('[name=description]', 'A freshly updated description');
2028 await page.click('form[action$="/settings"] button[type=submit]');
2029 expect(await page.locator('.form-success').isVisible()).toBe(true);
2030
2031 await page.goto(BASE);
2032 const desc = await page.locator('.repo-description').allTextContents();
2033 expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
2034 } finally { await page.close(); }
2035 });
2036});
2037