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