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