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