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('changes tab shows commit metadata card', async () => {
811 const page = await adminCtx.newPage();
812 try {
813 await page.goto(cleanPatchUrl + '?tab=changes');
814 expect(await page.locator('.commit-card').isVisible()).toBe(true);
815 expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
816 expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
817 expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
818 expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
819 } finally { await page.close(); }
820 });
821
822 test('patch description renders markdown', async () => {
823 const page = await adminCtx.newPage();
824 try {
825 await page.goto(cleanPatchUrl);
826 expect(await page.locator('.timeline-body.markdown-body').innerHTML()).toContain('<strong>');
827 } finally { await page.close(); }
828 });
829
830 test('clean patch shows apply-clean status immediately', async () => {
831 const page = await adminCtx.newPage();
832 try {
833 await page.goto(cleanPatchUrl);
834 expect(await page.locator('.apply-result').isVisible()).toBe(true);
835 expect(await page.locator('.apply-clean').isVisible()).toBe(true);
836 } finally { await page.close(); }
837 });
838
839 test('merge button appears for clean patch', async () => {
840 const page = await adminCtx.newPage();
841 try {
842 await page.goto(cleanPatchUrl);
843 expect(await page.locator('form[action*="/merge"] button').isVisible()).toBe(true);
844 } finally { await page.close(); }
845 });
846
847 test('patch diff is displayed with highlighted table', async () => {
848 const page = await adminCtx.newPage();
849 try {
850 await page.goto(cleanPatchUrl + '?tab=changes');
851 expect(await page.locator('.diff-table').first().isVisible()).toBe(true);
852 expect(await page.locator('.diff-row-add').count()).toBeGreaterThan(0);
853 } finally { await page.close(); }
854 });
855
856 test('patch appears in open list', async () => {
857 const page = await adminCtx.newPage();
858 try {
859 await page.goto(`${BASE}/my-repo/patches`);
860 const titles = await page.locator('.issue-title').allTextContents();
861 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
862 } finally { await page.close(); }
863 });
864
865 test('unauthenticated user is redirected to login from patch upload', async () => {
866 const ctx = await browser.newContext();
867 const page = await ctx.newPage();
868 try {
869 await page.goto(`${BASE}/my-repo/patches/new`);
870 expect(page.url()).toContain('/login');
871 } finally { await ctx.close(); }
872 });
873
874 test('upload conflict patch', async () => {
875 writeTempFile('/tmp/conflict.patch', CONFLICT_PATCH);
876 const page = await adminCtx.newPage();
877 try {
878 await page.goto(`${BASE}/my-repo/patches/new`);
879 await page.fill('[name=title]', 'Conflict patch');
880 await page.locator('[name=patch_file]').setInputFiles('/tmp/conflict.patch');
881 await page.click('form[action$="/patches"] button[type=submit]');
882 await page.waitForURL(/\/my-repo\/patches\/\d+/);
883 conflictPatchUrl = page.url();
884 } finally { await page.close(); }
885 });
886
887 test('conflict patch shows apply-conflict status', async () => {
888 const page = await adminCtx.newPage();
889 try {
890 await page.goto(conflictPatchUrl);
891 expect(await page.locator('.apply-conflict').isVisible()).toBe(true);
892 } finally { await page.close(); }
893 });
894
895 test('merge button absent for conflict patch', async () => {
896 const page = await adminCtx.newPage();
897 try {
898 await page.goto(conflictPatchUrl);
899 expect(await page.locator('form[action*="/merge"] button').count()).toBe(0);
900 } finally { await page.close(); }
901 });
902
903 test('merge clean patch changes status to merged', async () => {
904 const page = await adminCtx.newPage();
905 try {
906 await page.goto(cleanPatchUrl);
907 await page.click('form[action*="/merge"] button');
908 await page.waitForURL(new RegExp(cleanPatchUrl.replace(BASE, '')));
909 expect(await page.locator('.patch-badge').textContent()).toBe('merged');
910 } finally { await page.close(); }
911 });
912
913 test('merge uses patch From header as git author', async () => {
914 const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
915 const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
916 const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim();
917 const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim();
918 expect(authorName).toBe('Test User');
919 expect(authorEmail).toBe('test@example.com');
920 expect(subject).toBe('Add patch-test.txt');
921 });
922
923 test('merged patch appears in merged list', async () => {
924 const page = await adminCtx.newPage();
925 try {
926 await page.goto(`${BASE}/my-repo/patches?status=merged`);
927 const titles = await page.locator('.issue-title').allTextContents();
928 expect(titles.some(t => t.includes('Add patch-test.txt'))).toBe(true);
929 } finally { await page.close(); }
930 });
931
932 test('upload and close a patch', async () => {
933 writeTempFile('/tmp/close.patch', CLOSE_PATCH);
934 const page = await adminCtx.newPage();
935 try {
936 await page.goto(`${BASE}/my-repo/patches/new`);
937 await page.fill('[name=title]', 'Close me');
938 await page.locator('[name=patch_file]').setInputFiles('/tmp/close.patch');
939 await page.click('form[action$="/patches"] button[type=submit]');
940 await page.waitForURL(/\/my-repo\/patches\/\d+/);
941 closePatchUrl = page.url();
942 await page.click('form[action*="/close"] button');
943 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
944 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
945 } finally { await page.close(); }
946 });
947
948 test('closed patch appears in closed list', async () => {
949 const page = await adminCtx.newPage();
950 try {
951 await page.goto(`${BASE}/my-repo/patches?status=closed`);
952 const titles = await page.locator('.issue-title').allTextContents();
953 expect(titles.some(t => t.includes('Close me'))).toBe(true);
954 } finally { await page.close(); }
955 });
956
957 test('closed patch can be reopened', async () => {
958 const page = await adminCtx.newPage();
959 try {
960 await page.goto(closePatchUrl);
961 expect(await page.locator('.patch-badge').textContent()).toBe('closed');
962 await page.click('form[action*="/close"] button');
963 await page.waitForURL(new RegExp(closePatchUrl.replace(BASE, '')));
964 expect(await page.locator('.patch-badge').textContent()).toBe('open');
965 } finally { await page.close(); }
966 });
967
968 test('patch title and description can be edited', async () => {
969 const page = await adminCtx.newPage();
970 try {
971 await page.goto(conflictPatchUrl);
972 // Edit title via title form
973 await page.click('details.title-edit-details summary');
974 await page.fill('.title-edit-form-area [name=title]', 'Edited Conflict Patch');
975 await page.click('.title-edit-form-area [type=submit]');
976 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
977 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited Conflict Patch');
978 // Edit description via inline form
979 await page.click('.timeline-author .inline-edit-details summary');
980 await page.fill('.inline-edit-form-area [name=edit_description]', 'Updated desc');
981 await page.click('.inline-edit-form-area [type=submit]');
982 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
983 } finally { await page.close(); }
984 });
985
986 test('patch comment: add and edit', async () => {
987 const page = await adminCtx.newPage();
988 try {
989 await page.goto(conflictPatchUrl);
990 await page.fill('[name=body]', 'My patch comment');
991 await page.click('form[action$="/comments"] button[type=submit]');
992 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
993 expect(await page.locator('.timeline-body').last().textContent()).toContain('My patch comment');
994
995 // Edit the comment — scope to the timeline-item containing the comment text
996 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'My patch comment' });
997 await commentItem.locator('.inline-edit-details summary').click();
998 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited patch comment');
999 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
1000 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
1001 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited patch comment');
1002 } finally { await page.close(); }
1003 });
1004
1005 test('patch reaction on description', async () => {
1006 const page = await adminCtx.newPage();
1007 try {
1008 await page.goto(conflictPatchUrl);
1009 // Open reaction picker on the first timeline-item (description)
1010 await page.locator('.timeline-item').first().locator('.reaction-add-btn').click();
1011 await page.locator('.timeline-item').first().locator('.reaction-picker-btn').first().click();
1012 await page.waitForURL(new RegExp(conflictPatchUrl.replace(BASE, '')));
1013 expect(await page.locator('.reaction-btn').first().textContent()).toMatch(/\d/);
1014 } finally { await page.close(); }
1015 });
1016
1017 test('admin can delete a patch', async () => {
1018 const page = await adminCtx.newPage();
1019 try {
1020 const patchNum = conflictPatchUrl.split('/patches/')[1];
1021 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/delete`, {
1022 maxRedirects: 0,
1023 });
1024 expect(resp.status()).toBe(302);
1025 expect(resp.headers()['location']).toContain('/patches');
1026 // Patch should no longer be accessible
1027 const checkResp = await page.request.get(conflictPatchUrl);
1028 expect(checkResp.status()).toBe(404);
1029 } finally { await page.close(); }
1030 });
1031
1032 // ── Patch file re-upload & version protection ──────────────────────────────
1033
1034 let uploadTestPatchUrl: string;
1035
1036 // Adds upload-test.txt — applies cleanly to my-repo
1037 const UPLOAD_TEST_PATCH = [
1038 'From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001',
1039 'From: Original Author <original@example.com>',
1040 'Date: Wed, 03 Jan 2024 10:00:00 +0000',
1041 'Subject: [PATCH] Add upload-test.txt',
1042 '',
1043 '---',
1044 'diff --git a/upload-test.txt b/upload-test.txt',
1045 'new file mode 100644',
1046 'index 0000000..9daeafb',
1047 '--- /dev/null',
1048 '+++ b/upload-test.txt',
1049 '@@ -0,0 +1 @@',
1050 '+upload test',
1051 '',
1052 ].join('\n');
1053
1054 // Replacement: different author, same diff target
1055 const REPLACEMENT_PATCH = [
1056 'From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001',
1057 'From: Replaced Author <replaced@example.com>',
1058 'Date: Thu, 04 Jan 2024 10:00:00 +0000',
1059 'Subject: [PATCH] Add upload-test.txt (v2)',
1060 '',
1061 '---',
1062 'diff --git a/upload-test.txt b/upload-test.txt',
1063 'new file mode 100644',
1064 'index 0000000..9daeafb',
1065 '--- /dev/null',
1066 '+++ b/upload-test.txt',
1067 '@@ -0,0 +1 @@',
1068 '+upload test v2',
1069 '',
1070 ].join('\n');
1071
1072 test('create patch for re-upload tests', async () => {
1073 writeTempFile('/tmp/upload-test.patch', UPLOAD_TEST_PATCH);
1074 const page = await adminCtx.newPage();
1075 try {
1076 await page.goto(`${BASE}/my-repo/patches/new`);
1077 await page.fill('[name=title]', 'Upload test patch');
1078 await page.locator('[name=patch_file]').setInputFiles('/tmp/upload-test.patch');
1079 await page.click('form[action$="/patches"] button[type=submit]');
1080 await page.waitForURL(/\/my-repo\/patches\/\d+/);
1081 uploadTestPatchUrl = page.url();
1082 } finally { await page.close(); }
1083 });
1084
1085 test('upload patch file button is visible for admin on open patch', async () => {
1086 const page = await adminCtx.newPage();
1087 try {
1088 await page.goto(uploadTestPatchUrl);
1089 expect(await page.locator('details:has([name=patch_file])').count()).toBe(1);
1090 } finally { await page.close(); }
1091 });
1092
1093 test('non-author non-admin cannot upload patch file', async () => {
1094 const aliceCtx = await loggedInContext('alice', 'password123');
1095 const page = await aliceCtx.newPage();
1096 try {
1097 const patchNum = uploadTestPatchUrl.split('/patches/')[1];
1098 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
1099 multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
1100 maxRedirects: 0,
1101 });
1102 expect(resp.status()).toBe(403);
1103 } finally { await aliceCtx.close(); }
1104 });
1105
1106 test('upload button hidden for non-author non-admin', async () => {
1107 const aliceCtx = await loggedInContext('alice', 'password123');
1108 const page = await aliceCtx.newPage();
1109 try {
1110 await page.goto(uploadTestPatchUrl);
1111 expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
1112 } finally { await aliceCtx.close(); }
1113 });
1114
1115 test('admin can upload replacement patch file', async () => {
1116 writeTempFile('/tmp/replacement.patch', REPLACEMENT_PATCH);
1117 const page = await adminCtx.newPage();
1118 try {
1119 await page.goto(uploadTestPatchUrl);
1120 await page.locator('details:has([name=patch_file]) summary').click();
1121 await page.locator('[name=patch_file]').setInputFiles('/tmp/replacement.patch');
1122 await page.locator('details:has([name=patch_file]) button[type=submit]').click();
1123 await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
1124 } finally { await page.close(); }
1125 });
1126
1127 test('merge fails when version token is stale', async () => {
1128 // Patch that adds stale-version.txt — applies cleanly
1129 const STALE_PATCH = [
1130 'From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001',
1131 'From: Test User <test@example.com>',
1132 'Date: Fri, 05 Jan 2024 10:00:00 +0000',
1133 'Subject: [PATCH] Add stale-version.txt',
1134 '',
1135 '---',
1136 'diff --git a/stale-version.txt b/stale-version.txt',
1137 'new file mode 100644',
1138 'index 0000000..9daeafb',
1139 '--- /dev/null',
1140 '+++ b/stale-version.txt',
1141 '@@ -0,0 +1 @@',
1142 '+stale',
1143 '',
1144 ].join('\n');
1145 const STALE_PATCH_V2 = STALE_PATCH
1146 .replace('Add stale-version.txt', 'Add stale-version.txt (v2)')
1147 .replace('+stale', '+stale v2');
1148
1149 writeTempFile('/tmp/stale.patch', STALE_PATCH);
1150 const page = await adminCtx.newPage();
1151 try {
1152 // Create the patch
1153 await page.goto(`${BASE}/my-repo/patches/new`);
1154 await page.fill('[name=title]', 'Stale version test');
1155 await page.locator('[name=patch_file]').setInputFiles('/tmp/stale.patch');
1156 await page.click('form[action$="/patches"] button[type=submit]');
1157 await page.waitForURL(/\/my-repo\/patches\/\d+/);
1158 const stalePatchUrl = page.url();
1159 const patchNum = stalePatchUrl.split('/patches/')[1];
1160
1161 // Capture the version the admin sees on the page
1162 const staleVersion = await page.locator('form[action*="/merge"] [name=version]').inputValue();
1163
1164 // Author uploads a new patch file (simulated by admin here), bumping the version
1165 writeTempFile('/tmp/stale-v2.patch', STALE_PATCH_V2);
1166 await page.locator('details:has([name=patch_file]) summary').click();
1167 await page.locator('[name=patch_file]').setInputFiles('/tmp/stale-v2.patch');
1168 await page.locator('details:has([name=patch_file]) button[type=submit]').click();
1169 await page.waitForURL(new RegExp(stalePatchUrl.replace(BASE, '')));
1170
1171 // Admin tries to merge with the stale version — should be rejected
1172 const resp = await page.request.post(`${BASE}/my-repo/patches/${patchNum}/merge`, {
1173 form: { version: staleVersion },
1174 maxRedirects: 0,
1175 });
1176 expect(resp.status()).toBe(409);
1177 expect(await resp.text()).toContain('updated');
1178
1179 // Patch status must still be open
1180 const checkResp = await page.request.get(stalePatchUrl);
1181 expect(checkResp.status()).toBe(200);
1182 expect(await checkResp.text()).toContain('open');
1183 } finally { await page.close(); }
1184 });
1185
1186 test('merge succeeds with current version token after replacement upload', async () => {
1187 const page = await adminCtx.newPage();
1188 try {
1189 await page.goto(uploadTestPatchUrl);
1190 await page.click('form[action*="/merge"] button');
1191 await page.waitForURL(new RegExp(uploadTestPatchUrl.replace(BASE, '')));
1192 expect(await page.locator('.patch-badge').textContent()).toBe('merged');
1193 // Merged commit should carry the replacement patch's author
1194 const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
1195 const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
1196 expect(authorName).toBe('Replaced Author');
1197 } finally { await page.close(); }
1198 });
1199
1200 test('upload patch file button hidden on merged patch', async () => {
1201 const page = await adminCtx.newPage();
1202 try {
1203 await page.goto(uploadTestPatchUrl);
1204 expect(await page.locator('details:has([name=patch_file])').count()).toBe(0);
1205 } finally { await page.close(); }
1206 });
1207
1208 test('POST to upload on merged patch returns 400', async () => {
1209 const patchNum = uploadTestPatchUrl.split('/patches/')[1];
1210 const resp = await adminCtx.request.post(`${BASE}/my-repo/patches/${patchNum}/upload`, {
1211 multipart: { patch_file: { name: 'test.patch', mimeType: 'text/plain', buffer: Buffer.from(UPLOAD_TEST_PATCH) } },
1212 maxRedirects: 0,
1213 });
1214 expect(resp.status()).toBe(400);
1215 });
1216});
1217
1218// ─── Pagination ───────────────────────────────────────────────────────────────
1219
1220describe('pagination', () => {
1221 let adminCtx: BrowserContext;
1222
1223 beforeAll(async () => {
1224 adminCtx = await loggedInContext();
1225
1226 // Create a repo dedicated to pagination testing
1227 const page = await adminCtx.newPage();
1228 try {
1229 await page.goto(`${BASE}/new`);
1230 await page.fill('[name=name]', 'paged-repo');
1231 await page.click('form[action="/new"] button[type=submit]');
1232 await page.waitForURL(`${BASE}/paged-repo`);
1233 } finally { await page.close(); }
1234
1235 // Create 21 issues via the API (triggers page 2 at 20 per page)
1236 await bulkCreateIssues(adminCtx, 'paged-repo', 21);
1237
1238 // Create 21 patches via browser (patch upload requires multipart)
1239 await bulkCreatePatches(adminCtx, 'paged-repo', 21);
1240 });
1241
1242 afterAll(async () => { await adminCtx.close(); });
1243
1244 // ── Repo list pagination ──────────────────────────────────────────────────
1245
1246 test('repo list page 1 shows repos and no pagination when few repos', async () => {
1247 // With only a handful of test repos (< 20), there should be no pagination nav
1248 const page = await adminCtx.newPage();
1249 try {
1250 await page.goto(BASE);
1251 // Repos are shown
1252 expect(await page.locator('.repo-name').count()).toBeGreaterThan(0);
1253 } finally { await page.close(); }
1254 });
1255
1256 // ── Issue pagination ──────────────────────────────────────────────────────
1257
1258 test('issue list page 1 shows at most 20 items', async () => {
1259 const page = await adminCtx.newPage();
1260 try {
1261 await page.goto(`${BASE}/paged-repo/issues`);
1262 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1263 } finally { await page.close(); }
1264 });
1265
1266 test('issue list pagination nav appears when more than 20 issues', async () => {
1267 const page = await adminCtx.newPage();
1268 try {
1269 await page.goto(`${BASE}/paged-repo/issues`);
1270 expect(await page.locator('.pagination').isVisible()).toBe(true);
1271 } finally { await page.close(); }
1272 });
1273
1274 test('issue list page 2 shows remaining issues', async () => {
1275 const page = await adminCtx.newPage();
1276 try {
1277 await page.goto(`${BASE}/paged-repo/issues?page=2`);
1278 const count = await page.locator('.issue-item').count();
1279 expect(count).toBeGreaterThan(0);
1280 expect(count).toBeLessThanOrEqual(20);
1281 } finally { await page.close(); }
1282 });
1283
1284 test('issue list page 2 prev link goes to page 1', async () => {
1285 const page = await adminCtx.newPage();
1286 try {
1287 await page.goto(`${BASE}/paged-repo/issues?page=2`);
1288 const prevHref = await page.locator('.pagination-prev .pagination-btn').getAttribute('href');
1289 expect(prevHref).toContain('page=1');
1290 } finally { await page.close(); }
1291 });
1292
1293 test('issue list page 1 next link goes to page 2', async () => {
1294 const page = await adminCtx.newPage();
1295 try {
1296 await page.goto(`${BASE}/paged-repo/issues`);
1297 const nextHref = await page.locator('.pagination-next .pagination-btn').getAttribute('href');
1298 expect(nextHref).toContain('page=2');
1299 } finally { await page.close(); }
1300 });
1301
1302 // ── Patch pagination ──────────────────────────────────────────────────────
1303
1304 test('patch list page 1 shows at most 20 items', async () => {
1305 const page = await adminCtx.newPage();
1306 try {
1307 await page.goto(`${BASE}/paged-repo/patches`);
1308 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1309 } finally { await page.close(); }
1310 });
1311
1312 test('patch list pagination nav appears when more than 20 patches', async () => {
1313 const page = await adminCtx.newPage();
1314 try {
1315 await page.goto(`${BASE}/paged-repo/patches`);
1316 expect(await page.locator('.pagination').isVisible()).toBe(true);
1317 } finally { await page.close(); }
1318 });
1319
1320 test('patch list page 2 shows remaining patches', async () => {
1321 const page = await adminCtx.newPage();
1322 try {
1323 await page.goto(`${BASE}/paged-repo/patches?page=2`);
1324 const count = await page.locator('.issue-item').count();
1325 expect(count).toBeGreaterThan(0);
1326 } finally { await page.close(); }
1327 });
1328
1329 // ── Commit log pagination ─────────────────────────────────────────────────
1330
1331 test('commit log with few commits shows no cursor nav', async () => {
1332 // my-repo has 1 commit — both newer and older links should be absent
1333 const page = await adminCtx.newPage();
1334 try {
1335 await page.goto(`${BASE}/my-repo/commits/main`);
1336 expect(await page.locator('.commit-cursor-nav').count()).toBe(0);
1337 } finally { await page.close(); }
1338 });
1339});
1340
1341// ─── Branch selector ──────────────────────────────────────────────────────────
1342
1343describe('branch selector', () => {
1344 let adminCtx: BrowserContext;
1345
1346 beforeAll(async () => {
1347 adminCtx = await loggedInContext();
1348 // Add a second branch so the selector is meaningful
1349 await seedBranch('my-repo', 'dev');
1350 });
1351
1352 afterAll(async () => { await adminCtx.close(); });
1353
1354 test('branch selector appears on repo home', async () => {
1355 const page = await adminCtx.newPage();
1356 try {
1357 await page.goto(`${BASE}/my-repo`);
1358 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1359 expect(await page.locator('.branch-select').inputValue()).toBe('main');
1360 } finally { await page.close(); }
1361 });
1362
1363 test('branch selector shows all branches on repo home', async () => {
1364 const page = await adminCtx.newPage();
1365 try {
1366 await page.goto(`${BASE}/my-repo`);
1367 const options = await page.locator('.branch-select option').allTextContents();
1368 expect(options).toContain('main');
1369 expect(options).toContain('dev');
1370 } finally { await page.close(); }
1371 });
1372
1373 test('branch selector appears on file tree with current ref selected', async () => {
1374 const page = await adminCtx.newPage();
1375 try {
1376 await page.goto(`${BASE}/my-repo/tree/main`);
1377 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1378 expect(await page.locator('.branch-select').inputValue()).toBe('main');
1379 } finally { await page.close(); }
1380 });
1381
1382 test('branch selector appears on commit log with current ref selected', async () => {
1383 const page = await adminCtx.newPage();
1384 try {
1385 await page.goto(`${BASE}/my-repo/commits/main`);
1386 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1387 expect(await page.locator('.branch-select').inputValue()).toBe('main');
1388 } finally { await page.close(); }
1389 });
1390
1391 test('branch selector appears on file blob', async () => {
1392 const page = await adminCtx.newPage();
1393 try {
1394 await page.goto(`${BASE}/my-repo/blob/main/README.md`);
1395 expect(await page.locator('.branch-selector').isVisible()).toBe(true);
1396 expect(await page.locator('.branch-select').inputValue()).toBe('main');
1397 } finally { await page.close(); }
1398 });
1399
1400 test('switching branch on commit log navigates to the selected branch', async () => {
1401 const page = await adminCtx.newPage();
1402 try {
1403 await page.goto(`${BASE}/my-repo/commits/main`);
1404 await page.locator('.branch-select').selectOption('dev');
1405 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1406 await page.waitForURL(`${BASE}/my-repo/commits/dev`);
1407 expect(page.url()).toContain('/commits/dev');
1408 } finally { await page.close(); }
1409 });
1410
1411 test('switching branch on file tree navigates to the selected branch', async () => {
1412 const page = await adminCtx.newPage();
1413 try {
1414 await page.goto(`${BASE}/my-repo/tree/main`);
1415 await page.locator('.branch-select').selectOption('dev');
1416 await page.locator('form.branch-selector').evaluate((f: any) => f.submit());
1417 await page.waitForURL(`${BASE}/my-repo/tree/dev`);
1418 expect(page.url()).toContain('/tree/dev');
1419 } finally { await page.close(); }
1420 });
1421
1422 test('branch-switch route preserves subpath when switching tree', async () => {
1423 const page = await adminCtx.newPage();
1424 try {
1425 const resp = await page.request.get(
1426 `${BASE}/my-repo/branch-switch?view=tree&rev=dev&path=src/foo`,
1427 { maxRedirects: 0 },
1428 ).catch(r => r);
1429 // 302 redirect to /my-repo/tree/dev/src/foo
1430 const loc = (resp as any).headers()?.['location'] ?? '';
1431 expect(loc).toContain('/tree/dev/src/foo');
1432 } finally { await page.close(); }
1433 });
1434
1435 test('branch-switch route redirects commits view correctly', async () => {
1436 const page = await adminCtx.newPage();
1437 try {
1438 const resp = await page.request.get(
1439 `${BASE}/my-repo/branch-switch?view=commits&rev=dev`,
1440 { maxRedirects: 0 },
1441 ).catch(r => r);
1442 const loc = (resp as any).headers()?.['location'] ?? '';
1443 expect(loc).toContain('/commits/dev');
1444 } finally { await page.close(); }
1445 });
1446
1447 test('branch-switch route redirects blob view correctly', async () => {
1448 const page = await adminCtx.newPage();
1449 try {
1450 const resp = await page.request.get(
1451 `${BASE}/my-repo/branch-switch?view=blob&rev=dev&path=README.md`,
1452 { maxRedirects: 0 },
1453 ).catch(r => r);
1454 const loc = (resp as any).headers()?.['location'] ?? '';
1455 expect(loc).toContain('/blob/dev/README.md');
1456 } finally { await page.close(); }
1457 });
1458});
1459
1460// ─── Default branch settings ──────────────────────────────────────────────────
1461
1462describe('default branch settings', () => {
1463 // Runs after 'branch selector', so my-repo already has both main and dev branches.
1464 let adminCtx: BrowserContext;
1465
1466 beforeAll(async () => { adminCtx = await loggedInContext(); });
1467 afterAll(async () => { await adminCtx.close(); });
1468
1469 test('settings page shows default branch select', async () => {
1470 const page = await adminCtx.newPage();
1471 try {
1472 await page.goto(`${BASE}/my-repo/settings`);
1473 expect(await page.locator('#default_branch').isVisible()).toBe(true);
1474 const options = await page.locator('#default_branch option').allTextContents();
1475 expect(options).toContain('main');
1476 expect(options).toContain('dev');
1477 } finally { await page.close(); }
1478 });
1479
1480 test('current default branch is pre-selected', async () => {
1481 const page = await adminCtx.newPage();
1482 try {
1483 await page.goto(`${BASE}/my-repo/settings`);
1484 expect(await page.locator('#default_branch').inputValue()).toBe('main');
1485 } finally { await page.close(); }
1486 });
1487
1488 test('changing default branch saves and is reflected in the repo home', async () => {
1489 const page = await adminCtx.newPage();
1490 try {
1491 await page.goto(`${BASE}/my-repo/settings`);
1492 await page.locator('#default_branch').selectOption('dev');
1493 await page.click('form[action$="/settings"] button[type=submit]');
1494 expect(await page.locator('.form-success').isVisible()).toBe(true);
1495 // The select now shows dev as current
1496 expect(await page.locator('#default_branch').inputValue()).toBe('dev');
1497
1498 // Repo home branch selector should reflect the new default
1499 await page.goto(`${BASE}/my-repo`);
1500 expect(await page.locator('.branch-select').inputValue()).toBe('dev');
1501 } finally { await page.close(); }
1502 });
1503
1504 test('commit log link in repo nav uses the new default branch', async () => {
1505 const page = await adminCtx.newPage();
1506 try {
1507 await page.goto(`${BASE}/my-repo`);
1508 const commitsHref = await page.locator('.repo-tab[href*="/commits/"]').getAttribute('href');
1509 expect(commitsHref).toContain('/commits/dev');
1510 } finally { await page.close(); }
1511 });
1512
1513 test('changing default branch back to main restores original state', async () => {
1514 const page = await adminCtx.newPage();
1515 try {
1516 await page.goto(`${BASE}/my-repo/settings`);
1517 await page.locator('#default_branch').selectOption('main');
1518 await page.click('form[action$="/settings"] button[type=submit]');
1519 expect(await page.locator('.form-success').isVisible()).toBe(true);
1520 expect(await page.locator('#default_branch').inputValue()).toBe('main');
1521 } finally { await page.close(); }
1522 });
1523
1524 test('settings page shows hint instead of select when repo has no branches', async () => {
1525 // Create an empty repo (no commits → no branches)
1526 const page = await adminCtx.newPage();
1527 try {
1528 await page.goto(`${BASE}/new`);
1529 await page.fill('[name=name]', 'empty-for-branch-test');
1530 await page.click('form[action="/new"] button[type=submit]');
1531 await page.waitForURL(`${BASE}/empty-for-branch-test`);
1532
1533 await page.goto(`${BASE}/empty-for-branch-test/settings`);
1534 expect(await page.locator('#default_branch').count()).toBe(0);
1535 expect(await page.locator('.form-hint').isVisible()).toBe(true);
1536 } finally { await page.close(); }
1537 });
1538});
1539
1540// ─── File browser ─────────────────────────────────────────────────────────────
1541
1542describe('file browser', () => {
1543 // my-repo already has README.md + index.js from the 'repos' describe block.
1544 // We add a subdirectory here so we can test directory navigation.
1545 let adminCtx: BrowserContext;
1546
1547 beforeAll(async () => {
1548 adminCtx = await loggedInContext();
1549 await seedSubdir('my-repo', 'src', {
1550 'app.ts': 'export {};\n',
1551 'README.md': '# src readme\n',
1552 });
1553 });
1554
1555 afterAll(async () => { await adminCtx.close(); });
1556
1557 test('repo home shows file tree instead of recent commits', async () => {
1558 const page = await adminCtx.newPage();
1559 try {
1560 await page.goto(`${BASE}/my-repo`);
1561 expect(await page.locator('.file-tree').isVisible()).toBe(true);
1562 expect(await page.locator('.repo-commits-section').count()).toBe(0);
1563 } finally { await page.close(); }
1564 });
1565
1566 test('repo home file tree lists files and directories', async () => {
1567 const page = await adminCtx.newPage();
1568 try {
1569 await page.goto(`${BASE}/my-repo`);
1570 const names = await page.locator('.file-name a').allTextContents();
1571 expect(names).toContain('README.md');
1572 expect(names).toContain('index.js');
1573 expect(names).toContain('src');
1574 } finally { await page.close(); }
1575 });
1576
1577 test('directories appear before files in file tree', async () => {
1578 const page = await adminCtx.newPage();
1579 try {
1580 await page.goto(`${BASE}/my-repo`);
1581 const names = await page.locator('.file-name a').allTextContents();
1582 const srcIdx = names.indexOf('src');
1583 const readmeIdx = names.indexOf('README.md');
1584 expect(srcIdx).toBeGreaterThanOrEqual(0);
1585 expect(readmeIdx).toBeGreaterThanOrEqual(0);
1586 expect(srcIdx).toBeLessThan(readmeIdx);
1587 } finally { await page.close(); }
1588 });
1589
1590 test('no ".." entry at repository root', async () => {
1591 const page = await adminCtx.newPage();
1592 try {
1593 await page.goto(`${BASE}/my-repo`);
1594 const names = await page.locator('.file-name a').allTextContents();
1595 expect(names).not.toContain('..');
1596 } finally { await page.close(); }
1597 });
1598
1599 test('clicking directory navigates into it', async () => {
1600 const page = await adminCtx.newPage();
1601 try {
1602 await page.goto(`${BASE}/my-repo`);
1603 await page.locator('.file-name a', { hasText: 'src' }).click();
1604 await page.waitForURL(`${BASE}/my-repo/tree/main/src`);
1605 expect(page.url()).toContain('/tree/main/src');
1606 } finally { await page.close(); }
1607 });
1608
1609 test('".." entry appears in subdirectory', async () => {
1610 const page = await adminCtx.newPage();
1611 try {
1612 await page.goto(`${BASE}/my-repo/tree/main/src`);
1613 const names = await page.locator('.file-name a').allTextContents();
1614 expect(names).toContain('..');
1615 } finally { await page.close(); }
1616 });
1617
1618 test('".." at one level deep links to tree root', async () => {
1619 const page = await adminCtx.newPage();
1620 try {
1621 await page.goto(`${BASE}/my-repo/tree/main/src`);
1622 const upHref = await page.locator('.file-name a', { hasText: '..' }).getAttribute('href');
1623 expect(upHref).toBe('/my-repo/tree/main');
1624 } finally { await page.close(); }
1625 });
1626
1627 test('files in subdirectory show plain names, not full paths', async () => {
1628 const page = await adminCtx.newPage();
1629 try {
1630 await page.goto(`${BASE}/my-repo/tree/main/src`);
1631 const names = await page.locator('.file-name a').allTextContents();
1632 expect(names).toContain('app.ts');
1633 // Must NOT contain the full path with prefix
1634 expect(names).not.toContain('src/app.ts');
1635 expect(names).not.toContain('src/README.md');
1636 } finally { await page.close(); }
1637 });
1638
1639 test('readme is shown below file tree on repo home', async () => {
1640 const page = await adminCtx.newPage();
1641 try {
1642 await page.goto(`${BASE}/my-repo`);
1643 const treeBox = await page.locator('.file-tree').boundingBox();
1644 const readmeBox = await page.locator('.readme-section').boundingBox();
1645 expect(treeBox).not.toBeNull();
1646 expect(readmeBox).not.toBeNull();
1647 expect(readmeBox!.y).toBeGreaterThan(treeBox!.y + treeBox!.height - 1);
1648 } finally { await page.close(); }
1649 });
1650
1651 test('readme in subdirectory is shown when present', async () => {
1652 const page = await adminCtx.newPage();
1653 try {
1654 await page.goto(`${BASE}/my-repo/tree/main/src`);
1655 expect(await page.locator('.readme-section').isVisible()).toBe(true);
1656 expect(await page.locator('.readme-section .markdown-body').innerHTML())
1657 .toContain('src readme');
1658 } finally { await page.close(); }
1659 });
1660
1661 test('file tree on /tree/:ref also shows readme', async () => {
1662 const page = await adminCtx.newPage();
1663 try {
1664 await page.goto(`${BASE}/my-repo/tree/main`);
1665 expect(await page.locator('.file-tree').isVisible()).toBe(true);
1666 expect(await page.locator('.readme-section').isVisible()).toBe(true);
1667 } finally { await page.close(); }
1668 });
1669});
1670
1671// ─── Releases ─────────────────────────────────────────────────────────────────
1672
1673describe('releases', () => {
1674 let adminCtx: BrowserContext;
1675 let aliceCtx: BrowserContext;
1676 let releaseUrl: string;
1677 let srcReleaseUrl: string;
1678 let releaseWithAssetsUrl: string;
1679
1680 beforeAll(async () => {
1681 adminCtx = await loggedInContext();
1682 aliceCtx = await loggedInContext('alice', 'password123');
1683
1684 // Create a dedicated repo with at least one commit
1685 const page = await adminCtx.newPage();
1686 try {
1687 await page.goto(`${BASE}/new`);
1688 await page.fill('[name=name]', 'releases-repo');
1689 await page.click('form[action="/new"] button[type=submit]');
1690 await page.waitForURL(`${BASE}/releases-repo`);
1691 } finally { await page.close(); }
1692
1693 await seedRepo('releases-repo');
1694 });
1695
1696 afterAll(async () => {
1697 await adminCtx.close();
1698 await aliceCtx.close();
1699 });
1700
1701 // ── Navigation ──────────────────────────────────────────────────────────────
1702
1703 test('releases tab visible in repo nav', async () => {
1704 const page = await adminCtx.newPage();
1705 try {
1706 await page.goto(`${BASE}/releases-repo`);
1707 expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
1708 } finally { await page.close(); }
1709 });
1710
1711 test('releases list shows empty state when no releases', async () => {
1712 const page = await adminCtx.newPage();
1713 try {
1714 await page.goto(`${BASE}/releases-repo/releases`);
1715 expect(await page.locator('.empty-state').isVisible()).toBe(true);
1716 } finally { await page.close(); }
1717 });
1718
1719 // ── Access control ──────────────────────────────────────────────────────────
1720
1721 test('non-admin cannot access /releases/new', async () => {
1722 const page = await aliceCtx.newPage();
1723 try {
1724 const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
1725 expect(resp.status()).toBe(403);
1726 } finally { await page.close(); }
1727 });
1728
1729 test('non-admin POST to /releases returns 403', async () => {
1730 const page = await aliceCtx.newPage();
1731 try {
1732 const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
1733 multipart: { name: 'Test', tag_name: 'v0.1.0' },
1734 maxRedirects: 0,
1735 });
1736 expect(resp.status()).toBe(403);
1737 } finally { await page.close(); }
1738 });
1739
1740 test('unauthenticated user is redirected to login from /releases/new', async () => {
1741 const ctx = await browser.newContext();
1742 const page = await ctx.newPage();
1743 try {
1744 await page.goto(`${BASE}/releases-repo/releases/new`);
1745 expect(page.url()).toContain('/login');
1746 } finally { await ctx.close(); }
1747 });
1748
1749 // ── Validation ──────────────────────────────────────────────────────────────
1750
1751 test('missing release title shows error', async () => {
1752 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1753 multipart: {},
1754 });
1755 expect(await resp.text()).toContain('Release title is required');
1756 });
1757
1758 test('create_tag checked but no tag name shows error', async () => {
1759 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1760 multipart: { name: 'Test', create_tag: 'on' },
1761 });
1762 expect(await resp.text()).toContain('Tag name is required');
1763 });
1764
1765 // ── Create ──────────────────────────────────────────────────────────────────
1766
1767 test('create a basic release', async () => {
1768 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1769 multipart: {
1770 create_tag: 'on',
1771 tag_name: 'v1.0.0',
1772 revision: 'main',
1773 name: 'First release',
1774 notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
1775 },
1776 maxRedirects: 0,
1777 });
1778 expect(resp.status()).toBe(302);
1779 const location = resp.headers()['location']!;
1780 expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
1781 releaseUrl = `${BASE}${location}`;
1782 });
1783
1784 test('duplicate tag name shows error', async () => {
1785 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1786 multipart: { name: 'Duplicate', create_tag: 'on', tag_name: 'v1.0.0', revision: 'main' },
1787 });
1788 expect(await resp.text()).toContain('already exists');
1789 });
1790
1791 // ── List ────────────────────────────────────────────────────────────────────
1792
1793 test('release appears in list with tag badge', async () => {
1794 const page = await adminCtx.newPage();
1795 try {
1796 await page.goto(`${BASE}/releases-repo/releases`);
1797 expect(await page.locator('.release-item-title').textContent()).toContain('First release');
1798 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1799 } finally { await page.close(); }
1800 });
1801
1802 test('release list shows tag badge', async () => {
1803 const page = await adminCtx.newPage();
1804 try {
1805 await page.goto(`${BASE}/releases-repo/releases`);
1806 expect(await page.locator('.badge').first().textContent()).toContain('v1.0.0');
1807 } finally { await page.close(); }
1808 });
1809
1810 test('new release button hidden for non-admin', async () => {
1811 const page = await aliceCtx.newPage();
1812 try {
1813 await page.goto(`${BASE}/releases-repo/releases`);
1814 expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
1815 } finally { await page.close(); }
1816 });
1817
1818 // ── Detail ──────────────────────────────────────────────────────────────────
1819
1820 test('release detail shows title and tag badge', async () => {
1821 const page = await adminCtx.newPage();
1822 try {
1823 await page.goto(releaseUrl);
1824 expect(await page.locator('h2.page-title').textContent()).toBe('First release');
1825 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1826 } finally { await page.close(); }
1827 });
1828
1829 test('release notes rendered in detail view', async () => {
1830 const page = await adminCtx.newPage();
1831 try {
1832 await page.goto(releaseUrl);
1833 expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
1834 } finally { await page.close(); }
1835 });
1836
1837 // ── Source archives ─────────────────────────────────────────────────────────
1838
1839 test('create release with source code archives', async () => {
1840 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1841 multipart: { name: 'Source release', create_tag: 'on', tag_name: 'v1.1.0', revision: 'main', include_source_code: 'on' },
1842 maxRedirects: 0,
1843 });
1844 expect(resp.status()).toBe(302);
1845 const location = resp.headers()['location']!;
1846 srcReleaseUrl = `${BASE}${location}`;
1847 });
1848
1849 test('zip and tar.gz archives appear in downloads', async () => {
1850 const page = await adminCtx.newPage();
1851 try {
1852 await page.goto(srcReleaseUrl);
1853 const assetNames = await page.locator('.asset-name').allTextContents();
1854 expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
1855 expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
1856 } finally { await page.close(); }
1857 });
1858
1859 test('source archive download responds with 200', async () => {
1860 const page = await adminCtx.newPage();
1861 try {
1862 await page.goto(srcReleaseUrl);
1863 const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
1864 const resp = await page.request.get(`${BASE}${zipLink}`);
1865 expect(resp.status()).toBe(200);
1866 } finally { await page.close(); }
1867 });
1868
1869 // ── File upload ─────────────────────────────────────────────────────────────
1870
1871 test('create release with attached file', async () => {
1872 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1873 multipart: {
1874 name: 'Asset release',
1875 create_tag: 'on',
1876 tag_name: 'v1.2.0',
1877 revision: 'main',
1878 files: {
1879 name: 'release-asset.txt',
1880 mimeType: 'text/plain',
1881 buffer: Buffer.from('binary-like content for testing\n'),
1882 },
1883 },
1884 maxRedirects: 0,
1885 });
1886 expect(resp.status()).toBe(302);
1887 const location = resp.headers()['location']!;
1888 releaseWithAssetsUrl = `${BASE}${location}`;
1889 });
1890
1891 test('uploaded asset appears in downloads with filename and size', async () => {
1892 const page = await adminCtx.newPage();
1893 try {
1894 await page.goto(releaseWithAssetsUrl);
1895 const names = await page.locator('.asset-name').allTextContents();
1896 expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
1897 expect(await page.locator('.asset-size').isVisible()).toBe(true);
1898 } finally { await page.close(); }
1899 });
1900
1901 test('asset download responds with 200', async () => {
1902 const page = await adminCtx.newPage();
1903 try {
1904 await page.goto(releaseWithAssetsUrl);
1905 const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
1906 const resp = await page.request.get(`${BASE}${link}`);
1907 expect(resp.status()).toBe(200);
1908 } finally { await page.close(); }
1909 });
1910
1911 // ── Delete ──────────────────────────────────────────────────────────────────
1912
1913 test('non-admin cannot delete a release', async () => {
1914 const page = await aliceCtx.newPage();
1915 try {
1916 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1917 const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
1918 maxRedirects: 0,
1919 });
1920 expect(resp.status()).toBe(403);
1921 } finally { await page.close(); }
1922 });
1923
1924 test('admin can delete a release', async () => {
1925 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1926 const resp = await adminCtx.request.post(
1927 `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
1928 { maxRedirects: 0 },
1929 );
1930 expect(resp.status()).toBe(302);
1931 // Verify it's gone from the list
1932 const page = await adminCtx.newPage();
1933 try {
1934 await page.goto(`${BASE}/releases-repo/releases`);
1935 const titles = await page.locator('.release-item-title').allTextContents();
1936 expect(titles.some(t => t.includes('First release'))).toBe(false);
1937 } finally { await page.close(); }
1938 });
1939
1940 // ── Pagination ──────────────────────────────────────────────────────────────
1941
1942 test('release list shows at most 20 per page', async () => {
1943 // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
1944 // regardless of which browser-based tests above succeeded
1945 for (let i = 1; i <= 25; i++) {
1946 await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1947 multipart: { name: `Page test release ${i}`, create_tag: 'on', tag_name: `v9.${i}.0`, revision: 'main' },
1948 maxRedirects: 0,
1949 }).catch(() => {});
1950 }
1951 const page = await adminCtx.newPage();
1952 try {
1953 await page.goto(`${BASE}/releases-repo/releases`);
1954 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1955 } finally { await page.close(); }
1956 });
1957
1958 test('pagination nav appears with more than 20 releases', async () => {
1959 const page = await adminCtx.newPage();
1960 try {
1961 await page.goto(`${BASE}/releases-repo/releases`);
1962 expect(await page.locator('.pagination').isVisible()).toBe(true);
1963 } finally { await page.close(); }
1964 });
1965
1966 test('release list page 2 shows remaining releases', async () => {
1967 const page = await adminCtx.newPage();
1968 try {
1969 await page.goto(`${BASE}/releases-repo/releases?page=2`);
1970 const count = await page.locator('.issue-item').count();
1971 expect(count).toBeGreaterThan(0);
1972 expect(count).toBeLessThanOrEqual(20);
1973 } finally { await page.close(); }
1974 });
1975
1976 test('source archive tar.zst appears in downloads', async () => {
1977 const page = await adminCtx.newPage();
1978 try {
1979 await page.goto(srcReleaseUrl);
1980 const assetNames = await page.locator('.asset-name').allTextContents();
1981 expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
1982 } finally { await page.close(); }
1983 });
1984});
1985
1986// ─── Settings ─────────────────────────────────────────────────────────────────
1987
1988describe('settings', () => {
1989 let adminCtx: BrowserContext;
1990 let aliceCtx: BrowserContext;
1991 // Public key generated once in beforeAll, reused across SSH key tests
1992 let testPubKey: string;
1993
1994 beforeAll(async () => {
1995 adminCtx = await loggedInContext();
1996 aliceCtx = await loggedInContext('alice', 'password123');
1997
1998 // Generate a throwaway ed25519 key for SSH key tests.
1999 // Use Bun.spawn so the empty passphrase arg is passed correctly.
2000 const keyPath = '/tmp/hf-e2e-sshkey';
2001 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
2002 const keygen = Bun.spawn(
2003 ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
2004 { stdout: 'ignore', stderr: 'ignore' },
2005 );
2006 await keygen.exited;
2007 testPubKey = await Bun.file(`${keyPath}.pub`).text();
2008 testPubKey = testPubKey.trim();
2009 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
2010 });
2011
2012 afterAll(async () => {
2013 await adminCtx.close();
2014 await aliceCtx.close();
2015 });
2016
2017 test('settings page requires auth', async () => {
2018 const ctx = await browser.newContext();
2019 const page = await ctx.newPage();
2020 try {
2021 await page.goto(`${BASE}/settings`);
2022 expect(page.url()).toContain('/login');
2023 } finally { await ctx.close(); }
2024 });
2025
2026 test('settings page loads for logged-in user', async () => {
2027 const page = await adminCtx.newPage();
2028 try {
2029 await page.goto(`${BASE}/settings`);
2030 expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
2031 } finally { await page.close(); }
2032 });
2033
2034 // ── Password ──────────────────────────────────────────────────────────────
2035
2036 test('password change with mismatched passwords shows error', async () => {
2037 const page = await aliceCtx.newPage();
2038 try {
2039 await page.goto(`${BASE}/settings`);
2040 await page.fill('[name=new_password]', 'newpass123');
2041 await page.fill('[name=confirm_password]', 'different456');
2042 await page.click('form[action="/settings/password"] button[type=submit]');
2043 await page.waitForURL(/\/settings/);
2044 expect(page.url()).toContain('error');
2045 } finally { await page.close(); }
2046 });
2047
2048 test('password change with wrong current password shows error', async () => {
2049 const page = await aliceCtx.newPage();
2050 try {
2051 await page.goto(`${BASE}/settings`);
2052 await page.fill('[name=current_password]', 'wrongpassword');
2053 await page.fill('[name=new_password]', 'newpass123');
2054 await page.fill('[name=confirm_password]', 'newpass123');
2055 await page.click('form[action="/settings/password"] button[type=submit]');
2056 await page.waitForURL(/\/settings/);
2057 expect(page.url()).toContain('error');
2058 } finally { await page.close(); }
2059 });
2060
2061 test('password change too short shows error', async () => {
2062 const page = await aliceCtx.newPage();
2063 try {
2064 await page.goto(`${BASE}/settings`);
2065 await page.fill('[name=current_password]', 'password123');
2066 await page.fill('[name=new_password]', 'short');
2067 await page.fill('[name=confirm_password]', 'short');
2068 await page.click('form[action="/settings/password"] button[type=submit]');
2069 await page.waitForURL(/\/settings/);
2070 expect(page.url()).toContain('error');
2071 } finally { await page.close(); }
2072 });
2073
2074 // ── SSH keys ──────────────────────────────────────────────────────────────
2075
2076 test('add SSH key with unsupported key type shows error', async () => {
2077 const page = await adminCtx.newPage();
2078 try {
2079 await page.goto(`${BASE}/settings`);
2080 await page.fill('#ssh_key_name', 'Bad key');
2081 await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
2082 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2083 await page.waitForURL(/\/settings/);
2084 expect(page.url()).toContain('error');
2085 } finally { await page.close(); }
2086 });
2087
2088 test('add valid SSH key shows success and key appears in list', async () => {
2089 const page = await adminCtx.newPage();
2090 try {
2091 await page.goto(`${BASE}/settings`);
2092 await page.fill('#ssh_key_name', 'My Laptop');
2093 await page.fill('#ssh_public_key', testPubKey);
2094 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2095 await page.waitForURL(/\/settings/);
2096 expect(page.url()).toContain('success=ssh_key_added');
2097 await page.goto(`${BASE}/settings`);
2098 expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
2099 } finally { await page.close(); }
2100 });
2101
2102 test('add duplicate SSH key shows error', async () => {
2103 const page = await adminCtx.newPage();
2104 try {
2105 await page.goto(`${BASE}/settings`);
2106 await page.fill('#ssh_key_name', 'Duplicate');
2107 await page.fill('#ssh_public_key', testPubKey);
2108 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
2109 await page.waitForURL(/\/settings/);
2110 expect(page.url()).toContain('error');
2111 } finally { await page.close(); }
2112 });
2113
2114 test('delete SSH key removes it from list', async () => {
2115 const page = await adminCtx.newPage();
2116 try {
2117 await page.goto(`${BASE}/settings`);
2118 // Click the Remove button for the key added above
2119 await page.click('form[action="/settings/ssh-keys/delete"] button');
2120 await page.waitForURL(/\/settings/);
2121 expect(page.url()).toContain('success=ssh_key_deleted');
2122 await page.goto(`${BASE}/settings`);
2123 expect(await page.locator('.ssh-key-name').count()).toBe(0);
2124 } finally { await page.close(); }
2125 });
2126
2127 // ── Admin user management ────────────────────────────────────────────────
2128
2129 test('admin can create a new user account', async () => {
2130 const page = await adminCtx.newPage();
2131 try {
2132 await page.goto(`${BASE}/settings`);
2133 await page.fill('#new_username', 'charlie');
2134 await page.fill('#new_user_password', 'charliepw1');
2135 await page.click('form[action="/admin/users"] button[type=submit]');
2136 await page.waitForURL(/\/settings/);
2137 expect(page.url()).toContain('success=user_created');
2138 } finally { await page.close(); }
2139 });
2140
2141 test('admin cannot create duplicate username', async () => {
2142 const page = await adminCtx.newPage();
2143 try {
2144 await page.goto(`${BASE}/settings`);
2145 await page.fill('#new_username', 'charlie');
2146 await page.fill('#new_user_password', 'charliepw1');
2147 await page.click('form[action="/admin/users"] button[type=submit]');
2148 await page.waitForURL(/\/settings/);
2149 expect(page.url()).toContain('error');
2150 } finally { await page.close(); }
2151 });
2152
2153 test('admin cannot create user with invalid username characters', async () => {
2154 const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
2155 form: { username: 'bad user!', password: 'password123' },
2156 maxRedirects: 0,
2157 });
2158 expect(resp.status()).toBe(302);
2159 const location = resp.headers()['location'] ?? '';
2160 expect(location).toContain('error');
2161 });
2162
2163 test('non-admin gets 403 when creating user', async () => {
2164 const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
2165 form: { username: 'hacker', password: 'password123' },
2166 maxRedirects: 0,
2167 });
2168 expect(resp.status()).toBe(403);
2169 });
2170
2171 test('admin can delete user account', async () => {
2172 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
2173 form: { username: 'charlie' },
2174 maxRedirects: 0,
2175 });
2176 expect(resp.status()).toBe(302);
2177 expect(resp.headers()['location']).toContain('success=user_deleted');
2178 });
2179
2180 test('admin cannot delete the admin account', async () => {
2181 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
2182 form: { username: 'admin' },
2183 maxRedirects: 0,
2184 });
2185 expect(resp.status()).toBe(302);
2186 expect(resp.headers()['location']).toContain('error');
2187 });
2188
2189 test('settings page has no git identity section', async () => {
2190 const page = await adminCtx.newPage();
2191 try {
2192 await page.goto(`${BASE}/settings`);
2193 expect(await page.locator('text=Git Identity').count()).toBe(0);
2194 expect(await page.locator('[name=git_name]').count()).toBe(0);
2195 expect(await page.locator('[name=git_email]').count()).toBe(0);
2196 } finally { await page.close(); }
2197 });
2198
2199 test('git identity route no longer exists', async () => {
2200 const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
2201 form: { git_name: 'Test', git_email: 'test@example.com' },
2202 maxRedirects: 0,
2203 });
2204 expect(resp.status()).toBe(404);
2205 });
2206});
2207
2208// ─── Repository deletion ──────────────────────────────────────────────────────
2209
2210describe('repository deletion', () => {
2211 let adminCtx: BrowserContext;
2212
2213 beforeAll(async () => {
2214 adminCtx = await loggedInContext();
2215
2216 // Create a repo to delete
2217 const page = await adminCtx.newPage();
2218 try {
2219 await page.goto(`${BASE}/new`);
2220 await page.fill('[name=name]', 'deleteme-repo');
2221 await page.click('form[action="/new"] button[type=submit]');
2222 await page.waitForURL(`${BASE}/deleteme-repo`);
2223 } finally { await page.close(); }
2224 });
2225
2226 afterAll(async () => { await adminCtx.close(); });
2227
2228 test('admin can delete repository', async () => {
2229 const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
2230 maxRedirects: 0,
2231 });
2232 expect(resp.status()).toBe(302);
2233 expect(resp.headers()['location']).toBe('/');
2234 });
2235
2236 test('deleted repository returns 404', async () => {
2237 const page = await adminCtx.newPage();
2238 try {
2239 const resp = await page.request.get(`${BASE}/deleteme-repo`);
2240 expect(resp.status()).toBe(404);
2241 } finally { await page.close(); }
2242 });
2243
2244 test('deleted repository no longer appears in list', async () => {
2245 const page = await adminCtx.newPage();
2246 try {
2247 await page.goto(BASE);
2248 expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
2249 } finally { await page.close(); }
2250 });
2251
2252 test('non-admin cannot delete repository', async () => {
2253 const aliceCtx = await loggedInContext('alice', 'password123');
2254 const page = await aliceCtx.newPage();
2255 try {
2256 const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
2257 maxRedirects: 0,
2258 });
2259 expect(resp.status()).toBe(403);
2260 } finally {
2261 await page.close();
2262 await aliceCtx.close();
2263 }
2264 });
2265});
2266
2267// ─── 404 handling ─────────────────────────────────────────────────────────────
2268
2269describe('404 handling', () => {
2270 let adminCtx: BrowserContext;
2271
2272 beforeAll(async () => { adminCtx = await loggedInContext(); });
2273 afterAll(async () => { await adminCtx.close(); });
2274
2275 test('non-existent repository returns 404', async () => {
2276 const page = await adminCtx.newPage();
2277 try {
2278 const resp = await page.request.get(`${BASE}/no-such-repo`);
2279 expect(resp.status()).toBe(404);
2280 } finally { await page.close(); }
2281 });
2282
2283 test('non-existent issue returns 404', async () => {
2284 const page = await adminCtx.newPage();
2285 try {
2286 const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
2287 expect(resp.status()).toBe(404);
2288 } finally { await page.close(); }
2289 });
2290
2291 test('non-existent commit returns 404', async () => {
2292 const page = await adminCtx.newPage();
2293 try {
2294 const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
2295 expect(resp.status()).toBe(404);
2296 } finally { await page.close(); }
2297 });
2298
2299 test('non-existent file blob returns 404', async () => {
2300 const page = await adminCtx.newPage();
2301 try {
2302 const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
2303 expect(resp.status()).toBe(404);
2304 } finally { await page.close(); }
2305 });
2306
2307 test('non-existent patch returns 404', async () => {
2308 const page = await adminCtx.newPage();
2309 try {
2310 const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
2311 expect(resp.status()).toBe(404);
2312 } finally { await page.close(); }
2313 });
2314
2315 test('non-existent release returns 404', async () => {
2316 const page = await adminCtx.newPage();
2317 try {
2318 const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
2319 expect(resp.status()).toBe(404);
2320 } finally { await page.close(); }
2321 });
2322});
2323
2324// ─── Issue editing and deletion ───────────────────────────────────────────────
2325
2326describe('issue editing', () => {
2327 let adminCtx: BrowserContext;
2328 let aliceCtx: BrowserContext;
2329 let issueUrl: string;
2330
2331 beforeAll(async () => {
2332 adminCtx = await loggedInContext();
2333 aliceCtx = await loggedInContext('alice', 'password123');
2334
2335 // Create an issue to edit
2336 const page = await adminCtx.newPage();
2337 try {
2338 await page.goto(`${BASE}/my-repo/issues/new`);
2339 await page.fill('[name=title]', 'Issue to edit');
2340 await page.fill('[name=body]', 'Original body.');
2341 await page.click('form[action$="/issues"] button[type=submit]');
2342 await page.waitForURL(/\/my-repo\/issues\/\d+/);
2343 issueUrl = page.url();
2344 } finally { await page.close(); }
2345 });
2346
2347 afterAll(async () => {
2348 await adminCtx.close();
2349 await aliceCtx.close();
2350 });
2351
2352 test('author can edit issue title and body', async () => {
2353 const page = await adminCtx.newPage();
2354 try {
2355 await page.goto(issueUrl);
2356 // Edit title via title form
2357 await page.click('details.title-edit-details summary');
2358 await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
2359 await page.click('.title-edit-form-area [type=submit]');
2360 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2361 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
2362 // Edit body via inline form
2363 await page.click('.timeline-author .inline-edit-details summary');
2364 await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
2365 await page.click('.inline-edit-form-area [type=submit]');
2366 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2367 } finally { await page.close(); }
2368 });
2369
2370 test('edited marker appears after editing', async () => {
2371 const page = await adminCtx.newPage();
2372 try {
2373 await page.goto(issueUrl);
2374 expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
2375 } finally { await page.close(); }
2376 });
2377
2378 test('non-author non-admin cannot edit issue', async () => {
2379 const page = await aliceCtx.newPage();
2380 try {
2381 const issueNum = issueUrl.split('/issues/')[1];
2382 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
2383 form: { title: 'Hacked title', edit_body: '' },
2384 maxRedirects: 0,
2385 });
2386 expect(resp.status()).toBe(403);
2387 } finally { await page.close(); }
2388 });
2389
2390 test('author can edit issue comment', async () => {
2391 const page = await adminCtx.newPage();
2392 try {
2393 await page.goto(issueUrl);
2394 // Add a comment first
2395 await page.fill('textarea[name=body]', 'Comment to edit.');
2396 await page.click('form[action*="/comments"] button[type=submit]');
2397 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2398
2399 // Edit the comment
2400 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
2401 await commentItem.locator('.inline-edit-details summary').click();
2402 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
2403 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
2404 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2405 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
2406 } finally { await page.close(); }
2407 });
2408
2409 test('non-admin user can create an issue', async () => {
2410 const page = await aliceCtx.newPage();
2411 try {
2412 await page.goto(`${BASE}/my-repo/issues/new`);
2413 await page.fill('[name=title]', "Alice's issue");
2414 await page.click('form[action$="/issues"] button[type=submit]');
2415 await page.waitForURL(/\/my-repo\/issues\/\d+/);
2416 expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
2417 } finally { await page.close(); }
2418 });
2419
2420 test('non-admin cannot comment on a closed issue', async () => {
2421 // Close the issue as admin first
2422 const issueNum = issueUrl.split('/issues/')[1];
2423 await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
2424
2425 const page = await aliceCtx.newPage();
2426 try {
2427 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
2428 form: { body: 'comment on closed issue' },
2429 maxRedirects: 0,
2430 });
2431 // Non-admin gets redirected (silently ignored), not an error
2432 expect(resp.status()).toBe(302);
2433 // The comment should NOT appear
2434 await page.goto(issueUrl);
2435 const bodies = await page.locator('.timeline-body').allTextContents();
2436 expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
2437 } finally { await page.close(); }
2438 });
2439
2440 test('admin can delete issue', async () => {
2441 const issueNum = issueUrl.split('/issues/')[1];
2442 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
2443 maxRedirects: 0,
2444 });
2445 expect(resp.status()).toBe(302);
2446 // Issue should be gone
2447 const page = await adminCtx.newPage();
2448 try {
2449 const checkResp = await page.request.get(issueUrl);
2450 expect(checkResp.status()).toBe(404);
2451 } finally { await page.close(); }
2452 });
2453});
2454
2455// ─── Repository description update ───────────────────────────────────────────
2456
2457describe('repo description', () => {
2458 let adminCtx: BrowserContext;
2459
2460 beforeAll(async () => { adminCtx = await loggedInContext(); });
2461 afterAll(async () => { await adminCtx.close(); });
2462
2463 test('updating repo description is reflected on list page', async () => {
2464 const page = await adminCtx.newPage();
2465 try {
2466 await page.goto(`${BASE}/my-repo/settings`);
2467 await page.fill('[name=description]', 'A freshly updated description');
2468 await page.click('form[action$="/settings"] button[type=submit]');
2469 expect(await page.locator('.form-success').isVisible()).toBe(true);
2470
2471 await page.goto(BASE);
2472 const desc = await page.locator('.repo-description').allTextContents();
2473 expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
2474 } finally { await page.close(); }
2475 });
2476});
2477
2478// ─── Issue and patch templates ────────────────────────────────────────────────
2479
2480describe('issue and patch templates', () => {
2481 let adminCtx: BrowserContext;
2482
2483 beforeAll(async () => { adminCtx = await loggedInContext(); });
2484 afterAll(async () => { await adminCtx.close(); });
2485
2486 test('issue template can be saved and is prefilled on new issue form', async () => {
2487 const page = await adminCtx.newPage();
2488 try {
2489 await page.goto(`${BASE}/my-repo/settings`);
2490 await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
2491 await page.click('form[action$="/settings"] button[type=submit]');
2492 expect(await page.locator('.form-success').isVisible()).toBe(true);
2493
2494 await page.goto(`${BASE}/my-repo/issues/new`);
2495 const body = await page.locator('[name=body]').inputValue();
2496 expect(body).toContain('## Steps to reproduce');
2497 expect(body).toContain('## Expected behavior');
2498 } finally { await page.close(); }
2499 });
2500
2501 test('patch template can be saved and is prefilled on new patch form', async () => {
2502 const page = await adminCtx.newPage();
2503 try {
2504 await page.goto(`${BASE}/my-repo/settings`);
2505 await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
2506 await page.click('form[action$="/settings"] button[type=submit]');
2507 expect(await page.locator('.form-success').isVisible()).toBe(true);
2508
2509 await page.goto(`${BASE}/my-repo/patches/new`);
2510 const desc = await page.locator('[name=description]').inputValue();
2511 expect(desc).toContain('## Summary');
2512 expect(desc).toContain('## Testing');
2513 } finally { await page.close(); }
2514 });
2515
2516 test('clearing the issue template removes prefill', async () => {
2517 const page = await adminCtx.newPage();
2518 try {
2519 await page.goto(`${BASE}/my-repo/settings`);
2520 await page.fill('[name=issue_template]', '');
2521 await page.click('form[action$="/settings"] button[type=submit]');
2522 expect(await page.locator('.form-success').isVisible()).toBe(true);
2523
2524 await page.goto(`${BASE}/my-repo/issues/new`);
2525 const body = await page.locator('[name=body]').inputValue();
2526 expect(body).toBe('');
2527 } finally { await page.close(); }
2528 });
2529
2530 test('clearing the patch template removes prefill', async () => {
2531 const page = await adminCtx.newPage();
2532 try {
2533 await page.goto(`${BASE}/my-repo/settings`);
2534 await page.fill('[name=patch_template]', '');
2535 await page.click('form[action$="/settings"] button[type=submit]');
2536 expect(await page.locator('.form-success').isVisible()).toBe(true);
2537
2538 await page.goto(`${BASE}/my-repo/patches/new`);
2539 const desc = await page.locator('[name=description]').inputValue();
2540 expect(desc).toBe('');
2541 } finally { await page.close(); }
2542 });
2543});
2544
2545// ─── Commit signing ───────────────────────────────────────────────────────────
2546// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
2547
2548describe('commit signing', () => {
2549 let adminCtx: BrowserContext;
2550
2551 beforeAll(async () => { adminCtx = await loggedInContext(); });
2552 afterAll(async () => { await adminCtx.close(); });
2553
2554 test('allowed_signers file is generated at startup', () => {
2555 const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
2556 expect(existsSync(allowedSignersPath)).toBe(true);
2557 const content = readFileSync(allowedSignersPath, 'utf8');
2558 expect(content).toContain('namespaces="git"');
2559 expect(content).toContain('ssh-ed25519');
2560 });
2561
2562 test('merged commit has a gpgsig header', async () => {
2563 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2564 // Find the patch commit specifically by subject
2565 const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2566 expect(hash).toBeTruthy();
2567 const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2568 expect(obj).toContain('gpgsig');
2569 });
2570
2571 test('unsigned commits have no gpgsig header', async () => {
2572 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2573 // Initial commit was created by seedRepo (plain git commit, not hearthforge)
2574 const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2575 expect(hash).toBeTruthy();
2576 const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2577 expect(obj).not.toContain('gpgsig');
2578 });
2579
2580 test('commit log shows verified badge on signed commit', async () => {
2581 const page = await adminCtx.newPage();
2582 try {
2583 await page.goto(`${BASE}/my-repo/commits/main`);
2584 // Find the commit-item for the merged patch by subject text
2585 const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
2586 expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
2587 } finally { await page.close(); }
2588 });
2589
2590 test('commit log shows no sig badge on unsigned commit', async () => {
2591 const page = await adminCtx.newPage();
2592 try {
2593 await page.goto(`${BASE}/my-repo/commits/main`);
2594 // Initial commit was not signed via hearthforge
2595 const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
2596 expect(await initialItem.locator('.sig-badge').count()).toBe(0);
2597 } finally { await page.close(); }
2598 });
2599
2600 test('commit detail shows verified signature row for signed commit', async () => {
2601 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2602 const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2603 const page = await adminCtx.newPage();
2604 try {
2605 await page.goto(`${BASE}/my-repo/commit/${hash}`);
2606 const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2607 expect(await sigRow.isVisible()).toBe(true);
2608 expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
2609 } finally { await page.close(); }
2610 });
2611
2612 test('commit detail shows no signature row for unsigned commit', async () => {
2613 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2614 const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2615 const page = await adminCtx.newPage();
2616 try {
2617 await page.goto(`${BASE}/my-repo/commit/${hash}`);
2618 const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2619 expect(await sigRow.count()).toBe(0);
2620 } finally { await page.close(); }
2621 });
2622});
2623
2624// ─── Repo sorting and pinning ─────────────────────────────────────────────────
2625
2626describe('repo sorting and pinning', () => {
2627 let adminCtx: BrowserContext;
2628
2629 beforeAll(async () => {
2630 adminCtx = await loggedInContext();
2631 // Create two repos with predictable names: sort-aaa (created first/older),
2632 // sort-zzz (created second/newer). This lets us verify both name order and
2633 // creation-time order independently.
2634 const page = await adminCtx.newPage();
2635 try {
2636 await page.goto(`${BASE}/new`);
2637 await page.fill('[name=name]', 'sort-aaa');
2638 await page.click('form[action="/new"] button[type=submit]');
2639 await page.waitForURL(`${BASE}/sort-aaa`);
2640
2641 await page.goto(`${BASE}/new`);
2642 await page.fill('[name=name]', 'sort-zzz');
2643 await page.click('form[action="/new"] button[type=submit]');
2644 await page.waitForURL(`${BASE}/sort-zzz`);
2645 } finally { await page.close(); }
2646 });
2647
2648 afterAll(async () => { await adminCtx.close(); });
2649
2650 test('sort dropdown is visible on repo list page', async () => {
2651 const page = await adminCtx.newPage();
2652 try {
2653 await page.goto(BASE);
2654 const options = await page.locator('.repo-sort-select option').allTextContents();
2655 expect(options.some(t => t.includes('Newest'))).toBe(true);
2656 expect(options.some(t => t.includes('Name'))).toBe(true);
2657 } finally { await page.close(); }
2658 });
2659
2660 test('newest option is selected by default', async () => {
2661 // Use a fresh context to ensure no repo_sort cookie is set.
2662 const ctx = await browser.newContext();
2663 const page = await ctx.newPage();
2664 try {
2665 await login(page, 'admin', ADMIN_PASS);
2666 await page.goto(BASE);
2667 expect(await page.locator('.repo-sort-select').inputValue()).toBe('created');
2668 } finally { await ctx.close(); }
2669 });
2670
2671 test('Go button is hidden with JS and works without JS', async () => {
2672 const ctx = await browser.newContext({ javaScriptEnabled: false });
2673 const page = await ctx.newPage();
2674 try {
2675 await login(page, 'admin', ADMIN_PASS);
2676 await page.goto(BASE);
2677 // Go button visible without JS
2678 expect(await page.locator('form[action="/sort"] button[type=submit]').isVisible()).toBe(true);
2679 // Select name sort and submit via Go button
2680 await page.locator('.repo-sort-select').selectOption('name');
2681 await page.locator('form[action="/sort"] button[type=submit]').click();
2682 await page.waitForURL(BASE + '/');
2683 expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2684 } finally { await ctx.close(); }
2685 });
2686
2687 test('selecting name sort sets cookie and persists on next visit', async () => {
2688 const page = await adminCtx.newPage();
2689 try {
2690 await page.goto(BASE);
2691 await Promise.all([
2692 page.waitForURL(BASE + '/'),
2693 page.locator('.repo-sort-select').selectOption('name'),
2694 ]);
2695 expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2696 // Navigate away and back to confirm cookie persists
2697 await page.goto(`${BASE}/my-repo`);
2698 await page.goto(BASE);
2699 expect(await page.locator('.repo-sort-select').inputValue()).toBe('name');
2700 } finally {
2701 // Reset cookie so subsequent tests start from the default sort.
2702 await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2703 await page.close();
2704 }
2705 });
2706
2707 test('default sort shows newer repo before older repo', async () => {
2708 const page = await adminCtx.newPage();
2709 try {
2710 await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2711 await page.goto(`${BASE}/?q=sort-`);
2712 const names = await page.locator('.repo-name').allTextContents();
2713 expect(names.indexOf('sort-zzz')).toBeLessThan(names.indexOf('sort-aaa'));
2714 } finally { await page.close(); }
2715 });
2716
2717 test('name sort shows repos in alphabetical order', async () => {
2718 const page = await adminCtx.newPage();
2719 try {
2720 await adminCtx.addCookies([{ name: 'repo_sort', value: 'name', domain: 'localhost', path: '/' }]);
2721 await page.goto(`${BASE}/?q=sort-`);
2722 const names = await page.locator('.repo-name').allTextContents();
2723 expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
2724 } finally {
2725 await adminCtx.addCookies([{ name: 'repo_sort', value: 'created', domain: 'localhost', path: '/' }]);
2726 await page.close();
2727 }
2728 });
2729
2730 test('pinning a repo shows pinned badge on list page', async () => {
2731 const page = await adminCtx.newPage();
2732 try {
2733 await page.goto(`${BASE}/sort-aaa/settings`);
2734 await page.check('[name=is_pinned]');
2735 await page.click('form[action$="/settings"] button[type=submit]');
2736 expect(await page.locator('.form-success').isVisible()).toBe(true);
2737
2738 await page.goto(BASE);
2739 const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
2740 expect(await card.locator('.badge-pinned').isVisible()).toBe(true);
2741 } finally { await page.close(); }
2742 });
2743
2744 test('pinned repo appears before unpinned repos regardless of creation order', async () => {
2745 const page = await adminCtx.newPage();
2746 try {
2747 // sort-aaa is pinned; default (newest) sort would normally show sort-zzz
2748 // first since it's newer — but pinned repos float to the top.
2749 await page.goto(`${BASE}/?q=sort-`);
2750 const names = await page.locator('.repo-name').allTextContents();
2751 expect(names.indexOf('sort-aaa')).toBeLessThan(names.indexOf('sort-zzz'));
2752 } finally { await page.close(); }
2753 });
2754
2755 test('unpinning a repo removes the pinned badge', async () => {
2756 const page = await adminCtx.newPage();
2757 try {
2758 await page.goto(`${BASE}/sort-aaa/settings`);
2759 await page.uncheck('[name=is_pinned]');
2760 await page.click('form[action$="/settings"] button[type=submit]');
2761 expect(await page.locator('.form-success').isVisible()).toBe(true);
2762
2763 await page.goto(BASE);
2764 const card = page.locator('.repo-card').filter({ hasText: 'sort-aaa' });
2765 expect(await card.locator('.badge-pinned').count()).toBe(0);
2766 } finally { await page.close(); }
2767 });
2768});
2769
2770// ─── File editing ─────────────────────────────────────────────────────────────
2771
2772describe('file editing', () => {
2773 let adminCtx: BrowserContext;
2774
2775 beforeAll(async () => {
2776 adminCtx = await loggedInContext();
2777 // Create a dedicated repo so edits don't interfere with other tests
2778 const page = await adminCtx.newPage();
2779 try {
2780 await page.goto(`${BASE}/new`);
2781 await page.fill('[name=name]', 'edit-repo');
2782 await page.click('form[action="/new"] button[type=submit]');
2783 await page.waitForURL(`${BASE}/edit-repo`);
2784 } finally { await page.close(); }
2785 await seedRepo('edit-repo');
2786 });
2787
2788 afterAll(async () => { await adminCtx.close(); });
2789
2790 test('Edit button appears on text file blob when viewing a branch as admin', async () => {
2791 const page = await adminCtx.newPage();
2792 try {
2793 await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2794 const editBtn = page.locator('a[href*="/edit/main/index.js"]');
2795 expect(await editBtn.isVisible()).toBe(true);
2796 expect(await editBtn.textContent()).toBe('Edit');
2797 } finally { await page.close(); }
2798 });
2799
2800 test('Edit button does not appear when viewing a commit SHA', async () => {
2801 const sha = await getHeadCommit('edit-repo');
2802 const page = await adminCtx.newPage();
2803 try {
2804 await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`);
2805 expect(await page.locator('a[href*="/edit/"]').count()).toBe(0);
2806 } finally { await page.close(); }
2807 });
2808
2809 test('Edit button does not appear for unauthenticated visitors', async () => {
2810 const ctx = await browser.newContext();
2811 const page = await ctx.newPage();
2812 try {
2813 await page.goto(`${BASE}/edit-repo/blob/main/index.js`);
2814 expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0);
2815 } finally {
2816 await page.close();
2817 await ctx.close();
2818 }
2819 });
2820
2821 test('edit page loads with file content pre-filled', async () => {
2822 const page = await adminCtx.newPage();
2823 try {
2824 await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2825 expect(await page.locator('.file-blob-name').textContent()).toBe('index.js');
2826 const content = await page.locator('textarea[name=content]').inputValue();
2827 expect(content).toContain('hello');
2828 const msg = await page.locator('textarea[name=message]').inputValue();
2829 expect(msg).toBe('Edited index.js');
2830 } finally { await page.close(); }
2831 });
2832
2833 test('edit page shows which branch will be committed to', async () => {
2834 const page = await adminCtx.newPage();
2835 try {
2836 await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2837 expect(await page.content()).toContain('main');
2838 } finally { await page.close(); }
2839 });
2840
2841 test('edit page returns 404 for non-branch ref', async () => {
2842 const sha = await getHeadCommit('edit-repo');
2843 const page = await adminCtx.newPage();
2844 try {
2845 const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2846 expect(resp.status()).toBe(404);
2847 } finally { await page.close(); }
2848 });
2849
2850 test('submitting edit creates a new commit and redirects to blob view', async () => {
2851 const page = await adminCtx.newPage();
2852 try {
2853 await page.goto(`${BASE}/edit-repo/edit/main/index.js`);
2854 await page.fill('textarea[name=content]', 'console.log("edited");\n');
2855 await page.fill('textarea[name=message]', 'Update index.js via web editor');
2856 await page.locator('.form-actions button[type=submit]').click();
2857 await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/);
2858 // The commit detail view should show the commit message
2859 expect(await page.content()).toContain('Update index.js via web editor');
2860 } finally { await page.close(); }
2861 });
2862
2863 test('edit commit has a gpgsig header (is signed)', async () => {
2864 const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`;
2865 const hash = (
2866 await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet()
2867 ).text().trim();
2868 expect(hash).toBeTruthy();
2869 const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2870 expect(obj).toContain('gpgsig');
2871 });
2872
2873 test('edit commit shows verified badge in commit log', async () => {
2874 const page = await adminCtx.newPage();
2875 try {
2876 await page.goto(`${BASE}/edit-repo/commits/main`);
2877 const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' });
2878 expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true);
2879 } finally { await page.close(); }
2880 });
2881
2882 test('GET edit page returns 404 for non-branch ref', async () => {
2883 const sha = await getHeadCommit('edit-repo');
2884 const page = await adminCtx.newPage();
2885 try {
2886 const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`);
2887 expect(resp.status()).toBe(404);
2888 } finally { await page.close(); }
2889 });
2890});
2891
2892// ─── Labels ───────────────────────────────────────────────────────────────────
2893
2894describe('labels', () => {
2895 let adminCtx: BrowserContext;
2896 let issueUrl: string;
2897 let patchUrl: string;
2898
2899 const VALID_PATCH = [
2900 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
2901 'From: Test User <test@example.com>',
2902 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
2903 'Subject: [PATCH] Add label-test.txt',
2904 '',
2905 '---',
2906 'diff --git a/label-test.txt b/label-test.txt',
2907 'new file mode 100644',
2908 'index 0000000..9daeafb',
2909 '--- /dev/null',
2910 '+++ b/label-test.txt',
2911 '@@ -0,0 +1 @@',
2912 '+x',
2913 '',
2914 ].join('\n');
2915
2916 beforeAll(async () => {
2917 adminCtx = await loggedInContext();
2918
2919 // Create a dedicated repo for label tests
2920 const page = await adminCtx.newPage();
2921 try {
2922 await page.goto(`${BASE}/new`);
2923 await page.fill('[name=name]', 'label-repo');
2924 await page.click('form[action="/new"] button[type=submit]');
2925 await page.waitForURL(`${BASE}/label-repo`);
2926 } finally { await page.close(); }
2927
2928 // Create an issue
2929 const issuePage = await adminCtx.newPage();
2930 try {
2931 await issuePage.goto(`${BASE}/label-repo/issues/new`);
2932 await issuePage.fill('[name=title]', 'Labelled issue');
2933 await issuePage.click('form[action$="/issues"] button[type=submit]');
2934 await issuePage.waitForURL(/\/label-repo\/issues\/\d+/);
2935 issueUrl = issuePage.url();
2936 } finally { await issuePage.close(); }
2937
2938 // Create a patch
2939 writeTempFile('/tmp/label-test.patch', VALID_PATCH);
2940 const patchPage = await adminCtx.newPage();
2941 try {
2942 await patchPage.goto(`${BASE}/label-repo/patches/new`);
2943 await patchPage.fill('[name=title]', 'Labelled patch');
2944 await patchPage.locator('[name=patch_file]').setInputFiles('/tmp/label-test.patch');
2945 await patchPage.click('form[action$="/patches"] button[type=submit]');
2946 await patchPage.waitForURL(/\/label-repo\/patches\/\d+/);
2947 patchUrl = patchPage.url();
2948 } finally { await patchPage.close(); }
2949 });
2950
2951 afterAll(async () => { await adminCtx.close(); });
2952
2953 test('create label in repo settings', async () => {
2954 const page = await adminCtx.newPage();
2955 try {
2956 await page.goto(`${BASE}/label-repo/settings`);
2957 await page.fill('input[name=name]', 'bug');
2958 await page.locator('input[type=color][name=color]').evaluate(
2959 (el: any) => { el.value = '#ff0000'; },
2960 );
2961 await page.click('form[action$="/settings/labels"] button[type=submit]');
2962 await page.waitForURL(/\/label-repo\/settings/);
2963 expect(await page.locator('.label-settings-name').allTextContents()).toContain('bug');
2964 } finally { await page.close(); }
2965 });
2966
2967 test('create a second label', async () => {
2968 const page = await adminCtx.newPage();
2969 try {
2970 await page.goto(`${BASE}/label-repo/settings`);
2971 await page.fill('input[name=name]', 'enhancement');
2972 await page.locator('input[type=color][name=color]').evaluate(
2973 (el: any) => { el.value = '#00aa00'; },
2974 );
2975 await page.click('form[action$="/settings/labels"] button[type=submit]');
2976 await page.waitForURL(/\/label-repo\/settings/);
2977 const badges = await page.locator('.label-settings-name').allTextContents();
2978 expect(badges).toContain('bug');
2979 expect(badges).toContain('enhancement');
2980 } finally { await page.close(); }
2981 });
2982
2983 test('duplicate label name is rejected', async () => {
2984 const page = await adminCtx.newPage();
2985 try {
2986 await page.goto(`${BASE}/label-repo/settings`);
2987 await page.fill('input[name=name]', 'bug');
2988 await page.locator('input[type=color][name=color]').evaluate(
2989 (el: any) => { el.value = '#0000ff'; },
2990 );
2991 await page.click('form[action$="/settings/labels"] button[type=submit]');
2992 await page.waitForURL(/\/label-repo\/settings/);
2993 expect(await page.locator('.form-error').isVisible()).toBe(true);
2994 } finally { await page.close(); }
2995 });
2996
2997 test('non-admin cannot create labels', async () => {
2998 const ctx = await browser.newContext();
2999 try {
3000 const r = await ctx.request.post(`${BASE}/label-repo/settings/labels`, {
3001 form: { name: 'nope', color: '#123456' },
3002 maxRedirects: 0,
3003 });
3004 // Unauthenticated → redirected to /login
3005 expect(r.status()).toBe(302);
3006 expect(r.headers()['location']).toContain('/login');
3007 } finally { await ctx.close(); }
3008 });
3009
3010 test('assign label to issue', async () => {
3011 const page = await adminCtx.newPage();
3012 try {
3013 await page.goto(issueUrl);
3014 await page.selectOption('select[name=label_id]', { label: 'bug' });
3015 await page.click('form[action$="/labels/add"] button[type=submit]');
3016 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
3017 expect(await page.locator('.label-badge').allTextContents()).toContain('bug');
3018 } finally { await page.close(); }
3019 });
3020
3021 test('label appears on issue list', async () => {
3022 const page = await adminCtx.newPage();
3023 try {
3024 await page.goto(`${BASE}/label-repo/issues`);
3025 const item = page.locator('.issue-item').filter({ hasText: 'Labelled issue' });
3026 expect(await item.locator('.label-badge').allTextContents()).toContain('bug');
3027 } finally { await page.close(); }
3028 });
3029
3030 test('filter issues by label shows only matching issues', async () => {
3031 // Create a second issue without the label
3032 const createPage = await adminCtx.newPage();
3033 try {
3034 await createPage.goto(`${BASE}/label-repo/issues/new`);
3035 await createPage.fill('[name=title]', 'Unlabelled issue');
3036 await createPage.click('form[action$="/issues"] button[type=submit]');
3037 await createPage.waitForURL(/\/label-repo\/issues\/\d+/);
3038 } finally { await createPage.close(); }
3039
3040 // Open filter popup and apply label filter
3041 const page = await adminCtx.newPage();
3042 try {
3043 await page.goto(`${BASE}/label-repo/issues`);
3044 // Get the label id from the checkbox
3045 const checkbox = page.locator('.label-filter-item input[name=labels]').first();
3046 const labelId = await checkbox.getAttribute('value');
3047 expect(labelId).toBeTruthy();
3048
3049 // Navigate with the filter applied via URL
3050 await page.goto(`${BASE}/label-repo/issues?labels=${labelId}`);
3051 const titles = await page.locator('.issue-title').allTextContents();
3052 expect(titles.some(t => t.includes('Labelled issue'))).toBe(true);
3053 expect(titles.some(t => t.includes('Unlabelled issue'))).toBe(false);
3054 } finally { await page.close(); }
3055 });
3056
3057 test('filter popup is visible without JS', async () => {
3058 // details/summary is a native HTML element — verify it renders
3059 const page = await adminCtx.newPage();
3060 try {
3061 await page.goto(`${BASE}/label-repo/issues`);
3062 expect(await page.locator('details.label-filter').isVisible()).toBe(true);
3063 expect(await page.locator('details.label-filter summary').isVisible()).toBe(true);
3064 } finally { await page.close(); }
3065 });
3066
3067 test('remove label from issue', async () => {
3068 const page = await adminCtx.newPage();
3069 try {
3070 await page.goto(issueUrl);
3071 await page.click('form[action$="/labels/remove"] button[type=submit]');
3072 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
3073 // Label badge should no longer appear in the labels row
3074 const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
3075 expect(labelBadges).not.toContain('bug');
3076 } finally { await page.close(); }
3077 });
3078
3079 test('assign label to patch', async () => {
3080 const page = await adminCtx.newPage();
3081 try {
3082 await page.goto(patchUrl);
3083 await page.selectOption('select[name=label_id]', { label: 'enhancement' });
3084 await page.click('form[action$="/labels/add"] button[type=submit]');
3085 await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
3086 expect(await page.locator('.label-badge').allTextContents()).toContain('enhancement');
3087 } finally { await page.close(); }
3088 });
3089
3090 test('label appears on patch list', async () => {
3091 const page = await adminCtx.newPage();
3092 try {
3093 await page.goto(`${BASE}/label-repo/patches`);
3094 const item = page.locator('.issue-item').filter({ hasText: 'Labelled patch' });
3095 expect(await item.locator('.label-badge').allTextContents()).toContain('enhancement');
3096 } finally { await page.close(); }
3097 });
3098
3099 test('filter patches by label', async () => {
3100 const page = await adminCtx.newPage();
3101 try {
3102 await page.goto(`${BASE}/label-repo/patches`);
3103 // Find the checkbox for the 'enhancement' label specifically
3104 const checkbox = page.locator('.label-filter-item').filter({ hasText: 'enhancement' })
3105 .locator('input[name=labels]');
3106 const labelId = await checkbox.getAttribute('value');
3107 expect(labelId).toBeTruthy();
3108
3109 await page.goto(`${BASE}/label-repo/patches?labels=${labelId}`);
3110 const titles = await page.locator('.issue-title').allTextContents();
3111 expect(titles.some(t => t.includes('Labelled patch'))).toBe(true);
3112 } finally { await page.close(); }
3113 });
3114
3115 test('remove label from patch', async () => {
3116 const page = await adminCtx.newPage();
3117 try {
3118 await page.goto(patchUrl);
3119 await page.click('form[action$="/labels/remove"] button[type=submit]');
3120 await page.waitForURL(new RegExp(patchUrl.replace(BASE, '')));
3121 const labelBadges = await page.locator('.issue-labels-row .label-badge').allTextContents();
3122 expect(labelBadges).not.toContain('enhancement');
3123 } finally { await page.close(); }
3124 });
3125
3126 test('delete label removes it from settings list', async () => {
3127 const page = await adminCtx.newPage();
3128 try {
3129 await page.goto(`${BASE}/label-repo/settings`);
3130 const bugItem = page.locator('.label-settings-item').filter({ hasText: 'bug' });
3131 await bugItem.locator('form[action$="/labels/delete"] button').click();
3132 await page.waitForURL(/\/label-repo\/settings/);
3133 const badges = await page.locator('.label-settings-name').allTextContents();
3134 expect(badges).not.toContain('bug');
3135 } finally { await page.close(); }
3136 });
3137
3138 test('deleted label no longer appears in filter popup', async () => {
3139 const page = await adminCtx.newPage();
3140 try {
3141 await page.goto(`${BASE}/label-repo/issues`);
3142 const filterLabels = await page.locator('.label-filter-item').allTextContents();
3143 expect(filterLabels.every(t => !t.includes('bug'))).toBe(true);
3144 } finally { await page.close(); }
3145 });
3146});
3147