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 commitHash: string;
1384 let releaseUrl: string;
1385 let srcReleaseUrl: string;
1386 let releaseWithAssetsUrl: string;
1387
1388 beforeAll(async () => {
1389 adminCtx = await loggedInContext();
1390 aliceCtx = await loggedInContext('alice', 'password123');
1391
1392 // Create a dedicated repo with at least one commit
1393 const page = await adminCtx.newPage();
1394 try {
1395 await page.goto(`${BASE}/new`);
1396 await page.fill('[name=name]', 'releases-repo');
1397 await page.click('form[action="/new"] button[type=submit]');
1398 await page.waitForURL(`${BASE}/releases-repo`);
1399 } finally { await page.close(); }
1400
1401 await seedRepo('releases-repo');
1402 commitHash = await getHeadCommit('releases-repo');
1403 });
1404
1405 afterAll(async () => {
1406 await adminCtx.close();
1407 await aliceCtx.close();
1408 });
1409
1410 // ── Navigation ──────────────────────────────────────────────────────────────
1411
1412 test('releases tab visible in repo nav', async () => {
1413 const page = await adminCtx.newPage();
1414 try {
1415 await page.goto(`${BASE}/releases-repo`);
1416 expect(await page.locator('.repo-tab', { hasText: 'Releases' }).isVisible()).toBe(true);
1417 } finally { await page.close(); }
1418 });
1419
1420 test('releases list shows empty state when no releases', async () => {
1421 const page = await adminCtx.newPage();
1422 try {
1423 await page.goto(`${BASE}/releases-repo/releases`);
1424 expect(await page.locator('.empty-state').isVisible()).toBe(true);
1425 } finally { await page.close(); }
1426 });
1427
1428 // ── Access control ──────────────────────────────────────────────────────────
1429
1430 test('non-admin cannot access /releases/new', async () => {
1431 const page = await aliceCtx.newPage();
1432 try {
1433 const resp = await page.request.get(`${BASE}/releases-repo/releases/new`);
1434 expect(resp.status()).toBe(403);
1435 } finally { await page.close(); }
1436 });
1437
1438 test('non-admin POST to /releases returns 403', async () => {
1439 const page = await aliceCtx.newPage();
1440 try {
1441 const resp = await page.request.post(`${BASE}/releases-repo/releases`, {
1442 multipart: { tag_name: 'v0.1.0', commit_hash: commitHash },
1443 maxRedirects: 0,
1444 });
1445 expect(resp.status()).toBe(403);
1446 } finally { await page.close(); }
1447 });
1448
1449 test('unauthenticated user is redirected to login from /releases/new', async () => {
1450 const ctx = await browser.newContext();
1451 const page = await ctx.newPage();
1452 try {
1453 await page.goto(`${BASE}/releases-repo/releases/new`);
1454 expect(page.url()).toContain('/login');
1455 } finally { await ctx.close(); }
1456 });
1457
1458 // ── Validation ──────────────────────────────────────────────────────────────
1459
1460 test('empty tag name shows error', async () => {
1461 // Omit tag_name entirely — route now uses t.Optional so app validation runs
1462 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1463 multipart: { commit_hash: commitHash },
1464 });
1465 expect(await resp.text()).toContain('Tag name is required');
1466 });
1467
1468 test('invalid commit hash shows error', async () => {
1469 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1470 multipart: { tag_name: 'v-bad', commit_hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' },
1471 });
1472 expect(await resp.text()).toContain('Invalid commit');
1473 });
1474
1475 // ── Create ──────────────────────────────────────────────────────────────────
1476
1477 test('create a basic release', async () => {
1478 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1479 multipart: {
1480 tag_name: 'v1.0.0',
1481 name: 'First release',
1482 commit_hash: commitHash,
1483 notes: 'Initial stable release.\n\n- Feature A\n- Feature B',
1484 },
1485 maxRedirects: 0,
1486 });
1487 expect(resp.status()).toBe(302);
1488 const location = resp.headers()['location']!;
1489 expect(location).toMatch(/\/releases-repo\/releases\/\d+/);
1490 releaseUrl = `${BASE}${location}`;
1491 });
1492
1493 test('duplicate tag name shows error', async () => {
1494 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1495 multipart: { tag_name: 'v1.0.0', commit_hash: commitHash },
1496 });
1497 expect(await resp.text()).toContain('already exists');
1498 });
1499
1500 // ── List ────────────────────────────────────────────────────────────────────
1501
1502 test('release appears in list with tag badge', async () => {
1503 const page = await adminCtx.newPage();
1504 try {
1505 await page.goto(`${BASE}/releases-repo/releases`);
1506 expect(await page.locator('.release-item-title').textContent()).toContain('First release');
1507 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1508 } finally { await page.close(); }
1509 });
1510
1511 test('release list shows commit hash link', async () => {
1512 const page = await adminCtx.newPage();
1513 try {
1514 await page.goto(`${BASE}/releases-repo/releases`);
1515 expect(await page.locator('.release-item-meta a.monospace').textContent())
1516 .toBe(commitHash.slice(0, 8));
1517 } finally { await page.close(); }
1518 });
1519
1520 test('new release button hidden for non-admin', async () => {
1521 const page = await aliceCtx.newPage();
1522 try {
1523 await page.goto(`${BASE}/releases-repo/releases`);
1524 expect(await page.locator('a[href$="/releases/new"]').count()).toBe(0);
1525 } finally { await page.close(); }
1526 });
1527
1528 // ── Detail ──────────────────────────────────────────────────────────────────
1529
1530 test('release detail shows title, tag badge, and commit link', async () => {
1531 const page = await adminCtx.newPage();
1532 try {
1533 await page.goto(releaseUrl);
1534 expect(await page.locator('h2.page-title').textContent()).toBe('First release');
1535 expect(await page.locator('.badge').textContent()).toContain('v1.0.0');
1536 expect(await page.locator('.release-item-meta a.monospace').textContent())
1537 .toBe(commitHash.slice(0, 8));
1538 } finally { await page.close(); }
1539 });
1540
1541 test('release notes rendered in detail view', async () => {
1542 const page = await adminCtx.newPage();
1543 try {
1544 await page.goto(releaseUrl);
1545 expect(await page.locator('.markdown-body').textContent()).toContain('Initial stable release');
1546 } finally { await page.close(); }
1547 });
1548
1549 // ── Source archives ─────────────────────────────────────────────────────────
1550
1551 test('create release with source code archives', async () => {
1552 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1553 multipart: { tag_name: 'v1.1.0', commit_hash: commitHash, include_source_code: 'on' },
1554 maxRedirects: 0,
1555 });
1556 expect(resp.status()).toBe(302);
1557 const location = resp.headers()['location']!;
1558 srcReleaseUrl = `${BASE}${location}`;
1559 });
1560
1561 test('zip and tar.gz archives appear in downloads', async () => {
1562 const page = await adminCtx.newPage();
1563 try {
1564 await page.goto(srcReleaseUrl);
1565 const assetNames = await page.locator('.asset-name').allTextContents();
1566 expect(assetNames.some(n => n.endsWith('.zip'))).toBe(true);
1567 expect(assetNames.some(n => n.endsWith('.tar.gz'))).toBe(true);
1568 } finally { await page.close(); }
1569 });
1570
1571 test('source archive download responds with 200', async () => {
1572 const page = await adminCtx.newPage();
1573 try {
1574 await page.goto(srcReleaseUrl);
1575 const zipLink = await page.locator('.asset-name', { hasText: '.zip' }).getAttribute('href');
1576 const resp = await page.request.get(`${BASE}${zipLink}`);
1577 expect(resp.status()).toBe(200);
1578 } finally { await page.close(); }
1579 });
1580
1581 // ── File upload ─────────────────────────────────────────────────────────────
1582
1583 test('create release with attached file', async () => {
1584 const resp = await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1585 multipart: {
1586 tag_name: 'v1.2.0',
1587 commit_hash: commitHash,
1588 files: {
1589 name: 'release-asset.txt',
1590 mimeType: 'text/plain',
1591 buffer: Buffer.from('binary-like content for testing\n'),
1592 },
1593 },
1594 maxRedirects: 0,
1595 });
1596 expect(resp.status()).toBe(302);
1597 const location = resp.headers()['location']!;
1598 releaseWithAssetsUrl = `${BASE}${location}`;
1599 });
1600
1601 test('uploaded asset appears in downloads with filename and size', async () => {
1602 const page = await adminCtx.newPage();
1603 try {
1604 await page.goto(releaseWithAssetsUrl);
1605 const names = await page.locator('.asset-name').allTextContents();
1606 expect(names.some(n => n.includes('release-asset.txt'))).toBe(true);
1607 expect(await page.locator('.asset-size').isVisible()).toBe(true);
1608 } finally { await page.close(); }
1609 });
1610
1611 test('asset download responds with 200', async () => {
1612 const page = await adminCtx.newPage();
1613 try {
1614 await page.goto(releaseWithAssetsUrl);
1615 const link = await page.locator('.asset-name', { hasText: 'release-asset.txt' }).getAttribute('href');
1616 const resp = await page.request.get(`${BASE}${link}`);
1617 expect(resp.status()).toBe(200);
1618 } finally { await page.close(); }
1619 });
1620
1621 // ── Delete ──────────────────────────────────────────────────────────────────
1622
1623 test('non-admin cannot delete a release', async () => {
1624 const page = await aliceCtx.newPage();
1625 try {
1626 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1627 const resp = await page.request.post(`${BASE}/releases-repo/releases/${idMatch![1]}/delete`, {
1628 maxRedirects: 0,
1629 });
1630 expect(resp.status()).toBe(403);
1631 } finally { await page.close(); }
1632 });
1633
1634 test('admin can delete a release', async () => {
1635 const idMatch = releaseUrl.match(/\/releases\/(\d+)/);
1636 const resp = await adminCtx.request.post(
1637 `${BASE}/releases-repo/releases/${idMatch![1]}/delete`,
1638 { maxRedirects: 0 },
1639 );
1640 expect(resp.status()).toBe(302);
1641 // Verify it's gone from the list
1642 const page = await adminCtx.newPage();
1643 try {
1644 await page.goto(`${BASE}/releases-repo/releases`);
1645 const titles = await page.locator('.release-item-title').allTextContents();
1646 expect(titles.some(t => t.includes('First release'))).toBe(false);
1647 } finally { await page.close(); }
1648 });
1649
1650 // ── Pagination ──────────────────────────────────────────────────────────────
1651
1652 test('release list shows at most 20 per page', async () => {
1653 // Bulk-create 25 releases with a distinct prefix to guarantee > 20 total
1654 // regardless of which browser-based tests above succeeded
1655 for (let i = 1; i <= 25; i++) {
1656 await adminCtx.request.post(`${BASE}/releases-repo/releases`, {
1657 multipart: { tag_name: `v9.${i}.0`, commit_hash: commitHash },
1658 maxRedirects: 0,
1659 }).catch(() => {});
1660 }
1661 const page = await adminCtx.newPage();
1662 try {
1663 await page.goto(`${BASE}/releases-repo/releases`);
1664 expect(await page.locator('.issue-item').count()).toBeLessThanOrEqual(20);
1665 } finally { await page.close(); }
1666 });
1667
1668 test('pagination nav appears with more than 20 releases', async () => {
1669 const page = await adminCtx.newPage();
1670 try {
1671 await page.goto(`${BASE}/releases-repo/releases`);
1672 expect(await page.locator('.pagination').isVisible()).toBe(true);
1673 } finally { await page.close(); }
1674 });
1675
1676 test('release list page 2 shows remaining releases', async () => {
1677 const page = await adminCtx.newPage();
1678 try {
1679 await page.goto(`${BASE}/releases-repo/releases?page=2`);
1680 const count = await page.locator('.issue-item').count();
1681 expect(count).toBeGreaterThan(0);
1682 expect(count).toBeLessThanOrEqual(20);
1683 } finally { await page.close(); }
1684 });
1685
1686 test('source archive tar.zst appears in downloads', async () => {
1687 const page = await adminCtx.newPage();
1688 try {
1689 await page.goto(srcReleaseUrl);
1690 const assetNames = await page.locator('.asset-name').allTextContents();
1691 expect(assetNames.some(n => n.endsWith('.tar.zst'))).toBe(true);
1692 } finally { await page.close(); }
1693 });
1694});
1695
1696// ─── Settings ─────────────────────────────────────────────────────────────────
1697
1698describe('settings', () => {
1699 let adminCtx: BrowserContext;
1700 let aliceCtx: BrowserContext;
1701 // Public key generated once in beforeAll, reused across SSH key tests
1702 let testPubKey: string;
1703
1704 beforeAll(async () => {
1705 adminCtx = await loggedInContext();
1706 aliceCtx = await loggedInContext('alice', 'password123');
1707
1708 // Generate a throwaway ed25519 key for SSH key tests.
1709 // Use Bun.spawn so the empty passphrase arg is passed correctly.
1710 const keyPath = '/tmp/hf-e2e-sshkey';
1711 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1712 const keygen = Bun.spawn(
1713 ['ssh-keygen', '-t', 'ed25519', '-f', keyPath, '-N', '', '-C', 'e2e@hearthforge'],
1714 { stdout: 'ignore', stderr: 'ignore' },
1715 );
1716 await keygen.exited;
1717 testPubKey = await Bun.file(`${keyPath}.pub`).text();
1718 testPubKey = testPubKey.trim();
1719 await $`rm -f ${keyPath} ${keyPath}.pub`.quiet().nothrow();
1720 });
1721
1722 afterAll(async () => {
1723 await adminCtx.close();
1724 await aliceCtx.close();
1725 });
1726
1727 test('settings page requires auth', async () => {
1728 const ctx = await browser.newContext();
1729 const page = await ctx.newPage();
1730 try {
1731 await page.goto(`${BASE}/settings`);
1732 expect(page.url()).toContain('/login');
1733 } finally { await ctx.close(); }
1734 });
1735
1736 test('settings page loads for logged-in user', async () => {
1737 const page = await adminCtx.newPage();
1738 try {
1739 await page.goto(`${BASE}/settings`);
1740 expect(await page.locator('h1.page-title').textContent()).toBe('Settings');
1741 } finally { await page.close(); }
1742 });
1743
1744 // ── Password ──────────────────────────────────────────────────────────────
1745
1746 test('password change with mismatched passwords shows error', async () => {
1747 const page = await aliceCtx.newPage();
1748 try {
1749 await page.goto(`${BASE}/settings`);
1750 await page.fill('[name=new_password]', 'newpass123');
1751 await page.fill('[name=confirm_password]', 'different456');
1752 await page.click('form[action="/settings/password"] button[type=submit]');
1753 await page.waitForURL(/\/settings/);
1754 expect(page.url()).toContain('error');
1755 } finally { await page.close(); }
1756 });
1757
1758 test('password change with wrong current password shows error', async () => {
1759 const page = await aliceCtx.newPage();
1760 try {
1761 await page.goto(`${BASE}/settings`);
1762 await page.fill('[name=current_password]', 'wrongpassword');
1763 await page.fill('[name=new_password]', 'newpass123');
1764 await page.fill('[name=confirm_password]', 'newpass123');
1765 await page.click('form[action="/settings/password"] button[type=submit]');
1766 await page.waitForURL(/\/settings/);
1767 expect(page.url()).toContain('error');
1768 } finally { await page.close(); }
1769 });
1770
1771 test('password change too short shows error', async () => {
1772 const page = await aliceCtx.newPage();
1773 try {
1774 await page.goto(`${BASE}/settings`);
1775 await page.fill('[name=current_password]', 'password123');
1776 await page.fill('[name=new_password]', 'short');
1777 await page.fill('[name=confirm_password]', 'short');
1778 await page.click('form[action="/settings/password"] button[type=submit]');
1779 await page.waitForURL(/\/settings/);
1780 expect(page.url()).toContain('error');
1781 } finally { await page.close(); }
1782 });
1783
1784 // ── SSH keys ──────────────────────────────────────────────────────────────
1785
1786 test('add SSH key with unsupported key type shows error', async () => {
1787 const page = await adminCtx.newPage();
1788 try {
1789 await page.goto(`${BASE}/settings`);
1790 await page.fill('#ssh_key_name', 'Bad key');
1791 await page.fill('#ssh_public_key', 'ssh-invalid AAAABBBBCCCC test@test');
1792 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1793 await page.waitForURL(/\/settings/);
1794 expect(page.url()).toContain('error');
1795 } finally { await page.close(); }
1796 });
1797
1798 test('add valid SSH key shows success and key appears in list', async () => {
1799 const page = await adminCtx.newPage();
1800 try {
1801 await page.goto(`${BASE}/settings`);
1802 await page.fill('#ssh_key_name', 'My Laptop');
1803 await page.fill('#ssh_public_key', testPubKey);
1804 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1805 await page.waitForURL(/\/settings/);
1806 expect(page.url()).toContain('success=ssh_key_added');
1807 await page.goto(`${BASE}/settings`);
1808 expect(await page.locator('.ssh-key-name').textContent()).toContain('My Laptop');
1809 } finally { await page.close(); }
1810 });
1811
1812 test('add duplicate SSH key shows error', async () => {
1813 const page = await adminCtx.newPage();
1814 try {
1815 await page.goto(`${BASE}/settings`);
1816 await page.fill('#ssh_key_name', 'Duplicate');
1817 await page.fill('#ssh_public_key', testPubKey);
1818 await page.click('form[action="/settings/ssh-keys"] button[type=submit]');
1819 await page.waitForURL(/\/settings/);
1820 expect(page.url()).toContain('error');
1821 } finally { await page.close(); }
1822 });
1823
1824 test('delete SSH key removes it from list', async () => {
1825 const page = await adminCtx.newPage();
1826 try {
1827 await page.goto(`${BASE}/settings`);
1828 // Click the Remove button for the key added above
1829 await page.click('form[action="/settings/ssh-keys/delete"] button');
1830 await page.waitForURL(/\/settings/);
1831 expect(page.url()).toContain('success=ssh_key_deleted');
1832 await page.goto(`${BASE}/settings`);
1833 expect(await page.locator('.ssh-key-name').count()).toBe(0);
1834 } finally { await page.close(); }
1835 });
1836
1837 // ── Admin user management ────────────────────────────────────────────────
1838
1839 test('admin can create a new user account', async () => {
1840 const page = await adminCtx.newPage();
1841 try {
1842 await page.goto(`${BASE}/settings`);
1843 await page.fill('#new_username', 'charlie');
1844 await page.fill('#new_user_password', 'charliepw1');
1845 await page.click('form[action="/admin/users"] button[type=submit]');
1846 await page.waitForURL(/\/settings/);
1847 expect(page.url()).toContain('success=user_created');
1848 } finally { await page.close(); }
1849 });
1850
1851 test('admin cannot create duplicate username', async () => {
1852 const page = await adminCtx.newPage();
1853 try {
1854 await page.goto(`${BASE}/settings`);
1855 await page.fill('#new_username', 'charlie');
1856 await page.fill('#new_user_password', 'charliepw1');
1857 await page.click('form[action="/admin/users"] button[type=submit]');
1858 await page.waitForURL(/\/settings/);
1859 expect(page.url()).toContain('error');
1860 } finally { await page.close(); }
1861 });
1862
1863 test('admin cannot create user with invalid username characters', async () => {
1864 const resp = await adminCtx.request.post(`${BASE}/admin/users`, {
1865 form: { username: 'bad user!', password: 'password123' },
1866 maxRedirects: 0,
1867 });
1868 expect(resp.status()).toBe(302);
1869 const location = resp.headers()['location'] ?? '';
1870 expect(location).toContain('error');
1871 });
1872
1873 test('non-admin gets 403 when creating user', async () => {
1874 const resp = await aliceCtx.request.post(`${BASE}/admin/users`, {
1875 form: { username: 'hacker', password: 'password123' },
1876 maxRedirects: 0,
1877 });
1878 expect(resp.status()).toBe(403);
1879 });
1880
1881 test('admin can delete user account', async () => {
1882 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1883 form: { username: 'charlie' },
1884 maxRedirects: 0,
1885 });
1886 expect(resp.status()).toBe(302);
1887 expect(resp.headers()['location']).toContain('success=user_deleted');
1888 });
1889
1890 test('admin cannot delete the admin account', async () => {
1891 const resp = await adminCtx.request.post(`${BASE}/admin/users/delete`, {
1892 form: { username: 'admin' },
1893 maxRedirects: 0,
1894 });
1895 expect(resp.status()).toBe(302);
1896 expect(resp.headers()['location']).toContain('error');
1897 });
1898
1899 test('git identity: empty name shows error', async () => {
1900 const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1901 form: { git_name: ' ', git_email: 'test@example.com' },
1902 maxRedirects: 0,
1903 });
1904 expect(resp.status()).toBe(302);
1905 expect(resp.headers()['location']).toContain('error=Git+name+is+required');
1906 });
1907
1908 test('git identity: empty email shows error', async () => {
1909 const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1910 form: { git_name: 'Test User', git_email: ' ' },
1911 maxRedirects: 0,
1912 });
1913 expect(resp.status()).toBe(302);
1914 expect(resp.headers()['location']).toContain('error=Git+email+is+required');
1915 });
1916
1917 test('git identity: valid values redirect with success', async () => {
1918 const resp = await aliceCtx.request.post(`${BASE}/settings/git-identity`, {
1919 form: { git_name: 'Alice Smith', git_email: 'alice@example.com' },
1920 maxRedirects: 0,
1921 });
1922 expect(resp.status()).toBe(302);
1923 expect(resp.headers()['location']).toContain('success=git_identity');
1924 });
1925});
1926
1927// ─── Repository deletion ──────────────────────────────────────────────────────
1928
1929describe('repository deletion', () => {
1930 let adminCtx: BrowserContext;
1931
1932 beforeAll(async () => {
1933 adminCtx = await loggedInContext();
1934
1935 // Create a repo to delete
1936 const page = await adminCtx.newPage();
1937 try {
1938 await page.goto(`${BASE}/new`);
1939 await page.fill('[name=name]', 'deleteme-repo');
1940 await page.click('form[action="/new"] button[type=submit]');
1941 await page.waitForURL(`${BASE}/deleteme-repo`);
1942 } finally { await page.close(); }
1943 });
1944
1945 afterAll(async () => { await adminCtx.close(); });
1946
1947 test('admin can delete repository', async () => {
1948 const resp = await adminCtx.request.post(`${BASE}/deleteme-repo/settings/delete`, {
1949 maxRedirects: 0,
1950 });
1951 expect(resp.status()).toBe(302);
1952 expect(resp.headers()['location']).toBe('/');
1953 });
1954
1955 test('deleted repository returns 404', async () => {
1956 const page = await adminCtx.newPage();
1957 try {
1958 const resp = await page.request.get(`${BASE}/deleteme-repo`);
1959 expect(resp.status()).toBe(404);
1960 } finally { await page.close(); }
1961 });
1962
1963 test('deleted repository no longer appears in list', async () => {
1964 const page = await adminCtx.newPage();
1965 try {
1966 await page.goto(BASE);
1967 expect(await page.locator('.repo-name').allTextContents()).not.toContain('deleteme-repo');
1968 } finally { await page.close(); }
1969 });
1970
1971 test('non-admin cannot delete repository', async () => {
1972 const aliceCtx = await loggedInContext('alice', 'password123');
1973 const page = await aliceCtx.newPage();
1974 try {
1975 const resp = await page.request.post(`${BASE}/my-repo/settings/delete`, {
1976 maxRedirects: 0,
1977 });
1978 expect(resp.status()).toBe(403);
1979 } finally {
1980 await page.close();
1981 await aliceCtx.close();
1982 }
1983 });
1984});
1985
1986// ─── 404 handling ─────────────────────────────────────────────────────────────
1987
1988describe('404 handling', () => {
1989 let adminCtx: BrowserContext;
1990
1991 beforeAll(async () => { adminCtx = await loggedInContext(); });
1992 afterAll(async () => { await adminCtx.close(); });
1993
1994 test('non-existent repository returns 404', async () => {
1995 const page = await adminCtx.newPage();
1996 try {
1997 const resp = await page.request.get(`${BASE}/no-such-repo`);
1998 expect(resp.status()).toBe(404);
1999 } finally { await page.close(); }
2000 });
2001
2002 test('non-existent issue returns 404', async () => {
2003 const page = await adminCtx.newPage();
2004 try {
2005 const resp = await page.request.get(`${BASE}/my-repo/issues/99999`);
2006 expect(resp.status()).toBe(404);
2007 } finally { await page.close(); }
2008 });
2009
2010 test('non-existent commit returns 404', async () => {
2011 const page = await adminCtx.newPage();
2012 try {
2013 const resp = await page.request.get(`${BASE}/my-repo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef`);
2014 expect(resp.status()).toBe(404);
2015 } finally { await page.close(); }
2016 });
2017
2018 test('non-existent file blob returns 404', async () => {
2019 const page = await adminCtx.newPage();
2020 try {
2021 const resp = await page.request.get(`${BASE}/my-repo/blob/main/no-such-file.txt`);
2022 expect(resp.status()).toBe(404);
2023 } finally { await page.close(); }
2024 });
2025
2026 test('non-existent patch returns 404', async () => {
2027 const page = await adminCtx.newPage();
2028 try {
2029 const resp = await page.request.get(`${BASE}/my-repo/patches/99999`);
2030 expect(resp.status()).toBe(404);
2031 } finally { await page.close(); }
2032 });
2033
2034 test('non-existent release returns 404', async () => {
2035 const page = await adminCtx.newPage();
2036 try {
2037 const resp = await page.request.get(`${BASE}/my-repo/releases/99999`);
2038 expect(resp.status()).toBe(404);
2039 } finally { await page.close(); }
2040 });
2041});
2042
2043// ─── Issue editing and deletion ───────────────────────────────────────────────
2044
2045describe('issue editing', () => {
2046 let adminCtx: BrowserContext;
2047 let aliceCtx: BrowserContext;
2048 let issueUrl: string;
2049
2050 beforeAll(async () => {
2051 adminCtx = await loggedInContext();
2052 aliceCtx = await loggedInContext('alice', 'password123');
2053
2054 // Create an issue to edit
2055 const page = await adminCtx.newPage();
2056 try {
2057 await page.goto(`${BASE}/my-repo/issues/new`);
2058 await page.fill('[name=title]', 'Issue to edit');
2059 await page.fill('[name=body]', 'Original body.');
2060 await page.click('form[action$="/issues"] button[type=submit]');
2061 await page.waitForURL(/\/my-repo\/issues\/\d+/);
2062 issueUrl = page.url();
2063 } finally { await page.close(); }
2064 });
2065
2066 afterAll(async () => {
2067 await adminCtx.close();
2068 await aliceCtx.close();
2069 });
2070
2071 test('author can edit issue title and body', async () => {
2072 const page = await adminCtx.newPage();
2073 try {
2074 await page.goto(issueUrl);
2075 // Edit title via title form
2076 await page.click('details.title-edit-details summary');
2077 await page.fill('.title-edit-form-area [name=title]', 'Edited issue title');
2078 await page.click('.title-edit-form-area [type=submit]');
2079 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2080 expect(await page.locator('.issue-detail-title').textContent()).toBe('Edited issue title');
2081 // Edit body via inline form
2082 await page.click('.timeline-author .inline-edit-details summary');
2083 await page.fill('.inline-edit-form-area [name=edit_body]', 'Updated body text.');
2084 await page.click('.inline-edit-form-area [type=submit]');
2085 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2086 } finally { await page.close(); }
2087 });
2088
2089 test('edited marker appears after editing', async () => {
2090 const page = await adminCtx.newPage();
2091 try {
2092 await page.goto(issueUrl);
2093 expect(await page.locator('time.edited-indicator').count()).toBeGreaterThan(0);
2094 } finally { await page.close(); }
2095 });
2096
2097 test('non-author non-admin cannot edit issue', async () => {
2098 const page = await aliceCtx.newPage();
2099 try {
2100 const issueNum = issueUrl.split('/issues/')[1];
2101 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/edit`, {
2102 form: { title: 'Hacked title', edit_body: '' },
2103 maxRedirects: 0,
2104 });
2105 expect(resp.status()).toBe(403);
2106 } finally { await page.close(); }
2107 });
2108
2109 test('author can edit issue comment', async () => {
2110 const page = await adminCtx.newPage();
2111 try {
2112 await page.goto(issueUrl);
2113 // Add a comment first
2114 await page.fill('textarea[name=body]', 'Comment to edit.');
2115 await page.click('form[action*="/comments"] button[type=submit]');
2116 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2117
2118 // Edit the comment
2119 const commentItem = page.locator('.timeline-item:not(.timeline-item-new)').filter({ hasText: 'Comment to edit.' });
2120 await commentItem.locator('.inline-edit-details summary').click();
2121 await commentItem.locator('.inline-edit-form-area [name=edit_body]').fill('Edited comment text.');
2122 await commentItem.locator('.inline-edit-form-area [type=submit]').click();
2123 await page.waitForURL(new RegExp(issueUrl.replace(BASE, '')));
2124 expect(await page.locator('.timeline-body').last().textContent()).toContain('Edited comment text.');
2125 } finally { await page.close(); }
2126 });
2127
2128 test('non-admin user can create an issue', async () => {
2129 const page = await aliceCtx.newPage();
2130 try {
2131 await page.goto(`${BASE}/my-repo/issues/new`);
2132 await page.fill('[name=title]', "Alice's issue");
2133 await page.click('form[action$="/issues"] button[type=submit]');
2134 await page.waitForURL(/\/my-repo\/issues\/\d+/);
2135 expect(await page.locator('.issue-detail-title').textContent()).toBe("Alice's issue");
2136 } finally { await page.close(); }
2137 });
2138
2139 test('non-admin cannot comment on a closed issue', async () => {
2140 // Close the issue as admin first
2141 const issueNum = issueUrl.split('/issues/')[1];
2142 await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/close`, { maxRedirects: 0 }).catch(() => {});
2143
2144 const page = await aliceCtx.newPage();
2145 try {
2146 const resp = await page.request.post(`${BASE}/my-repo/issues/${issueNum}/comments`, {
2147 form: { body: 'comment on closed issue' },
2148 maxRedirects: 0,
2149 });
2150 // Non-admin gets redirected (silently ignored), not an error
2151 expect(resp.status()).toBe(302);
2152 // The comment should NOT appear
2153 await page.goto(issueUrl);
2154 const bodies = await page.locator('.timeline-body').allTextContents();
2155 expect(bodies.every(b => !b.includes('comment on closed issue'))).toBe(true);
2156 } finally { await page.close(); }
2157 });
2158
2159 test('admin can delete issue', async () => {
2160 const issueNum = issueUrl.split('/issues/')[1];
2161 const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {
2162 maxRedirects: 0,
2163 });
2164 expect(resp.status()).toBe(302);
2165 // Issue should be gone
2166 const page = await adminCtx.newPage();
2167 try {
2168 const checkResp = await page.request.get(issueUrl);
2169 expect(checkResp.status()).toBe(404);
2170 } finally { await page.close(); }
2171 });
2172});
2173
2174// ─── Repository description update ───────────────────────────────────────────
2175
2176describe('repo description', () => {
2177 let adminCtx: BrowserContext;
2178
2179 beforeAll(async () => { adminCtx = await loggedInContext(); });
2180 afterAll(async () => { await adminCtx.close(); });
2181
2182 test('updating repo description is reflected on list page', async () => {
2183 const page = await adminCtx.newPage();
2184 try {
2185 await page.goto(`${BASE}/my-repo/settings`);
2186 await page.fill('[name=description]', 'A freshly updated description');
2187 await page.click('form[action$="/settings"] button[type=submit]');
2188 expect(await page.locator('.form-success').isVisible()).toBe(true);
2189
2190 await page.goto(BASE);
2191 const desc = await page.locator('.repo-description').allTextContents();
2192 expect(desc.some(d => d.includes('freshly updated description'))).toBe(true);
2193 } finally { await page.close(); }
2194 });
2195});
2196