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